Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11cbc30a10 | ||
|
|
62d72ad84c | ||
|
|
c0838e7e41 | ||
|
|
9c6ab99bf1 | ||
|
|
87ab311764 | ||
|
|
15c075e6a6 | ||
|
|
f6d1e685e0 | ||
|
|
81108e150b | ||
|
|
dad9aefefa | ||
|
|
71d49cb12b | ||
|
|
f7e2efaaa2 | ||
|
|
557208e16b | ||
|
|
53401dbb0c | ||
|
|
6c52ac37dd | ||
|
|
95a17a3ffc | ||
|
|
3318741508 | ||
|
|
3d7ab2690c | ||
|
|
17eefcd24b | ||
|
|
6f62ce7997 | ||
|
|
2228a32d1a | ||
|
|
096dfd48f0 | ||
|
|
3922970bfc | ||
|
|
2ab0edd5c6 | ||
|
|
4b3272bcd4 | ||
|
|
80d5d24637 | ||
|
|
e40397e239 | ||
|
|
15bc7fdc3f | ||
|
|
da049be58d | ||
|
|
6ec64e8a03 | ||
|
|
bca56cbba6 | ||
|
|
bb48aa0cc8 | ||
|
|
6ffb4bcb93 | ||
|
|
08595594d7 | ||
|
|
540bbac35b | ||
|
|
c4d5595ca9 | ||
|
|
86d1eb8030 | ||
|
|
ab529e5ee5 | ||
|
|
15cb1f4311 | ||
|
|
97ca00868f | ||
|
|
df8885c350 | ||
|
|
4e27a3a84a | ||
|
|
8685464c53 | ||
|
|
26b5939f9f | ||
|
|
95bcace6bd | ||
|
|
ce57cdf260 | ||
|
|
3ec138ba99 | ||
|
|
f419c0f96d | ||
|
|
17101a8901 | ||
|
|
66aa00fb50 | ||
|
|
9d6e151a6f | ||
|
|
0df41d3eff | ||
|
|
2a1ed8cc7a | ||
|
|
c5951a10ff | ||
|
|
3dbd29ab50 | ||
|
|
1b50c7a5b4 | ||
|
|
cbd70d1b88 | ||
|
|
0ae8949d40 | ||
|
|
0975d970d1 | ||
|
|
7808673431 | ||
|
|
50ed921ca1 | ||
|
|
b34c29ec35 | ||
|
|
97e5ded043 | ||
|
|
7ceeafbae8 | ||
|
|
54b4a24a14 | ||
|
|
e1bfb343f4 | ||
|
|
7da423bf3c | ||
|
|
fa83ee579c | ||
|
|
25c694aa2a | ||
|
|
e064d394ba |
No files matched your search
File renamed without changes.
@@ -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.
|
||||
@@ -1,40 +0,0 @@
|
||||
name: Diag NPM Token
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
diag:
|
||||
name: Safe npm credential diagnostic
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Verify npm registry and credential
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
if test -z "$NODE_AUTH_TOKEN"; then
|
||||
echo "NPM_TOKEN is unavailable to this workflow."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
npm ping --registry=https://registry.npmjs.org
|
||||
npm whoami --registry=https://registry.npmjs.org
|
||||
|
||||
if npm access list collaborators dingtalk-workspace-cli \
|
||||
--registry=https://registry.npmjs.org >/dev/null; then
|
||||
echo "Package collaborator access: available"
|
||||
else
|
||||
echo "Package collaborator access: unavailable"
|
||||
exit 1
|
||||
fi
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -68,6 +68,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,7 +97,7 @@ 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。
|
||||
|
||||
@@ -99,9 +110,9 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
|
||||
| 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 迁移前完全一致 |
|
||||
| 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 核对:
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
+3
-3
@@ -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 自动发布,不需要人工审批或输入确认短语。
|
||||
|
||||
## 正式发布
|
||||
|
||||
@@ -147,7 +147,7 @@ fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`
|
||||
|
||||
- 只接受 `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`,不会覆盖稳定入口。
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -232,6 +232,67 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"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 +592,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 +675,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 +808,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 +817,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 +831,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
continue
|
||||
}
|
||||
|
||||
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
|
||||
if !paramAliasRepresentativePayloadCases[caseKey] {
|
||||
if !paramAliasRepresentativePayloadCases[caseKey] && !paramAliasCandidateRepresentativePayloadCases[caseKey] {
|
||||
continue
|
||||
}
|
||||
executedRepresentatives[caseKey] = true
|
||||
@@ -742,26 +843,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 +1209,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 +1304,10 @@ func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
|
||||
return variant, true
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return complete, true
|
||||
}
|
||||
complete, ok = paramAliasCandidateCompleteCommands[command]
|
||||
return complete, ok
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -10,16 +10,16 @@
|
||||
},
|
||||
|
||||
"concepts": {
|
||||
"search_query": {"denotes":"search keyword string","canonical_hint":"query","members":["query","keyword","keywords","q","search-word"],"excludes":["name","subject","text","title"],"commands":["aitable +base-search","aitable +find-record","aitable +record-bulk-patch","aitable +record-query","aitable +template-search","aitable base search","aitable field search-options","aitable record list","aitable record query","aitable search","aitable template search","contact +dept-members","contact +resolve-dept","contact +search-user","doc +create-from-template","doc +find-doc","doc +search","doc +template-search","doc template search","drive +find-file","drive +search","drive +search-docs","drive search","mail +find-mail-user","mail user search","oa +search-forms","oa approval search-forms","calendar +book-search","calendar +search-event"],"risk":"green"},
|
||||
"pagination_size": {"denotes":"returned item count upper bound","canonical_hint":"limit","members":["limit","size","page-size","max-results","max-result","take","top","per-page"],"excludes":["count","page","cursor"],"commands":["aitable +base-list","aitable +record-history-list","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable +workflow-list","aitable base list","aitable field search-options","aitable list","aitable record history-list","aitable record list","aitable record query","aitable record query-empty","aitable template search","aitable workflow history","aitable workflow list","calendar event list","chat message list","devdoc article search","doc +comment-list","doc +find-doc","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +search-docs","drive +star-list","drive +version-history","drive list","drive list-spaces","drive permission list","drive recent","drive recycle list","drive search","drive star list","mail thread list","oa +list-executed","calendar +agenda","calendar +room-groups","calendar +room-find","calendar +search-event"],"risk":"green"},
|
||||
"page_number": {"denotes": "one-based page number", "canonical_hint": "page", "members": ["page", "page-no", "current-page", "page-num"], "excludes": ["cursor", "page-index", "page-size", "page-token"], "commands": ["devdoc article search"], "risk": "green"},
|
||||
"page_cursor": {"denotes":"pagination cursor/token","canonical_hint":"cursor","members":["cursor","next-cursor","page-token","next-token","next-page-token"],"excludes":["page","offset"],"commands":["aitable +base-list","aitable +base-search","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable base list","aitable base search","aitable list","aitable record list","aitable record query","aitable record query-empty","aitable search","aitable template search","calendar event list","doc +comment-list","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +star-list","drive +version-history","drive list","drive list-spaces","drive recent","drive recycle list","drive search","drive star list","calendar +agenda","calendar +search-event"],"risk":"green"},
|
||||
"content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc +checkpoint-update", "doc +comment-create", "doc +comment-reply", "doc +comment-update", "doc +create", "doc +doc-append", "doc block insert", "doc block update", "doc comment create", "doc comment create-inline", "doc comment reply", "doc comment update", "doc create"], "risk": "green"},
|
||||
"time_start": {"denotes": "start time point with unchanged value format and unit", "canonical_hint": "start", "members": ["start", "start-time", "start-date", "from", "from-date", "begin", "since", "time-min", "min-time"], "excludes": ["date", "time", "end"], "commands": ["calendar event list", "chat message list-all", "report list", "calendar +agenda", "calendar +free", "calendar +freebusy", "calendar +my-free", "calendar +suggest-time", "calendar +room-find", "calendar +search-event", "calendar +suggestion"], "risk": "yellow"},
|
||||
"time_end": {"denotes": "end time point with unchanged value format and unit", "canonical_hint": "end", "members": ["end", "end-time", "end-date", "time-max", "max-time"], "excludes": ["date", "time", "start"], "commands": ["calendar event list", "calendar +agenda", "calendar +free", "calendar +freebusy", "calendar +my-free", "calendar +suggest-time", "calendar +room-find", "calendar +search-event", "calendar +suggestion"], "risk": "yellow"},
|
||||
"search_query": {"denotes":"search keyword string","canonical_hint":"query","members":["query","keyword","keywords","q","search-word"],"excludes":["name","subject","text","title"],"commands":["aitable +base-search","aitable +find-record","aitable +record-bulk-patch","aitable +record-query","aitable +template-search","aitable base search","aitable field search-options","aitable record list","aitable record query","aitable search","aitable template search","contact +dept-members","contact +resolve-dept","contact +search-user","doc +create-from-template","doc +find-doc","doc +search","doc +template-search","doc template search","drive +find-file","drive +search","drive +search-docs","drive search","mail +find-mail-user","mail user search","oa +search-forms","oa approval search-forms","calendar +book-search","calendar +search-event","minutes +latest","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes +transcript","todo +search","wiki +node-search","wiki +resolve-space","wiki +space-search","wiki node search","wiki space search"],"risk":"green"},
|
||||
"pagination_size": {"denotes":"returned item count upper bound","canonical_hint":"limit","members":["limit","size","page-size","max-results","max-result","take","top","per-page"],"excludes":["count","page","cursor"],"commands":["aitable +base-list","aitable +record-history-list","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable +workflow-list","aitable base list","aitable field search-options","aitable list","aitable record history-list","aitable record list","aitable record query","aitable record query-empty","aitable template search","aitable workflow history","aitable workflow list","calendar event list","chat message list","devdoc article search","doc +comment-list","doc +find-doc","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +search-docs","drive +star-list","drive +version-history","drive list","drive list-spaces","drive permission list","drive recent","drive recycle list","drive search","drive star list","mail thread list","oa +list-executed","calendar +agenda","calendar +room-groups","calendar +room-find","calendar +search-event","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes list all","minutes list mine","minutes list shared","minutes tag query","todo +list-comment","todo comment list","wiki +member-list","wiki +node-search","wiki +space-search","wiki feed list","wiki member list","wiki node list","wiki node search","wiki space list","wiki space search"],"risk":"green"},
|
||||
"page_number": {"denotes":"one-based page number","canonical_hint":"page","members":["page","page-no","current-page","page-num"],"excludes":["cursor","page-index","page-size","page-token"],"commands":["devdoc article search","todo +get-my-tasks","todo +list-comment","todo comment list","todo task list"],"risk":"green"},
|
||||
"page_cursor": {"denotes":"pagination cursor/token","canonical_hint":"cursor","members":["cursor","next-cursor","page-token","next-token","next-page-token"],"excludes":["page","offset"],"commands":["aitable +base-list","aitable +base-search","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable base list","aitable base search","aitable list","aitable record list","aitable record query","aitable record query-empty","aitable search","aitable template search","calendar event list","doc +comment-list","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +star-list","drive +version-history","drive list","drive list-spaces","drive recent","drive recycle list","drive search","drive star list","calendar +agenda","calendar +search-event","minutes +detail","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes +transcript","wiki +feed-list","wiki +node-search","wiki feed list","wiki node list","wiki node search","wiki space list"],"risk":"green"},
|
||||
"content_text": {"denotes":"text body content","canonical_hint":"text","members":["text","content","body"],"excludes":["title","name"],"commands":["doc +checkpoint-update","doc +comment-create","doc +comment-reply","doc +comment-update","doc +create","doc +doc-append","doc block insert","doc block update","doc comment create","doc comment create-inline","doc comment reply","doc comment update","doc create","minutes +summary","minutes update summary","todo +comment","todo comment add"],"risk":"green"},
|
||||
"time_start": {"denotes":"start time point with unchanged value format and unit","canonical_hint":"start","members":["start","start-time","start-date","from","from-date","begin","since","time-min","min-time"],"excludes":["date","time","end"],"commands":["calendar event list","chat message list-all","report list","calendar +agenda","calendar +free","calendar +freebusy","calendar +my-free","calendar +suggest-time","calendar +room-find","calendar +search-event","calendar +suggestion","minutes +search","minutes audio-memo list","minutes list all","minutes list mine","minutes list shared"],"risk":"yellow"},
|
||||
"time_end": {"denotes":"end time point with unchanged value format and unit","canonical_hint":"end","members":["end","end-time","end-date","time-max","max-time"],"excludes":["date","time","start"],"commands":["calendar event list","calendar +agenda","calendar +free","calendar +freebusy","calendar +my-free","calendar +suggest-time","calendar +room-find","calendar +search-event","calendar +suggestion","minutes +search","minutes audio-memo list","minutes list all","minutes list mine","minutes list shared"],"risk":"yellow"},
|
||||
"base_id": {"denotes":"multi-dimensional table Base id","canonical_hint":"base-id","members":["base","base-id","base-token"],"excludes":[],"commands":["aitable +advperm-disable","aitable +advperm-enable","aitable +attachment-put","aitable +attachment-remove","aitable +attachment-upload","aitable +base-copy","aitable +base-delete","aitable +base-get","aitable +base-get-primary-doc-id","aitable +base-schema-snapshot","aitable +base-update","aitable +chart-delete","aitable +chart-get","aitable +chart-share-get","aitable +chart-share-update","aitable +chart-update","aitable +dashboard-arrange","aitable +dashboard-delete","aitable +dashboard-get","aitable +dashboard-share-get","aitable +dashboard-share-update","aitable +dashboard-update","aitable +export-data","aitable +field-delete","aitable +field-get","aitable +field-update","aitable +find-record","aitable +form-delete","aitable +form-field-hide","aitable +form-field-list","aitable +form-field-update","aitable +form-list","aitable +form-share-get","aitable +form-share-update","aitable +form-update","aitable +import-upload","aitable +list-tables","aitable +record-bulk-patch","aitable +record-delete","aitable +record-history-list","aitable +record-primary-doc-create","aitable +record-primary-doc-get","aitable +record-query","aitable +record-query-empty","aitable +record-share-links","aitable +record-share-url","aitable +record-update","aitable +record-upsert","aitable +record-upsert-by-key","aitable +resolve-table","aitable +role-create","aitable +role-delete","aitable +role-get","aitable +role-list","aitable +role-update","aitable +section-create","aitable +section-delete","aitable +section-list-empty","aitable +section-list-nodes","aitable +section-move-node","aitable +section-rename","aitable +section-reorder","aitable +table-delete","aitable +table-get","aitable +table-update","aitable +view-delete","aitable +view-duplicate","aitable +view-get","aitable +view-get-frozen-cols","aitable +view-get-lock","aitable +view-get-row-height","aitable +view-lock","aitable +view-preset-apply","aitable +view-set-fill-color-rule","aitable +view-set-frozen-cols","aitable +view-set-row-height","aitable +view-update","aitable +workflow-deploy","aitable +workflow-disable","aitable +workflow-enable","aitable +workflow-get","aitable +workflow-list","aitable advperm disable","aitable advperm enable","aitable advperm role-create","aitable advperm role-delete","aitable advperm role-get","aitable advperm role-list","aitable advperm role-update","aitable attachment upload","aitable base copy","aitable base delete","aitable base get","aitable base get-primary-doc-id","aitable base update","aitable chart create","aitable chart delete","aitable chart get","aitable chart share get","aitable chart share update","aitable chart update","aitable dashboard arrange","aitable dashboard create","aitable dashboard delete","aitable dashboard get","aitable dashboard share get","aitable dashboard share update","aitable dashboard update","aitable export data","aitable field create","aitable field delete","aitable field get","aitable field list","aitable field search-options","aitable field update","aitable form create","aitable form delete","aitable form field hide","aitable form field list","aitable form field update","aitable form get","aitable form list","aitable form questions create","aitable form questions delete","aitable form share get","aitable form share update","aitable form update","aitable import upload","aitable info","aitable record batch-update","aitable record create","aitable record delete","aitable record get","aitable record history-list","aitable record primary-doc-create","aitable record primary-doc-get","aitable record query","aitable record query-empty","aitable record share-url","aitable record update","aitable record upsert","aitable section create","aitable section delete","aitable section list-empty","aitable section list-nodes","aitable section move-node","aitable section rename","aitable section reorder","aitable table create","aitable table delete","aitable table get","aitable table list","aitable table update","aitable view create","aitable view delete","aitable view duplicate","aitable view get aggregate","aitable view get card","aitable view get field-widths","aitable view get fill-color-rule","aitable view get filter","aitable view get frozen-cols","aitable view get group","aitable view get lock","aitable view get row-height","aitable view get sort","aitable view get timebar","aitable view get visible-fields","aitable view list","aitable view lock","aitable view update aggregate","aitable view update card","aitable view update field-widths","aitable view update fill-color-rule","aitable view update filter","aitable view update frozen-cols","aitable view update group","aitable view update name","aitable view update row-height","aitable view update sort","aitable view update timebar","aitable view update visible-fields","aitable workflow create","aitable workfloLine truncated
|
||||
"table_id": {"denotes":"single AI Table data-table Table ID","canonical_hint":"table-id","members":["table","table-id"],"excludes":["table-ids","source-table-id","target-table-id","name"],"commands":["aitable +attachment-put","aitable +attachment-remove","aitable +base-get-primary-doc-id","aitable +export-data","aitable +field-delete","aitable +field-get","aitable +field-update","aitable +find-record","aitable +form-delete","aitable +form-field-hide","aitable +form-field-list","aitable +form-field-update","aitable +form-list","aitable +form-share-get","aitable +form-share-update","aitable +form-update","aitable +import-data","aitable +record-bulk-patch","aitable +record-delete","aitable +record-history-list","aitable +record-primary-doc-create","aitable +record-primary-doc-get","aitable +record-query","aitable +record-query-empty","aitable +record-share-links","aitable +record-share-url","aitable +record-update","aitable +record-upsert","aitable +record-upsert-by-key","aitable +table-delete","aitable +table-update","aitable +view-delete","aitable +view-duplicate","aitable +view-get","aitable +view-get-frozen-cols","aitable +view-get-lock","aitable +view-get-row-height","aitable +view-lock","aitable +view-preset-apply","aitable +view-set-fill-color-rule","aitable +view-set-frozen-cols","aitable +view-set-row-height","aitable +view-update","aitable base get-primary-doc-id","aitable export data","aitable field create","aitable field delete","aitable field get","aitable field list","aitable field search-options","aitable field update","aitable form create","aitable form delete","aitable form field hide","aitable form field list","aitable form field update","aitable form get","aitable form list","aitable form questions create","aitable form questions delete","aitable form share get","aitable form share update","aitable form update","aitable import data","aitable record batch-update","aitable record create","aitable record delete","aitable record get","aitable record history-list","aitable record primary-doc-create","aitable record primary-doc-get","aitable record query","aitable record query-empty","aitable record share-url","aitable record update","aitable record upsert","aitable table delete","aitable table update","aitable view create","aitable view delete","aitable view duplicate","aitable view get aggregate","aitable view get card","aitable view get field-widths","aitable view get fill-color-rule","aitable view get filter","aitable view get frozen-cols","aitable view get group","aitable view get lock","aitable view get row-height","aitable view get sort","aitable view get timebar","aitable view get visible-fields","aitable view list","aitable view lock","aitable view update aggregate","aitable view update card","aitable view update field-widths","aitable view update fill-color-rule","aitable view update filter","aitable view update frozen-cols","aitable view update group","aitable view update name","aitable view update row-height","aitable view update sort","aitable view update timebar","aitable view update visible-fields","aitable workflow run"],"risk":"green"},
|
||||
"plain_description": {"denotes":"plain-text description or remark with unchanged value","canonical_hint":"description","members":["description","desc"],"excludes":["field-description","config"],"commands":["aitable +base-update","aitable +form-update","aitable +table-update","aitable base update","aitable form create","aitable form update","aitable table update","calendar +create","calendar +update"],"risk":"green"},
|
||||
"plain_description": {"denotes":"plain-text description or remark with unchanged value","canonical_hint":"description","members":["description","desc"],"excludes":["field-description","config"],"commands":["aitable +base-update","aitable +form-update","aitable +table-update","aitable base update","aitable form create","aitable form update","aitable table update","calendar +create","calendar +update","wiki +space-create","wiki space create"],"risk":"green"},
|
||||
"workflow_id": {"denotes":"single AI Table workflow identifier; service flowId and CLI workflow-id carry the same value","canonical_hint":"workflow-id","members":["workflow-id","flow-id"],"excludes":["id","task-id"],"commands":["aitable +workflow-deploy","aitable +workflow-disable","aitable +workflow-enable","aitable +workflow-get","aitable workflow disable","aitable workflow enable","aitable workflow get","aitable workflow history","aitable workflow run","aitable workflow update"],"risk":"green"},
|
||||
"dept_id": {"denotes": "single department id", "canonical_hint": "dept", "members": ["dept", "dept-id", "department", "department-id", "parent", "parent-id"], "excludes": ["depts", "dept-ids", "department-ids", "name", "query"], "commands": ["contact +list-sub-depts", "contact dept list-children"], "risk": "yellow"},
|
||||
"dept_ids": {"denotes": "department id list", "canonical_hint": "dept-ids", "members": ["depts", "dept-ids", "department-ids"], "excludes": ["dept", "dept-id", "department-id", "name", "query"], "commands": ["contact +list-dept-members"], "risk": "yellow"},
|
||||
@@ -31,7 +31,7 @@
|
||||
"open_message_ids": {"denotes": "DingTalk openMessageId list with unchanged element values", "canonical_hint": "msg-ids", "members": ["msg-ids", "message-ids", "open-message-ids"], "excludes": ["msg-id", "message-id", "open-message-id", "ref-msg-id", "src-msg-id"], "commands": ["chat +flag-cancel", "chat +flag-create", "chat +messages-combine-forward", "chat +messages-mget", "chat message combine-forward", "chat message list-by-ids", "chat message list-emotion-replies"], "risk": "yellow"},
|
||||
"referenced_open_message_id": {"denotes": "referenced DingTalk openMessageId in a reply", "canonical_hint": "ref-msg-id", "members": ["ref-msg-id", "ref-message-id"], "excludes": ["msg-id", "message-id", "open-message-id", "msg-ids", "src-msg-id"], "commands": ["chat +messages-reply", "chat message reply"], "risk": "yellow"},
|
||||
"user_id": {"denotes": "single user id", "canonical_hint": "user-id", "members": ["user", "user-id", "userid", "uid", "staff-id"], "excludes": ["at-user-ids", "to-user", "users", "user-ids", "name"], "commands": ["chat +chat-role-query-user", "chat +chat-role-set-user", "chat +messages-list-direct", "chat chmod", "chat conversation-info", "chat group transfer-owner", "chat group-role query-user", "chat group-role remove-user", "chat group-role set-user", "chat message list", "chat message send", "contact user profile get"], "risk": "yellow"},
|
||||
"user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat +messages-batch-send-by-bot", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot", "calendar +freebusy", "calendar +suggestion"], "risk": "yellow"},
|
||||
"user_ids": {"denotes":"user id list","canonical_hint":"user-ids","members":["users","user-ids"],"excludes":["user","user-id","userid","uid","staff-id","at-user-ids"],"commands":["attendance +check-result","attendance check result","chat +messages-batch-send-by-bot","chat group members remove","chat group set-admin","chat group-mute-member","chat message read-status","chat message search-advanced","chat message send-by-bot","calendar +freebusy","calendar +suggestion","minutes +share","minutes +unshare","minutes permission add","minutes permission remove","wiki +member-add","wiki +member-remove","wiki +member-update"],"risk":"yellow"},
|
||||
"open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat +chat-members-get", "chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"ding_id": {"denotes": "DING id", "canonical_hint": "ding-id", "members": ["ding-id", "open-ding-id"], "excludes": ["id"], "commands": ["ding message receiver-status"], "risk": "yellow"},
|
||||
"folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive +copy", "drive +create-folder", "drive +create-shortcut", "drive +list", "drive +move", "drive +upload", "drive commit", "drive copy", "drive list", "drive mkdir", "drive move", "drive shortcut", "drive upload", "drive upload-info", "mail folder update"], "risk": "green"},
|
||||
@@ -49,7 +49,7 @@
|
||||
"drive_modified_time_end": {"denotes": "Drive search modified-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "modified-to", "members": ["modified-to", "modified-before", "modify-time-to", "modified-time-end"], "excludes": ["modified-from", "created-from", "created-to", "end", "to"], "commands": ["drive +search", "drive search"], "risk": "yellow"},
|
||||
"drive_file_size_bytes": {"denotes": "file size in bytes passed unchanged", "canonical_hint": "file-size", "members": ["file-size", "file-size-bytes", "size-bytes", "content-length"], "excludes": ["part-size", "page-size", "limit", "size"], "commands": ["drive commit", "drive upload-info"], "risk": "yellow"},
|
||||
"drive_sort_direction": {"denotes": "Drive result sort direction with unchanged asc/desc enum", "canonical_hint": "order", "members": ["order", "sort", "sort-direction", "order-direction"], "excludes": ["order-by", "sort-by", "order-field"], "commands": ["drive +list", "drive list", "drive star list"], "risk": "green"},
|
||||
"workspace_id": {"denotes": "single knowledge-base or document-space workspace ID/URL passed unchanged; never a numeric DingDrive storage space ID", "canonical_hint": "workspace", "members": ["workspace", "workspace-id", "knowledge-base-id", "wiki-workspace-id"], "excludes": ["space", "space-id", "drive-space-id", "storage-space-id", "dingdrive-space-id", "folder", "folder-id", "node", "node-id", "dentry-id"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +copy", "doc +create", "doc +create-from-template", "doc +grant-and-share", "doc +import", "doc +list", "doc +move", "doc create", "doc file create", "doc import", "doc template apply", "drive +copy", "drive +create-shortcut", "drive +move", "drive copy", "drive list", "drive move", "drive permission add", "drive permission list", "drive permission remove", "drive permission transfer-owner", "drive permission update", "drive shortcut", "drive upload"], "risk": "yellow"},
|
||||
"workspace_id": {"denotes":"single knowledge-base or document-space workspace ID/URL passed unchanged; never a numeric DingDrive storage space ID","canonical_hint":"workspace","members":["workspace","workspace-id","knowledge-base-id","wiki-workspace-id"],"excludes":["space","space-id","drive-space-id","storage-space-id","dingdrive-space-id","folder","folder-id","node","node-id","dentry-id"],"commands":["doc +access-change","doc +access-grant","doc +access-revoke","doc +copy","doc +create","doc +create-from-template","doc +grant-and-share","doc +import","doc +list","doc +move","doc create","doc file create","doc import","doc template apply","drive +copy","drive +create-shortcut","drive +move","drive copy","drive list","drive move","drive permission add","drive permission list","drive permission remove","drive permission transfer-owner","drive permission update","drive shortcut","drive upload","wiki +delete-space","wiki +feed-list","wiki +member-add","wiki +member-list","wiki +member-remove","wiki +member-update","wiki +move","wiki +node-copy","wiki +node-create","wiki +node-delete","wiki +node-list","wiki +node-search","wiki +space-get","wiki feed list","wiki member add","wiki member list","wiki member remove","wiki member update","wiki node copy","wiki node create","wiki node delete","wiki node list","wiki node move","wiki node search","wiki space delete","wiki space get"],"risk":"yellow"},
|
||||
"created_time_start": {"denotes": "Doc/Drive search created-time lower bound in unchanged millisecond timestamp unit", "canonical_hint": "created-from", "members": ["created-from", "created-after", "create-time-from", "created-time-start", "create-time-start"], "excludes": ["created-to", "modified-from", "modified-to", "start", "from"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"},
|
||||
"created_time_end": {"denotes": "Doc/Drive search created-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "created-to", "members": ["created-to", "created-before", "create-time-to", "created-time-end", "create-time-end"], "excludes": ["created-from", "modified-from", "modified-to", "end", "to"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"},
|
||||
"document_permission_role": {"denotes": "direct document-space permission role on grant/apply/update, passed unchanged", "canonical_hint": "role", "members": ["role", "permission-role", "access-role", "member-role"], "excludes": ["filter-role", "reserve-role", "permission", "public-permission"], "commands": ["doc +access-change", "doc +access-grant", "doc +grant-and-share", "drive permission add", "drive permission apply", "drive permission update"], "risk": "yellow"},
|
||||
@@ -61,7 +61,17 @@
|
||||
"calendar_person_name_list": {"denotes":"comma-separated DingTalk person names resolved by the shortcut","canonical_hint":"with","members":["with","names","attendee-names","participant-names"],"excludes":["user","user-id","user-ids","users","attendees","participants","open-dingtalk-ids"],"commands":["calendar +book","calendar +invite","calendar +suggest-time"],"risk":"yellow"},
|
||||
"calendar_room_ids": {"denotes":"calendar meeting roomId list with unchanged element values; one roomId is a one-element list","canonical_hint":"rooms","members":["rooms","room-id","room-ids"],"excludes":["room","room-name","group-id","location"],"commands":["calendar event create","calendar room add","calendar room delete","calendar busy search","calendar +create","calendar +freebusy"],"risk":"yellow"},
|
||||
"calendar_duration_minutes": {"denotes":"meeting duration in minutes with unchanged numeric text","canonical_hint":"duration","members":["duration","duration-minutes","meeting-duration-minutes"],"excludes":["remind-minutes","start","end","from","to"],"commands":["calendar event suggest","calendar +suggest-time","calendar +suggestion"],"risk":"green"},
|
||||
"calendar_timezone": {"denotes":"IANA timezone string with unchanged value","canonical_hint":"timezone","members":["timezone","time-zone","tz"],"excludes":["offset","utc-offset","locale"],"commands":["calendar event create","calendar event suggest","calendar event update","calendar +create","calendar +suggestion","calendar +update"],"risk":"green"}
|
||||
"calendar_timezone": {"denotes":"IANA timezone string with unchanged value","canonical_hint":"timezone","members":["timezone","time-zone","tz"],"excludes":["offset","utc-offset","locale"],"commands":["calendar event create","calendar event suggest","calendar event update","calendar +create","calendar +suggestion","calendar +update"],"risk":"green"},
|
||||
"minutes_task_uuid": {"denotes":"single DingTalk Minutes taskUuid with unchanged value","canonical_hint":"task-uuid","members":["task-uuid","uuid"],"excludes":["ids","task-uuids","uuids","url","minutes-id","meeting-id","record-id","task-id"],"commands":["minutes +action-items","minutes +apply-permission","minutes +detail","minutes +download","minutes +export-pack","minutes +mindmap","minutes +record-pause","minutes +record-resume","minutes +record-stop","minutes +record-wrap-up","minutes +replace-batch","minutes +share","minutes +speaker-insights","minutes +speaker-replace","minutes +summary","minutes +transcript","minutes +unshare","minutes +update","minutes record pause","minutes record resume","minutes record stop","minutes replace-text","minutes speaker replace","minutes update summary","minutes update title"],"risk":"yellow"},
|
||||
"minutes_task_uuids": {"denotes":"Minutes taskUuid list with unchanged element values and unchanged list encoding","canonical_hint":"ids","members":["ids","task-uuids","uuids"],"excludes":["id","task-uuid","uuid","url","minutes-id","minutes-ids","meeting-id","meeting-ids"],"commands":["minutes +detail","minutes +download","minutes +share","minutes +unshare","minutes get batch","minutes permission add","minutes permission remove","minutes speaker summary create","minutes speaker summary get"],"risk":"yellow"},
|
||||
"minutes_hot_words": {"denotes":"personal hot-word list with unchanged values and unchanged list encoding","canonical_hint":"words","members":["words","hot-words","hotwords"],"excludes":["word","hot-word","query","keyword","keywords","search"],"commands":["minutes +prepare-asr","minutes hot-word add","minutes hot-word delete"],"risk":"yellow"},
|
||||
"minutes_upload_session_id": {"denotes":"single Minutes file-upload sessionId with unchanged value","canonical_hint":"session-id","members":["session-id","upload-session-id","upload-id"],"excludes":["id","task-id","task-uuid","minutes-id","recording-session-id"],"commands":["minutes upload cancel","minutes upload complete"],"risk":"yellow"},
|
||||
"todo_task_id": {"denotes":"single DingTalk todo taskId with unchanged value","canonical_hint":"task-id","members":["task-id","todo-id"],"excludes":["id","ids","task","task-ids","todo-ids","parent-id","comment-id","attachment-id","open-task-id"],"commands":["todo +comment","todo +complete","todo +get","todo +list-attachment","todo +list-comment","todo +list-sub","todo +reminder","todo +reopen","todo +update","todo comment add","todo comment delete","todo comment list","todo tag add","todo task add-attachment","todo task add-executor","todo task add-participant","todo task add-reminder","todo task create-sub","todo task delete","todo task done","todo task get","todo task list-attachment","todo task list-sub","todo task remove-attachment","todo task remove-executor","todo task remove-participant","todo task reset-reminder","todo task update"],"risk":"yellow"},
|
||||
"todo_due_time": {"denotes":"todo deadline ISO-8601 value, not an independent reminder time","canonical_hint":"due","members":["due","due-time","deadline"],"excludes":["remind-at","reminder-at","reminder-time","reminder-time-stamp","due-date-offset","plan-finish-start","plan-finish-end","plan-finish-date-start","plan-finish-date-end"],"commands":["todo +assign","todo +create","todo +update","todo task create","todo task create-sub","todo task update"],"risk":"yellow"},
|
||||
"todo_executor_ids": {"denotes":"todo executor userId list with unchanged comma-separated value","canonical_hint":"executors","members":["executors","executor-ids"],"excludes":["executor","executor-id","participant","participant-id","participants","participant-ids","to","user","user-id","user-ids","users","name"],"commands":["todo +create","todo task add-executor","todo task create","todo task create-sub","todo task remove-executor"],"risk":"yellow"},
|
||||
"todo_participant_ids": {"denotes":"todo participant userId list with unchanged comma-separated value","canonical_hint":"participants","members":["participants","participant-ids"],"excludes":["participant","participant-id","executor","executor-id","executors","executor-ids","to","user","user-id","user-ids","users","name"],"commands":["todo task add-participant","todo task remove-participant"],"risk":"yellow"},
|
||||
"todo_completion_state": {"denotes":"todo completion boolean string true/false with unchanged value","canonical_hint":"status","members":["status","done","is-done","todo-status"],"excludes":["state","task-status"],"commands":["todo +get-my-tasks","todo +search","todo task done","todo task list","todo task update"],"risk":"yellow"},
|
||||
"todo_role_types": {"denotes":"todo creator/executor/participant role filter list with unchanged comma-separated value","canonical_hint":"role-types","members":["role-types","roles"],"excludes":["role","role-id","role-ids"],"commands":["todo +due-today","todo +get-my-tasks","todo +get-related-tasks","todo task list"],"risk":"yellow"}
|
||||
},
|
||||
|
||||
"command_overrides": {
|
||||
@@ -305,7 +315,115 @@
|
||||
"aitable +import-upload": {"scoped_aliases":{"size":"file-size"},"scope_strict":true,"note":"On this import-upload command --file-size is the local file size in bytes; the mapping is command-local because size is a pagination synonym elsewhere."},
|
||||
"aitable +table-copy": {"scoped_aliases":{"table-id":"source-table-id"},"ambiguous":["base","base-id","base-token"],"scope_strict":true,"note":"The only existing table role is the source table, while an unqualified Base spelling cannot choose between source-base-id and target-base-id."},
|
||||
"aitable workflow run": {"block":["record-id"],"note":"The command accepts a list of one to five record IDs. A singular --record-id is deliberately blocked instead of relying on edit-distance correction; --table-id and --record-ids must remain paired for record-triggered workflows."},
|
||||
"aitable workflow history": {"scoped_aliases":{"start-time":"after-time","end-time":"before-time","page-index":"page"},"block":["cursor","offset","page-token","next-cursor","next-token","page-no","page-number","current-page"],"scope_strict":true,"note":"This endpoint uses a zero-based page plus per-page size, not cursor/offset pagination. Time-bound aliases preserve the required Unix-millisecond value unchanged."}
|
||||
"aitable workflow history": {"scoped_aliases":{"start-time":"after-time","end-time":"before-time","page-index":"page"},"block":["cursor","offset","page-token","next-cursor","next-token","page-no","page-number","current-page"],"scope_strict":true,"note":"This endpoint uses a zero-based page plus per-page size, not cursor/offset pagination. Time-bound aliases preserve the required Unix-millisecond value unchanged."},
|
||||
"minutes +share": {"bind":{"member-uids":"user_ids","id":"minutes_task_uuid"},"block":["user","user-id","userid","uid","staff-id","union-id","open-dingtalk-id","policy"],"note":"--member-uids is a DingTalk userId list; singular inputs and staffId/unionId/openDingTalkId value domains are not promoted. Semantic permission names and numeric policy values require value conversion and are intentionally blocked across surfaces. The broad real --id is explicitly bound to the Minutes taskUuid concept."},
|
||||
"minutes +unshare": {"bind":{"member-uids":"user_ids","id":"minutes_task_uuid"},"block":["user","user-id","userid","uid","staff-id","union-id","open-dingtalk-id"],"note":"--member-uids is a DingTalk userId list; singular inputs and staffId/unionId/openDingTalkId value domains are not promoted. The broad real --id is explicitly bound to the Minutes taskUuid concept."},
|
||||
"minutes permission add": {"bind":{"member-uids":"user_ids","ids":"minutes_task_uuids"},"block":["id","task-uuid","uuid","user","user-id","userid","uid","staff-id","union-id","open-dingtalk-id","permission"],"note":"--ids is a taskUuid list and --member-uids is a DingTalk userId list. Singular task IDs, singular member IDs, staffId/unionId/openDingTalkId domains, and semantic permission names are not promoted into these list/numeric roles."},
|
||||
"minutes permission remove": {"bind":{"member-uids":"user_ids","ids":"minutes_task_uuids"},"block":["id","task-uuid","uuid","user","user-id","userid","uid","staff-id","union-id","open-dingtalk-id"],"note":"--ids is a taskUuid list and --member-uids is a DingTalk userId list. Singular task/member inputs and staffId/unionId/openDingTalkId domains are not promoted."},
|
||||
"minutes speaker replace": {"scoped_aliases":{"user-id":"target-uid","userid":"target-uid","uid":"target-uid","old-speaker":"from","source-speaker":"from","new-speaker":"to","target-speaker":"to"},"block":["target-uids","user","staff-id","union-id","open-dingtalk-id"],"scope_strict":true,"note":"--id is taskUuid, --from/--to are speaker-label roles, and singular --target-uid requires one stable DingTalk userId. Plural target IDs and other user identity domains are not promoted. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes +speaker-replace": {"scoped_aliases":{"user-id":"target-uid","userid":"target-uid","uid":"target-uid","old-speaker":"from","source-speaker":"from","new-speaker":"to","target-speaker":"to"},"block":["target-uids","user","staff-id","union-id","open-dingtalk-id"],"scope_strict":true,"note":"--id is taskUuid, --from/--to are speaker-label roles, and singular --target-uid requires one stable DingTalk userId. Plural target IDs and other user identity domains are not promoted. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes audio-memo list": {"scoped_aliases":{"limit":"max","page-size":"max","max-results":"max","next-cursor":"cursor"},"block":["next-token","page-token","next-page-token"],"scope_strict":true,"note":"This command uses --max for the count bound and an integer cursor; opaque pagination tokens cannot be renamed into that integer domain."},
|
||||
"minutes tag query": {"scoped_aliases":{"id":"tag-id"},"block":["tag-ids","group-id","group-name"],"scope_strict":true,"note":"The generic identifier on this exact command is one Minutes tag ID, not a taskUuid, tag list, or group identifier."},
|
||||
"minutes replace-text": {"scoped_aliases":{"find":"search","original-text":"search","new-text":"replace","replacement-text":"replace"},"block":["text","content","body"],"scope_strict":true,"note":"Search and replacement are two distinct required roles; role-free text spellings cannot safely choose one side. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes +replace-batch": {"scoped_aliases":{"pairs":"pair"},"block":["search","replace","original-text","replacement-text"],"scope_strict":true,"note":"The repeatable --pair value is encoded as source=>replacement; name normalization cannot compose it from two separate values. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes +download": {"ambiguous":["destination","path","save-path"],"note":"The destination role depends on singular --output versus batch --output-dir and cannot be selected from a generic path name. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes list all": {"block":["date-range"],"note":"A date range must be represented by both --start and --end; name-only normalization cannot split one value into two flags."},
|
||||
"minutes list mine": {"block":["date-range"],"note":"A date range must be represented by both --start and --end; name-only normalization cannot split one value into two flags."},
|
||||
"minutes list shared": {"block":["date-range"],"note":"A date range must be represented by both --start and --end; name-only normalization cannot split one value into two flags."},
|
||||
"minutes +search": {"block":["date-range"],"note":"A date range must be represented by both --start and --end; name-only normalization cannot split one value into two flags."},
|
||||
"minutes +apply-permission": {"block":["policy"],"note":"Semantic permission names and numeric policy values require value conversion and cannot be passed through by renaming. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes permission apply": {"block":["ids","task-uuids","uuids","permission"],"note":"This leaf accepts one taskUuid and a numeric policy. Task lists and semantic permission names require cardinality/value conversion and cannot be passed through by renaming."},
|
||||
"minutes get info": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."},
|
||||
"minutes get summary": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."},
|
||||
"minutes get keywords": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."},
|
||||
"minutes get transcription": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."},
|
||||
"minutes get todos": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."},
|
||||
"minutes get audio": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."},
|
||||
"minutes mind-graph create": {"block":["ids","task-uuids","uuids"],"note":"This leaf creates a mind graph for exactly one taskUuid; a task list cannot be renamed into the singular role."},
|
||||
"minutes mind-graph status": {"block":["ids","task-uuids","uuids"],"note":"This leaf queries the mind graph for exactly one taskUuid; a task list cannot be renamed into the singular role."},
|
||||
"minutes upload create": {"block":["file","file-path","path","template-ids"],"note":"The atomic create command requires file-name, byte-sized file-size, and at most one template ID. A local path needs stat/upload orchestration, and a template list cannot be renamed into the singular role."},
|
||||
"minutes upload complete": {"block":["session-ids","upload-session-ids","upload-ids"],"note":"The command completes exactly one upload session; plural session spellings cannot be renamed into its singular sessionId."},
|
||||
"minutes upload cancel": {"block":["session-ids","upload-session-ids","upload-ids"],"note":"The command cancels exactly one upload session; plural session spellings cannot be renamed into its singular sessionId."},
|
||||
"minutes +upload": {"block":["file-name","file-size","size-bytes","template-ids"],"note":"The Shortcut requires a local --file path, computes file metadata, and accepts at most one template ID; atomic metadata or template lists cannot be renamed into those roles."},
|
||||
"minutes record start": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. Plural or upload-session spellings are blocked."},
|
||||
"minutes record pause": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes record resume": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes record stop": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes +record-start": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. Plural or upload-session spellings are blocked."},
|
||||
"minutes +record-pause": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes +record-resume": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes +record-stop": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes +speaker-insights": {"block":["task-ids"],"note":"--id is one Minutes taskUuid while optional --task-id is one asynchronous recovery handle; neither role accepts a list. The broad real --id is explicitly bound to the single Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes +upload-and-analyze": {"scoped_aliases":{"task-uuid":"resume-id","uuid":"resume-id"},"block":["file-name","file-size","size-bytes","id","resume-ids","template-ids"],"scope_strict":true,"note":"--resume-id is one already-created taskUuid and --template-id is singular. Explicit taskUuid spellings may resume one task, while generic --id, list spellings, and atomic file metadata remain blocked against route/cardinality confusion."},
|
||||
"minutes +action-items": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."},
|
||||
"minutes +detail": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."},
|
||||
"minutes +export-pack": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."},
|
||||
"minutes +mindmap": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."},
|
||||
"minutes +record-wrap-up": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."},
|
||||
"minutes +summary": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."},
|
||||
"minutes +transcript": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."},
|
||||
"minutes +update": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."},
|
||||
"minutes update summary": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."},
|
||||
"minutes update title": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."},
|
||||
"todo task create-sub": {"bind":{"parent-id":"todo_task_id"},"scoped_aliases":{"parent-task-id":"parent-id"},"ambiguous":["task"],"scope_strict":true,"note":"This command has a parent taskId and a new child title. Reviewed task-id/todo-id spellings reduce to --parent-id, while role-free --task cannot choose between the parent target and child title and must stop before execution."},
|
||||
"todo task update": {"ambiguous":["task"],"note":"This command has both --task-id and --title. Role-free --task cannot safely choose the existing target identifier or replacement title."},
|
||||
"todo +update": {"ambiguous":["task"],"note":"This shortcut has both --task-id and --title. Role-free --task cannot safely choose the existing target identifier or replacement title."},
|
||||
"todo +todo-done": {"scoped_aliases":{"query":"task","keyword":"task","title":"task"},"block":["task-id","todo-id"],"scope_strict":true,"note":"The real --task is a title substring query, not taskId. Query/title spellings preserve the search text; stable IDs must use todo task done."},
|
||||
"todo task create": {"scoped_aliases":{"task":"title"},"note":"This creation command has one task-title role, so the exact --task spelling can preserve the title text without competing with an existing target taskId."},
|
||||
"todo +create": {"scoped_aliases":{"task":"title"},"note":"This creation shortcut has one task-title role, so the exact --task spelling can preserve the title text without competing with an existing target taskId."},
|
||||
"todo +assign": {"scoped_aliases":{"assignee-name":"to","title":"task"},"block":["executor-id","executor-ids","executors","user-id","user-ids"],"note":"This shortcut resolves one human name from --to and creates one title from --task. Exact name/title spellings preserve those roles; stable userId spellings must not be renamed into a name-resolution input."},
|
||||
"todo +assign-multi": {"scoped_aliases":{"assignee-names":"to","title":"task"},"block":["executor-id","executor-ids","executors","user-id","user-ids"],"note":"This shortcut resolves a comma-separated list of human names from --to and creates one title from --task. Exact name/title spellings preserve those roles; stable userId spellings must not be renamed into a name-resolution input."},
|
||||
"todo +remind": {"scoped_aliases":{"deadline":"at","due":"at","due-time":"at","title":"task"},"block":["remind-at","reminder-at","reminder-rules","reminder-time","reminder-time-stamp"],"scope_strict":true,"note":"This shortcut has one title role and its real --at writes the task deadline dueTime. Exact title/deadline spellings preserve those values; independent reminder spellings are rejected and must route to todo +reminder or todo task add-reminder/reset-reminder."},
|
||||
"todo +search": {"scoped_aliases":{"subject":"query","title":"query","task":"query"},"block":["limit","max-result","max-results","page","page-size","per-page","size","take","top"],"note":"This shortcut searches the task card subject/title substring across complete traversal. Subject/title/task spellings preserve query text; result-count and page-size names are not aliases for the real --max-pages safety cap."},
|
||||
"todo +complete": {"block":["done","is-done","status","todo-status"],"note":"The target state is fixed by the +complete command. A status flag would falsely imply caller-selectable state; use +reopen or todo task done for the opposite state."},
|
||||
"todo +reopen": {"block":["done","is-done","status","todo-status"],"note":"The target state is fixed by the +reopen command. A status flag would falsely imply caller-selectable state; use +complete or todo task done for the opposite state."},
|
||||
"todo +reminder": {"scoped_aliases":{"remind-at":"at","reminder-at":"at","reminder-time":"at","reminder-time-stamp":"at"},"block":["deadline","due","due-time","reminder-rules"],"note":"The real --at is the ISO-8601 customTime reminder timestamp. Reminder-time spellings preserve that value; task deadlines and a JSON reminder-rules array are different roles or structures."},
|
||||
"todo task add-reminder": {"scoped_aliases":{"at":"reminder-time-stamp","reminder-at":"reminder-time-stamp","reminder-time":"reminder-time-stamp"},"block":["deadline","due","due-time","reminder-rules"],"note":"This command writes one independent reminder rule. --at is the same ISO-8601 customTime value as --reminder-time-stamp; a task deadline or JSON rules array cannot be reduced to one atomic reminder field. The inherited real --remind-at remains command-owned and cannot be intercepted by PreParse."},
|
||||
"todo task reset-reminder": {"block":["at","base-time","deadline","due","due-date-offset","due-time","reminder-time-stamp"],"note":"This command accepts one --reminder-rules JSON array. Atomic reminder fields or task deadlines require value construction and are not name-only aliases. The inherited real --remind-at cannot be blocked by PreParse."},
|
||||
"todo task list": {"scoped_aliases":{"limit":"size","max-results":"size","page-number":"page","priorities":"priority"},"block":["plan-finish-end","plan-finish-start","priority-id","priority-name"],"scope_strict":true,"note":"Here --size is the total returned-item target and auto-pages beyond 20, so only total-limit spellings are mapped. --page-number preserves the one-based page value. The list-only --priorities spelling preserves the unchanged 10/20/30/40 filter values without claiming a global priority concept. The sibling shortcut's plan-finish-start/end use Unix milliseconds and are blocked because this command requires ISO-8601 date strings."},
|
||||
"todo +get-my-tasks": {"scoped_aliases":{"page-number":"page","page-size":"size","per-page":"size","priorities":"priority"},"block":["limit","max-result","max-results","plan-finish-date-end","plan-finish-date-start","priority-id","priority-name","take","top"],"note":"This shortcut's --size is per-page, and --all ignores pageSize; only explicit per-page spellings are aliases. Total-limit spellings are blocked. The list-only --priorities spelling preserves the unchanged 10/20/30/40 filter values without claiming a global priority concept. The command requires Unix millisecond plan-finish-start/end values, so the stable command's date-named ISO-8601 flags cannot be converted by name-only normalization."},
|
||||
"todo +list-comment": {"scoped_aliases":{"page-number":"page"},"note":"The explicit page-number spelling preserves the one-based page value; page-size remains handled by the reviewed pagination-size concept."},
|
||||
"todo comment list": {"scoped_aliases":{"page-number":"page"},"note":"The explicit page-number spelling preserves the one-based page value; page-size remains handled by the reviewed pagination-size concept."},
|
||||
"todo +get-related-tasks": {"scoped_aliases":{"todo-status":"status"},"block":["done","is-done"],"note":"This shortcut accepts the service enum status value such as TODO, not a boolean completion flag. The product-qualified spelling is safe; boolean spellings are rejected."},
|
||||
"todo tag add": {"block":["tag-ids","tag-names","tags"],"note":"The command requires existing tag code values. Generic tags may be names or IDs and are not safely equivalent to --tag-codes."},
|
||||
"todo tag delete": {"block":["tag-ids","tag-names","tags"],"note":"The command deletes tag definitions by existing tag code. Generic tags may be names or IDs and are not safely equivalent to --tag-codes."},
|
||||
"todo tag update": {"block":["tag-codes","tag-ids","tag-names","tags"],"note":"The real --user-tags is a JSON array of tag objects. A flat list of codes, names, or IDs cannot be renamed into that structure."},
|
||||
"wiki +resolve-space": {"bind":{"name":"search_query"},"scoped_aliases":{"knowledge-base-name":"name","space-name":"name","wiki-name":"name","workspace-name":"name"},"block":["id","knowledge-base-id","space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"--name is a knowledge-space name search string, not workspaceId. Search spellings preserve the unchanged text; ID spellings stop before lookup."},
|
||||
"wiki +wiki-new-doc": {"scoped_aliases":{"doc-title":"title","document-title":"title","knowledge-base-name":"space","space-name":"space","wiki-name":"space","workspace-name":"space"},"ambiguous":["name"],"block":["id","knowledge-base-id","space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"--space is a knowledge-space name search string and --title is the new document title. Role-free --name cannot choose between them, and workspace/storage IDs are not accepted by this name-resolving shortcut."},
|
||||
"wiki +node-get": {"scoped_aliases":{"doc-id":"node","document-id":"node","document-url":"node","id":"node","node-id":"node","url":"node","wiki-node-id":"node"},"block":["dentry-id","folder","folder-id","knowledge-base-id","space-id","workspace","workspace-id"],"scope_strict":true,"note":"This exact shortcut has one Wiki node target and explicitly accepts nodeId or URL. Numeric dentryId, folder and workspace identifiers are different roles."},
|
||||
"wiki +node-copy": {"scoped_aliases":{"destination-folder-id":"folder","document-id":"node","folder-id":"folder","node-id":"node","source-document-id":"node","source-node-id":"node","target-folder-id":"folder","wiki-node-id":"node"},"ambiguous":["destination-id","id","target-id"],"block":["dentry-id","source-folder-id","space-id"],"scope_strict":true,"note":"Source node, destination workspace and destination folder are separate ID roles. --node-id and explicit source/target spellings preserve values; role-free target IDs stop before copy."},
|
||||
"wiki +move": {"scoped_aliases":{"destination-folder-id":"folder","document-id":"node","folder-id":"folder","node-id":"node","source-document-id":"node","source-node-id":"node","target-folder-id":"folder","wiki-node-id":"node"},"ambiguous":["destination-id","id","target-id"],"block":["dentry-id","source-folder-id","space-id"],"scope_strict":true,"note":"Source node, destination workspace and destination folder are separate ID roles. --node-id and explicit source/target spellings preserve values; role-free target IDs stop before move."},
|
||||
"wiki +move-to-drive": {"scoped_aliases":{"destination-folder-id":"folder","document-id":"node","folder-id":"folder","node-id":"node","source-document-id":"node","source-node-id":"node","target-folder-id":"folder","wiki-node-id":"node"},"ambiguous":["destination-id","id","target-id"],"block":["dentry-id","knowledge-base-id","space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"The source is one Wiki node while --folder is a Drive destination folder. --node-id preserves the source value; workspace and storage-space identifiers are not substitutes for either role."},
|
||||
"wiki +node-delete": {"scoped_aliases":{"document-id":"node","node-id":"node","target-node-id":"node","wiki-node-id":"node"},"ambiguous":["id"],"block":["dentry-id","folder-id","space-id"],"scope_strict":true,"note":"The destructive target node and the workspace confirmation scope are distinct required IDs. --node-id is explicit and value-preserving; generic --id cannot choose one and must stop before confirmation."},
|
||||
"wiki node copy": {"scoped_aliases":{"destination-folder-id":"folder","document-id":"node","source-document-id":"node","source-node-id":"node","target-folder-id":"folder","wiki-node-id":"node"},"ambiguous":["destination-id","id","target-id"],"block":["dentry-id","source-folder-id","space-id"],"scope_strict":true,"note":"Native hidden node/folder/workspace compatibility flags remain native. These additional role-qualified spellings preserve the same values; generic target IDs remain ambiguous."},
|
||||
"wiki node move": {"scoped_aliases":{"destination-folder-id":"folder","document-id":"node","source-document-id":"node","source-node-id":"node","target-folder-id":"folder","wiki-node-id":"node"},"ambiguous":["destination-id","id","target-id"],"block":["dentry-id","source-folder-id","space-id"],"scope_strict":true,"note":"Native hidden node/folder/workspace compatibility flags remain native. These additional role-qualified spellings preserve the same values; generic target IDs remain ambiguous."},
|
||||
"wiki node delete": {"scoped_aliases":{"document-id":"node","target-node-id":"node","wiki-node-id":"node"},"ambiguous":["id"],"block":["dentry-id","folder-id","space-id"],"scope_strict":true,"note":"The node target and workspace authorization scope are distinct IDs. Native hidden node/workspace aliases remain native; generic --id must stop before deletion."},
|
||||
"wiki +node-list": {"scoped_aliases":{"directory-id":"folder","folder-id":"folder","next-cursor":"cursor","next-page-token":"cursor","next-token":"cursor","page-size":"limit","parent-folder-id":"folder","parent-node-id":"folder","per-page":"limit"},"ambiguous":["id","max-result","max-results","size","take","top"],"block":["dentry-id","node-id","offset","page","space-id"],"scope_strict":true,"note":"Workspace and optional parent folder are separate IDs. Per-page --limit and aggregate --max-items are separate limits; generic maximum spellings cannot choose one."},
|
||||
"wiki +node-create": {"scoped_aliases":{"directory-id":"folder","display-name":"name","folder-id":"folder","node-name":"name","parent-folder-id":"folder","parent-node-id":"folder","node-type":"type"},"ambiguous":["id"],"block":["dentry-id","node-id","query","space-id","space-type","wiki-space-type"],"scope_strict":true,"note":"Workspace, parent folder, node display name and node type are distinct roles. Node-type aliases stay local; search and space-type spellings are rejected."},
|
||||
"wiki node list": {"scoped_aliases":{"directory-id":"folder","folder-id":"folder"},"ambiguous":["id"],"block":["dentry-id","max-items","node-id","offset","page","page-all","page-delay","page-limit","space-id"],"scope_strict":true,"note":"Native hidden parent/pagination/workspace aliases remain native. This leaf is single-page; Shortcut auto-page controls must not be accepted on the native command."},
|
||||
"wiki node create": {"scoped_aliases":{"directory-id":"folder","display-name":"name","folder-id":"folder","node-name":"name","node-type":"type"},"ambiguous":["id"],"block":["dentry-id","node-id","query","space-id","space-type","wiki-space-type"],"scope_strict":true,"note":"Native hidden parent/workspace aliases remain native. Workspace, parent folder, display name and node type remain distinct roles."},
|
||||
"wiki +node-search": {"scoped_aliases":{"node-name":"query"},"block":["id","name","node-id","offset","page","space-id"],"scope_strict":true,"note":"The command searches within one workspace by unchanged keyword text. A node ID or role-free name is not a workspace-scoped search parameter."},
|
||||
"wiki node search": {"scoped_aliases":{"node-name":"query"},"block":["id","name","node-id","offset","page","space-id"],"scope_strict":true,"note":"Native hidden query/pagination/workspace aliases remain native. Search text, workspace and node identifiers are distinct roles."},
|
||||
"wiki +space-create": {"scoped_aliases":{"display-name":"name","knowledge-base-name":"name","space-name":"name","wiki-name":"name","workspace-name":"name"},"block":["node-name","query","title","workspace","workspace-id"],"scope_strict":true,"note":"This creates a new knowledge-base container. Name and plain description aliases preserve text; existing workspace IDs, search queries and node titles are different roles."},
|
||||
"wiki space create": {"scoped_aliases":{"display-name":"name","knowledge-base-name":"name","space-name":"name","wiki-name":"name","workspace-name":"name"},"block":["node-name","query","title","workspace","workspace-id"],"scope_strict":true,"note":"Native hidden --description remains native. This command creates a container; existing workspace IDs, search queries and node titles are not creation names."},
|
||||
"wiki +space-list": {"scoped_aliases":{"next-cursor":"cursor","next-page-token":"cursor","next-token":"cursor","page-size":"limit","per-page":"limit","space-type":"type","wiki-space-type":"type"},"ambiguous":["max-result","max-results","size","take","top"],"block":["id","knowledge-base-id","node-type","offset","page","space","space-id","storage-space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"This Shortcut lists only orgWikiSpace/myWikiSpace and has separate per-page and aggregate limits. Drive storage types belong to native wiki space list."},
|
||||
"wiki space list": {"scoped_aliases":{"space-type":"type"},"block":["id","knowledge-base-id","max-items","node-type","offset","page","page-all","page-delay","page-limit","space","space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"This composite native leaf can list Wiki or Drive storage spaces by --type but is single-page. A target workspace ID and Shortcut auto-page controls are not accepted."},
|
||||
"wiki +space-search": {"scoped_aliases":{"knowledge-base-name":"query","space-name":"query","wiki-name":"query","workspace-name":"query"},"block":["id","knowledge-base-id","space-id","space-type","type","wiki-space-type","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"This Shortcut searches organization knowledge bases by keyword and does not expose native --type myWikiSpace routing. IDs and type selectors must not be rewritten into query text."},
|
||||
"wiki space search": {"scoped_aliases":{"knowledge-base-name":"query","space-name":"query","space-type":"type","wiki-name":"query","wiki-space-type":"type","workspace-name":"query"},"block":["id","knowledge-base-id","space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"Native hidden query/pagination aliases remain native. Name spellings are unchanged search text, while --type myWikiSpace is a distinct route selector."},
|
||||
"wiki +space-get": {"scoped_aliases":{"id":"workspace"},"block":["folder-id","node-id","space-id","storage-space-id"],"scope_strict":true,"note":"This exact command has one knowledge-base workspace target, so role-free --id is safe locally. Drive storage-space, node and folder IDs remain separate domains."},
|
||||
"wiki space get": {"scoped_aliases":{"id":"workspace"},"block":["folder-id","node-id","space-id","storage-space-id"],"scope_strict":true,"note":"Native --workspace-id remains a hidden compatibility flag. This exact leaf has one knowledge-base workspace target; other ID domains are blocked."},
|
||||
"wiki +delete-space": {"scoped_aliases":{"id":"workspace"},"block":["folder-id","node-id","space-id","storage-space-id"],"scope_strict":true,"note":"The destructive command has one confirmed knowledge-base workspace target. Drive storage-space, node and folder IDs must stop before confirmation."},
|
||||
"wiki space delete": {"scoped_aliases":{"id":"workspace"},"block":["folder-id","node-id","space-id","storage-space-id"],"scope_strict":true,"note":"Native --workspace-id remains a hidden compatibility flag. The destructive target is one knowledge-base workspace, not a Drive space or child node."},
|
||||
"wiki +feed-list": {"scoped_aliases":{"page-size":"limit","per-page":"limit"},"ambiguous":["max-result","max-results","size","take","top"],"block":["id","node-id","offset","page","space-id"],"scope_strict":true,"note":"Per-page --limit and aggregate --max-items are separate bounds. Feed scope is one workspace; node and storage-space IDs are different roles."},
|
||||
"wiki feed list": {"scoped_aliases":{"id":"workspace"},"block":["max-items","node-id","offset","page","page-all","page-delay","page-limit","space-id"],"scope_strict":true,"note":"Native hidden workspace/pagination aliases remain native. This leaf is single-page, and Shortcut auto-page controls are not accepted."},
|
||||
"wiki +member-add": {"scoped_aliases":{"uid":"user","user-id":"user"},"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Workspace and a 1-30 item userId list are separate ID roles. --user-id/--uid value-preservingly normalize to the real repeatable --user alias; openDingTalkId and staffId spellings remain blocked."},
|
||||
"wiki member add": {"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Only canonical --users and the manually wired hidden --user are runtime-consumed. Auto-registered real hidden --user-id/--uid/--user-ids/--user-list/--user-id-list bypass central rewriting but are not read by collectUserIDs; use --users until command-owned wiring is fixed."},
|
||||
"wiki +member-update": {"scoped_aliases":{"uid":"user","user-id":"user"},"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Workspace and a 1-30 item userId list are separate ID roles. --user-id/--uid value-preservingly normalize to the real repeatable --user alias; openDingTalkId and staffId spellings remain blocked."},
|
||||
"wiki member update": {"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Only canonical --users and the manually wired hidden --user are runtime-consumed. Auto-registered real hidden --user-id/--uid/--user-ids/--user-list/--user-id-list bypass central rewriting but are not read by collectUserIDs; use --users until command-owned wiring is fixed."},
|
||||
"wiki +member-remove": {"scoped_aliases":{"uid":"user","user-id":"user"},"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Workspace and a 1-30 item userId list are separate ID roles. --user-id/--uid value-preservingly normalize to the real repeatable --user alias; openDingTalkId and staffId spellings remain blocked."},
|
||||
"wiki member remove": {"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Only canonical --users and the manually wired hidden --user are runtime-consumed. Auto-registered real hidden --user-id/--uid/--user-ids/--user-list/--user-id-list bypass central rewriting but are not read by collectUserIDs; use --users until command-owned wiring is fixed."},
|
||||
"wiki +member-list": {"scoped_aliases":{"filter-roles":"filter-role","role-filter":"filter-role","roles":"filter-role"},"ambiguous":["id"],"block":["cursor","name","open-dingtalk-id","page-all","role","user-id","user-ids","users"],"scope_strict":true,"note":"This command lists members and can filter by a role list. It does not accept user filters or cursor auto-pagination, and workspace/user IDs must not be conflated."},
|
||||
"wiki member list": {"scoped_aliases":{"filter-roles":"filter-role","role-filter":"filter-role","roles":"filter-role"},"ambiguous":["id"],"block":["cursor","name","open-dingtalk-id","page-all","role","user-id","user-ids","users"],"scope_strict":true,"note":"Native hidden workspace/pagination aliases remain native. The endpoint has no user filter or cursor and returns at most 50 members."}
|
||||
},
|
||||
|
||||
"validation_fixture": {
|
||||
@@ -842,7 +960,150 @@
|
||||
{"command":"calendar +update","emitted":"remove-user-ids","expect":"remove-attendees","via":"override:scoped_strict"},
|
||||
{"command":"calendar +update","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:event-id-vs-calendar-id"},
|
||||
{"command":"calendar +update","emitted":"attendees","expect":"did-you-mean:ambiguous","via":"guard:add-vs-remove-role"},
|
||||
{"command":"calendar +update","emitted":"room-ids","expect":"did-you-mean:blocked","via":"guard:dedicated-room-command"}
|
||||
{"command":"calendar +update","emitted":"room-ids","expect":"did-you-mean:blocked","via":"guard:dedicated-room-command"},
|
||||
{"command":"minutes update title","emitted":"task-uuid","expect":"id","via":"concept:minutes_task_uuid"},
|
||||
{"command":"minutes +record-pause","emitted":"uuid","expect":"id","via":"concept:minutes_task_uuid"},
|
||||
{"command":"minutes +latest","emitted":"query","expect":"keyword","via":"concept:search_query"},
|
||||
{"command":"minutes +transcript","emitted":"query","expect":"keyword","via":"concept:search_query+mutually-exclusive-id"},
|
||||
{"command":"minutes +detail","emitted":"task-uuids","expect":"ids","via":"concept:minutes_task_uuids"},
|
||||
{"command":"minutes get batch","emitted":"uuids","expect":"ids","via":"concept:minutes_task_uuids"},
|
||||
{"command":"minutes +list-all","emitted":"page-size","expect":"limit","via":"concept:pagination_size"},
|
||||
{"command":"minutes +search","emitted":"next-token","expect":"cursor","via":"concept:page_cursor"},
|
||||
{"command":"minutes list mine","emitted":"start-time","expect":"start","via":"concept:time_start"},
|
||||
{"command":"minutes list all","emitted":"end-time","expect":"end","via":"concept:time_end"},
|
||||
{"command":"minutes +summary","emitted":"text","expect":"content","via":"concept:content_text"},
|
||||
{"command":"minutes hot-word add","emitted":"hot-words","expect":"words","via":"concept:minutes_hot_words"},
|
||||
{"command":"minutes upload complete","emitted":"upload-id","expect":"session-id","via":"concept:minutes_upload_session_id"},
|
||||
{"command":"minutes +share","emitted":"user-ids","expect":"member-uids","via":"override:bind-user-id-list"},
|
||||
{"command":"minutes +share","emitted":"user-id","expect":"did-you-mean:blocked","via":"guard:member-list-cardinality"},
|
||||
{"command":"minutes +speaker-replace","emitted":"user-id","expect":"target-uid","via":"override:bind-target-user"},
|
||||
{"command":"minutes +speaker-replace","emitted":"staff-id","expect":"did-you-mean:blocked","via":"guard:user-id-value-domain"},
|
||||
{"command":"minutes audio-memo list","emitted":"limit","expect":"max","via":"override:scoped-page-size"},
|
||||
{"command":"minutes audio-memo list","emitted":"next-token","expect":"did-you-mean:blocked","via":"guard:integer-cursor-vs-token"},
|
||||
{"command":"minutes tag query","emitted":"id","expect":"tag-id","via":"override:scoped-tag-id"},
|
||||
{"command":"minutes replace-text","emitted":"original-text","expect":"search","via":"override:scoped-search-role"},
|
||||
{"command":"minutes replace-text","emitted":"text","expect":"did-you-mean:blocked","via":"guard:search-vs-replacement-role"},
|
||||
{"command":"minutes +replace-batch","emitted":"pairs","expect":"pair","via":"override:scoped-pair-cardinality"},
|
||||
{"command":"minutes +replace-batch","emitted":"search","expect":"did-you-mean:blocked","via":"guard:requires-pair-value-composition"},
|
||||
{"command":"minutes +download","emitted":"destination","expect":"did-you-mean:ambiguous","via":"guard:output-role"},
|
||||
{"command":"minutes +detail","emitted":"url","expect":"did-you-mean:blocked","via":"guard:url-needs-value-extraction"},
|
||||
{"command":"minutes +apply-permission","emitted":"policy","expect":"did-you-mean:blocked","via":"guard:permission-value-conversion"},
|
||||
{"command":"minutes permission apply","emitted":"permission","expect":"did-you-mean:blocked","via":"guard:permission-value-conversion"},
|
||||
{"command":"minutes upload create","emitted":"file","expect":"did-you-mean:blocked","via":"guard:file-path-needs-orchestration"},
|
||||
{"command":"minutes +upload","emitted":"file-size","expect":"did-you-mean:blocked","via":"guard:file-metadata-vs-path"},
|
||||
{"command":"minutes record start","emitted":"upload-id","expect":"did-you-mean:blocked","via":"guard:session-value-domain"},
|
||||
{"command":"minutes +upload-and-analyze","emitted":"task-uuid","expect":"resume-id","via":"override:resume-task-uuid"},
|
||||
{"command":"minutes get info","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"},
|
||||
{"command":"minutes get summary","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"},
|
||||
{"command":"minutes get keywords","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"},
|
||||
{"command":"minutes get transcription","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"},
|
||||
{"command":"minutes get todos","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"},
|
||||
{"command":"minutes get audio","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"},
|
||||
{"command":"minutes mind-graph create","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"},
|
||||
{"command":"minutes mind-graph status","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"},
|
||||
{"command":"minutes permission apply","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"},
|
||||
{"command":"minutes permission add","emitted":"id","expect":"did-you-mean:blocked","via":"guard:list-vs-single-task-uuid"},
|
||||
{"command":"minutes permission remove","emitted":"id","expect":"did-you-mean:blocked","via":"guard:list-vs-single-task-uuid"},
|
||||
{"command":"minutes upload complete","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-upload-session"},
|
||||
{"command":"minutes upload cancel","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-upload-session"},
|
||||
{"command":"minutes record start","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"},
|
||||
{"command":"minutes record pause","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"},
|
||||
{"command":"minutes record resume","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"},
|
||||
{"command":"minutes record stop","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"},
|
||||
{"command":"minutes +record-start","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"},
|
||||
{"command":"minutes +record-pause","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"},
|
||||
{"command":"minutes +record-resume","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"},
|
||||
{"command":"minutes +record-stop","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"},
|
||||
{"command":"minutes speaker replace","emitted":"target-uids","expect":"did-you-mean:blocked","via":"guard:single-target-user"},
|
||||
{"command":"minutes +speaker-replace","emitted":"target-uids","expect":"did-you-mean:blocked","via":"guard:single-target-user"},
|
||||
{"command":"minutes tag query","emitted":"tag-ids","expect":"did-you-mean:blocked","via":"guard:single-tag"},
|
||||
{"command":"minutes +speaker-insights","emitted":"task-ids","expect":"did-you-mean:blocked","via":"guard:single-async-task"},
|
||||
{"command":"minutes +upload-and-analyze","emitted":"resume-ids","expect":"did-you-mean:blocked","via":"guard:single-resume-task"},
|
||||
{"command":"minutes upload create","emitted":"template-ids","expect":"did-you-mean:blocked","via":"guard:single-template"},
|
||||
{"command":"minutes +upload","emitted":"template-ids","expect":"did-you-mean:blocked","via":"guard:single-template"},
|
||||
{"command":"minutes +upload-and-analyze","emitted":"template-ids","expect":"did-you-mean:blocked","via":"guard:single-template"},
|
||||
{"command":"todo task get","emitted":"todo-id","expect":"task-id","via":"concept:todo_task_id"},
|
||||
{"command":"todo task get","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-id"},
|
||||
{"command":"todo task get","emitted":"task-ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-id"},
|
||||
{"command":"todo task get","emitted":"todo-ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-id"},
|
||||
{"command":"todo task create-sub","emitted":"task-id","expect":"parent-id","via":"concept:todo_task_id+bind"},
|
||||
{"command":"todo task create-sub","emitted":"task-ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-parent-task-id"},
|
||||
{"command":"todo task create-sub","emitted":"parent-task-id","expect":"parent-id","via":"override:parent-task-id"},
|
||||
{"command":"todo task create-sub","emitted":"task","expect":"did-you-mean:ambiguous","via":"guard:parent-target-vs-child-title"},
|
||||
{"command":"todo task create","emitted":"task","expect":"title","via":"override:creation-title"},
|
||||
{"command":"todo +create","emitted":"task","expect":"title","via":"override:creation-title"},
|
||||
{"command":"todo +assign","emitted":"title","expect":"task","via":"override:creation-title"},
|
||||
{"command":"todo +assign-multi","emitted":"title","expect":"task","via":"override:creation-title"},
|
||||
{"command":"todo +assign","emitted":"assignee-name","expect":"to","via":"override:human-name"},
|
||||
{"command":"todo +assign-multi","emitted":"assignee-names","expect":"to","via":"override:human-name-list"},
|
||||
{"command":"todo +update","emitted":"task","expect":"did-you-mean:ambiguous","via":"guard:target-id-vs-title"},
|
||||
{"command":"todo task update","emitted":"task","expect":"did-you-mean:ambiguous","via":"guard:target-id-vs-title"},
|
||||
{"command":"todo +remind","emitted":"due","expect":"at","via":"override:deadline-to-at"},
|
||||
{"command":"todo +remind","emitted":"title","expect":"task","via":"override:creation-title"},
|
||||
{"command":"todo task create","emitted":"deadline","expect":"due","via":"concept:todo_due_time"},
|
||||
{"command":"todo +create","emitted":"deadline","expect":"due","via":"concept:todo_due_time"},
|
||||
{"command":"todo task add-executor","emitted":"executor-ids","expect":"executors","via":"concept:todo_executor_ids"},
|
||||
{"command":"todo task add-executor","emitted":"executor-id","expect":"did-you-mean:blocked","via":"guard:single-vs-list-executor"},
|
||||
{"command":"todo task add-executor","emitted":"participant-id","expect":"did-you-mean:blocked","via":"guard:executor-vs-participant-role"},
|
||||
{"command":"todo +create","emitted":"executor-ids","expect":"executors","via":"concept:todo_executor_ids"},
|
||||
{"command":"todo task add-participant","emitted":"participant-ids","expect":"participants","via":"concept:todo_participant_ids"},
|
||||
{"command":"todo task add-participant","emitted":"participant-id","expect":"did-you-mean:blocked","via":"guard:single-vs-list-participant"},
|
||||
{"command":"todo task add-participant","emitted":"executor-id","expect":"did-you-mean:blocked","via":"guard:participant-vs-executor-role"},
|
||||
{"command":"todo task update","emitted":"status","expect":"done","via":"concept:todo_completion_state"},
|
||||
{"command":"todo task update","emitted":"is-done","expect":"done","via":"concept:todo_completion_state"},
|
||||
{"command":"todo task done","emitted":"done","expect":"status","via":"concept:todo_completion_state"},
|
||||
{"command":"todo +search","emitted":"done","expect":"status","via":"concept:todo_completion_state"},
|
||||
{"command":"todo +search","emitted":"todo-status","expect":"status","via":"concept:todo_completion_state"},
|
||||
{"command":"todo +get-related-tasks","emitted":"todo-status","expect":"status","via":"override:enum-status"},
|
||||
{"command":"todo +get-related-tasks","emitted":"done","expect":"did-you-mean:blocked","via":"guard:boolean-vs-enum-status"},
|
||||
{"command":"todo +due-today","emitted":"roles","expect":"role-types","via":"concept:todo_role_types"},
|
||||
{"command":"todo +get-my-tasks","emitted":"roles","expect":"role-types","via":"concept:todo_role_types"},
|
||||
{"command":"todo +get-related-tasks","emitted":"roles","expect":"role-types","via":"concept:todo_role_types"},
|
||||
{"command":"todo task list","emitted":"roles","expect":"role-types","via":"concept:todo_role_types"},
|
||||
{"command":"todo +get-my-tasks","emitted":"priorities","expect":"priority","via":"override:priority-list"},
|
||||
{"command":"todo comment add","emitted":"text","expect":"content","via":"concept:content_text"},
|
||||
{"command":"todo +comment","emitted":"body","expect":"content","via":"concept:content_text"},
|
||||
{"command":"todo comment list","emitted":"page-size","expect":"size","via":"concept:pagination_size"},
|
||||
{"command":"todo +get-my-tasks","emitted":"current-page","expect":"page","via":"concept:page_number"},
|
||||
{"command":"todo +get-my-tasks","emitted":"page-number","expect":"page","via":"override:page-number"},
|
||||
{"command":"todo +get-my-tasks","emitted":"page-size","expect":"size","via":"override:per-page-size"},
|
||||
{"command":"todo +get-my-tasks","emitted":"max-results","expect":"did-you-mean:blocked","via":"guard:total-vs-per-page"},
|
||||
{"command":"todo +list-comment","emitted":"page-number","expect":"page","via":"override:page-number"},
|
||||
{"command":"todo comment list","emitted":"page-number","expect":"page","via":"override:page-number"},
|
||||
{"command":"todo task list","emitted":"limit","expect":"size","via":"override:limit"},
|
||||
{"command":"todo task list","emitted":"page-number","expect":"page","via":"override:page-number"},
|
||||
{"command":"todo +todo-done","emitted":"query","expect":"task","via":"override:query"},
|
||||
{"command":"todo +search","emitted":"keyword","expect":"query","via":"concept:search_query"},
|
||||
{"command":"todo +search","emitted":"title","expect":"query","via":"override:title-query"},
|
||||
{"command":"todo +search","emitted":"subject","expect":"query","via":"override:subject-query"},
|
||||
{"command":"todo +reminder","emitted":"reminder-time-stamp","expect":"at","via":"override:custom-reminder-time"},
|
||||
{"command":"todo task add-reminder","emitted":"at","expect":"reminder-time-stamp","via":"override:custom-reminder-time"},
|
||||
{"command":"todo +update","emitted":"todo-id","expect":"task-id","via":"concept:todo_task_id"},
|
||||
{"command":"todo +complete","emitted":"todo-id","expect":"task-id","via":"concept:todo_task_id"},
|
||||
{"command":"todo +reopen","emitted":"todo-id","expect":"task-id","via":"concept:todo_task_id"},
|
||||
{"command":"todo +reminder","emitted":"todo-id","expect":"task-id","via":"concept:todo_task_id"},
|
||||
{"command":"todo +todo-done","emitted":"task-id","expect":"did-you-mean:blocked","via":"guard:title-query-vs-task-id"},
|
||||
{"command":"todo task list","emitted":"plan-finish-start","expect":"did-you-mean:blocked","via":"guard:iso-vs-unix-millis"},
|
||||
{"command":"todo +get-my-tasks","emitted":"plan-finish-date-start","expect":"did-you-mean:blocked","via":"guard:unix-millis-vs-iso"},
|
||||
{"command":"todo +remind","emitted":"reminder-time-stamp","expect":"did-you-mean:blocked","via":"guard:deadline-vs-reminder"},
|
||||
{"command":"todo +reminder","emitted":"deadline","expect":"did-you-mean:blocked","via":"guard:deadline-vs-reminder"},
|
||||
{"command":"todo task reset-reminder","emitted":"due-date-offset","expect":"did-you-mean:blocked","via":"guard:atomic-vs-json-rules"},
|
||||
{"command":"todo tag update","emitted":"tag-codes","expect":"did-you-mean:blocked","via":"guard:flat-list-vs-json"},
|
||||
{"command":"todo +search","emitted":"page-size","expect":"did-you-mean:blocked","via":"guard:max-pages-vs-page-size"},
|
||||
{"command":"todo +complete","emitted":"status","expect":"did-you-mean:blocked","via":"guard:fixed-target-state"},
|
||||
{"command":"todo +complete","emitted":"is-done","expect":"did-you-mean:blocked","via":"guard:fixed-target-state"},
|
||||
{"command":"todo +reopen","emitted":"done","expect":"did-you-mean:blocked","via":"guard:fixed-target-state"},
|
||||
{"command":"todo +reopen","emitted":"todo-status","expect":"did-you-mean:blocked","via":"guard:fixed-target-state"},
|
||||
{"command":"wiki +node-copy","emitted":"node-id","expect":"node","via":"override:scoped_strict"},
|
||||
{"command":"wiki +move","emitted":"node-id","expect":"node","via":"override:scoped_strict"},
|
||||
{"command":"wiki +move-to-drive","emitted":"node-id","expect":"node","via":"override:scoped_strict"},
|
||||
{"command":"wiki +node-delete","emitted":"node-id","expect":"node","via":"override:scoped_strict"},
|
||||
{"command":"wiki +member-add","emitted":"user-id","expect":"user","via":"override:scoped_strict"},
|
||||
{"command":"wiki +member-add","emitted":"uid","expect":"user","via":"override:scoped_strict"},
|
||||
{"command":"wiki +member-update","emitted":"user-id","expect":"user","via":"override:scoped_strict"},
|
||||
{"command":"wiki +member-update","emitted":"uid","expect":"user","via":"override:scoped_strict"},
|
||||
{"command":"wiki +member-remove","emitted":"user-id","expect":"user","via":"override:scoped_strict"},
|
||||
{"command":"wiki +member-remove","emitted":"uid","expect":"user","via":"override:scoped_strict"}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -579,8 +579,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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
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,508 @@ 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, ¤t, &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(¤t, "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)
|
||||
}
|
||||
|
||||
// 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 +1271,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(¤t, "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{
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
@@ -1367,6 +1394,328 @@ func normalizeSchemaFlagMigrations(
|
||||
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.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.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 +1827,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 +2305,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 == "" {
|
||||
|
||||
@@ -1739,11 +1739,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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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` — 创建智能会话分组(可指定群名称关键词和成员作为匹配规则)
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -57,8 +57,10 @@
|
||||
| `message add-emoji` / `remove-emoji` | 默认 emoji reaction |
|
||||
| `message create-text-emotion` / `add-text-emotion` / `update-text-emotion` / `remove-text-emotion` | 文字表情 |
|
||||
| `message list-emotion-replies` | 批量 reaction/文字回应 |
|
||||
| `emotion list` / `send` / `favorite` | 当前用户个人收藏表情列表、发送和新增 |
|
||||
|
||||
Favorite、消息 Pin、消息 Top 与会话 Top 是四种对象,不能互换。
|
||||
个人收藏表情与消息 reaction/文字回应不同;发送收藏表情使用 `chat emotion send`,给已有消息贴表情使用 `chat message add-emoji` 或 `chat message add-text-emotion`。
|
||||
|
||||
## 群与成员底层能力
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dingtalk-doc
|
||||
description: 钉钉在线文字文档(adoc)本体及其内容的操作:查找、创建、读取、文档信息、编辑、块、评论、附件与媒体、白板卡片、导入、导出(docx/markdown/pdf)、版本、模板、权限、分享及Markdown/JSONML写入。不包括:文档空间与钉盘的文件管理(归 dingtalk-drive,doc 同名原子命令已弃用)、知识库空间与节点管理(归 dingtalk-wiki)、原生 .md 文件读写(归 dingtalk-misc)、电子表格 axls(归 dingtalk-misc)、AI 表格 able(归 dingtalk-aitable)。命令前缀:dws doc。
|
||||
description: 钉钉在线文字文档(adoc)的查找创建、读写、文档信息、白板卡片、附件、评论、媒体、导入导出、版本、模板、权限分享;本地文件转在线文档或协作编辑归 dingtalk-doc。原文件/文件夹归 dingtalk-drive,知识库归 dingtalk-wiki,原生 .md/axls 归 dingtalk-misc,able 归 dingtalk-aitable。前缀:dws doc。
|
||||
metadata:
|
||||
cli_version: ">=0.2.14"
|
||||
category: product
|
||||
@@ -35,21 +35,23 @@ metadata:
|
||||
|
||||
## Golden Route
|
||||
|
||||
选择最小入口。ID/URL 直用;只有标题时先搜索,唯一候选后执行,禁止默认第一项。
|
||||
走最小入口:ID/URL 直用;标题先搜索,唯一命中再执行。成本顺序:稳定 ID → shortcut → 局部读 → 精确写;禁以产品 Schema、全文或原子命令起步。
|
||||
|
||||
| 用户意图 | 唯一推荐入口 | 关键边界 |
|
||||
|---|---|---|
|
||||
| 按标题或主题定位文档 | `dws doc +search --query <关键词>` | 检查候选类型与分页;需要正文时再用真实 `nodeId` 执行 `+fetch` |
|
||||
| 已知 ID/URL 读取正文或局部内容 | `dws doc +fetch --node <ID或URL>` | `--scope keyword/section/range` 可减少无关正文;非 adoc 切换对应产品 |
|
||||
| 最近访问文档 | `dws doc +search`(省略 `--query`) | `--limit` 为每页量,`--max-items` 为总上限;完整集合才加 `--page-all` 并检查 `complete` |
|
||||
| 已知 ID/URL 读取正文或局部内容 | `dws doc +fetch --node <ID或URL>` | 具体术语直用 `keyword`;章节先 `outline` 再 `section`;只有整篇任务才用默认 `full` |
|
||||
| 聚合查看信息、权限、版本、媒体或评论 | `dws doc +inspect --node <ID或URL>` | 仅打开任务所需的 `--include-*`,不要默认全取 |
|
||||
| 新建在线文字文档并写入内容 | `dws doc +create --name <标题> --content @<相对文件>` | 长或复杂 Markdown 使用 `@file`/stdin;根据结果验证真实 `nodeId` |
|
||||
| 追加、覆盖或精确编辑 block | `dws doc +update --node <ID或URL> --command <动作>` | `--expected-revision` 仅用于 JSONML 原子覆盖;先消费 Schema confirmation |
|
||||
| 新建在线文字文档并写入内容 | `dws doc +create --name <标题> --content @<相对文件>` | 先在本地完成正文;Runtime 负责 Markdown 分片和回读,禁止 Agent 自行拆成多次远程写入 |
|
||||
| 追加、覆盖或精确编辑 block | `dws doc +update --node <ID或URL> --command <动作>` | 唯一文本直接 `str_replace`;章节/block 先局部取 ID 再精准修改;整篇覆盖才用 overwrite |
|
||||
| 重要内容更新且需要恢复点 | `dws doc +checkpoint-update` | 自动保存版本、更新并回读;检查 `steps` 和 `compensation` |
|
||||
| 版本操作 | `dws doc +version-save --node` / `dws doc +version-list --node` / `dws doc +version-revert --node --version` | 快照/列表/回滚 |
|
||||
| 导出为 docx/markdown/pdf | `dws doc +export --export-format <格式>` | 格式必须显式指定;普通文件下载切 `dingtalk-drive` |
|
||||
| 导入本地文件为在线对象 | `dws doc +import --file <相对路径>` | folder/workspace 可选,缺省导入默认根;纯上传切 `dingtalk-drive` |
|
||||
| 浏览模板 | `dws doc +template-list [--source MY\|PUBLIC]` | 没有名称/关键词时使用;不调用 search/create |
|
||||
| 搜索模板 | `dws doc +template-search --query <名称或关键词>` | 有查询词才使用;零或多候选停止 |
|
||||
| 本地文件转在线文档 | `dws doc +import --file <相对路径>` | “传上去/放进文件夹”不改变路由;在线改、协作编辑或转换用 import,仅保留原文件切 `dingtalk-drive` |
|
||||
| 封面/背景 | `+resource-update/+resource-delete`;`+background-update/+background-delete` | 写后 `+inspect --include-style`;禁查 Catalog |
|
||||
| 浏览模板 | `dws doc +template-list [--source MY\|PUBLIC]` | “我的/我这边”只查 MY;明确公开才查 PUBLIC;“有哪些/全部”翻页至完整 |
|
||||
| 搜索模板 | `dws doc +template-search --query <名称或关键词>` | 来源可选 MY/PUBLIC;零命中停止,禁止拿无关模板替代;多候选消歧 |
|
||||
| 从模板创建 | `dws doc +create-from-template --template-id <唯一ID>` | 已有唯一 templateId 才创建;不重复 list/search |
|
||||
| 创建评论或聚合待处理评论 | `dws doc +comment-create [--selection]` / `+review` | 划词统一用 `+comment-create`;后续操作使用真实 `commentKey` |
|
||||
| 添加/调整/移除协作者权限 | `dws doc +access-grant/+access-change/+access-revoke` | 先读取现有权限;姓名歧义或 profile 不一致时禁止写入 |
|
||||
@@ -58,18 +60,20 @@ metadata:
|
||||
|
||||
## 关键结果语义
|
||||
|
||||
- 保留真实 `nodeId`、URL、资源类型和容器;标题不能替代稳定 ID。
|
||||
- 按状态恢复:`partial_success` 只补未完成步骤;`unknown` 先回读且不重试写入;`retryable` 仅限明确未开始;权限、参数、认证失败停止。
|
||||
- 保留真实 `nodeId`、URL、资源类型和容器;同轮创建后直接复用返回 ID,禁止再按标题或钉盘搜索。
|
||||
- 先消费完整回执;Runtime 已分片/回读时不重复读取。只有局部验收、`partial_success` 或 commit-unknown 才再 `+fetch`。
|
||||
- 状态恢复:`partial_success` 只补未完成步骤;`unknown` 先回读、不重试写;`retryable` 仅限明确未开始;权限/参数/认证失败停止。
|
||||
- 仅在结果明确且关键内容回读匹配后报告写入完成。
|
||||
- 搜索、列表和批量结果必须检查 `complete`、`hasMore`、continuation 和失败项;单页结果不得表述为完整集合。
|
||||
- 搜索/列表检查 `complete`、`hasMore`、cursor 和失败项。“有哪些/列出/全部”逐页至完整;只有示例/前 N 条可提前停止并声明范围。
|
||||
- `+import` 已含上传、转换和轮询;成功检查 `success=true`、`taskId`、`documentUrl`。超时或中断保留 `taskId`、查询原任务,禁止重导。
|
||||
- 导出/下载仅用工作目录相对路径,默认不覆盖并原子落盘。
|
||||
|
||||
## 参数与安全边界
|
||||
|
||||
- `@file` 统一协议:已有或临时文件先暂存到 cwd 后传 `@相对路径`;单次生成文本优先 `--content -` (stdin);禁止绝对路径和 `..`。
|
||||
- `@file`:已有或临时文件先暂存到 cwd;传 `@相对路径`,禁绝对路径和 `..`。
|
||||
- `doc +update` 的动作由 `--command` 指定;block 操作的 ID 必须来自 `+fetch --detail with-ids` 或真实 block 列表。
|
||||
- Schema 门禁:已知只读路由且参数明确时直接执行;写命令或 selection/参数不清、准备 Help 时,本轮仅查一次 `dws schema --cli-path "doc +<leaf>" --compact --fields use_when,avoid_when,parameters,constraints,confirmation -f json`;禁用产品级/`--all`。
|
||||
- 执行前消费其中的 `confirmation`:`user_required` 且用户已确认同一目标/动作/参数才加 `--yes`,否则预览/询问;禁止靠失败探测门禁。
|
||||
- Schema 门禁:不确定时仅查一次精确 leaf:`--fields use_when,avoid_when,parameters,constraints,confirmation`;禁用产品级/`--all`。准备 Help 时,本轮仅查一次。
|
||||
- 消费本页或精确 Schema 的 `confirmation`:`user_required` 且原请求/预授权已确认目标、动作、参数时,首调即加 `--yes`;否则预览/询问;禁止靠失败探测门禁。
|
||||
- JSONML 顶层必须是单个非空元素;禁止 `[[...]]` 元素数组包裹。
|
||||
|
||||
## 按需加载
|
||||
@@ -80,25 +84,24 @@ Golden Route 已给出命令且参数足够时,禁止读取 reference。其余
|
||||
|---|---|
|
||||
| 低频/无 shortcut 意图消歧 | [intent-guide.md](references/intent-guide.md) / [doc.md](references/doc.md) 对应章节 |
|
||||
| 分页、`partial_success`、`status=unknown` 或恢复 | [contracts.md](references/contracts.md) |
|
||||
| 复杂 JSONML/非默认操作 | [create](references/doc/doc-create.md) / [read](references/doc/doc-read.md) / [update](references/doc/doc-update.md) |
|
||||
| block/划词评论/媒体高级参数 | [block](references/doc/doc-block.md) / [comment](references/doc/doc-comment.md) / [media](references/doc/doc-media.md) |
|
||||
| 复杂 JSONML、长文或局部精准读写 | [create](references/doc/doc-create.md) / [read](references/doc/doc-read.md) / [update](references/doc/doc-update.md) |
|
||||
| block/划词评论/媒体/封面/背景高级参数 | [block](references/doc/doc-block.md) / [comment](references/doc/doc-comment.md) / [media](references/doc/doc-media.md) |
|
||||
| 导出/导入失败恢复 | [export](references/doc/doc-export.md) / [import](references/doc/doc-import.md) |
|
||||
|
||||
`+create`、`+fetch`、`+update` append/overwrite、`+export`、`+import` 禁止读取 reference;禁止预加载或连读。
|
||||
常规 `+create`、`+fetch`、`+update` append/overwrite、`+export`、`+import` 禁止读取 reference;需策略时仅读上表一项,禁预加载/连读。
|
||||
|
||||
## 错误最短路径
|
||||
|
||||
1. 零命中、多候选、类型不明或分页不完整:停止后续写入,展示候选或 continuation;禁止默认第一项。
|
||||
2. Help 不参与选路;按上方门禁先消费一次精确 leaf Schema。只有真实 `unknown flag`/契约漂移后才查一次 leaf Help;`unknown command` 只查一次 shortcut 清单,禁止试探后缀和 `dws doc --help | grep/head`。
|
||||
3. `REVISION_CONFLICT`:重新读取当前 revision,展示差异;未经用户确认不得改成无 revision 覆盖。
|
||||
4. `partial_success`:保留已完成步骤,优先执行补偿或从 checkpoint 继续,不重放成功步骤。
|
||||
5. `doc_write_commit_unknown`:先回读文档确认状态;禁止自动重试创建或追加。
|
||||
6. 认证、权限或 profile 错误:只读取 `dingtalk-shared` 对应 reference;不要尝试同义底层命令绕过。
|
||||
7. 导出或媒体失败:保留 `jobId/resourceId/nodeId` 后停止;禁止 `curl`、安装依赖、Python 文档库或手写 HTTP 兜底。
|
||||
4. `doc_write_commit_unknown`:先回读;禁止自动重试创建或追加。
|
||||
5. 认证、权限或 profile 错误:只读 `dingtalk-shared` 对应 reference,不用底层命令绕过。
|
||||
6. 导出/媒体失败:保留稳定 ID 后停止;禁止网络请求、安装依赖或本地文档库兜底。
|
||||
|
||||
## 跨产品边界
|
||||
|
||||
- 普通文件、目录、纯上传下载、节点存储权限 → `dingtalk-drive`
|
||||
- 普通文件/目录/纯上传下载/节点权限 → `dingtalk-drive`;保留原文件用原子命令 `dws drive upload --workspace`,在线转换用 `doc +import --workspace`
|
||||
- 知识库空间、节点层级和成员管理 → `dingtalk-wiki`
|
||||
- 原生 `.md` 文件读取和编辑 → `dingtalk-misc`
|
||||
- `axls` / `able` → 对应电子表格或多维表 Skill
|
||||
|
||||
@@ -13,10 +13,12 @@
|
||||
| `success` | 所有计划步骤完成,且需要验证的内容已经回读 | 可以向用户报告完成 |
|
||||
| `partial_success` | 已发生部分副作用,后续步骤失败 | 检查 `steps` 与 `compensation`,不得重放成功步骤 |
|
||||
| `unknown` | 请求已发出但无法确定服务端是否提交 | 先回读目标;创建和追加禁止自动重试 |
|
||||
| `retryable` | 服务端明确业务执行尚未开始,且允许重试 | 遵循 `retry_after`,最多有界重试一次 |
|
||||
| `failed` | 已确认没有完成目标动作 | 根据 `retryable` 和 `suggestedActions` 决定是否重试 |
|
||||
| `retryable` | 服务端明确业务执行尚未开始,且允许重试 | 遵循 `retry_after_seconds`,最多有界重试一次 |
|
||||
| `failed` | 已确认没有完成目标动作 | 根据 `retryable`、`actions` 和 details 决定是否重试 |
|
||||
|
||||
`ok=true` 或进程退出码为零不能替代 `status/verified`。写回执应保留 operation、target、steps、warnings、failures、compensation 和 verification 摘要。
|
||||
进程退出码为零不能替代业务证据。采用 `doc.operation.v1` 的 shortcut 回执固定提供 `contractVersion/ok/status/complete/operation/steps/data/warnings/compensation`;`target/failures/verification` 只有实际操作返回时才能消费,不是通用字段。`+import` 使用现有导入回执,成功时检查 `success=true`、`taskId`、`documentUrl`、`documentName` 与 `documentType`,不要要求不存在的 `status/steps`。业务 `status` 不应与框架外层 `outcome` 混为一谈。
|
||||
|
||||
稳定返回 ID/任务 ID 只用于定位目标、恢复流程或继续查询,不能单独证明写操作成功。成功证据优先级从高到低为:与该操作匹配的明确服务端成功终态 → 契约要求的写后读回匹配 → 仅 transport/退出码;异步任务必须使用返回的任务 ID 查询到成功终态。只有成功终态成立且所有必要回读均匹配时才报告成功;`partial_success`、`unknown`、未完成任务或仅返回资源/任务 ID 都不得报告完成。Runtime 已给出充分回读证据时,不为“再确认一次”重复请求。
|
||||
|
||||
## 分页与完整性
|
||||
|
||||
@@ -24,14 +26,14 @@
|
||||
|
||||
- `complete=true`:已证明覆盖请求范围;
|
||||
- `hasMore=true`:仍有后续页,必须保留有效 continuation;
|
||||
- `truncated=true`:因 maxPages/maxItems/timeout 等边界停止;
|
||||
- `failures[]`:已返回部分数据但某一页或后处理失败。
|
||||
- `truncated=true`:成功返回因 `max_pages` 或 `max_items` 边界停止,并通过 `stopReason` 说明原因;
|
||||
- 后续页请求失败、cursor 缺失/停滞/循环时返回 typed error;部分结果位于 `details.items`,并保留 `details.status=partial_success`、`complete=false`、`reason/page/nextCursor/count`。不要期待成功回执的空 `failures[]` 承载这类错误,也不要把 timeout 写成 `truncated`。
|
||||
|
||||
只有 `complete=true` 且没有未处理失败时,才能把结果描述为完整集合。
|
||||
只有 `complete=true` 且没有未处理失败时,才能把结果描述为完整集合。用户问“有哪些”“全部”或要求“列出结果”时,使用返回的 cursor/continuation 继续取页直至完整;只有用户明确要示例、前 N 条或接受部分结果时才可提前停止,并说明已覆盖范围。分页应复用原查询与过滤条件,不得换命令或放宽关键词。
|
||||
|
||||
## 错误
|
||||
|
||||
结构化错误至少区分 validation、not_found、ambiguous、type_mismatch、revision_conflict、confirmation_required、permission_denied、partial_success 和 commit_unknown,并提供 failure stage、retryable 和 suggested actions。权限、认证和参数错误直接进入 `failed`;写请求只有明确 `execution_started=false` 才能进入 `retryable`,其余传输异常进入 `unknown`。`retryable=false` 表示自动重放不安全,不代表用户检查状态后永远不能重新发起。
|
||||
结构化错误至少区分 validation、not_found、ambiguous、type_mismatch、revision_conflict、confirmation_required、permission_denied、partial_success 和 commit_unknown,并提供 failure stage、retryable 与已有的 `actions`/details。权限、认证和参数错误直接进入 `failed`;写请求只有明确 `execution_started=false` 才能进入 `retryable`,其余传输异常进入 `unknown`。`retryable=false` 表示自动重放不安全,不代表用户检查状态后永远不能重新发起。不要发明 `suggestedAction` 或顶层 `nextCommand` 字段。
|
||||
|
||||
## 安全落盘
|
||||
|
||||
|
||||
@@ -26,10 +26,14 @@
|
||||
dws doc +template-search --query "周报" --source PUBLIC --format json
|
||||
```
|
||||
|
||||
来源按用户原话守门:“我的模板/我这边”只查 `MY`,明确“公开/钉钉模板库”才查 `PUBLIC`;不得为了凑结果跨来源扩展。未指定来源时保持默认 `MY`。
|
||||
|
||||
- `selection.status=resolved`:取唯一候选的 `templateId`。
|
||||
- `selection.status=not_found`:报告零命中后停止。
|
||||
- `selection.status=not_found`:报告零命中后停止;不得改用语义不相干的热门模板,更不得擅自创建文档。
|
||||
- `selection.status=selection_required`:展示候选并要求用户选择,禁止默认第一项。
|
||||
|
||||
若返回 `hasMore=true`,沿原 query/source 使用 cursor 继续搜索;只有服务端返回完整结果后才能判断零命中或完整候选集。
|
||||
|
||||
选定后只创建一次:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -12,7 +12,7 @@ dws doc +create --name "<文档名>" --content @body.json --doc-format jsonml --
|
||||
|
||||
- 统一输入协议:已有或临时文件先暂存到当前工作目录后传 `@相对文件`;单次生成文本可用 `--content -` 从 stdin 读取。
|
||||
- `@file` 禁止绝对路径和 `..` 逃逸;不要直接引用宿主临时目录。
|
||||
- `--name` 是文档名称,不等于正文中的 heading。用户明确要求“正文一级标题”时,正文仍须包含对应 H1。
|
||||
- `--name` 是文档名称;默认不要再在正文开头重复同名 H1,只有用户明确要求正文一级标题时才保留。
|
||||
- JSONML 顶层必须是数组;仅在确实需要富结构时加载 JSONML cookbook。
|
||||
|
||||
`+create` 负责创建、长 Markdown 分片和最终回读验证。正常成功结果至少包含:
|
||||
@@ -35,6 +35,18 @@ dws doc +create --name "<文档名>" --content @body.json --doc-format jsonml --
|
||||
- `status=unknown`:服务端可能已经提交;先定位并读取文档,禁止自动重试。
|
||||
- 没有真实 `nodeId` 或写回执时,禁止声称“已创建”。
|
||||
|
||||
## 创作与执行策略
|
||||
|
||||
采用 Plan → Execute → Observe → Iterate,将循环放在本地内容与定点修正上,不把长文拆成一串远程创建/追加:
|
||||
|
||||
1. **Plan**:明确受众、目的、范围和结构;正文由一个主上下文串行维护,避免按章节并行生成造成重复、矛盾和语气漂移。
|
||||
2. **Draft**:短内容可直接传;多行、长文或含特殊字符时先形成 cwd 内相对文件。用户未要求富结构时优先 Markdown;只有样式/引用/嵌套结构确有必要时才用 JSONML。
|
||||
3. **Execute**:只调用一次 `+create`。DWS Runtime 会分片长 Markdown、记录每步并回读验证;Agent 不采用“先骨架、再逐节远程插入”流程,以减少网络次数、顺序错误和 commit-unknown 面。
|
||||
4. **Observe**:先检查回执中的 `nodeId`、`verified`、`steps` 和失败状态。回执已证明完整时不重复拉全文;需要内容质量验收时,只用 `+fetch --scope section/keyword` 读取待检查部分。
|
||||
5. **Iterate**:后续修正复用同一 `nodeId`,按 [`doc-update.md`](doc-update.md) 做最小 block/文本修改,禁止重新创建整篇。
|
||||
|
||||
交付前检查标题是否重复、段落是否连贯、编号是否统一;只有真实行列数据才使用表格,富组件服务于理解而不是装饰。明确字数要求时应在写入前完成本地统计,不能凭模型估算宣称达标。
|
||||
|
||||
## 高级通道
|
||||
|
||||
只有 shortcut 未公开所需的底层参数或需要原始响应时,才读取精确 leaf Schema 后使用 `dws doc create`。不要因为熟悉旧参数就默认退回原子命令,也不要使用已删除的 Python 创建脚本。
|
||||
|
||||
@@ -8,14 +8,15 @@ dws doc +import --file ./report.docx --folder <FOLDER_ID> --format json
|
||||
dws doc +import --file ./notes.md --workspace <WORKSPACE_ID> --name "会议纪要" --format json
|
||||
```
|
||||
|
||||
`+import` 一次完成创建会话、上传、确认转换和轮询。支持 `doc/docx/xls/xlsx/md/txt/xmind/mark`,文件大小上限 20MB。
|
||||
`+import` 一次完成创建会话、上传、确认转换和终态轮询。支持 `doc/docx/xls/xlsx/md/txt/xmind/mark`,文件大小上限 20MB。转换成功回执包含 `success=true`、`taskId`、`documentUrl`、`documentName` 和 `documentType`,不包含 `status` 或 `steps`;成功返回即表示本次内部轮询已到终态。超时或中断时保留错误中的 `taskId`,只查询原任务。
|
||||
|
||||
## 本地文件边界
|
||||
|
||||
- `--file` 只接受当前工作目录内已存在的相对路径;禁止绝对路径、`..` 或符号链接逃逸。
|
||||
- `--folder` 与 `--workspace` 都是可选位置;都不传时导入默认根目录,两者都有时优先 `--folder`。
|
||||
- `--folder` 与 `--workspace` 都是可选位置;都不传时导入默认根目录。通常只提供一个;当前 Runtime 在两者同时出现时会一并传给服务端,未声明客户端优先级,不要依赖 `--folder` 覆盖 `--workspace`。
|
||||
- CLI 负责上传和格式转换。不要先用 Python/Office 库解析文件,不要安装本地依赖来伪造在线导入结果,也不要手写 HTTP 上传。
|
||||
- 白名单外格式(如 HTML/PDF)自动改走原文件上传,返回 `fallback=upload`、`converted=false`;不得报告成已经转换为可编辑在线文档。
|
||||
- “在线改/协作编辑/转在线文档”属于导入转换;“存着/归档/保留原文件/不要转换”属于 `dingtalk-drive` 纯上传。目标为文档空间时,纯上传使用 `drive upload --workspace <WORKSPACE_ID>`,不要因容器叫“文档空间”就误报为在线文档。
|
||||
|
||||
## 失败处理
|
||||
|
||||
|
||||
@@ -16,10 +16,22 @@ dws doc +media-download --node <DOC_ID> --resource-id <RESOURCE_ID> --output ./d
|
||||
dws doc +media-preview --node <DOC_ID> --resource-id <RESOURCE_ID> --format json
|
||||
```
|
||||
|
||||
## 封面与背景 Shortcut
|
||||
|
||||
```bash
|
||||
dws doc +resource-update --node <DOC_ID> --file ./cover.png --format json
|
||||
dws doc +resource-download --node <DOC_ID> --output ./cover.png --format json
|
||||
dws doc +resource-delete --node <DOC_ID> --format json
|
||||
dws doc +background-update --node <DOC_ID> --color "#E8F2FE" --format json
|
||||
dws doc +background-delete --node <DOC_ID> --format json
|
||||
```
|
||||
|
||||
封面不是正文媒体 block;背景仅接受 `#RRGGBB` 纯色。设置/清除后用一次 `+inspect --include-style` 验证,禁止为这些已知能力查询 shortcut Catalog。
|
||||
|
||||
## 稳定 ID 与结果
|
||||
|
||||
- `resourceId`、`blockId`、`nodeId` 必须来自真实 media/block 返回,不能从标题或本地文件名猜测。
|
||||
- 插入后保留 `resourceId` 和插入结果;下载必须检查 `localPath`、`sizeBytes > 0`。
|
||||
- 插入成功回执在 `data.blockId` 返回已回读验证的媒体块 ID;后续定位只复用该 `blockId`。回执不提供相邻空块字段,不得据此猜测或自动删除其他块。插入回执成功后禁止重传媒体。下载必须检查 `localPath`、`sizeBytes > 0`。
|
||||
- 下载输出只接受工作目录内相对路径,默认 no-clobber。
|
||||
- 删除源文件是独立的破坏性本地操作,不属于媒体下载;只有用户明确要求且下载验证成功后才能执行。
|
||||
|
||||
|
||||
@@ -24,10 +24,24 @@ dws doc +fetch --node <DOC_ID> --scope keyword --keyword "风险|结论" --conte
|
||||
|
||||
只有需要块 ID、revision 或 JSONML 保真结构时才提高 `--detail`;先读取最小必要范围,避免把整篇大文档放入上下文。
|
||||
|
||||
整篇读取使用默认 scope 或 `--scope full`;`full` 不是关键词,禁止写成 `--keyword full`。
|
||||
|
||||
## 最小读取漏斗
|
||||
|
||||
按用户已经给出的线索选最短路径,不先拉全文:
|
||||
|
||||
1. 已知稳定 ID/URL,且任务确实涉及整篇:直接默认 `full + simple`,一次返回 Markdown。
|
||||
2. 用户给出具体术语、错误码或同义词:直接 `keyword`;`foo|bar` 是 OR,`context-before/after` 是字符数。该模式会缩小 Agent 输出与 token,但当前 Runtime 仍需取得正文后做本地投影,不把它误述为减少上游传输。
|
||||
3. 用户指向某章但没有 block ID:先 `outline --detail with-ids`,再用返回的真实标题 ID 执行 `section`。通常两次小结果比反复处理整篇更稳定。
|
||||
4. 已知起止 block:直接 `range`;只找表格、图片等结构时用 `tags`。
|
||||
5. 只有确需全文总结、全局一致性检查或整篇保真改写时,才读取完整内容。
|
||||
|
||||
`simple` 用于阅读;`with-ids` 用于定位下一次写操作;`full` 只用于必须保留样式、引用或 JSONML 结构的编辑。局部结果只能证明所选范围,不得据此声称已检查整篇。
|
||||
|
||||
## 后续路由
|
||||
|
||||
- 读后修改:把稳定的 `nodeId` 交给 [`doc-update.md`](doc-update.md) 的 `+update` 或 `+checkpoint-update`。
|
||||
- 附件/图片:先用 [`doc-media.md`](doc-media.md) 的 `+media-list` 取得稳定 `resourceId`,再下载;不要复用正文中的临时签名 URL。
|
||||
- 富结构专家编辑:确实需要原始 JSONML 或 shortcut 未公开的参数时,先读取精确 leaf Schema,再使用原子 `doc read`/`doc block`。
|
||||
|
||||
禁止把原子 `doc read` 当作默认入口,也不要在读取后无条件整篇回写。筛选结果只用于读取,不能把虚拟 fragment 容器整体写回文档。
|
||||
禁止把原子 `doc read` 当作默认入口,也不要在读取后无条件整篇回写。筛选结果只用于读取,不能把虚拟 fragment 容器整体写回文档;同一次任务里已拿到稳定 `nodeId` 后,后续调用直接复用,避免再次按标题搜索。
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
普通追加、覆盖和 block 编辑统一使用 `+update`:
|
||||
|
||||
`--command` 只接受下列枚举值,不接受 JSON、自然语言或拼接子命令;动作参数必须分别传给 `--content/--old/--new/--block-id/--after-block-id`。
|
||||
|
||||
```bash
|
||||
dws doc +update --node <DOC_ID> --command append --content "补充说明" --format json
|
||||
dws doc +update --node <DOC_ID> --command append --content @append.md --format json
|
||||
@@ -38,9 +40,29 @@ block ID 必须来自 `+fetch --detail with-ids` 或真实 block 列表,禁止
|
||||
|
||||
`--expected-revision` 只允许 `--command overwrite --doc-format jsonml`。Markdown、append 和 block 接口没有服务端原子 revision 契约,禁止用写前读取模拟乐观锁。
|
||||
|
||||
## 最小改写决策
|
||||
|
||||
| 已知条件 | 推荐路径 | 成本与成功率理由 |
|
||||
|---|---|---|
|
||||
| 用户明确要求在末尾追加 | 直接 `append` | 不为找末尾先拉全文;需要语气衔接时只读末节 |
|
||||
| 已知唯一旧文本与新文本 | 直接 `str_replace --old --new` | 省掉 block 解析;旧文本不唯一时 Runtime 必须失败,不放宽匹配 |
|
||||
| 指定章节但没有 block ID | `+fetch outline` → `+fetch section --detail with-ids` → block 动作 | 两个小读取换取稳定锚点,避免全文 token 和误改相邻章节 |
|
||||
| 已知真实 block ID | 直接 `block_replace/delete/insert_after` | 最小副作用;不改无关 block |
|
||||
| 多处富结构保真修改 | `+fetch --detail full` 后定点 JSONML 更新 | 保留图片、附件、引用、表格和样式;不要从 Markdown 有损重建 |
|
||||
| 整篇重要覆盖 | `+checkpoint-update --mode overwrite` | 自动保存恢复点、执行并回读;普通 overwrite 只用于明确不需恢复点的场景 |
|
||||
|
||||
同一篇文档的正文由一个主上下文串行维护:Plan(确定最小变更)→ Execute(一次写)→ Observe(先消费回执)→ Iterate(只修未达标部分)。不要按章节并行写同一文档,也不要每次迭代都重新读取全文或 Schema。
|
||||
|
||||
## Block ID 生命周期与保真
|
||||
|
||||
- `block_replace` 成功后 Runtime 使用同一 `blockId` 回读验证,该 ID 可继续作为锚点;验证失败时先局部 `+fetch` 核对现状。`block_delete` 成功后旧 ID 失效,不得继续复用。
|
||||
- `block_insert_after` / `block_copy_insert_after` 后,原锚点通常仍可识别,但新 block 的 ID 必须来自真实返回或局部回读,禁止按顺序猜测。
|
||||
- `str_replace` 的简单行内替换通常不要求重新取 ID;若后续依赖块结构,仍以局部回读为准。
|
||||
- 从 Markdown 读取后覆盖整篇可能丢失图片、附件、@人/@文档、评论锚点、表格样式和嵌套块。只改局部时使用 block 手术;确需整篇保真改写时使用 `full` JSONML,并以 `--expected-revision` 防止覆盖并发修改。
|
||||
|
||||
## 确认与验证
|
||||
|
||||
- 执行前从精确 leaf Schema 消费 `confirmation`;需要确认时先 `--dry-run`/询问,用户确认同一参数后追加 `--yes`,禁止先失败探测门禁。
|
||||
- `+update` 与 `+checkpoint-update` 当前都要求用户确认。目标、动作、内容范围或参数变化后必须重新确认;只有发现本地文档与 live leaf 漂移时才查一次精确 Schema,禁止每次写入都重复发现。
|
||||
- `+update` 已负责回读验证。除非结果为 `unknown` 或任务需要额外结构验收,不要再做一次全篇读取。
|
||||
- `doc_write_verification_failed` 表示写入已经发生,必须先读取现状;禁止直接重复写入。
|
||||
- `partial_success` 只恢复未完成步骤,不能重放成功步骤。
|
||||
|
||||
@@ -1303,13 +1303,25 @@ func TestReleaseWorkflowParallelizesSealedValidationWithoutWeakeningPublication(
|
||||
"- e2e",
|
||||
"verify-github-tag-authority.sh",
|
||||
"go test -v -count=1 -timeout=5m ./test/scripts/... -run '^TestRelease'",
|
||||
"check-command-compatibility.sh",
|
||||
`tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh`,
|
||||
`--repo-root "$GITHUB_WORKSPACE"`,
|
||||
`--base-ref HEAD`,
|
||||
`--stable-ref "$PREVIOUS_STABLE"`,
|
||||
`--candidate-ref HEAD`,
|
||||
"test-multi-profile-e2e.sh",
|
||||
} {
|
||||
if !strings.Contains(validation, required) {
|
||||
t.Errorf("parallel release validation is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"./scripts/policy/check-command-compatibility.sh",
|
||||
"./scripts/policy/check-authoritative-schema-compatibility.sh",
|
||||
} {
|
||||
if strings.Contains(validation, forbidden) {
|
||||
t.Errorf("parallel release validation bypasses the shared compatibility runner with %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(build, "test-multi-profile-e2e.sh") {
|
||||
t.Error("multi-profile E2E must not serialize GoReleaser")
|
||||
@@ -1379,7 +1391,7 @@ func TestReleaseWorkflowHidesOnlyVerifiedSealedTagFromCompatibilityBaseline(t *t
|
||||
|
||||
verifiedTag := strings.Index(validation, "verify-github-tag-authority.sh")
|
||||
deleteLocalTag := strings.Index(validation, "git update-ref -d")
|
||||
compatibility := strings.Index(validation, "check-command-compatibility.sh")
|
||||
compatibility := strings.Index(validation, "tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh")
|
||||
if verifiedTag == -1 || deleteLocalTag == -1 || compatibility == -1 ||
|
||||
verifiedTag > deleteLocalTag || deleteLocalTag > compatibility {
|
||||
t.Fatal("release tag authority verification, local candidate removal, and compatibility checking must stay ordered")
|
||||
|
||||
@@ -75,6 +75,7 @@ type releaseTestRepo struct {
|
||||
prepare string
|
||||
render string
|
||||
releaseCmd string
|
||||
compat string
|
||||
lib string
|
||||
verify string
|
||||
}
|
||||
@@ -110,11 +111,206 @@ func newReleaseTestRepo(t *testing.T) *releaseTestRepo {
|
||||
prepare: filepath.Join(sourceRoot, "scripts", "release", "prepare-changelog.sh"),
|
||||
render: filepath.Join(sourceRoot, "scripts", "release", "render-release-fragments.sh"),
|
||||
releaseCmd: filepath.Join(sourceRoot, "scripts", "release", "release.sh"),
|
||||
compat: filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh"),
|
||||
lib: filepath.Join(sourceRoot, "scripts", "release", "release-lib.sh"),
|
||||
verify: filepath.Join(sourceRoot, "scripts", "release", "verify-release-artifacts.sh"),
|
||||
}
|
||||
}
|
||||
|
||||
func seedReleaseCompatibilityRefs(t *testing.T, repo string) (stable, base, candidate string) {
|
||||
t.Helper()
|
||||
mustRun(t, repo, "git", "config", "user.name", "Release Compatibility Test")
|
||||
mustRun(t, repo, "git", "config", "user.email", "release-compatibility-test@example.com")
|
||||
mustWriteFile(t, filepath.Join(repo, "seed.txt"), []byte("stable\n"), 0o644)
|
||||
mustRun(t, repo, "git", "add", "seed.txt")
|
||||
mustRun(t, repo, "git", "commit", "-m", "stable")
|
||||
mustRun(t, repo, "git", "tag", "stable-ref")
|
||||
stable = strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD^{commit}"))
|
||||
|
||||
mustWriteFile(t, filepath.Join(repo, "seed.txt"), []byte("base\n"), 0o644)
|
||||
mustRun(t, repo, "git", "add", "seed.txt")
|
||||
mustRun(t, repo, "git", "commit", "-m", "base")
|
||||
mustRun(t, repo, "git", "branch", "base-ref")
|
||||
base = strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD^{commit}"))
|
||||
|
||||
mustWriteFile(t, filepath.Join(repo, "seed.txt"), []byte("candidate\n"), 0o644)
|
||||
mustRun(t, repo, "git", "add", "seed.txt")
|
||||
mustRun(t, repo, "git", "commit", "-m", "candidate")
|
||||
mustRun(t, repo, "git", "branch", "candidate-ref")
|
||||
candidate = strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD^{commit}"))
|
||||
return stable, base, candidate
|
||||
}
|
||||
|
||||
func TestReleaseCompatibilityCheckRunsCLIAndSchemaWithExactRefs(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
runner := filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh")
|
||||
repo := t.TempDir()
|
||||
mustRun(t, repo, "git", "init", "-b", "main")
|
||||
stable, base, candidate := seedReleaseCompatibilityRefs(t, repo)
|
||||
|
||||
trace := filepath.Join(t.TempDir(), "compatibility.log")
|
||||
checker := func(name string) []byte {
|
||||
return []byte("#!/bin/sh\nset -eu\nprintf '" + name + ":%s\\n' \"$*\" >> \"$TRACE\"\n")
|
||||
}
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-interface-baselines.sh"), checker("cli"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), checker("schema"), 0o755)
|
||||
|
||||
cmd := exec.Command(
|
||||
runner,
|
||||
"--repo-root", repo,
|
||||
"--base-ref", "base-ref",
|
||||
"--stable-ref", "stable-ref",
|
||||
"--candidate-ref", "candidate-ref",
|
||||
)
|
||||
cmd.Env = append(os.Environ(), "TRACE="+trace)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("release compatibility check error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
got, err := os.ReadFile(trace)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", trace, err)
|
||||
}
|
||||
want := fmt.Sprintf("cli:--base-ref %s --stable-ref %s --candidate-ref %s\n", base, stable, candidate) +
|
||||
fmt.Sprintf("schema:--base-ref %s --stable-ref %s --candidate-ref %s\n", base, stable, candidate)
|
||||
if string(got) != want {
|
||||
t.Fatalf("release compatibility calls = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCompatibilityCheckFreezesRefsBeforeRunningEitherChecker(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
runner := filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh")
|
||||
repo := t.TempDir()
|
||||
mustRun(t, repo, "git", "init", "-b", "main")
|
||||
stable, base, candidate := seedReleaseCompatibilityRefs(t, repo)
|
||||
|
||||
trace := filepath.Join(t.TempDir(), "compatibility.log")
|
||||
resolveAndLog := `
|
||||
base="$2"
|
||||
stable="$4"
|
||||
candidate="$6"
|
||||
printf '%s:%s:%s:%s\n' "$CHECK_NAME" \
|
||||
"$(git -C "$REPO_ROOT_FOR_TEST" rev-parse --verify "${base}^{commit}")" \
|
||||
"$(git -C "$REPO_ROOT_FOR_TEST" rev-parse --verify "${stable}^{commit}")" \
|
||||
"$(git -C "$REPO_ROOT_FOR_TEST" rev-parse --verify "${candidate}^{commit}")" >> "$TRACE"
|
||||
`
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-interface-baselines.sh"), []byte("#!/bin/sh\nset -eu\nCHECK_NAME=cli\n"+resolveAndLog+"git -C \"$REPO_ROOT_FOR_TEST\" update-ref refs/heads/base-ref \"$MUTATE_TO\"\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nCHECK_NAME=schema\n"+resolveAndLog), 0o755)
|
||||
|
||||
cmd := exec.Command(
|
||||
runner,
|
||||
"--repo-root", repo,
|
||||
"--base-ref", "base-ref",
|
||||
"--stable-ref", "stable-ref",
|
||||
"--candidate-ref", "candidate-ref",
|
||||
)
|
||||
cmd.Env = append(os.Environ(), "TRACE="+trace, "REPO_ROOT_FOR_TEST="+repo, "MUTATE_TO="+candidate)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("release compatibility check error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
got, err := os.ReadFile(trace)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", trace, err)
|
||||
}
|
||||
want := fmt.Sprintf("cli:%s:%s:%s\nschema:%s:%s:%s\n", base, stable, candidate, base, stable, candidate)
|
||||
if string(got) != want {
|
||||
t.Fatalf("release compatibility resolved commits = %q, want %q", got, want)
|
||||
}
|
||||
if moved := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "base-ref^{commit}")); moved != candidate {
|
||||
t.Fatalf("mutating checker did not move base-ref: got %s, want %s", moved, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCompatibilityCheckStopsBeforeSchemaWhenCLIFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
runner := filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh")
|
||||
repo := t.TempDir()
|
||||
mustRun(t, repo, "git", "init", "-b", "main")
|
||||
seedReleaseCompatibilityRefs(t, repo)
|
||||
|
||||
trace := filepath.Join(t.TempDir(), "compatibility.log")
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-interface-baselines.sh"), []byte("#!/bin/sh\nprintf 'cli\\n' >> \"$TRACE\"\nexit 17\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), []byte("#!/bin/sh\nprintf 'schema\\n' >> \"$TRACE\"\n"), 0o755)
|
||||
|
||||
cmd := exec.Command(
|
||||
runner,
|
||||
"--repo-root", repo,
|
||||
"--base-ref", "base-ref",
|
||||
"--stable-ref", "stable-ref",
|
||||
"--candidate-ref", "candidate-ref",
|
||||
)
|
||||
cmd.Env = append(os.Environ(), "TRACE="+trace)
|
||||
output, err := cmd.CombinedOutput()
|
||||
exitErr, ok := err.(*exec.ExitError)
|
||||
if !ok || exitErr.ExitCode() != 17 {
|
||||
t.Fatalf("release compatibility CLI failure = %v, want exit 17\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(string(output), "error: authoritative CLI compatibility failed") {
|
||||
t.Fatalf("release compatibility CLI failure has no boundary message:\n%s", output)
|
||||
}
|
||||
got, err := os.ReadFile(trace)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", trace, err)
|
||||
}
|
||||
if string(got) != "cli\n" {
|
||||
t.Fatalf("checks after CLI failure = %q, want only CLI", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCompatibilityCheckFailsWhenSchemaFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
runner := filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh")
|
||||
repo := t.TempDir()
|
||||
mustRun(t, repo, "git", "init", "-b", "main")
|
||||
seedReleaseCompatibilityRefs(t, repo)
|
||||
|
||||
trace := filepath.Join(t.TempDir(), "compatibility.log")
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-interface-baselines.sh"), []byte("#!/bin/sh\nprintf 'cli\\n' >> \"$TRACE\"\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), []byte("#!/bin/sh\nprintf 'schema\\n' >> \"$TRACE\"\nexit 19\n"), 0o755)
|
||||
|
||||
cmd := exec.Command(
|
||||
runner,
|
||||
"--repo-root", repo,
|
||||
"--base-ref", "base-ref",
|
||||
"--stable-ref", "stable-ref",
|
||||
"--candidate-ref", "candidate-ref",
|
||||
)
|
||||
cmd.Env = append(os.Environ(), "TRACE="+trace)
|
||||
output, err := cmd.CombinedOutput()
|
||||
exitErr, ok := err.(*exec.ExitError)
|
||||
if !ok || exitErr.ExitCode() != 19 {
|
||||
t.Fatalf("release compatibility Schema failure = %v, want exit 19\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(string(output), "error: authoritative Schema compatibility failed") {
|
||||
t.Fatalf("release compatibility Schema failure has no boundary message:\n%s", output)
|
||||
}
|
||||
got, err := os.ReadFile(trace)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", trace, err)
|
||||
}
|
||||
if string(got) != "cli\nschema\n" {
|
||||
t.Fatalf("checks before Schema failure = %q, want CLI then Schema", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseVersionOrdering(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
@@ -2631,12 +2827,35 @@ func TestReleaseCommandRejectsDifferentFetchAndPushRepositories(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCommandRunsCLIAndSchemaCompatibilityInInitialPreflight(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
installReleaseCommandFixture(t, r)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
r.commitAndPush(t, "install release compatibility fixture")
|
||||
|
||||
output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
|
||||
"prerelease", "v1.0.1-beta.1", "--remote", "origin",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("release validation error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
head := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "HEAD^{commit}"))
|
||||
stable := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "v1.0.0^{commit}"))
|
||||
for _, want := range []string{
|
||||
fmt.Sprintf("cli-compatibility --base-ref %s --stable-ref %s --candidate-ref %s", head, stable, head),
|
||||
fmt.Sprintf("schema-compatibility --base-ref %s --stable-ref %s --candidate-ref %s", head, stable, head),
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Errorf("initial release preflight is missing %q\noutput:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCommandRechecksAdvancedStableAuthority(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
installReleaseCommandFixture(t, r)
|
||||
section := "## [1.0.2-beta.1] - 2026-07-11\n\n### Changed\n\n- Validate a candidate after stable authority advances.\n\n"
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(section)), 0o644)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-command-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nprintf 'compatibility %s\\n' \"$*\"\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\nbuild:\n\t@:\npolicy:\n\t@:\npackage:\n\t@git tag -a v1.0.1 -m 'Release v1.0.1'\n\t@git push origin refs/tags/v1.0.1\n"), 0o644)
|
||||
r.commitAndPush(t, "install advancing release fixture")
|
||||
|
||||
@@ -2646,20 +2865,28 @@ func TestReleaseCommandRechecksAdvancedStableAuthority(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("release validation error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(output, "Stable authority advanced from v1.0.0 to v1.0.1") ||
|
||||
!strings.Contains(output, "--stable-ref v1.0.1") {
|
||||
t.Fatalf("advanced stable command tree was not rechecked:\n%s", output)
|
||||
head := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "HEAD^{commit}"))
|
||||
stable := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "v1.0.1^{commit}"))
|
||||
for _, want := range []string{
|
||||
"Stable authority advanced from v1.0.0 to v1.0.1",
|
||||
fmt.Sprintf("cli-compatibility --base-ref %s --stable-ref %s --candidate-ref %s", head, stable, head),
|
||||
fmt.Sprintf("schema-compatibility --base-ref %s --stable-ref %s --candidate-ref %s", head, stable, head),
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Errorf("advanced stable authority recheck is missing %q\noutput:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func installReleaseCommandFixture(t *testing.T, r *releaseTestRepo) {
|
||||
t.Helper()
|
||||
for _, source := range []string{r.lib, r.contract, r.releaseCmd} {
|
||||
for _, source := range []string{r.lib, r.contract, r.releaseCmd, r.compat} {
|
||||
releaseCopyFile(t, source, filepath.Join(r.root, "scripts", "release", filepath.Base(source)), 0o755)
|
||||
}
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-package-managers.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-release-artifacts.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-command-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-authoritative-interface-baselines.sh"), []byte("#!/bin/sh\nset -eu\nprintf 'cli-compatibility %s\\n' \"$*\"\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nprintf 'schema-compatibility %s\\n' \"$*\"\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\nbuild:\n\t@:\npolicy:\n\t@:\npackage:\n\t@:\n"), 0o644)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user