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
chichuan 14c5bed4fc ci: split app-c race partition for runner headroom 2026-08-21 00:50:46 +08:00
赤川 c1bd6dcf64 Merge branch 'main' into codex/fix-stable-active-fragments 2026-08-21 00:06:27 +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
github-actions[bot] d4ff8a5f4f Merge pull request #1070 from DingTalk-Real-AI/codex/oa-ding-report-shortcuts
feat(shortcut): harden OA DING and Report workflows
2026-08-20 23:44:23 +08:00
Dennis 11a9ad5d49 fix(shortcut): align OA execution availability 2026-08-20 23:03:35 +08:00
Dennis 23940e4752 revert: keep coverage shard output compact 2026-08-20 22:24:17 +08:00
Dennis 8cb0f64477 fix(shortcut): reject backward OA cursors 2026-08-20 22:15:27 +08:00
Dennis bbaf033618 ci: stream app coverage progress 2026-08-20 22:14:35 +08:00
Dennis 2d38beb7be fix(shortcuts): separate compatibility visibility from availability 2026-08-20 21:51:21 +08:00
chichuan fe66ac18a4 feat(policy): govern flag requiredness changes 2026-08-20 21:19:55 +08:00
Dennis bda408d966 test(ding): cover compatibility reminder mappings 2026-08-20 21:10:45 +08:00
Dennis 33631502c9 fix(shortcuts): align unavailable writes and query validation 2026-08-20 21:02:52 +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 5c9f738012 fix(shortcuts): preserve published schema bindings 2026-08-20 20:20:41 +08:00
Dennis 56c5fb35b8 chore(policy): retire completed flag migrations 2026-08-20 20:20:27 +08:00
Dennis b19c52f61b fix(oa): make approval keyword normalization explicit 2026-08-20 20:20:25 +08:00
Dennis c0641dbf64 fix(shortcut): preserve OA and DING CLI compatibility 2026-08-20 20:20:23 +08:00
Dennis 3b5cb3b0cb test(shortcut): close OA DING Report coverage gaps 2026-08-20 20:20:21 +08:00
Dennis fd2ed2174f chore(release): add OA DING Report fragment 2026-08-20 20:20:19 +08:00
Dennis 5bbaa304e3 docs(shortcut): refresh OA DING Report live evidence 2026-08-20 20:20:17 +08:00
Dennis db938778af chore(shortcut): sync OA DING Report with current main 2026-08-20 20:20:15 +08:00
Dennis 1155b9b5c0 test(shortcut): align OA reviewed input fixtures 2026-08-20 20:20:12 +08:00
Dennis 8fa93ef030 fix(shortcut): retire OA discovery aliases after downgrade 2026-08-20 20:20:10 +08:00
Dennis f4ad1a15f5 docs(shortcut): record Report double-layer release proof 2026-08-20 20:20:08 +08:00
Dennis d2cbd928e2 docs(shortcut): record DING double-layer release proof 2026-08-20 20:20:06 +08:00
Dennis 2346dfba4e fix(shortcut): require OA zero-page pagination evidence 2026-08-20 20:20:03 +08:00
Dennis f6ad2fa01a fix(shortcut): publish Report range constraints 2026-08-20 20:19:44 +08:00
Dennis 7bc56f3dea feat(shortcut): unlock Report outbox workflows 2026-08-20 20:19:41 +08:00
Dennis 03001eb4e0 fix(shortcut): harden DING write routing evidence 2026-08-20 20:19:39 +08:00
Dennis 91974ce981 docs(shortcut): close OA residual audit gaps 2026-08-20 20:19:37 +08:00
Dennis c6417e3527 feat(shortcut): harden Report reads and availability 2026-08-20 20:19:35 +08:00
Dennis 161687ae4c fix(shortcut): deliver OA validation evidence 2026-08-20 20:19:32 +08:00
Dennis 4da5a07d1d feat(shortcut): harden DING reads and availability 2026-08-20 20:19:30 +08:00
Dennis 2cb83d388b fix(shortcut): publish OA validation constraints 2026-08-20 20:19:25 +08:00
Dennis ba9c0f624e feat(shortcut): harden OA workflows and availability 2026-08-20 20:19:19 +08:00
Dennis ff65f80c98 refactor(oa): add strict shortcut response helpers 2026-08-20 20:19:09 +08:00
github-actions[bot] 42240f5e9e Merge pull request #1079 from DingTalk-Real-AI/codex/fix-stable-migration-receipts
fix(ci): keep completed migration receipts inert
2026-08-20 20:18:04 +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
chichuan 11cbc30a10 fix(ci): keep completed migration receipts inert 2026-08-20 19:16:07 +08:00
chichuan 74859b966b fix(release): consume active fragments in stable seals 2026-08-20 17:07:53 +08:00
github-actions[bot] 62d72ad84c chore: update formula for v1.0.59 [skip ci] 2026-08-20 08:45:55 +00: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
赤川 c0838e7e41 Merge pull request #1072 from DingTalk-Real-AI/codex/changelog-v1.0.59-stable
chore: prepare v1.0.59 changelog
2026-08-20 16:06:54 +08:00
chichuan 9c6ab99bf1 chore: prepare v1.0.59 changelog 2026-08-20 16:01:09 +08:00
github-actions[bot] 87ab311764 chore: update beta formula for v1.0.59-beta.5 [skip ci] 2026-08-20 07:55:40 +00:00
赤川 15c075e6a6 Merge pull request #1068 from DingTalk-Real-AI/codex/changelog-v1.0.59-beta.5
chore: prepare v1.0.59-beta.5 changelog
2026-08-20 14:56:06 +08:00
chichuan f6d1e685e0 chore: prepare v1.0.59-beta.5 changelog 2026-08-20 14:52:30 +08:00
github-actions[bot] 81108e150b Merge pull request #1046 from xlb1130/feat/85614588-chat-personal-emotion
feat(chat): add personal emotion commands
2026-08-20 06:27:50 +00:00
xlb1130 dad9aefefa Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 14:08:05 +08:00
github-actions[bot] 71d49cb12b Merge pull request #1033 from pengzhihan47-star/codex/dingtalk-doc-skill-opt-v1
docs(skill): optimize dingtalk-doc workflows
2026-08-20 14:04:45 +08:00
柏智 f7e2efaaa2 ci: retrigger checks 2026-08-20 13:50:18 +08:00
pengzhihan47-star 557208e16b Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 13:31:31 +08:00
xlb1130 53401dbb0c Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 13:25:41 +08:00
github-actions[bot] 6c52ac37dd Merge pull request #1066 from DingTalk-Real-AI/codex/minutes-todo-wiki-param-aliases
feat(cli): expand Minutes TODO Wiki parameter aliases
2026-08-20 13:21:38 +08:00
xlb1130 95a17a3ffc Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 13:21:07 +08:00
pengzhihan47-star 3318741508 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 13:01:04 +08:00
克谨 3d7ab2690c feat(cli): expand Minutes TODO Wiki parameter aliases 2026-08-20 12:45:19 +08:00
github-actions[bot] 17eefcd24b Merge pull request #1064 from DingTalk-Real-AI/codex/fix-1060-schema-lineage
fix(policy): preserve historical Schema migration lineage
2026-08-20 04:29:42 +00:00
xlb1130 6f62ce7997 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 12:17:24 +08:00
赤川 2228a32d1a Merge branch 'main' into codex/fix-1060-schema-lineage 2026-08-20 12:11:55 +08:00
github-actions[bot] a6f79e951b Merge pull request #1050 from DingTalk-Real-AI/codex/fix-cli-eval-functional
fix: harden shortcut functional workflows
2026-08-20 04:08:39 +00:00
长真 096dfd48f0 docs(chat): keep emotion skill route within budget 2026-08-20 11:57:55 +08:00
chichuan 3922970bfc fix(policy): preserve schema migration lineage 2026-08-20 11:52:00 +08:00
xlb1130 2ab0edd5c6 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 11:45:41 +08:00
pengzhihan47-star 4b3272bcd4 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 11:42:41 +08:00
长真 80d5d24637 Revert "docs(chat): trim chat skill context budget"
This reverts commit c5951a10ff.
2026-08-20 11:39:50 +08:00
柏智 e40397e239 docs(skill): restore bounded doc guidance 2026-08-20 11:34:14 +08:00
xlb1130 15bc7fdc3f Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 11:27:40 +08:00
柏智 da049be58d docs(skill): require terminal evidence for doc writes 2026-08-20 11:21:58 +08:00
柏智 6ec64e8a03 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 11:09:24 +08:00
柏智 bca56cbba6 Merge remote-tracking branch 'origin/codex/dingtalk-doc-skill-opt-v1' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 11:09:19 +08:00
john bb48aa0cc8 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 10:37:44 +08:00
柏智 6ffb4bcb93 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 10:37:30 +08:00
pengzhihan47-star 08595594d7 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 10:15:26 +08:00
柏智 540bbac35b Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 09:57:23 +08:00
柏智 c4d5595ca9 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 02:09:49 +08:00
柏智 86d1eb8030 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 22:47:50 +08:00
柏智 ab529e5ee5 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 22:05:02 +08:00
xlb1130 15cb1f4311 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 22:01:44 +08:00
长真 97ca00868f test(chat): cover personal emotion user resolution 2026-08-19 21:51:49 +08:00
xlb1130 df8885c350 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 21:06:35 +08:00
柏智 4e27a3a84a Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 19:45:13 +08:00
xlb1130 8685464c53 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 19:28:52 +08:00
长真 26b5939f9f chore(ci): retrigger pr checks 2026-08-19 18:34:38 +08:00
柏智 95bcace6bd Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 18:10:22 +08:00
长真 ce57cdf260 chore(ci): retrigger pr checks 2026-08-19 16:26:32 +08:00
xlb1130 3ec138ba99 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 15:14:09 +08:00
柏智 f419c0f96d Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 14:37:01 +08:00
xlb1130 17101a8901 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 13:41:20 +08:00
柏智 66aa00fb50 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 12:23:42 +08:00
长真 9d6e151a6f Merge remote-tracking branch 'upstream/main' into feat/85614588-chat-personal-emotion 2026-08-19 10:54:30 +08:00
柏智 0df41d3eff Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 10:40:45 +08:00
柏智 2a1ed8cc7a Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 10:09:48 +08:00
长真 c5951a10ff docs(chat): trim chat skill context budget 2026-08-19 00:55:27 +08:00
长真 3dbd29ab50 feat(chat): add personal emotion commands 2026-08-18 23:59:15 +08:00
柏智 1b50c7a5b4 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 23:25:57 +08:00
柏智 cbd70d1b88 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 21:03:52 +08:00
柏智 0ae8949d40 fix(doc): align skill contracts with runtime 2026-08-18 20:53:02 +08:00
pengzhihan47-star 0975d970d1 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 20:36:25 +08:00
柏智 7808673431 fix(doc): align media receipt contract 2026-08-18 20:31:07 +08:00
pengzhihan47-star 50ed921ca1 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 19:28:45 +08:00
pengzhihan47-star b34c29ec35 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 18:41:09 +08:00
pengzhihan47-star 97e5ded043 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 13:49:34 +08:00
pengzhihan47-star 7ceeafbae8 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 12:27:08 +08:00
pengzhihan47-star 54b4a24a14 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 11:35:30 +08:00
pengzhihan47-star e1bfb343f4 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 10:13:27 +08:00
柏智 7da423bf3c docs(skill): clarify import and recent document routes 2026-08-18 09:37:47 +08:00
柏智 fa83ee579c docs(skill): remove lark-specific wording 2026-08-18 08:25:06 +08:00
pengzhihan47-star 25c694aa2a Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 07:46:06 +08:00
柏智 e064d394ba docs(skill): optimize dingtalk doc workflows 2026-08-18 01:02:37 +08:00
115 changed files with 10235 additions and 3821 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.
+3 -1
View File
@@ -25,7 +25,9 @@ category: Added
发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序,
将未归档 fragments 汇总为唯一的版本章节,并移动到
`.changes/released/<version>/`。因此 release-seal PR 是唯一会修改
`.changes/released/<version>/`。beta 发布后若有新 fragments 合入并直接准备 stable,
stable 封板会把它们追加到明确的 post-beta 小节,并归档到正式版本目录;没有新
fragments 时仍只生成原有 beta 晋级模板。因此 release-seal PR 是唯一会修改
`CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。
归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、
删除或重写已归档文件。
+5
View File
@@ -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.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Chat personal emotions** — adds `chat emotion list`, `chat emotion send`, and `chat emotion favorite` for current-user personal favorite emotion listing, sending, and favoriting.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products.
+5
View File
@@ -0,0 +1,5 @@
---
category: Changed
---
- **Stable release sealing** — directly preparing a stable release now renders and archives release fragments merged after its beta baseline, avoiding a forced extra beta solely to consume pending notes.
+12 -4
View File
@@ -534,7 +534,8 @@ jobs:
# where the Schema partition alone owned most of the wall clock. The
# app-<partition> names are pinned to the helper's partition set by
# TestCIAppRacePartitionMatrixMatchesHelper, so a partition can never lose
# its job silently.
# its job silently. The CrossPlatformCoverage-heavy C range is split again
# to retain headroom on runners reclaimed near the five-minute mark.
timeout-minutes: 20
strategy:
fail-fast: false
@@ -542,7 +543,10 @@ jobs:
shard:
- app-schema
- app-a-b
- app-c
- app-c-a-l
- app-c-m-r
- app-c-s-z
- app-c-other
- app-d-r
- app-s-z-example-fuzz
- generators
@@ -678,7 +682,8 @@ jobs:
# partitions run concurrently and each releases its framework registries
# when the process exits; cli/smoke need headroom beyond go test -timeout for
# setup + assembly. The app-<partition> names are pinned to the helper's
# partition set by TestCIAppRacePartitionMatrixMatchesHelper.
# partition set by TestCIAppRacePartitionMatrixMatchesHelper. The
# CrossPlatformCoverage-heavy C range is split again for runner headroom.
timeout-minutes: 20
strategy:
fail-fast: false
@@ -686,7 +691,10 @@ jobs:
shard:
- app-schema
- app-a-b
- app-c
- app-c-a-l
- app-c-m-r
- app-c-s-z
- app-c-other
- app-d-r
- app-s-z-example-fuzz
- generators
+4 -2
View File
@@ -2698,9 +2698,11 @@ jobs:
;;
compatibility)
test -n "$PREVIOUS_STABLE"
./scripts/policy/check-command-compatibility.sh \
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh" \
--repo-root "$GITHUB_WORKSPACE" \
--base-ref HEAD \
--stable-ref "$PREVIOUS_STABLE"
--stable-ref "$PREVIOUS_STABLE" \
--candidate-ref HEAD
;;
e2e)
bash scripts/dev/test-multi-profile-e2e.sh
+25
View File
@@ -6,6 +6,31 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.59] - 2026-08-20
This release promotes the sealed `v1.0.59-beta.5` contents to stable.
### Changed
- **Chat personal emotions** — adds commands to list, send, and favorite the current user's personal favorite emotions.
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage.
- **Shortcut functional workflows** — fixes Drive preview accuracy, AITable write verification and deletion accounting, Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
## [1.0.59-beta.5] - 2026-08-20
### Added
- **Chat personal emotions** — adds `chat emotion list`, `chat emotion send`, and `chat emotion favorite` for current-user personal favorite emotion listing, sending, and favoriting.
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products.
### Fixed
- **Shortcut functional workflows** (#1050) — fixes truthful Drive push/sync previews, strict AITable write verification and deletion accounting, lossless Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
## [1.0.59-beta.4] - 2026-08-20
### Added
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.59-beta.4"
version "1.0.59-beta.5"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-darwin-arm64.tar.gz"
sha256 "f788467e9979c70ef210b411ac915b1506ea77ffa496e26b53cfa99650158721"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-darwin-arm64.tar.gz"
sha256 "274d56599a8e33ccca86a139424cab95a54ba311d6b643bccb2d3e6608cd16b4"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-darwin-amd64.tar.gz"
sha256 "a01988709c0dc99dd5874859eb265ba08a6fda412a7ead8303c68e61d2a8b195"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-darwin-amd64.tar.gz"
sha256 "0a0a00a77ca24c102204cd6b7de3de58f406a7e0fad2dd965a4c1fe903c34f39"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-linux-arm64.tar.gz"
sha256 "8e1a993b2137a082a8cc1d9535dfc2d7b3e4399c76d295840f9dc1f15cca7a0d"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-linux-arm64.tar.gz"
sha256 "1e7af6393979c2fa433af9207722989749f11ea8e09ff9bcd5696e505d6d7f88"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-linux-amd64.tar.gz"
sha256 "26e4cd72cfb96b38ef808863391b81a5c45c3170bca56b5eac457fc601b000c5"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-linux-amd64.tar.gz"
sha256 "4896e71a1417acc3d8834fa99f0e81511e020ff952e57c0562cc255e90acec80"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-skills.zip"
sha256 "a75107bdc14b5476e097842acc92f798301d8ffb59de9ade01f863d166a89435"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-skills.zip"
sha256 "11000b9c3566e3b38e3037b6b3069d55c8f50725b3ed9cd67714a7ebb794cd47"
end
def install
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCli < Formula
desc "Automate DingTalk workspace tasks from the terminal"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.58"
version "1.0.59"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-arm64.tar.gz"
sha256 "7d98599f90cae9d42b51ff2863efc87dbfb4a3176ff3c84fc2216110c0157a70"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-darwin-arm64.tar.gz"
sha256 "61135a2a9286204ce060847e653c63c1e9784a0fa631bb7e0563b90628762a35"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-amd64.tar.gz"
sha256 "4c12e35e5bf7e0905812cd42dc94a5345068a2c16e306bb50b13c5c78b5cb95d"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-darwin-amd64.tar.gz"
sha256 "fd14b0b1a1475891fb243bf6453857a1044ab5a40bcf7dc1c7c795f57e5b03ba"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-arm64.tar.gz"
sha256 "5ef6bde24bc3db6a11a0f1d0b3343a048956b2cbcf6cd3409a037fb6ba425489"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-linux-arm64.tar.gz"
sha256 "5bfe9ac7d1798b028f0fad579bbdffec5898e2fb16ee36f5766ab58e208abd50"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-amd64.tar.gz"
sha256 "3ccadcc6f070a39d2b2ba20429a4fcdc2f21639bf79f34361dc7d16f501bfda6"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-linux-amd64.tar.gz"
sha256 "be1eb9a1f8fc5048e578b5b0bde212fc90baca0f289236c7c333d824bd869cf3"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-skills.zip"
sha256 "2626debc21c3daadfd155b4c167b2219b97e801398fe4441a8b48138960ab264"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-skills.zip"
sha256 "7ce5c3ab6f6a367407f64971bc5ff96cfcdfade2c1a10d326144b17c7b25a57e"
end
def install
+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">
+55 -8
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 消费。
## 跨命令迁移原语
@@ -68,6 +75,17 @@ optional bool legacy flag,不能隐藏仍由 Cobra hard-required 的参数。
`replacement_constant.value` 与 legacy `no_opt` 都必须是 `true`;negative flag、默认即
`true` 或固定 `false` 的语义不在本轮证明范围,必须另行设计,不能借本清单放行。
如果 `command_move` 的参数 `from` 在更早 stable 中仍使用另一历史名称,Schema adapter
只能把同一 legacy command 上、已经由 base-owned lifecycle 返回且
`state=consumed` 的 flag rename 回执作为前驱边。例如
`group → conversation-id` 与 `conversation-id → open-topic-id` 可以组合,但不能把
candidate 自增的 pending 记录、其他命令的同名参数、参数概念词典或 CLI alias 当作证据。
首次消费 pending command 回执时,merge-base 的 normalized Schema 必须真实发布中间参数,
并逐跳验证参数签名和 constraints;command 回执合入为 consumed 后,中间 Schema 已从 main
消失,此时保留的两份 consumed 回执可继续对 stable 做受限重放,直到 stable 也达到 after
并让回执转为惰性记录或由独立 PR 清理。两种阶段都拒绝残留 predecessor/intermediate、字段漂移、环、分叉、
target 碰撞或 primary path/tool identity 不唯一;positionals 不在该组合授权面内。
`replacement_constant` 不是清单自报即可成立的例外。after 阶段的 Interface Snapshot
必须从 replacement 命令的同一份框架运行时声明中捕获完全一致的 property/value,缺失、
值不符或额外常量都会使 lifecycle 落入 partial。对于 #1054,`dws chat topic create`
@@ -86,22 +104,22 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
两种迁移都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记的历史参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。
跨命令清单复用下文同一套 `pending → consumed → cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
跨命令清单复用下文同一套 `pending → consumed → inert/cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
当前首批 pending 记录覆盖 `chat topic` 收口:`chat group create --thread` 拆到 `chat topic create`,以及 `chat message list-topic-replies` / `forward-topic` 迁到对应的 `chat topic` 命令。前一条完整登记 `name` / `type` / `users` 的同名承接,以及 `thread` → `convThreadEnabled=true` 的常量承接。产品 PR 消费这些记录时只能把三条 `state` 改为 `consumed`,不得改写其 before、after、Schema mapping、constant 或 reason。
## 两阶段迁移与回执清理
每条迁移以 `(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`;继续保留过期回执会被门禁拒绝 |
| 4. 惰性保留或清理 | 当 merge-base 和 stable 都已经是 `after`,该记录不再提供任何授权;后续 PR 可以原样保留或删除 | 两份参考快照均精确匹配 `after`;保留时仍必须是不可改写的 `consumed`,接口偏离 `after` 继续失败 |
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。stable 发布只会让已经追平的 `consumed` 回执变成无授权效果的审计记录,不会在没有代码变更时让后续业务 PR 失去合规性;清理仍可作为独立的账本压缩动作,但不再是下一个 PR 的强制前置条件。
下面只是清单结构示例,不代表已审批命令;实际字段必须从 Interface Snapshot 核对:
@@ -146,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 会验证:
@@ -166,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`。
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
@@ -177,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 投影边界
@@ -204,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 相同的权威门禁:
+6 -3
View File
@@ -3,7 +3,7 @@
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
- **Products**: 13
- **Total commands**: 160
- **Total commands**: 163
- **Generated from**: `internal/plugin` command descriptors — the same code path the CLI uses at runtime.
> Auto-generated. Update plugin descriptors in `internal/plugin/`, not this file.
@@ -33,7 +33,7 @@ Every command inherits these flags (documented here once, not repeated per comma
- [`dws aitable` — AI Tables](#dws-aitable) · 41 commands
- [`dws attendance` — Attendance](#dws-attendance) · 4 commands
- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
- [`dws chat` — Group Chat / IM](#dws-chat) · 26 commands
- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 2 commands
- [`dws ding` — DING Messages](#dws-ding) · 2 commands
@@ -134,12 +134,15 @@ _Calendar events, participants, meeting rooms, and busy-status queries._
_Group chats, conversations, messages, and robot/webhook integrations._
**23 commands**
**26 commands**
| Command | Description | When to use |
|---|---|---|
| `dws chat bot search` | Search robots (bots) created by the current user by keyword. | When the agent needs to resolve one of its own bots by name to a robot code before sending bot messages. |
| `dws chat conversation-info` | Retrieve basic metadata for a conversation (single chat or group chat) by conversation ID. | When the agent needs context about a conversation (name, type, member count) before operating on it. |
| `dws chat emotion favorite` | Add a media ID to the current user's personal favorite emotions. | When the agent needs to save an available mediaId as a reusable personal emotion, optionally preserving source message context. |
| `dws chat emotion list` | List the current user's personal favorite emotions. | When the agent needs to inspect available personal emotions or resolve an emotionId/mediaId before sending. |
| `dws chat emotion send` | Send a personal favorite emotion to a group or direct chat as the authenticated user. | When the agent needs to send a known personal emotion mediaId to exactly one group, userId, or openDingTalkId target. |
| `dws chat group create` | Create a new internal group chat with a set of initial members. | When the agent needs to spin up a dedicated group for a new project, incident, or discussion thread. |
| `dws chat group members` | List members of a group chat; can also be used against the current user to enumerate their groups' members. | When the agent needs the roster of a group before mentioning, removing, or auditing members. |
| `dws chat group members add` | Add one or more users to an existing group chat. | When the agent expands a group to include additional participants. |
+10 -5
View File
@@ -23,7 +23,7 @@
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、命令兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、CLI 与 Schema 兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
OSS 镜像默认不参与发布 DAG,适用于尚未创建 Bucket 的仓库。云端封板会把当时的仓库变量 `ENABLE_OSS_MIRROR=true` 记录为不可变 tag 元数据 `OSS-Mirror: enabled`,否则记录为 `deferred`;后续发布和撤回只读取该 sealed policy,不读取变量的当前值。`enabled` 继续对缺失凭据、无效 Bucket、上传、pointer 和撤回失败保持 fail-closed;`deferred` 明确跳过不存在的渠道。为避免补发后撤回遗漏,deferred 版本暂不接受 `repair_oss_version`,启用 OSS 只影响后续新 tag,直到补齐可审计的不可变 repair 证明。
@@ -102,7 +102,7 @@ fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR
dws-release v1.2.3-beta.1
```
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
预检包含测试、策略检查、旧正式版 CLI 与 Schema 双基线兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
## 正式发布
@@ -140,14 +140,19 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1
`.changes/<unique-name>.md` 中增加一个独立 fragment;格式和允许的分类见
[`.changes/README.md`](../.changes/README.md)。预发封板时
`scripts/release/prepare-changelog.sh prerelease <version>` 会稳定排序并汇总所有未归档
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。因此并发 PR 不会争用
`CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与归档移动,供审计复核。
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。如果 beta 发布后又有
带 fragment 的 PR 合入,而维护者决定直接发布 stable,
`scripts/release/prepare-changelog.sh stable <version> --from-beta <tag>` 会保留 beta 晋级摘要
模板,并把这些 post-beta fragments 写到明确的 `Changes since <beta>` 边界之后,再移动到
`.changes/released/<stable-version>/`。没有 active fragment 时,stable 仍只生成原有晋级摘要
模板。因此并发 PR 不会争用 `CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与
归档移动,供审计复核。
## CI/CD 保证
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
- tag 必须由云端 seal job 创建为 annotated tag;封板提交必须已通过 PR 合入并包含在远端 `main` 历史中。流水线允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整 CLI 与 Schema 契约;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
+42 -73
View File
@@ -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",
@@ -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 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;current HEAD exact Shortcut 与 owning atomic/raw 的请求身份、1 项结果和完整接收行集合一致。",
"availability": "available"
},
{
"suite": "semantic",
@@ -3602,67 +3584,54 @@
"availability": "available"
},
{
"suite": "read",
"service": "oa",
"command": "+list-cc",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "oa",
"command": "+list-executed",
"risk": "read",
"status": "real-ok"
},
{
"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",
"service": "oa",
"command": "+list-submitted",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "oa",
"command": "+my-initiated",
"risk": "read",
"status": "real-ok"
},
{
"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",
"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;current HEAD exact 与 owning atomic 同场景已知页均为 20 项、稳定身份集合和 next cursor 一致,独立未来范围均为 0 且明确终止。终止页回显 cursor 只作已验证收据且不发布 next_token。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "report",
"command": "+outbox-list",
"risk": "read",
"status": "real-ok"
"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"
},
{
"suite": "semantic",
"service": "report",
"command": "+template-search",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;current HEAD exact 与 owning atomic 完整集合过滤的已知结果均为 1 且身份一致,随机 UUID 查询均为 0。",
"availability": "available"
},
{
"suite": "read",
@@ -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 approval list-submitted", "oa approval list-initiated"}},
}
for _, test := range tests {
t.Run(test.path, func(t *testing.T) {
+35
View File
@@ -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)
}
}
}
+28
View File
@@ -68,6 +68,34 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
return `{"success":true,"result":[]}`
case "list_suggested_event_times":
return `{"success":true,"result":{"recommendEventTimes":[]}}`
case "list_by_keyword_and_time_range":
return `{"success":true,"result":{"itemList":[{"taskUuid":"u1","startTime":1}]}}`
case "get_minutes_basic_info":
return `{"success":true,"result":{"taskUuid":"u1","title":"Fixture Minutes"}}`
case "get_minutes_transcription":
return `{"success":true,"result":{"paragraphList":[],"hasNext":false}}`
case "create_personal_todo":
return `{"success":true,"result":{"taskId":"task-1"}}`
case "get_todo_detail":
return `{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"Fixture Todo","isDone":false}}}`
case "get_user_todos_in_current_org":
return `{"success":true,"result":{"todoCards":[],"hasMore":false}}`
case "add_todo_reminder":
return `{"success":true}`
case "copy_document":
return `{"success":true,"nodeId":"copy-1"}`
case "move_document", "add_member", "update_member", "remove_member":
return `{"success":true}`
case "get_document_info":
if len(c.calls) > 1 {
switch c.calls[len(c.calls)-2].tool {
case "copy_document":
return `{"success":true,"nodeId":"copy-1","workspaceId":"workspace-1","folderId":"folder-1"}`
case "move_document":
return `{"success":true,"nodeId":"node-1","workspaceId":"drive-1","folderId":"folder-1"}`
}
}
return `{"success":true,"nodeId":"node-1","workspaceId":"source-1","folderId":"source-folder"}`
case "create_calendar_event":
return `{"success":true,"result":{"eventId":"event-1"}}`
case "update_calendar_event", "delete_calendar_event", "add_calendar_participant", "remove_calendar_participant":
@@ -226,12 +226,72 @@ 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"},
}
// paramAliasCandidateCompleteCommands contains complete invocations for the
// reviewed Minutes/TODO/Wiki joint draft. Keeping candidate-only commands in a
// separate map lets this test file land before the draft replaces the formal
// param_concepts.json: inactive candidate templates are ignored, while every
// command becomes mandatory as soon as one of its reviewed aliases is active.
var paramAliasCandidateCompleteCommands = map[string][]string{
"minutes +detail": {"minutes", "+detail", "--ids", "u1,u2"},
"minutes +latest": {"minutes", "+latest", "--keyword", "fixture"},
"minutes +list-all": {"minutes", "+list-all", "--limit", "7"},
"minutes +record-pause": {"minutes", "+record-pause", "--id", "u1", "--yes"},
"minutes +replace-batch": {"minutes", "+replace-batch", "--id", "u1", "--pair", "old=>new", "--yes"},
"minutes +search": {"minutes", "+search", "--query", "fixture", "--cursor", "cursor-1"},
"minutes +share": {"minutes", "+share", "--ids", "u1,u2", "--member-uids", "user-1,user-2", "--permission", "view", "--yes"},
"minutes +speaker-replace": {"minutes", "+speaker-replace", "--id", "u1", "--from", "old", "--to", "new", "--target-uid", "user-1", "--yes"},
"minutes +summary": {"minutes", "+summary", "--id", "u1", "--content", "fixture", "--yes"},
"minutes +transcript": {"minutes", "+transcript", "--keyword", "fixture"},
"minutes +upload-and-analyze": {"minutes", "+upload-and-analyze", "--resume-id", "u1", "--yes"},
"minutes audio-memo list": {"minutes", "audio-memo", "list", "--max", "7"},
"minutes get batch": {"minutes", "get", "batch", "--ids", "u1,u2"},
"minutes hot-word add": {"minutes", "hot-word", "add", "--words", "DWS,Minutes"},
"minutes list all": {"minutes", "list", "all", "--end", "2026-03-10T23:59:59+08:00"},
"minutes list mine": {"minutes", "list", "mine", "--start", "2026-03-10T00:00:00+08:00"},
"minutes replace-text": {"minutes", "replace-text", "--id", "u1", "--search", "old", "--replace", "new"},
"minutes tag query": {"minutes", "tag", "query", "--tag-id", "tag-1"},
"minutes update title": {"minutes", "update", "title", "--id", "u1", "--title", "Fixture Minutes"},
"minutes upload complete": {"minutes", "upload", "complete", "--session-id", "session-1"},
"todo +assign": {"todo", "+assign", "--task", "Fixture Todo", "--to", "Fixture User", "--yes"},
"todo +assign-multi": {"todo", "+assign-multi", "--task", "Fixture Todo", "--to", "Fixture User,User Two", "--yes"},
"todo +comment": {"todo", "+comment", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
"todo +complete": {"todo", "+complete", "--task-id", "task-1", "--yes"},
"todo +create": {"todo", "+create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
"todo +due-today": {"todo", "+due-today", "--role-types", "executor"},
"todo +get-my-tasks": {"todo", "+get-my-tasks", "--role-types", "executor", "--priority", "40", "--page", "2", "--size", "7"},
"todo +get-related-tasks": {"todo", "+get-related-tasks", "--role-types", "creator,executor", "--status", "false"},
"todo +list-comment": {"todo", "+list-comment", "--task-id", "task-1", "--page", "2"},
"todo +remind": {"todo", "+remind", "--task", "Fixture Todo", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
"todo +reminder": {"todo", "+reminder", "--task-id", "task-1", "--base-time", "customTime", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
"todo +reopen": {"todo", "+reopen", "--task-id", "task-1", "--yes"},
"todo +search": {"todo", "+search", "--query", "fixture", "--status", "false"},
"todo +todo-done": {"todo", "+todo-done", "--task", "Fixture Todo", "--yes"},
"todo +update": {"todo", "+update", "--task-id", "task-1", "--title", "Fixture Updated Todo", "--yes"},
"todo comment add": {"todo", "comment", "add", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
"todo comment list": {"todo", "comment", "list", "--task-id", "task-1", "--page", "2", "--size", "7"},
"todo task add-executor": {"todo", "task", "add-executor", "--task-id", "task-1", "--executors", "user-1,user-2", "--yes"},
"todo task add-participant": {"todo", "task", "add-participant", "--task-id", "task-1", "--participants", "user-1,user-2", "--yes"},
"todo task add-reminder": {"todo", "task", "add-reminder", "--task-id", "task-1", "--base-time", "customTime", "--reminder-time-stamp", "2026-03-10T18:00:00+08:00", "--yes"},
"todo task create": {"todo", "task", "create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
"todo task create-sub": {"todo", "task", "create-sub", "--parent-id", "task-parent", "--title", "Fixture Sub Todo", "--executors", "user-1", "--yes"},
"todo task done": {"todo", "task", "done", "--task-id", "task-1", "--status", "true", "--yes"},
"todo task get": {"todo", "task", "get", "--task-id", "task-1"},
"todo task list": {"todo", "task", "list", "--role-types", "executor", "--page", "2", "--size", "7"},
"todo task update": {"todo", "task", "update", "--task-id", "task-1", "--done", "true", "--yes"},
"wiki +member-add": {"wiki", "+member-add", "--workspace", "workspace-1", "--user", "user-1", "--role", "READER", "--yes"},
"wiki +member-remove": {"wiki", "+member-remove", "--workspace", "workspace-1", "--user", "user-1", "--yes"},
"wiki +member-update": {"wiki", "+member-update", "--workspace", "workspace-1", "--user", "user-1", "--role", "EDITOR", "--yes"},
"wiki +move": {"wiki", "+move", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
"wiki +move-to-drive": {"wiki", "+move-to-drive", "--node", "node-1", "--folder", "folder-1", "--yes"},
"wiki +node-copy": {"wiki", "+node-copy", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
"wiki +node-delete": {"wiki", "+node-delete", "--workspace", "workspace-1", "--node", "node-1", "--yes"},
}
// A command can expose more than one mutually exclusive canonical route. In
// that case the shared command template above cannot contain every canonical
// flag at once, so select a fixture-specific complete invocation here.
@@ -531,6 +591,20 @@ var paramAliasNewConfirmationCases = []struct {
{command: "drive +version-revert", emitted: "version-number", canonical: "version"},
}
// Candidate confirmation cases become active with the joint draft. One write
// workflow per product plus TODO's reminder workflow proves semantic aliasing
// cannot move execution across the shared --yes barrier.
var paramAliasCandidateConfirmationCases = []struct {
command string
emitted string
canonical string
}{
{command: "minutes +record-pause", emitted: "uuid", canonical: "id"},
{command: "todo +create", emitted: "deadline", canonical: "due"},
{command: "todo +reminder", emitted: "reminder-time-stamp", canonical: "at"},
{command: "wiki +node-copy", emitted: "node-id", canonical: "node"},
}
// paramAliasRepresentativePayloadCases keeps final transport coverage across
// old concept aliases, command overrides, native compatibility flags, read and
// write commands, and different products. Every reviewed alias is still
@@ -600,6 +674,30 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
}
// Candidate representatives exercise the final transport boundary for each
// Minutes/TODO/Wiki alias family. They are required only when the exact fixture
// exists in the loaded reviewed table, so the tests are mergeable before the
// joint draft is promoted to internal/cli/param_concepts.json.
var paramAliasCandidateRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("minutes +latest", "query"): true,
paramAliasPayloadCaseKey("minutes +transcript", "query"): true,
paramAliasPayloadCaseKey("minutes get batch", "uuids"): true,
paramAliasPayloadCaseKey("minutes update title", "task-uuid"): true,
paramAliasPayloadCaseKey("minutes upload complete", "upload-id"): true,
paramAliasPayloadCaseKey("todo +create", "deadline"): true,
paramAliasPayloadCaseKey("todo +get-my-tasks", "current-page"): true,
paramAliasPayloadCaseKey("todo +reminder", "reminder-time-stamp"): true,
paramAliasPayloadCaseKey("todo comment add", "text"): true,
paramAliasPayloadCaseKey("todo task add-executor", "executor-ids"): true,
paramAliasPayloadCaseKey("todo task get", "todo-id"): true,
paramAliasPayloadCaseKey("todo task update", "status"): true,
paramAliasPayloadCaseKey("wiki +member-add", "user-id"): true,
paramAliasPayloadCaseKey("wiki +member-remove", "uid"): true,
paramAliasPayloadCaseKey("wiki +member-update", "user-id"): true,
paramAliasPayloadCaseKey("wiki +move-to-drive", "node-id"): true,
paramAliasPayloadCaseKey("wiki +node-copy", "node-id"): true,
}
// paramAliasCalendarPayloadCases keeps the full reviewed Calendar expansion
// separate from the long-lived app-c race process. Each case still executes
// both canonical and alias argv through the real PreParse/Cobra path and
@@ -709,6 +807,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
}
activeCommands := make(map[string]bool)
activeFixtureCases := make(map[string]bool)
activeCases := 0
executedRepresentatives := make(map[string]bool)
for _, fixture := range concepts.Fixture {
@@ -717,6 +816,8 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
}
activeCommands[fixture.Command] = true
activeCases++
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
activeFixtureCases[caseKey] = true
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
if !ok {
t.Errorf("reviewed active fixture %q/%q has no complete-command E2E template", fixture.Command, fixture.Emitted)
@@ -729,8 +830,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
continue
}
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
if !paramAliasRepresentativePayloadCases[caseKey] {
if !paramAliasRepresentativePayloadCases[caseKey] && !paramAliasCandidateRepresentativePayloadCases[caseKey] {
continue
}
executedRepresentatives[caseKey] = true
@@ -742,26 +842,43 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
if activeCases == 0 {
t.Fatal("reviewed fixture contains no active alias cases")
}
templateCommands := make(map[string]bool, len(paramAliasCompleteCommands)+len(paramAliasCandidateCompleteCommands))
for command := range paramAliasCompleteCommands {
if !activeCommands[command] {
t.Errorf("complete-command E2E template %q has no active reviewed fixture", command)
}
templateCommands[command] = true
}
for command := range paramAliasCandidateCompleteCommands {
if activeCommands[command] {
templateCommands[command] = true
}
}
for command := range activeCommands {
if _, ok := paramAliasCompleteCommands[command]; !ok {
if !templateCommands[command] {
t.Errorf("active reviewed command %q has no complete-command E2E template", command)
}
}
if len(activeCommands) != len(paramAliasCompleteCommands) {
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(paramAliasCompleteCommands), len(activeCommands), activeCases)
if len(activeCommands) != len(templateCommands) {
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(templateCommands), len(activeCommands), activeCases)
}
for caseKey := range paramAliasRepresentativePayloadCases {
if !executedRepresentatives[caseKey] {
t.Errorf("representative final-payload case %q has no active reviewed fixture", caseKey)
}
}
if len(executedRepresentatives) != len(paramAliasRepresentativePayloadCases) {
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), len(paramAliasRepresentativePayloadCases))
activeRepresentatives := len(paramAliasRepresentativePayloadCases)
for caseKey := range paramAliasCandidateRepresentativePayloadCases {
if !activeFixtureCases[caseKey] {
continue
}
activeRepresentatives++
if !executedRepresentatives[caseKey] {
t.Errorf("candidate representative final-payload case %q was not executed", caseKey)
}
}
if len(executedRepresentatives) != activeRepresentatives {
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), activeRepresentatives)
}
}
@@ -1091,7 +1208,20 @@ func TestCrossPlatformCoverageNewAITableDeleteDisableAliasesPreserveConfirmation
}
func TestCrossPlatformCoverageNewParamAliasesCannotBypassConfirmation(t *testing.T) {
for _, test := range paramAliasNewConfirmationCases {
tests := append([]struct {
command string
emitted string
canonical string
}{}, paramAliasNewConfirmationCases...)
for _, candidate := range paramAliasCandidateConfirmationCases {
entry, exists := cli.LookupParamAlias(candidate.command)
target, active := entry.ResolveAlias(candidate.emitted)
if exists && active && target == candidate.canonical {
tests = append(tests, candidate)
}
}
for _, test := range tests {
test := test
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
@@ -1173,6 +1303,10 @@ func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
return variant, true
}
}
if ok {
return complete, true
}
complete, ok = paramAliasCandidateCompleteCommands[command]
return complete, ok
}
+3
View File
@@ -72,6 +72,9 @@ func missingChatCatalogCoveragePaths() []string {
"chat clear-messages",
"chat clear-red-point",
"chat data-auth cross-org",
"chat emotion favorite",
"chat emotion list",
"chat emotion send",
"chat group audit-join-validation",
"chat group list-all",
"chat group list-join-validations",
+56 -5
View File
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 422
publicShortcutCount = 415
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including the hidden historical minutes.shortcut_minutes_search contract.
schemaPublishedShortcutCount = 447
// including reviewed hidden compatibility and unavailable contracts.
schemaPublishedShortcutCount = 453
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 422
publiclyDeliveredShortcutCount = 415
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
product := executeShortcutSchemaQuery(t, "chat")
productPayload, _ := product["product"].(map[string]any)
if got, want := int(product["count"].(float64)), 217; got != want {
if got, want := int(product["count"].(float64)), 220; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
@@ -140,6 +140,57 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
assertChatCatalogCompleteLeafContracts(t)
}
func TestChatPersonalEmotionSchemaDeclaresUnpinnedIMAdapter(t *testing.T) {
for _, tc := range []struct {
cliPath string
params map[string]string
}{
{
cliPath: "chat emotion list",
},
{
cliPath: "chat emotion send",
params: map[string]string{
"media-id": "mediaId",
"emotion-id": "emotionId",
"group": "openConversationId",
"open-dingtalk-id": "receiverOpenDingTalkId",
"idempotency-key": "uuid",
},
},
{
cliPath: "chat emotion favorite",
params: map[string]string{
"media-id": "mediaId",
"name": "name",
"source-conversation-id": "sourceConversationId",
"source-message-id": "sourceMessageId",
},
},
} {
t.Run(tc.cliPath, func(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", tc.cliPath)
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
t.Fatalf("%s interface_mode = %q, want composite", tc.cliPath, got)
}
reason := schemaContractString(leaf["interface_reason"])
if !strings.Contains(reason, "Reviewed unpinned remote adapter") {
t.Fatalf("%s interface_reason = %q", tc.cliPath, reason)
}
parameters := schemaContractMap(leaf["parameters"])
for name, want := range tc.params {
parameter := parameters[name]
if parameter == nil {
t.Fatalf("%s missing --%s parameter: %#v", tc.cliPath, name, parameters)
}
if got := schemaContractString(parameter["property"]); got != want {
t.Fatalf("%s --%s property = %q, want %q", tc.cliPath, name, got, want)
}
}
})
}
}
func TestCrossPlatformCoverageAITableTableBootstrapPublishesResultContract(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", "aitable +table-bootstrap")
result, _ := leaf["result"].(map[string]any)
+3 -3
View File
@@ -191,12 +191,12 @@
"from": "oa +list-processes",
"mode": "ambiguous",
"candidates": [
"oa +list-forms",
"oa +my-initiated",
"oa +search-forms",
"oa approval list-submitted",
"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 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",
@@ -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 approval list-submitted", "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 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.",
},
}
+1 -1
View File
@@ -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 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"},
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+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)
}
}
}
+1 -1
View File
@@ -10569,7 +10569,7 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
chatCategoryCmd.AddCommand(chatCategoryCreateSmartCmd)
chatMessageCmd.AddCommand(chatMessageListDirectCmd, chatMessageSearchCommonCmd, chatMessageCombineForwardCmd, chatMessageForwardTopicCmd, chatMessageSetPinCmd, chatMessageUnsetPinCmd, chatMessageListPinCmd, chatMessageAddFavoriteCmd, chatMessageRemoveFavoriteCmd, chatMessageListFavoritesCmd, chatMessageSetTopMsgCmd, chatMessageUnsetTopMsgCmd, chatMessageListEmotionRepliesCmd)
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd, newChatToolbarCommand())
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd, newChatToolbarCommand(), newChatEmotionCommand())
// Keep the v1.0.56 command surface recognizable while directing callers to
// the supported nested commands. The chat root's "im" alias makes these
+253
View File
@@ -0,0 +1,253 @@
package helpers
import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/spf13/cobra"
)
const personalEmotionUnpinnedReason = "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command."
func newChatEmotionCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "emotion",
Short: "个人收藏表情",
Long: "查询、发送和新增当前用户的个人收藏表情。",
RunE: groupRunE,
}
cmd.AddCommand(
newChatEmotionListCommand(),
newChatEmotionSendCommand(),
newChatEmotionFavoriteCommand(),
)
return cmd
}
func newChatEmotionListCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "列出个人收藏表情",
RunE: func(cmd *cobra.Command, args []string) error {
return callMCPToolOnServer("im", "list_personal_emotions", map[string]any{})
},
}
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat", Name: "list_personal_emotions",
CanonicalPath: "chat.list_personal_emotions", CLIPath: "chat emotion list", PrimaryCLIPath: "chat emotion list",
},
Description: "列出当前用户的个人收藏表情",
Interface: &contract.InterfaceSpec{
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
},
Selection: contract.SelectionSpec{
AgentSummary: "列出当前用户的个人收藏表情",
UseWhen: []string{"需要查看当前用户已收藏的表情、获取 emotionId 或 mediaId 时"},
AvoidWhen: []string{"查询消息 reaction 使用 chat message list-emotion-replies"},
Examples: []string{"dws chat emotion list --format json"},
},
},
})
return cmd
}
func newChatEmotionSendCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "send",
Short: "发送个人收藏表情",
Long: `发送当前用户的个人收藏表情。
⚠️ 重要:该接口会真实发送表情到目标会话,不可用于测试或试探性调用。调用前必须确认表情媒体 ID 和接收对象无误。`,
Example: ` dws chat emotion send --media-id <mediaId> --group <openConversationId>
dws chat emotion send --media-id <mediaId> --emotion-id <emotionId> --user <userId>
dws chat emotion send --media-id <mediaId> --open-dingtalk-id <openDingTalkId> --uuid <idempotencyKey>`,
RunE: func(cmd *cobra.Command, args []string) error {
mediaID, _ := cmd.Flags().GetString("media-id")
if strings.TrimSpace(mediaID) == "" {
return fmt.Errorf("--media-id is required")
}
target, err := personalEmotionSendTarget(cmd)
if err != nil {
return err
}
payload := map[string]any{"mediaId": strings.TrimSpace(mediaID)}
emotionID, _ := cmd.Flags().GetString("emotion-id")
if strings.TrimSpace(emotionID) != "" {
payload["emotionId"] = strings.TrimSpace(emotionID)
}
for key, value := range target {
payload[key] = value
}
if uuid := strings.TrimSpace(flagOrFallback(cmd, "uuid", "idempotency-key")); uuid != "" {
payload["uuid"] = uuid
}
return callMCPToolOnServer("im", "send_personal_emotion", payload)
},
}
cmd.Flags().String("media-id", "", "表情媒体 ID (必填)")
cmd.Flags().String("emotion-id", "", "表情 ID")
cmd.Flags().String("conversation-id", "", "群聊 openConversationId")
cmd.Flags().String("group", "", "群聊 openConversationId(--conversation-id 别名)")
cmd.Flags().String("user", "", "单聊接收人 userId;CLI 会解析为 openDingTalkId")
cmd.Flags().String("open-dingtalk-id", "", "单聊接收人 openDingTalkId")
cmd.Flags().String("uuid", "", "幂等键")
cmd.Flags().String("idempotency-key", "", "幂等键(--uuid 别名)")
cmd.MarkFlagsMutuallyExclusive("conversation-id", "group")
cli.AnnotateRuntimeConstraints(cmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"conversation-id", "group", "user", "open-dingtalk-id"}},
RequireOneOf: [][]string{{"conversation-id", "group", "user", "open-dingtalk-id"}},
})
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
},
Contract: personalEmotionSendContract(),
})
return cmd
}
func newChatEmotionFavoriteCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "favorite",
Short: "新增个人收藏表情",
Example: ` dws chat emotion favorite --media-id <mediaId> --name "赞"
dws chat emotion favorite --media-id <mediaId> --source-conversation-id <cid> --source-message-id <mid>`,
RunE: func(cmd *cobra.Command, args []string) error {
mediaID, _ := cmd.Flags().GetString("media-id")
if strings.TrimSpace(mediaID) == "" {
return fmt.Errorf("--media-id is required")
}
sourceConversationID, _ := cmd.Flags().GetString("source-conversation-id")
sourceMessageID, _ := cmd.Flags().GetString("source-message-id")
if err := validatePersonalEmotionSourcePair(sourceConversationID, sourceMessageID); err != nil {
return err
}
payload := map[string]any{"mediaId": strings.TrimSpace(mediaID)}
name, _ := cmd.Flags().GetString("name")
if strings.TrimSpace(name) != "" {
payload["name"] = strings.TrimSpace(name)
}
if strings.TrimSpace(sourceConversationID) != "" {
payload["sourceConversationId"] = strings.TrimSpace(sourceConversationID)
payload["sourceMessageId"] = strings.TrimSpace(sourceMessageID)
}
return callMCPToolOnServer("im", "favorite_personal_emotion", payload)
},
}
cmd.Flags().String("media-id", "", "待收藏 mediaId (必填)")
cmd.Flags().String("name", "", "表情名称")
cmd.Flags().String("source-conversation-id", "", "来源会话 ID;需与 --source-message-id 成对指定")
cmd.Flags().String("source-message-id", "", "来源消息 ID;需与 --source-conversation-id 成对指定")
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
},
Contract: personalEmotionFavoriteContract(),
})
return cmd
}
func personalEmotionSendTarget(cmd *cobra.Command) (map[string]any, error) {
groupID := strings.TrimSpace(flagOrFallback(cmd, "conversation-id", "group"))
userID, _ := cmd.Flags().GetString("user")
openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id")
userID = strings.TrimSpace(userID)
openDingTalkID = strings.TrimSpace(openDingTalkID)
specified := 0
for _, value := range []string{groupID, userID, openDingTalkID} {
if value != "" {
specified++
}
}
if specified != 1 {
return nil, fmt.Errorf("--conversation-id, --user or --open-dingtalk-id is required; specify exactly one")
}
if groupID != "" {
return map[string]any{"openConversationId": groupID}, nil
}
if openDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
return nil, err
}
return map[string]any{"receiverOpenDingTalkId": openDingTalkID}, nil
}
if isOpenDingTalkID(userID) {
return map[string]any{"receiverOpenDingTalkId": userID}, nil
}
resolved, err := resolveOpenDingTalkID(cmd.Context(), userID)
if err != nil {
return nil, fmt.Errorf("cannot resolve --user %q to openDingTalkId: %w; pass --open-dingtalk-id instead", userID, err)
}
return map[string]any{"receiverOpenDingTalkId": resolved}, nil
}
func validatePersonalEmotionSourcePair(sourceConversationID, sourceMessageID string) error {
hasConversation := strings.TrimSpace(sourceConversationID) != ""
hasMessage := strings.TrimSpace(sourceMessageID) != ""
if hasConversation != hasMessage {
return fmt.Errorf("--source-conversation-id and --source-message-id must be specified together")
}
return nil
}
func personalEmotionSendContract() LeafContract {
return LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat", Name: "send_personal_emotion",
CanonicalPath: "chat.send_personal_emotion", CLIPath: "chat emotion send", PrimaryCLIPath: "chat emotion send",
},
Description: "以当前用户身份向群聊或单聊发送个人收藏表情",
Interface: &contract.InterfaceSpec{
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
},
Selection: contract.SelectionSpec{
AgentSummary: "以当前用户身份发送个人收藏表情",
UseWhen: []string{"用户明确要求发送个人收藏表情,且已提供 mediaId 或 emotionId 时"},
AvoidWhen: []string{"发送普通文本、Markdown 或文件时使用 chat message send"},
Examples: []string{"dws chat emotion send --media-id <mediaId> --group <openConversationId> --uuid <idempotencyKey>"},
},
Parameters: []contract.ParamDecl{
{Name: "media-id", Property: "mediaId", Required: boolPtr(true)},
{Name: "emotion-id", Property: "emotionId", Required: boolPtr(false)},
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)},
{Name: "group", Property: "openConversationId", Required: boolPtr(false)},
{Name: "user", Property: "receiverOpenDingTalkId", Required: boolPtr(false)},
{Name: "open-dingtalk-id", Property: "receiverOpenDingTalkId", Required: boolPtr(false)},
{Name: "uuid", Property: "uuid", Required: boolPtr(false)},
{Name: "idempotency-key", Property: "uuid", Required: boolPtr(false)},
},
}
}
func personalEmotionFavoriteContract() LeafContract {
return LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat", Name: "favorite_personal_emotion",
CanonicalPath: "chat.favorite_personal_emotion", CLIPath: "chat emotion favorite", PrimaryCLIPath: "chat emotion favorite",
},
Description: "将 mediaId 新增到当前用户的个人收藏表情",
Interface: &contract.InterfaceSpec{
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
},
Selection: contract.SelectionSpec{
AgentSummary: "新增当前用户的个人收藏表情",
UseWhen: []string{"用户要把一个 mediaId 收藏为个人表情时"},
AvoidWhen: []string{"收藏消息使用 chat message add-favorite"},
Examples: []string{"dws chat emotion favorite --media-id <mediaId> --name \"赞\""},
},
Parameters: []contract.ParamDecl{
{Name: "media-id", Property: "mediaId", Required: boolPtr(true)},
{Name: "name", Property: "name", Required: boolPtr(false)},
{Name: "source-conversation-id", Property: "sourceConversationId", Required: boolPtr(false), RequiredWhen: "source-message-id is provided"},
{Name: "source-message-id", Property: "sourceMessageId", Required: boolPtr(false), RequiredWhen: "source-conversation-id is provided"},
},
}
}
@@ -0,0 +1,254 @@
package helpers
import (
"context"
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type personalEmotionCall struct {
server string
tool string
args map[string]any
}
type personalEmotionCaller struct {
calls []personalEmotionCall
}
func (c *personalEmotionCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
copied := make(map[string]any, len(args))
for key, value := range args {
copied[key] = value
}
c.calls = append(c.calls, personalEmotionCall{server: server, tool: tool, args: copied})
if server == "contact" && tool == "get_user_info_by_user_ids" {
return textToolResult(`{"result":[{"userId":"u1","openDingTalkId":"` + helperCurrentDOpenID2 + `"}]}`), nil
}
return textToolResult(`{"ok":true}`), nil
}
func (*personalEmotionCaller) Format() string { return "json" }
func (*personalEmotionCaller) DryRun() bool { return false }
func (*personalEmotionCaller) Fields() string { return "" }
func (*personalEmotionCaller) JQ() string { return "" }
func executePersonalEmotionCommand(t *testing.T, caller *personalEmotionCaller, args ...string) error {
t.Helper()
installHelpersCoreDeps(t, caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
root := newChatCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
return root.ExecuteContext(context.Background())
}
func requirePersonalEmotionCall(t *testing.T, caller *personalEmotionCaller, tool string, want map[string]any) {
t.Helper()
if len(caller.calls) != 1 {
t.Fatalf("calls = %d, want 1: %+v", len(caller.calls), caller.calls)
}
call := caller.calls[0]
if call.server != "im" || call.tool != tool {
t.Fatalf("tool call = %s/%s, want im/%s", call.server, call.tool, tool)
}
if !reflect.DeepEqual(call.args, want) {
t.Fatalf("args = %#v, want %#v", call.args, want)
}
}
func TestChatEmotionListCallsIMToolWithoutBusinessArgs(t *testing.T) {
// TC-001: list 无业务参数,当前用户身份由 MCP server 注入。
caller := &personalEmotionCaller{}
if err := executePersonalEmotionCommand(t, caller, "emotion", "list"); err != nil {
t.Fatalf("chat emotion list returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "list_personal_emotions", map[string]any{})
}
func TestChatEmotionSendMapsGroupTargetAndIdempotency(t *testing.T) {
// TC-002: 群聊目标映射为 openConversationId,uuid 与表情字段按 MCP 字段透传。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "send",
"--media-id", "@media",
"--emotion-id", "emotion123",
"--group", "cid123",
"--idempotency-key", "idem-001",
)
if err != nil {
t.Fatalf("chat emotion send returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
"mediaId": "@media",
"emotionId": "emotion123",
"openConversationId": "cid123",
"uuid": "idem-001",
})
}
func TestChatEmotionSendMapsOpenDingTalkTarget(t *testing.T) {
// TC-003: 已知 openDingTalkId 时直传 receiverOpenDingTalkId,不做外部解析。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "send",
"--media-id", "@media",
"--open-dingtalk-id", helperCurrentDOpenID,
)
if err != nil {
t.Fatalf("chat emotion send returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
"mediaId": "@media",
"receiverOpenDingTalkId": helperCurrentDOpenID,
})
}
func TestChatEmotionSendTreatsOpenDingTalkIDPassedAsUserAsResolvedTarget(t *testing.T) {
// TC-004: --user 收到 openDingTalkId 形态时保持 chat message send 的兼容语义。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "send",
"--media-id", "@media",
"--user", helperCurrentDOpenID,
)
if err != nil {
t.Fatalf("chat emotion send returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
"mediaId": "@media",
"receiverOpenDingTalkId": helperCurrentDOpenID,
})
}
func TestChatEmotionSendResolvesUserIDTarget(t *testing.T) {
// TC-004b: --user 收到普通 userId 时先解析为 openDingTalkId,再发送个人收藏表情。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "send",
"--media-id", "@media",
"--user", "u1",
)
if err != nil {
t.Fatalf("chat emotion send returned error: %v", err)
}
if len(caller.calls) != 2 {
t.Fatalf("calls = %d, want contact resolve then send: %+v", len(caller.calls), caller.calls)
}
resolveCall := caller.calls[0]
if resolveCall.server != "contact" || resolveCall.tool != "get_user_info_by_user_ids" {
t.Fatalf("resolve call = %s/%s, want contact/get_user_info_by_user_ids", resolveCall.server, resolveCall.tool)
}
sendCall := caller.calls[1]
if sendCall.server != "im" || sendCall.tool != "send_personal_emotion" {
t.Fatalf("send call = %s/%s, want im/send_personal_emotion", sendCall.server, sendCall.tool)
}
want := map[string]any{
"mediaId": "@media",
"receiverOpenDingTalkId": helperCurrentDOpenID2,
}
if !reflect.DeepEqual(sendCall.args, want) {
t.Fatalf("send args = %#v, want %#v", sendCall.args, want)
}
}
func TestChatEmotionSendRejectsInvalidTargets(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "missing target",
args: []string{"emotion", "send", "--media-id", "@media"},
wantErr: "specify exactly one",
},
{
name: "multiple targets",
args: []string{"emotion", "send", "--media-id", "@media", "--group", "cid", "--open-dingtalk-id", helperCurrentDOpenID},
wantErr: "specify exactly one",
},
{
name: "missing media",
args: []string{"emotion", "send", "--group", "cid"},
wantErr: "--media-id is required",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller, tc.args...)
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("error = %v, want containing %q", err, tc.wantErr)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid command reached MCP: %+v", caller.calls)
}
})
}
}
func TestChatEmotionFavoriteMapsOptionalSourcePair(t *testing.T) {
// TC-005: 收藏来源字段成对出现时透传为 sourceConversationId/sourceMessageId。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "favorite",
"--media-id", "@media",
"--name", "赞",
"--source-conversation-id", "cid123",
"--source-message-id", "msg123",
)
if err != nil {
t.Fatalf("chat emotion favorite returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "favorite_personal_emotion", map[string]any{
"mediaId": "@media",
"name": "赞",
"sourceConversationId": "cid123",
"sourceMessageId": "msg123",
})
}
func TestChatEmotionFavoriteRejectsMissingRequiredOrUnpairedSource(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "missing media",
args: []string{"emotion", "favorite", "--name", "赞"},
wantErr: "--media-id is required",
},
{
name: "source conversation only",
args: []string{"emotion", "favorite", "--media-id", "@media", "--source-conversation-id", "cid123"},
wantErr: "must be specified together",
},
{
name: "source message only",
args: []string{"emotion", "favorite", "--media-id", "@media", "--source-message-id", "msg123"},
wantErr: "must be specified together",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller, tc.args...)
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("error = %v, want containing %q", err, tc.wantErr)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid command reached MCP: %+v", caller.calls)
}
})
}
}
+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
+20 -2
View File
@@ -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
@@ -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)
}
}
+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"},
@@ -479,8 +479,8 @@ func evaluateCommandMigrationLifecycle(
}
}
if allAfter {
if exists {
return nil, fmt.Errorf("consumed command migration %s is stale after all references reached the after state", approved.displayKey())
if exists && proposed.State != CommandMigrationConsumed {
return nil, fmt.Errorf("candidate changed consumed command migration %s back to pending", approved.displayKey())
}
continue
}
@@ -383,9 +383,9 @@ func TestCrossPlatformCoverageCommandMigrationLifecycleEdges(t *testing.T) {
{"after pending receipt", after, map[string]Snapshot{"main": before, "stable": before}, pending, pending, "without marking it consumed"},
{"partial", partial, map[string]Snapshot{"main": before, "stable": before}, pending, consumed, "partially applied"},
{"consumed drift", before, map[string]Snapshot{"main": after, "stable": before}, consumed, consumed, "drifted from consumed"},
{"stale receipt", after, map[string]Snapshot{"main": after, "stable": after}, consumed, consumed, "stale after all references"},
{"early cleanup", after, map[string]Snapshot{"main": after, "stable": before}, consumed, empty, "must retain consumed"},
{"consumed back to pending", after, map[string]Snapshot{"main": after, "stable": before}, consumed, pending, "must retain consumed"},
{"inert consumed back to pending", after, map[string]Snapshot{"main": after, "stable": after}, consumed, pending, "back to pending"},
{"candidate added consumed", after, map[string]Snapshot{"main": before, "stable": before}, empty, consumed, "must start pending"},
{"candidate base mismatch", before, map[string]Snapshot{"main": after, "stable": before}, empty, pending, "does not match"},
} {
@@ -402,6 +402,9 @@ func TestCrossPlatformCoverageCommandMigrationLifecycleEdges(t *testing.T) {
if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": after}, consumed, empty); err != nil || len(got) != 0 {
t.Fatalf("cleaned stale receipt=%#v, %v", got, err)
}
if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": after}, consumed, consumed); err != nil || len(got) != 0 {
t.Fatalf("retained inert receipt=%#v, %v", got, err)
}
if got, err := AuthorizeCommandMigrations(before, map[string]Snapshot{"main": before, "stable": before}, empty, pending); err != nil || len(got) != 0 {
t.Fatalf("candidate pending plan=%#v, %v", got, err)
}
+8 -4
View File
@@ -889,7 +889,7 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRetainsConsumedReceipt
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresCleanupAfterAllReferencesCatchUp(t *testing.T) {
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsAllowsRetentionOrCleanupAfterAllReferencesCatchUp(t *testing.T) {
after := testFlagMigrationSnapshot(true, true)
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
references := map[string]Snapshot{
@@ -897,11 +897,15 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresCleanupAfterAl
"stable": after,
}
if report, err := CompareAllWithFlagMigrations(after, references, consumed, consumed); err == nil {
t.Fatalf("stale consumed receipt was accepted after every reference caught up: %#v", report)
report, err := CompareAllWithFlagMigrations(after, references, consumed, consumed)
if err != nil {
t.Fatalf("inert consumed receipt was rejected after every reference caught up: %v", err)
}
if !report.Compatible {
t.Fatalf("inert consumed receipt changed compatibility: %#v", report.Comparisons)
}
report, err := CompareAllWithFlagMigrations(after, references, consumed, testEmptyFlagMigrationManifest())
report, err = CompareAllWithFlagMigrations(after, references, consumed, testEmptyFlagMigrationManifest())
if err != nil {
t.Fatalf("cleanup of stale consumed receipt was rejected: %v", err)
}
+127 -20
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,
@@ -579,8 +667,8 @@ func evaluateFlagMigrationLifecycle(
}
}
if allReferencesAfter {
if exists {
return nil, fmt.Errorf("consumed flag migration %s is stale after all references reached the after state", approved.displayKey())
if exists && proposed.State != FlagMigrationConsumed {
return nil, fmt.Errorf("candidate changed consumed flag migration %s back to pending", approved.displayKey())
}
continue
}
@@ -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)
}
@@ -602,12 +602,12 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsLifecycleErrors(t *tes
wantErr: "back to pending",
},
{
name: "consumed receipt becomes stale",
name: "inert consumed receipt cannot revert to pending",
current: after,
references: map[string]Snapshot{"merge-base": after, "stable": after},
authority: consumed,
candidate: consumed,
wantErr: "is stale after all references reached the after state",
candidate: pending,
wantErr: "back to pending",
},
{
name: "candidate-added receipt starts pending",
@@ -857,15 +857,15 @@ func TestCrossPlatformCoverageOptionalFlagMigrationLifecycleRemainsHostile(t *te
}
})
t.Run("consumed receipt remains stale after every reference converges", func(t *testing.T) {
_, err := CompareAllWithFlagMigrations(
t.Run("consumed receipt becomes inert after every reference converges", func(t *testing.T) {
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": after, "stable": after},
consumed,
consumed,
)
if err == nil || !strings.Contains(err.Error(), "stale after all references reached the after state") {
t.Fatalf("stale optional migration error = %v", err)
if err != nil || !report.Compatible {
t.Fatalf("inert optional migration = (%#v, %v), want compatible", report, err)
}
})
}
@@ -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) {
@@ -0,0 +1,101 @@
// 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))
}
wantCompatibilityVisible := map[string]bool{
"+list": true,
"+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 {
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 record.CompatibilityVisible {
compatibilityVisible++
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) {
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 {
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 != 2 || compatibilityVisible != len(wantCompatibilityVisible) {
t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/2/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible))
}
}
@@ -0,0 +1,102 @@
// 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))
}
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]
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 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) {
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 record.CompatibilityVisible {
compatibilityVisible++
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) {
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 {
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 != 0 || compatibilityVisible != len(wantCompatibilityVisible) {
t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/0/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible))
}
}
@@ -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 != 4 || unavailable != 0 {
t.Fatalf("public/unavailable=%d/%d, want 4/0", public, unavailable)
}
}
@@ -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)
}
})
}
}
+316
View File
@@ -0,0 +1,316 @@
// 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."
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 {
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 虽有稳定写回执,但下游没有稳定接收人身份和可查询撤回终态,当前不可执行",
apperrors.WithOperation(operation),
apperrors.WithOrigin("shortcut_registry"),
apperrors.WithFailureStage("capability_gate"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithReason("write_fixture_unavailable"),
)
}
+154 -175
View File
@@ -1,226 +1,205 @@
// 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"
"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"
)
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 类型: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 <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: "dingId"}},
"dws ding +receiver-status --ding-id <DING_ID>",
),
Flags: []shortcut.Flag{{Name: "ding-id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}},
Tips: []string{"dws ding +receiver-status --ding-id <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 <VALUES> --content <CONTENT>"},
},
},
Flags: []shortcut.Flag{
var SendPersonal = compatibilityDingWrite(
"+send-personal", "以本人身份发送 DING 给指定人",
"仅为历史 CLI 兼容保留:明确确认后按原有参数发送;因接收人稳定身份与撤回终态尚不可验证,不进入 Agent 公共发现。",
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: "提醒方式:app、sms 或 call"},
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键"},
},
Execute: func(rt *shortcut.RuntimeContext) error {
[]contract.ParamDecl{
{Name: "users", Property: "users", InterfaceType: "array"},
{Name: "content", Property: "content"}, {Name: "type", Property: "type"}, {Name: "uuid", Property: "uuid"},
},
"dws ding +send-personal --users <VALUES> --content <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": rt.Str("type"),
"remindType": remindType,
}
if rt.Changed("uuid") {
params["uuid"] = rt.Str("uuid")
}
return rt.CallMCP("send_personal_ding", params)
},
}
)
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 任务,但接收人稳定身份与精确撤回终态仍缺失。",
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 <GROUP_ID> --message-id <MESSAGE_ID> --users <VALUES>",
)
var RecallPersonal = compatibilityDingWrite(
"+recall-personal", "撤回本人发起的 DING",
"仅为历史 CLI 兼容保留:明确确认后按稳定 openDingId 撤回;因终态与残留通知尚不可查询验证,不进入 Agent 公共发现。",
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 <DING_ID>",
func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("recall_personal_ding", map[string]any{"openDingId": rt.Str("id")})
},
)
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: declaration,
Flags: flags, Tips: []string{example}, Execute: execute,
}
}
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",
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 <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 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,
)
shortcut.Register(List, ReceiverStatus, SendPersonal, SendByMessage, RecallPersonal)
}
@@ -0,0 +1,434 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package ding
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"math"
"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
failures map[string]error
history []string
arguments []map[string]any
}
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)
}
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 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() {
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, RecallPersonal} {
if declaration.Safety.Confirmation != "user_required" || declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" {
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 unavailable write safety/interface drift", SendByMessage.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, "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}},
"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)
}
}
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) {
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"}}},
"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)}}}},
} {
if projected, projectErr := dingProjectReceivers(fixture, "im/list_ding_receiver_status", "ding-1"); projectErr == nil {
t.Errorf("%s returned success: %#v", name, projected)
}
}
}
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}}`},
}}
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) {
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)
}
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) {
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 {
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)
}
}
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()
}
+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 {
+415
View File
@@ -0,0 +1,415 @@
// 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 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"}
}
func oaWriteSafety() contract.SafetySpec {
return contract.SafetySpec{Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"}
}
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,
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: availability, Reason: interfaceReason},
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),
)
}
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 {
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
}
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
break
}
}
instances = append(instances, row)
}
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")
}
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
}
func oaHasMorePage(result map[string]any, operation string, currentPage 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}
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 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")
}
if limit <= 0 || limit > 100 {
return apperrors.NewValidation("--limit 必须在 1 到 100 之间")
}
return nil
}
@@ -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)
}
}
+188 -537
View File
@@ -1,59 +1,73 @@
// 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, _ := data["result"].(map[string]any)
evidence, err := oaHasMorePage(result, operation, 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 前不会进入公开发现。",
true,
oaCollectionResult("instances", "严格验证的待处理审批实例页"),
oaPagePagination("page"),
[]contract.ParamDecl{
{Name: "start", Property: "start"},
{Name: "end", Property: "end"},
{Name: "page", Property: "page"},
{Name: "limit", Property: "limit"},
{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 +75,25 @@ 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"),
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 毫秒范围")
}
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 +103,154 @@ 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,故不进入 Agent 公开发现。",
true,
oaCollectionResult("forms", "严格验证的可见审批表单页"), oaPagePagination("cursor"),
[]contract.ParamDecl{{Name: "cursor", Property: "cursor"}, {Name: "limit", Property: "limit"}},
"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`},
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")
}
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, _ := data["result"].(map[string]any)
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 时使用;要无条件遍历全部定义不要使用本命令。",
true,
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}},
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 strings.TrimSpace(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": strings.TrimSpace(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, 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"),
Flags: []shortcut.Flag{
{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: "关键字搜索"},
},
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.",
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"},
},
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)
}
@@ -0,0 +1,728 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package oa
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"reflect"
"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 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,
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")
}
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)
}
}
}
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); err == nil {
t.Fatal("numbered page without hasMore was accepted")
}
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", 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")
}
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)
}
}
func TestCrossPlatformCoverageMyInitiatedNeverFallsBackToRawResponse(t *testing.T) {
caller := &oaCoverageCaller{responses: map[string][]string{
"get_submitted_instances": {`{"success":"true","result":{"values":[],"hasMore":false}}`},
}}
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)
}
}
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) {
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")
})
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) {
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")
})
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])
}
})
}
+272
View File
@@ -0,0 +1,272 @@
// 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, _ := data["result"].(map[string]any)
page, err := oaHasMorePage(result, operation, 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 时不进入 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",
),
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 之间")
}
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。",
true,
oaCollectionResult("done", "严格验证的已处理审批摘要"), nil,
[]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"}},
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。",
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 报销",
),
Flags: []shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索(可选)"},
{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"}, 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"))}
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, _ := data["result"].(map[string]any)
page, err := oaHasMorePage(result, operation, 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 前不进入 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 报销",
),
Flags: []shortcut.Flag{
{Name: "keyword", Type: shortcut.FlagString, Desc: "待审批单据的单号或标题关键词", Required: true},
{Name: "comment", Type: shortcut.FlagString, Desc: "审批意见(可选)"},
},
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 {
keyword := strings.TrimSpace(rt.Str("keyword"))
if keyword == "" {
return apperrors.NewValidation("--keyword 不能为空")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
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,
})
if err != nil {
return err
}
items, err := oaProjectInstances(pending, "oa/list_pending_approvals", "result.values")
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
}
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)
}
@@ -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": {},
@@ -377,15 +374,11 @@ 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": {},
"report\u0000+inbox-list": {},
"report\u0000+outbox-list": {},
"report\u0000+report-latest": {},
"report\u0000+template-search": {},
"sheet\u0000+list-sheets": {},
"sheet\u0000+read": {},
"todo\u0000+assign": {},
+477
View File
@@ -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)))
}
+130
View File
@@ -0,0 +1,130 @@
// 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 天内自己提交的最新日志详情时使用;默认最近 20 天,也可成对指定创建时间窗,完整验证候选后按精确 reportId 读回详情。",
Risk: shortcut.RiskRead,
Safety: reportReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: reportContract(
"+report-latest", "读取我最近提交的一篇日志详情",
"只想查看明确 20 天内自己提交的最新日志详情时使用;默认最近 20 天,也可成对指定创建时间窗,完整验证候选后按精确 reportId 读回详情。",
reportLatestResult(), nil,
[]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: "按日志模板名称精确过滤"},
{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 天"},
},
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(),
}
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
latestCount := 0
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
latestCount = 1
} else if created == latestTime {
latestCount++
}
}
if latestCount != 1 {
return "", reportResponseError(operation, "ambiguous_latest_order", "多篇日志具有相同的最高 createTime,不能确定唯一最新日志")
}
return latestID, nil
}
func init() {
shortcut.Register(ReportLatest)
}
+230 -235
View File
@@ -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: "start"}, {Name: "end", Property: "end"},
{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 时使用;每次创建或修改时间窗不得超过 20 天。",
Risk: shortcut.RiskRead,
Safety: reportReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: reportContract(
"+outbox-list", "列出我发出的日志",
"需要按创建或修改时间回顾自己提交的日志并取得稳定 reportId 时使用;每次创建或修改时间窗不得超过 20 天。",
reportCollectionResult("reports", "严格验证的发件箱日志页"), reportPagination(),
[]contract.ParamDecl{
{Name: "cursor", Property: "cursor"}, {Name: "size", Property: "size"},
{Name: "start", Property: "start"}, {Name: "end", Property: "end"},
{Name: "modified-start", Property: "modifiedStart"}, {Name: "modified-end", Property: "modifiedEnd"},
{Name: "template-name", Property: "templateName"},
},
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)
}
@@ -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))
}
}
@@ -0,0 +1,625 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package report
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"math"
"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 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 {
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)
}
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,
"--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 {
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"}},
{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{}}
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)
}
}
}
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")
}
}
+18 -5
View File
@@ -40,6 +40,15 @@ var attendanceSemanticCatalogJSON []byte
//go:embed semantic_catalog_mail.json
var mailSemanticCatalogJSON []byte
//go:embed semantic_catalog_oa.json
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"`
@@ -69,6 +78,9 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
todoSemanticCatalogJSON,
attendanceSemanticCatalogJSON,
mailSemanticCatalogJSON,
oaSemanticCatalogJSON,
dingSemanticCatalogJSON,
reportSemanticCatalogJSON,
)
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
@@ -130,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 {
@@ -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) {
@@ -0,0 +1,48 @@
{
"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,
"compatibility_visible": true,
"reviewed": true
},
"+receiver-status": {
"disposition": "semantic_adapter",
"semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;current HEAD exact Shortcut 与 owning atomic/raw 的请求身份、1 项结果和完整接收行集合一致。",
"risk": "read",
"availability": "available",
"public": true,
"reviewed": true
},
"+send-personal": {
"disposition": "semantic_adapter",
"semantic_delta": "历史 CLI 兼容入口在用户确认后保持可执行,并修正 app/sms/call 到 APP/SMS/PHONE 的下游枚举映射;因接收项没有稳定接收人身份且撤回无可查询终态,不进入 Agent 公共发现。",
"risk": "write",
"availability": "available",
"public": false,
"compatibility_visible": true,
"reviewed": true
},
"+send-by-message": {
"disposition": "semantic_adapter",
"semantic_delta": "覆盖基于消息的应用内、短信和电话 DING并映射 lark-cli 三种 urgent 任务;隔离 APP fixture 已证明源消息、稳定 DING 回执和目标 DING 读回,但接收人身份与撤回终态仍不可验证,保持不可用。",
"risk": "write",
"public": false,
"reviewed": true
},
"+recall-personal": {
"disposition": "semantic_adapter",
"semantic_delta": "历史 CLI 兼容入口在用户确认后保持可执行;个人 DING 撤回只返回布尔成功且不回显终态,不能作为 Agent 可验证写能力,因此不进入公共发现。",
"risk": "high-risk-write",
"availability": "available",
"public": false,
"compatibility_visible": true,
"reviewed": true
}
}
}
@@ -0,0 +1,96 @@
{
"version": 1,
"service": "oa",
"default_availability": "unavailable",
"shortcuts": {
"+list-executed": {
"disposition": "semantic_adapter",
"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,不能把空数组推断为终页,因此不进入 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,故与主列表一并保持非公开。",
"risk": "read",
"availability": "available",
"primary": "+list-submitted",
"public": false,
"compatibility_visible": 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 的集合高度重叠;严格实现拒绝缺失、畸形、停滞或倒退游标,下游修复前不进入 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,因此不进入 Agent 公开发现。",
"risk": "read",
"availability": "available",
"public": false,
"compatibility_visible": true,
"reviewed": true
},
"+pending": {
"disposition": "alias_internal",
"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,因此不进入 Agent 公开发现。",
"risk": "read",
"availability": "available",
"public": false,
"compatibility_visible": true,
"reviewed": true
},
"+approve-by": {
"disposition": "primary_smart",
"semantic_delta": "唯一定位实例和任务、确认后写入、精确任务读回的严格编排已实现;缺少可撤销的待办审批 fixture 和可靠清理路径,因此不进入 Agent 公开发现。",
"risk": "high-risk-write",
"availability": "available",
"public": false,
"reviewed": true
}
}
}
@@ -0,0 +1,39 @@
{
"version": 1,
"service": "report",
"default_availability": "unavailable",
"shortcuts": {
"+inbox-list": {
"disposition": "semantic_adapter",
"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,
"reviewed": true
},
"+outbox-list": {
"disposition": "semantic_adapter",
"semantic_delta": "严格验证发件箱集合、稳定 reportId 和分页终止证据;current HEAD exact 与 owning atomic 同场景已知页均为 1 项且身份一致,独立未来范围均为 0 并明确终止。",
"risk": "read",
"availability": "available",
"public": true,
"reviewed": true
},
"+template-search": {
"disposition": "semantic_adapter",
"semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;current HEAD exact 与 owning atomic 完整集合过滤的已知结果均为 1 且身份一致,随机 UUID 查询均为 0。",
"risk": "read",
"availability": "available",
"public": true,
"reviewed": true
},
"+report-latest": {
"disposition": "primary_smart",
"semantic_delta": "完整验证默认最近 20 天或显式不超过 20 天的发件箱;current HEAD exact 所选稳定 reportId 与 owning atomic 候选和精确详情身份一致,严格详情字段计数双层均为 3。",
"risk": "read",
"availability": "available",
"public": true,
"reviewed": true
}
}
}
-277
View File
@@ -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)
}
@@ -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 {
-109
View File
@@ -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)
}
-247
View File
@@ -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)
}
@@ -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)
}
-220
View File
@@ -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)
}
+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) {
+2 -1
View File
@@ -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
+20 -8
View File
@@ -14,7 +14,7 @@ usage() {
# Single source of truth for the partition set. CI runs one job per partition and
# pins its shard names to this list, so a name that appears here without a
# dispatch entry below fails closed rather than silently skipping tests.
APP_PARTITIONS='schema a-b c d-r s-z-example-fuzz'
APP_PARTITIONS='schema a-b c-a-l c-m-r c-s-z c-other d-r s-z-example-fuzz'
mode="${1:-}"
partition=""
@@ -66,7 +66,10 @@ fi
schema_count=0
ab_count=0
c_count=0
cal_count=0
cmr_count=0
csz_count=0
cother_count=0
dr_count=0
sz_count=0
unmatched_count=0
@@ -75,7 +78,10 @@ while IFS= read -r test_name; do
case "$test_name" in
Test*Schema*) schema_count=$((schema_count + 1)) ;;
Test[A-B]*) ab_count=$((ab_count + 1)) ;;
TestC*) c_count=$((c_count + 1)) ;;
TestCrossPlatformCoverage[A-L]*) cal_count=$((cal_count + 1)) ;;
TestCrossPlatformCoverage[M-R]*) cmr_count=$((cmr_count + 1)) ;;
TestCrossPlatformCoverage[S-Z]*) csz_count=$((csz_count + 1)) ;;
TestC*) cother_count=$((cother_count + 1)) ;;
Test[D-R]*) dr_count=$((dr_count + 1)) ;;
Test[S-Z]*|Example*|Fuzz*) sz_count=$((sz_count + 1)) ;;
*)
@@ -96,7 +102,10 @@ classified=''
for spec in \
"schema:$schema_count" \
"a-b:$ab_count" \
"c:$c_count" \
"c-a-l:$cal_count" \
"c-m-r:$cmr_count" \
"c-s-z:$csz_count" \
"c-other:$cother_count" \
"d-r:$dr_count" \
"s-z-example-fuzz:$sz_count"
do
@@ -133,14 +142,14 @@ for name in $classified; do
done
total_count="$(wc -l < "$tests" | tr -d ' ')"
assigned_count=$((schema_count + ab_count + c_count + dr_count + sz_count))
assigned_count=$((schema_count + ab_count + cal_count + cmr_count + csz_count + cother_count + dr_count + sz_count))
if [ "$assigned_count" -ne "$total_count" ]; then
printf 'app race partitions assigned %s tests, want %s\n' "$assigned_count" "$total_count" >&2
exit 1
fi
printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c=%s d-r=%s s-z-example-fuzz=%s\n' \
"$total_count" "$schema_count" "$ab_count" "$c_count" "$dr_count" "$sz_count"
printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c-a-l=%s c-m-r=%s c-s-z=%s c-other=%s d-r=%s s-z-example-fuzz=%s\n' \
"$total_count" "$schema_count" "$ab_count" "$cal_count" "$cmr_count" "$csz_count" "$cother_count" "$dr_count" "$sz_count"
if [ "$mode" = "verify" ]; then
exit 0
@@ -198,7 +207,10 @@ run_named_partition() {
case "$1" in
schema) run_partition schema no-race "$schema_pattern" ;;
a-b) run_partition a-b race '^Test[A-B]' "$schema_pattern" ;;
c) run_partition c race '^TestC' "$schema_pattern" ;;
c-a-l) run_partition c-a-l race '^TestCrossPlatformCoverage[A-L]' "$schema_pattern" ;;
c-m-r) run_partition c-m-r race '^TestCrossPlatformCoverage[M-R]' "$schema_pattern" ;;
c-s-z) run_partition c-s-z race '^TestCrossPlatformCoverage[S-Z]' "$schema_pattern" ;;
c-other) run_partition c-other race '^TestC' '^Test.*Schema|^TestCrossPlatformCoverage' ;;
d-r) run_partition d-r race '^Test[D-R]' "$schema_pattern" ;;
s-z-example-fuzz)
run_partition s-z-example-fuzz race '^(Test[S-Z]|Example|Fuzz)' "$schema_pattern"
+3
View File
@@ -29,6 +29,9 @@ 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",
ROOT / "internal" / "shortcut" / "semantic_catalog_ding.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_report.json",
]
@@ -403,6 +403,11 @@ CANDIDATE_RAW="$TMP_ROOT/candidate-schema.json"
go build -o "$CANDIDATE_BIN" ./cmd
)
CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA=false
if "$CHECKER" --help 2>&1 | grep -Fq -- 'migration-base-schema'; then
CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA=true
fi
mkdir -p "$TMP_ROOT/base-home" "$TMP_ROOT/stable-home" "$TMP_ROOT/candidate-home"
HOME="$TMP_ROOT/base-home" DWS_LANG=zh \
"$BASE_BIN" schema --all --format json >"$BASE_RAW"
@@ -468,23 +473,22 @@ check_with_migrations() {
historical_kind="$1"
historical_ref="$2"
historical_baseline="$3"
set -- \
--approved-flag-migrations "$APPROVED_MANIFEST" \
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then
check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" \
--approved-flag-migrations "$APPROVED_MANIFEST" \
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
set -- "$@" \
--approved-command-migrations "$APPROVED_COMMAND_MANIFEST" \
--candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST" \
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
else
check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" \
--approved-flag-migrations "$APPROVED_MANIFEST" \
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
--candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST"
fi
if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ] &&
[ "$CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA" = true ]; then
set -- "$@" --migration-base-schema "$BASELINE"
fi
check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" "$@"
}
check_with_migrations "PR merge-base" "$BASE_REF" "$BASELINE"
+22 -1
View File
@@ -68,7 +68,28 @@ if [ "$archive_changed" = true ]; then
index($0, heading) == 1 { found = 1; next }
found && /^## / { exit }
found { print }
' >"$tmp_root/actual-notes"
' >"$tmp_root/actual-section"
case "$release_version" in
*-beta.*)
cp "$tmp_root/actual-section" "$tmp_root/actual-notes"
;;
*)
stable_beta="$(sed -n 's/^### Changes since `\(v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*\)`$/\1/p' "$tmp_root/actual-section")"
[ "$(printf '%s\n' "$stable_beta" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || {
printf '%s\n' 'error: stable release-seal with archived fragments requires exactly one ### Changes since `vX.Y.Z-beta.N` boundary' >&2
exit 1
}
. "$ROOT/scripts/release/release-lib.sh"
[ "$(release_core_tag "$stable_beta")" = "v$release_version" ] || {
printf 'error: stable post-beta fragment boundary %s does not match release v%s\n' "$stable_beta" "$release_version" >&2
exit 1
}
awk '
found { print }
/^### Changes since `v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*`$/ { found = 1 }
' "$tmp_root/actual-section" >"$tmp_root/actual-notes"
;;
esac
normalize_notes() {
awk '
/^[[:space:]]*$/ && !started { next }
File diff suppressed because it is too large Load Diff
@@ -233,6 +233,53 @@ func TestCrossPlatformCoverageSchemaCommandMigrationNormalizationEdges(t *testin
t.Fatalf("constraint drift was hidden: %s", failures)
}
unchangedLegacyConstraints := cloneContract(current)
product = unchangedLegacyConstraints.Products["chat"]
tool = product.Tools["chat.move"]
tool.Constraints = baseline.Products["chat"].Tools["chat.move"].Constraints
product.Tools["chat.move"] = tool
unchangedLegacyConstraints.Products["chat"] = product
if _, err := normalizeSchemaCommandMigrations(baseline, unchangedLegacyConstraints, migrations); err == nil ||
!strings.Contains(err.Error(), "still reference legacy Schema constraint parameter") {
t.Fatalf("unchanged legacy constraints error=%v", err)
}
mixedLegacyConstraints := cloneContract(current)
product = mixedLegacyConstraints.Products["chat"]
tool = product.Tools["chat.move"]
tool.Constraints = `{"require_together":[["keep","new-id","old-id"]]}`
product.Tools["chat.move"] = tool
mixedLegacyConstraints.Products["chat"] = product
if _, err := normalizeSchemaCommandMigrations(baseline, mixedLegacyConstraints, migrations); err == nil ||
!strings.Contains(err.Error(), "still reference legacy Schema constraint parameter") {
t.Fatalf("mixed legacy constraints error=%v", err)
}
malformedHistoricalConstraints := cloneContract(baseline)
product = malformedHistoricalConstraints.Products["chat"]
tool = product.Tools["chat.move"]
tool.Constraints = "{"
product.Tools["chat.move"] = tool
malformedHistoricalConstraints.Products["chat"] = product
if _, err := normalizeSchemaCommandMigrations(malformedHistoricalConstraints, current, migrations); err == nil ||
!strings.Contains(err.Error(), "historical Schema constraints are not canonicalizable") {
t.Fatalf("malformed historical constraints error=%v", err)
}
malformedCurrentConstraints := cloneContract(current)
product = malformedCurrentConstraints.Products["chat"]
tool = product.Tools["chat.move"]
tool.Constraints = "{"
product.Tools["chat.move"] = tool
malformedCurrentConstraints.Products["chat"] = product
if _, err := normalizeSchemaCommandMigrations(baseline, malformedCurrentConstraints, migrations); err == nil ||
!strings.Contains(err.Error(), "current Schema constraints are not canonicalizable") {
t.Fatalf("malformed current constraints error=%v", err)
}
if source, found := migratedConstraintSourceParameter("{", map[string]string{"legacy": "canonical"}); found || source != "" {
t.Fatalf("malformed migrated constraint source = %q, %v", source, found)
}
extractionWrongSource := cloneContract(current)
product = extractionWrongSource.Products["chat"]
tool = product.Tools["chat.create_group"]
@@ -657,6 +704,531 @@ func TestCrossPlatformCoverageSchemaCommandMigrationLifecycleAndRun(t *testing.T
}
}
func TestCrossPlatformCoverageSchemaCommandMigrationComposesHistoricalFlagLineage(t *testing.T) {
directory := t.TempDir()
stableContract, baseContract, currentContract := schemaCommandLineageContracts()
baselinePath := filepath.Join(directory, "stable-schema.json")
baseSchemaPath := filepath.Join(directory, "base-schema.json")
currentPath := filepath.Join(directory, "current-schema.json")
approvedFlagPath := filepath.Join(directory, "approved-flags.json")
candidateFlagPath := filepath.Join(directory, "candidate-flags.json")
approvedCommandPath := filepath.Join(directory, "approved-commands.json")
candidateCommandPath := filepath.Join(directory, "candidate-commands.json")
currentSnapshotPath := filepath.Join(directory, "current-snapshot.json")
baseSnapshotPath := filepath.Join(directory, "base-snapshot.json")
stableSnapshotPath := filepath.Join(directory, "stable-snapshot.json")
writeSchemaContractFile(t, baselinePath, stableContract)
writeSchemaContractFile(t, baseSchemaPath, baseContract)
writeRawSchemaContractFile(t, currentPath, currentContract)
writeFlagMigrationManifestFile(t, approvedFlagPath, schemaCommandLineageFlagManifest())
writeFlagMigrationManifestFile(t, candidateFlagPath, schemaCommandLineageFlagManifest())
writeCommandMigrationManifestFile(t, approvedCommandPath, schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending))
writeCommandMigrationManifestFile(t, candidateCommandPath, schemaCommandLineageManifest(interfacesnapshot.CommandMigrationConsumed))
writeInterfaceSnapshotFile(t, currentSnapshotPath, schemaCommandLineageSnapshot(true, true))
writeInterfaceSnapshotFile(t, baseSnapshotPath, schemaCommandLineageSnapshot(true, false))
writeInterfaceSnapshotFile(t, stableSnapshotPath, schemaCommandLineageSnapshot(false, false))
args := []string{
"--check", baselinePath,
"--current", currentPath,
"--migration-base-schema", baseSchemaPath,
"--approved-flag-migrations", approvedFlagPath,
"--candidate-flag-migrations", candidateFlagPath,
"--approved-command-migrations", approvedCommandPath,
"--candidate-command-migrations", candidateCommandPath,
"--migration-current-snapshot", currentSnapshotPath,
"--migration-base-snapshot", baseSnapshotPath,
"--migration-stable-snapshot", stableSnapshotPath,
}
var stdout, stderr strings.Builder
if code := run(args, &stdout, &stderr); code != 0 {
t.Fatalf("pending command lineage code=%d stderr=%s", code, stderr.String())
}
withoutBaseSchema := append([]string(nil), args[:4]...)
withoutBaseSchema = append(withoutBaseSchema, args[6:]...)
stderr.Reset()
if code := run(withoutBaseSchema, &stdout, &stderr); code != 2 ||
!strings.Contains(stderr.String(), "requires --migration-base-schema") {
t.Fatalf("missing migration base Schema code=%d stderr=%q", code, stderr.String())
}
stderr.Reset()
if code := run([]string{
"--check", baselinePath,
"--current", currentPath,
"--migration-base-schema", baseSchemaPath,
}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "requires both flag and command") {
t.Fatalf("orphan migration base Schema code=%d stderr=%q", code, stderr.String())
}
missingBaseSchema := append([]string(nil), args...)
missingBaseSchema[5] = filepath.Join(directory, "missing-base-schema.json")
stderr.Reset()
if code := run(missingBaseSchema, &stdout, &stderr); code != 2 ||
!strings.Contains(stderr.String(), "read migration merge-base Schema contract") {
t.Fatalf("unreadable migration base Schema code=%d stderr=%q", code, stderr.String())
}
// After the command move is merged, the merge-base Schema is already at the
// final name. The two consumed receipts must keep the stable lineage durable
// until the stable release itself reaches the after state.
writeSchemaContractFile(t, baseSchemaPath, currentContract)
writeCommandMigrationManifestFile(t, approvedCommandPath, schemaCommandLineageManifest(interfacesnapshot.CommandMigrationConsumed))
writeInterfaceSnapshotFile(t, baseSnapshotPath, schemaCommandLineageSnapshot(true, true))
stdout.Reset()
stderr.Reset()
if code := run(args, &stdout, &stderr); code != 0 {
t.Fatalf("consumed command lineage code=%d stderr=%s", code, stderr.String())
}
}
func TestCrossPlatformCoverageSchemaCommandMigrationLineageFailsClosed(t *testing.T) {
tests := []struct {
name string
commandState string
mutate func(*schemaContract, *schemaContract, *schemaContract, *[]interfacesnapshot.FlagMigration, *[]interfacesnapshot.CommandMigration)
want string
}{
{
name: "pending merge-base missing intermediate",
commandState: interfacesnapshot.CommandMigrationPending,
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
delete(tool.Parameters, "conversation-id")
})
},
want: "merge-base Schema lacks intermediate parameter",
},
{
name: "merge-base missing source tool",
commandState: interfacesnapshot.CommandMigrationPending,
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
product := base.Products["chat"]
delete(product.Tools, "chat.list_topic_replies")
base.Products["chat"] = product
},
want: "merge-base Schema lacks source tool",
},
{
name: "pending merge-base parameter drift",
commandState: interfacesnapshot.CommandMigrationPending,
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
mutateSchemaCommandMigrationParameter(base, "chat.list_topic_replies", "conversation-id", func(parameter *parameterSchema) {
parameter.Property = "differentProperty"
})
},
want: "changed a non-migration field",
},
{
name: "pending merge-base wrong path",
commandState: interfacesnapshot.CommandMigrationPending,
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
tool.PrimaryCLIPath = "chat unrelated"
})
},
want: "merge-base Schema source tool has primary_cli_path",
},
{
name: "pending merge-base already publishes final",
commandState: interfacesnapshot.CommandMigrationPending,
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
tool.Parameters["open-topic-id"] = tool.Parameters["conversation-id"]
})
},
want: "already publishes final parameter",
},
{
name: "pending flag receipt",
commandState: interfacesnapshot.CommandMigrationPending,
mutate: func(_, _, _ *schemaContract, flags *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
(*flags)[0].State = interfacesnapshot.FlagMigrationPending
},
want: "requires a consumed flag migration receipt",
},
{
name: "flag receipt belongs to another command",
commandState: interfacesnapshot.CommandMigrationPending,
mutate: func(_, _, _ *schemaContract, flags *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
(*flags)[0].Command = "dws chat message other"
},
want: `historical Schema tool lacks parameter "conversation-id"`,
},
{
name: "current retains predecessor",
commandState: interfacesnapshot.CommandMigrationPending,
mutate: func(stable, _, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) {
tool.Parameters["group"] = stable.Products["chat"].Tools["chat.list_topic_replies"].Parameters["group"]
})
},
want: "current Schema still publishes predecessor parameter",
},
{
name: "pending current constraints retain predecessor",
commandState: interfacesnapshot.CommandMigrationPending,
mutate: func(_, _, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) {
tool.Constraints = `{"require_together":[["group","open-conv-thread-id","open-topic-id"]]}`
})
},
want: "current Schema constraints still reference predecessor parameter",
},
{
name: "consumed current constraints retain predecessor",
commandState: interfacesnapshot.CommandMigrationConsumed,
mutate: func(_, _, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) {
tool.Constraints = `{"require_together":[["group","open-conv-thread-id","open-topic-id"]]}`
})
},
want: "current Schema constraints still reference predecessor parameter",
},
{
name: "current retains intermediate",
commandState: interfacesnapshot.CommandMigrationPending,
mutate: func(_, base, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) {
tool.Parameters["conversation-id"] = base.Products["chat"].Tools["chat.list_topic_replies"].Parameters["conversation-id"]
})
},
want: `still publishes legacy Schema parameter "conversation-id"`,
},
{
name: "consumed merge-base final drift",
commandState: interfacesnapshot.CommandMigrationConsumed,
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
mutateSchemaCommandMigrationParameter(base, "chat.list_topic_replies", "open-topic-id", func(parameter *parameterSchema) {
parameter.Required = false
})
},
want: "changed a non-name field",
},
{
name: "consumed merge-base wrong path",
commandState: interfacesnapshot.CommandMigrationConsumed,
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
tool.PrimaryCLIPath = "chat unrelated"
})
},
want: "consumed command migration",
},
{
name: "consumed merge-base missing final",
commandState: interfacesnapshot.CommandMigrationConsumed,
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
delete(tool.Parameters, "open-topic-id")
})
},
want: "merge-base Schema lacks final parameter",
},
{
name: "consumed merge-base retains intermediate",
commandState: interfacesnapshot.CommandMigrationConsumed,
mutate: func(stable, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
tool.Parameters["conversation-id"] = stable.Products["chat"].Tools["chat.list_topic_replies"].Parameters["group"]
})
},
want: "still publishes intermediate parameter",
},
{
name: "pending constraints drift",
commandState: interfacesnapshot.CommandMigrationPending,
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
tool.Constraints = `{"require_one_of":[["conversation-id"]]}`
})
},
want: "changed merge-base Schema constraints",
},
{
name: "pending current keeps intermediate constraints",
commandState: interfacesnapshot.CommandMigrationPending,
mutate: func(_, base, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) {
tool.Constraints = base.Products["chat"].Tools["chat.list_topic_replies"].Constraints
})
},
want: "still reference legacy Schema constraint parameter",
},
{
name: "merge-base retains predecessor",
commandState: interfacesnapshot.CommandMigrationPending,
mutate: func(stable, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
tool.Parameters["group"] = stable.Products["chat"].Tools["chat.list_topic_replies"].Parameters["group"]
})
},
want: "merge-base Schema still publishes predecessor parameter",
},
{
name: "historical constraints malformed",
commandState: interfacesnapshot.CommandMigrationPending,
mutate: func(stable, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
mutateSchemaCommandMigrationTool(stable, "chat.list_topic_replies", func(tool *toolSchema) {
tool.Constraints = "not-json"
})
},
want: "historical Schema constraints are not canonicalizable",
},
{
name: "merge-base constraints malformed",
commandState: interfacesnapshot.CommandMigrationPending,
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
tool.Constraints = "not-json"
})
},
want: "merge-base Schema constraints are not canonicalizable",
},
{
name: "consumed constraints drift",
commandState: interfacesnapshot.CommandMigrationConsumed,
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
tool.Constraints = `{"require_one_of":[["open-topic-id"]]}`
})
},
want: "changed merge-base Schema constraints",
},
{
name: "target collision",
commandState: interfacesnapshot.CommandMigrationPending,
mutate: func(stable, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
mutateSchemaCommandMigrationTool(stable, "chat.list_topic_replies", func(tool *toolSchema) {
tool.Parameters["open-topic-id"] = tool.Parameters["group"]
})
},
want: "target \"open-topic-id\" already exists",
},
{
name: "lineage cycle",
commandState: interfacesnapshot.CommandMigrationPending,
mutate: func(_, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, commands *[]interfacesnapshot.CommandMigration) {
(*commands)[0].Schema.Parameters[0].To = "group"
},
want: "lineage cycle",
},
{
name: "unsupported command receipt state",
commandState: "unknown",
mutate: func(_, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
},
want: "unsupported lineage state",
},
{
name: "duplicate historical path",
commandState: interfacesnapshot.CommandMigrationPending,
mutate: func(stable, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
product := stable.Products["chat"]
product.Tools["chat.duplicate"] = product.Tools["chat.list_topic_replies"]
stable.Products["chat"] = product
},
want: "matches 2 historical Schema tools",
},
{
name: "source tool fork",
commandState: interfacesnapshot.CommandMigrationPending,
mutate: func(_, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, commands *[]interfacesnapshot.CommandMigration) {
fork := (*commands)[0]
fork.Legacy.Command = "dws chat message fork"
fork.Replacement.Command = "dws chat topic fork"
*commands = append(*commands, fork)
},
want: "fork Schema source tool",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
stable, baseBefore, current := schemaCommandLineageContracts()
base := baseBefore
if test.commandState == interfacesnapshot.CommandMigrationConsumed {
base = cloneContract(current)
}
flags := append([]interfacesnapshot.FlagMigration(nil), schemaCommandLineageFlagManifest().Migrations...)
commands := append([]interfacesnapshot.CommandMigration(nil), schemaCommandLineageManifest(test.commandState).Migrations...)
test.mutate(&stable, &base, &current, &flags, &commands)
if _, err := normalizeSchemaCommandMigrationLineage(stable, base, current, flags, commands); err == nil ||
!strings.Contains(err.Error(), test.want) {
t.Fatalf("lineage error=%v, want %q", err, test.want)
}
})
}
}
func TestCrossPlatformCoverageSchemaCommandMigrationLineagePreservesOrdinaryChecks(t *testing.T) {
stable, base, current := schemaCommandLineageContracts()
mutateSchemaCommandMigrationTool(&current, "chat.list_topic_replies", func(tool *toolSchema) {
tool.Positionals = []positionalSchema{{Name: "open-topic-id", Index: 0, Type: "string", Required: true}}
})
normalized, err := normalizeSchemaCommandMigrationLineage(
stable,
base,
current,
schemaCommandLineageFlagManifest().Migrations,
schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations,
)
if err != nil {
t.Fatal(err)
}
if failures := strings.Join(checkCompatibility(normalized, current), "\n"); !strings.Contains(failures, "changed positionals") {
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()
mutateSchemaCommandMigrationTool(&stable, "chat.list_topic_replies", func(tool *toolSchema) {
tool.Parameters["id"] = tool.Parameters["group"]
})
flags := schemaCommandLineageFlagManifest().Migrations
second := flags[0]
second.Legacy.Name = "id"
flags = append(flags, second)
normalized, err = normalizeSchemaCommandMigrationLineage(
stable,
base,
current,
flags,
schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations,
)
if err != nil {
t.Fatalf("equivalent predecessor aliases should pass: %v", err)
}
if failures := checkCompatibility(normalized, current); len(failures) != 0 {
t.Fatalf("equivalent predecessor aliases remained incompatible: %v", failures)
}
mutateSchemaCommandMigrationParameter(&stable, "chat.list_topic_replies", "id", func(parameter *parameterSchema) {
parameter.Property = "differentProperty"
})
if _, err := normalizeSchemaCommandMigrationLineage(
stable,
base,
current,
flags,
schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations,
); err == nil || !strings.Contains(err.Error(), "changed a non-migration field") {
t.Fatalf("drifted predecessor alias error=%v", err)
}
}
func TestCrossPlatformCoverageSchemaCommandMigrationLineageDefensiveEdges(t *testing.T) {
stable, base, current := schemaCommandLineageContracts()
flags := schemaCommandLineageFlagManifest().Migrations
commands := schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations
flagExtraction := schemaCommandMigrationAuthorizations()[1]
if _, err := stageSchemaCommandMigrationPredecessors(stable, base, current, flags, []interfacesnapshot.CommandMigration{flagExtraction}); err != nil {
t.Fatalf("non-move command migration should be ignored: %v", err)
}
missingTool := cloneContract(stable)
product := missingTool.Products["chat"]
delete(product.Tools, "chat.list_topic_replies")
missingTool.Products["chat"] = product
if _, err := stageSchemaCommandMigrationPredecessors(missingTool, base, current, flags, commands); err != nil {
t.Fatalf("missing historical source should remain for the ordinary checker: %v", err)
}
if _, err := stageSchemaCommandMigrationPredecessors(current, current, current, flags, schemaCommandLineageManifest(interfacesnapshot.CommandMigrationConsumed).Migrations); err != nil {
t.Fatalf("already-after historical source should be a no-op: %v", err)
}
wrongPath := cloneContract(stable)
mutateSchemaCommandMigrationTool(&wrongPath, "chat.list_topic_replies", func(tool *toolSchema) {
tool.PrimaryCLIPath = "chat unrelated"
})
if _, err := stageSchemaCommandMigrationPredecessors(wrongPath, base, current, flags, commands); err != nil {
t.Fatalf("wrong historical path should remain for the command normalizer: %v", err)
}
bothNames := cloneContract(stable)
mutateSchemaCommandMigrationTool(&bothNames, "chat.list_topic_replies", func(tool *toolSchema) {
tool.Parameters["conversation-id"] = tool.Parameters["group"]
})
if _, err := stageSchemaCommandMigrationPredecessors(bothNames, base, current, flags, commands); err == nil ||
!strings.Contains(err.Error(), "publishes both predecessor") {
t.Fatalf("ambiguous predecessor error=%v", err)
}
duplicatePath := cloneContract(stable)
product = duplicatePath.Products["chat"]
product.Tools["chat.duplicate"] = product.Tools["chat.list_topic_replies"]
duplicatePath.Products["chat"] = product
if _, err := stageSchemaCommandMigrationPredecessors(duplicatePath, base, current, flags, commands); err == nil ||
!strings.Contains(err.Error(), "requires one exact historical Schema tool") {
t.Fatalf("duplicate primary path error=%v", err)
}
forkStable, forkBase, forkCurrent := schemaCommandLineageContracts()
mutateSchemaCommandMigrationTool(&forkBase, "chat.list_topic_replies", func(tool *toolSchema) {
tool.Parameters["other-mid"] = tool.Parameters["conversation-id"]
})
mutateSchemaCommandMigrationTool(&forkCurrent, "chat.list_topic_replies", func(tool *toolSchema) {
tool.Parameters["other-final"] = tool.Parameters["open-topic-id"]
})
forkFlags := append([]interfacesnapshot.FlagMigration(nil), flags...)
forkFlag := flags[0]
forkFlag.Canonical.Name = "other-mid"
forkFlags = append(forkFlags, forkFlag)
forkCommands := schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations
forkCommands[0].Schema.Parameters = append(forkCommands[0].Schema.Parameters,
interfacesnapshot.CommandParameterMigration{From: "other-mid", To: "other-final"})
if _, err := stageSchemaCommandMigrationPredecessors(forkStable, forkBase, forkCurrent, forkFlags, forkCommands); err == nil ||
!strings.Contains(err.Error(), "forks Schema predecessor") {
t.Fatalf("forked predecessor error=%v", err)
}
cycleStable, cycleBase, _ := schemaCommandLineageContracts()
mutateSchemaCommandMigrationTool(&cycleStable, "chat.list_topic_replies", func(tool *toolSchema) {
tool.Parameters["id"] = tool.Parameters["group"]
})
mutateSchemaCommandMigrationTool(&cycleBase, "chat.list_topic_replies", func(tool *toolSchema) {
tool.Parameters["other-mid"] = tool.Parameters["conversation-id"]
})
cycleFlags := append([]interfacesnapshot.FlagMigration(nil), flags...)
cycleFlag := flags[0]
cycleFlag.Legacy.Name = "id"
cycleFlag.Canonical.Name = "other-mid"
cycleFlags = append(cycleFlags, cycleFlag)
cycleCommands := schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations
cycleCommands[0].Schema.Parameters[0].To = "id"
cycleCommands[0].Schema.Parameters = append(cycleCommands[0].Schema.Parameters,
interfacesnapshot.CommandParameterMigration{From: "other-mid", To: "other-final"})
missingCurrent := schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{}}
if _, err := stageSchemaCommandMigrationPredecessors(cycleStable, cycleBase, missingCurrent, cycleFlags, cycleCommands); err == nil ||
!strings.Contains(err.Error(), "lineage cycle") {
t.Fatalf("cross-lineage cycle error=%v", err)
}
}
func schemaCommandMigrationContract(after bool) schemaContract {
id := parameterSchema{Type: `"string"`, Property: "resourceId", Required: true, CLIRequired: true}
keep := parameterSchema{Type: `"string"`, Property: "keep"}
@@ -722,6 +1294,155 @@ func schemaCommandMigrationContract(after bool) schemaContract {
return schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{"chat": {Tools: tools}}}
}
func schemaCommandLineageContracts() (schemaContract, schemaContract, schemaContract) {
conversation := parameterSchema{
Type: `"string"`,
Property: "openconversationId",
InterfaceType: "string",
Required: true,
CLIRequired: true,
}
topic := parameterSchema{
Type: `"string"`,
Property: "openConversationThreadId",
InterfaceType: "string",
Required: true,
CLIRequired: true,
}
tool := toolSchema{
PrimaryCLIPath: "chat message list-topic-replies",
InterfaceMode: "mcp",
InterfaceRef: `{"product_id":"im","rpc_name":"list_topic_replies"}`,
Availability: "available",
Parameters: map[string]parameterSchema{
"group": conversation,
"topic-id": topic,
},
Constraints: `{"require_together":[["group","topic-id"]]}`,
Effect: "read",
Risk: "low",
Confirmation: "not_required",
Idempotency: "idempotent",
}
stable := schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{
"chat": {Tools: map[string]toolSchema{"chat.list_topic_replies": tool}},
}}
base := cloneContract(stable)
mutateSchemaCommandMigrationTool(&base, "chat.list_topic_replies", func(tool *toolSchema) {
delete(tool.Parameters, "group")
tool.Parameters["conversation-id"] = conversation
tool.Constraints = `{"require_together":[["conversation-id","topic-id"]]}`
})
current := cloneContract(base)
mutateSchemaCommandMigrationTool(&current, "chat.list_topic_replies", func(tool *toolSchema) {
delete(tool.Parameters, "conversation-id")
delete(tool.Parameters, "topic-id")
tool.Parameters["open-topic-id"] = conversation
tool.Parameters["open-conv-thread-id"] = topic
tool.PrimaryCLIPath = "chat topic list-replies"
tool.Constraints = `{"require_together":[["open-conv-thread-id","open-topic-id"]]}`
})
return stable, base, current
}
func schemaCommandLineageFlagManifest() interfacesnapshot.FlagMigrationManifest {
beforeCanonical := interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Hidden: true, Scope: "local"}
afterCanonical := interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"}
return interfacesnapshot.FlagMigrationManifest{
Version: interfacesnapshot.FlagMigrationManifestVersion,
Migrations: []interfacesnapshot.FlagMigration{{
Command: "dws chat message list-topic-replies",
Legacy: interfacesnapshot.FlagMigrationSide{
Name: "group",
Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"},
After: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Hidden: true, Scope: "local", AliasOf: "conversation-id"},
},
Canonical: interfacesnapshot.FlagMigrationSide{
Name: "conversation-id",
Before: beforeCanonical,
After: afterCanonical,
},
State: interfacesnapshot.FlagMigrationConsumed,
Reason: "preserve the reviewed group to conversation-id lineage",
}},
}
}
func schemaCommandLineageManifest(state string) interfacesnapshot.CommandMigrationManifest {
return interfacesnapshot.CommandMigrationManifest{
Version: interfacesnapshot.CommandMigrationManifestVersion,
Migrations: []interfacesnapshot.CommandMigration{{
Kind: interfacesnapshot.CommandMigrationMove,
Legacy: interfacesnapshot.CommandMigrationSide{
Command: "dws chat message list-topic-replies",
Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true, Hidden: true},
},
Replacement: interfacesnapshot.CommandMigrationSide{
Command: "dws chat topic list-replies",
Before: interfacesnapshot.CommandMigrationState{},
After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
},
Schema: interfacesnapshot.CommandMigrationSchema{
ProductID: "chat",
SourceToolID: "chat.list_topic_replies",
ReplacementToolID: "chat.list_topic_replies",
Parameters: []interfacesnapshot.CommandParameterMigration{
{From: "conversation-id", To: "open-topic-id"},
{From: "topic-id", To: "open-conv-thread-id"},
},
},
State: state,
Reason: "move topic reply listing while retaining the legacy command",
}},
}
}
func schemaCommandLineageSnapshot(flagAfter, commandAfter bool) interfacesnapshot.Snapshot {
legacyFlags := []interfacesnapshot.Flag{
{Name: "conversation-id", Type: "string", Hidden: true},
{Name: "group", Type: "string", Required: true},
{Name: "topic-id", Type: "string", Required: true},
}
if flagAfter {
legacyFlags[0].Hidden = false
legacyFlags[0].Required = true
legacyFlags[1].Required = false
legacyFlags[1].Hidden = true
legacyFlags[1].AliasOf = "conversation-id"
}
commands := []interfacesnapshot.Command{
{Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
{
Path: "dws chat message list-topic-replies",
Runnable: true,
Aliases: []string{},
LocalFlags: legacyFlags,
InheritedFlags: []interfacesnapshot.Flag{},
},
}
if commandAfter {
commands[1].Hidden = true
commands = append(commands, interfacesnapshot.Command{
Path: "dws chat topic list-replies",
Runnable: true,
Aliases: []string{},
LocalFlags: []interfacesnapshot.Flag{},
InheritedFlags: []interfacesnapshot.Flag{},
})
}
return interfacesnapshot.Snapshot{
SchemaVersion: interfacesnapshot.SchemaVersion,
Rules: interfacesnapshot.Rules{
ExcludedCommandSubtrees: []string{"dws __complete", "dws __completeNoDesc", "dws completion", "dws help"},
ExcludedFlags: []string{"help"},
},
Commands: commands,
}
}
func schemaCommandMigrationAuthorizations() []interfacesnapshot.CommandMigration {
return []interfacesnapshot.CommandMigration{
{
+474 -17
View File
@@ -87,6 +87,7 @@ func run(args []string, stdout, stderr io.Writer) int {
var normalizePath, checkPath, mergePath, currentPath string
var approvedFlagMigrationsPath, candidateFlagMigrationsPath string
var approvedCommandMigrationsPath, candidateCommandMigrationsPath string
var migrationBaseSchemaPath string
var migrationCurrentSnapshotPath, migrationBaseSnapshotPath, migrationStableSnapshotPath string
flags := flag.NewFlagSet("schema-compat", flag.ContinueOnError)
flags.SetOutput(stderr)
@@ -98,6 +99,7 @@ func run(args []string, stdout, stderr io.Writer) int {
flags.StringVar(&candidateFlagMigrationsPath, "candidate-flag-migrations", "", "detached candidate flag migration manifest")
flags.StringVar(&approvedCommandMigrationsPath, "approved-command-migrations", "", "base-owned approved command migration manifest")
flags.StringVar(&candidateCommandMigrationsPath, "candidate-command-migrations", "", "detached candidate command migration manifest")
flags.StringVar(&migrationBaseSchemaPath, "migration-base-schema", "", "normalized merge-base Schema contract used to verify cross-migration lineage")
flags.StringVar(&migrationCurrentSnapshotPath, "migration-current-snapshot", "", "current interface snapshot used for migration authorization")
flags.StringVar(&migrationBaseSnapshotPath, "migration-base-snapshot", "", "merge-base interface snapshot used for migration authorization")
flags.StringVar(&migrationStableSnapshotPath, "migration-stable-snapshot", "", "stable interface snapshot used for migration authorization")
@@ -149,6 +151,14 @@ func run(args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "Schema migration authorization is only valid with --check")
return 2
}
if flagMigrationPair && commandMigrationPair && migrationBaseSchemaPath == "" {
fmt.Fprintln(stderr, "combined Schema flag and command migration authorization requires --migration-base-schema")
return 2
}
if migrationBaseSchemaPath != "" && (!flagMigrationPair || !commandMigrationPair) {
fmt.Fprintln(stderr, "--migration-base-schema requires both flag and command migration manifest pairs")
return 2
}
if normalizePath != "" {
currentPath = normalizePath
@@ -175,8 +185,9 @@ func run(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "read schema baseline: %v\n", err)
return 2
}
var flagMigrations []interfacesnapshot.FlagMigration
if flagMigrationPair {
migrations, err := authorizeSchemaFlagMigrations(
flagMigrations, err = authorizeSchemaFlagMigrations(
approvedFlagMigrationsPath,
candidateFlagMigrationsPath,
migrationCurrentSnapshotPath,
@@ -187,14 +198,9 @@ func run(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "authorize Schema flag migrations: %v\n", err)
return 2
}
baseline, err = normalizeSchemaFlagMigrations(baseline, current, migrations)
if err != nil {
fmt.Fprintf(stderr, "normalize approved Schema flag migrations: %v\n", err)
return 2
}
}
if commandMigrationPair {
migrations, err := authorizeSchemaCommandMigrations(
commandMigrations, err := authorizeSchemaCommandMigrations(
approvedCommandMigrationsPath,
candidateCommandMigrationsPath,
migrationCurrentSnapshotPath,
@@ -205,11 +211,32 @@ func run(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "authorize Schema command migrations: %v\n", err)
return 2
}
baseline, err = normalizeSchemaCommandMigrations(baseline, current, migrations)
if flagMigrationPair {
migrationBase, readErr := readContract(migrationBaseSchemaPath)
if readErr != nil {
fmt.Fprintf(stderr, "read migration merge-base Schema contract: %v\n", readErr)
return 2
}
baseline, err = normalizeSchemaCommandMigrationLineage(
baseline,
migrationBase,
current,
flagMigrations,
commandMigrations,
)
} else {
baseline, err = normalizeSchemaCommandMigrations(baseline, current, commandMigrations)
}
if err != nil {
fmt.Fprintf(stderr, "normalize approved Schema command migrations: %v\n", err)
return 2
}
} else if flagMigrationPair {
baseline, err = normalizeSchemaFlagMigrations(baseline, current, flagMigrations)
if err != nil {
fmt.Fprintf(stderr, "normalize approved Schema flag migrations: %v\n", err)
return 2
}
}
failures := checkCompatibility(baseline, current)
if len(failures) > 0 {
@@ -1234,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,
@@ -1246,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 {
@@ -1367,6 +1402,393 @@ 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
// parameter, but only after the merge-base Schema or a retained consumed
// command receipt proves the corresponding next hop.
func normalizeSchemaCommandMigrationLineage(
historical schemaContract,
mergeBase schemaContract,
current schemaContract,
flagMigrations []interfacesnapshot.FlagMigration,
commandMigrations []interfacesnapshot.CommandMigration,
) (schemaContract, error) {
normalized, err := normalizeSchemaFlagMigrations(historical, current, flagMigrations)
if err != nil {
return schemaContract{}, fmt.Errorf("normalize ordinary flag migrations: %w", err)
}
staged, err := stageSchemaCommandMigrationPredecessors(
normalized,
mergeBase,
current,
flagMigrations,
commandMigrations,
)
if err != nil {
return schemaContract{}, err
}
return normalizeSchemaCommandMigrations(staged, current, commandMigrations)
}
// stageSchemaCommandMigrationPredecessors replays only the name-changing edge
// recorded by a base-owned consumed flag migration. It leaves interface,
// safety, dry-run, and positional facts untouched so the ordinary checker
// remains authoritative for every non-name change.
func stageSchemaCommandMigrationPredecessors(
historical schemaContract,
mergeBase schemaContract,
current schemaContract,
flagMigrations []interfacesnapshot.FlagMigration,
commandMigrations []interfacesnapshot.CommandMigration,
) (schemaContract, error) {
staged := cloneContract(historical)
moveBySource := map[schemaToolRef]string{}
for _, migration := range commandMigrations {
if migration.Kind != interfacesnapshot.CommandMigrationMove {
continue
}
ref := schemaToolRef{productID: migration.Schema.ProductID, toolID: migration.Schema.SourceToolID}
if previous, exists := moveBySource[ref]; exists {
return schemaContract{}, fmt.Errorf(
"approved command migrations fork Schema source tool %q between %q and %q",
migration.Schema.SourceToolID,
previous,
migration.Legacy.Command,
)
}
moveBySource[ref] = migration.Legacy.Command
}
for _, migration := range commandMigrations {
if migration.Kind != interfacesnapshot.CommandMigrationMove {
continue
}
oldProduct, productExists := historical.Products[migration.Schema.ProductID]
oldTool, toolExists := oldProduct.Tools[migration.Schema.SourceToolID]
if !productExists || !toolExists {
continue
}
legacyPath := strings.TrimPrefix(migration.Legacy.Command, "dws ")
replacementPath := strings.TrimPrefix(migration.Replacement.Command, "dws ")
if oldTool.PrimaryCLIPath == replacementPath {
continue
}
if oldTool.PrimaryCLIPath != legacyPath {
// Preserve the existing command normalizer's deterministic path error.
continue
}
baseProduct, baseProductExists := mergeBase.Products[migration.Schema.ProductID]
baseTool, baseToolExists := baseProduct.Tools[migration.Schema.SourceToolID]
currentProduct, currentProductExists := current.Products[migration.Schema.ProductID]
currentTool, currentToolExists := currentProduct.Tools[migration.Schema.SourceToolID]
firstHopRenames := map[string]string{}
composedRenames := map[string]string{}
lineageApplied := false
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
}
if _, legacyAlsoPublished := oldTool.Parameters[flagMigration.Legacy.Name]; legacyAlsoPublished {
return schemaContract{}, fmt.Errorf(
"approved command migration %q historical Schema tool publishes both predecessor %q and intermediate %q",
migration.Legacy.Command,
flagMigration.Legacy.Name,
parameter.From,
)
}
}
composedRenames[parameter.From] = parameter.To
continue
}
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
}
if flagMigration.State != interfacesnapshot.FlagMigrationConsumed {
return schemaContract{}, fmt.Errorf(
"approved command migration %q Schema predecessor %q -> %q requires a consumed flag migration receipt",
migration.Legacy.Command,
flagMigration.Legacy.Name,
parameter.From,
)
}
if _, published := oldTool.Parameters[flagMigration.Legacy.Name]; published {
predecessors = append(predecessors, flagMigration)
}
}
if len(predecessors) == 0 {
continue
}
lineageApplied = true
if !baseProductExists || !baseToolExists {
return schemaContract{}, fmt.Errorf(
"approved command migration %q merge-base Schema lacks source tool %q",
migration.Legacy.Command,
migration.Schema.SourceToolID,
)
}
var stagedParameter parameterSchema
switch migration.State {
case interfacesnapshot.CommandMigrationPending:
if baseTool.PrimaryCLIPath != legacyPath {
return schemaContract{}, fmt.Errorf(
"pending command migration %q merge-base Schema source tool has primary_cli_path %q",
migration.Legacy.Command,
baseTool.PrimaryCLIPath,
)
}
intermediate, exists := baseTool.Parameters[parameter.From]
if !exists {
return schemaContract{}, fmt.Errorf(
"pending command migration %q merge-base Schema lacks intermediate parameter %q",
migration.Legacy.Command,
parameter.From,
)
}
if _, exists := baseTool.Parameters[parameter.To]; exists {
return schemaContract{}, fmt.Errorf(
"pending command migration %q merge-base Schema already publishes final parameter %q",
migration.Legacy.Command,
parameter.To,
)
}
stagedParameter = intermediate
case interfacesnapshot.CommandMigrationConsumed:
if baseTool.PrimaryCLIPath != replacementPath {
return schemaContract{}, fmt.Errorf(
"consumed command migration %q merge-base Schema source tool has primary_cli_path %q",
migration.Legacy.Command,
baseTool.PrimaryCLIPath,
)
}
finalParameter, exists := baseTool.Parameters[parameter.To]
if !exists {
return schemaContract{}, fmt.Errorf(
"consumed command migration %q merge-base Schema lacks final parameter %q",
migration.Legacy.Command,
parameter.To,
)
}
if _, exists := baseTool.Parameters[parameter.From]; exists {
return schemaContract{}, fmt.Errorf(
"consumed command migration %q merge-base Schema still publishes intermediate parameter %q",
migration.Legacy.Command,
parameter.From,
)
}
stagedParameter = oldTool.Parameters[predecessors[0].Legacy.Name]
for _, predecessor := range predecessors {
oldParameter := oldTool.Parameters[predecessor.Legacy.Name]
composite := interfacesnapshot.CommandParameterMigration{From: predecessor.Legacy.Name, To: parameter.To}
if err := validateEquivalentCommandSchemaParameter(migration, composite, oldParameter, finalParameter); err != nil {
return schemaContract{}, err
}
}
default:
return schemaContract{}, fmt.Errorf(
"approved command migration %q has unsupported lineage state %q",
migration.Legacy.Command,
migration.State,
)
}
stagedProduct := staged.Products[migration.Schema.ProductID]
stagedTool := stagedProduct.Tools[migration.Schema.SourceToolID]
for _, predecessor := range predecessors {
if predecessor.Legacy.Name == parameter.To {
return schemaContract{}, fmt.Errorf(
"approved command migration %q forms a Schema parameter lineage cycle through %q",
migration.Legacy.Command,
parameter.To,
)
}
if existing, claimed := firstHopRenames[predecessor.Legacy.Name]; claimed && existing != parameter.From {
return schemaContract{}, fmt.Errorf(
"approved command migration %q forks Schema predecessor %q to both %q and %q",
migration.Legacy.Command,
predecessor.Legacy.Name,
existing,
parameter.From,
)
}
oldParameter := oldTool.Parameters[predecessor.Legacy.Name]
if migration.State == interfacesnapshot.CommandMigrationPending {
if err := validateRenamedSchemaParameter(predecessor, oldParameter, stagedParameter); err != nil {
return schemaContract{}, err
}
}
if _, exists := baseTool.Parameters[predecessor.Legacy.Name]; exists {
return schemaContract{}, fmt.Errorf(
"approved command migration %q merge-base Schema still publishes predecessor parameter %q",
migration.Legacy.Command,
predecessor.Legacy.Name,
)
}
if currentProductExists && currentToolExists {
if _, exists := currentTool.Parameters[predecessor.Legacy.Name]; exists {
return schemaContract{}, fmt.Errorf(
"approved command migration %q current Schema still publishes predecessor parameter %q",
migration.Legacy.Command,
predecessor.Legacy.Name,
)
}
}
delete(stagedTool.Parameters, predecessor.Legacy.Name)
firstHopRenames[predecessor.Legacy.Name] = parameter.From
composedRenames[predecessor.Legacy.Name] = parameter.To
}
stagedTool.Parameters[parameter.From] = stagedParameter
stagedProduct.Tools[migration.Schema.SourceToolID] = stagedTool
staged.Products[migration.Schema.ProductID] = stagedProduct
}
if !lineageApplied {
continue
}
matches := schemaToolsByPrimaryPath(historical, legacyPath)
wantRef := schemaToolRef{productID: migration.Schema.ProductID, toolID: migration.Schema.SourceToolID}
if len(matches) != 1 || matches[0] != wantRef {
return schemaContract{}, fmt.Errorf(
"approved command migration %q predecessor lineage requires one exact historical Schema tool, got %#v",
migration.Legacy.Command,
matches,
)
}
if currentProductExists && currentToolExists {
if source, found := migratedConstraintSourceParameter(currentTool.Constraints, firstHopRenames); found {
return schemaContract{}, fmt.Errorf(
"approved command migration %q current Schema constraints still reference predecessor parameter %q",
migration.Legacy.Command,
source,
)
}
}
for _, finalTarget := range composedRenames {
if _, cycle := firstHopRenames[finalTarget]; cycle {
return schemaContract{}, fmt.Errorf(
"approved command migration %q forms a Schema parameter lineage cycle through %q",
migration.Legacy.Command,
finalTarget,
)
}
}
firstHopConstraints, firstHopOK := canonicalizeMigratedConstraints(oldTool.Constraints, firstHopRenames)
if !firstHopOK {
return schemaContract{}, fmt.Errorf(
"approved command migration %q historical Schema constraints are not canonicalizable",
migration.Legacy.Command,
)
}
baseConstraints, baseOK := canonicalizeMigratedConstraints(baseTool.Constraints, nil)
if !baseOK {
return schemaContract{}, fmt.Errorf(
"approved command migration %q merge-base Schema constraints are not canonicalizable",
migration.Legacy.Command,
)
}
stagedProduct := staged.Products[migration.Schema.ProductID]
stagedTool := stagedProduct.Tools[migration.Schema.SourceToolID]
switch migration.State {
case interfacesnapshot.CommandMigrationPending:
if firstHopConstraints != baseConstraints {
return schemaContract{}, fmt.Errorf(
"pending command migration %q predecessor lineage changed merge-base Schema constraints",
migration.Legacy.Command,
)
}
stagedTool.Constraints = baseTool.Constraints
case interfacesnapshot.CommandMigrationConsumed:
composedConstraints, composedOK := canonicalizeMigratedConstraints(oldTool.Constraints, composedRenames)
if !composedOK || composedConstraints != baseConstraints {
return schemaContract{}, fmt.Errorf(
"consumed command migration %q predecessor lineage changed merge-base Schema constraints",
migration.Legacy.Command,
)
}
stagedTool.Constraints = firstHopConstraints
}
stagedProduct.Tools[migration.Schema.SourceToolID] = stagedTool
staged.Products[migration.Schema.ProductID] = stagedProduct
}
return staged, nil
}
// normalizeSchemaCommandMigrations projects only the Schema consequences that
// are coupled to an already-authorized CLI command migration. It rewrites a
// cloned historical contract; the ordinary checker still rejects every field
@@ -1478,12 +1900,30 @@ func normalizeSchemaCommandMigrations(
)
}
}
if oldTool.Constraints != newSource.Constraints {
oldConstraints, oldOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames)
newConstraints, newOK := canonicalizeMigratedConstraints(newSource.Constraints, nil)
if oldOK && newOK && oldConstraints == newConstraints {
normalizedTool.Constraints = newSource.Constraints
}
oldConstraints, oldOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames)
if !oldOK {
return schemaContract{}, fmt.Errorf(
"approved command migration %q historical Schema constraints are not canonicalizable",
migration.Legacy.Command,
)
}
newConstraints, newOK := canonicalizeMigratedConstraints(newSource.Constraints, nil)
if !newOK {
return schemaContract{}, fmt.Errorf(
"approved command migration %q current Schema constraints are not canonicalizable",
migration.Legacy.Command,
)
}
if source, found := migratedConstraintSourceParameter(newSource.Constraints, renames); found {
return schemaContract{}, fmt.Errorf(
"approved command migration %q current constraints still reference legacy Schema constraint parameter %q",
migration.Legacy.Command,
source,
)
}
normalizedTool.Constraints = oldConstraints
if oldConstraints == newConstraints {
normalizedTool.Constraints = newSource.Constraints
}
normalizedTool.PrimaryCLIPath = replacementPath
@@ -1938,6 +2378,23 @@ func canonicalizeMigratedConstraints(raw string, renames map[string]string) (str
return string(encoded), err == nil
}
func migratedConstraintSourceParameter(raw string, renames map[string]string) (string, bool) {
groups, ok := parseMigrationConstraintsStrict(raw)
if !ok {
return "", false
}
for _, kind := range []string{"mutually_exclusive", "require_one_of", "require_together"} {
for _, group := range groups[kind] {
for _, member := range group {
if _, renamed := renames[member]; renamed {
return member, true
}
}
}
}
return "", false
}
func parseMigrationConstraintsStrict(raw string) (map[string][][]string, bool) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
+143 -4
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
@@ -1739,11 +1877,12 @@ func TestCrossPlatformCoverageSchemaConsumedReceiptIsNoOpForAfterBaseline(t *tes
t.Fatalf("consumed receipt did not preserve compatibility: %v", failures)
}
// Once stable also reaches after, retaining the consumed receipt is stale;
// deleting it yields no authorization and the already-after Schema passes.
// Once stable also reaches after, retaining or deleting the consumed receipt
// yields no authorization and the already-after Schema passes.
writeInterfaceSnapshotFile(t, stableSnapshotPath, schemaFlagMigrationSnapshot(true))
if _, err := authorizeSchemaFlagMigrations(approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath); err == nil || !strings.Contains(err.Error(), "stale after all references reached the after state") {
t.Fatalf("stale consumed receipt error = %v", err)
migrations, err = authorizeSchemaFlagMigrations(approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath)
if err != nil || len(migrations) != 0 {
t.Fatalf("retained consumed receipt authorizations = %#v, %v", migrations, err)
}
emptyManifest := interfacesnapshot.FlagMigrationManifest{
Version: interfacesnapshot.FlagMigrationManifestVersion,
@@ -173,8 +173,9 @@ func main() {
currentSnapshot := flag.String("migration-current-snapshot", "", "candidate interface snapshot")
baseSnapshot := flag.String("migration-base-snapshot", "", "base interface snapshot")
stableSnapshot := flag.String("migration-stable-snapshot", "", "stable interface snapshot")
_ = flag.String("approved-command-migrations", "", "base command ledger")
_ = flag.String("candidate-command-migrations", "", "candidate command ledger")
migrationBaseSchema := flag.String("migration-base-schema", "", "merge-base Schema contract")
approvedCommand := flag.String("approved-command-migrations", "", "base command ledger")
candidateCommand := flag.String("candidate-command-migrations", "", "candidate command ledger")
flag.Parse()
if *normalize != "" {
@@ -214,6 +215,29 @@ func main() {
os.Exit(2)
}
}
commandPair := *approvedCommand != "" || *candidateCommand != ""
if commandPair && (*approvedCommand == "" || *candidateCommand == "") {
fmt.Fprintln(os.Stderr, "stable Schema guard received a partial command migration pair")
os.Exit(2)
}
if commandPair {
data, err := os.ReadFile(*migrationBaseSchema)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if !strings.Contains(string(data), "BASE_AUTHORITY") {
fmt.Fprintf(os.Stderr, "migration base Schema is not base-owned: %s\n", data)
os.Exit(2)
}
fmt.Fprintln(os.Stdout, "BASE_SCHEMA_LINEAGE=BASE_AUTHORITY")
} else {
if *migrationBaseSchema != "" {
fmt.Fprintln(os.Stderr, "flag-only Schema check received command migration lineage")
os.Exit(2)
}
fmt.Fprintln(os.Stdout, "FLAG_ONLY_SCHEMA_LINEAGE_OMITTED")
}
currentData, err := os.ReadFile(*current)
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -418,9 +442,16 @@ func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testi
authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED",
},
{
name: "governed checker protects stable Schema contract",
name: "governed checker protects stable Schema contract",
baseGovernance: "complete",
commandGovernance: "complete",
checkerMode: "stable-schema-guard",
authorityMarker: "STABLE_SCHEMA_CONTRACT_ENFORCED",
},
{
name: "lineage capable checker omits command lineage for flag-only governance",
baseGovernance: "complete",
checkerMode: "stable-schema-guard",
checkerMode: "flag-only-schema-guard",
authorityMarker: "STABLE_SCHEMA_CONTRACT_ENFORCED",
},
{
@@ -611,7 +642,7 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) {
switch test.baseGovernance {
case "complete":
checkerSource := governedSchemaCheckerSource
if test.checkerMode == "stable-schema-guard" {
if test.checkerMode == "stable-schema-guard" || test.checkerMode == "flag-only-schema-guard" {
checkerSource = stableSchemaGuardCheckerSource
}
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), checkerSource, 0o644)
@@ -794,6 +825,12 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) {
if test.baseGovernance == "complete" && strings.Count(got, "BASE_INTERFACE_HELPER_GENERATE=BASE") != 3 {
t.Fatalf("governed Schema check did not generate three base-owned interface snapshots; output:\n%s", got)
}
if test.checkerMode == "stable-schema-guard" && strings.Count(got, "BASE_SCHEMA_LINEAGE=BASE_AUTHORITY") != 2 {
t.Fatalf("governed Schema check did not pass the base-owned Schema lineage to both historical checks; output:\n%s", got)
}
if test.checkerMode == "flag-only-schema-guard" && strings.Count(got, "FLAG_ONLY_SCHEMA_LINEAGE_OMITTED") != 2 {
t.Fatalf("flag-only Schema check received command migration lineage; output:\n%s", got)
}
wrongStable := exec.Command(
"sh",
+122
View File
@@ -0,0 +1,122 @@
#!/bin/sh
set -eu
# Release compatibility is one decision seam over the sealed source tree.
# Trusted release tooling may orchestrate this script, but the explicitly
# selected repository remains the authority for both CLI and Schema checks.
REPO_ROOT=""
BASE_REF=""
STABLE_REF=""
CANDIDATE_REF="HEAD"
usage() {
printf '%s\n' \
"usage: $0 --repo-root <path> --base-ref <ref> --stable-ref <ref> [--candidate-ref <ref>]" >&2
}
while [ "$#" -gt 0 ]; do
case "$1" in
--repo-root)
[ "$#" -ge 2 ] || { usage; exit 2; }
REPO_ROOT="$2"
shift 2
;;
--base-ref)
[ "$#" -ge 2 ] || { usage; exit 2; }
BASE_REF="$2"
shift 2
;;
--stable-ref)
[ "$#" -ge 2 ] || { usage; exit 2; }
STABLE_REF="$2"
shift 2
;;
--candidate-ref)
[ "$#" -ge 2 ] || { usage; exit 2; }
CANDIDATE_REF="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
printf 'error: unknown argument: %s\n' "$1" >&2
usage
exit 2
;;
esac
done
[ -n "$REPO_ROOT" ] && [ -n "$BASE_REF" ] && [ -n "$STABLE_REF" ] || {
usage
exit 2
}
REPO_ROOT="$(CDPATH= cd -- "$REPO_ROOT" && pwd -P)" || {
printf 'error: release source root is not available: %s\n' "$REPO_ROOT" >&2
exit 2
}
GIT_ROOT="$(git -C "$REPO_ROOT" rev-parse --show-toplevel 2>/dev/null)" || {
printf 'error: release source root is not a Git worktree: %s\n' "$REPO_ROOT" >&2
exit 2
}
[ "$GIT_ROOT" = "$REPO_ROOT" ] || {
printf 'error: release source root must be the Git worktree root: %s\n' "$REPO_ROOT" >&2
exit 2
}
CLI_CHECK="$REPO_ROOT/scripts/policy/check-authoritative-interface-baselines.sh"
SCHEMA_CHECK="$REPO_ROOT/scripts/policy/check-authoritative-schema-compatibility.sh"
for check in "$CLI_CHECK" "$SCHEMA_CHECK"; do
[ -x "$check" ] || {
printf 'error: authoritative release compatibility checker is unavailable: %s\n' "$check" >&2
exit 2
}
done
resolve_commit() {
label="$1"
ref="$2"
commit="$(git -C "$REPO_ROOT" rev-parse --verify "${ref}^{commit}" 2>/dev/null)" || {
printf 'error: release %s ref is not available in sealed source: %s\n' "$label" "$ref" >&2
return 2
}
[ -n "$commit" ] || {
printf 'error: release %s ref resolved to an empty commit: %s\n' "$label" "$ref" >&2
return 2
}
printf '%s\n' "$commit"
}
# Freeze the complete comparison tuple before either checker runs. Otherwise a
# concurrent ref update (or the first checker itself) could make Schema inspect
# different Git objects from the CLI gate even when both receive the same names.
BASE_COMMIT="$(resolve_commit base "$BASE_REF")"
STABLE_COMMIT="$(resolve_commit stable "$STABLE_REF")"
CANDIDATE_COMMIT="$(resolve_commit candidate "$CANDIDATE_REF")"
printf '==> Checking authoritative CLI compatibility\n'
if "$CLI_CHECK" \
--base-ref "$BASE_COMMIT" \
--stable-ref "$STABLE_COMMIT" \
--candidate-ref "$CANDIDATE_COMMIT"; then
:
else
status=$?
printf 'error: authoritative CLI compatibility failed\n' >&2
exit "$status"
fi
printf '==> Checking authoritative Schema compatibility\n'
if "$SCHEMA_CHECK" \
--base-ref "$BASE_COMMIT" \
--stable-ref "$STABLE_COMMIT" \
--candidate-ref "$CANDIDATE_COMMIT"; then
:
else
status=$?
printf 'error: authoritative Schema compatibility failed\n' >&2
exit "$status"
fi
+40 -4
View File
@@ -64,17 +64,49 @@ release_date="${DWS_RELEASE_DATE:-$(TZ=Asia/Shanghai date +%F)}"
section="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-section.XXXXXX")"
output="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-output.XXXXXX")"
fragments="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-fragments.XXXXXX")"
cleanup() { rm -f "$section" "$output" "$fragments"; }
fragment_entries="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-fragment-entries.XXXXXX")"
cleanup() { rm -f "$section" "$output" "$fragments" "$fragment_entries"; }
trap cleanup EXIT HUP INT TERM
render_fragments=false
if [ "$CHANNEL" = "prerelease" ]; then
render_fragments=true
else
if [ -e "$CHANGES_DIR" ] && [ ! -d "$CHANGES_DIR" ]; then
printf 'release fragments path is not a directory: %s\n' "$CHANGES_DIR" >&2
exit 1
fi
if [ -d "$CHANGES_DIR" ]; then
find "$CHANGES_DIR" -mindepth 1 -maxdepth 1 -print | sort >"$fragment_entries"
while IFS= read -r entry; do
base="${entry##*/}"
if [ "$base" = 'README.md' ] && [ -f "$entry" ] && [ ! -L "$entry" ]; then
continue
fi
if [ "$base" = 'released' ] && [ -d "$entry" ] && [ ! -L "$entry" ]; then
continue
fi
render_fragments=true
break
done <"$fragment_entries"
fi
fi
if [ "$render_fragments" = true ]; then
"$SCRIPT_DIR/render-release-fragments.sh" "$CHANGES_DIR" >"$fragments"
fi
{
printf '## [%s] - %s\n\n' "$semver" "$release_date"
if [ "$CHANNEL" = "stable" ]; then
printf 'This release promotes the sealed `%s` contents to stable.\n\n' "$FROM_BETA"
printf '### Changed\n\n'
printf -- '- TODO: summarize the complete user-visible release promoted from `%s`.\n' "$FROM_BETA"
if [ -s "$fragments" ]; then
printf '\n### Changes since `%s`\n\n' "$FROM_BETA"
cat "$fragments"
fi
else
"$SCRIPT_DIR/render-release-fragments.sh" "$CHANGES_DIR" >"$fragments"
cat "$fragments"
fi
} > "$section"
@@ -106,14 +138,18 @@ fi
[ "$inserted" -eq 1 ] || { printf 'CHANGELOG is missing ## [Unreleased]\n' >&2; exit 1; }
cp "$output" "$CHANGELOG"
if [ "$CHANNEL" = "prerelease" ]; then
if [ -s "$fragments" ]; then
archive_dir="$CHANGES_DIR/released/$semver"
mkdir -p "$archive_dir"
find "$CHANGES_DIR" -mindepth 1 -maxdepth 1 -type f -name '*.md' ! -name 'README.md' -exec mv {} "$archive_dir"/ \;
fi
if [ "$CHANNEL" = "stable" ]; then
printf 'Prepared CHANGELOG template for %s. Replace TODO, review, commit, and merge it before release.\n' "$VERSION"
if [ -s "$fragments" ]; then
printf 'Prepared CHANGELOG template and archived post-beta release fragments for %s. Replace TODO, review, commit, and merge the release-seal PR before release.\n' "$VERSION"
else
printf 'Prepared CHANGELOG template for %s. Replace TODO, review, commit, and merge it before release.\n' "$VERSION"
fi
else
printf 'Prepared CHANGELOG and archived release fragments for %s. Review, commit, and merge the release-seal PR before release.\n' "$VERSION"
fi
+11 -7
View File
@@ -19,7 +19,7 @@ usage() {
cat >&2 <<'EOF'
usage: release.sh <prerelease|stable> <version> [options]
Runs the full test, command-compatibility, package, and install preflight.
Runs the full test, authoritative CLI/Schema compatibility, package, and install preflight.
The default is validation only. Official publication is cloud-only.
Options:
@@ -359,10 +359,12 @@ else
make policy
if [ -n "$previous_stable" ]; then
printf '==> Comparing command tree with %s\n' "$previous_stable"
"$ROOT/scripts/policy/check-command-compatibility.sh" \
printf '==> Comparing authoritative CLI and Schema with %s\n' "$previous_stable"
"$SCRIPT_DIR/check-release-compatibility.sh" \
--repo-root "$ROOT" \
--base-ref HEAD \
--stable-ref "$previous_stable"
--stable-ref "$previous_stable" \
--candidate-ref HEAD
fi
printf '==> Building local release artifacts for %s\n' "$VERSION"
@@ -412,11 +414,13 @@ printf '==> Revalidating delivered stable baseline %s\n' "$previous_stable"
require_delivered_previous_stable
if [ "$previous_stable" != "$previous_stable_before_refresh" ]; then
printf '==> Stable authority advanced from %s to %s; rechecking command compatibility\n' \
printf '==> Stable authority advanced from %s to %s; rechecking authoritative CLI and Schema compatibility\n' \
"${previous_stable_before_refresh:-none}" "$previous_stable"
"$ROOT/scripts/policy/check-command-compatibility.sh" \
"$SCRIPT_DIR/check-release-compatibility.sh" \
--repo-root "$ROOT" \
--base-ref HEAD \
--stable-ref "$previous_stable"
--stable-ref "$previous_stable" \
--candidate-ref HEAD
fi
if [ "$PUBLISH" -eq 1 ]; then
+3 -3
View File
@@ -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` |
+49
View File
@@ -1212,6 +1212,51 @@ Flags:
--msg-ids string 消息 ID 列表,逗号分隔 (必填)
```
### emotion (个人收藏表情)
#### 列出个人收藏表情
```
Usage:
dws chat emotion list [flags]
Example:
dws chat emotion list --format json
```
当前用户身份由 MCP server 注入,无业务参数。
#### 发送个人收藏表情
```
Usage:
dws chat emotion send [flags]
Example:
dws chat emotion send --media-id <mediaId> --group <openConversationId>
dws chat emotion send --media-id <mediaId> --emotion-id <emotionId> --user <userId>
dws chat emotion send --media-id <mediaId> --open-dingtalk-id <openDingTalkId> --uuid <idempotencyKey>
Flags:
--media-id string 表情媒体 ID (必填)
--emotion-id string 表情 ID (可选)
--conversation-id string 群聊 openConversationId(与 --group 等价)
--group string 群聊 openConversationId
--user string 单聊接收人 userId,CLI 会解析为 openDingTalkId
--open-dingtalk-id string 单聊接收人 openDingTalkId
--uuid string 幂等键
--idempotency-key string 幂等键(--uuid 等价)
```
目标参数 `--conversation-id/--group`、`--user`、`--open-dingtalk-id` 三选一。该命令会真实发送表情,执行前必须确认目标和 mediaId。
#### 新增个人收藏表情
```
Usage:
dws chat emotion favorite [flags]
Example:
dws chat emotion favorite --media-id <mediaId> --name "赞"
dws chat emotion favorite --media-id <mediaId> --source-conversation-id <cid> --source-message-id <mid>
Flags:
--media-id string 待收藏 mediaId (必填)
--name string 表情名称
--source-conversation-id string 来源会话 ID,需与 --source-message-id 成对指定
--source-message-id string 来源消息 ID,需与 --source-conversation-id 成对指定
```
### list-top-conversations (置顶会话)
#### 拉取置顶会话列表
@@ -2129,6 +2174,9 @@ Flags:
用户说"群公告/发布公告/修改公告/查看公告/定时公告" → `chat group notice create/edit/get/list`
用户说"批量查消息/按ID查消息/根据消息ID查" → `chat message list-by-ids`
用户说"批量查消息回复/表情回复/文字回复/消息回应列表" → `chat message list-emotion-replies`
用户说"查看个人收藏表情/列出我的收藏表情" → `chat emotion list`
用户说"发送个人收藏表情/发表情包" → `chat emotion send`
用户说"收藏表情/新增个人收藏表情" → `chat emotion favorite`
用户说"emoji回应/表情回应/给消息加表情" → `chat message add-emoji`
用户说"取消emoji回应/移除表情回应" → `chat message remove-emoji`
用户说"文字表情回应/添加文字表情" → `chat message add-text-emotion`
@@ -2200,6 +2248,7 @@ Flags:
- `chat message list-emotion-replies` — 批量拉取消息的表情回复和文字回复
- `chat message add-text-emotion` / `update-text-emotion` / `remove-text-emotion` — 对消息添加、原地更新或移除文字表情回应
- `chat message create-text-emotion` — 创建文字表情模板,返回 emotionId 供 add-text-emotion 使用
- `chat emotion list` / `send` / `favorite` — 当前用户个人收藏表情列表、发送和新增;不同于消息 reaction/文字回应
- `chat category list` — 获取用户自定义会话分组列表
- `chat category list-conversations` — 拉取指定分组下的会话列表
- `chat category create-smart` — 创建智能会话分组(可指定群名称关键词和成员作为匹配规则)
+1
View File
@@ -47,6 +47,7 @@ metadata:
| 查看指定群成员(用户/机器人) | `dws chat +chat-members-list --group <群名或ID>` | 唯一解析并全量读取 |
| 获取群邀请链接 | `dws chat +chat-invite-url --group <群名或ID>` | 多候选时停止 |
| 查看群机器人 | `dws chat +chat-bots --group <群名或ID>` | 返回稳定 `bots[]` |
| 个人收藏表情列表/发送/收藏 | `dws chat emotion list/send/favorite` | 约束见 leaf Schema |
| 修改群名称 | `dws chat group rename --id <openConversationId> --name <新名称>` | 只知群名时先用 `+chat-search --query <群名>` 唯一解析 ID;不猜 `+chat-rename` |
| 查看指定群内 @我的消息 | `dws chat +at-me --group <群名> --page-all` | 检查 `complete`;空结果仍返回数组 |
| 查看全部会话 | `dws chat +conversation-list --page-all` | 检查 `complete` / `failures` |

Some files were not shown because too many files have changed in this diff Show More