Compare commits

...
Author SHA1 Message Date
赤川 2228a32d1a Merge branch 'main' into codex/fix-1060-schema-lineage 2026-08-20 12:11:55 +08:00
github-actions[bot] a6f79e951b Merge pull request #1050 from DingTalk-Real-AI/codex/fix-cli-eval-functional
fix: harden shortcut functional workflows
2026-08-20 04:08:39 +00:00
chichuan 3922970bfc fix(policy): preserve schema migration lineage 2026-08-20 11:52:00 +08:00
Dennis4477 9b0441ca56 Merge branch 'main' into codex/fix-cli-eval-functional 2026-08-20 11:47:08 +08:00
github-actions[bot] b6eaf3c5af chore: update beta formula for v1.0.59-beta.4 [skip ci] 2026-08-20 03:42:00 +00:00
赤川 aa4ae9a903 Merge pull request #1063 from DingTalk-Real-AI/codex/fix-996-multi-profile-skill-path
fix(ci): align multi-profile skill paths with canonical setup
2026-08-20 10:53:27 +08:00
chichuan 7a019c6fa3 fix(ci): align multi-profile skill paths 2026-08-20 10:46:17 +08:00
赤川 e2e4d6fc22 Merge pull request #1062 from DingTalk-Real-AI/codex/changelog-v1.0.59-beta.4
chore: prepare v1.0.59-beta.4 changelog
2026-08-20 10:30:46 +08:00
chichuan 2c0d6e4118 chore: prepare v1.0.59-beta.4 changelog 2026-08-20 10:25:13 +08:00
github-actions[bot] 379f625ca9 Merge pull request #1045 from DingTalk-Real-AI/codex/attendance-mail-shortcuts
feat(shortcut): harden Attendance and Mail workflows
2026-08-20 02:07:16 +00:00
Dennis 17741851f5 test: satisfy native shortcut coverage gate 2026-08-19 16:20:04 +08:00
Dennis d10446bea7 ci: reuse preinstalled archive tooling 2026-08-19 15:50:03 +08:00
Dennis 0ed05a2c5d fix: address shortcut review edge cases 2026-08-19 14:46:27 +08:00
Dennis ae1edefee6 test: cover shortcut hardening branches 2026-08-19 14:46:23 +08:00
Dennis 6dbc7ed82b fix: harden shortcut functional workflows 2026-08-19 14:46:19 +08:00
55 changed files with 3800 additions and 187 deletions

No files matched your search

@@ -0,0 +1,5 @@
---
category: 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.
+35 -5
View File
@@ -617,7 +617,13 @@ jobs:
- name: Install archive tooling
if: ${{ matrix.shard == 'release-scripts' && steps.select.outputs.affected == 'true' }}
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Test shard with Race Detection
if: ${{ steps.select.outputs.affected == 'true' }}
@@ -757,7 +763,13 @@ jobs:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Test release scripts
shell: bash
@@ -1135,7 +1147,13 @@ jobs:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Build
run: make build
@@ -1186,7 +1204,13 @@ jobs:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Run policy and shortcut coverage
run: |
@@ -1267,7 +1291,13 @@ jobs:
- name: Install archive tooling
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Run baseline unit tests with coverage
if: steps.baseline-cache.outputs.cache-hit != 'true'
+4 -2
View File
@@ -2698,9 +2698,11 @@ jobs:
;;
compatibility)
test -n "$PREVIOUS_STABLE"
./scripts/policy/check-command-compatibility.sh \
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh" \
--repo-root "$GITHUB_WORKSPACE" \
--base-ref HEAD \
--stable-ref "$PREVIOUS_STABLE"
--stable-ref "$PREVIOUS_STABLE" \
--candidate-ref HEAD
;;
e2e)
bash scripts/dev/test-multi-profile-e2e.sh
+33
View File
@@ -6,6 +6,39 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.59-beta.4] - 2026-08-20
### Added
- **招聘职位管理** (#976) — 新增招聘职位列表、详情查询和职位创建命令。
- **OA admin approval query** — `oa approval list-by-admin` queries approval instances of a template with admin scope, with simple flags and an advanced `--request` mode; `startTime`/`endTime` use `yyyy-MM-dd HH:mm:ss` strings per the 2026-08 MCP contract update (ISO-8601 flag inputs auto-convert), and pageSize/time format are validated client-side with localized errors.
### Changed
- **Attendance and Mail Shortcuts** (#1045) — publishes only capabilities with
strict response, identity, pagination, and real-data verification while
retaining historical CLI discovery and argument compatibility for commands
that remain unavailable to agents. Mailbox auto-resolution now accepts both
reviewed string and object response shapes, and Attendance date ranges cover
the complete requested end date without dropping cross-midnight punches whose
actual check time is inside the requested range. The schedule query remains
CLI-compatible but is withheld from the Agent catalog because its downstream
service returns a successful process exit with a null body for both populated
and empty ranges.
- **Chat group roles** (#1058) — exposes the single-value `--role-id` flag for assigning one custom group role while preserving hidden `--role-ids` compatibility.
- **CLI compatibility governance** — adds a reviewed two-stage path for hiding retained legacy commands or optional `NoOpt=true` boolean flags from Help and Schema when their activated capability moves to a dedicated command, with legacy-leaf, complete parameter/constant mapping, durable runtime constant evidence, protected framework bridges, dry-run preservation, parameter-collision, and fail-closed required-parameter checks.
### Fixed
- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use directory links — junctions on Windows from the npm and PowerShell installers, symbolic links from `dws upgrade` / `dws skill setup` — with a safe copy fallback when link creation is unavailable (including Windows without Developer Mode); custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. A degraded child-move that cannot consume the emptied source shell fails the publication loudly with the destination retained, so a failed move never silently discards staging data. When the fresh mkdir-claim identity captured by the child-move fallback — dev:ino on POSIX, the volume file ID on Windows — no longer matches the destination, the publication reports `ErrSkillPathPublicationUncertain` and keeps the destination: the object may belong to a concurrent writer, and the mono/multi upgrade copy fallbacks as well as `dws skill setup` honor the sentinel by surfacing the state instead of retrying over (and displacing) it. npm same-volume and cross-volume moves, PowerShell recoverably moves, and npm canonical-link / copied-set confirmation follow the same occupy-then-confirm contract. Same-volume publication and restore use a no-replace primitive (mkdir-claim plus child move, symlink-at-dest, or hard-link plus retract) so a dest that becomes occupied after any pre-check is refused instead of replaced. A rollback that has already quarantined dest re-checks identity in quarantine and restores an unmatched object onto dest with that same no-replace publish, so dest is not left empty with the concurrent object hidden unless the restore itself fails (then both locations are named). The npm installer now proves ownership at dest (inode, device, fingerprint) before any quarantine rename, matching Go: a concurrent replacement is left on the original path, and only a post-quarantine identity change is restored with no-replace. Go identity proofs report a stable file identity on every platform — dev:ino on Linux and macOS, the volume file ID on Windows — with the post-publish content fingerprint as the backstop against inode recycling; Shell copied-set rollback proves dest first with inode, child names, and a recursive content digest (sorted paths, mode bits, file hashes, link targets) for the same reason, so an in-place content edit after publish is preserved rather than retracted as this transaction's object. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. The standalone event/devapp copy publishers use the same mkdir claim instead of `mv`ing a staging directory onto dest. Shell copied-set and link rollback claim dest into quarantine before deleting, so a concurrent writer's file, symlink, or directory is renamed back instead of being deleted by a path-blind `rm` after an inode pre-check. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems — except when the failure reports the uncertain sentinel, which retains the destination for manual inspection instead of retrying over it. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible; stamp roots created before the ownership marker existed are preserved rather than pruned. Standalone installers verify every downloaded release asset against `checksums.txt`, and the Line truncated
- **Chat user mentions** — preserves literal `<@openDingTalkId>` tokens in current-user Markdown messages and rejects mismatches between message-body mentions and mention flags before sending.
- **Chat direct media** — uses the IM upload target field for current-user direct file, audio, and video uploads, then uses the Chat receiver field for final message delivery.
## [1.0.59-beta.3] - 2026-08-19
### Added
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.59-beta.3"
version "1.0.59-beta.4"
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.3/dws-darwin-arm64.tar.gz"
sha256 "9c99adcefd9104368eb443f0a1b4af8e7aceaa1ffdd4462e486854c1692bb6ce"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-darwin-arm64.tar.gz"
sha256 "f788467e9979c70ef210b411ac915b1506ea77ffa496e26b53cfa99650158721"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-amd64.tar.gz"
sha256 "f5cc8efb1f982d68ae549190fd683292359c2ab542b532fa52bb35e6b5c049af"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-darwin-amd64.tar.gz"
sha256 "a01988709c0dc99dd5874859eb265ba08a6fda412a7ead8303c68e61d2a8b195"
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.3/dws-linux-arm64.tar.gz"
sha256 "7a4efd04b417ce8013b1e431274b396179958da244164f59974358ba327ff093"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-linux-arm64.tar.gz"
sha256 "8e1a993b2137a082a8cc1d9535dfc2d7b3e4399c76d295840f9dc1f15cca7a0d"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-amd64.tar.gz"
sha256 "90181e8f2e9010c1943a5773c3d45d7d3ac85d6bc93e18a9aaa7c69909e553d7"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-linux-amd64.tar.gz"
sha256 "26e4cd72cfb96b38ef808863391b81a5c45c3170bca56b5eac457fc601b000c5"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-skills.zip"
sha256 "e7028914a4a826af9b18ed4922d68fa8f279473817fed4f305465bc8a7aad363"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-skills.zip"
sha256 "a75107bdc14b5476e097842acc92f798301d8ffb59de9ade01f863d166a89435"
end
def install
+11
View File
@@ -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
并按 lifecycle 清理。两种阶段都拒绝残留 predecessor/intermediate、字段漂移、环、分叉、
target 碰撞或 primary path/tool identity 不唯一;positionals 不在该组合授权面内。
`replacement_constant` 不是清单自报即可成立的例外。after 阶段的 Interface Snapshot
必须从 replacement 命令的同一份框架运行时声明中捕获完全一致的 property/value,缺失、
值不符或额外常量都会使 lifecycle 落入 partial。对于 #1054,`dws chat topic create`
+3 -3
View File
@@ -23,7 +23,7 @@
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、命令兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、CLI 与 Schema 兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
OSS 镜像默认不参与发布 DAG,适用于尚未创建 Bucket 的仓库。云端封板会把当时的仓库变量 `ENABLE_OSS_MIRROR=true` 记录为不可变 tag 元数据 `OSS-Mirror: enabled`,否则记录为 `deferred`;后续发布和撤回只读取该 sealed policy,不读取变量的当前值。`enabled` 继续对缺失凭据、无效 Bucket、上传、pointer 和撤回失败保持 fail-closed;`deferred` 明确跳过不存在的渠道。为避免补发后撤回遗漏,deferred 版本暂不接受 `repair_oss_version`,启用 OSS 只影响后续新 tag,直到补齐可审计的不可变 repair 证明。
@@ -102,7 +102,7 @@ fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR
dws-release v1.2.3-beta.1
```
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
预检包含测试、策略检查、旧正式版 CLI 与 Schema 双基线兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
## 正式发布
@@ -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`,不会覆盖稳定入口。
+366 -3
View File
@@ -7,6 +7,8 @@ import (
"io"
"math"
"os"
"reflect"
"sort"
"strconv"
"strings"
"time"
@@ -126,6 +128,83 @@ func resolveWorkflowDSL(cmd *cobra.Command) (map[string]any, error) {
return dsl, nil
}
// executeAitableWorkflowPublish executes a publish exactly once, then requires
// the reviewed valid/flowId envelope before rendering any success output.
// create_workflow is non-idempotent, so transport uncertainty is never retried.
func executeAitableWorkflowPublish(toolName string, args map[string]any) error {
if deps.Caller.DryRun() {
return callMCPToolOnServer("aitable", toolName, args)
}
raw, err := callMCPToolReturnTextOnServer(context.Background(), "aitable", toolName, args)
if err != nil {
return err
}
var envelope map[string]any
if err := json.Unmarshal([]byte(raw), &envelope); err != nil || envelope == nil {
return fmt.Errorf("%s response is not a JSON object", toolName)
}
valid, validFound, flowID, err := strictAitableWorkflowPublishResult(envelope)
if err != nil {
return fmt.Errorf("%s response validation failed: %w", toolName, err)
}
if !validFound {
return fmt.Errorf("%s response is missing valid", toolName)
}
if !valid {
return fmt.Errorf("%s returned valid=false; inspect issues and correct the workflow DSL", toolName)
}
if flowID == "" {
return fmt.Errorf("%s response is missing a non-empty flowId", toolName)
}
return RenderLegacyMCPText(toolName, raw)
}
func strictAitableWorkflowPublishResult(envelope map[string]any) (valid bool, validFound bool, flowID string, err error) {
var visit func(map[string]any) error
visit = func(object map[string]any) error {
if raw, exists := object["valid"]; exists {
value, ok := raw.(bool)
if !ok {
return fmt.Errorf("valid must be boolean, got %T", raw)
}
if validFound && valid != value {
return fmt.Errorf("conflicting valid values")
}
valid, validFound = value, true
}
for _, key := range []string{"flowId", "workflowId"} {
raw, exists := object[key]
if !exists {
continue
}
value, ok := raw.(string)
value = strings.TrimSpace(value)
if !ok || value == "" {
return fmt.Errorf("%s must be a non-empty string", key)
}
if flowID != "" && flowID != value {
return fmt.Errorf("conflicting workflow IDs")
}
flowID = value
}
for _, key := range []string{"data", "result", "response"} {
if nested, ok := object[key].(map[string]any); ok {
if err := visit(nested); err != nil {
return err
}
}
}
return nil
}
if envelope == nil {
return false, false, "", fmt.Errorf("empty response")
}
if err := visit(envelope); err != nil {
return false, false, "", err
}
return valid, validFound, flowID, nil
}
func validateWorkflowRunFlags(cmd *cobra.Command, _ []string) error {
tableID, _ := cmd.Flags().GetString("table-id")
tableID = strings.TrimSpace(tableID)
@@ -1184,6 +1263,290 @@ func runAitableViewUpdateArray(cmd *cobra.Command, blockKey string) error {
return callUpdateViewWithBlock(baseID, tableID, viewID, blockKey, cfgMap[blockKey], nil)
}
func runAitableViewUpdateFilter(cmd *cobra.Command) error {
baseID, tableID, viewID, _, err := viewUpdateCommonPreflight(cmd, "filter", nil, false)
if err != nil {
return err
}
jsonStr, _ := cmd.Flags().GetString("json")
if jsonStr == "" {
return fmt.Errorf("必须指定 --json 传入 filter JSON 数组")
}
var parsed any
if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil {
return fmt.Errorf("--json 解析失败: %v", err)
}
cfgMap := map[string]any{"filter": parsed}
if err := normalizeViewConfigBlock(cfgMap); err != nil {
return err
}
filter, _ := cfgMap["filter"].([]any)
fieldTypes, err := loadAitableFieldTypes(context.Background(), baseID, tableID)
if err != nil {
return err
}
if err := validateAitableViewFilter(filter, fieldTypes); err != nil {
return err
}
toolArgs := map[string]any{
"baseId": baseID, "tableId": tableID, "viewId": viewID,
"config": map[string]any{"filter": filter},
}
if deps.Caller.DryRun() {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true, "executed": false, "tool": "update_view", "arguments": toolArgs,
})
}
if _, err := callMCPToolReturnTextOnServer(context.Background(), "aitable", "update_view", toolArgs); err != nil {
return err
}
var actual any
var readBackErr error
for attempt := 0; attempt < aitableViewFilterReadbackAttempts; attempt++ {
if attempt > 0 {
backoff := time.Duration(1<<(attempt-1)) * time.Second
if backoff > 8*time.Second {
backoff = 8 * time.Second
}
aitableViewFilterReadbackSleep(backoff)
}
view, _, err := getViewRaw(context.Background(), baseID, tableID, viewID)
if err != nil {
readBackErr = err
continue
}
actualViewID, _ := view["viewId"].(string)
if actualViewID != viewID {
readBackErr = fmt.Errorf("update_view read-back returned viewId %q, want %q", actualViewID, viewID)
continue
}
actual = walkViewPath(view, "filter")
if persistedViewFilterMatches(actual, filter) {
readBackErr = nil
break
}
readBackErr = fmt.Errorf("update_view filter read-back mismatch: got %s, want %s", compactJSON(actual), compactJSON(filter))
}
if readBackErr != nil {
return &CLIError{Code: CodeMCPToolError, Message: readBackErr.Error(), Suggestion: "重新读取 view get filter,确认服务端支持所用字段类型和操作符后再试"}
}
return deps.Out.PrintJSON(map[string]any{
"success": true,
"data": map[string]any{"baseId": baseID, "tableId": tableID, "viewId": viewID, "filter": filter, "verified": true},
})
}
const aitableViewFilterReadbackAttempts = 6
var aitableViewFilterReadbackSleep = time.Sleep
func persistedViewFilterMatches(actual any, expected []any) bool {
if reflect.DeepEqual(actual, expected) {
return true
}
root, ok := actual.(map[string]any)
if !ok || root["operator"] != "and" {
return false
}
operands, ok := root["operands"].([]any)
return ok && reflect.DeepEqual(operands, expected)
}
func loadAitableFieldTypes(ctx context.Context, baseID, tableID string) (map[string]string, error) {
raw, err := callMCPReadToolReturnTextOnServer(ctx, "aitable", "get_fields", map[string]any{"baseId": baseID, "tableId": tableID})
if err != nil {
return nil, err
}
var payload any
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return nil, fmt.Errorf("get_fields response is not valid JSON: %v", err)
}
fields, ok := findAitableObjectList(payload, "fields", "fieldList")
if !ok {
return nil, fmt.Errorf("get_fields response is missing the fields collection")
}
types := make(map[string]string, len(fields))
for index, field := range fields {
fieldID, _ := field["fieldId"].(string)
if strings.TrimSpace(fieldID) == "" {
fieldID, _ = field["id"].(string)
}
fieldType, _ := field["type"].(string)
if strings.TrimSpace(fieldType) == "" {
fieldType, _ = field["fieldType"].(string)
}
if strings.TrimSpace(fieldID) == "" || strings.TrimSpace(fieldType) == "" {
return nil, fmt.Errorf("get_fields field %d is missing fieldId or type", index)
}
types[strings.TrimSpace(fieldID)] = strings.TrimSpace(fieldType)
}
return types, nil
}
func findAitableObjectList(value any, names ...string) ([]map[string]any, bool) {
wanted := make([]string, 0, len(names))
seen := make(map[string]bool, len(names))
for _, name := range names {
name = strings.ToLower(name)
if !seen[name] {
wanted = append(wanted, name)
seen[name] = true
}
}
var walk func(any) ([]map[string]any, bool)
walk = func(current any) ([]map[string]any, bool) {
switch typed := current.(type) {
case map[string]any:
keys := make([]string, 0, len(typed))
for key := range typed {
keys = append(keys, key)
}
sort.Strings(keys)
for _, name := range wanted {
for _, key := range keys {
if !strings.EqualFold(key, name) {
continue
}
items, ok := typed[key].([]any)
if !ok {
return nil, false
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
object, ok := item.(map[string]any)
if !ok {
return nil, false
}
out = append(out, object)
}
return out, true
}
}
for _, key := range keys {
if found, ok := walk(typed[key]); ok {
return found, true
}
}
case []any:
for _, child := range typed {
if found, ok := walk(child); ok {
return found, true
}
}
}
return nil, false
}
return walk(value)
}
func validateAitableViewFilter(filter []any, fieldTypes map[string]string) error {
for index, raw := range filter {
condition, ok := raw.(map[string]any)
if !ok {
return fmt.Errorf("filter[%d] must be an object", index)
}
if err := validateAitableViewFilterCondition(condition, fieldTypes); err != nil {
return fmt.Errorf("filter[%d]: %w", index, err)
}
}
return nil
}
func validateAitableViewFilterCondition(condition map[string]any, fieldTypes map[string]string) error {
operator, _ := condition["operator"].(string)
operator = strings.TrimSpace(operator)
if operator == "" || !validFilterOperators[operator] {
return fmt.Errorf("unsupported operator %q", operator)
}
operands, ok := condition["operands"].([]any)
if !ok {
return fmt.Errorf("operator %s requires an operands array", operator)
}
if operator == "and" || operator == "or" {
return fmt.Errorf("logical operator %s is not supported by the persisted view protocol; pass a flat array of leaf conditions (combined as AND)", operator)
}
wantOperands := 2
if operator == "exist" || operator == "un_exist" {
wantOperands = 1
}
if len(operands) != wantOperands {
return fmt.Errorf("operator %s requires %d operands", operator, wantOperands)
}
fieldID, ok := operands[0].(string)
fieldID = strings.TrimSpace(fieldID)
if !ok || fieldID == "" {
return fmt.Errorf("operator %s requires a fieldId as its first operand", operator)
}
fieldType, exists := fieldTypes[fieldID]
if !exists {
return fmt.Errorf("filter references unknown fieldId %q", fieldID)
}
if isAitableDateFieldType(fieldType) && !isAitableDateFilterOperator(operator) {
return fmt.Errorf("operator %s is invalid for %s field %s; use date_eq/before/after/not_before/not_after/exist/un_exist", operator, fieldType, fieldID)
}
if operator == "any_of" || operator == "all_of" || operator == "none_of" {
if !strings.EqualFold(fieldType, "multipleSelect") && !strings.EqualFold(fieldType, "multiSelect") {
return fmt.Errorf("operator %s requires a multipleSelect field, got %s", operator, fieldType)
}
if operator == "any_of" {
if values, ok := operands[1].([]any); ok {
if err := validateAitableMultiSelectOptionNames(values); err != nil {
return err
}
return fmt.Errorf("multipleSelect any_of with multiple values is not supported by the persisted view protocol; use one scalar option or separate views")
}
}
if err := validateAitableMultiSelectFilterValue(operands[1]); err != nil {
return err
}
}
return nil
}
func validateAitableMultiSelectOptionNames(values []any) error {
if len(values) == 0 {
return fmt.Errorf("multipleSelect any_of array must not be empty")
}
for index, value := range values {
text, ok := value.(string)
text = strings.TrimSpace(text)
if !ok || text == "" {
return fmt.Errorf("multipleSelect any_of value %d must be a non-empty option-name string", index)
}
}
return nil
}
func isAitableDateFieldType(fieldType string) bool {
return strings.EqualFold(fieldType, "date") ||
strings.EqualFold(fieldType, "createdTime") ||
strings.EqualFold(fieldType, "lastModifiedTime")
}
func isAitableDateFilterOperator(operator string) bool {
switch operator {
case "date_eq", "before", "after", "not_before", "not_after", "exist", "un_exist":
return true
default:
return false
}
}
func validateAitableMultiSelectFilterValue(value any) error {
if typed, ok := value.(string); ok && strings.TrimSpace(typed) != "" {
return nil
}
return fmt.Errorf("multipleSelect filter value must be one option-name string; the persisted view protocol does not support a multi-value OR expression")
}
func compactJSON(value any) string {
raw, err := json.Marshal(value)
if err != nil {
return fmt.Sprintf("%#v", value)
}
return string(raw)
}
func newAitableCommand() *cobra.Command {
// Product-level Agent routing Decl (migrated from selection/aitable.json
// products.aitable). Catalog assembly stamps provenance contract_final.
@@ -4076,7 +4439,7 @@ colorConfigs (JSON 数组) / officialHoliday (bool)。`,
若传对象会自动 wrap 为数组;其他非法格式拒绝。`,
Example: ` dws aitable view update filter --view-id VIEW_ID --json '[{"operator":"and","operands":[{"operator":"eq","operands":["fldX","value"]}]}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
return runAitableViewUpdateArray(cmd, "filter")
return runAitableViewUpdateFilter(cmd)
},
}
DeclareLeafMetadata(viewUpdateFilterCmd, LeafSpec{
@@ -5282,7 +5645,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
}
// create_workflow is non-idempotent. Bypass the retry wrapper to
// prevent an uncertain first response from creating a duplicate.
return callMCPToolOnServer("aitable", "create_workflow", toolArgs)
return executeAitableWorkflowPublish("create_workflow", toolArgs)
},
}
DeclareLeafMetadata(workflowCreateCmd, LeafSpec{
@@ -5376,7 +5739,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
if locale, _ := cmd.Flags().GetString("locale"); strings.TrimSpace(locale) != "" {
toolArgs["locale"] = locale
}
return callAitableTool("update_workflow", toolArgs)
return executeAitableWorkflowPublish("update_workflow", toolArgs)
},
}
DeclareLeafMetadata(workflowUpdateCmd, LeafSpec{
@@ -361,3 +361,228 @@ func TestCrossPlatformCoverageAitableDeleteCancellationEdges(t *testing.T) {
_ = runAitableCoverageCommand(t, &aitableCommandCoverageCaller{}, args...)
}
}
func TestCrossPlatformCoverageAitableViewFilterValidationAndReadBack(t *testing.T) {
testseam.Swap(t, &aitableViewFilterReadbackSleep, func(time.Duration) {})
oldArgs := os.Args
t.Cleanup(func() { os.Args = oldArgs })
os.Args = []string{"dws", "aitable"}
filter := `[{"operator":"eq","operands":["fldA","x"]},{"operator":"any_of","operands":["fldMulti","A"]}]`
fields := `{"data":{"fields":[{"fieldId":"fldA","type":"text"},{"fieldId":"fldB","type":"text"},{"fieldId":"fldMulti","type":"multipleSelect"}]}}`
readBack := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":[{"operator":"eq","operands":["fldA","x"]},{"operator":"any_of","operands":["fldMulti","A"]}]}]}}`
t.Run("flat leaf filters are written then exactly verified", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`, readBack}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter)
if err != nil || len(caller.calls) != 3 || caller.calls[0].tool != "get_fields" || caller.calls[1].tool != "update_view" || caller.calls[2].tool != "get_views" {
t.Fatalf("verified view filter = err:%v calls:%#v", err, caller.calls)
}
config, _ := caller.calls[1].args["config"].(map[string]any)
if _, ok := config["filter"].([]any); !ok {
t.Fatalf("update_view filter encoding = %#v", caller.calls[1].args)
}
})
t.Run("logical groups fail closed before write", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"or","operands":[{"operator":"eq","operands":["fldA","x"]},{"operator":"eq","operands":["fldB","y"]}]}]`)
if err == nil || !strings.Contains(err.Error(), "persisted view protocol") || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
t.Fatalf("logical filter group fail-closed = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("unknown field is rejected before write", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"eq","operands":["missing","x"]}]`)
if err == nil || !strings.Contains(err.Error(), "unknown fieldId") || len(caller.calls) != 1 {
t.Fatalf("unknown filter field = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("eventual persisted and wrapper is retried then verified", func(t *testing.T) {
input := `[{"operator":"any_of","operands":["fldMulti","A"]}]`
stale := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":{"operator":"and","operands":[]}}]}}`
terminal := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":{"operator":"and","operands":[{"operator":"any_of","operands":["fldMulti","A"]}]}}]}}`
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`, stale, terminal}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
if err != nil || len(caller.calls) != 4 || caller.calls[2].tool != "get_views" || caller.calls[3].tool != "get_views" {
t.Fatalf("eventual wrapped filter = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("multi-select operator rejects text field", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"any_of","operands":["fldA",["A"]]}]`)
if err == nil || !strings.Contains(err.Error(), "requires a multipleSelect field") || len(caller.calls) != 1 {
t.Fatalf("wrong filter field type = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("multi-select array fails closed before write", func(t *testing.T) {
input := `[{"operator":"any_of","operands":["fldMulti",["A","B"]]}]`
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
if err == nil || !strings.Contains(err.Error(), "persisted view protocol") || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
t.Fatalf("multi-select array fail-closed = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("multi-select invalid array value fails before write", func(t *testing.T) {
input := `[{"operator":"any_of","operands":["fldMulti",["A",""]]}]`
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
if err == nil || !strings.Contains(err.Error(), "non-empty option-name") || len(caller.calls) != 1 {
t.Fatalf("invalid multi-select array = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("date and system-time fields require date operators", func(t *testing.T) {
fieldTypes := map[string]string{"date": "date", "created": "createdTime", "modified": "lastModifiedTime"}
for fieldID := range fieldTypes {
valid := []any{map[string]any{"operator": "date_eq", "operands": []any{fieldID, "2026-08-18"}}}
if err := validateAitableViewFilter(valid, fieldTypes); err != nil {
t.Fatalf("date_eq for %s: %v", fieldID, err)
}
invalid := []any{map[string]any{"operator": "eq", "operands": []any{fieldID, "2026-08-18"}}}
if err := validateAitableViewFilter(invalid, fieldTypes); err == nil || !strings.Contains(err.Error(), "invalid for") {
t.Fatalf("eq for %s = %v, want date-operator error", fieldID, err)
}
}
})
t.Run("dry-run validates but performs no write or read-back", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}, dryRun: true}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter, "--dry-run")
if err != nil || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
t.Fatalf("view filter dry-run = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("read-back mismatch is not success", func(t *testing.T) {
responses := []string{fields, `{"success":true}`}
for range aitableViewFilterReadbackAttempts {
responses = append(responses, `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":[]}]}}`)
}
caller := &aitableTestCaller{responses: responses}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter)
if err == nil || !strings.Contains(err.Error(), "read-back mismatch") || len(caller.calls) != 2+aitableViewFilterReadbackAttempts {
t.Fatalf("mismatched filter readback = err:%v calls:%#v", err, caller.calls)
}
})
}
func TestCrossPlatformCoverageAitableViewFilterFailureAndShapeEdges(t *testing.T) {
testseam.Swap(t, &aitableViewFilterReadbackSleep, func(time.Duration) {})
testseam.Protect(t, &os.Args)
os.Args = []string{"dws", "aitable"}
fields := `{"data":{"fields":[{"fieldId":"fldA","type":"text"}]}}`
filter := `[{"operator":"eq","operands":["fldA","x"]}]`
args := []string{"view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json=" + filter}
t.Run("update transport error", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}, errors: []error{nil, context.Canceled}}
if err := runAitableCoverageCommand(t, caller, args...); err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || len(caller.calls) != 2 {
t.Fatalf("update error = %v, calls=%#v", err, caller.calls)
}
})
t.Run("readback transport errors exhaust", func(t *testing.T) {
errs := []error{nil, nil}
for range aitableViewFilterReadbackAttempts {
errs = append(errs, context.DeadlineExceeded)
}
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`}, errors: errs}
if err := runAitableCoverageCommand(t, caller, args...); err == nil || len(caller.calls) != 2+aitableViewFilterReadbackAttempts {
t.Fatalf("readback errors = %v, calls=%d", err, len(caller.calls))
}
})
t.Run("readback wrong identity exhausts", func(t *testing.T) {
responses := []string{fields, `{"success":true}`}
for range aitableViewFilterReadbackAttempts {
responses = append(responses, `{"data":{"views":[{"viewId":"other","filter":[]}]}}`)
}
caller := &aitableTestCaller{responses: responses}
if err := runAitableCoverageCommand(t, caller, args...); err == nil || !strings.Contains(err.Error(), "returned viewId") {
t.Fatalf("wrong readback identity = %v", err)
}
})
loadCases := []struct {
name string
response string
callErr error
wantError string
}{
{name: "transport", callErr: context.Canceled, wantError: "context canceled"},
{name: "invalid json", response: `{`, wantError: "not valid JSON"},
{name: "missing collection", response: `{}`, wantError: "missing the fields collection"},
{name: "missing identity", response: `{"fields":[{"type":"text"}]}`, wantError: "missing fieldId or type"},
}
for _, tc := range loadCases {
t.Run("load fields "+tc.name, func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{tc.response}, errors: []error{tc.callErr}}
installAitableDeps(t, caller)
if _, err := loadAitableFieldTypes(context.Background(), "b", "t"); err == nil || !strings.Contains(err.Error(), tc.wantError) {
t.Fatalf("load fields error = %v, want %q", err, tc.wantError)
}
})
}
t.Run("load legacy field keys", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{`{"fieldList":[{"id":"legacy","fieldType":"text"}]}`}}
installAitableDeps(t, caller)
got, err := loadAitableFieldTypes(context.Background(), "b", "t")
if err != nil || got["legacy"] != "text" {
t.Fatalf("legacy field keys = %#v, %v", got, err)
}
})
if _, ok := findAitableObjectList(map[string]any{"fields": "bad"}, "fields"); ok {
t.Fatal("scalar fields collection must fail")
}
if _, ok := findAitableObjectList(map[string]any{"fields": []any{"bad"}}, "fields"); ok {
t.Fatal("scalar field item must fail")
}
if got, ok := findAitableObjectList([]any{"skip", map[string]any{"nested": map[string]any{"fields": []any{map[string]any{"fieldId": "f"}}}}}, "fields"); !ok || len(got) != 1 {
t.Fatalf("recursive fields = %#v, %v", got, ok)
}
if got, ok := findAitableObjectList(map[string]any{
"fieldList": []any{map[string]any{"fieldId": "legacy"}},
"fields": []any{map[string]any{"fieldId": "canonical"}},
}, "fields", "fieldList"); !ok || len(got) != 1 || got[0]["fieldId"] != "canonical" {
t.Fatalf("declared collection priority = %#v, %v", got, ok)
}
invalidFilters := []struct {
filter []any
want string
}{
{filter: []any{"bad"}, want: "must be an object"},
{filter: []any{map[string]any{"operator": "bogus", "operands": []any{}}}, want: "unsupported operator"},
{filter: []any{map[string]any{"operator": "eq", "operands": "bad"}}, want: "requires an operands array"},
{filter: []any{map[string]any{"operator": "and", "operands": []any{}}}, want: "logical operator"},
{filter: []any{map[string]any{"operator": "exist", "operands": []any{"f", "extra"}}}, want: "requires 1 operands"},
{filter: []any{map[string]any{"operator": "eq", "operands": []any{1, "x"}}}, want: "requires a fieldId"},
{filter: []any{map[string]any{"operator": "any_of", "operands": []any{"multi", 1}}}, want: "one option-name string"},
}
for _, tc := range invalidFilters {
if err := validateAitableViewFilter(tc.filter, map[string]string{"f": "text", "multi": "multipleSelect"}); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("validate filter %#v = %v, want %q", tc.filter, err, tc.want)
}
}
if err := validateAitableMultiSelectOptionNames(nil); err == nil {
t.Fatal("empty any_of array must fail")
}
if err := validateAitableMultiSelectOptionNames([]any{"ok", 1}); err == nil {
t.Fatal("non-string any_of option must fail")
}
if err := validateAitableMultiSelectOptionNames([]any{"first", " second "}); err != nil {
t.Fatalf("valid any_of option names = %v", err)
}
if got := compactJSON(make(chan int)); !strings.HasPrefix(got, "(chan int)") {
t.Fatalf("compactJSON fallback = %q", got)
}
if persistedViewFilterMatches("bad", nil) || persistedViewFilterMatches(map[string]any{"operator": "or"}, nil) {
t.Fatal("invalid persisted wrapper must not match")
}
}
+5 -1
View File
@@ -24,6 +24,7 @@ type aitableTestCaller struct {
responses []string
errors []error
calls []aitableTestCall
dryRun bool
}
func (c *aitableTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
@@ -38,8 +39,11 @@ func (c *aitableTestCaller) CallTool(_ context.Context, server, tool string, arg
}
return textToolResult(response), nil
}
func (c *aitableTestCaller) CallReadTool(ctx context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
return c.CallTool(ctx, server, tool, args)
}
func (*aitableTestCaller) Format() string { return "json" }
func (*aitableTestCaller) DryRun() bool { return false }
func (c *aitableTestCaller) DryRun() bool { return c.dryRun }
func (*aitableTestCaller) Fields() string { return "" }
func (*aitableTestCaller) JQ() string { return "" }
@@ -23,27 +23,42 @@ type aitableWorkflowCall struct {
}
type aitableWorkflowCaller struct {
calls []aitableWorkflowCall
calls []aitableWorkflowCall
response string
err error
dryRun bool
}
func (c *aitableWorkflowCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, aitableWorkflowCall{productID: productID, toolName: toolName, args: args})
if c.err != nil {
return nil, c.err
}
response := c.response
if response == "" {
response = `{"status":"success","data":{"valid":true,"flowId":"flow-test","issues":[]}}`
}
return &edition.ToolResult{Content: []edition.ContentBlock{{
Type: "text",
Text: `{"status":"success","data":{"valid":true,"flowId":"flow-test","issues":[]}}`,
Text: response,
}}}, nil
}
func (*aitableWorkflowCaller) Format() string { return "json" }
func (*aitableWorkflowCaller) DryRun() bool { return false }
func (c *aitableWorkflowCaller) DryRun() bool { return c.dryRun }
func (*aitableWorkflowCaller) Fields() string { return "" }
func (*aitableWorkflowCaller) JQ() string { return "" }
func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*aitableWorkflowCaller, error) {
t.Helper()
caller := &aitableWorkflowCaller{}
return caller, runAitableWorkflowCommandWithCaller(t, caller, stdin, args...)
}
func runAitableWorkflowCommandWithCaller(t *testing.T, caller *aitableWorkflowCaller, stdin io.Reader, args ...string) error {
t.Helper()
testseam.Protect(t, &os.Args)
caller := &aitableWorkflowCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
os.Args = append([]string{"dws", "aitable", "workflow"}, args...)
@@ -51,6 +66,7 @@ func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*
cmd := newAitableCommand()
cmd.PersistentFlags().String("format", "json", "output format")
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
cmd.PersistentFlags().Bool("dry-run", false, "preview only")
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs(append([]string{"workflow"}, args...))
@@ -58,10 +74,10 @@ func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*
stdin = strings.NewReader("")
}
cmd.SetIn(stdin)
return caller, cmd.Execute()
return cmd.Execute()
}
func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
func TestCrossPlatformCoverageAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
wantDSL := map[string]any{
"version": "workflow-dsl/v1",
"name": "create test",
@@ -92,6 +108,65 @@ func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
}
}
func TestCrossPlatformCoverageAitableWorkflowPublishRejectsFalseSuccess(t *testing.T) {
tests := []struct {
name string
response string
want string
}{
{name: "valid false", response: `{"status":"success","data":{"valid":false,"issues":[{"message":"bad dsl"}]}}`, want: "valid=false"},
{name: "missing valid", response: `{"status":"success","data":{"flowId":"flow-test"}}`, want: "missing valid"},
{name: "missing flow id", response: `{"status":"success","data":{"valid":true}}`, want: "missing a non-empty flowId"},
{name: "wrong valid type", response: `{"status":"success","data":{"valid":"true","flowId":"flow-test"}}`, want: "valid must be boolean"},
{name: "malformed response", response: `{`, want: "not a JSON object"},
{name: "null response", response: `null`, want: "not a JSON object"},
{name: "conflicting valid", response: `{"valid":true,"data":{"valid":false,"flowId":"flow-test"}}`, want: "conflicting valid values"},
{name: "invalid flow id", response: `{"valid":true,"flowId":1}`, want: "flowId must be a non-empty string"},
{name: "conflicting workflow ids", response: `{"valid":true,"flowId":"one","data":{"workflowId":"two"}}`, want: "conflicting workflow IDs"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &aitableWorkflowCaller{response: tc.response}
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
if err == nil || !strings.Contains(err.Error(), tc.want) || len(caller.calls) != 1 {
t.Fatalf("workflow false success = err:%v calls:%#v", err, caller.calls)
}
})
}
t.Run("update uses the same strict contract", func(t *testing.T) {
caller := &aitableWorkflowCaller{response: `{"status":"success","data":{"valid":false}}`}
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
"update", "--base-id", "base", "--workflow-id", "flow", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
if err == nil || !strings.Contains(err.Error(), "valid=false") || len(caller.calls) != 1 || caller.calls[0].toolName != "update_workflow" {
t.Fatalf("workflow update false success = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("dry-run does not call publish tool", func(t *testing.T) {
caller := &aitableWorkflowCaller{dryRun: true}
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`, "--dry-run")
if err != nil || len(caller.calls) != 0 {
t.Fatalf("workflow create dry-run = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("transport error", func(t *testing.T) {
caller := &aitableWorkflowCaller{err: context.Canceled}
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || len(caller.calls) != 1 {
t.Fatalf("workflow transport error = err:%v calls:%#v", err, caller.calls)
}
})
if _, _, _, err := strictAitableWorkflowPublishResult(nil); err == nil || !strings.Contains(err.Error(), "empty response") {
t.Fatalf("nil workflow envelope = %v", err)
}
}
func TestAitableWorkflowEditExampleMapsEmptyArguments(t *testing.T) {
caller, err := runAitableWorkflowCommand(t, nil, "edit-example")
if err != nil {
+2 -2
View File
@@ -108,7 +108,7 @@ func driveFolderPushResultSpec() *contract.ResultSpec {
"preview_kind":{"type":"string","description":"预览类型","enum":["plan"]},
"operation":{"type":"string","description":"预览的命令"},
"if_exists":{"type":"string","description":"远端同名文件处理策略"},
"plan":{"type":"object","description":"预览的推送计划","properties":{"summary":{"type":"object","description":"计划动作计数","properties":{"uploaded":{"type":"integer","description":"计划上传或覆盖的文件数"},"skipped":{"type":"integer","description":"计划跳过的文件数"},"failed":{"type":"integer","description":"预检失败的条目数"},"aborted":{"type":"boolean","description":"计划是否会中止"}},"required":["uploaded","skipped","failed","aborted"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"计划动作"},"size_bytes":{"type":"integer","description":"文件字节数"},"error":{"type":"string","description":"预检失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["summary","items"],"additionalProperties":false}
"plan":{"type":"object","description":"预览的推送计划","properties":{"summary":{"type":"object","description":"计划动作计数;兼容执行计数 uploaded/skipped 在预览中保持为零","properties":{"uploaded":{"type":"integer","description":"兼容字段;预览未执行上传,固定为零"},"skipped":{"type":"integer","description":"兼容字段;预览未执行跳过,固定为零"},"failed":{"type":"integer","description":"预检失败的条目数"},"aborted":{"type":"boolean","description":"计划是否会中止"},"planned_uploads":{"type":"integer","description":"计划上传或覆盖的文件数"},"planned_skips":{"type":"integer","description":"计划按策略跳过的文件数"},"planned_folders":{"type":"integer","description":"计划创建的远端目录数"}},"required":["uploaded","skipped","failed","aborted"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"未执行的 planned_* 计划动作或已确认的预检失败"},"size_bytes":{"type":"integer","description":"文件字节数"},"error":{"type":"string","description":"预检失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["summary","items"],"additionalProperties":false}
},
"additionalProperties":false
}`),
@@ -134,7 +134,7 @@ func driveFolderSyncResultSpec() *contract.ResultSpec {
"executed":{"type":"boolean","description":"是否执行了写操作","const":false},
"preview_kind":{"type":"string","description":"预览类型","enum":["plan"]},
"operation":{"type":"string","description":"预览的命令","const":"drive sync"},
"plan":{"type":"object","description":"预览的同步计划","properties":{"detection":{"type":"string","description":"计划使用的差异检测模式","enum":["exact","quick"]},"diff":{"type":"object","description":"计划执行前的双端差异","properties":{"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}},"required":["new_local","new_remote","modified","unchanged","unknown"],"additionalProperties":false},"summary":{"type":"object","description":"计划动作计数","properties":{"pulled":{"type":"integer","description":"计划拉取的条目数"},"pushed":{"type":"integer","description":"计划推送的条目数"},"skipped":{"type":"integer","description":"计划跳过的条目数"},"failed":{"type":"integer","description":"预检失败的条目数"}},"required":["pulled","pushed","skipped","failed"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"计划动作"},"direction":{"type":"string","description":"同步方向"},"error":{"type":"string","description":"失败或跳过原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["detection","diff","summary","items"],"additionalProperties":false}
"plan":{"type":"object","description":"预览的同步计划","properties":{"detection":{"type":"string","description":"计划使用的差异检测模式","enum":["exact","quick"]},"diff":{"type":"object","description":"计划执行前的双端差异","properties":{"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}},"required":["new_local","new_remote","modified","unchanged","unknown"],"additionalProperties":false},"summary":{"type":"object","description":"计划动作计数;兼容执行计数 pulled/pushed/skipped 在预览中保持为零","properties":{"pulled":{"type":"integer","description":"兼容字段;预览未执行拉取,固定为零"},"pushed":{"type":"integer","description":"兼容字段;预览未执行推送,固定为零"},"skipped":{"type":"integer","description":"兼容字段;预览未执行跳过,固定为零"},"failed":{"type":"integer","description":"预检失败的条目数"},"planned_pulls":{"type":"integer","description":"计划拉取的文件数"},"planned_pushes":{"type":"integer","description":"计划上传或覆盖的文件数"},"planned_skips":{"type":"integer","description":"计划按策略跳过的文件数"},"planned_folders":{"type":"integer","description":"计划创建的远端目录数"}},"required":["pulled","pushed","skipped","failed"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"未执行的 planned_* 计划动作、待决策动作或预检失败"},"direction":{"type":"string","description":"同步方向"},"error":{"type":"string","description":"失败或跳过原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["detection","diff","summary","items"],"additionalProperties":false}
},
"oneOf":[
{"required":["detection","diff","summary","items"]},
@@ -93,10 +93,26 @@ func TestCrossPlatformCoverageDriveFolderContractsPublishResultAndDryRun(t *test
if name == "sync" {
assertDriveSyncResultSchema(t, final.Result.DataSchema)
}
if name == "push" {
assertDrivePushResultSchema(t, final.Result.DataSchema)
}
})
}
}
func assertDrivePushResultSchema(t *testing.T, raw json.RawMessage) {
t.Helper()
properties := resultSchemaProperties(t, raw)
plan := schemaObjectProperty(t, properties, "plan")
summary := schemaObjectProperty(t, schemaProperties(t, plan), "summary")
summaryProperties := schemaProperties(t, summary)
for _, name := range []string{"planned_uploads", "planned_skips", "planned_folders"} {
if _, ok := summaryProperties[name]; !ok {
t.Fatalf("push plan summary schema is missing %s", name)
}
}
}
func resultSchemaProperties(t *testing.T, raw json.RawMessage) map[string]any {
t.Helper()
var schema map[string]any
@@ -133,6 +149,13 @@ func assertDriveSyncResultSchema(t *testing.T, raw json.RawMessage) {
planProperties := schemaProperties(t, plan)
planDiff := schemaObjectProperty(t, planProperties, "diff")
assertSchemaRequired(t, planDiff, "new_local", "new_remote", "modified", "unchanged", "unknown")
planSummary := schemaObjectProperty(t, planProperties, "summary")
planSummaryProperties := schemaProperties(t, planSummary)
for _, name := range []string{"planned_pulls", "planned_pushes", "planned_skips", "planned_folders"} {
if _, ok := planSummaryProperties[name]; !ok {
t.Fatalf("sync plan summary schema is missing %s", name)
}
}
var schema map[string]any
if err := json.Unmarshal(raw, &schema); err != nil {
+26 -14
View File
@@ -39,6 +39,11 @@ const (
pushActionSkipped = "skipped" // 按 --if-exists 跳过
pushActionFolderCreated = "folder_created" // 新建远端目录(不计入 uploaded)
pushActionFailed = "failed"
pushActionPlannedUpload = "planned_upload"
pushActionPlannedOverwrite = "planned_overwrite"
pushActionPlannedSkip = "planned_skip"
pushActionPlannedFolderCreate = "planned_folder_create"
)
// localPushFile 描述一个待推送的本地常规文件。
@@ -79,10 +84,13 @@ type drivePushItem struct {
// drivePushSummary 是各动作的计数汇总。uploaded 同时统计新建与覆盖。
type drivePushSummary struct {
Uploaded int `json:"uploaded"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Aborted bool `json:"aborted"`
Uploaded int `json:"uploaded"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Aborted bool `json:"aborted"`
PlannedUploads int `json:"planned_uploads,omitempty"`
PlannedSkips int `json:"planned_skips,omitempty"`
PlannedFolders int `json:"planned_folders,omitempty"`
}
type drivePushResult struct {
@@ -380,7 +388,8 @@ func printDrivePushDryRunWithPreflight(ifExists string, remoteFiles map[string]*
continue
}
plannedFolders[dir] = "dry-run-planned-folder"
plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionFolderCreated})
plan.Summary.PlannedFolders++
plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionPlannedFolderCreate})
}
for _, lf := range localFiles {
size := lf.Size
@@ -390,25 +399,25 @@ func printDrivePushDryRunWithPreflight(ifExists string, remoteFiles map[string]*
plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: "父目录未能创建"})
continue
}
action := pushActionUploaded
action := pushActionPlannedUpload
if rf, exists := remoteFiles[lf.RelPath]; exists {
switch ifExists {
case ifExistsSkip:
action = pushActionSkipped
action = pushActionPlannedSkip
case ifExistsSmart:
if rf.ModifiedTimeValid && rf.ModifiedTime >= lf.ModTimeMillis {
action = pushActionSkipped
action = pushActionPlannedSkip
} else {
action = pushActionOverwritten
action = pushActionPlannedOverwrite
}
case ifExistsOverwrite:
action = pushActionOverwritten
action = pushActionPlannedOverwrite
}
}
if action == pushActionSkipped {
plan.Summary.Skipped++
if action == pushActionPlannedSkip {
plan.Summary.PlannedSkips++
} else {
plan.Summary.Uploaded++
plan.Summary.PlannedUploads++
}
plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: action, SizeBytes: &size})
}
@@ -811,7 +820,10 @@ func defaultPushPutOpenedFile(ctx context.Context, url string, headers map[strin
if _, err := file.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek upload file: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, file)
// net/http owns and closes Request.Body after RoundTrip. The *os.File belongs
// to pushUploadFilePinned, which must stat it after PUT before commit_upload;
// wrap it in a no-op closer so HTTP completion cannot close that shared handle.
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, io.NopCloser(file))
if err != nil {
return fmt.Errorf("failed to create upload request: %w", err)
}
@@ -421,6 +421,29 @@ func TestCrossPlatformCoverageDrivePushFinalDefaultOpenedPUTAndCommit(t *testing
}
}
func TestCrossPlatformCoverageDrivePushOpenedPUTKeepsCallerOwnedHandle(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
_, _ = io.Copy(io.Discard, request.Body)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
path := filepath.Join(t.TempDir(), "payload")
mustWrite(t, path, "payload")
file, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
if err := defaultPushPutOpenedFile(context.Background(), server.URL, nil, file, 7); err != nil {
t.Fatalf("PUT failed: %v", err)
}
if _, err := file.Stat(); err != nil {
t.Fatalf("HTTP PUT closed its caller-owned file handle: %v", err)
}
}
func TestCrossPlatformCoverageDriveSyncFinalPreflightAndKeepBothErrors(t *testing.T) {
t.Run("local target preflight lstat error and directory", func(t *testing.T) {
_, root, _ := openDriveFinalCoverageRoot(t)
+120 -4
View File
@@ -65,6 +65,14 @@ const (
syncActionRenamedLocal = "renamed_local"
syncActionSkipped = "skipped"
syncActionFailed = "failed"
syncActionPlannedDownload = "planned_download"
syncActionPlannedUpload = "planned_upload"
syncActionPlannedOverwrite = "planned_overwrite"
syncActionPlannedFolderCreate = "planned_folder_create"
syncActionPlannedRenameLocal = "planned_rename_local"
syncActionPlannedSkip = "planned_skip"
syncActionDecisionRequired = "decision_required"
)
// driveSyncItem 是输出 items[] 中每条操作的明细。
@@ -77,10 +85,14 @@ type driveSyncItem struct {
// driveSyncSummary 是各方向的计数汇总。
type driveSyncSummary struct {
Pulled int `json:"pulled"`
Pushed int `json:"pushed"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Pulled int `json:"pulled"`
Pushed int `json:"pushed"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
PlannedPulls int `json:"planned_pulls,omitempty"`
PlannedPushes int `json:"planned_pushes,omitempty"`
PlannedSkips int `json:"planned_skips,omitempty"`
PlannedFolders int `json:"planned_folders,omitempty"`
}
// driveSyncDiff 是本次同步前算出的五类差异(与 status 同源)。
@@ -267,6 +279,12 @@ func runDriveSync(cmd *cobra.Command, _ []string) error {
// --dry-run:只算差异、不执行任何同步动作。
if deps.Caller.DryRun() {
if len(preflight) == 0 && res.Summary.Failed == 0 {
appendDriveSyncDryRunPlan(&res, driveSyncDryRunPlanInput{
LocalDirs: localDirs, LocalByRel: localByRel, RemoteFiles: remoteFiles, RemoteFolders: remoteFolders,
NewLocal: newLocal, NewRemote: newRemote, Modified: modified, Unknown: unknown, OnConflict: onConflict,
})
}
return deps.Out.PrintJSON(driveSyncDryRunResult{
DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive sync", Plan: res,
})
@@ -394,6 +412,104 @@ func runDriveSync(cmd *cobra.Command, _ []string) error {
return nil
}
type driveSyncDryRunPlanInput struct {
LocalDirs []string
LocalByRel map[string]localPushFile
RemoteFiles map[string]*remoteFile
RemoteFolders map[string]string
NewLocal []string
NewRemote []string
Modified []string
Unknown []string
OnConflict string
}
func appendDriveSyncDryRunPlan(res *driveSyncResult, input driveSyncDryRunPlanInput) {
for _, rel := range input.Unknown {
res.Summary.PlannedSkips++
res.Items = append(res.Items, driveSyncItem{
RelPath: rel, Action: syncActionPlannedSkip, Direction: syncDirectionConflict,
Error: "远端无可靠 md5,内容无法核对,执行时会跳过(可改用 --quick 按 modified_time 比对)",
})
}
plannedFolders := make(map[string]string, len(input.RemoteFolders)+len(input.LocalDirs))
for rel, fileID := range input.RemoteFolders {
plannedFolders[rel] = fileID
}
for _, dir := range input.LocalDirs {
if _, ok := plannedFolders[dir]; ok {
continue
}
parentRel, _ := splitRel(dir)
if plannedFolders[parentRel] == "" {
res.Summary.Failed++
res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"})
continue
}
plannedFolders[dir] = "dry-run-planned-folder"
res.Summary.PlannedFolders++
res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionPlannedFolderCreate, Direction: syncDirectionPush})
}
for _, rel := range input.NewRemote {
res.Summary.PlannedPulls++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull})
}
for _, rel := range input.NewLocal {
parentRel, _ := splitRel(rel)
if plannedFolders[parentRel] == "" {
res.Summary.Failed++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"})
continue
}
res.Summary.PlannedPushes++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedUpload, Direction: syncDirectionPush})
}
occupied := make(map[string]bool, len(input.LocalByRel)+len(input.LocalDirs)+len(input.RemoteFiles)+len(input.RemoteFolders))
for rel := range input.LocalByRel {
occupied[rel] = true
}
for _, rel := range input.LocalDirs {
occupied[rel] = true
}
for rel := range input.RemoteFiles {
occupied[rel] = true
}
for rel := range input.RemoteFolders {
if rel != "" {
occupied[rel] = true
}
}
for _, rel := range input.Modified {
switch input.OnConflict {
case syncConflictRemoteWins:
res.Summary.PlannedPulls++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull})
case syncConflictLocalWins:
res.Summary.PlannedPushes++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedOverwrite, Direction: syncDirectionConflict})
case syncConflictKeepBoth:
candidate := driveSyncSuffixedRel(rel, input.RemoteFiles[rel].FileID, occupied)
occupied[candidate] = true
res.Summary.PlannedPulls++
res.Items = append(res.Items,
driveSyncItem{RelPath: candidate, Action: syncActionPlannedRenameLocal, Direction: syncDirectionConflict},
driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull},
)
case syncConflictAsk:
res.Items = append(res.Items, driveSyncItem{
RelPath: rel, Action: syncActionDecisionRequired, Direction: syncDirectionConflict,
Error: "执行时需要交互选择 remote-wins、local-wins、keep-both 或 skip",
})
default:
res.Summary.PlannedSkips++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedSkip, Direction: syncDirectionConflict})
}
}
}
// syncPathBlockedByTypeConflict 判断 rel 本身或任一祖先是否是文件/目录类型冲突根。
// 祖先检查保证冲突目录下的本地、远端后代不会绕过根路径的 fail-closed 结论。
func syncPathBlockedByTypeConflict(rel string, conflicts map[string]string) bool {
@@ -3,6 +3,7 @@ package helpers
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
@@ -189,6 +190,116 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlansWithoutRemoteWrites(t *testin
}
}
func TestCrossPlatformCoverageDrivePushDryRunPublishesPlannedNotExecutedActions(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "local.txt"), "local")
caller := syncCaller(nil)
caller.dryRun = true
var out bytes.Buffer
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
testseam.Swap(t, &os.Args, []string{"dws", "drive", "push"})
cmd := findDriveSubcommand(t, "push")
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("dry-run push: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("decode dry-run push: %v\n%s", err, out.String())
}
plan := payload["plan"].(map[string]any)
summary := plan["summary"].(map[string]any)
if summary["uploaded"] != float64(0) || summary["planned_uploads"] != float64(1) {
t.Fatalf("dry-run summary reports execution instead of a plan: %#v", summary)
}
items := plan["items"].([]any)
if got := items[0].(map[string]any)["action"]; got != "planned_upload" {
t.Fatalf("dry-run action = %v, want planned_upload", got)
}
}
func TestCrossPlatformCoverageDriveSyncDryRunReturnsRealActionPlan(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "local.txt"), "local")
mustWrite(t, filepath.Join(dir, "shared.txt"), "shared")
if err := os.Mkdir(filepath.Join(dir, "empty-folder"), 0o755); err != nil {
t.Fatal(err)
}
caller := syncCaller(map[string]string{
"ROOT": `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2},{"name":"shared.txt","type":"file","fileId":"S","modifyTime":2}],"nextToken":""}}`,
})
caller.dryRun = true
var out bytes.Buffer
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
testseam.Swap(t, &os.Args, []string{"dws", "drive", "sync"})
cmd := findDriveSubcommand(t, "sync")
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("dry-run sync: %v", err)
}
var payload driveSyncDryRunResult
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("decode dry-run sync: %v\n%s", err, out.String())
}
if payload.Executed || payload.Plan.Summary.Pulled != 0 || payload.Plan.Summary.Pushed != 0 || payload.Plan.Summary.Skipped != 0 {
t.Fatalf("dry-run plan summary = %+v, executed=%v", payload.Plan.Summary, payload.Executed)
}
if payload.Plan.Summary.PlannedPulls != 1 || payload.Plan.Summary.PlannedPushes != 1 ||
payload.Plan.Summary.PlannedSkips != 1 || payload.Plan.Summary.PlannedFolders != 1 {
t.Fatalf("dry-run planned summary = %+v", payload.Plan.Summary)
}
actions := map[string]int{}
for _, item := range payload.Plan.Items {
actions[item.Action]++
}
if len(payload.Plan.Items) != 4 || actions["planned_download"] != 1 || actions["planned_upload"] != 1 ||
actions["planned_skip"] != 1 || actions["planned_folder_create"] != 1 {
t.Fatalf("dry-run action plan = %#v", payload.Plan.Items)
}
for _, tool := range []string{"download_file", "get_upload_info", "commit_upload", "create_folder"} {
if calls := caller.callsFor(tool); len(calls) != 0 {
t.Fatalf("dry-run called %s: %#v", tool, calls)
}
}
}
func TestCrossPlatformCoverageDriveSyncDryRunPlanBranchMatrix(t *testing.T) {
remoteFiles := map[string]*remoteFile{
"conflict.txt": {FileID: "remote-file"},
}
remoteFolders := map[string]string{"": "root", "existing": "existing-id"}
localDirs := []string{"existing", "new", "missing/child"}
localFiles := map[string]localPushFile{"conflict.txt": {}, "new/file.txt": {}}
for _, policy := range []string{syncConflictRemoteWins, syncConflictLocalWins, syncConflictKeepBoth, syncConflictAsk, syncConflictSkip} {
t.Run(policy, func(t *testing.T) {
res := &driveSyncResult{}
appendDriveSyncDryRunPlan(res, driveSyncDryRunPlanInput{
LocalDirs: localDirs, LocalByRel: localFiles, RemoteFiles: remoteFiles, RemoteFolders: remoteFolders,
NewLocal: []string{"new/file.txt", "missing/file.txt"}, NewRemote: []string{"remote.txt"},
Modified: []string{"conflict.txt"}, Unknown: []string{"unknown.txt"}, OnConflict: policy,
})
if res.Summary.PlannedSkips == 0 || res.Summary.PlannedFolders == 0 || res.Summary.PlannedPulls == 0 || res.Summary.Failed != 2 {
t.Fatalf("dry-run plan summary for %s = %#v; items=%#v", policy, res.Summary, res.Items)
}
switch policy {
case syncConflictRemoteWins, syncConflictKeepBoth:
if res.Summary.PlannedPulls < 2 {
t.Fatalf("%s planned pulls = %d", policy, res.Summary.PlannedPulls)
}
case syncConflictLocalWins:
if res.Summary.PlannedPushes < 2 {
t.Fatalf("local wins planned pushes = %d", res.Summary.PlannedPushes)
}
}
})
}
}
func TestCrossPlatformCoverageDrivePull_dryRunPlanBranches(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "skip.txt"), "skip")
@@ -242,7 +353,7 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) {
[]string{"existing", "missing/child"}, files("orphan/a.txt", "skip.txt", "new.txt")); err != nil {
t.Fatalf("skip plan: %v", err)
}
if text := out.String(); !strings.Contains(text, pushActionFailed) || !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionUploaded) {
if text := out.String(); !strings.Contains(text, pushActionFailed) || !strings.Contains(text, pushActionPlannedSkip) || !strings.Contains(text, pushActionPlannedUpload) {
t.Fatalf("skip plan did not cover failed/skipped/uploaded: %s", text)
}
out.Reset()
@@ -250,14 +361,14 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) {
files("smart-skip.txt", "smart-overwrite.txt")); err != nil {
t.Fatalf("smart plan: %v", err)
}
if text := out.String(); !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionOverwritten) {
if text := out.String(); !strings.Contains(text, pushActionPlannedSkip) || !strings.Contains(text, pushActionPlannedOverwrite) {
t.Fatalf("smart plan did not cover skip/overwrite: %s", text)
}
out.Reset()
if err := printDrivePushDryRun(ifExistsOverwrite, remoteFiles, remoteFolders, nil, files("overwrite.txt")); err != nil {
t.Fatalf("overwrite plan: %v", err)
}
if !strings.Contains(out.String(), pushActionOverwritten) {
if !strings.Contains(out.String(), pushActionPlannedOverwrite) {
t.Fatalf("overwrite plan must overwrite existing file: %s", out.String())
}
}
+6 -1
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -557,13 +558,17 @@ func newWikiCommand() *cobra.Command {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
role := normalizePermissionRole(mustGetFlag(cmd, "role"))
if role == "OWNER" {
return apperrors.NewValidation("OWNER 角色不可通过 wiki member add 添加")
}
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
return callMCPTool("add_member", map[string]any{
"workspaceId": workspaceID,
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
"roleId": role,
"userIds": userIds,
})
},
+25 -2
View File
@@ -1,11 +1,13 @@
package helpers
import (
stderrors "errors"
"io"
"os"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -106,10 +108,17 @@ func TestCrossPlatformCoverageProxySubCommandCoverage(t *testing.T) {
}
func executeWikiEdge(t *testing.T, args ...string) error {
t.Helper()
_, err := executeWikiEdgeWithCaller(t, args...)
return err
}
func executeWikiEdgeWithCaller(t *testing.T, args ...string) (*scriptedToolCaller, error) {
t.Helper()
oldDeps := deps
oldArgs := os.Args
InitDeps(&scriptedToolCaller{})
caller := &scriptedToolCaller{}
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
t.Cleanup(func() {
@@ -125,7 +134,7 @@ func executeWikiEdge(t *testing.T, args ...string) error {
root.SetIn(os.Stdin)
root.SetArgs(args)
os.Args = append([]string{"dws", "wiki"}, args...)
return root.Execute()
return caller, root.Execute()
}
func TestCrossPlatformCoverageWikiRoutingAndValidationEdges(t *testing.T) {
@@ -154,6 +163,20 @@ func TestCrossPlatformCoverageWikiRoutingAndValidationEdges(t *testing.T) {
}
}
func TestCrossPlatformCoverageWikiMemberAddRejectsOwner(t *testing.T) {
caller, err := executeWikiEdgeWithCaller(t, "member", "add", "--workspace", "space", "--users", "u1", "--role", "OWNER")
if err == nil || !strings.Contains(strings.ToUpper(err.Error()), "OWNER") {
t.Fatalf("member add OWNER error = %v, want local OWNER rejection", err)
}
var appErr *apperrors.Error
if !stderrors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
t.Fatalf("member add OWNER error = %#v, want validation category", err)
}
if caller.calls != 0 {
t.Fatalf("member add OWNER made %d remote calls, want 0", caller.calls)
}
}
func TestCrossPlatformCoverageWikiDeleteCancellationEdges(t *testing.T) {
oldStdin := os.Stdin
t.Cleanup(func() { os.Stdin = oldStdin })
+5 -13
View File
@@ -403,11 +403,7 @@ var BaseCopy = shortcut.Shortcut{
},
Tips: []string{`dws aitable +base-copy --base-id BASE_ID --target-folder-id FOLDER_ID`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("copy_base", map[string]any{
"baseId": rt.Str("base-id"),
"targetFolderId": rt.Str("target-folder-id"),
"onlyCopyMeta": rt.Bool("only-struct"),
})
return executeBaseCopy(rt)
},
}
@@ -660,7 +656,7 @@ var RecordQuery = shortcut.Shortcut{
Command: "+record-query",
Product: serverMain,
Description: "查询表格记录(按 ID / 条件 / 关键词,并支持字段投影和分页)",
Intent: "读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗。",
Intent: "读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗;服务端未返回的计算字段不会由 CLI 本地补算。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -682,7 +678,7 @@ var RecordQuery = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "查询表格记录(按 ID / 条件 / 关键词,并支持字段投影和分页)",
UseWhen: []string{"读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗。"},
UseWhen: []string{"读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗;服务端未返回的计算字段不会由 CLI 本地补算。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{
"dws aitable +record-query --base-id B --table-id T --query \"关键词\" --limit 50",
@@ -975,11 +971,7 @@ var RecordPrimaryDocGet = shortcut.Shortcut{
},
Tips: []string{`dws aitable +record-primary-doc-get --base-id B --table-id T --record-id R`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("get_primary_doc", map[string]any{
"baseId": rt.Str("base-id"),
"tableId": rt.Str("table-id"),
"recordId": rt.Str("record-id"),
})
return executeRecordPrimaryDocGet(rt)
},
}
@@ -2078,7 +2070,7 @@ func workflowListProject(data map[string]any) ([]map[string]any, error) {
return nil, fmt.Errorf("list_workflows response item %d must be an object, got %T", index, item)
}
row := map[string]any{}
if v, ok := workflowListFirst(m, "workflowId", "workflow_id", "id"); ok {
if v, ok := workflowListFirst(m, "workflowId", "flowId", "workflow_id", "id"); ok {
row["workflowId"] = v
}
if v, ok := workflowListFirst(m, "name", "workflowName", "title"); ok {
@@ -79,6 +79,154 @@ func TestCrossPlatformCoverageBaseSchemaSnapshotE2E(t *testing.T) {
})
}
func TestCrossPlatformCoverageBaseCopyRequiresNewIDAndExactReadBackE2E(t *testing.T) {
t.Run("verified copy", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"data":{"fileId":"folder","type":"FOLDER"}}`},
{text: `{"data":{"newBaseId":"new-base"}}`},
{text: `{"data":{"baseId":"new-base","tables":[]}}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
if err != nil || !strings.Contains(out, `"newBaseId": "new-base"`) || len(caller.calls) != 3 || caller.calls[0].product != "drive" || caller.calls[0].tool != "get_file_info" || caller.calls[2].tool != "get_base" {
t.Fatalf("base copy = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("missing new ID is unknown without read-back", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"data":{"fileId":"folder","type":"folder"}}`},
{text: `{"success":true}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
var typed *apperrors.Error
if err == nil || out != "" || len(caller.calls) != 2 || !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" || typed.Retryable {
t.Fatalf("missing newBaseId = output:%q err:%#v calls:%#v", out, err, caller.calls)
}
})
t.Run("explicit downstream target rejection is stable and not retryable", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"data":{"fileId":"folder","type":"folder"}}`},
{err: errors.New("Invalid target folder ID")},
}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
var typed *apperrors.Error
if err == nil || out != "" || len(caller.calls) != 2 || !errors.As(err, &typed) || typed.Reason != "target_not_supported" || typed.Retryable {
t.Fatalf("rejected target = output:%q err:%#v calls:%#v", out, err, caller.calls)
}
})
t.Run("rejects a non-folder target before write", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"data":{"fileId":"file-1","type":"FILE"}}`}}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "file-1", "--yes")
var typed *apperrors.Error
if err == nil || out != "" || len(caller.calls) != 1 || !errors.As(err, &typed) || typed.Reason != "target_wrong_type" || typed.Retryable || typed.ExecutionStarted == nil || *typed.ExecutionStarted {
t.Fatalf("wrong target type = output:%q err:%#v calls:%#v", out, err, caller.calls)
}
})
t.Run("rejects target metadata without a supported type", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"data":{"fileId":"folder"}}`}}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
var typed *apperrors.Error
if err == nil || out != "" || len(caller.calls) != 1 || !errors.As(err, &typed) || typed.Reason != "target_not_supported" || typed.Retryable {
t.Fatalf("unsupported target metadata = output:%q err:%#v calls:%#v", out, err, caller.calls)
}
})
t.Run("rejects URL target before transport", func(t *testing.T) {
caller := &upsertByKeyCaller{}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "https://example.test/folder", "--yes")
if err == nil || out != "" || len(caller.calls) != 0 {
t.Fatalf("URL target = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("rejects pure numeric dentryId before transport", func(t *testing.T) {
caller := &upsertByKeyCaller{}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "123456789", "--yes")
if err == nil || out != "" || len(caller.calls) != 0 {
t.Fatalf("numeric target = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("nodeId-only metadata cannot prove dentryUuid", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"data":{"nodeId":"folder","type":"FOLDER"}}`}}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
var typed *apperrors.Error
if err == nil || out != "" || len(caller.calls) != 1 || !errors.As(err, &typed) || typed.Reason != "target_not_supported" || typed.Retryable {
t.Fatalf("nodeId-only metadata = output:%q err:%#v calls:%#v", out, err, caller.calls)
}
})
}
func TestCrossPlatformCoverageBaseCopyFailureAndDryRunEdgesE2E(t *testing.T) {
t.Run("invalid source", func(t *testing.T) {
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+base-copy", "--base-id", "bad/id", "--target-folder-id", "folder", "--yes")
if err == nil || out != "" {
t.Fatalf("invalid source = output:%q err:%v", out, err)
}
})
t.Run("dry run", func(t *testing.T) {
caller := &upsertByKeyCaller{dryRun: true}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--dry-run")
if err != nil || len(caller.calls) != 0 || !strings.Contains(out, `"status": "planned"`) {
t.Fatalf("dry run = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("target lookup error", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{err: context.Canceled}}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || out != "" || len(caller.calls) != 1 {
t.Fatalf("target error = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("generic copy error is unknown", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"data":{"fileId":"folder","type":"folder"}}`},
{err: errors.New("transport failed")},
}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
if err == nil || out != "" || len(caller.calls) != 2 {
t.Fatalf("copy error = output:%q err:%v", out, err)
}
})
readBackCases := []struct {
name string
step upsertByKeyStep
}{
{name: "transport", step: upsertByKeyStep{err: context.DeadlineExceeded}},
{name: "missing id", step: upsertByKeyStep{text: `{}`}},
{name: "wrong id", step: upsertByKeyStep{text: `{"baseId":"other"}`}},
}
for _, tc := range readBackCases {
t.Run("readback "+tc.name, func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"data":{"fileId":"folder","type":"folder"}}`},
{text: `{"newBaseId":"new-base"}`},
tc.step,
}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
if err == nil || out != "" || len(caller.calls) != 3 {
t.Fatalf("readback %s = output:%q err:%v", tc.name, out, err)
}
})
}
for _, value := range []string{"", "white space", "control\x00"} {
if validCompositeOpaqueID(value) {
t.Errorf("validCompositeOpaqueID(%q) = true", value)
}
}
if pureNumericID("") || pureNumericID("123a") || !pureNumericID("123") {
t.Fatal("pureNumericID edge mismatch")
}
}
func bootstrapFields(count int) []any {
fields := make([]any, 0, count)
for index := 0; index < count; index++ {
+136
View File
@@ -0,0 +1,136 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"fmt"
"strings"
"unicode"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func executeBaseCopy(rt *shortcut.RuntimeContext) error {
baseID := strings.TrimSpace(rt.Str("base-id"))
targetFolderID := strings.TrimSpace(rt.Str("target-folder-id"))
if !validCompositeOpaqueID(baseID) {
return apperrors.NewValidation("--base-id 不是合法的 Base ID")
}
if !validCompositeOpaqueID(targetFolderID) || pureNumericID(targetFolderID) {
return apperrors.NewValidation("--target-folder-id 必须是文件夹 dentryUuid,不能是 URL、路径或空值")
}
params := map[string]any{
"baseId": baseID,
"targetFolderId": targetFolderID,
"onlyCopyMeta": rt.Bool("only-struct"),
}
result := newCompositeResult("base_copy")
result.Resolved = map[string]any{"sourceBaseId": baseID, "targetFolderId": targetFolderID}
result.Plan = []compositeStep{
{Index: 1, Name: "verify target folder", Tool: "drive/get_file_info", Status: "planned", Arguments: map[string]any{"fileId": targetFolderID}},
{Index: 2, Name: "copy base", Tool: "copy_base", Status: "planned", Arguments: params},
}
if rt.DryRun() {
result.Status = "planned"
result.Executed = false
return rt.Output(result)
}
targetData, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": targetFolderID})
if err != nil {
return err
}
actualTargetID := findStringByKeys(targetData, "fileId", "dentryUuid")
if actualTargetID == "" || actualTargetID != targetFolderID {
return apperrors.NewAPI("drive/get_file_info did not prove the exact target dentryUuid",
apperrors.WithOperation("drive/get_file_info"), apperrors.WithReason("target_not_supported"),
apperrors.WithFailureStage("target_resolution"), apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false))
}
targetType := findStringByKeys(targetData, "type", "dentryType", "fileType")
if targetType == "" {
return apperrors.NewAPI("drive/get_file_info response is missing the target node type",
apperrors.WithOperation("drive/get_file_info"), apperrors.WithReason("target_not_supported"),
apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false))
}
if !strings.EqualFold(targetType, "folder") {
return apperrors.NewAPI(fmt.Sprintf("target %s is %s, not a folder", targetFolderID, targetType),
apperrors.WithOperation("drive/get_file_info"), apperrors.WithReason("target_wrong_type"),
apperrors.WithFailureStage("target_resolution"), apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false))
}
result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 1, Name: "verify target folder", Tool: "drive/get_file_info", Status: "completed", Result: map[string]any{"fileId": actualTargetID, "type": targetType}})
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, "copy_base", params)
newBaseID := findStringByKeys(writeData, "newBaseId")
if newBaseID == "" && copyBaseRejectedTarget(writeErr) {
return apperrors.NewAPI("copy_base rejected a target that Drive proved is the exact folder dentryUuid",
apperrors.WithOperation("aitable/copy_base"), apperrors.WithOrigin("mcp"),
apperrors.WithReason("target_not_supported"), apperrors.WithFailureStage("copy_base"),
apperrors.WithExecutionStarted(true), apperrors.WithRetryable(false), apperrors.WithCause(writeErr),
apperrors.WithHint("the current copy_base service does not accept this Drive folder contract; retrying or substituting spaceId/nodeId/dentryId is unsafe"))
}
if newBaseID == "" || !validCompositeOpaqueID(newBaseID) {
cause := fmt.Errorf("copy_base response is missing a valid newBaseId")
if writeErr != nil {
cause = fmt.Errorf("copy_base response error: %w; newBaseId is unavailable", writeErr)
}
result.Status = "unknown"
return compositeError(result, cause, false)
}
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "copy_base", "newBaseId": newBaseID})
readBack, verifyErr := rt.CallMCPData(serverMain, "get_base", map[string]any{"baseId": newBaseID})
if verifyErr == nil {
actualID := findStringByKeys(readBack, "baseId")
if actualID == "" {
verifyErr = fmt.Errorf("get_base read-back is missing baseId")
} else if actualID != newBaseID {
verifyErr = fmt.Errorf("get_base read-back identity mismatch: got %q, want %q", actualID, newBaseID)
}
}
if verifyErr != nil {
result.Status = "partial_success"
result.Verification = map[string]any{"status": "failed", "newBaseId": newBaseID, "error": verifyErr.Error()}
return compositeError(result, verifyErr, false)
}
result.CompletedCount = 2
result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 2, Name: "copy base", Tool: "copy_base", Status: "completed", Result: map[string]any{"newBaseId": newBaseID}})
result.Verification = map[string]any{"status": "verified", "newBaseId": newBaseID}
result.Result = map[string]any{"newBaseId": newBaseID, "base": readBack}
return rt.Output(result)
}
func copyBaseRejectedTarget(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "invalid target folder") ||
strings.Contains(message, "invalid targetfolder")
}
func validCompositeOpaqueID(value string) bool {
if value == "" || len(value) > 512 || strings.ContainsAny(value, "/?#") {
return false
}
for _, r := range value {
if unicode.IsSpace(r) || unicode.IsControl(r) {
return false
}
}
return true
}
func pureNumericID(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return true
}
+142
View File
@@ -0,0 +1,142 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"fmt"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func executeRecordPrimaryDocGet(rt *shortcut.RuntimeContext) error {
baseID, tableID, requestedRecordID := rt.Str("base-id"), rt.Str("table-id"), rt.Str("record-id")
fieldsData, err := rt.CallMCPData(serverMain, "get_fields", map[string]any{"baseId": baseID, "tableId": tableID})
if err != nil {
return err
}
fields, found := findNamedObjectList(fieldsData, "fields", "fieldList")
if !found {
return apperrors.NewAPI("get_fields response is missing the fields collection",
apperrors.WithOperation("aitable/get_fields"), apperrors.WithReason("target_invalid_response"),
apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false))
}
primaryFieldIDs := make([]string, 0, 1)
for index, field := range fields {
fieldType := stringValue(field, "type", "fieldType")
if !strings.EqualFold(fieldType, "primaryDoc") {
continue
}
fieldID := stringValue(field, "fieldId", "id")
if fieldID == "" {
return apperrors.NewAPI(fmt.Sprintf("primaryDoc field %d is missing fieldId", index),
apperrors.WithOperation("aitable/get_fields"), apperrors.WithReason("target_invalid_response"),
apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false))
}
primaryFieldIDs = append(primaryFieldIDs, fieldID)
}
recordParams := map[string]any{
"baseId": baseID, "tableId": tableID, "recordIds": []string{requestedRecordID},
}
if len(primaryFieldIDs) > 0 {
recordParams["fieldIds"] = primaryFieldIDs
}
window, err := queryRecordWindow(rt, recordParams, 1)
if err != nil {
return err
}
records := window.Records
if len(records) == 0 {
return apperrors.NewAPI(fmt.Sprintf("record %s was not found", requestedRecordID),
apperrors.WithOperation("aitable/query_records"), apperrors.WithReason("RESOURCE_NOT_FOUND"),
apperrors.WithFailureStage("target_resolution"), apperrors.WithExecutionStarted(false))
}
if len(records) != 1 || recordID(records[0]) != requestedRecordID {
return apperrors.NewAPI("record preflight did not return the exact requested record",
apperrors.WithOperation("aitable/query_records"), apperrors.WithReason("target_invalid_response"),
apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false))
}
if len(primaryFieldIDs) == 0 {
return rt.Output(map[string]any{
"status": "no_primary_doc_field", "exists": false, "recordId": requestedRecordID, "nodeId": nil,
})
}
data, err := rt.CallMCPData(serverHelper, "get_primary_doc", map[string]any{
"baseId": baseID, "tableId": tableID, "recordId": requestedRecordID,
})
if err != nil {
if knownPrimaryDocUnassociatedError(err) {
return rt.Output(map[string]any{
"status": "unassociated", "exists": false, "recordId": requestedRecordID, "fieldIds": primaryFieldIDs, "nodeId": nil,
})
}
return err
}
nodeID := primaryDocNodeID(data)
if nodeID == "" {
if knownPrimaryDocUnassociatedData(data) {
return rt.Output(map[string]any{
"status": "unassociated", "exists": false, "recordId": requestedRecordID, "fieldIds": primaryFieldIDs, "nodeId": nil,
})
}
return apperrors.NewAPI("get_primary_doc response is missing nodeId",
apperrors.WithOperation("aitable-helper/get_primary_doc"), apperrors.WithReason("target_invalid_response"),
apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false))
}
return rt.Output(map[string]any{
"status": "associated", "exists": true, "recordId": requestedRecordID, "fieldIds": primaryFieldIDs, "nodeId": nodeID,
})
}
func knownPrimaryDocUnassociatedError(err error) bool {
message := strings.ToLower(strings.TrimSpace(err.Error()))
return strings.Contains(message, "no record") || strings.Contains(message, "unassociated")
}
func primaryDocNodeID(value any) string {
object, ok := value.(map[string]any)
if !ok {
return ""
}
for _, key := range []string{"nodeId", "dentryUuid"} {
if nodeID, ok := object[key].(string); ok && strings.TrimSpace(nodeID) != "" {
return strings.TrimSpace(nodeID)
}
}
for _, envelopeKey := range []string{"data", "result", "response"} {
if nodeID := primaryDocNodeID(object[envelopeKey]); nodeID != "" {
return nodeID
}
}
return ""
}
func knownPrimaryDocUnassociatedData(value any) bool {
object, ok := value.(map[string]any)
if !ok {
return false
}
if exists, ok := object["exists"].(bool); ok && !exists {
return true
}
for _, key := range []string{"nodeId", "dentryUuid"} {
if nodeID, exists := object[key]; exists && (nodeID == nil || strings.TrimSpace(fmt.Sprint(nodeID)) == "") {
return true
}
}
if status, ok := object["status"].(string); ok {
status = strings.ToLower(strings.TrimSpace(status))
if status == "unassociated" || status == "no_record" {
return true
}
}
for _, envelopeKey := range []string{"data", "result", "response"} {
if child, ok := object[envelopeKey].(map[string]any); ok && knownPrimaryDocUnassociatedData(child) {
return true
}
}
return false
}
+78 -6
View File
@@ -84,9 +84,43 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
return rt.Output(result)
}
for offset := 0; offset < len(ids); offset += recordBatchSize {
end := minInt(offset+recordBatchSize, len(ids))
batch := ids[offset:end]
existingIDs, err := queryExistingRecordIDs(rt, baseID, tableID, ids)
if err != nil {
return err
}
existingSet := make(map[string]bool, len(existingIDs))
for _, id := range existingIDs {
existingSet[id] = true
}
missingIDs := make([]string, 0, len(ids)-len(existingIDs))
for _, id := range ids {
if !existingSet[id] {
missingIDs = append(missingIDs, id)
}
}
result.Resolved = map[string]any{
"existingRecordIds": existingIDs,
"missingRecordIds": missingIDs,
}
result.Plan = nil
for offset := 0; offset < len(existingIDs); offset += recordBatchSize {
end := minInt(offset+recordBatchSize, len(existingIDs))
result.Plan = append(result.Plan, compositeStep{
Index: len(result.Plan) + 1, Name: "delete existing record batch", Tool: "delete_records",
Status: "planned", Offset: offset, Count: end - offset,
})
}
if len(existingIDs) == 0 {
result.Status = "unchanged"
result.Executed = false
result.Verification = map[string]any{"status": "verified_absent_before_write", "missingCount": len(missingIDs)}
result.Result = map[string]any{"deletedCount": 0, "missingCount": len(missingIDs), "batchCount": 0}
return rt.Output(result)
}
for offset := 0; offset < len(existingIDs); offset += recordBatchSize {
end := minInt(offset+recordBatchSize, len(existingIDs))
batch := existingIDs[offset:end]
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, "delete_records", map[string]any{
"baseId": baseID, "tableId": tableID, "recordIds": batch,
})
@@ -109,7 +143,7 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
step.Status = "unknown"
result.CompletedSteps = append(result.CompletedSteps, step)
result.CompletedCount = offset
result.FailedCount = len(ids) - offset
result.FailedCount = len(existingIDs) - offset
result.Status = "unknown"
if offset > 0 {
result.Status = "partial_success"
@@ -130,11 +164,49 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
result.CompletedCount = end
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "delete_records", "offset": offset, "recordIds": batch})
}
result.Verification = map[string]any{"status": "verified_absent", "verifiedCount": len(ids)}
result.Result = map[string]any{"deletedCount": len(ids), "batchCount": len(result.CompletedSteps)}
result.Verification = map[string]any{
"status": "verified_absent", "verifiedCount": len(existingIDs),
"missingBeforeWriteCount": len(missingIDs),
}
result.Result = map[string]any{
"deletedCount": len(existingIDs), "missingCount": len(missingIDs),
"batchCount": len(result.CompletedSteps),
}
return rt.Output(result)
}
func queryExistingRecordIDs(rt *shortcut.RuntimeContext, baseID, tableID string, ids []string) ([]string, error) {
records, err := queryDeletedRecordsByIDs(rt, baseID, tableID, ids)
if err != nil {
return nil, err
}
wanted := make(map[string]bool, len(ids))
for _, id := range ids {
wanted[id] = true
}
present := make(map[string]bool, len(records))
for index, record := range records {
id := recordID(record)
if id == "" {
return nil, fmt.Errorf("delete preflight record %d is missing recordId", index)
}
if !wanted[id] {
return nil, fmt.Errorf("delete preflight returned unexpected recordId %q", id)
}
if present[id] {
return nil, fmt.Errorf("delete preflight returned duplicate recordId %q", id)
}
present[id] = true
}
existing := make([]string, 0, len(present))
for _, id := range ids {
if present[id] {
existing = append(existing, id)
}
}
return existing, nil
}
func parseRecordIDs(values []string) ([]string, error) {
seen := map[string]bool{}
ids := make([]string, 0, len(values))
@@ -4,11 +4,14 @@
package aitable
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func TestCrossPlatformCoverageRecordObjectAndIDValidation(t *testing.T) {
@@ -90,6 +93,137 @@ func TestCrossPlatformCoverageRecordDeleteDryRunAndDualFailureE2E(t *testing.T)
}
}
func TestCrossPlatformCoverageRecordPrimaryDocPreflightAndNormalizationE2E(t *testing.T) {
t.Run("record not found is classified", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
{text: `{"records":[]}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "missing")
var typed *apperrors.Error
if err == nil || out != "" || !errors.As(err, &typed) || typed.Reason != "RESOURCE_NOT_FOUND" {
t.Fatalf("missing primary-doc record = output:%q err:%#v", out, err)
}
})
t.Run("table without primary doc field is normalized", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"fields":[{"fieldId":"text","type":"text"}]}`},
{text: `{"records":[{"recordId":"r1","cells":{"text":"x"}}]}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
if err != nil || !strings.Contains(out, `"status": "no_primary_doc_field"`) || !strings.Contains(out, `"exists": false`) || len(caller.calls) != 2 {
t.Fatalf("no primary field = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("empty primary doc cell is unassociated", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
{text: `{"records":[{"recordId":"r1","cells":{}}]}`},
{text: `{"data":{"nodeId":null}}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
if err != nil || !strings.Contains(out, `"status": "unassociated"`) || !strings.Contains(out, `"exists": false`) || len(caller.calls) != 3 || caller.calls[2].tool != "get_primary_doc" {
t.Fatalf("unassociated primary doc = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("known helper no-record error is unassociated", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
{text: `{"records":[{"recordId":"r1","cells":{}}]}`},
{err: errors.New("no record")},
}}
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
if err != nil || !strings.Contains(out, `"status": "unassociated"`) || !strings.Contains(out, `"exists": false`) || len(caller.calls) != 3 {
t.Fatalf("no-record primary doc = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("associated doc is resolved through helper", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
{text: `{"records":[{"recordId":"r1","cells":{"primary":{"associated":true}}}]}`},
{text: `{"data":{"nodeId":"node-1"}}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
if err != nil || !strings.Contains(out, `"nodeId": "node-1"`) || !strings.Contains(out, `"exists": true`) || len(caller.calls) != 3 || caller.calls[2].tool != "get_primary_doc" {
t.Fatalf("associated primary doc = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
}
func TestCrossPlatformCoverageRecordPrimaryDocFailureAndShapeEdgesE2E(t *testing.T) {
cases := []struct {
name string
steps []upsertByKeyStep
}{
{name: "fields transport", steps: []upsertByKeyStep{{err: context.Canceled}}},
{name: "missing fields collection", steps: []upsertByKeyStep{{text: `{}`}}},
{name: "primary field missing id", steps: []upsertByKeyStep{{text: `{"fields":[{"type":"primaryDoc"}]}`}}},
{name: "record query error", steps: []upsertByKeyStep{{text: `{"fields":[]}`}, {err: context.DeadlineExceeded}}},
{name: "wrong record identity", steps: []upsertByKeyStep{{text: `{"fields":[]}`}, {text: `{"records":[{"recordId":"other"}]}`}}},
{name: "helper unknown error", steps: []upsertByKeyStep{{text: `{"fields":[{"fieldId":"p","type":"primaryDoc"}]}`}, {text: `{"records":[{"recordId":"r1"}]}`}, {err: errors.New("permission denied")}}},
{name: "helper missing node", steps: []upsertByKeyStep{{text: `{"fields":[{"fieldId":"p","type":"primaryDoc"}]}`}, {text: `{"records":[{"recordId":"r1"}]}`}, {text: `{"data":{"message":"ok"}}`}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
if err == nil || out != "" {
t.Fatalf("primary doc failure = output:%q err:%v", out, err)
}
})
}
for _, value := range []any{
map[string]any{"exists": false},
map[string]any{"dentryUuid": ""},
map[string]any{"status": "NO_RECORD"},
map[string]any{"data": map[string]any{"result": map[string]any{"status": "unassociated"}}},
} {
if !knownPrimaryDocUnassociatedData(value) {
t.Errorf("known unassociated shape not recognized: %#v", value)
}
}
if knownPrimaryDocUnassociatedData([]any{"unrelated"}) || knownPrimaryDocUnassociatedData(nil) {
t.Fatal("unrelated shapes must not be unassociated")
}
for _, value := range []any{
map[string]any{"metadata": map[string]any{"exists": false}},
map[string]any{"items": []any{map[string]any{"nodeId": nil}}},
} {
if knownPrimaryDocUnassociatedData(value) {
t.Errorf("unrelated nested marker must not be unassociated: %#v", value)
}
}
if got := primaryDocNodeID(map[string]any{"metadata": map[string]any{"nodeId": "wrong"}}); got != "" {
t.Fatalf("unrelated nested nodeId = %q, want empty", got)
}
if got := primaryDocNodeID(map[string]any{"data": map[string]any{"response": map[string]any{"dentryUuid": " node-1 "}}}); got != "node-1" {
t.Fatalf("known envelope nodeId = %q, want node-1", got)
}
}
func TestCrossPlatformCoverageRecordDeleteRejectsMalformedPreflightRecordsE2E(t *testing.T) {
for _, tc := range []struct {
name string
records string
}{
{name: "missing id", records: `[{"cells":{}}]`},
{name: "unexpected id", records: `[{"recordId":"other"}]`},
{name: "duplicate id", records: `[{"recordId":"r1"},{"recordId":"r1"}]`},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":` + tc.records + `}`}}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err == nil || out != "" || len(caller.calls) != 1 {
t.Fatalf("delete malformed preflight = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
}
}
func TestCrossPlatformCoverageRecordBatchVerificationEdgesE2E(t *testing.T) {
record := []map[string]any{{"recordId": "r1", "cells": map[string]any{"f": "new"}}}
@@ -612,11 +612,18 @@ func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testi
})
t.Run("delete readback follows active continuation to absence", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, args map[string]any) (string, error) {
written := false
verificationPage := 0
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, args map[string]any) (string, error) {
if tool != "query_records" {
written = true
return `{"deletedCount":1}`, nil
}
if call == 1 {
if !written {
return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil
}
verificationPage++
if verificationPage == 1 {
if _, exists := args["cursor"]; exists {
t.Fatalf("first deletion readback unexpectedly has cursor: %#v", args)
}
@@ -628,23 +635,30 @@ func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testi
return `{"success":true,"data":{"records":[],"hasMore":false}}`, nil
}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err != nil || out == "" || len(caller.calls) != 3 {
if err != nil || out == "" || len(caller.calls) != 4 {
t.Fatalf("delete continued absence output=%q error=%v calls=%#v", out, err, caller.calls)
}
})
t.Run("delete readback follows active continuation to remaining record", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) {
written := false
verificationPage := 0
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, _ map[string]any) (string, error) {
if tool != "query_records" {
written = true
return `{"deletedCount":1}`, nil
}
if call == 1 {
if !written {
return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil
}
verificationPage++
if verificationPage == 1 {
return `{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c1"}}`, nil
}
return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil
}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err == nil || out != "" || len(caller.calls) != 3 {
if err == nil || out != "" || len(caller.calls) != 4 {
t.Fatalf("delete continued remaining output=%q error=%v calls=%#v", out, err, caller.calls)
}
var typed *apperrors.Error
@@ -654,14 +668,21 @@ func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testi
})
t.Run("delete readback fails closed on empty continuation stall", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) {
written := false
verificationPage := 0
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, _ map[string]any) (string, error) {
if tool != "query_records" {
written = true
return `{"deletedCount":1}`, nil
}
return fmt.Sprintf(`{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c%d"}}`, call), nil
if !written {
return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil
}
verificationPage++
return fmt.Sprintf(`{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c%d"}}`, verificationPage), nil
}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err == nil || out != "" || len(caller.calls) != recordQueryMaxConsecutiveEmptyPages+1 {
if err == nil || out != "" || len(caller.calls) != recordQueryMaxConsecutiveEmptyPages+2 {
t.Fatalf("delete stalled continuation output=%q error=%v calls=%#v", out, err, caller.calls)
}
var typed *apperrors.Error
@@ -678,21 +699,26 @@ func TestCrossPlatformCoverageRecordWriteReadbackUsesStableServicePagesE2E(t *te
records := updateFixtureRecords(0, size, "完成")
caller := &upsertByKeyCaller{}
queryCalls := 0
writeSeen := false
caller.callFn = func(_ int, _, tool string, args map[string]any) (string, error) {
if tool != "query_records" {
writeSeen = true
return `{"success":true}`, nil
}
queryCalls++
if operation == "delete" {
ids := args["recordIds"].([]string)
empty := make([]map[string]any, len(ids))
response := pagedRecordQueryResponse(t, empty, args)
requested := make([]map[string]any, 0, len(ids))
if !writeSeen {
for _, id := range ids {
requested = append(requested, map[string]any{"recordId": id})
}
}
response := pagedRecordQueryResponse(t, requested, args)
var payload map[string]any
if err := json.Unmarshal([]byte(response), &payload); err != nil {
t.Fatal(err)
}
page := payload["data"].(map[string]any)
page["records"] = []any{}
raw, _ := json.Marshal(payload)
return string(raw), nil
}
@@ -725,6 +751,9 @@ func TestCrossPlatformCoverageRecordWriteReadbackUsesStableServicePagesE2E(t *te
t.Fatalf("%s size %d false negative: output=%q err=%v", operation, size, out, err)
}
wantQueries := (size + recordQueryServicePageSize - 1) / recordQueryServicePageSize
if operation == "delete" {
wantQueries *= 2
}
if queryCalls != wantQueries {
t.Fatalf("%s size %d query calls=%d, want %d", operation, size, queryCalls, wantQueries)
}
@@ -860,15 +889,20 @@ func recordIDFixtures(count int) []string {
func TestCrossPlatformCoverageRecordDeleteAutoChunksAndProvesAbsenceE2E(t *testing.T) {
ids := recordIDFixtures(101)
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"deletedCount":100}`},
{text: `{"data":{"records":[]}}`},
{text: `{"data":{"records":[]}}`},
{text: `{"data":{"records":[]}}`},
{text: `{"data":{"records":[]}}`},
{text: `{"data":{"records":[]}}`},
{text: `{"deletedCount":1}`},
{text: `{"data":{"records":[]}}`},
writes := 0
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, args map[string]any) (string, error) {
if tool == "delete_records" {
writes++
return fmt.Sprintf(`{"deletedCount":%d}`, len(args["recordIds"].([]string))), nil
}
requested := args["recordIds"].([]string)
records := make([]map[string]any, 0, len(requested))
if writes == 0 {
for _, id := range requested {
records = append(records, map[string]any{"recordId": id})
}
}
return recordListJSON(t, records), nil
}}
out, err := runRecordDeleteCLI(t, caller, ids)
if err != nil {
@@ -879,14 +913,43 @@ func TestCrossPlatformCoverageRecordDeleteAutoChunksAndProvesAbsenceE2E(t *testi
t.Fatalf("delete output missing %s: %s", want, out)
}
}
if len(caller.calls) != 8 || caller.calls[0].tool != "delete_records" || caller.calls[1].tool != "query_records" || caller.calls[6].tool != "delete_records" {
if len(caller.calls) != 14 || caller.calls[0].tool != "query_records" || caller.calls[6].tool != "delete_records" || caller.calls[12].tool != "delete_records" {
t.Fatalf("delete call sequence = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageRecordDeleteDoesNotCountMissingIDsE2E(t *testing.T) {
t.Run("all requested IDs were already absent", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"success":true,"data":{"records":[],"hasMore":false}}`}}}
out, err := runRecordDeleteCLI(t, caller, []string{"missing-1", "missing-2"})
if err != nil || len(caller.calls) != 1 || caller.calls[0].tool != "query_records" ||
!strings.Contains(out, `"deletedCount": 0`) || !strings.Contains(out, `"missingCount": 2`) {
t.Fatalf("already absent delete = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("only records proven present are counted", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`},
{text: `{"deletedCount":1}`},
{text: `{"success":true,"data":{"records":[],"hasMore":false}}`},
}}
out, err := runRecordDeleteCLI(t, caller, []string{"missing", "r1"})
if err != nil || !strings.Contains(out, `"deletedCount": 1`) || !strings.Contains(out, `"missingCount": 1`) {
t.Fatalf("mixed delete = output:%q err:%v", out, err)
}
deleted := caller.calls[1].args["recordIds"].([]string)
if !slices.Equal(deleted, []string{"r1"}) {
t.Fatalf("delete target IDs = %#v", deleted)
}
})
}
func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t *testing.T) {
t.Run("legal empty collection proves deletion", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: ""}, {text: `{"records":[]}`}}}
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"records":[{"recordId":"r1"}]}`}, {text: ""}, {text: `{"records":[]}`},
}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err != nil || !strings.Contains(out, `"status": "recovered"`) {
t.Fatalf("delete recovered = output:%q err:%v", out, err)
@@ -895,6 +958,7 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t
t.Run("explicit service success with empty data proves deletion", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"records":[{"recordId":"r1"}]}`},
{text: `{"deletedCount":1}`},
{text: `{"success":true,"status":"success","error":{},"data":{}}`},
}}
@@ -905,7 +969,9 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t
})
t.Run("missing records contract is unknown", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"deletedCount":1}`}, {text: `{"data":{}}`}}}
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"records":[{"recordId":"r1"}]}`}, {text: `{"deletedCount":1}`}, {text: `{"data":{}}`},
}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err == nil || out != "" {
t.Fatalf("delete missing records = output:%q err:%v", out, err)
@@ -918,6 +984,7 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t
t.Run("remaining record is not success", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"records":[{"recordId":"r1"}]}`},
{text: `{"deletedCount":0}`},
{text: `{"records":[{"recordId":"r1","cells":{"fld":"still here"}}]}`},
}}
@@ -930,15 +997,22 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t
func TestCrossPlatformCoverageRecordDeletePartialCheckpointE2E(t *testing.T) {
ids := recordIDFixtures(101)
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"deletedCount":100}`},
{text: `{"records":[]}`},
{text: `{"records":[]}`},
{text: `{"records":[]}`},
{text: `{"records":[]}`},
{text: `{"records":[]}`},
{text: `{"deletedCount":0}`},
{text: `{"records":[{"recordId":"r100","cells":{}}]}`},
writes := 0
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, args map[string]any) (string, error) {
if tool == "delete_records" {
writes++
return `{"deletedCount":0}`, nil
}
requested := args["recordIds"].([]string)
records := make([]map[string]any, 0, len(requested))
if writes == 0 {
for _, id := range requested {
records = append(records, map[string]any{"recordId": id})
}
} else if writes == 2 {
records = append(records, map[string]any{"recordId": "r100", "cells": map[string]any{}})
}
return recordListJSON(t, records), nil
}}
out, err := runRecordDeleteCLI(t, caller, ids)
if err == nil || out != "" {
@@ -77,6 +77,10 @@ func TestCrossPlatformCoverageAITableProjectionValidAndMissingIdentityShapes(t *
if err != nil || len(workflows) != 1 {
t.Fatalf("workflow projection = %#v, %v", workflows, err)
}
workflows, err = workflowListProject(map[string]any{"list": []any{map[string]any{"flowId": "flow", "name": "real service shape"}}})
if err != nil || len(workflows) != 1 || workflows[0]["workflowId"] != "flow" {
t.Fatalf("workflow flowId projection = %#v, %v", workflows, err)
}
}
func TestCrossPlatformCoverageCompositeAndReviewedContractFallbacks(t *testing.T) {
@@ -183,6 +187,7 @@ func TestCrossPlatformCoverageViewPresetValidationAndDryRunE2E(t *testing.T) {
}
func TestCrossPlatformCoverageViewPresetReadBackFailuresAndUpdateE2E(t *testing.T) {
disableViewPresetSleep(t)
preflight := `{"views":[{"viewId":"v","viewName":"X","viewType":"Grid","config":{"f":0}}]}`
cases := []struct {
name string
@@ -237,6 +242,40 @@ func TestCrossPlatformCoverageViewPresetPureMatching(t *testing.T) {
}
}
func TestCrossPlatformCoverageViewPresetProjectionShapeEdges(t *testing.T) {
if !presetViewMatches(map[string]any{"viewType": "Grid", "f": 1}, "Grid", map[string]any{"f": 1}) {
t.Fatal("top-level projected config must match")
}
invalid := []map[string]any{
{"columns": "bad", "custom": map[string]any{}},
{"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": map[string]any{"f": "bad"}}},
{"columns": []any{1}, "custom": map[string]any{"hiddenFields": map[string]any{"1": false}}},
{"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": map[string]any{}}},
{"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": []any{}}},
{"columns": []any{1}, "custom": map[string]any{"hiddenFields": []any{false}}},
{"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": []any{"bad"}}},
}
for _, view := range invalid {
if got, ok := projectedVisibleFieldIDs(view); ok || got != nil {
t.Errorf("invalid projection %#v = %#v, %v", view, got, ok)
}
}
}
func TestCrossPlatformCoverageViewPresetReadBackDuplicateStopsE2E(t *testing.T) {
disableViewPresetSleep(t)
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"views":[]}`},
{text: `{"viewId":"v1"}`},
{text: `{"views":[{"viewId":"v1","viewName":"X"},{"viewId":"v2","viewName":"X"}]}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
"--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"f":1}`, "--yes")
if err == nil || out != "" || len(caller.calls) != 3 {
t.Fatalf("duplicate readback = output:%q err:%v calls:%#v", out, err, caller.calls)
}
}
func TestCrossPlatformCoverageWorkflowDeployValidationAndFailureStagesE2E(t *testing.T) {
cases := []struct {
name string
+103 -19
View File
@@ -7,12 +7,17 @@ import (
"fmt"
"reflect"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const viewPresetReadbackAttempts = 8
var viewPresetSleep = time.Sleep
var ViewPresetApply = shortcut.Shortcut{
Service: "aitable",
Command: "+view-preset-apply",
@@ -100,23 +105,39 @@ func executeViewPresetApply(rt *shortcut.RuntimeContext) error {
if action == "create" {
viewID = findStringByKeys(writeData, "viewId")
}
readBack, verifyErr := rt.CallMCPData(serverMain, "get_views", map[string]any{"baseId": baseID, "tableId": tableID})
verifiedViews, found := findNamedObjectList(readBack, "views", "viewList")
if verifyErr == nil && !found {
verifyErr = fmt.Errorf("get_views read-back is missing the views collection")
}
verifiedMatches := viewsByExactName(verifiedViews, name)
if verifyErr == nil && len(verifiedMatches) != 1 {
verifyErr = fmt.Errorf("view read-back matched %d exact-name views, want 1", len(verifiedMatches))
}
if verifyErr == nil {
actualID := stringValue(verifiedMatches[0], "viewId", "id")
if actualID == "" || (viewID != "" && actualID != viewID) {
verifyErr = fmt.Errorf("view read-back identity mismatch: got %q, response %q", actualID, viewID)
} else if !presetViewMatches(verifiedMatches[0], viewType, config) {
verifyErr = fmt.Errorf("view read-back does not contain the declared type/config")
} else {
viewID = actualID
var verifiedMatches []map[string]any
var verifyErr error
for attempt := 0; attempt < viewPresetReadbackAttempts; attempt++ {
if attempt > 0 {
backoff := time.Duration(1<<(attempt-1)) * time.Second
if backoff > 12*time.Second {
backoff = 12 * time.Second
}
viewPresetSleep(backoff)
}
readBack, readErr := rt.CallMCPData(serverMain, "get_views", map[string]any{"baseId": baseID, "tableId": tableID})
verifyErr = readErr
verifiedViews, found := findNamedObjectList(readBack, "views", "viewList")
if verifyErr == nil && !found {
verifyErr = fmt.Errorf("get_views read-back is missing the views collection")
}
verifiedMatches = viewsByExactName(verifiedViews, name)
if verifyErr == nil && len(verifiedMatches) != 1 {
verifyErr = fmt.Errorf("view read-back matched %d exact-name views, want 1", len(verifiedMatches))
if len(verifiedMatches) > 1 {
break
}
}
if verifyErr == nil {
actualID := stringValue(verifiedMatches[0], "viewId", "id")
if actualID == "" || (viewID != "" && actualID != viewID) {
verifyErr = fmt.Errorf("view read-back identity mismatch: got %q, response %q", actualID, viewID)
} else if !presetViewMatches(verifiedMatches[0], viewType, config) {
verifyErr = fmt.Errorf("view read-back does not contain the declared type/config")
} else {
viewID = actualID
break
}
}
}
if verifyErr != nil {
@@ -153,8 +174,71 @@ func presetViewMatches(view map[string]any, viewType string, config map[string]a
if actualType != "" && actualType != viewType {
return false
}
actualConfig, ok := view["config"].(map[string]any)
return ok && mapContains(actualConfig, config)
actualConfig := make(map[string]any, len(config))
if nested, ok := view["config"].(map[string]any); ok {
for key, value := range nested {
actualConfig[key] = value
}
}
for key := range config {
if value, ok := view[key]; ok {
actualConfig[key] = value
}
}
if _, wanted := config["visibleFieldIds"]; wanted {
if visible, ok := projectedVisibleFieldIDs(view); ok {
actualConfig["visibleFieldIds"] = visible
}
}
return mapContains(actualConfig, config)
}
// get_views projects visible fields as columns plus hiddenFields instead of
// echoing create_view's config.visibleFieldIds input. Different deployments
// return hiddenFields either as a fieldId-keyed object or a parallel array.
func projectedVisibleFieldIDs(view map[string]any) ([]any, bool) {
columns, columnsOK := view["columns"].([]any)
custom, customOK := view["custom"].(map[string]any)
if !columnsOK || !customOK {
return nil, false
}
if hidden, ok := custom["hiddenFields"].(map[string]any); ok {
visible := make([]any, 0, len(columns))
for _, column := range columns {
fieldID, fieldOK := column.(string)
if !fieldOK {
return nil, false
}
isHidden, exists := hidden[fieldID]
if !exists {
return nil, false
}
hiddenFlag, hiddenTypeOK := isHidden.(bool)
if !hiddenTypeOK {
return nil, false
}
if !hiddenFlag {
visible = append(visible, fieldID)
}
}
return visible, true
}
hidden, hiddenOK := custom["hiddenFields"].([]any)
if !hiddenOK || len(columns) != len(hidden) {
return nil, false
}
visible := make([]any, 0, len(columns))
for index, column := range columns {
fieldID, fieldOK := column.(string)
isHidden, hiddenTypeOK := hidden[index].(bool)
if !fieldOK || !hiddenTypeOK {
return nil, false
}
if !isHidden {
visible = append(visible, fieldID)
}
}
return visible, true
}
func mapContains(actual, expected map[string]any) bool {
@@ -7,13 +7,21 @@ import (
"errors"
"strings"
"testing"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
const workflowDSLFixture = `{"version":"workflow-dsl/v1","name":"提醒","nodes":[]}`
func disableViewPresetSleep(t *testing.T) {
t.Helper()
testseam.Swap(t, &viewPresetSleep, func(time.Duration) {})
}
func TestCrossPlatformCoverageViewPresetCreateUpdateAndVerificationE2E(t *testing.T) {
disableViewPresetSleep(t)
t.Run("create and verify exact config", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"views":[]}`},
@@ -30,6 +38,32 @@ func TestCrossPlatformCoverageViewPresetCreateUpdateAndVerificationE2E(t *testin
}
})
t.Run("projected columns and hidden flags verify visible field config", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"views":[]}`},
{text: `{"data":{"viewId":"v1"}}`},
{text: `{"views":[{"viewId":"v1","viewName":"投影","viewType":"Grid","columns":["f1","f2","f3"],"custom":{"hiddenFields":[false,true,false]}}]}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
"--base-id", "base", "--table-id", "table", "--name", "投影", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f1","f3"]}`, "--yes")
if err != nil || !strings.Contains(out, `"status": "verified"`) {
t.Fatalf("projected view preset = output:%q err:%v", out, err)
}
})
t.Run("projected columns and keyed hidden flags verify visible field config", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"views":[]}`},
{text: `{"data":{"viewId":"v1"}}`},
{text: `{"views":[{"viewId":"v1","viewName":"投影对象","viewType":"Grid","columns":["f1","f2","f3"],"custom":{"hiddenFields":{"f1":true,"f2":false,"f3":false}}}]}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
"--base-id", "base", "--table-id", "table", "--name", "投影对象", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f2","f3"]}`, "--yes")
if err != nil || !strings.Contains(out, `"status": "verified"`) {
t.Fatalf("keyed projected view preset = output:%q err:%v", out, err)
}
})
t.Run("unchanged preset makes no write", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"views":[{"viewId":"v1","viewName":"待处理","viewType":"Grid","config":{"visibleFieldIds":["f1"]}}]}`}}}
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
@@ -52,7 +86,26 @@ func TestCrossPlatformCoverageViewPresetCreateUpdateAndVerificationE2E(t *testin
})
}
func TestCrossPlatformCoverageViewPresetRetriesUntilTerminalStateE2E(t *testing.T) {
disableViewPresetSleep(t)
steps := []upsertByKeyStep{
{text: `{"views":[]}`},
{text: `{"data":{"viewId":"v1"}}`},
}
for attempt := 0; attempt < viewPresetReadbackAttempts-1; attempt++ {
steps = append(steps, upsertByKeyStep{text: `{"views":[]}`})
}
steps = append(steps, upsertByKeyStep{text: `{"views":[{"viewId":"v1","viewName":"X","viewType":"Grid","config":{"visibleFieldIds":["f1"]}}]}`})
caller := &upsertByKeyCaller{steps: steps}
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
"--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f1"]}`, "--yes")
if err != nil || !strings.Contains(out, `"status": "verified"`) || len(caller.calls) != viewPresetReadbackAttempts+2 {
t.Fatalf("eventual view preset = output:%q err:%v calls:%#v", out, err, caller.calls)
}
}
func TestCrossPlatformCoverageViewPresetAmbiguousOrMismatchedIsNotSuccessE2E(t *testing.T) {
disableViewPresetSleep(t)
t.Run("duplicate name stops before write", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"views":[{"viewId":"v1","viewName":"X"},{"viewId":"v2","viewName":"X"}]}`}}}
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
@@ -219,6 +219,9 @@ func ResolveBaseName(reader Reader, name string, allowFuzzy bool) (Resolution, e
all = append(all, Candidate{ID: id, Name: candidateName})
}
next, hasMore, hasMoreKnown := pagination(data)
if hasMoreKnown && !hasMore {
return selectCandidate("base", query, dedupe(all), allowFuzzy)
}
if next == "" {
if hasMoreKnown && hasMore {
return Resolution{}, incomplete("base", query, all, "search_bases 声明有后续页但没有 cursor")
@@ -125,6 +125,24 @@ func TestCrossPlatformCoverageResolveBaseNameExactPaginationE2E(t *testing.T) {
}
}
func TestCrossPlatformCoverageResolveBaseStopsOnAuthoritativeTerminalCursor(t *testing.T) {
reader := &resolverReader{steps: []resolverStep{
{data: map[string]any{"bases": []any{}, "hasMore": true, "nextCursor": "next"}},
{data: map[string]any{
"bases": []any{map[string]any{"baseId": "b1", "baseName": "项目管理"}},
"hasMore": false,
"nextCursor": "next",
}},
}}
got, err := ResolveBaseName(reader, "项目管理", false)
if err != nil || got.Selected.ID != "b1" {
t.Fatalf("terminal cursor resolution = %#v, %v", got, err)
}
if len(reader.calls) != 2 {
t.Fatalf("terminal hasMore=false made %d calls, want 2", len(reader.calls))
}
}
func TestCrossPlatformCoverageResolveNameFuzzyIsExplicitAndAmbiguityFails(t *testing.T) {
data := map[string]any{"bases": []any{
map[string]any{"baseId": "b1", "baseName": "项目管理归档"},
+7 -2
View File
@@ -162,8 +162,13 @@ var SearchUser = shortcut.Shortcut{
// the clean output projection applied to every list command.
// Field names are probed defensively across candidate keys.
func searchUserProject(data map[string]any) []map[string]any {
raw, ok := data["result"].([]any)
if !ok {
var raw []any
switch result := data["result"].(type) {
case []any:
raw = result
case map[string]any:
raw = []any{result}
default:
return []map[string]any{}
}
out := make([]map[string]any, 0, len(raw))
@@ -0,0 +1,45 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package contact
import "testing"
func TestCrossPlatformCoverageSearchMobileProjectsObjectEnvelope(t *testing.T) {
users := searchUserProject(map[string]any{
"success": true,
"result": map[string]any{
"name": "Alice",
"userId": "u1",
"flowerName": "A",
"openDingTalkId": "open-1",
"title": "Engineer",
"unrelatedPrivate": "drop-me",
},
})
if len(users) != 1 || users[0]["userId"] != "u1" || users[0]["name"] != "Alice" {
t.Fatalf("object envelope projection = %#v, want one user", users)
}
if _, exists := users[0]["unrelatedPrivate"]; exists {
t.Fatalf("projection leaked unrelated field: %#v", users[0])
}
}
func TestCrossPlatformCoverageSearchMobilePreservesEmptyObjectSemantics(t *testing.T) {
users := searchUserProject(map[string]any{"success": true, "result": map[string]any{}})
if len(users) != 0 {
t.Fatalf("empty result projected users: %#v", users)
}
}
func TestCrossPlatformCoverageSearchMobileProjectsArrayAndMissingResult(t *testing.T) {
users := searchUserProject(map[string]any{
"result": []any{map[string]any{"name": "Alice", "userId": "u1"}},
})
if len(users) != 1 || users[0]["userId"] != "u1" || users[0]["name"] != "Alice" {
t.Fatalf("array envelope projection = %#v, want one user", users)
}
if users := searchUserProject(map[string]any{"success": true}); len(users) != 0 {
t.Fatalf("missing result projected users: %#v", users)
}
}
+2 -1
View File
@@ -33,7 +33,8 @@ func RequireWriteAcknowledgement(operation string, data map[string]any) error {
// RequirePermissionMutationAcknowledgement validates the target-level payload
// returned by add/remove_member_permission. The backend's top-level
// success=true is not sufficient: historically it also accompanied a
// resultMap that merely echoed nonexistent task UUIDs.
// resultMap that merely echoed nonexistent task UUIDs, and it does not prove
// that every requested task/member pair was changed.
func RequirePermissionMutationAcknowledgement(operation string, taskUUIDs, memberUIDs []string, data map[string]any) error {
if err := validateEnvelope(data); err != nil {
return err
@@ -26,6 +26,26 @@ func TestCrossPlatformCoverageMinutesWorkflowShapesRejectUnknownE2E(t *testing.T
}
}
func TestCrossPlatformCoverageMinutesPermissionMutationRequiresExactResultMap(t *testing.T) {
for _, data := range []map[string]any{
{"success": true, "result": map[string]any{}},
{"success": true, "result": map[string]any{"resultMap": map[string]any{}}},
{"success": true, "result": map[string]any{"resultMap": map[string]any{"u1": []any{}}}},
{"success": true, "result": map[string]any{"resultMap": map[string]any{"other": []any{"m1"}}}},
} {
if err := RequirePermissionMutationAcknowledgement("unshare", []string{"u1"}, []string{"m1"}, data); err == nil {
t.Fatalf("incomplete permission acknowledgement accepted: %#v", data)
}
}
valid := map[string]any{
"success": true,
"result": map[string]any{"resultMap": map[string]any{"u1": []any{"m1"}}},
}
if err := RequirePermissionMutationAcknowledgement("unshare", []string{"u1"}, []string{"m1"}, valid); err != nil {
t.Fatalf("valid permission acknowledgement rejected: %v", err)
}
}
func TestCrossPlatformCoverageMinutesWorkflowObservedShapesE2E(t *testing.T) {
record, err := RecordResult("pause", "u1", map[string]any{"success": true, "result": map[string]any{"cmd": "pause", "uuid": "u1"}})
if err != nil || record["cmd"] != "pause" {
@@ -0,0 +1,51 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package todo
import (
"context"
"io"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type todoPageSizeCaller struct {
calls int
}
func (c *todoPageSizeCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
c.calls++
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"result":{"todoCards":[]}}`}}}, nil
}
func (*todoPageSizeCaller) Format() string { return "json" }
func (*todoPageSizeCaller) DryRun() bool { return false }
func (*todoPageSizeCaller) Fields() string { return "" }
func (*todoPageSizeCaller) JQ() string { return "" }
func TestCrossPlatformCoverageGetMyTasksRejectsUnsupportedPageSize(t *testing.T) {
caller := &todoPageSizeCaller{}
helpers.InitDepsForTest(t, caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.AddCommand(shortcut.Commands()...)
root.SetArgs([]string{"todo", "+get-my-tasks", "--size", "21"})
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "--size") {
t.Fatalf("size 21 error = %v, want local --size validation", err)
}
if caller.calls != 0 {
t.Fatalf("size 21 made %d remote calls, want 0", caller.calls)
}
}
+24
View File
@@ -154,6 +154,30 @@ func projectWikiRows(items []any, aliases map[string][]string) []map[string]any
return rows
}
func projectWikiRowsPreservingSource(items []any, aliases map[string][]string) []map[string]any {
rows := make([]map[string]any, 0, len(items))
for _, item := range items {
source := item.(map[string]any)
row := make(map[string]any, len(source)+len(aliases))
for key, value := range source {
row[key] = value
}
for canonical, candidates := range aliases {
if value, ok := row[canonical]; ok && value != nil {
continue
}
for _, candidate := range candidates {
if value, ok := source[candidate]; ok && value != nil {
row[canonical] = value
break
}
}
}
rows = append(rows, row)
}
return rows
}
func addWikiPagination(out, page map[string]any) {
for _, pair := range [][2]string{{"nextCursor", "nextCursor"}, {"nextToken", "nextCursor"}, {"nextPageToken", "nextCursor"}, {"pageToken", "nextCursor"}, {"hasMore", "hasMore"}, {"truncated", "truncated"}, {"totalCount", "totalCount"}, {"autoPageComplete", "autoPageComplete"}, {"autoPageStopReason", "autoPageStopReason"}, {"pagesFetched", "pagesFetched"}} {
if value, ok := page[pair[0]]; ok && value != nil {
+1 -1
View File
@@ -237,7 +237,7 @@ var FeedList = readShortcut("+feed-list", "严格分页列出知识库动态", "
if err != nil {
return err
}
feeds := projectWikiRows(items, map[string][]string{"id": {"id", "feedId"}, "type": {"type", "feedType", "action"}, "time": {"time", "createTime"}, "name": {"name", "title", "fileName"}, "nodeId": {"nodeId", "fileId"}})
feeds := projectWikiRowsPreservingSource(items, map[string][]string{"id": {"id", "feedId"}, "type": {"type", "feedType", "action"}, "time": {"time", "createTime"}, "name": {"name", "title", "fileName"}, "nodeId": {"nodeId", "fileId"}})
out := map[string]any{"count": len(feeds), "feeds": feeds}
addWikiPagination(out, page)
return rt.Output(out)
@@ -294,6 +294,29 @@ func TestCrossPlatformCoverageWikiReadWorkflows(t *testing.T) {
})
}
func TestCrossPlatformCoverageWikiFeedListPreservesBusinessPayload(t *testing.T) {
caller := &wikiCoverageCaller{responses: map[string][]string{
"wiki/list_workspace_feeds": {`{"success":true,"feeds":[{"type":1,"time":1750067400000,"content":{"doc":{"name":"Important","docKey":"dk1"}},"users":[{"nick":"Alice","userId":"u1"}],"futureField":{"value":"keep"}}],"hasMore":false}`},
}}
out, err := runWikiCoverageCLI(t, caller, "+feed-list", "--workspace", "w")
if err != nil {
t.Fatal(err)
}
feeds := out["feeds"].([]any)
feed := feeds[0].(map[string]any)
content, contentOK := feed["content"].(map[string]any)
users, usersOK := feed["users"].([]any)
if !contentOK || content["doc"].(map[string]any)["name"] != "Important" {
t.Fatalf("content was lost or changed: %#v", feed)
}
if !usersOK || users[0].(map[string]any)["nick"] != "Alice" {
t.Fatalf("users were lost or changed: %#v", feed)
}
if _, ok := feed["futureField"]; !ok {
t.Fatalf("unknown feed business fields must be preserved: %#v", feed)
}
}
func TestCrossPlatformCoverageWikiWriteWorkflows(t *testing.T) {
t.Run("confirmation gates precede every remote call", func(t *testing.T) {
for _, tc := range []struct {
+6 -6
View File
@@ -638,13 +638,13 @@ MONO_HOME="$WORKDIR/home-mono"
MULTI_HOME="$WORKDIR/home-multi"
mkdir -p "$MONO_HOME" "$MULTI_HOME"
capture "$OUT_DIR/skill-mono-setup.txt" env HOME="$MONO_HOME" "$BIN" skill setup --mode mono --target codex --source "$ROOT" --yes
expect_contains "$MONO_HOME/.codex/skills/dws/SKILL.md" "corpId:userId"
expect_contains "$MONO_HOME/.codex/skills/dws/SKILL.md" "禁止选择第一项、最近登录或最近使用账号"
expect_contains "$MONO_HOME/.codex/skills/dws/references/global-reference.md" "userId/userName"
expect_contains "$MONO_HOME/.agents/skills/dws/SKILL.md" "corpId:userId"
expect_contains "$MONO_HOME/.agents/skills/dws/SKILL.md" "禁止选择第一项、最近登录或最近使用账号"
expect_contains "$MONO_HOME/.agents/skills/dws/references/global-reference.md" "userId/userName"
capture "$OUT_DIR/skill-multi-setup.txt" env HOME="$MULTI_HOME" "$BIN" skill setup --mode multi --target codex --source "$ROOT" --yes
expect_contains "$MULTI_HOME/.codex/skills/dingtalk-shared/SKILL.md" "禁止选择第一项、最近登录或最近使用账号"
expect_contains "$MULTI_HOME/.codex/skills/dingtalk-misc/references/profile.md" "corpId:userId"
expect_contains "$MULTI_HOME/.codex/skills/dingtalk-misc/references/profile.md" "isOrgCurrent"
expect_contains "$MULTI_HOME/.agents/skills/dingtalk-shared/SKILL.md" "禁止选择第一项、最近登录或最近使用账号"
expect_contains "$MULTI_HOME/.agents/skills/dingtalk-misc/references/profile.md" "corpId:userId"
expect_contains "$MULTI_HOME/.agents/skills/dingtalk-misc/references/profile.md" "isOrgCurrent"
log "verifying empty profile list"
capture "$OUT_DIR/list-empty.json" "$BIN" profile list --format json
@@ -403,6 +403,11 @@ CANDIDATE_RAW="$TMP_ROOT/candidate-schema.json"
go build -o "$CANDIDATE_BIN" ./cmd
)
CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA=false
if "$CHECKER" --help 2>&1 | grep -Fq -- 'migration-base-schema'; then
CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA=true
fi
mkdir -p "$TMP_ROOT/base-home" "$TMP_ROOT/stable-home" "$TMP_ROOT/candidate-home"
HOME="$TMP_ROOT/base-home" DWS_LANG=zh \
"$BASE_BIN" schema --all --format json >"$BASE_RAW"
@@ -468,23 +473,22 @@ check_with_migrations() {
historical_kind="$1"
historical_ref="$2"
historical_baseline="$3"
set -- \
--approved-flag-migrations "$APPROVED_MANIFEST" \
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then
check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" \
--approved-flag-migrations "$APPROVED_MANIFEST" \
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
set -- "$@" \
--approved-command-migrations "$APPROVED_COMMAND_MANIFEST" \
--candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST" \
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
else
check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" \
--approved-flag-migrations "$APPROVED_MANIFEST" \
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
--candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST"
fi
if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ] &&
[ "$CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA" = true ]; then
set -- "$@" --migration-base-schema "$BASELINE"
fi
check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" "$@"
}
check_with_migrations "PR merge-base" "$BASE_REF" "$BASELINE"
@@ -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, &current, &flags, &commands)
if _, err := normalizeSchemaCommandMigrationLineage(stable, base, current, flags, commands); err == nil ||
!strings.Contains(err.Error(), test.want) {
t.Fatalf("lineage error=%v, want %q", err, test.want)
}
})
}
}
func TestCrossPlatformCoverageSchemaCommandMigrationLineagePreservesOrdinaryChecks(t *testing.T) {
stable, base, current := schemaCommandLineageContracts()
mutateSchemaCommandMigrationTool(&current, "chat.list_topic_replies", func(tool *toolSchema) {
tool.Positionals = []positionalSchema{{Name: "open-topic-id", Index: 0, Type: "string", Required: true}}
})
normalized, err := normalizeSchemaCommandMigrationLineage(
stable,
base,
current,
schemaCommandLineageFlagManifest().Migrations,
schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations,
)
if err != nil {
t.Fatal(err)
}
if failures := strings.Join(checkCompatibility(normalized, current), "\n"); !strings.Contains(failures, "changed positionals") {
t.Fatalf("lineage hid positional drift: %s", failures)
}
// 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(&current, "chat.list_topic_replies", func(tool *toolSchema) {
delete(tool.Parameters, "conversation-id")
delete(tool.Parameters, "topic-id")
tool.Parameters["open-topic-id"] = conversation
tool.Parameters["open-conv-thread-id"] = topic
tool.PrimaryCLIPath = "chat topic list-replies"
tool.Constraints = `{"require_together":[["open-conv-thread-id","open-topic-id"]]}`
})
return stable, base, current
}
func schemaCommandLineageFlagManifest() interfacesnapshot.FlagMigrationManifest {
beforeCanonical := interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Hidden: true, Scope: "local"}
afterCanonical := interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"}
return interfacesnapshot.FlagMigrationManifest{
Version: interfacesnapshot.FlagMigrationManifestVersion,
Migrations: []interfacesnapshot.FlagMigration{{
Command: "dws chat message list-topic-replies",
Legacy: interfacesnapshot.FlagMigrationSide{
Name: "group",
Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"},
After: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Hidden: true, Scope: "local", AliasOf: "conversation-id"},
},
Canonical: interfacesnapshot.FlagMigrationSide{
Name: "conversation-id",
Before: beforeCanonical,
After: afterCanonical,
},
State: interfacesnapshot.FlagMigrationConsumed,
Reason: "preserve the reviewed group to conversation-id lineage",
}},
}
}
func schemaCommandLineageManifest(state string) interfacesnapshot.CommandMigrationManifest {
return interfacesnapshot.CommandMigrationManifest{
Version: interfacesnapshot.CommandMigrationManifestVersion,
Migrations: []interfacesnapshot.CommandMigration{{
Kind: interfacesnapshot.CommandMigrationMove,
Legacy: interfacesnapshot.CommandMigrationSide{
Command: "dws chat message list-topic-replies",
Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true, Hidden: true},
},
Replacement: interfacesnapshot.CommandMigrationSide{
Command: "dws chat topic list-replies",
Before: interfacesnapshot.CommandMigrationState{},
After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
},
Schema: interfacesnapshot.CommandMigrationSchema{
ProductID: "chat",
SourceToolID: "chat.list_topic_replies",
ReplacementToolID: "chat.list_topic_replies",
Parameters: []interfacesnapshot.CommandParameterMigration{
{From: "conversation-id", To: "open-topic-id"},
{From: "topic-id", To: "open-conv-thread-id"},
},
},
State: state,
Reason: "move topic reply listing while retaining the legacy command",
}},
}
}
func schemaCommandLineageSnapshot(flagAfter, commandAfter bool) interfacesnapshot.Snapshot {
legacyFlags := []interfacesnapshot.Flag{
{Name: "conversation-id", Type: "string", Hidden: true},
{Name: "group", Type: "string", Required: true},
{Name: "topic-id", Type: "string", Required: true},
}
if flagAfter {
legacyFlags[0].Hidden = false
legacyFlags[0].Required = true
legacyFlags[1].Required = false
legacyFlags[1].Hidden = true
legacyFlags[1].AliasOf = "conversation-id"
}
commands := []interfacesnapshot.Command{
{Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
{
Path: "dws chat message list-topic-replies",
Runnable: true,
Aliases: []string{},
LocalFlags: legacyFlags,
InheritedFlags: []interfacesnapshot.Flag{},
},
}
if commandAfter {
commands[1].Hidden = true
commands = append(commands, interfacesnapshot.Command{
Path: "dws chat topic list-replies",
Runnable: true,
Aliases: []string{},
LocalFlags: []interfacesnapshot.Flag{},
InheritedFlags: []interfacesnapshot.Flag{},
})
}
return interfacesnapshot.Snapshot{
SchemaVersion: interfacesnapshot.SchemaVersion,
Rules: interfacesnapshot.Rules{
ExcludedCommandSubtrees: []string{"dws __complete", "dws __completeNoDesc", "dws completion", "dws help"},
ExcludedFlags: []string{"help"},
},
Commands: commands,
}
}
func schemaCommandMigrationAuthorizations() []interfacesnapshot.CommandMigration {
return []interfacesnapshot.CommandMigration{
{
+398 -14
View File
@@ -87,6 +87,7 @@ func run(args []string, stdout, stderr io.Writer) int {
var normalizePath, checkPath, mergePath, currentPath string
var approvedFlagMigrationsPath, candidateFlagMigrationsPath string
var approvedCommandMigrationsPath, candidateCommandMigrationsPath string
var migrationBaseSchemaPath string
var migrationCurrentSnapshotPath, migrationBaseSnapshotPath, migrationStableSnapshotPath string
flags := flag.NewFlagSet("schema-compat", flag.ContinueOnError)
flags.SetOutput(stderr)
@@ -98,6 +99,7 @@ func run(args []string, stdout, stderr io.Writer) int {
flags.StringVar(&candidateFlagMigrationsPath, "candidate-flag-migrations", "", "detached candidate flag migration manifest")
flags.StringVar(&approvedCommandMigrationsPath, "approved-command-migrations", "", "base-owned approved command migration manifest")
flags.StringVar(&candidateCommandMigrationsPath, "candidate-command-migrations", "", "detached candidate command migration manifest")
flags.StringVar(&migrationBaseSchemaPath, "migration-base-schema", "", "normalized merge-base Schema contract used to verify cross-migration lineage")
flags.StringVar(&migrationCurrentSnapshotPath, "migration-current-snapshot", "", "current interface snapshot used for migration authorization")
flags.StringVar(&migrationBaseSnapshotPath, "migration-base-snapshot", "", "merge-base interface snapshot used for migration authorization")
flags.StringVar(&migrationStableSnapshotPath, "migration-stable-snapshot", "", "stable interface snapshot used for migration authorization")
@@ -149,6 +151,14 @@ func run(args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "Schema migration authorization is only valid with --check")
return 2
}
if flagMigrationPair && commandMigrationPair && migrationBaseSchemaPath == "" {
fmt.Fprintln(stderr, "combined Schema flag and command migration authorization requires --migration-base-schema")
return 2
}
if migrationBaseSchemaPath != "" && (!flagMigrationPair || !commandMigrationPair) {
fmt.Fprintln(stderr, "--migration-base-schema requires both flag and command migration manifest pairs")
return 2
}
if normalizePath != "" {
currentPath = normalizePath
@@ -175,8 +185,9 @@ func run(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "read schema baseline: %v\n", err)
return 2
}
var flagMigrations []interfacesnapshot.FlagMigration
if flagMigrationPair {
migrations, err := authorizeSchemaFlagMigrations(
flagMigrations, err = authorizeSchemaFlagMigrations(
approvedFlagMigrationsPath,
candidateFlagMigrationsPath,
migrationCurrentSnapshotPath,
@@ -187,14 +198,9 @@ func run(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "authorize Schema flag migrations: %v\n", err)
return 2
}
baseline, err = normalizeSchemaFlagMigrations(baseline, current, migrations)
if err != nil {
fmt.Fprintf(stderr, "normalize approved Schema flag migrations: %v\n", err)
return 2
}
}
if commandMigrationPair {
migrations, err := authorizeSchemaCommandMigrations(
commandMigrations, err := authorizeSchemaCommandMigrations(
approvedCommandMigrationsPath,
candidateCommandMigrationsPath,
migrationCurrentSnapshotPath,
@@ -205,11 +211,32 @@ func run(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "authorize Schema command migrations: %v\n", err)
return 2
}
baseline, err = normalizeSchemaCommandMigrations(baseline, current, migrations)
if flagMigrationPair {
migrationBase, readErr := readContract(migrationBaseSchemaPath)
if readErr != nil {
fmt.Fprintf(stderr, "read migration merge-base Schema contract: %v\n", readErr)
return 2
}
baseline, err = normalizeSchemaCommandMigrationLineage(
baseline,
migrationBase,
current,
flagMigrations,
commandMigrations,
)
} else {
baseline, err = normalizeSchemaCommandMigrations(baseline, current, commandMigrations)
}
if err != nil {
fmt.Fprintf(stderr, "normalize approved Schema command migrations: %v\n", err)
return 2
}
} else if flagMigrationPair {
baseline, err = normalizeSchemaFlagMigrations(baseline, current, flagMigrations)
if err != nil {
fmt.Fprintf(stderr, "normalize approved Schema flag migrations: %v\n", err)
return 2
}
}
failures := checkCompatibility(baseline, current)
if len(failures) > 0 {
@@ -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 == "" {
@@ -173,8 +173,9 @@ func main() {
currentSnapshot := flag.String("migration-current-snapshot", "", "candidate interface snapshot")
baseSnapshot := flag.String("migration-base-snapshot", "", "base interface snapshot")
stableSnapshot := flag.String("migration-stable-snapshot", "", "stable interface snapshot")
_ = flag.String("approved-command-migrations", "", "base command ledger")
_ = flag.String("candidate-command-migrations", "", "candidate command ledger")
migrationBaseSchema := flag.String("migration-base-schema", "", "merge-base Schema contract")
approvedCommand := flag.String("approved-command-migrations", "", "base command ledger")
candidateCommand := flag.String("candidate-command-migrations", "", "candidate command ledger")
flag.Parse()
if *normalize != "" {
@@ -214,6 +215,29 @@ func main() {
os.Exit(2)
}
}
commandPair := *approvedCommand != "" || *candidateCommand != ""
if commandPair && (*approvedCommand == "" || *candidateCommand == "") {
fmt.Fprintln(os.Stderr, "stable Schema guard received a partial command migration pair")
os.Exit(2)
}
if commandPair {
data, err := os.ReadFile(*migrationBaseSchema)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if !strings.Contains(string(data), "BASE_AUTHORITY") {
fmt.Fprintf(os.Stderr, "migration base Schema is not base-owned: %s\n", data)
os.Exit(2)
}
fmt.Fprintln(os.Stdout, "BASE_SCHEMA_LINEAGE=BASE_AUTHORITY")
} else {
if *migrationBaseSchema != "" {
fmt.Fprintln(os.Stderr, "flag-only Schema check received command migration lineage")
os.Exit(2)
}
fmt.Fprintln(os.Stdout, "FLAG_ONLY_SCHEMA_LINEAGE_OMITTED")
}
currentData, err := os.ReadFile(*current)
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -418,9 +442,16 @@ func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testi
authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED",
},
{
name: "governed checker protects stable Schema contract",
name: "governed checker protects stable Schema contract",
baseGovernance: "complete",
commandGovernance: "complete",
checkerMode: "stable-schema-guard",
authorityMarker: "STABLE_SCHEMA_CONTRACT_ENFORCED",
},
{
name: "lineage capable checker omits command lineage for flag-only governance",
baseGovernance: "complete",
checkerMode: "stable-schema-guard",
checkerMode: "flag-only-schema-guard",
authorityMarker: "STABLE_SCHEMA_CONTRACT_ENFORCED",
},
{
@@ -611,7 +642,7 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) {
switch test.baseGovernance {
case "complete":
checkerSource := governedSchemaCheckerSource
if test.checkerMode == "stable-schema-guard" {
if test.checkerMode == "stable-schema-guard" || test.checkerMode == "flag-only-schema-guard" {
checkerSource = stableSchemaGuardCheckerSource
}
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), checkerSource, 0o644)
@@ -794,6 +825,12 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) {
if test.baseGovernance == "complete" && strings.Count(got, "BASE_INTERFACE_HELPER_GENERATE=BASE") != 3 {
t.Fatalf("governed Schema check did not generate three base-owned interface snapshots; output:\n%s", got)
}
if test.checkerMode == "stable-schema-guard" && strings.Count(got, "BASE_SCHEMA_LINEAGE=BASE_AUTHORITY") != 2 {
t.Fatalf("governed Schema check did not pass the base-owned Schema lineage to both historical checks; output:\n%s", got)
}
if test.checkerMode == "flag-only-schema-guard" && strings.Count(got, "FLAG_ONLY_SCHEMA_LINEAGE_OMITTED") != 2 {
t.Fatalf("flag-only Schema check received command migration lineage; output:\n%s", got)
}
wrongStable := exec.Command(
"sh",
+122
View File
@@ -0,0 +1,122 @@
#!/bin/sh
set -eu
# Release compatibility is one decision seam over the sealed source tree.
# Trusted release tooling may orchestrate this script, but the explicitly
# selected repository remains the authority for both CLI and Schema checks.
REPO_ROOT=""
BASE_REF=""
STABLE_REF=""
CANDIDATE_REF="HEAD"
usage() {
printf '%s\n' \
"usage: $0 --repo-root <path> --base-ref <ref> --stable-ref <ref> [--candidate-ref <ref>]" >&2
}
while [ "$#" -gt 0 ]; do
case "$1" in
--repo-root)
[ "$#" -ge 2 ] || { usage; exit 2; }
REPO_ROOT="$2"
shift 2
;;
--base-ref)
[ "$#" -ge 2 ] || { usage; exit 2; }
BASE_REF="$2"
shift 2
;;
--stable-ref)
[ "$#" -ge 2 ] || { usage; exit 2; }
STABLE_REF="$2"
shift 2
;;
--candidate-ref)
[ "$#" -ge 2 ] || { usage; exit 2; }
CANDIDATE_REF="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
printf 'error: unknown argument: %s\n' "$1" >&2
usage
exit 2
;;
esac
done
[ -n "$REPO_ROOT" ] && [ -n "$BASE_REF" ] && [ -n "$STABLE_REF" ] || {
usage
exit 2
}
REPO_ROOT="$(CDPATH= cd -- "$REPO_ROOT" && pwd -P)" || {
printf 'error: release source root is not available: %s\n' "$REPO_ROOT" >&2
exit 2
}
GIT_ROOT="$(git -C "$REPO_ROOT" rev-parse --show-toplevel 2>/dev/null)" || {
printf 'error: release source root is not a Git worktree: %s\n' "$REPO_ROOT" >&2
exit 2
}
[ "$GIT_ROOT" = "$REPO_ROOT" ] || {
printf 'error: release source root must be the Git worktree root: %s\n' "$REPO_ROOT" >&2
exit 2
}
CLI_CHECK="$REPO_ROOT/scripts/policy/check-authoritative-interface-baselines.sh"
SCHEMA_CHECK="$REPO_ROOT/scripts/policy/check-authoritative-schema-compatibility.sh"
for check in "$CLI_CHECK" "$SCHEMA_CHECK"; do
[ -x "$check" ] || {
printf 'error: authoritative release compatibility checker is unavailable: %s\n' "$check" >&2
exit 2
}
done
resolve_commit() {
label="$1"
ref="$2"
commit="$(git -C "$REPO_ROOT" rev-parse --verify "${ref}^{commit}" 2>/dev/null)" || {
printf 'error: release %s ref is not available in sealed source: %s\n' "$label" "$ref" >&2
return 2
}
[ -n "$commit" ] || {
printf 'error: release %s ref resolved to an empty commit: %s\n' "$label" "$ref" >&2
return 2
}
printf '%s\n' "$commit"
}
# Freeze the complete comparison tuple before either checker runs. Otherwise a
# concurrent ref update (or the first checker itself) could make Schema inspect
# different Git objects from the CLI gate even when both receive the same names.
BASE_COMMIT="$(resolve_commit base "$BASE_REF")"
STABLE_COMMIT="$(resolve_commit stable "$STABLE_REF")"
CANDIDATE_COMMIT="$(resolve_commit candidate "$CANDIDATE_REF")"
printf '==> Checking authoritative CLI compatibility\n'
if "$CLI_CHECK" \
--base-ref "$BASE_COMMIT" \
--stable-ref "$STABLE_COMMIT" \
--candidate-ref "$CANDIDATE_COMMIT"; then
:
else
status=$?
printf 'error: authoritative CLI compatibility failed\n' >&2
exit "$status"
fi
printf '==> Checking authoritative Schema compatibility\n'
if "$SCHEMA_CHECK" \
--base-ref "$BASE_COMMIT" \
--stable-ref "$STABLE_COMMIT" \
--candidate-ref "$CANDIDATE_COMMIT"; then
:
else
status=$?
printf 'error: authoritative Schema compatibility failed\n' >&2
exit "$status"
fi
+11 -7
View File
@@ -19,7 +19,7 @@ usage() {
cat >&2 <<'EOF'
usage: release.sh <prerelease|stable> <version> [options]
Runs the full test, command-compatibility, package, and install preflight.
Runs the full test, authoritative CLI/Schema compatibility, package, and install preflight.
The default is validation only. Official publication is cloud-only.
Options:
@@ -359,10 +359,12 @@ else
make policy
if [ -n "$previous_stable" ]; then
printf '==> Comparing command tree with %s\n' "$previous_stable"
"$ROOT/scripts/policy/check-command-compatibility.sh" \
printf '==> Comparing authoritative CLI and Schema with %s\n' "$previous_stable"
"$SCRIPT_DIR/check-release-compatibility.sh" \
--repo-root "$ROOT" \
--base-ref HEAD \
--stable-ref "$previous_stable"
--stable-ref "$previous_stable" \
--candidate-ref HEAD
fi
printf '==> Building local release artifacts for %s\n' "$VERSION"
@@ -412,11 +414,13 @@ printf '==> Revalidating delivered stable baseline %s\n' "$previous_stable"
require_delivered_previous_stable
if [ "$previous_stable" != "$previous_stable_before_refresh" ]; then
printf '==> Stable authority advanced from %s to %s; rechecking command compatibility\n' \
printf '==> Stable authority advanced from %s to %s; rechecking authoritative CLI and Schema compatibility\n' \
"${previous_stable_before_refresh:-none}" "$previous_stable"
"$ROOT/scripts/policy/check-command-compatibility.sh" \
"$SCRIPT_DIR/check-release-compatibility.sh" \
--repo-root "$ROOT" \
--base-ref HEAD \
--stable-ref "$previous_stable"
--stable-ref "$previous_stable" \
--candidate-ref HEAD
fi
if [ "$PUBLISH" -eq 1 ]; then
+14 -2
View File
@@ -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")
+233 -6
View File
@@ -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)
}