diff --git a/.changes/released/1.0.59-beta.4/1045-attendance-mail-shortcuts.md b/.changes/released/1.0.59-beta.4/1045-attendance-mail-shortcuts.md new file mode 100644 index 00000000..e6b650d2 --- /dev/null +++ b/.changes/released/1.0.59-beta.4/1045-attendance-mail-shortcuts.md @@ -0,0 +1,14 @@ +--- +category: 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. diff --git a/.changes/released/1.0.59-beta.4/1058-chat-group-role-single-flag.md b/.changes/released/1.0.59-beta.4/1058-chat-group-role-single-flag.md new file mode 100644 index 00000000..b84f9ebc --- /dev/null +++ b/.changes/released/1.0.59-beta.4/1058-chat-group-role-single-flag.md @@ -0,0 +1,5 @@ +--- +category: Changed +--- + +- **Chat group roles** (#1058) — exposes the single-value `--role-id` flag for assigning one custom group role while preserving hidden `--role-ids` compatibility. diff --git a/.changes/976-recruit-job.md b/.changes/released/1.0.59-beta.4/976-recruit-job.md similarity index 100% rename from .changes/976-recruit-job.md rename to .changes/released/1.0.59-beta.4/976-recruit-job.md diff --git a/.changes/996-canonical-agent-skills.md b/.changes/released/1.0.59-beta.4/996-canonical-agent-skills.md similarity index 100% rename from .changes/996-canonical-agent-skills.md rename to .changes/released/1.0.59-beta.4/996-canonical-agent-skills.md diff --git a/.changes/chat-user-mention-tokens.md b/.changes/released/1.0.59-beta.4/chat-user-mention-tokens.md similarity index 100% rename from .changes/chat-user-mention-tokens.md rename to .changes/released/1.0.59-beta.4/chat-user-mention-tokens.md diff --git a/.changes/released/1.0.59-beta.4/command-path-migration-governance.md b/.changes/released/1.0.59-beta.4/command-path-migration-governance.md new file mode 100644 index 00000000..61f89635 --- /dev/null +++ b/.changes/released/1.0.59-beta.4/command-path-migration-governance.md @@ -0,0 +1,5 @@ +--- +category: Changed +--- + +- **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. diff --git a/.changes/released/1.0.59-beta.4/oa-approval-list-by-admin.md b/.changes/released/1.0.59-beta.4/oa-approval-list-by-admin.md new file mode 100644 index 00000000..da6f2c33 --- /dev/null +++ b/.changes/released/1.0.59-beta.4/oa-approval-list-by-admin.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 728d8f7b..4377f4b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 npm engine declaration now reflects the actual Node 16.7+ API floor. + +- **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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ce9d6372..4c334984 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,9 +74,9 @@ coverage is additionally selected for platform-sensitive code. `make authoritative-interface-integrity BASE_REF= STABLE_REF= CANDIDATE_REF=`. The Make target delegates to the authoritative wrapper; CI does not invoke a second comparator or the legacy fixture checker. See - [CLI flag compatibility migration governance](docs/cli-interface-flag-migrations.md) + [CLI Help / Schema compatibility migration governance](docs/cli-interface-flag-migrations.md) for the reviewed two-stage `pending` → `consumed` lifecycle. - Agent-visible flag migrations must also run + Agent-visible flag or command-path migrations must also run `make schema-compatibility BASE_REF= STABLE_REF= CANDIDATE_REF=`; it consumes the same base-owned ledger rather than a second exception list. 5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may diff --git a/cmd/interface-snapshot/main.go b/cmd/interface-snapshot/main.go index bfaab482..f00252c9 100644 --- a/cmd/interface-snapshot/main.go +++ b/cmd/interface-snapshot/main.go @@ -157,6 +157,16 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) { "", "candidate flag migration manifest", ) + approvedCommandMigrationsPath := flags.String( + "approved-command-migrations", + "", + "merge-base-owned approved command migration manifest", + ) + candidateCommandMigrationsPath := flags.String( + "candidate-command-migrations", + "", + "candidate command migration manifest", + ) if err := flags.Parse(args); err != nil { return false, err } @@ -174,8 +184,13 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) { "--approved-flag-migrations and --candidate-flag-migrations must be provided together", ) } - if *approvedMigrationsPath != "" && (*basePath == "" || *stablePath == "") { - return false, fmt.Errorf("flag migration compare requires both --base and --stable") + if (*approvedCommandMigrationsPath == "") != (*candidateCommandMigrationsPath == "") { + return false, fmt.Errorf( + "--approved-command-migrations and --candidate-command-migrations must be provided together", + ) + } + if (*approvedMigrationsPath != "" || *approvedCommandMigrationsPath != "") && (*basePath == "" || *stablePath == "") { + return false, fmt.Errorf("migration compare requires both --base and --stable") } current, err := readSnapshot(*currentPath) @@ -197,7 +212,39 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) { } report := interfacesnapshot.CompareAll(current, references) - if *approvedMigrationsPath != "" { + if *approvedCommandMigrationsPath != "" { + flagApproved := interfacesnapshot.FlagMigrationManifest{Version: interfacesnapshot.FlagMigrationManifestVersion, Migrations: []interfacesnapshot.FlagMigration{}} + flagCandidate := flagApproved + if *approvedMigrationsPath != "" { + flagApproved, err = readFlagMigrationManifest(*approvedMigrationsPath) + if err != nil { + return false, fmt.Errorf("read approved flag migrations: %w", err) + } + flagCandidate, err = readFlagMigrationManifest(*candidateMigrationsPath) + if err != nil { + return false, fmt.Errorf("read candidate flag migrations: %w", err) + } + } + commandApproved, readErr := readCommandMigrationManifest(*approvedCommandMigrationsPath) + if readErr != nil { + return false, fmt.Errorf("read approved command migrations: %w", readErr) + } + commandCandidate, readErr := readCommandMigrationManifest(*candidateCommandMigrationsPath) + if readErr != nil { + return false, fmt.Errorf("read candidate command migrations: %w", readErr) + } + report, err = interfacesnapshot.CompareAllWithInterfaceMigrations( + current, + references, + flagApproved, + flagCandidate, + commandApproved, + commandCandidate, + ) + if err != nil { + return false, fmt.Errorf("validate interface migration lifecycle: %w", err) + } + } else if *approvedMigrationsPath != "" { approved, readErr := readFlagMigrationManifest(*approvedMigrationsPath) if readErr != nil { return false, fmt.Errorf("read approved flag migrations: %w", readErr) @@ -234,6 +281,15 @@ func readFlagMigrationManifest(path string) (interfacesnapshot.FlagMigrationMani return interfacesnapshot.ReadFlagMigrationManifest(file) } +func readCommandMigrationManifest(path string) (interfacesnapshot.CommandMigrationManifest, error) { + file, err := os.Open(filepath.Clean(path)) + if err != nil { + return interfacesnapshot.CommandMigrationManifest{}, err + } + defer file.Close() + return interfacesnapshot.ReadCommandMigrationManifest(file) +} + func validateHelpRendering(root *cobra.Command, snapshot interfacesnapshot.Snapshot) error { for _, command := range snapshot.Commands { path := strings.TrimPrefix(command.Path, "dws") @@ -280,5 +336,5 @@ func readSnapshot(path string) (interfacesnapshot.Snapshot, error) { func printUsage(w io.Writer) { fmt.Fprintln(w, "usage:") fmt.Fprintln(w, " interface-snapshot generate [--output FILE]") - fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE]") + fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE] [--approved-command-migrations FILE --candidate-command-migrations FILE]") } diff --git a/cmd/interface-snapshot/main_test.go b/cmd/interface-snapshot/main_test.go index 62bb91cb..7bb44fb7 100644 --- a/cmd/interface-snapshot/main_test.go +++ b/cmd/interface-snapshot/main_test.go @@ -166,6 +166,102 @@ func TestCrossPlatformCoverageRunCompareRequiresBothFlagMigrationInputs(t *testi } } +func TestCrossPlatformCoverageRunCompareCommandMigrationInputs(t *testing.T) { + dir := t.TempDir() + snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws")) + emptyFlag := writeManifest(t, dir, "empty-flags.json", `{"version":1,"migrations":[]}`) + emptyCommand := writeManifest(t, dir, "empty-commands.json", `{"version":1,"migrations":[]}`) + invalid := writeManifest(t, dir, "invalid-commands.json", `{`) + + var stdout, stderr bytes.Buffer + args := []string{ + "compare", + "--current", snapshotPath, + "--base", snapshotPath, + "--stable", snapshotPath, + "--approved-flag-migrations", emptyFlag, + "--candidate-flag-migrations", emptyFlag, + "--approved-command-migrations", emptyCommand, + "--candidate-command-migrations", emptyCommand, + } + if exitCode := run(args, &stdout, &stderr); exitCode != 0 { + t.Fatalf("combined migration compare exit=%d stderr=%s", exitCode, stderr.String()) + } + for _, test := range []struct { + name string + approved string + candidate string + want string + }{ + {"approved flag", invalid, emptyFlag, "read approved flag migrations"}, + {"candidate flag", emptyFlag, invalid, "read candidate flag migrations"}, + } { + t.Run(test.name, func(t *testing.T) { + stdout.Reset() + stderr.Reset() + testArgs := []string{ + "compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath, + "--approved-flag-migrations", test.approved, + "--candidate-flag-migrations", test.candidate, + "--approved-command-migrations", emptyCommand, + "--candidate-command-migrations", emptyCommand, + } + if exitCode := run(testArgs, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), test.want) { + t.Fatalf("combined flag error exit=%d stderr=%s", exitCode, stderr.String()) + } + }) + } + + for _, test := range []struct { + name string + approved string + candidate string + want string + }{ + {"approved", invalid, emptyCommand, "read approved command migrations"}, + {"candidate", emptyCommand, invalid, "read candidate command migrations"}, + } { + t.Run(test.name, func(t *testing.T) { + stdout.Reset() + stderr.Reset() + testArgs := []string{ + "compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath, + "--approved-command-migrations", test.approved, + "--candidate-command-migrations", test.candidate, + } + if exitCode := run(testArgs, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), test.want) { + t.Fatalf("command manifest error exit=%d stderr=%s", exitCode, stderr.String()) + } + }) + } + + stderr.Reset() + if exitCode := run([]string{ + "compare", "--current", snapshotPath, "--base", snapshotPath, + "--approved-command-migrations", emptyCommand, + }, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), "provided together") { + t.Fatalf("one-sided command manifest exit=%d stderr=%s", exitCode, stderr.String()) + } + + if _, err := readCommandMigrationManifest(filepath.Join(dir, "missing.json")); err == nil { + t.Fatal("missing command migration manifest unexpectedly read") + } + if _, err := readCommandMigrationManifest(invalid); err == nil { + t.Fatal("invalid command migration manifest unexpectedly read") + } + + pending := writeManifest(t, dir, "pending-command.json", commandMigrationManifestJSON("pending")) + consumed := writeManifest(t, dir, "consumed-command.json", commandMigrationManifestJSON("consumed")) + stderr.Reset() + if exitCode := run([]string{ + "compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath, + "--approved-command-migrations", pending, + "--candidate-command-migrations", consumed, + }, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), "validate interface migration lifecycle") { + t.Fatalf("command lifecycle error exit=%d stderr=%s", exitCode, stderr.String()) + } +} + func TestCrossPlatformCoverageRunCompareRequiresBothReferencesForFlagMigrations(t *testing.T) { dir := t.TempDir() currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws")) @@ -567,6 +663,33 @@ func flagMigrationManifestJSON(state string) string { }`, "STATE", state, 1) } +func commandMigrationManifestJSON(state string) string { + return strings.Replace(`{ + "version": 1, + "migrations": [{ + "kind": "command_move", + "legacy": { + "command": "dws chat message old", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true, "hidden": true} + }, + "replacement": { + "command": "dws chat topic new", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.move", + "replacement_tool_id": "chat.move", + "parameters": [] + }, + "state": "STATE", + "reason": "reviewed command migration" + }] +}`, "STATE", state, 1) +} + func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool { for _, flag := range flags { if flag.Name == name && flag.Type == flagType { diff --git a/docs/ci-pr-gates.md b/docs/ci-pr-gates.md index d34f3679..8016833d 100644 --- a/docs/ci-pr-gates.md +++ b/docs/ci-pr-gates.md @@ -184,11 +184,11 @@ candidate SHA。 `check-interface-baseline.sh` 不再作为本地或 CI 的兼容性审批入口,也不能用于批准 flag 迁移。 -Schema compatibility 使用同一组 base、stable、candidate refs 和同一份 base-owned flag -migration ledger。merge-base-owned checker 分别规范化 merge-base 与 stable 的完整 -Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 Interface -lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会 -放宽其他 Schema 历史字段。 +Schema compatibility 使用同一组 base、stable、candidate refs,以及 base-owned flag +与 command migration ledgers。merge-base-owned checker 分别规范化 merge-base 与 +stable 的完整 Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 +Interface lifecycle 的 exact rename、command move 或 flag extraction 规范化到当前历史 +副本,不会维护第二份 allowlist,也不会放宽其他 Schema 历史字段。 For a release-seal branch that archives rendered fragments: diff --git a/docs/cli-interface-flag-migrations.md b/docs/cli-interface-flag-migrations.md index d4a50124..60c5b684 100644 --- a/docs/cli-interface-flag-migrations.md +++ b/docs/cli-interface-flag-migrations.md @@ -1,7 +1,9 @@ -# CLI flag 兼容迁移治理 +# CLI Help / Schema 兼容迁移治理 本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。 +同一套 base-owned lifecycle 也治理两类跨命令迁移:旧命令保留执行能力但从 Help / Schema 导航隐藏,并迁到新的公开命令路径;或把旧命令中的一个可选 flag 拆成新的专用命令。跨命令迁移只允许清单精确声明的 `command_became_hidden` / `flag_became_hidden` 及其 Schema 投影,不是通用 command-path breaking-change 豁免。 + 同名 flag 的精确类型迁移属于另一类评审机制,只能进入 `internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename 只能进入本文的 JSON lifecycle ledger。一项迁移不得跨两种机制组合授权。 @@ -31,7 +33,7 @@ Smoke fixture,不参与迁移审批。 同时提供 `--base` 与 `--stable`;核心 lifecycle 也拒绝缺失 stable 的非空清单,避免 调用方因漏传历史参考而提前清理 consumed receipt。 -PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入本机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空清单,不会让 candidate 新增的 comparator 决定本 PR 是否兼容。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。 +PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入 flag 机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空 flag 清单。后续引入 command migration 扩展时,base 已拥有 flag comparator;bootstrap 仍只执行 base-owned 普通比较,不向旧 helper 传入新的 command ledger,因此允许随治理 PR 提交仍处于 before 的 pending 计划,也不会授予任何迁移豁免。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。 这条边界保护比较规则和审批数据,不是任意代码沙箱。GitHub workflow / launcher 的变更仍由仓库保护规则和真人评审负责;candidate Cobra 构建也会执行 candidate 代码,因此对同一 runner 上的主动恶意代码,需要独立进程或文件系统隔离,不能把本门禁描述成已经解决。 @@ -39,10 +41,55 @@ PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁 ```text scripts/policy/interface-migrations/approved-flag-migrations-v1.json +scripts/policy/interface-migrations/approved-command-migrations-v1.json ``` 清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。 +## 跨命令迁移原语 + +`approved-command-migrations-v1.json` 只接受两种 `kind`: + +| kind | CLI after 状态 | Schema 允许的精确投影 | +|---|---|---| +| `command_move` | legacy 命令仍 runnable、由 visible 变 hidden;replacement 由 absent 变 visible runnable | 同一 stable tool identity 的 `primary_cli_path` 改到 replacement;只允许清单列出的参数改名,参数类型、property、requiredness、default 等必须等价 | +| `flag_extraction` | legacy 命令保持 visible runnable;指定 legacy flag 仍可执行但由 visible 变 hidden;replacement 由 absent 变 visible runnable | source tool 只删除指定参数;replacement tool 必须位于精确的新路径,并保持 source 的 interface 与 safety identity;清单必须完整列出每个 source 参数到 replacement 参数或常量 property 的承接关系 | + +`command_move` 只能隐藏没有子命令的 legacy leaf,且 legacy 与 replacement +不得互为祖先路径;整棵命令树的迁移需要单独设计逐叶治理,不能复用这一原语。 +稳定 Schema tool 可以继续接受普通的 optional 参数新增,但不得借路径迁移引入清单未登记的 +`required`、`cli_required` 或 `required_when` 参数;参数改名的目标也不得与历史 +Schema 中已有的其他参数重名,避免把两个历史参数静默合并。`flag_extraction` 只接受 +optional bool legacy flag,不能隐藏仍由 Cobra hard-required 的参数。它必须对 source tool +的全部历史参数逐项声明:普通参数使用精确 `from` → `to`(同名也必须显式写出),且恰好 +一个与 legacy flag 同名的 `from` 使用 `replacement_constant`,不得同时声明 `to`;所有 +`from` 与 replacement 参数/property 目标必须唯一。legacy bool flag 的 `no_opt` 必须等于 +常量布尔值的字符串形式。v1 只治理 optional bool flag 的 `NoOpt=true` 激活分支,因此 +`replacement_constant.value` 与 legacy `no_opt` 都必须是 `true`;negative flag、默认即 +`true` 或固定 `false` 的语义不在本轮证明范围,必须另行设计,不能借本清单放行。 + +`replacement_constant` 不是清单自报即可成立的例外。after 阶段的 Interface Snapshot +必须从 replacement 命令的同一份框架运行时声明中捕获完全一致的 property/value,缺失、 +值不符或额外常量都会使 lifecycle 落入 partial。对于 #1054,`dws chat topic create` +必须通过 `NewLeafCommand` 的 `ConstParams` 声明并实际注入 +`convThreadEnabled=true`;手写 `RunE` 固定值、Cobra annotation 或只改清单都不能提供这份 +同源证据,Snapshot 只读取 `corecmd` 包内私有注册表公开的只读副本。第一次向旧快照增加 +bool 常量证据属于 bootstrap;一旦任一历史快照已记录该 +证据,普通 Interface Compare 会持续要求 property/value 集合完全一致,因此 ledger 清理后 +删除、翻转或增加常量仍会阻塞。若 candidate 改动 command ledger,则 +`internal/corecmd/corecmd.go`、`internal/corecmd/interface_const_params.go` 与 +`internal/helpers/leaf.go` 三份执行/证据桥必须保持 base Git blob 不变;框架演进必须先用 +独立 PR 合入,不能和产品消费混在一起。 + +replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run` 非空时不得删除或改值; +历史未声明时允许 replacement 新增 dry-run。这与普通 Schema 兼容规则保持同一单调边界。 + +两种迁移都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记的历史参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。 + +跨命令清单复用下文同一套 `pending → consumed → cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。 + +当前首批 pending 记录覆盖 `chat topic` 收口:`chat group create --thread` 拆到 `chat topic create`,以及 `chat message list-topic-replies` / `forward-topic` 迁到对应的 `chat topic` 命令。前一条完整登记 `name` / `type` / `users` 的同名承接,以及 `thread` → `convThreadEnabled=true` 的常量承接。产品 PR 消费这些记录时只能把三条 `state` 改为 `consumed`,不得改写其 before、after、Schema mapping、constant 或 reason。 + ## 两阶段迁移与回执清理 每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期: diff --git a/docs/shortcut-downstream-requirements/attendance.md b/docs/shortcut-downstream-requirements/attendance.md new file mode 100644 index 00000000..064abb6b --- /dev/null +++ b/docs/shortcut-downstream-requirements/attendance.md @@ -0,0 +1,310 @@ +# Attendance Shortcut 下游业务能力需求规格 + +> 日期:2026-08-18 +> Rebased executable 基线:`69bda96e49c7a478729b5f9232677fd9055e5d7d`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中 +> 对比基线:Lark CLI 1.0.87 +> 范围:Attendance Shortcut only;不改 DWS 产品 Skill 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。 + +## 1. 执行摘要 + +- Attendance 共审核 35 个源码 Shortcut;8 个具备 Agent 公开条件,27 个保持 unavailable。为守住已发布 CLI 的 argv/Help 兼容,其中 11 个历史可见入口继续以 compatibility-visible 形式可发现,但仍从 Agent public Catalog 排除、保持 legacy 输出且不发布 Result/Pagination;其余 16 个保持 hidden。公开数量按「严格响应合同 + 稳定身份 + 安全真实 fixture」的发布门计算,不把空数组或仅退出码 0 计为通过。 +- 这 11 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;它们的 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 MCP 字段名由 Execute 的显式 adapter 负责,不能在未经过版本化迁移时重定向已发布 Schema property。 +- `+check-result` 已覆盖 Lark CLI 当前唯一 Attendance 用户任务 `attendance user_tasks query`;DWS inventory 还包含打卡流水、审批、班次、规则、设置、假期和个人视图等更宽能力。排班查询入口虽然保留历史 CLI 兼容,但因 `DS-ATTENDANCE-008` 当前保持 Agent-unavailable。 +- 已确认 8 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少服务端确定终止证据、缺少安全可回收的管理员/写操作 fixture、6 个读场景缺少请求绑定字段或 nonempty/zero 双态 fixture、班次详情不回显稳定 ID、个人设置缺少逐场景权限发现与安全 fixture,以及排班查询对合法非空/空请求均返回 `exit 0 + literal null`。 +- 审批模板的同类型多模板问题已在上游修复:以 `processCode` 作为资源身份,`approveType` 只做请求绑定,并要求 `submitUrl` 非空。班次详情与个人设置仍有下游合同/权限前置,不能以请求 echo 或部分场景成功伪造整体可用。 + +| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 建议 Owner | 解锁的 Shortcut | +|---|---|---|---|---|---|---| +| `DS-ATTENDANCE-001` | P1 | business-service defect / contract insufficient | 搜索后读取补卡规则详情 | unavailable | Attendance Wukong 规则服务 | `+get-adjustment-rule` | +| `DS-ATTENDANCE-002` | P1 | business-service defect / contract insufficient | 发现报表列并查询考勤/假期报表 | unavailable | Attendance 报表服务 / MCP adapter | `+list-report-columns`, `+query-report-data`, `+query-report-leave` | +| `DS-ATTENDANCE-003` | P2 | contract insufficient | 可靠翻完打卡结果 | partial | Attendance 打卡查询服务 | `+check-result` 完整分页 | +| `DS-ATTENDANCE-004` | P1 | tenant-or-fixture / permission | 验证考勤组、全局设置、余额和写操作 | blocked / unavailable | Attendance 产品测试基础设施 / 权限 Owner | 14 个读写 Shortcut | +| `DS-ATTENDANCE-005` | P1 | response contract / tenant-or-fixture | 可验证地读取摘要、假期、签到和个人考勤 | blocked / unavailable | Attendance 查询服务 / 产品测试基础设施 | 6 个读 Shortcut | +| `DS-ATTENDANCE-006` | P1 | response contract | 用搜索得到的班次 ID 精确读取同一班次详情 | unavailable | Attendance Wukong 班次服务 | `+get-class` | +| `DS-ATTENDANCE-007` | P1 | capability / permission fixture | 可发现地读取全部个人设置场景 | blocked / unavailable | Attendance 设置服务 / 权限 Owner / 测试基础设施 | `+get-self-setting` | +| `DS-ATTENDANCE-008` | P1 | response contract | 可验证地读取员工排班 | unavailable | Attendance Wukong 排班服务 / MCP adapter | `+get-schedule` | + +## 2. 用户任务与能力缺口总览 + +| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 | +|---|---|---|---|---|---| +| 批量查询员工打卡结果 | `attendance +check-result` | `attendance user_tasks query` | covered;框架分页 token 由当前页保守派生 | contract insufficient | 声明 `Pagination(kind=cursor,cursor_parameter=offset)`;续页只放 `meta.pagination`,业务 `data` 仅含 `count/records` | +| 搜索并读取班次 | `+search-class` → `+get-class` | 无同级入口 | partial | response contract | 只公开搜索;详情因不回显请求 classId 而 unavailable | +| 搜索并读取补卡规则 | `+search-adjustment-rule` → `+get-adjustment-rule` | 无同级入口 | partial | business-service defect | 只公开搜索;详情 unavailable | +| 发现字段并查询考勤报表 | `+list-report-columns` → `+query-report-data` | 无同级入口 | unavailable | contract insufficient | 两个入口均不进入 Agent Catalog;历史 `+query-report-data` 仅保留 CLI 兼容可见性 | +| 查询假期报表 | `+query-report-leave` | 无同级入口 | unavailable | business-service defect | hidden/unavailable | +| 搜索并读取考勤组 | `+search-group` → `+get-group` | 无同级入口 | blocked | tenant-or-fixture | 无已知非空安全 fixture;历史 `+search-group` 仅保留 CLI 兼容可见性,二者都不进入 Agent Catalog | +| 查询企业全局设置和假期余额 | `+get-global-setting`, `+get-leave-balance` | 无同级入口 | blocked | permission / fixture | hidden/unavailable | +| 查询个人设置 | `+get-self-setting` | 无同级入口 | partial | capability / permission fixture | 前五个场景已验证;全部场景发布前保持 Agent-unavailable,仅保留历史 CLI 兼容可见性 | +| 查询员工排班 | `+get-schedule` | 无同级入口 | unavailable | response contract | 合法非空与保证零命中请求均收到 `exit 0 + literal null`;旧 CLI 兼容可见,但不进入 Agent Catalog | +| 修改排班、班次、考勤组、假期和打卡结果 | 9 个写 Shortcut | 无同级入口 | unsafe to verify | tenant-or-fixture / contract insufficient | hidden/unavailable,不以 dry-run 记通过 | + +## 3. 下游需求明细 + +### `DS-ATTENDANCE-001` — 让搜索得到的补卡规则可被稳定读取 + +#### A. 用户任务与现状 + +- 用户任务:先按名称浏览补卡规则,再用结果中的稳定主键读取完整规则。 +- canonical Shortcut:`attendance +search-adjustment-rule`、`attendance +get-adjustment-rule`。 +- atomic/raw route:`attendance adjustment search`、`attendance adjustment get`。 +- Exact Shortcut 与 atomic/raw 均使用搜索返回的同一候选主键;搜索明确成功且非空,详情调用明确 `success=true`,但 `result=null`。 +- 已排除上游空数组投影、整数解析和候选字段遗漏:多个可作为候选的数值字段均未得到非空详情;加班规则的相邻搜索→详情闭环正常。 +- 置信度:高。仍需下游确认“搜索 ID 与详情 ID 不同”还是详情服务未返回对象。 +- 安全证据句柄:`ATT-DETAIL-NULL-01`;仓库不保存 raw body、资源 ID 或 trace。 + +#### B. 需要下游提供的合同 + +- 明确 `get_adjustment_rule` 列表项中哪个字段是 `get_adjustment_rule_detail.adjustmentId` 的稳定主键;名称和类型必须在 Schema 中一致。 +- 对存在且有权限的规则返回 `success=true` 和非空对象 `result`,对象必须回显同一稳定规则 ID。 +- 对不存在、已删除、无权限、租户未开通分别返回稳定的 typed error;不得以 `success=true + result=null` 表示任一失败。 +- 如详情接口不受支持,提供可发现的 capability/feature 状态,或在搜索结果中返回足以完成详情任务的完整对象并声明字段稳定性。 +- 改动应 additive/versioned;旧字段保留兼容期,禁止静默改变现有 ID 的语义。 + +#### C. 验收标准 + +1. 创建或选择隔离规则,atomic search 非空并取得稳定 ID。 +2. atomic detail 和 exact `+get-adjustment-rule` 均返回同一 ID 的非空对象。 +3. 不存在 ID、无权限和已删除 ID 分别返回非零 typed error。 +4. 上游恢复公开后,搜索→详情 E2E 通过且仓库/远端无测试残留。 + +#### D. 临时处置 + +`+get-adjustment-rule` 保持 Agent-unavailable 并从公开 Catalog 排除;旧 CLI 入口仅为 argv/Help 兼容继续可见,`+search-adjustment-rule` 不再承诺详情入口可用。 + +### `DS-ATTENDANCE-002` — 提供可发现、可验证的考勤报表合同 + +#### A. 用户任务与现状 + +- Golden Route:列出企业可查询报表列 → 选择稳定列 ID → 查询一批员工的列值;另一路径按假期类型查询时长报表。 +- canonical Shortcut:`+list-report-columns`、`+query-report-data`、`+query-report-leave`。 +- atomic/raw operations:`get_report_columns`、`get_report_columns_value`、`get_leave_time_by_leave_names`。 +- 观察:列发现与假期报表调用均退出码 0 且 payload 为 JSON `null`;使用未经验证的列 ID 查询列值仅得到显式空数组,不能证明列 ID 有效或查询正确。 +- 已排除上游投影丢失:原子调用本身即返回 `null`;Shortcut 现已拒绝把 `null` 当作合法空集合。 +- 置信度:高。权限/租户功能可能是触发条件,但接口没有返回可区分的状态。 +- 安全证据句柄:`ATT-REPORT-NULL-01`。 + +#### B. 需要下游提供的合同 + +- `get_report_columns`:成功时必须返回显式列数组;每项含稳定 `columnId`、显示名、值类型、单位、支持的日期/人员范围和是否需要管理员权限。 +- 合法无列必须是 `success=true + result=[]`;未开通、无权限和服务异常必须是不同 typed error,不得返回裸 `null`。 +- `get_report_columns_value`:返回值必须绑定请求的用户集合、列 ID 和时间范围;未知列返回 `COLUMN_NOT_FOUND`,不能静默得到空数组。 +- `get_leave_time_by_leave_names`:返回显式数组并包含稳定用户身份、假期类型标识、单位和数值;合法零记录为显式空数组。 +- 列值和假期报表若分页,必须提供 page/cursor、hasMore 和终止证据;批量用户存在部分失败时返回逐项 ledger 与整体 partial status。 +- 提供安全 capability discovery:租户是否开通、调用身份所需权限、最大用户数、最大列数、最大时间跨度。 + +#### C. 验收标准 + +1. 管理员测试租户中列发现有已知非空和明确空租户两组 E2E。 +2. 使用发现的同一 `columnId` 执行 atomic 与 exact Shortcut,返回与请求用户/区间绑定的非空值。 +3. 未知列、无权限、未开通和超范围分别产生稳定非零错误。 +4. 假期报表至少覆盖已知非空、合法空和未知假期类型。 +5. 分页/partial 分支和远端零残留通过。 + +#### D. 临时处置 + +三个报表 Shortcut 均保持 Agent-unavailable;其中历史 `+query-report-data` 只保留 CLI 兼容可见性。不得用 `null`、请求 echo 或未验证列产生的空数组标记 PASS。 + +### `DS-ATTENDANCE-003` — 为打卡结果提供确定的分页终止证据 + +#### A. 用户任务与现状 + +- `+check-result` 已真实返回非空打卡结果并覆盖 Lark 任务;当前接口只接受 `offset/limit`,响应缺少稳定总量、hasMore 或 nextOffset。 +- DWS 只能在返回条数小于 limit 时证明结束;满页时保守输出 `meta.pagination.endpoint_exhausted=false` 和 `next_token=offset+count`,不能声明全量完成。`complete/nextOffset/limit` 仅保留在 legacy 兼容输出,unified 业务 `data` 不冒充分页协议。 +- 安全证据句柄:`ATT-CHECK-PAGE-01`。 + +#### B. 需要下游提供的合同 + +- 响应增加 `hasMore` 与 `nextOffset`,或 `totalCount`;这些字段必须与同一快照/排序一致。 +- 固定稳定排序键和同 offset 重放语义;说明并发新增/修改是否可能造成重复或漏项。 +- 空页且 `hasMore=true` 必须仍给出前进 token/offset;重复或倒退 offset 为协议错误。 +- 声明最大 limit、最大时间跨度和超过上限的 typed validation error。 + +#### C. 验收标准与临时处置 + +- 验收覆盖多页、最后一页、零记录、满页但仍有下一页、重复 token/offset 和并发变更。 +- 下游完成前,DWS 使用框架 `PaginationSpec` 和 `meta.pagination`表达保守续页;`cursor_parameter=offset` 表示调用者将 `next_token` 作为下一次 `--offset`,不表示下游已提供服务端 opaque cursor。满页始终不会被当作已完整。 + +### `DS-ATTENDANCE-004` — 建立可回收的 Attendance 管理员与写操作测试资源 + +#### A. 用户任务与现状 + +- 受影响读取:`+search-group`、`+get-group`、`+get-group-filtered`、`+get-global-setting`、`+get-leave-balance`。 +- 受影响写入:`+import-schedule`、`+create-class`、`+update-class`、`+update-group-members`、`+create-group`、`+update-group`、`+update-leave-type`、`+save-leave-balance`、`+boss-check`。 +- 当前安全身份没有已知非空考勤组 fixture;全局设置被权限拒绝;余额读取没有可验证结果。写操作会影响真实员工规则,且部分资源缺删除/恢复能力,因此未执行生产数据写入。 +- 这不是对业务接口必然有 bug 的结论,而是可测试性和权限前置不足。 +- 安全证据句柄:`ATT-FIXTURE-GAP-01`。 + +#### B. 需要的测试基础设施与合同 + +- 提供隔离租户或专用测试组织,包含:管理员测试身份、两个无业务含义测试成员、一个可删除考勤组、一个可删除班次、一个可恢复假期类型、可控排班与打卡结果。 +- 只授予完成相应接口所需的最小 scopes;提供 capability discovery,区分权限不足、功能未开通和资源不存在。 +- 写接口返回稳定资源 ID、逐项结果、幂等/commit-unknown 语义;所有更新支持精确读回。 +- 为不可删除的企业设置提供 snapshot/restore 或专用 reset API;余额和 BOSS 改签必须能恢复原值。 +- Fixture 有 TTL、Owner 和自动清理告警;日志只保留受控 evidence handle,不输出业务内容或身份值。 + +#### C. 验收标准与临时处置 + +1. 考勤组搜索有已知非空和保证零命中;详情绑定同一 ID。 +2. create→get→update→restore/delete 覆盖班次、考勤组与排班。 +3. 成员、余额和打卡结果写入均有 before/after 精确读回并恢复原值。 +4. 未确认时远程写调用为 0;任一 partial/commit-unknown 非零退出。 +5. 测试结束远端和本地均零残留。 + +在完整 fixture 到位前,相关 Shortcut 保持 hidden/unavailable。 + +### `DS-ATTENDANCE-005` — 为 6 个读场景提供请求绑定与双态 fixture + +#### A. 用户任务与现状 + +- `+get-summary`:真实响应只含统计项,不回显请求 user、period 或 statsType,上游无法证明返回属于哪个请求。 +- `+list-leave-types`:当前安全租户只有已知非空列表,而命令无筛选参数;不能用越界分页或错误请求伪造合法空结果。 +- `+get-leave-records`、`+get-checkin-record`:当前只取得合法空结果,缺少已知非空流水 fixture,无法排除响应投影或请求绑定错误。 +- `+my-attendance`、`+this-month`:上游已严格验证当前用户 profile 与每条打卡 ID,但当前期间仅有合法空数组,缺少同一身份下的已知非空 fixture。 +- 安全证据句柄:`ATT-READ-FIXTURE-GAP-01`;不保存 raw body、用户 ID 或打卡时间。 + +#### B. 需要下游提供的合同与 fixture + +- 摘要响应回显稳定 userId、统计周期起止和 statsType,或返回可校验的请求摘要;任一字段不一致必须 typed failure。 +- 提供隔离的「无假期类型」测试租户,以显式 `success=true + result=[]` 证明 `+list-leave-types` 的合法空语义。 +- 提供可创建、读取并清理的假期变更流水、签到流水和打卡流水;每项都必须包含稳定 ID、请求用户和时间范围回显。 +- 为 nonempty 与 guaranteed-zero 提供独立 fixture;未知用户、无权限、未开通和合法空集合必须可区分,不得都返回裸 `null` 或无标识空数组。 + +#### C. 验收标准与临时处置 + +1. 每个集合叶子都用 exact Shortcut 和 owning atomic/raw 在同一参数下各证明一次已知非空和一次合法保证零命中。 +2. 非空项的稳定 ID、用户和时间绑定在两层结果中一致;空结果仍有显式业务 success 和正确集合容器。 +3. malformed/null/success=false/错身份/超范围均非零失败,且不会继续调用后续考勤接口。 + +在上述证据完整前,6 个 Shortcut 均保持 Agent-unavailable,并仅为历史 argv/Help 保留 CLI 兼容可见性;已实现的严格校验不等于已获得发布证据。 + +### `DS-ATTENDANCE-006` — 让班次详情回显可验证的稳定身份 + +#### A. 用户任务与现状 + +- 用户任务:先用 `+search-class` 浏览班次并取得稳定 `classId`,再用同一 ID 读取班次详情。 +- canonical Shortcut:`+search-class`、`+get-class`;atomic/raw route:`attendance class search`、`attendance class get`。 +- 在 clean discovery HEAD 上,搜索 exact/raw 均返回同一组非空正整数 `classId`;使用其中真实 ID 调用 raw detail,服务端返回 `success=true` 和非空 `shiftVO`,但对象没有 `id` 或 `classId`。 +- 上游不能把请求 ID 注入响应来伪造 readback,也不能仅凭“非空详情”证明详情属于请求资源。因此 `+get-class` 保持 unavailable。 +- 安全证据句柄:`ATT-CLASS-ID-ECHO-GAP-01`;不保存 raw body、资源 ID 或 trace。 + +#### B. 需要下游提供的合同 + +- `get_class_detail` 成功对象必须回显与请求精确一致的稳定 `id`/`classId`,类型与 `get_class_list` 列表身份字段一致。 +- 存在、已删除、不存在、无权限和租户未开通必须返回可区分的 typed terminal 状态;不得以非空但无身份对象表示可验证成功。 +- 明确班次 ID 的租户作用域、生命周期和搜索→详情一致性;如详情存在版本号,也应返回稳定版本字段以支持更新前读回。 +- 改动需 additive/versioned;现有详情业务字段保持兼容。 + +#### C. 验收标准与临时处置 + +1. exact/raw 搜索得到同一非空 `classId`,同 ID detail 均返回身份精确匹配的非空对象。 +2. 不存在、已删除和无权限分别非零 typed failure,不能成为 `success=true + result=null` 或无身份对象。 +3. 上游 `+get-class` 的 missing/false/null/malformed/wrong-ID 回归与真实 E2E 全部通过。 + +下游补齐稳定 ID 回显前,`+get-class` 保持 hidden/unavailable;`+search-class` 仍可独立公开。 + +### `DS-ATTENDANCE-007` — 提供个人设置逐场景 capability 与权限安全 fixture + +#### A. 用户任务与现状 + +- `+get-self-setting` 公开参数包含 6 个场景。clean discovery HEAD 上,前 5 个场景的 exact/raw 均能精确绑定请求 userId、场景字段和已观测类型;`bossAttendStatNotify` 在两层均返回稳定业务错误 `NO_PERMISSION`。 +- 当前接口没有 capability discovery 告知调用身份可读哪些场景,也没有可安全授权的隔离 fixture。只验证 5/6 不能宣称整个公开枚举可用。 +- 这不是把权限错误误判为业务空结果;exact/raw 均非零退出。上游保留严格 user/scene/type 校验,但发布面整体降级。 +- 安全证据句柄:`ATT-SELF-SETTING-PERMISSION-GAP-01`。 + +#### B. 需要下游提供的合同与 fixture + +- 提供 capability discovery,返回当前调用身份逐场景的 readable/forbidden/unsupported 状态、所需最小 scope/角色和租户功能开通状态。 +- 为 6 个场景提供字段名、类型、可空性和版本化语义;成功必须回显请求 userId,并明确返回对应场景字段。 +- 提供隔离测试身份或可撤销的临时最小权限授权 fixture,使 6 个场景均能完成 exact/raw 同场景验证;测试后权限必须回收。 +- 无权限、场景不支持、用户不存在和设置未配置必须返回不同 typed error;不得统一为 `null`、空对象或无标识空成功。 + +#### C. 验收标准与临时处置 + +1. capability discovery 与 6 个场景实际调用一致,不遗漏权限前置。 +2. 每个场景 exact/raw 的 userId、场景字段、类型和对象内容一致;`null`、错类型、错用户均非零。 +3. bogus user、invalid scene、无权限和未开通均返回可区分非零错误。 +4. 权限 fixture 全程最小化、可撤销,结束后无授权残留。 + +能力发现和安全 fixture 到位前,`+get-self-setting` 保持 Agent-unavailable;旧 CLI 入口仅保留兼容可见性。 + +### `DS-ATTENDANCE-008` — 让排班查询返回可判定的成功集合或业务错误 + +#### A. 用户任务与现状 + +- 用户任务:按员工和日期范围读取逐日排班,用稳定排班 ID 继续执行只读分析或受控的 BOSS 改签。 +- canonical Shortcut:`attendance +get-schedule`;owning raw route:`attendance-wukong/getScheduleByRange`。 +- 两次独立 clean HEAD 的真实验证中,已知历史非空区间与保证零命中的未来区间都得到同一结果:owning raw 进程退出 0,但响应为 literal `null`;Exact Shortcut 均以 `response_validation/empty_tool_response` 非零拒绝。 +- 这既不能证明排班非空,也不能证明合法为空。上游严格校验已避免把 `null` 投影成 `[]`,但在下游提供可判定合同前无法公开该能力。 +- 安全证据句柄:`ATT-SCHEDULE-NULL-01`;仓库不保存用户、日期、排班 ID、raw body 或 trace。 + +#### B. 需要下游提供的合同 + +- 成功查询必须返回显式排班数组;每项包含稳定非空排班 ID、请求用户身份、业务日期、班次身份和是否休息等字段。 +- 合法零结果必须返回 `success=true + result=[]`(或等价的已审核显式集合),不得以裸 `null`、缺字段或空 body 表示。 +- 无权限、用户不存在、租户未开通、日期范围非法和服务异常必须返回可区分的 typed nonzero error;不得继续用进程退出 0 掩盖业务失败。 +- 如服务存在分页,必须提供页大小、前进 token/页号、hasMore/total 和明确终止证据;同一请求的 item identity 不得跨页重复。 + +#### C. 验收标准与临时处置 + +1. 已知非空 fixture 的 raw 与 exact 均返回同一显式数组,稳定 ID 集合、用户和日期绑定一致。 +2. 保证零命中 fixture 的 raw 与 exact 均返回显式空数组,并有明确终止证据。 +3. `null`、缺集合、错型 item、重复/空 ID、错用户和越界日期全部非零;错误 reason 可稳定区分。 +4. 新 clean HEAD 完成 nonempty/zero 双层 E2E,仓库和远端均无测试残留。 + +下游修复前,`+get-schedule` 保持 `public=false/unavailable`、legacy 输出且不发布 Result/Pagination;旧 CLI/Help/full Schema 仅为历史兼容继续可发现,不代表 Agent 可用。 + +## 4. Lark 对齐与平台差异 + +| Lark 用户任务 | 所需下游能力 | 可精确对齐 | 平台差异 | DWS 推荐结论 | +|---|---|---|---|---| +| `attendance user_tasks query` 查询打卡结果 | 现有 `query_check_result`;最好补分页终止证据 | yes,分页完整性 partial | Lark 当前没有同级的排班、规则、报表和企业设置任务 | 保留 `+check-result` 为主对齐入口,报告分页边界 | + +无法对齐的不是 DWS 缺入口,而是部分钉钉管理面缺少可验证下游合同或安全 fixture;不能为追求同名率伪造成功。 + +## 5. 超越 Lark 的产品机会 + +| 产品原生能力 | 所需下游支持 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 | +|---|---|---|---|---| +| 异常考勤处置队列 | 稳定异常记录 ID、原因、关联审批、处理状态、分页和可恢复更正 | `attendance +exceptions` / `+resolve-exception` | 读写分离;更正确认;写后同 ID 终态读回;可恢复 | P2 | +| 跨员工考勤汇总 | 可按组织/成员批量聚合迟到、缺卡、加班、请假并给出统计口径版本 | `attendance +team-summary` | 最小权限、聚合脱敏、口径版本、分页完整性 | P2 | +| 规则影响预览 | 更新班次/考勤组/假期前返回受影响成员与日期范围,不提交写入 | `attendance +rule-impact-preview` | 只读、稳定影响计数、无副作用、与最终写请求同参数语义 | P1 | + +## 6. 无需下游变更的上游修复 + +| Shortcut | 上游根因 | 已完成修复 | 回归证据 | +|---|---|---|---| +| 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 单元负向矩阵与最终 clean runtime tree 的 8 个公开入口真实 nonempty/zero、详情或模板 exact/raw 双层复核均完成 | +| `+check-record` | 初版误用业务归属日 `workDate` 校验按 `checkDateFrom/checkDateTo` 发起的实际打卡查询,导致跨午夜下班卡被静默丢弃 | 改用 `userCheckTime` 严格绑定请求日期范围;`workDate` 只作为班次归属日原样保留。完整 raw 集合仍必须先通过显式 collection、全量正整数唯一 ID、请求用户和实际打卡时间校验;任何实际时间越界都整次 fail-closed,不再静默过滤 | 最终 live 复核 exact/raw 均为 157 条且完整对象一致;旧轮 `workDate=start-24h`、`userCheckTime` 在范围内的跨午夜 OffDuty 记录明确保留;fresh zero 双层显式空,不由过滤制造 | +| `+check-result`, `+list-approve` | 初版把裸日期 `--end` 解析为当天 00:00,可能拒绝结束日白天的结果;旧 end-of-day 语义还会漏最后 999ms | 裸日期结束边界改为本地下一日 00:00 前 1ms;显式 datetime 保持精确值;结束日中午与最后 1ms 可接受,下一日 00:00 非零拒绝 | Execute 回归覆盖结束日中午/最后毫秒/下一日并锁定 reason;最终 live 的 `+check-result` 有真实 end-date item,`+list-approve` end-date 单日 probe exact/raw 一致 | +| `+get-approve-template` | 把请求维度 `approveType` 误作集合唯一身份,会拒绝同一类型下多个合法模板 | 改用非空唯一 `processCode` 作为资源身份;`approveType` 仅做请求精确绑定;每项 `submitUrl` 必须非空;允许 TRAVEL/OUT 同类型多项 | missing/wrong/duplicate processCode、wrong approveType、missing/blank submitUrl 负向矩阵;clean HEAD 上 5 个类型 exact/raw 全通过,TRAVEL/OUT 双项集合一致 | +| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求正整数且不重复的稳定 ID,严格校验分页矛盾与无前进页 | 坏 item/空 ID/重复 ID/分页矛盾单元回归通过;clean HEAD 上 nonempty/guaranteed-zero 与 raw 对照通过,班次/加班规则另完成实际多页前进与终止 | +| `+get-overtime-rule` | 能力存在但缺少请求 ID 与响应对象的强绑定 | 详情对象要求非空且 `id` 与请求精确一致 | missing/false/null/malformed/wrong-ID/valid Execute 级矩阵;clean HEAD 上 exact/raw 同真实搜索 ID 对象一致,raw 对不存在 ID 返回错对象时 exact 非零拒绝 | +| `+get-class` | 上游已严格要求 `shiftVO.id`,但真实下游详情不回显任何 ID | 没有注入请求 ID 或放宽校验;按真实合同降级 unavailable | discovery HEAD 上真实搜索→raw detail 非空但 ID 缺失;等待 `DS-ATTENDANCE-006`,修复后再重跑 | +| `+get-self-setting` | 仅检查场景 key 存在会让 `null` 伪成功;用户外围空白可造成下传/比较漂移 | 用户输入只归一化一次并以同值下传/比较;场景字段必须非空且符合已观测 object/boolean/integer 类型;因 1/6 场景权限不可验证而整体 unavailable | 5 个 scene exact/raw 对照通过;boss scene exact/raw 均 `NO_PERMISSION`,等待 `DS-ATTENDANCE-007`,不把部分场景成功当整体 PASS | +| `+my-attendance`, `+this-month` | 旧的当前用户解析可跳过 malformed row,也可把 success=false 中的 stale result 当身份 | 改为严格 business success/result/唯一用户身份,坏 profile 后考勤 raw 调用为 0;每条打卡要求唯一正整数 ID | 静态/Execute 回归已通过;因当前只有合法空集合而保持 unavailable,不记 live PASS | + +### 6.1 clean-HEAD live 发布门状态 + +| 叶子 | clean executable HEAD 双层证据 | 发布状态 | +|---|---|---| +| `+check-result` | exact/raw known-nonempty 以 20/20/8 三页前进并终止;48 个 ID、用户绑定与逐页对象一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 | +| `+check-record` | exact/raw 均 157 条且完整对象、稳定 ID 集合一致;跨午夜 `workDate=start-24h`、`userCheckTime` 在范围内的记录已保留;fresh zero 两层均为显式空 | `PASS`;最终 SHA 见 PR 证据 | +| `+list-approve` | exact/raw known-nonempty 为 7 条,稳定 ID、用户、类型、日期范围及完整数组一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 | +| `+get-schedule` | 两次独立 clean HEAD 的 known-nonempty 与 guaranteed-zero 均为 raw `exit 0 + literal null`,Exact Shortcut 均非零 `empty_tool_response`;没有把未知结果投影成空数组 | unavailable;等待 `DS-ATTENDANCE-008`,旧 CLI 仅兼容可见 | +| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | exact/raw known-nonempty 与随机唯一词 guaranteed-zero 通过;稳定 ID 集合与分页终止一致,班次为 5/5/3 三页,加班规则为 1/1/1 三页 | `PASS`;最终 SHA 见 PR 证据 | +| `+get-overtime-rule` | 使用本轮真实搜索取得的 ID,exact 与 raw 单项对象一致;不存在 ID 的 raw 返回错 ID 对象时 exact 非零拒绝 | `PASS`;最终 SHA 见 PR 证据 | +| `+get-approve-template` | 5 个 approveType 全部 exact/raw 通过,数量 1/1/1/2/2;TRAVEL/OUT 多项 `processCode` 非空唯一且集合一致,类型绑定和提交入口有效 | `PASS`;最终 SHA 见 PR 证据 | +| `+get-class` | raw 非空但不回显请求 ID | unavailable;等待下游合同,不以旧调用记 PASS | +| `+get-self-setting` | 5 个场景通过,1 个场景 `NO_PERMISSION` | unavailable;等待 capability/权限 fixture,不以部分结果记 PASS | + +pre-rebase discovery 轮次的多页加班规则 raw 验证曾一次返回字面量 `null` 且进程退出 0;该次结果没有计为 PASS,重试后才完成同场景双层分页核对。这是 owning atomic/raw 的下游/renderer 终态合同风险:atomic 不应把 transport/null 失败表示为零退出。Shortcut 自身对 `null` 仍严格非零,不会把它投影为空集合;后续最终轮次未再出现该 transient。 + +上述 8 个公开入口均在最终 clean runtime tree 从零重跑,未继承 discovery PASS;最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。`+get-schedule` 的四次 raw `null` 与 Exact 非零结果作为降级证据保留,不计入公开通过数。 + +## 7. 安全与脱敏声明 + +- 本文不含真实用户、组织、租户、profile、规则、排班、考勤组或打卡记录 ID。 +- 本文不含 trace/request ID、token、签名 URL、邮箱、电话、业务标题正文或真实日程内容。 +- Raw 响应仅在仓库外临时目录中处理并已删除;本文只保留不可反查的证据句柄和聚合事实。 +- 进入 Git 前必须扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。 diff --git a/docs/shortcut-downstream-requirements/mail.md b/docs/shortcut-downstream-requirements/mail.md new file mode 100644 index 00000000..a9fede56 --- /dev/null +++ b/docs/shortcut-downstream-requirements/mail.md @@ -0,0 +1,198 @@ +# Mail Shortcut 下游业务能力需求规格 + +> 日期:2026-08-18 +> Rebased executable 基线:`3fc3be37c67d14f60273a702a7a6b38f6ba32d4c`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中 +> 对比基线:lark-cli 1.0.87 +> 范围:Shortcut only;不改 `skills/multi` 或 `skills/mono` 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。 +> 发布属性:仓库安全版本;不包含真实邮箱、人员、组织、邮件内容、资源 ID 或请求标识。 + +## 1. 执行摘要 + +本轮对 18 个 Mail Shortcut 完成严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result 收口。8 个公开只读入口已在相同 runtime tree 逐条完成 Shortcut 与原子层的真实数据双层复核;`+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 因缺少可控 guaranteed-zero fixture 保持 Agent-unavailable,但为守住既有 argv/Help 合同继续以 compatibility-visible 形式留在 CLI;4 个草稿/模板写入口因无法证明清理终态同样不进入公开 Catalog。 + +上述 6 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;其 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 `folderId`、`size` 等 MCP 字段继续由 Execute 显式适配,不能在未经过版本化迁移时改写已发布 Schema property。 + +仍不能诚实对齐的任务集中在草稿/模板清理终态、发送终态、回复/转发草稿语义、批量修改/删除逐项结果、回执、签名、事件监听、模板附件事务和联系人创建身份回执。它们不是再包一层 Shortcut 就能解决,需要下游业务接口或安全测试 fixture 补足可验证合同。 + +| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 下游 Owner | 解锁的 Shortcut | +|---|---|---|---|---|---|---| +| `DS-Mail-001` | P0 | contract insufficient | 发信/发送草稿并确认最终投递 | partial | Mail service / adapter | `+send`、`+draft-send` | +| `DS-Mail-002` | P0 | missing capability | 回复、回复全部、转发默认保存草稿 | partial | Mail service | `+reply`、`+reply-all`、`+forward` | +| `DS-Mail-003` | P0 | contract insufficient | 批量修改、移动、软删除邮件 | partial | Mail service / adapter | `+message-modify`、`+message-trash` | +| `DS-Mail-004` | P1 | missing capability | 处理已读回执与邮箱签名 | unavailable | Mail service | `+send-receipt`、`+decline-receipt`、`+signature` | +| `DS-Mail-005` | P1 | missing capability | 持续监听新邮件 | unavailable | Event + Mail service | `+watch` | +| `DS-Mail-006` | P1 | contract insufficient | 带附件/内联图片的模板创建更新 | partial | Mail + Drive adapters | 完整 `+template-create/update` | +| `DS-Mail-007` | P1 | adapter defect | 创建联系人并取得稳定身份 | blocked | Mail adapter | `+contact-create/update/delete` | +| `DS-Mail-008` | P1 | adapter defect | 一致的成功、空结果与分页合同 | partial | Mail adapter | 全部 list/search Shortcut | +| `DS-Mail-009` | P1 | tenant-or-fixture | 安全验证发送、回执、分享和监听 | blocked | Product QA / tenant admin | 全部高影响 Mail Shortcut | +| `DS-Mail-010` | P0 | contract insufficient | 草稿/模板可证明的清理终态 | blocked | Mail service / adapter | `+draft-create/edit`、`+template-create/update` | + +## 2. 用户任务与能力缺口总览 + +| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 | +|---|---|---|---|---|---| +| 浏览/筛选摘要 | `+triage`、`+search-mail` | `+triage` | covered | 无 | 公开,严格分页 | +| 固定未读/近期列表 | `+unread-mail`、`+recent-mail` | Lark 对应任务入口 | blocked | 固定查询/文件夹缺可控 guaranteed-zero fixture | 保持 unavailable | +| 读取一封、多封、会话 | `+message`、`+messages`、`+thread` | 同名入口 | covered | 无 | 公开,精确 ID 读回 | +| 新建/编辑草稿 | `+draft-create`、`+draft-edit` | 同名入口 | blocked | 两次 batch-delete 后同 ID 仍可读,无法证明零残留 | 保持 unavailable | +| 创建/更新基础模板 | `+template-create`、`+template-update` | 同名入口 | blocked | delete 后 get 没有 typed nonfound;from/isDraft 也不可读回 | 保持 unavailable | +| 发送新邮件/已有草稿 | 无公开 Shortcut;存在 raw send | `+send`、`+draft-send` | partial | 终态、逐项结果、幂等不足 | 保持 raw,不宣称对齐 | +| 回复/回复全部/转发 | 无公开 Shortcut;raw 路径会立即发送 | `+reply`、`+reply-all`、`+forward` | partial | 缺少默认草稿与邮件头保真合同 | 保持 raw,不宣称对齐 | +| 修改/删除邮件 | 无公开 Shortcut;存在 raw batch route | `+message-modify`、`+message-trash` | partial | 无逐项 ledger 和严格终态 | 保持 raw,不宣称对齐 | +| 发送/拒绝已读回执 | 无 | `+send-receipt`、`+decline-receipt` | unavailable | 专用业务接口与标签合同缺失 | 明确不可用 | +| 邮箱签名 | 无 | `+signature` | unavailable | 签名读取接口缺失 | 明确不可用 | +| 分享邮件到聊天 | raw 高风险入口 | `+share-to-chat` | partial | 缺安全 fixture、逐目标结果与读回 | 不公开 Shortcut | +| HTML lint | 无 | `+lint-html` | unavailable | 缺统一邮件 HTML 规则包 | 下游或本地规则能力需求 | +| 监听新邮件 | 无公开 Mail Shortcut | `+watch` | unavailable | 订阅生命周期和安全事件合同不足 | 不公开 Shortcut | +| 文件夹/标签/联系人/企业邮箱用户 | `+folder-list`、`+user-search`、`+find-mail-user` 公开;其余列表不公开 | 无同名任务入口 | partial DWS extra | 标签/模板/联系人/会话列表缺安全双态 fixture | 无双态证据的入口保持 unavailable | + +## 3. 下游需求明细 + +### `DS-Mail-001` — 可验证的发送生命周期 + +- 用户任务:发送新邮件或一个/多个草稿,并知道每一封最终是成功、失败、部分成功还是状态未知。 +- 当前证据:raw 发送可返回业务 success 或发送标识,但不能统一证明最终投递;批量草稿发送没有逐项 ledger、请求顺序、未知提交和安全重试合同。 +- 所需接口合同: + - 创建/发送必须返回稳定 `messageId` 与 `internetMessageId`,并明确 `accepted/pending/sent/partial_failure/failure/unknown`。 + - 提供按同一身份查询发送状态的接口;状态必须绑定请求邮件与收件人集合。 + - 批量发送返回逐项结果,任何一项失败时整体不得退出 0 冒充全成功。 + - 支持幂等键,或明确 unknown commit 不可自动重试。 + - 失败错误区分参数、权限、风控、限流、收件人拒收和提交未知。 +- 验收:安全自发自收 fixture 完成 draft-create → exact get → send → 状态终态 → sent-folder exact read;批量中注入一项失败,验证 ledger 与非零整体结果;清理无测试草稿残留。 + +### `DS-Mail-002` — 回复/转发的草稿优先与 MIME 保真 + +- 用户任务:回复、回复全部或转发一封邮件,默认保存草稿,只有再次确认才发送。 +- 当前证据:DWS raw route 会创建回复/转发草稿后立即发送,无法对齐 Lark 的默认草稿语义;上游也无法证明 `In-Reply-To`、`References`、原始引用块和收件人集合正确。 +- 所需接口合同: + - 独立 `create_reply_draft`、`create_reply_all_draft`、`create_forward_draft`,返回稳定草稿 ID,不隐式发送。 + - 服务端生成并可读回线程关系头、回复全部去重后的 To/CC、转发引用块和附件继承结果。 + - 发送必须复用 `DS-Mail-001` 的确认、终态和幂等合同。 +- 验收:用隔离自发邮件分别创建三类草稿,精确 ID 读回核对父邮件、参与人集合和引用语义;未确认时远程发送调用为 0;确认发送后状态终态可验证。 + +### `DS-Mail-003` — 邮件修改、移动和删除的逐项终态 + +- 用户任务:批量标记已读/未读、增删标签、移动文件夹、软删除邮件。 +- 当前证据:raw batch route 多数只给聚合 success;删除后邮件仍可能可读,无法区分“移入已删除文件夹”“永久删除”“延迟可见”或“未生效”。 +- 所需接口合同: + - 每个输入 messageId 返回 `applied/already_applied/failed/unknown` 与稳定原因码。 + - 修改/移动后详情或摘要必须可读回 `isRead/tags/folderId`;删除返回明确 tombstone 或 folder transition。 + - 软删除和永久删除使用不同操作,危险级别与确认要求可声明。 + - 任何部分失败整体 outcome 为 `partial_failure` 且进程非零。 +- 验收:创建隔离邮件,执行 mark-unread/read、标签增删、移动与软删除,每步同 ID 读回;错误 ID 与合法 ID 混合时逐项 ledger 完整且整体非零。 + +### `DS-Mail-004` — 已读回执与签名 + +- 用户任务:识别邮件是否请求回执;确认后发送标准回执,或拒绝并清除提示;列出和查看默认签名。 +- 当前证据:现有 Mail 接口没有稳定暴露回执请求标签、专用发送/拒绝操作或签名读取资源,上游无法安全组合普通回复替代。 +- 所需接口合同: + - 消息详情公开稳定回执请求状态和请求者身份类型。 + - 专用 send/decline receipt 操作,幂等且返回状态;正文由服务端生成,不能让上游伪造。 + - 签名列表/详情返回稳定 ID、默认发送场景、HTML/文本内容和敏感字段标注。 +- 验收:预置请求回执邮件,未确认零写调用;发送/拒绝后状态读回且重复调用幂等;签名已知非空与合法空均可证明。 + +### `DS-Mail-005` — 新邮件监听的订阅生命周期 + +- 用户任务:在限定时间内监听新邮件,得到稳定、可恢复、可去重的事件流。 +- 当前证据:通用事件基础设施不能证明 Mail scope、订阅状态、ready marker、断线续传和消息读取权限形成完整任务链。 +- 所需接口合同:订阅/查询/退订;明确 user/bot 身份、scope 和租户开关;ready marker;事件 `eventId/messageId/mailbox/time`;断线 cursor、去重和界限参数;心跳不冒充业务事件。 +- 验收:隔离邮箱订阅后注入一封测试邮件,只收到一次并能以 messageId 精确读取;超时、权限缺失、断线重连和退订后零事件均有确定结果。 + +### `DS-Mail-006` — 模板附件与内联图片事务 + +- 用户任务:创建或更新含普通附件、内联图片和 HTML 的模板,同时保留未修改 MIME 结构。 +- 当前证据:本轮只对齐名称、主题、正文核心字段;现有多步上传缺少模板级事务、附件稳定 ID、失败回滚和更新时的结构保真证明。 +- 所需接口合同:创建/更新草稿会话、附件上传会话、content-id 映射、提交/取消;返回逐附件 ledger;更新提供版本或 etag,避免 last-write-wins 覆盖;失败可回滚且无孤儿文件。 +- 验收:普通附件和内联图片各一,创建后按模板 ID 读取附件 ID/名称/大小/content-id;更新正文不丢附件;中途失败自动取消并证明零孤儿资源。 + +### `DS-Mail-007` — 联系人写操作的稳定身份 + +- 用户任务:创建、更新、删除个人邮件联系人并验证精确对象。 +- 当前证据:真实 create 返回 `success=true` 但没有 contactId;上游只能用随机显示名再扫列表定位,无法用于一般用户输入,因为名称/邮箱可能重复。 +- 所需接口合同:create 返回稳定 contactId;get-by-id;update/delete 返回同 ID 与版本;列表支持 exact email 或 ID filter;重复联系人规则明确。 +- 验收:创建回执直接得到 ID,get-by-id 精确核对,更新同 ID,删除后 not-found/tombstone;重复邮箱和同名联系人有稳定结果而非猜测。 + +### `DS-Mail-008` — 统一成功、空结果与分页协议 + +- 用户任务:可靠地区分“确实没有结果”“还有下一页”“服务异常或响应漂移”。 +- 当前证据:同一产品的 success 同时出现布尔和字符串;hasMore 也出现两种编码;搜索终页用 `$`,部分列表用空串;零命中邮件会返回 `total=0` 加一个只有空收件人字段的占位对象。当前租户又没有空邮箱或空邮件文件夹,不能为无筛选列表证明 guaranteed-zero。 +- 所需接口合同: + - success 与 hasMore 统一为布尔;所有列表显式数组,合法空只返回 `[]`。 + - 统一 `nextCursor` 与 `endpointExhausted`;终页不使用业务哨兵对象或魔法值。 + - 每项稳定 ID 必填;total 使用整数;服务错误必须 `success=false` 和稳定错误码。 + - 保留兼容期,但提供 capability/version 让上游安全切换。 +- 验收:每个列表/搜索执行已知非空、保证零命中、坏 item、缺集合、错型、hasMore 无游标、重复游标;只有显式合法空成功。 + +### `DS-Mail-009` — 安全租户与真实 E2E fixture + +- 用户任务:在不触达真实业务收件人和内容的前提下验证所有高影响 Mail Shortcut。 +- 所需 fixture:隔离自发自收邮箱、可控第二收件人、回执请求邮件、可分享的测试聊天、安全事件订阅、测试签名、可回收附件;所有资源用随机无业务含义标记并有自动清理。 +- 权限:最小 Mail read/write/event、Drive attachment、IM share scopes 分离;可测试 user/bot 差异和缺权限错误。 +- 验收:stdout 只输出 PASS 标签与聚合计数;原始 JSON 只在临时目录;finally 清理;远端零测试草稿/模板/联系人/邮件/订阅残留;仓库和历史扫描无身份数据。 + +### `DS-Mail-010` — 草稿/模板可证明的清理终态 + +- 用户任务:用可回收 fixture 验证草稿与模板写 Shortcut,不留下无法确认的远端测试对象。 +- 当前证据:草稿创建/更新回执和 exact-ID 读回成功,但同一 ID 连续两次 batch-delete 后仍可读;模板 delete 返回成功后,get 仅为未分类失败,既非 typed nonfound 也不能证明 tombstone。 +- 所需接口合同:分离软删除与永久删除;返回稳定 ID、终态和幂等证据;get-by-id 对已永久删除对象返回稳定 `not_found/deleted` 错误或已审核 tombstone,不得空 body、通用失败或继续返回对象。 +- 验收:create/update → exact-ID readback → permanent delete → exact Shortcut + raw get 双层 typed absence;有界轮询后仍可读或终态未知时整体非零,且不得发布 Shortcut。 +- 临时处置:四个写 Shortcut 保持 `public=false` / `unavailable`,直到安全 fixture 与 typed absence 同时可证明。 + +## 4. Lark 对齐与平台差异 + +| Lark 用户任务 | 可精确对齐 | 平台差异 | DWS 推荐结论 | +|---|---|---|---| +| `+message` / `+messages` / `+thread` / `+triage` | yes | DWS 额外自动解析邮箱和收件箱,并严格发布完整性 | 已公开 | +| `+draft-create` / `+draft-edit` | blocked | 核心写回可证,但删除后同 ID 仍可读,无安全清理终态 | 不公开,保持 unavailable | +| `+template-create` / `+template-update` | blocked | 核心字段可读回,但 from/isDraft 不可验且删除后缺 typed nonfound | 不公开,保持 unavailable | +| `+send` / `+draft-send` | no | DWS raw 偏立即发送且缺统一终态/逐项 ledger | 暂不公开 Shortcut | +| `+reply` / `+reply-all` / `+forward` | no | DWS raw 会立即发送,Lark 默认保存草稿 | 暂不公开 Shortcut | +| `+message-modify` / `+message-trash` | no | 聚合 success 不足以证明逐项终态 | 暂不公开 Shortcut | +| `+send-receipt` / `+decline-receipt` | no | 缺专用接口和可验证标签 | platform unavailable | +| `+signature` | no | 缺签名读取资源 | platform unavailable | +| `+watch` | no | 缺完整订阅生命周期与安全 fixture | fixture + capability blocked | +| `+share-to-chat` | partial | raw 可调用但缺逐目标验证和安全 fixture | 保持 raw | +| `+lint-html` | no | DWS 未提供统一规则包 | downstream/local capability needed | + +## 5. 超越 Lark 的产品机会 + +| 产品原生能力 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 | +|---|---|---|---| +| 文件夹、标签与联系人目录 | `+organize`:规则化移动、标记与标签组合 | 逐项 ledger、写后读回、补偿恢复 | P1 | +| 收信规则、白名单、黑名单、自动回复 | `+inbox-policy-audit` | 只读汇总优先;写操作强确认和版本化 | P2 | +| 邮箱日历 | `+mail-calendar-conflicts` | 与主 Calendar 的 ownership boundary 明确,禁止双写 | P2 | +| 发送状态与召回 | `+delivery-audit` | 终态、收件人粒度、召回结果和不可逆提示 | P1 | +| 附件导出与分享 | `+archive-message` | 精确 messageId、原子本地写入、敏感路径与清理 | P2 | + +## 6. 无需下游变更的上游修复 + +| Shortcut | 上游根因 | 已完成修复 | 回归证据 | +|---|---|---|---| +| 全部 list/search | 容忍式探测任意 result/data/list/items,坏元素静默丢弃 | 固定已观测路径、严格 success/数组/item/ID;无双态 fixture 的 leaf 不发布 | deterministic 响应矩阵;live 证据逐 leaf 记录,不作泛化 | +| `+search-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | 各完成 known-nonempty 20;3 个 fresh 零命中 raw 均为 `total=0` + 无稳定 ID/正文且收件字段全空的 reviewed sentinel + terminal cursor,exact 才归一化为显式 `[]`;不把该下游特例描述成 raw 空数组 | +| `+search-mail` / `+triage` 自动邮箱解析 | 严格化时只接受顶层对象数组,会拒绝历史已观测的字符串数组和 `result/data.emailAccounts` 包装 | 仅接受三个审核路径 `emailAccounts` / `result.emailAccounts` / `data.emailAccounts`,每项可为非空邮箱字符串或含非空 `email` 的对象;缺集合、错型、坏项或多路径冲突全部 fail-closed;空发件人也不再投影为空字符串成功 | top/result/data × string/object、blank/wrong/multiple-path 与 sender missing/null/wrong-type 回归覆盖;最终 live 未传 `--email` 执行 `+search-mail`/`+triage`,owning 响应为顶层 object-item 形态并成功解析 | +| `+unread-mail` / `+recent-mail` / `+thread-list` | 固定条件或文件夹不能保证零命中 | 严格响应代码已完成,但没有空邮箱/空文件夹证据时关闭发布 | BLOCKED fixture;不得修改真实邮件状态造空 | +| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | 各完成 known-nonempty 20 + fresh raw 显式空;stable identity set 与 raw pagination/meta 精确一致;`+user-search` 同轮实跑历史 string `--limit` | +| `+tag-list` / `+template-list` / `+contact-list` | 无 query 的列表容易把末页/删除后列表误作合法空 | 严格响应代码已完成;无专用空邮箱和 typed cleanup 时关闭发布 | BLOCKED fixture;不把临时资源从列表消失记为零态 PASS | +| `+message(s)` / `+thread` | 缺任务层完整读取和身份绑定 | 自动邮箱解析、精确请求 ID 读回、保序多读 | `+message`/`+thread` 与同稳定 ID raw 完整对象一致;`+messages` 用两个不同 ID 验证输入顺序与逐对象一致 | +| 草稿/模板写 | 仅写回执会产生假成功 | 稳定 ID + exact get + 请求字段核对;清理无法证明时保持 unavailable | deterministic 回执/读回矩阵 PASS;live cleanup BLOCKED | + +### 6.1 clean executable HEAD 双层证据 + +| 公开入口 | exact Shortcut + owning raw 证据 | 状态 | +|---|---|---| +| `+search-mail`, `+triage` | 各 20 条 known-nonempty;3 个独立 fresh 零命中由 raw `total=0`、无稳定 ID/正文的单 sentinel 与 terminal cursor 共同证明,exact 严格归一化为显式空;稳定 message ID 集合和分页状态一致 | `PASS_WITH_REVIEWED_ZERO_ENCODING`;最终 SHA 见 PR 证据 | +| `+user-search`, `+find-mail-user` | 各 20 条 known-nonempty 与 raw 显式 fresh zero;条件身份集合和分页状态一致 | `PASS`;最终 SHA 见 PR 证据 | +| `+folder-list` | 顶层 5 条 nonempty;本轮先由 raw 验证同一父文件夹确实为空,再由 Shortcut 返回显式空;ID 集合一致 | `PASS`;最终 SHA 见 PR 证据 | +| `+message`, `+messages`, `+thread` | 单邮件/会话同稳定 ID 完整对象一致;批量用两个不同 ID 验证请求顺序和逐对象一致 | `PASS`;最终 SHA 见 PR 证据 | + +8 个公开入口均在最终 clean runtime tree 从零重跑;其中 6 个使用标准 raw 显式空或精确对象证据,2 个邮件搜索使用上述审核过的下游零命中 sentinel 编码。最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。 + +## 7. 安全与脱敏声明 + +- 本文不含用户、组织、租户、profile、邮箱、人员姓名、邮件/会话/模板/联系人/聊天真实 ID。 +- 本文不含邮件主题正文、收发件人、trace/request ID、token、签名 URL、电话或真实业务时间。 +- 真实 E2E 原始响应仅在仓库外临时目录解析;普通输出只保留能力标签、计数和布尔断言。 +- 临时草稿虽已执行两次 batch-delete 但仍可按同 ID 读取;临时模板删除后也未获得 typed nonfound。两者都不记为清理 PASS,四个写 Shortcut 因此保持 unavailable。 +- 当前邮箱没有已验证的空邮件文件夹或专用空邮箱;因此 `+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 不记 live 双态 PASS,并保持 unavailable。 +- 最终提交前仍需扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。 diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index 30bfa1d6..a13ab19a 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -1,6 +1,6 @@ { - "generated_at": "2026-08-18T17:38:50.904696", - "count": 436, + "generated_at": "2026-08-19T10:35:58.304269", + "count": 423, "results": [ { "suite": "semantic", @@ -933,137 +933,84 @@ "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+check-record", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "查询原始打卡流水;只有显式成功数组可表示空结果,每条必须有唯一正整数 ID 并绑定请求用户和日期。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+check-result", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "对齐 Lark attendance user_tasks query;严格校验正整数 ID 及请求用户/日期绑定,以 cursor_parameter=offset 将保守续页证据发布到 meta.pagination。", + "availability": "available" }, { - "suite": "read", - "service": "attendance", - "command": "+get-adjustment-rule", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+get-approve-template", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "把补卡、请假、加班、外出、出差映射为审批模板类型;每项以非空唯一 processCode 作为稳定身份,approveType 精确绑定请求且 submitUrl 必须非空,允许同类型返回多个模板。", + "availability": "available" }, { - "suite": "read", - "service": "attendance", - "command": "+get-checkin-record", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "attendance", - "command": "+get-leave-records", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+get-overtime-rule", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "使用搜索得到的加班规则 ID 读取详情,严格拒绝空 result,且响应 id 必须与请求 overtimeId 精确一致。", + "availability": "available" }, { - "suite": "read", - "service": "attendance", - "command": "+get-schedule", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "attendance", - "command": "+get-self-setting", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "attendance", - "command": "+get-summary", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+list-approve", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "把审批类型语义映射为业务枚举;每条审批必须有唯一正整数 ID,并精确绑定请求用户、类型与时间范围。", + "availability": "available" }, { - "suite": "read", - "service": "attendance", - "command": "+list-leave-types", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "attendance", - "command": "+my-attendance", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "attendance", - "command": "+query-report-data", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+search-adjustment-rule", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格读取 result.adjustmentList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+search-class", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格读取 result.items、展开 shiftVO、要求唯一正整数 classId,并将无矛盾的 totalCount/totalPage 续页证据发布到 meta.pagination。", + "availability": "available" }, { - "suite": "read", - "service": "attendance", - "command": "+search-group", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+search-overtime-rule", "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "attendance", - "command": "+this-month", - "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格读取 result.atRuleList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。", + "availability": "available" }, { "suite": "semantic", @@ -3305,74 +3252,84 @@ "availability": "available" }, { - "suite": "read", - "service": "mail", - "command": "+contact-list", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "mail", "command": "+find-mail-user", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "面向联系人解析的精简用户搜索;修复零命中被误报为调用失败,并严格验证用户数组、身份和分页。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "mail", "command": "+folder-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "只接受显式 folders 数组及稳定 folder ID;缺字段、错型和坏元素不再退化为空列表。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "mail", - "command": "+recent-mail", + "command": "+message", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "对齐 Lark 单封完整读入口;自动解析邮箱,要求顶层 message 非空且返回 ID 与请求 messageId 精确一致。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "mail", + "command": "+messages", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "对齐 Lark 多封完整读入口;最多 100 个 ID,保持请求顺序,任何单封缺失、错型或身份不一致都会使整次调用失败。", + "availability": "available" + }, + { + "suite": "semantic", "service": "mail", "command": "+search-mail", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "用 KQL 搜索邮件摘要;只接受已观测 messages 数组、稳定 messageId 和完整分页证据,窄化支持 Mail 专属 total=0 占位哨兵。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "mail", - "command": "+tag-list", + "command": "+thread", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "对齐 Lark 完整会话读取;自动解析邮箱并要求 conversation.id 与请求精确一致。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "mail", - "command": "+template-list", + "command": "+triage", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "对齐 Lark triage 任务入口,并超过其显式邮箱要求:可自动解析当前邮箱与收件箱,严格处理邮件搜索的终止游标和零命中哨兵。", + "availability": "available" }, { - "suite": "read", - "service": "mail", - "command": "+thread-list", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "mail", - "command": "+unread-mail", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "mail", "command": "+user-search", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "搜索企业邮箱用户并保留 hasMore/nextCursor;显式空数组合法,坏用户或缺稳定 ID 失败。", + "availability": "available" }, { "suite": "semantic", diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index b4fe5fff..82f982fb 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -16,12 +16,12 @@ import ( ) const ( - publicShortcutCount = 435 + publicShortcutCount = 422 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, // including the hidden historical minutes.shortcut_minutes_search contract. - schemaPublishedShortcutCount = 438 + schemaPublishedShortcutCount = 447 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. - publiclyDeliveredShortcutCount = 435 + publiclyDeliveredShortcutCount = 422 ) func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { diff --git a/internal/cli/param_aliases_generated.go b/internal/cli/param_aliases_generated.go index 4b5b93c9..8827cceb 100644 --- a/internal/cli/param_aliases_generated.go +++ b/internal/cli/param_aliases_generated.go @@ -3422,7 +3422,7 @@ var generatedParamAliases = []ParamAliasEntry{ "uid": "user", "userid": "user", }, - Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, }, { CLIPath: "chat group-role update", diff --git a/internal/cli/param_concepts.json b/internal/cli/param_concepts.json index 3a2f9a8b..4b9086b4 100644 --- a/internal/cli/param_concepts.json +++ b/internal/cli/param_concepts.json @@ -155,7 +155,6 @@ "chat group-role update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."}, "chat +chat-role-set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."}, "chat group-role remove-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."}, - "chat group-role set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."}, "chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."}, "chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."}, "doc block insert": {"block": ["before-block-id"], "note": "Parent and reference roles remain distinct. --before-block-id needs both --ref-block and --where before, while role-free --block-id cannot choose parent versus reference.", "scoped_aliases": {"parent-block-id": "parent-block", "ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "ambiguous": ["block-id"], "scope_strict": true}, @@ -438,7 +437,6 @@ {"command": "chat message send", "emitted": "file-path", "expect": "file", "via": "native:reviewed-compatibility-alias"}, {"command": "chat category add-conv", "emitted": "category-id", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"}, {"command": "chat category rename", "emitted": "category-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"}, - {"command": "chat group-role set-user", "emitted": "role-id", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"}, {"command": "chat group-role update", "emitted": "role-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"}, {"command": "chat message send-by-webhook", "emitted": "at-user-ids", "expect": "at-users", "via": "override:scoped-role"}, {"command": "chat message send-by-bot", "emitted": "at-users", "expect": "at-user-ids", "via": "override:scoped-role"}, diff --git a/internal/corecmd/corecmd.go b/internal/corecmd/corecmd.go index 04741dce..b94cc080 100644 --- a/internal/corecmd/corecmd.go +++ b/internal/corecmd/corecmd.go @@ -230,9 +230,10 @@ const ( // - Validate / PostMount — orchestration only; must not register business flags // or assemble business params that belong in Flags/ConstParams. // -// Exactly one of RunE / Invoke / Orchestrate must be set; New validates this at -// construction time. corecmd stays dispatch-agnostic and never calls a backend: -// the adapters (FromLeafSpec / FromShortcut) supply the body. +// Exactly one of RunE / Invoke / ResultInvoke / Orchestrate must be set; New +// validates this at construction time. corecmd stays dispatch-agnostic and +// never calls a backend: the adapters (FromLeafSpec / FromShortcut) supply the +// body. type Spec struct { Use string Short string @@ -259,7 +260,8 @@ type Spec struct { // backend call). ConfirmFirst bool // ConstParams are fixed toolArgs merged after flag assembly (e.g. precheckOnly). - // They are payload declaration, not user flags, and never satisfy Required. + // They are payload declaration, not user flags, never satisfy Required, and + // require an Invoke or ResultInvoke dispatcher that consumes assembled args. ConstParams map[string]any // Contract is the authoring-time leaf contract declaration (selection / // interface / parameters / dry-run / identity). When non-empty, embed @@ -371,7 +373,9 @@ func (c *Ctx) Yes() bool { return BoolFlag(c.cmd, "yes") } // malformed spec can never run the pipeline — write-confirmation prompt // included — and then silently exit 0 having done nothing. func New(spec Spec) *cobra.Command { + spec.ConstParams = cloneConstParams(spec.ConstParams) validateDispatchDecl(spec) + validateConstParamsDecl(spec) validateSafetySpec(spec) validateContractDecl(spec) validateInputSpecs(spec.Use, spec.Flags) @@ -399,6 +403,7 @@ func New(spec Spec) *cobra.Command { if spec.PostMount != nil { spec.PostMount(cmd) } + attachInterfaceBoolConstParams(cmd, spec.ConstParams) if spec.OutputRollout != "" { output.SetCommandRollout(cmd, spec.OutputRollout) } @@ -462,6 +467,17 @@ func New(spec Spec) *cobra.Command { return cmd } +func cloneConstParams(params map[string]any) map[string]any { + if params == nil { + return nil + } + frozen := make(map[string]any, len(params)) + for key, value := range params { + frozen[key] = value + } + return frozen +} + // ConfirmFirstAnnotation marks commands whose Spec declared ConfirmFirst. The // delivery gate reads it to tell a declared guard-first command apart from an // accidental confirm-before-validate inversion: guard-first is only legitimate @@ -537,6 +553,21 @@ func validateDispatchDecl(spec Spec) { } } +func validateConstParamsDecl(spec Spec) { + if len(spec.ConstParams) == 0 { + return + } + if spec.Invoke == nil && spec.ResultInvoke == nil { + panic(fmt.Sprintf("command %q ConstParams require Invoke or ResultInvoke", spec.Use)) + } + for _, flag := range spec.Flags { + key := bindKey(flag) + if _, conflicts := spec.ConstParams[key]; conflicts { + panic(fmt.Sprintf("command %q ConstParams key %q conflicts with flag --%s", spec.Use, key, flag.Name)) + } + } +} + // validateSafetySpec rejects partial safety declarations. A zero value remains // the historical read-only default, but once any field is authored all four // independent Schema dimensions must be explicit. diff --git a/internal/corecmd/corecmd_test.go b/internal/corecmd/corecmd_test.go index a5ae5b96..f9743865 100644 --- a/internal/corecmd/corecmd_test.go +++ b/internal/corecmd/corecmd_test.go @@ -26,6 +26,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/spf13/cobra" ) @@ -1415,17 +1416,23 @@ func TestCrossPlatformCoverageBuildArgsIntArgDefaultFloor(t *testing.T) { } } -func TestNewCommandMergesConstParams(t *testing.T) { +func TestCrossPlatformCoverageNewCommandFreezesAndMergesConstParams(t *testing.T) { var got map[string]any + declared := map[string]any{ + "precheckOnly": false, + "convThreadEnabled": true, + } cmd := New(Spec{ Use: "pub", Flags: []FlagSpec{{Name: "id", Usage: "ID", Bind: "versionId", Trim: true}}, - ConstParams: map[string]any{"precheckOnly": false}, + ConstParams: declared, Invoke: func(_ *Ctx, toolArgs map[string]any) error { got = toolArgs return nil }, }) + declared["precheckOnly"] = true + declared["forgedAfterNew"] = true _ = cmd.Flags().Set("id", "V1") if err := cmd.RunE(cmd, nil); err != nil { t.Fatal(err) @@ -1436,6 +1443,95 @@ func TestNewCommandMergesConstParams(t *testing.T) { if got["precheckOnly"] != false { t.Fatalf("precheckOnly = %#v, want false", got["precheckOnly"]) } + if got["convThreadEnabled"] != true { + t.Fatalf("convThreadEnabled = %#v, want true", got["convThreadEnabled"]) + } + if _, exists := got["forgedAfterNew"]; exists { + t.Fatalf("caller mutation leaked into dispatch args: %#v", got) + } + evidence := InterfaceBoolConstParams(cmd) + if !reflect.DeepEqual(evidence, map[string]bool{"convThreadEnabled": true, "precheckOnly": false}) { + t.Fatalf("bool ConstParams evidence = %#v", evidence) + } + if got["precheckOnly"] != evidence["precheckOnly"] { + t.Fatalf("dispatch/evidence drift: args=%#v evidence=%#v", got, evidence) + } +} + +func TestCrossPlatformCoverageNewCommandDoesNotProjectMixedConstParamsEvidence(t *testing.T) { + var got map[string]any + cmd := New(Spec{ + Use: "mixed", + ConstParams: map[string]any{ + "convThreadEnabled": true, + "retryLimit": 3, + }, + Invoke: func(_ *Ctx, toolArgs map[string]any) error { + got = toolArgs + return nil + }, + }) + + if evidence := InterfaceBoolConstParams(cmd); evidence != nil { + t.Fatalf("mixed ConstParams evidence = %#v; want missing evidence", evidence) + } + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatal(err) + } + if got["convThreadEnabled"] != true || got["retryLimit"] != 3 { + t.Fatalf("mixed ConstParams dispatch args = %#v", got) + } +} + +func TestCrossPlatformCoverageNewCommandRejectsInvalidConstParamsDispatch(t *testing.T) { + mustPanic := func(name string, spec Spec, needle string) { + t.Helper() + defer func() { + r := recover() + if r == nil { + t.Fatalf("%s: expected panic", name) + } + if msg, _ := r.(string); !strings.Contains(msg, needle) { + t.Fatalf("%s: panic=%v, want %q", name, r, needle) + } + }() + New(spec) + } + + mustPanic("RunE", Spec{ + Use: "run-e", + ConstParams: map[string]any{"fixed": true}, + RunE: func(*cobra.Command, []string) error { return nil }, + }, "ConstParams require Invoke or ResultInvoke") + mustPanic("Orchestrate", Spec{ + Use: "orchestrate", + ConstParams: map[string]any{"fixed": true}, + Orchestrate: func(*Ctx) error { return nil }, + }, "ConstParams require Invoke or ResultInvoke") + mustPanic("explicit bind conflict", Spec{ + Use: "bind-conflict", + Flags: []FlagSpec{{Name: "thread", Usage: "T", Bind: "convThreadEnabled"}}, + ConstParams: map[string]any{"convThreadEnabled": true}, + Invoke: func(*Ctx, map[string]any) error { return nil }, + }, "conflicts with flag --thread") + mustPanic("default bind conflict", Spec{ + Use: "default-bind-conflict", + Flags: []FlagSpec{{Name: "fixed-value", Usage: "F"}}, + ConstParams: map[string]any{"fixedValue": true}, + Invoke: func(*Ctx, map[string]any) error { return nil }, + }, "conflicts with flag --fixed-value") + + result := New(Spec{ + Use: "result", + OutputRollout: output.RolloutUnifiedActive, + ConstParams: map[string]any{"fixed": true}, + ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) { + return output.Success(nil), nil + }, + }) + if evidence := InterfaceBoolConstParams(result); !evidence["fixed"] { + t.Fatalf("ResultInvoke ConstParams evidence = %#v", evidence) + } } func TestCrossPlatformCoverageNewCommandConfirmFirstAnnotationAndOrder(t *testing.T) { diff --git a/internal/corecmd/interface_const_params.go b/internal/corecmd/interface_const_params.go new file mode 100644 index 00000000..261e64d1 --- /dev/null +++ b/internal/corecmd/interface_const_params.go @@ -0,0 +1,63 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package corecmd + +import ( + "sync" + + "github.com/spf13/cobra" +) + +var interfaceBoolConstParamsRegistry sync.Map + +// attachInterfaceBoolConstParams records framework-owned ConstParams evidence. +// Only a non-empty, entirely boolean declaration is representable in v1; +// empty or mixed declarations remove any evidence for the command. +func attachInterfaceBoolConstParams(cmd *cobra.Command, params map[string]any) { + if cmd == nil { + return + } + if len(params) == 0 { + interfaceBoolConstParamsRegistry.Delete(cmd) + return + } + bools := make(map[string]bool, len(params)) + for key, value := range params { + boolValue, ok := value.(bool) + if !ok { + interfaceBoolConstParamsRegistry.Delete(cmd) + return + } + bools[key] = boolValue + } + interfaceBoolConstParamsRegistry.Store(cmd, bools) +} + +// InterfaceBoolConstParams returns a clone of framework-owned boolean +// ConstParams evidence. Callers cannot mutate the private registry. +func InterfaceBoolConstParams(cmd *cobra.Command) map[string]bool { + if cmd == nil { + return nil + } + stored, ok := interfaceBoolConstParamsRegistry.Load(cmd) + if !ok { + return nil + } + params := stored.(map[string]bool) + clone := make(map[string]bool, len(params)) + for key, value := range params { + clone[key] = value + } + return clone +} diff --git a/internal/corecmd/interface_const_params_test.go b/internal/corecmd/interface_const_params_test.go new file mode 100644 index 00000000..ea01d24e --- /dev/null +++ b/internal/corecmd/interface_const_params_test.go @@ -0,0 +1,44 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package corecmd + +import ( + "reflect" + "testing" + + "github.com/spf13/cobra" +) + +func TestCrossPlatformCoverageInterfaceBoolConstParamsRegistryClonesAndDeletes(t *testing.T) { + attachInterfaceBoolConstParams(nil, map[string]any{"ignored": true}) + if got := InterfaceBoolConstParams(nil); got != nil { + t.Fatalf("nil command evidence = %#v, want nil", got) + } + + cmd := &cobra.Command{Use: "send"} + declared := map[string]any{"convThreadEnabled": true, "precheckOnly": false} + attachInterfaceBoolConstParams(cmd, declared) + declared["convThreadEnabled"] = false + + want := map[string]bool{"convThreadEnabled": true, "precheckOnly": false} + got := InterfaceBoolConstParams(cmd) + if !reflect.DeepEqual(got, want) { + t.Fatalf("registered bool ConstParams = %#v, want %#v", got, want) + } + got["convThreadEnabled"] = false + if again := InterfaceBoolConstParams(cmd); !reflect.DeepEqual(again, want) { + t.Fatalf("reader leaked mutable registry state: %#v", again) + } + + attachInterfaceBoolConstParams(cmd, map[string]any{"convThreadEnabled": true, "retryLimit": 3}) + if got := InterfaceBoolConstParams(cmd); got != nil { + t.Fatalf("mixed ConstParams retained evidence: %#v", got) + } + + attachInterfaceBoolConstParams(cmd, map[string]any{"convThreadEnabled": true}) + attachInterfaceBoolConstParams(cmd, nil) + if got := InterfaceBoolConstParams(cmd); got != nil { + t.Fatalf("empty ConstParams retained evidence: %#v", got) + } +} diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index a07e6289..94720e8d 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -27,6 +27,38 @@ import ( "github.com/spf13/cobra" ) +func resolveChatGroupRoleSetUserRoleIDs(cmd *cobra.Command) ([]string, error) { + roleIDChanged := cmd.Flags().Changed("role-id") + roleIDsChanged := cmd.Flags().Changed("role-ids") + switch { + case roleIDChanged && roleIDsChanged: + // PreRunE rejects callers that explicitly pass both flags. Reaching this + // branch means the hidden legacy flag was promoted to satisfy Cobra's + // required marker on the public canonical flag. + return parseCSVValues(mustGetFlag(cmd, "role-ids")), nil + case roleIDChanged: + roleIDs := parseCSVValues(mustGetFlag(cmd, "role-id")) + if len(roleIDs) == 0 { + return nil, apperrors.NewValidation("--role-id 不能为空") + } + if len(roleIDs) > 1 { + return nil, apperrors.NewValidation("--role-id 只允许指定一个群身份") + } + return roleIDs, nil + case roleIDsChanged: + return parseCSVValues(mustGetFlag(cmd, "role-ids")), nil + default: + return nil, apperrors.NewValidation("缺少必填参数 --role-id") + } +} + +func prepareChatGroupRoleSetUserRoleID(cmd *cobra.Command) error { + if cmd.Flags().Changed("role-id") && cmd.Flags().Changed("role-ids") { + return apperrors.NewValidation("--role-id 与 --role-ids 不能同时指定") + } + return promoteLegacyChatString(cmd, "role-id", "role-ids") +} + // promoteLegacyChatString copies an explicitly supplied legacy flag into the // new canonical flag. Cobra validates MarkFlagRequired after PreRunE, and the // overwrite also preserves the migration rule that a legacy value wins when @@ -7753,17 +7785,23 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 chatGroupRoleSetUserCmd := &cobra.Command{ Use: "set-user", Short: "设置用户的群身份(覆盖该用户的全部群身份)", - Example: ` dws chat group-role set-user --conversation-id --user --role-ids roleId1,roleId2 + Example: ` dws chat group-role set-user --conversation-id --user --role-id # 查询人员: dws contact user search --keyword "姓名" --format json # 查询 role-id: dws chat group-role list --conversation-id `, + PreRunE: func(cmd *cobra.Command, args []string) error { + return prepareChatGroupRoleSetUserRoleID(cmd) + }, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "conversation-id", "role-ids"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } if err := validateRequiredFlagWithAliases(cmd, "user", "userId"); err != nil { return err } - roleIDs := parseCSVValues(mustGetFlag(cmd, "role-ids")) + roleIDs, err := resolveChatGroupRoleSetUserRoleIDs(cmd) + if err != nil { + return err + } user := flagOrFallback(cmd, "user", "userId") toolArgs := map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), @@ -7798,13 +7836,13 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 }, Selection: contract.SelectionSpec{ AgentSummary: "为指定群成员设置自定义角色", - UseWhen: []string{"需要把一个或多个已有角色分配给成员时"}, + UseWhen: []string{"需要把一个已有角色分配给成员时"}, AvoidWhen: []string{"创建新角色定义时使用 chat group-role add"}, - Examples: []string{"dws chat group-role set-user --conversation-id --user --role-ids roleId1,roleId2"}, + Examples: []string{"dws chat group-role set-user --conversation-id --user --role-id "}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId"}, - {Name: "role-ids", Property: "openRoleIds"}, + {Name: "role-id", Property: "openRoleIds"}, {Name: "user", Property: "openDingTalkId"}, }, }, @@ -7814,8 +7852,11 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 chatGroupRoleSetUserCmd.Flags().String("user", "", "用户 userId(必填)") chatGroupRoleSetUserCmd.Flags().String("userId", "", "--user 的别名") _ = chatGroupRoleSetUserCmd.Flags().MarkHidden("userId") - chatGroupRoleSetUserCmd.Flags().String("role-ids", "", "群身份 openRoleId 列表,逗号分隔 (必填),传空字符串则清除该用户所有群身份") - _ = chatGroupRoleSetUserCmd.MarkFlagRequired("role-ids") + chatGroupRoleSetUserCmd.Flags().String("role-id", "", "群身份 openRoleId,由 group-role list 返回 (必填)") + chatGroupRoleSetUserCmd.Flags().String("role-ids", "", "已隐藏的兼容参数:群身份 openRoleId 列表,逗号分隔") + _ = chatGroupRoleSetUserCmd.Flags().MarkHidden("role-ids") + corecmd.AnnotateFlagAlias(chatGroupRoleSetUserCmd, "role-ids", "role-id") + _ = chatGroupRoleSetUserCmd.MarkFlagRequired("role-id") chatGroupRoleRemoveUserCmd := &cobra.Command{ Use: "remove-user", diff --git a/internal/helpers/chat_command_edges_test.go b/internal/helpers/chat_command_edges_test.go index 118cc983..b2f70a1b 100644 --- a/internal/helpers/chat_command_edges_test.go +++ b/internal/helpers/chat_command_edges_test.go @@ -243,6 +243,155 @@ func TestCrossPlatformCoverageChatGroupUpdateIconRejectsBlankMediaID(t *testing. } } +func TestChatGroupRoleSetUserAcceptsSingleRoleIDAndLegacyRoleIDs(t *testing.T) { + previousDeps, previousArgs := deps, os.Args + os.Args = []string{"dws", "chat"} + t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs }) + + tests := []struct { + name string + args []string + want []string + }{ + { + name: "public single role id", + args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1"}, + want: []string{"r1"}, + }, + { + name: "hidden legacy role ids", + args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids=r1,r2"}, + want: []string{"r1", "r2"}, + }, + { + name: "hidden legacy empty role ids", + args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids="}, + want: nil, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + if err := runChatCoverageCommand(t, caller, tc.args...); err != nil { + t.Fatal(err) + } + if caller.calls != 1 { + t.Fatalf("tool calls = %d, want 1", caller.calls) + } + if got := caller.args["openRoleIds"]; !reflect.DeepEqual(got, tc.want) { + t.Fatalf("openRoleIds = %#v, want %#v", got, tc.want) + } + }) + } +} + +func TestChatGroupRoleSetUserRejectsConflictingRoleFlags(t *testing.T) { + previousDeps, previousArgs := deps, os.Args + os.Args = []string{"dws", "chat"} + t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs }) + + caller := &scriptedToolCaller{} + err := runChatCoverageCommand(t, caller, + "group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1", "--role-ids=r2") + if err == nil { + t.Fatal("set-user accepted --role-id with --role-ids, want validation error") + } + if !strings.Contains(err.Error(), "--role-id 与 --role-ids 不能同时指定") { + t.Fatalf("error = %v", err) + } + if caller.calls != 0 { + t.Fatalf("tool calls = %d, want 0", caller.calls) + } +} + +func TestChatGroupRoleSetUserRejectsMultiplePublicRoleIDs(t *testing.T) { + previousDeps, previousArgs := deps, os.Args + os.Args = []string{"dws", "chat"} + t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs }) + + caller := &scriptedToolCaller{} + err := runChatCoverageCommand(t, caller, + "group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1,r2") + if err == nil { + t.Fatal("set-user accepted multiple --role-id values, want validation error") + } + if !strings.Contains(err.Error(), "--role-id 只允许指定一个群身份") { + t.Fatalf("error = %v", err) + } + if caller.calls != 0 { + t.Fatalf("tool calls = %d, want 0", caller.calls) + } +} + +func TestChatGroupRoleSetUserRejectsMissingOrEmptyPublicRoleID(t *testing.T) { + previousDeps, previousArgs := deps, os.Args + os.Args = []string{"dws", "chat"} + t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs }) + + tests := []struct { + name string + args []string + want string + }{ + { + name: "missing public role id", + args: []string{"group-role", "set-user", "--group=cid", "--user=D1"}, + want: "role-id", + }, + { + name: "empty public role id", + args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-id="}, + want: "--role-id 不能为空", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + err := runChatCoverageCommand(t, caller, tc.args...) + if err == nil { + t.Fatal("set-user accepted a missing or empty --role-id, want validation error") + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want substring %q", err, tc.want) + } + if caller.calls != 0 { + t.Fatalf("tool calls = %d, want 0", caller.calls) + } + }) + } +} + +func TestChatGroupRoleSetUserRoleIDResolverDefensiveBranches(t *testing.T) { + newCommand := func(t *testing.T) *cobra.Command { + t.Helper() + cmd := &cobra.Command{} + cmd.Flags().String("role-id", "", "") + cmd.Flags().String("role-ids", "", "") + return cmd + } + + t.Run("legacy flag without pre-run promotion", func(t *testing.T) { + cmd := newCommand(t) + if err := cmd.Flags().Set("role-ids", "r1,r2"); err != nil { + t.Fatal(err) + } + got, err := resolveChatGroupRoleSetUserRoleIDs(cmd) + if err != nil { + t.Fatal(err) + } + if want := []string{"r1", "r2"}; !reflect.DeepEqual(got, want) { + t.Fatalf("role IDs = %#v, want %#v", got, want) + } + }) + + t.Run("no role flag", func(t *testing.T) { + _, err := resolveChatGroupRoleSetUserRoleIDs(newCommand(t)) + if err == nil || !strings.Contains(err.Error(), "缺少必填参数 --role-id") { + t.Fatalf("error = %v, want missing --role-id validation", err) + } + }) +} + func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) { previousDeps, previousArgs := deps, os.Args os.Args = []string{"dws", "chat"} @@ -293,7 +442,7 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) {"group", "audit-join-validation", "--conversation-id=cid", "--record-id=1", "--applicant=D1", "--inviter=D2", "--status=AuditApprove", "--description=ok"}, {"mark-read", "--conversation-id=cid", "--message-id=mid"}, {"text", "translate", "--query=hello", "--to=zh_CN"}, - {"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids=r1"}, + {"group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1"}, {"group-role", "remove-user", "--group=cid", "--user=D1", "--role-ids=r1"}, {"group-role", "query-user", "--group=cid", "--user=D1"}, {"group", "set-admin", "--group=cid", "--users=u1,D1"}, @@ -523,7 +672,7 @@ func TestCrossPlatformCoverageChatIMIDMigrationRequiredFlagErrors(t *testing.T) {name: "role add missing conversation", path: []string{"group-role", "add"}, flag: map[string]string{"name": "role"}, want: "conversation-id"}, {name: "role update missing conversation", path: []string{"group-role", "update"}, flag: map[string]string{"role-id": "r1", "name": "role"}, want: "conversation-id"}, {name: "role remove missing conversation", path: []string{"group-role", "remove"}, flag: map[string]string{"role-id": "r1"}, want: "conversation-id"}, - {name: "role set user missing conversation", path: []string{"group-role", "set-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"}, + {name: "role set user missing conversation", path: []string{"group-role", "set-user"}, flag: map[string]string{"user": "D1", "role-id": "r1"}, want: "conversation-id"}, {name: "role remove user missing conversation", path: []string{"group-role", "remove-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"}, {name: "role query user missing conversation", path: []string{"group-role", "query-user"}, flag: map[string]string{"user": "D1"}, want: "conversation-id"}, {name: "bots missing legacy group", path: []string{"group", "bots"}, want: "group"}, diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go index 8dfe47b5..9e3393f9 100644 --- a/internal/helpers/oa.go +++ b/internal/helpers/oa.go @@ -8,6 +8,7 @@ import ( "io" "strconv" "strings" + "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" @@ -322,6 +323,95 @@ func newOAAttachmentCommand() *cobra.Command { return attachmentCmd } +// oaAdminQueryMaxPageSize is the pageSize upper bound of +// get_process_instances_by_admin. +const oaAdminQueryMaxPageSize = float64(20) + +// validateOARequestProcessCode checks the processCode field of a decoded +// --request payload: the tool requires it, and the backend answers a bad +// processCode with success:true and an empty list, so reject it client-side. +func validateOARequestProcessCode(request map[string]any) error { + v, ok := request["processCode"] + if !ok { + return fmt.Errorf("--request 缺少必填字段 processCode") + } + s, ok := v.(string) + if !ok || s == "" { + return fmt.Errorf("--request processCode 必须为非空字符串") + } + return nil +} + +// validateOARequestPageSize checks the pageSize field of a decoded +// --request payload (json.Number values from decodeOARequest). +func validateOARequestPageSize(request map[string]any) error { + v, ok := request["pageSize"] + if !ok { + return nil + } + n, ok := v.(json.Number) + if !ok { + return fmt.Errorf("pageSize 必须为数字") + } + f, err := n.Float64() + if err != nil || f < 1 || f > oaAdminQueryMaxPageSize { + return fmt.Errorf("pageSize 必须在 1-%d 之间,got: %s", int(oaAdminQueryMaxPageSize), n.String()) + } + return nil +} + +// oaAdminTimeLayout is the startTime/endTime wire format of +// get_process_instances_by_admin since the 2026-08 MCP contract update +// (both fields changed from epoch millis to strings). +const oaAdminTimeLayout = "2006-01-02 15:04:05" + +// oaAdminTimeLayoutHint is the user-facing spelling of the layout used in +// error messages. +const oaAdminTimeLayoutHint = "yyyy-MM-dd HH:mm:ss" + +var oaAdminTimeZone = time.FixedZone("Asia/Shanghai", 8*3600) + +// formatOAAdminQueryTime renders a millisecond timestamp into the +// yyyy-MM-dd HH:mm:ss string required by get_process_instances_by_admin. +func formatOAAdminQueryTime(ms int64) string { + return time.UnixMilli(ms).In(oaAdminTimeZone).Format(oaAdminTimeLayout) +} + +// validateOARequestTimeRange checks startTime/endTime of a decoded +// --request payload: startTime is required, both must be +// yyyy-MM-dd HH:mm:ss strings, and endTime must be strictly after +// startTime (matching simple mode's ValidateTimeRange). +func validateOARequestTimeRange(request map[string]any) error { + startRaw, ok := request["startTime"] + if !ok { + return fmt.Errorf("--request 缺少必填字段 startTime") + } + startStr, ok := startRaw.(string) + if !ok { + return fmt.Errorf("startTime 必须为 %s 格式字符串", oaAdminTimeLayoutHint) + } + start, err := time.ParseInLocation(oaAdminTimeLayout, startStr, oaAdminTimeZone) + if err != nil { + return fmt.Errorf("startTime 必须为 %s 格式,got: %s", oaAdminTimeLayoutHint, startStr) + } + endRaw, ok := request["endTime"] + if !ok { + return nil + } + endStr, ok := endRaw.(string) + if !ok { + return fmt.Errorf("endTime 必须为 %s 格式字符串", oaAdminTimeLayoutHint) + } + end, err := time.ParseInLocation(oaAdminTimeLayout, endStr, oaAdminTimeZone) + if err != nil { + return fmt.Errorf("endTime 必须为 %s 格式,got: %s", oaAdminTimeLayoutHint, endStr) + } + if !end.After(start) { + return fmt.Errorf("--request endTime 必须晚于 startTime") + } + return nil +} + // ────────────────────────────────────────────────────────── // dws oa — OA 审批 // MCP tools(tools/list): list_pending_approvals, get_processInstance_detail, @@ -329,7 +419,8 @@ func newOAAttachmentCommand() *cobra.Command { // get_processInstance_records, list_initiated_instances, list_pending_tasks, // list_user_visible_process, append_task, search_form, oa_ding_user, revert_task, // get_inst_revert_activities, get_process_schema, forecast_process, -// start_process_instance, get_attachment_download_url, auth_download_file, +// start_process_instance, get_process_instances_by_admin, +// get_attachment_download_url, auth_download_file, // auth_preview_attachment // ────────────────────────────────────────────────────────── @@ -1432,6 +1523,129 @@ func newOaCommand() *cobra.Command { }, }, }) + // 以管理员身份查询审批实例列表 + listByAdminSimpleFlags := []string{"process-code", "start", "end", "cursor", "limit", "user-ids", "statuses"} + approvalListByAdminCmd := &cobra.Command{ + Use: "list-by-admin", Short: "以管理员身份查询审批模板的实例列表", + Example: ` dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20 + dws oa approval list-by-admin --request '{"processCode":"PROC-xxx","startTime":"2026-03-10 00:00:00","cursor":0,"pageSize":20,"statuses":["RUNNING"]}'`, + PreRunE: func(cmd *cobra.Command, args []string) error { + // Cobra 的 flag group 校验(英文报错)在 PreRunE 之后执行, + // 这里先校验同一组约束,保证用户看到的是中文错误。 + request, _ := cmd.Flags().GetString("request") + processCode, _ := cmd.Flags().GetString("process-code") + if request == "" && processCode == "" { + return fmt.Errorf("--request、--process-code 至少指定一个") + } + if request != "" { + for _, name := range listByAdminSimpleFlags { + if cmd.Flags().Changed(name) { + return fmt.Errorf("--request 与 --%s 不能同时指定", name) + } + } + } + if processCode != "" && !cmd.Flags().Changed("start") { + return fmt.Errorf("--process-code、--start 必须同时指定(缺少 --start)") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + if raw, _ := cmd.Flags().GetString("request"); raw != "" { + request, err := decodeOARequest(raw) + if err != nil { + return fmt.Errorf("--request JSON 解析失败: %w", err) + } + if err := validateOARequestProcessCode(request); err != nil { + return err + } + if err := validateOARequestPageSize(request); err != nil { + return err + } + if err := validateOARequestTimeRange(request); err != nil { + return err + } + return callMCPTool("get_process_instances_by_admin", map[string]any{"ProcessInstanceListQueryRequest": request}) + } + if err := validateRequiredFlags(cmd, "process-code", "start"); err != nil { + return err + } + startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start")) + if err != nil { + return err + } + cursor, err := strconv.ParseFloat(mustGetFlag(cmd, "cursor"), 64) + if err != nil { + return fmt.Errorf("--cursor 必须为数字: %w", err) + } + pageSize, err := strconv.ParseFloat(mustGetFlag(cmd, "limit"), 64) + if err != nil { + return fmt.Errorf("--limit 必须为数字: %w", err) + } + if pageSize < 1 || pageSize > oaAdminQueryMaxPageSize { + return fmt.Errorf("--limit 必须在 1-%d 之间,got: %s", int(oaAdminQueryMaxPageSize), mustGetFlag(cmd, "limit")) + } + request := map[string]any{ + "processCode": mustGetFlag(cmd, "process-code"), + "startTime": formatOAAdminQueryTime(startMs), + "cursor": cursor, + "pageSize": pageSize, + } + if v, _ := cmd.Flags().GetString("end"); v != "" { + endMs, err := parseISOTimeToMillis("end", v) + if err != nil { + return err + } + if err := validateTimeRange(startMs, endMs); err != nil { + return err + } + request["endTime"] = formatOAAdminQueryTime(endMs) + } + if v, _ := cmd.Flags().GetString("user-ids"); v != "" { + request["userIds"] = strings.Split(v, ",") + } + if v, _ := cmd.Flags().GetString("statuses"); v != "" { + request["statuses"] = strings.Split(v, ",") + } + return callMCPTool("get_process_instances_by_admin", map[string]any{"ProcessInstanceListQueryRequest": request}) + }, + } + DeclareLeafMetadata(approvalListByAdminCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "oa", + Name: "get_process_instances_by_admin", + CanonicalPath: "oa.get_process_instances_by_admin", + CLIPath: "oa approval list-by-admin", + PrimaryCLIPath: "oa approval list-by-admin", + }, + Description: "以管理员身份获取审批单列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "get_process_instances_by_admin"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "以管理员身份按模板、时间范围、状态与用户查询企业内审批实例列表", + UseWhen: []string{"具备 OA 审批管理员权限,需要跨用户统计或检索某模板下的审批实例时"}, + AvoidWhen: []string{ + "只查自己的待办/已办/已发起/抄送时改用 list-pending / list-executed / list-initiated / list-cc", + "无 OA 管理员权限时不要使用,该命令查不到数据", + }, + Examples: []string{ + "dws oa approval list-by-admin --process-code --start \"2026-03-10T00:00:00+08:00\" --cursor 0 --limit 20", + "dws oa approval list-by-admin --process-code --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --statuses RUNNING,COMPLETED --user-ids \"userId1,userId2\"", + }, + }, + // Simple-mode flags are mapping exclusions (encoded inside ProcessInstanceListQueryRequest). + Parameters: []contract.ParamDecl{ + {Name: "request", Property: "ProcessInstanceListQueryRequest", InterfaceType: "object"}, + }, + }, + }) approvalCreateCmd := &cobra.Command{ Use: "create-instance", Short: "发起审批实例(需要 --yes 确认)", Example: "dws oa approval create-instance --process-code --form-values '{\"事由\":\"测试\"}' --yes", @@ -1610,6 +1824,27 @@ func newOaCommand() *cobra.Command { RequireTogether: [][]string{{"process-code", "dept-id", "form-values"}}, }) + approvalListByAdminCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)") + approvalListByAdminCmd.Flags().String("start", "", "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)(简单模式使用;与 --request 互斥)") + approvalListByAdminCmd.Flags().String("end", "", "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)(可选)") + approvalListByAdminCmd.Flags().String("cursor", "0", "分页游标,首次传 0") + approvalListByAdminCmd.Flags().String("limit", "20", "每页大小,最大 20") + approvalListByAdminCmd.Flags().String("user-ids", "", "按发起人 userId 过滤,多个用逗号分隔(可选)") + approvalListByAdminCmd.Flags().String("statuses", "", "按审批状态过滤,多个用逗号分隔(可选,如 RUNNING、TERMINATED、COMPLETED)") + approvalListByAdminCmd.Flags().String("request", "", "完整请求 JSON(高级模式;与简单模式参数互斥)") + approvalListByAdminCmd.MarkFlagsOneRequired("request", "process-code") + approvalListByAdminCmd.MarkFlagsRequiredTogether("process-code", "start") + listByAdminMutuallyExclusive := make([][]string, 0, len(listByAdminSimpleFlags)) + for _, name := range listByAdminSimpleFlags { + approvalListByAdminCmd.MarkFlagsMutuallyExclusive("request", name) + listByAdminMutuallyExclusive = append(listByAdminMutuallyExclusive, []string{"request", name}) + } + cli.AnnotateRuntimeConstraints(approvalListByAdminCmd, cli.RuntimeSchemaConstraints{ + MutuallyExclusive: listByAdminMutuallyExclusive, + RequireOneOf: [][]string{{"request", "process-code"}}, + RequireTogether: [][]string{{"process-code", "start"}}, + }) + approvalCreateCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)") approvalCreateCmd.Flags().String("dept-id", "-1", "发起人部门 ID") approvalCreateCmd.Flags().String("form-values", "", "表单值 JSON(简单模式使用;与 --request 互斥)") @@ -1656,6 +1891,7 @@ func newOaCommand() *cobra.Command { approvalRevertTaskCmd, approvalFormSchemaCmd, approvalForecastCmd, + approvalListByAdminCmd, approvalCreateCmd, ) approvalCmd.AddCommand(newOAAttachmentCommand()) diff --git a/internal/helpers/oa_coverage_more_test.go b/internal/helpers/oa_coverage_more_test.go index 70cb18cc..019b64e8 100644 --- a/internal/helpers/oa_coverage_more_test.go +++ b/internal/helpers/oa_coverage_more_test.go @@ -131,6 +131,51 @@ func TestCrossPlatformCoverageOAApprovalCreateInstanceRequiresExplicitYes(t *tes } } +func TestCrossPlatformCoverageOAApprovalListByAdminMapsSimpleOptions(t *testing.T) { + caller := &scriptedToolCaller{} + err := executeOACommand(t, caller, + "approval", "list-by-admin", + "--process-code", "PROC", + "--start", "2030-01-01T09:00:00+08:00", + "--end", "2030-01-01T10:00:00+08:00", + "--cursor", "5", + "--limit", "20", + "--user-ids", "user-1,user-2", + "--statuses", "RUNNING,COMPLETED", + ) + if err != nil { + t.Fatalf("list by admin: %v", err) + } + if caller.server != "oa" || caller.tool != "get_process_instances_by_admin" { + t.Fatalf("called %s/%s, want oa/get_process_instances_by_admin", caller.server, caller.tool) + } + request, ok := caller.args["ProcessInstanceListQueryRequest"].(map[string]any) + if !ok { + t.Fatalf("request payload = %#v", caller.args) + } + if got := request["processCode"]; got != "PROC" { + t.Fatalf("processCode = %#v", got) + } + if got := request["cursor"]; got != float64(5) { + t.Fatalf("cursor = %#v", got) + } + if got := request["pageSize"]; got != float64(20) { + t.Fatalf("pageSize = %#v", got) + } + if got := request["startTime"]; got != "2030-01-01 09:00:00" { + t.Fatalf("startTime = %#v", got) + } + if got := request["endTime"]; got != "2030-01-01 10:00:00" { + t.Fatalf("endTime = %#v", got) + } + if got := request["userIds"]; len(got.([]string)) != 2 || got.([]string)[0] != "user-1" || got.([]string)[1] != "user-2" { + t.Fatalf("userIds = %#v", got) + } + if got := request["statuses"]; len(got.([]string)) != 2 || got.([]string)[0] != "RUNNING" || got.([]string)[1] != "COMPLETED" { + t.Fatalf("statuses = %#v", got) + } +} + func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *testing.T) { validCases := []struct { name string @@ -157,6 +202,26 @@ func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *t args: []string{"approval", "create-instance", "--request", `{"processCode":"PROC"}`, "--yes"}, tool: "start_process_instance", }, + { + name: "list-by-admin simple mode", + args: []string{"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00"}, + tool: "get_process_instances_by_admin", + }, + { + name: "list-by-admin request mode", + args: []string{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, + tool: "get_process_instances_by_admin", + }, + { + name: "list-by-admin request mode without pageSize", + args: []string{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0}`}, + tool: "get_process_instances_by_admin", + }, + { + name: "list-by-admin request mode with endTime", + args: []string{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":"2030-01-01 23:59:59","cursor":0,"pageSize":20}`}, + tool: "get_process_instances_by_admin", + }, } for _, tc := range validCases { t.Run(tc.name, func(t *testing.T) { @@ -188,6 +253,31 @@ func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *t {"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--dept-id", "bad", "--yes"}, {"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--approvers", "u", "--approvers-action-type", "bad", "--yes"}, {"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--cc-list", "u", "--cc-position", "bad", "--yes"}, + {"approval", "list-by-admin"}, + {"approval", "list-by-admin", "--process-code", "PROC"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "bad"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T10:00:00+08:00", "--end", "bad"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T10:00:00+08:00", "--end", "2030-01-01T09:00:00+08:00"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--cursor", "bad"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "bad"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "21"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "0"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", ""}, + {"approval", "list-by-admin", "--request", "{"}, + {"approval", "list-by-admin", "--request", "null"}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC"}`, "--process-code", "PROC"}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":21}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":"20"}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":1893459600000,"cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 10:00:00","endTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":1893463200000,"cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","endTime":"NOT-A-TIME","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":"2030-01-01","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":123,"startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, } for _, args := range invalidCases { caller := &scriptedToolCaller{} diff --git a/internal/interfacesnapshot/command_migrations.go b/internal/interfacesnapshot/command_migrations.go new file mode 100644 index 00000000..053db474 --- /dev/null +++ b/internal/interfacesnapshot/command_migrations.go @@ -0,0 +1,639 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package interfacesnapshot + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "reflect" + "strconv" + "strings" +) + +const CommandMigrationManifestVersion = 1 + +const ( + CommandMigrationPending = "pending" + CommandMigrationConsumed = "consumed" + + CommandMigrationMove = "command_move" + CommandMigrationFlagExtraction = "flag_extraction" +) + +// CommandMigrationManifest governs compatibility-preserving surface moves that +// cannot be represented as an in-command flag rename. The merge-base owns the +// authorization; the candidate copy is only a lifecycle receipt. +type CommandMigrationManifest struct { + Version int `json:"version"` + Migrations []CommandMigration `json:"migrations"` +} + +type CommandMigration struct { + Kind string `json:"kind"` + Legacy CommandMigrationSide `json:"legacy"` + Replacement CommandMigrationSide `json:"replacement"` + LegacyFlag CommandMigrationFlag `json:"legacy_flag"` + Schema CommandMigrationSchema `json:"schema"` + State string `json:"state"` + Reason string `json:"reason"` +} + +type CommandMigrationSide struct { + Command string `json:"command"` + Before CommandMigrationState `json:"before"` + After CommandMigrationState `json:"after"` +} + +type CommandMigrationState struct { + Present bool `json:"present"` + Runnable bool `json:"runnable,omitempty"` + Hidden bool `json:"hidden,omitempty"` +} + +type CommandMigrationFlag struct { + Name string `json:"name,omitempty"` + Before FlagMigrationState `json:"before"` + After FlagMigrationState `json:"after"` +} + +type CommandMigrationSchema struct { + ProductID string `json:"product_id"` + SourceToolID string `json:"source_tool_id"` + ReplacementToolID string `json:"replacement_tool_id"` + Parameters []CommandParameterMigration `json:"parameters"` +} + +type CommandParameterMigration struct { + From string `json:"from"` + To string `json:"to,omitempty"` + ReplacementConstant *CommandReplacementConstant `json:"replacement_constant,omitempty"` +} + +type CommandReplacementConstant struct { + Property string `json:"property"` + Value bool `json:"value"` +} + +func ReadCommandMigrationManifest(r io.Reader) (CommandMigrationManifest, error) { + data, err := io.ReadAll(r) + if err != nil { + return CommandMigrationManifest{}, fmt.Errorf("read command migration manifest: %w", err) + } + var manifest CommandMigrationManifest + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&manifest); err != nil { + return CommandMigrationManifest{}, err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + return CommandMigrationManifest{}, fmt.Errorf("command migration manifest contains trailing multiple JSON values") + } + return CommandMigrationManifest{}, fmt.Errorf("read trailing command migration manifest data: %w", err) + } + strict := json.NewDecoder(bytes.NewReader(data)) + strict.UseNumber() + if err := validateLabeledMigrationJSONValue(strict, "$", reflect.TypeOf(CommandMigrationManifest{}), "command"); err != nil { + return CommandMigrationManifest{}, err + } + if err := manifest.Validate(); err != nil { + return CommandMigrationManifest{}, err + } + return manifest, nil +} + +func (m CommandMigrationManifest) Validate() error { + if m.Version != CommandMigrationManifestVersion { + return fmt.Errorf("unsupported command migration manifest version %d (want %d)", m.Version, CommandMigrationManifestVersion) + } + if m.Migrations == nil { + return fmt.Errorf("command migration manifest migrations must be an array") + } + seen := make(map[string]bool, len(m.Migrations)) + for index, migration := range m.Migrations { + if err := migration.validate(); err != nil { + return fmt.Errorf("command migration %d: %w", index, err) + } + if seen[migration.key()] { + return fmt.Errorf("command migration %d duplicates %s", index, migration.displayKey()) + } + seen[migration.key()] = true + } + return nil +} + +func (m CommandMigration) validate() error { + if m.Kind != CommandMigrationMove && m.Kind != CommandMigrationFlagExtraction { + return fmt.Errorf("invalid kind %q", m.Kind) + } + if !isExactCommandPath(m.Legacy.Command) || !isExactCommandPath(m.Replacement.Command) { + return fmt.Errorf("legacy and replacement must be exact command paths rooted at dws") + } + if m.Legacy.Command == m.Replacement.Command { + return fmt.Errorf("legacy and replacement command paths must differ") + } + if strings.TrimSpace(m.Reason) == "" || m.Reason != strings.TrimSpace(m.Reason) { + return fmt.Errorf("migration must include a non-empty trimmed reason") + } + if m.State != CommandMigrationPending && m.State != CommandMigrationConsumed { + return fmt.Errorf("invalid state %q", m.State) + } + for label, state := range map[string]CommandMigrationState{ + "legacy before": m.Legacy.Before, + "legacy after": m.Legacy.After, + "replacement before": m.Replacement.Before, + "replacement after": m.Replacement.After, + } { + if err := state.validate(label); err != nil { + return err + } + } + if !m.Legacy.Before.Present || !m.Legacy.Before.Runnable || m.Legacy.Before.Hidden || + !m.Legacy.After.Present || !m.Legacy.After.Runnable { + return fmt.Errorf("legacy command must remain runnable and start visible") + } + if m.Replacement.Before.Present || !m.Replacement.After.Present || !m.Replacement.After.Runnable || m.Replacement.After.Hidden { + return fmt.Errorf("replacement command must migrate exactly from absent to visible runnable") + } + if err := m.Schema.validate(m.Kind); err != nil { + return err + } + switch m.Kind { + case CommandMigrationMove: + if strings.HasPrefix(m.Replacement.Command, m.Legacy.Command+" ") || + strings.HasPrefix(m.Legacy.Command, m.Replacement.Command+" ") { + return fmt.Errorf("command_move legacy and replacement command paths must not have an ancestor relationship") + } + if !m.Legacy.After.Hidden { + return fmt.Errorf("command_move legacy command must migrate exactly from visible to hidden") + } + if m.LegacyFlag != (CommandMigrationFlag{}) { + return fmt.Errorf("command_move must not declare legacy_flag") + } + if m.Schema.SourceToolID != m.Schema.ReplacementToolID { + return fmt.Errorf("command_move must retain one stable Schema tool identity") + } + case CommandMigrationFlagExtraction: + if m.Legacy.After.Hidden || m.Legacy.Before != m.Legacy.After { + return fmt.Errorf("flag_extraction legacy command must remain visible and unchanged") + } + if err := m.LegacyFlag.validate(); err != nil { + return err + } + if m.Schema.SourceToolID == m.Schema.ReplacementToolID { + return fmt.Errorf("flag_extraction requires a distinct replacement Schema tool") + } + if m.LegacyFlag.Before.Type != "bool" || m.LegacyFlag.After.Type != "bool" { + return fmt.Errorf("flag_extraction legacy_flag must be an optional bool") + } + var extracted *CommandParameterMigration + constantCount := 0 + for index := range m.Schema.Parameters { + parameter := &m.Schema.Parameters[index] + if parameter.ReplacementConstant == nil { + continue + } + constantCount++ + if parameter.From == m.LegacyFlag.Name { + extracted = parameter + } + } + if constantCount != 1 { + return fmt.Errorf("flag_extraction must declare exactly one replacement constant") + } + if extracted == nil { + return fmt.Errorf("flag_extraction must map the exact extracted flag to replacement_constant") + } + if !extracted.ReplacementConstant.Value { + return fmt.Errorf("flag_extraction v1 replacement_constant must be true") + } + wantNoOpt := strconv.FormatBool(extracted.ReplacementConstant.Value) + if m.LegacyFlag.Before.NoOpt != wantNoOpt || m.LegacyFlag.After.NoOpt != wantNoOpt { + return fmt.Errorf("flag_extraction legacy_flag no_opt must equal replacement constant %q", wantNoOpt) + } + } + return nil +} + +func (s CommandMigrationState) validate(label string) error { + if !s.Present { + if s.Runnable || s.Hidden { + return fmt.Errorf("%s absent state must not declare command attributes", label) + } + return nil + } + if !s.Runnable { + return fmt.Errorf("%s present state must be runnable", label) + } + return nil +} + +func (f CommandMigrationFlag) validate() error { + if !isExactFlagName(f.Name) { + return fmt.Errorf("legacy_flag name must be an exact flag") + } + if err := f.Before.validate("legacy_flag before"); err != nil { + return err + } + if err := f.After.validate("legacy_flag after"); err != nil { + return err + } + if !f.Before.Present || !f.After.Present || f.Before.Hidden || !f.After.Hidden { + return fmt.Errorf("legacy_flag must migrate exactly from visible to hidden while remaining present") + } + if f.Before.Required || f.After.Required { + return fmt.Errorf("legacy_flag must remain optional before and after extraction") + } + before := f.Before + after := f.After + before.Hidden = false + after.Hidden = false + if before != after { + return fmt.Errorf("legacy_flag may change only hidden visibility") + } + return nil +} + +func (s CommandMigrationSchema) validate(kind string) error { + for label, value := range map[string]string{ + "product_id": s.ProductID, + "source_tool_id": s.SourceToolID, + "replacement_tool_id": s.ReplacementToolID, + } { + if !isExactSchemaIdentifier(value) { + return fmt.Errorf("schema %s must be an exact identifier", label) + } + } + if s.Parameters == nil { + return fmt.Errorf("schema parameters must be an array") + } + seenFrom := map[string]bool{} + seenTo := map[string]bool{} + seenConstantProperty := map[string]bool{} + for index, parameter := range s.Parameters { + if !isExactFlagName(parameter.From) { + return fmt.Errorf("schema parameter %d from must be an exact parameter name", index) + } + if seenFrom[parameter.From] { + return fmt.Errorf("schema parameter %d duplicates from %q", index, parameter.From) + } + seenFrom[parameter.From] = true + if kind == CommandMigrationMove { + if parameter.ReplacementConstant != nil { + return fmt.Errorf("command_move schema parameter %d must not declare replacement_constant", index) + } + if !isExactFlagName(parameter.To) || parameter.To == parameter.From { + return fmt.Errorf("command_move schema parameter %d requires a distinct exact to name", index) + } + if seenTo[parameter.To] { + return fmt.Errorf("schema parameter %d duplicates to %q", index, parameter.To) + } + seenTo[parameter.To] = true + continue + } + if kind != CommandMigrationFlagExtraction { + return fmt.Errorf("invalid command migration kind %q", kind) + } + + hasTo := parameter.To != "" + hasConstant := parameter.ReplacementConstant != nil + if !hasTo && !hasConstant { + return fmt.Errorf("flag_extraction schema parameter %d requires an exact to or replacement_constant", index) + } + if hasTo && hasConstant { + return fmt.Errorf("flag_extraction schema parameter %d must declare exactly one target", index) + } + if hasTo { + if !isExactFlagName(parameter.To) { + return fmt.Errorf("flag_extraction schema parameter %d to must be an exact parameter name", index) + } + if seenTo[parameter.To] { + return fmt.Errorf("schema parameter %d duplicates to %q", index, parameter.To) + } + if seenConstantProperty[parameter.To] { + return fmt.Errorf("schema parameter %d duplicates parameter target %q", index, parameter.To) + } + seenTo[parameter.To] = true + continue + } + property := parameter.ReplacementConstant.Property + if !isExactSchemaIdentifier(property) { + return fmt.Errorf("flag_extraction schema parameter %d replacement_constant requires an exact property", index) + } + if seenConstantProperty[property] { + return fmt.Errorf("schema parameter %d duplicates replacement_constant property %q", index, property) + } + if seenTo[property] { + return fmt.Errorf("schema parameter %d duplicates parameter target %q", index, property) + } + seenConstantProperty[property] = true + } + return nil +} + +func isExactSchemaIdentifier(value string) bool { + return value != "" && value == strings.TrimSpace(value) && + !strings.ContainsAny(value, "*?[]{} /\\\t\r\n") +} + +func (m CommandMigration) key() string { + return strings.Join([]string{m.Kind, m.Legacy.Command, m.Replacement.Command, m.Schema.ProductID, m.Schema.SourceToolID, m.Schema.ReplacementToolID}, "\x00") +} + +func (m CommandMigration) displayKey() string { + return fmt.Sprintf("%s %q -> %q", m.Kind, m.Legacy.Command, m.Replacement.Command) +} + +type commandMigrationPhase string + +const ( + commandMigrationBefore commandMigrationPhase = "before" + commandMigrationAfter commandMigrationPhase = "after" + commandMigrationPartial commandMigrationPhase = "partial" +) + +func AuthorizeCommandMigrations( + current Snapshot, + references map[string]Snapshot, + authority CommandMigrationManifest, + candidate CommandMigrationManifest, +) ([]CommandMigration, error) { + if err := current.Validate(); err != nil { + return nil, fmt.Errorf("validate current interface snapshot: %w", err) + } + for label, snapshot := range references { + if err := snapshot.Validate(); err != nil { + return nil, fmt.Errorf("validate %s interface snapshot: %w", label, err) + } + } + if err := authority.Validate(); err != nil { + return nil, fmt.Errorf("validate approved command migrations: %w", err) + } + if err := candidate.Validate(); err != nil { + return nil, fmt.Errorf("validate candidate command migrations: %w", err) + } + type commandMoveTopologyCheck struct { + label string + snapshot Snapshot + manifest CommandMigrationManifest + } + topologyChecks := []commandMoveTopologyCheck{ + {label: "approved", snapshot: current, manifest: authority}, + {label: "candidate", snapshot: current, manifest: candidate}, + } + if mergeBase, _, ok := flagMigrationAuthoritySnapshot(references); ok { + topologyChecks = append(topologyChecks, + commandMoveTopologyCheck{label: "approved base", snapshot: mergeBase, manifest: authority}, + commandMoveTopologyCheck{label: "candidate base", snapshot: mergeBase, manifest: candidate}, + ) + } + for _, check := range topologyChecks { + if err := validateCommandMoveLegacyLeaves(check.snapshot, check.manifest); err != nil { + return nil, fmt.Errorf("validate %s command migration topology: %w", check.label, err) + } + } + return evaluateCommandMigrationLifecycle(current, references, authority, candidate) +} + +func validateCommandMoveLegacyLeaves(snapshot Snapshot, manifest CommandMigrationManifest) error { + commands := commandIndex(snapshot) + for _, migration := range manifest.Migrations { + if migration.Kind != CommandMigrationMove { + continue + } + if _, present := commands[migration.Legacy.Command]; !present { + continue + } + for _, command := range snapshot.Commands { + if strings.HasPrefix(command.Path, migration.Legacy.Command+" ") { + return fmt.Errorf("command_move legacy command %q must be a leaf", migration.Legacy.Command) + } + } + } + return nil +} + +func evaluateCommandMigrationLifecycle( + current Snapshot, + references map[string]Snapshot, + authority CommandMigrationManifest, + candidate CommandMigrationManifest, +) ([]CommandMigration, error) { + if len(authority.Migrations) == 0 && len(candidate.Migrations) == 0 { + return nil, nil + } + if _, ok := references["stable"]; !ok { + return nil, fmt.Errorf("command migration lifecycle requires a stable reference") + } + mergeBase, label, ok := flagMigrationAuthoritySnapshot(references) + if !ok { + return nil, fmt.Errorf("command migration lifecycle requires a main or merge-base reference") + } + authorityByKey := commandMigrationIndex(authority) + candidateByKey := commandMigrationIndex(candidate) + authorizations := make([]CommandMigration, 0, len(authority.Migrations)) + for _, approved := range authority.Migrations { + basePhase := matchCommandMigrationPhase(mergeBase, approved) + wantBase := commandMigrationBefore + if approved.State == CommandMigrationConsumed { + wantBase = commandMigrationAfter + } + if basePhase != wantBase { + return nil, fmt.Errorf("approved command migration %s is %s in %s, want exact %s state for %s", approved.displayKey(), basePhase, label, wantBase, approved.State) + } + proposed, exists := candidateByKey[approved.key()] + if exists && !sameCommandMigrationApproval(approved, proposed) { + return nil, fmt.Errorf("candidate modified base-owned command migration %s", approved.displayKey()) + } + currentPhase := matchCommandMigrationPhase(current, approved) + switch approved.State { + case CommandMigrationPending: + if !exists { + return nil, fmt.Errorf("candidate removed pending command migration %s", approved.displayKey()) + } + switch currentPhase { + case commandMigrationBefore: + if proposed.State != CommandMigrationPending { + return nil, fmt.Errorf("candidate falsely consumed unchanged command migration %s", approved.displayKey()) + } + case commandMigrationAfter: + if proposed.State != CommandMigrationConsumed { + return nil, fmt.Errorf("candidate completed command migration %s without marking it consumed", approved.displayKey()) + } + authorizations = append(authorizations, approved) + default: + return nil, fmt.Errorf("candidate partially applied command migration %s", approved.displayKey()) + } + case CommandMigrationConsumed: + if currentPhase != commandMigrationAfter { + return nil, fmt.Errorf("candidate drifted from consumed command migration %s", approved.displayKey()) + } + allAfter := true + for _, reference := range references { + if matchCommandMigrationPhase(reference, approved) != commandMigrationAfter { + allAfter = false + break + } + } + if allAfter { + if exists { + return nil, fmt.Errorf("consumed command migration %s is stale after all references reached the after state", approved.displayKey()) + } + continue + } + if !exists || proposed.State != CommandMigrationConsumed { + return nil, fmt.Errorf("candidate must retain consumed command migration %s until every reference reaches the after state", approved.displayKey()) + } + authorizations = append(authorizations, approved) + } + } + for _, proposed := range candidate.Migrations { + if _, exists := authorityByKey[proposed.key()]; exists { + continue + } + if proposed.State != CommandMigrationPending { + return nil, fmt.Errorf("candidate-added command migration %s must start pending", proposed.displayKey()) + } + if matchCommandMigrationPhase(mergeBase, proposed) != commandMigrationBefore { + return nil, fmt.Errorf("candidate-added command migration %s does not match the merge-base before state", proposed.displayKey()) + } + if matchCommandMigrationPhase(current, proposed) != commandMigrationBefore { + return nil, fmt.Errorf("candidate-added command migration %s cannot authorize its own interface change", proposed.displayKey()) + } + } + return authorizations, nil +} + +func commandMigrationIndex(manifest CommandMigrationManifest) map[string]CommandMigration { + index := make(map[string]CommandMigration, len(manifest.Migrations)) + for _, migration := range manifest.Migrations { + index[migration.key()] = migration + } + return index +} + +func sameCommandMigrationApproval(left, right CommandMigration) bool { + left.State = "" + right.State = "" + return reflect.DeepEqual(left, right) +} + +func matchCommandMigrationPhase(snapshot Snapshot, migration CommandMigration) commandMigrationPhase { + commands := commandIndex(snapshot) + legacy := commandMigrationStateForCommand(commands, migration.Legacy.Command) + replacement := commandMigrationStateForCommand(commands, migration.Replacement.Command) + before := legacy == migration.Legacy.Before && replacement == migration.Replacement.Before + after := legacy == migration.Legacy.After && replacement == migration.Replacement.After + if migration.Kind == CommandMigrationFlagExtraction { + command, exists := commands[migration.Legacy.Command] + flagState := FlagMigrationState{} + if exists { + flagState = flagMigrationStateForCommand(command, migration.LegacyFlag.Name) + } + before = before && flagState == migration.LegacyFlag.Before + after = after && flagState == migration.LegacyFlag.After + if replacement, replacementExists := commands[migration.Replacement.Command]; replacementExists { + after = after && commandMigrationReplacementConstantsMatch(replacement, migration) + } else { + after = false + } + } + if before { + return commandMigrationBefore + } + if after { + return commandMigrationAfter + } + return commandMigrationPartial +} + +func commandMigrationReplacementConstantsMatch(command Command, migration CommandMigration) bool { + expected := make(map[string]bool) + for _, parameter := range migration.Schema.Parameters { + if parameter.ReplacementConstant == nil { + continue + } + expected[parameter.ReplacementConstant.Property] = parameter.ReplacementConstant.Value + } + return reflect.DeepEqual(command.BoolConstParams, expected) +} + +func commandMigrationStateForCommand(commands map[string]Command, path string) CommandMigrationState { + command, exists := commands[path] + if !exists { + return CommandMigrationState{} + } + return CommandMigrationState{Present: true, Runnable: command.Runnable, Hidden: command.Hidden} +} + +// CompareAllWithInterfaceMigrations applies both migration families to one +// ordinary report, so the two ledgers cannot mask each other's unrelated +// findings. +func CompareAllWithInterfaceMigrations( + current Snapshot, + references map[string]Snapshot, + flagAuthority FlagMigrationManifest, + flagCandidate FlagMigrationManifest, + commandAuthority CommandMigrationManifest, + commandCandidate CommandMigrationManifest, +) (Report, error) { + flagAuthorizations, err := AuthorizeFlagMigrations(current, references, flagAuthority, flagCandidate) + if err != nil { + return Report{}, err + } + commandAuthorizations, err := AuthorizeCommandMigrations(current, references, commandAuthority, commandCandidate) + if err != nil { + return Report{}, err + } + report := CompareAll(current, references) + for index := range report.Comparisons { + comparison := &report.Comparisons[index] + reference := references[comparison.Reference] + filtered := comparison.Blocking[:0] + for _, change := range comparison.Blocking { + if flagMigrationAuthorizesChange(current, reference, change, flagAuthorizations) || + commandMigrationAuthorizesChange(current, reference, change, commandAuthorizations) { + continue + } + filtered = append(filtered, change) + } + comparison.Blocking = filtered + comparison.Compatible = len(filtered) == 0 + } + report.Compatible = true + for _, comparison := range report.Comparisons { + if !comparison.Compatible { + report.Compatible = false + break + } + } + return report, nil +} + +func commandMigrationAuthorizesChange(current, reference Snapshot, change Change, authorizations []CommandMigration) bool { + canonicalPath := acceptedPathIndex(reference)[change.Path] + if canonicalPath == "" { + canonicalPath = change.Path + } + for _, migration := range authorizations { + if canonicalPath != migration.Legacy.Command || + matchCommandMigrationPhase(reference, migration) != commandMigrationBefore || + matchCommandMigrationPhase(current, migration) != commandMigrationAfter { + continue + } + switch migration.Kind { + case CommandMigrationMove: + if change.Kind == "command_became_hidden" && change.Flag == "" { + return true + } + case CommandMigrationFlagExtraction: + if change.Kind == "flag_became_hidden" && change.Flag == migration.LegacyFlag.Name { + return true + } + } + } + return false +} diff --git a/internal/interfacesnapshot/command_migrations_test.go b/internal/interfacesnapshot/command_migrations_test.go new file mode 100644 index 00000000..9585afa1 --- /dev/null +++ b/internal/interfacesnapshot/command_migrations_test.go @@ -0,0 +1,744 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package interfacesnapshot + +import ( + "errors" + "os" + "strings" + "testing" +) + +type commandMigrationErrorReader struct{} + +func (commandMigrationErrorReader) Read([]byte) (int, error) { + return 0, errors.New("command migration read failure") +} + +func TestApprovedCommandMigrationManifestRemainsValid(t *testing.T) { + manifest, err := os.Open("../../scripts/policy/interface-migrations/approved-command-migrations-v1.json") + if err != nil { + t.Fatalf("open approved command migration manifest: %v", err) + } + defer manifest.Close() + if _, err := ReadCommandMigrationManifest(manifest); err != nil { + t.Fatalf("approved command migration manifest is invalid: %v", err) + } +} + +func TestCrossPlatformCoverageReadCommandMigrationManifestFailsClosed(t *testing.T) { + valid := commandMigrationManifestJSON() + if _, err := ReadCommandMigrationManifest(strings.NewReader(valid)); err != nil { + t.Fatalf("valid command migration manifest: %v", err) + } + validExtraction := flagExtractionCommandMigrationManifestJSON() + if _, err := ReadCommandMigrationManifest(strings.NewReader(validExtraction)); err != nil { + t.Fatalf("valid flag extraction command migration manifest: %v", err) + } + for _, test := range []struct { + input string + want string + }{ + {strings.Replace(valid, `"state": "pending"`, `"state": "pending", "allow": true`, 1), "unknown field"}, + {strings.Replace(valid, `"state": "pending"`, `"state": "pending", "state": "consumed"`, 1), "duplicate field"}, + {strings.Replace(valid, `"version": 1`, `"version": null`, 1), "must be"}, + {strings.Replace(valid, "dws chat message old", "dws chat *", 1), "exact command paths"}, + {strings.Replace(valid, `"kind": "command_move"`, `"kind": "anything"`, 1), "invalid kind"}, + {`{"version":1,"migrations":null}`, "must be an array"}, + {strings.Replace(validExtraction, `"replacement_constant":{"property":"convThreadEnabled","value":true}`, `"replacement_constant":null`, 1), "must be an object"}, + {strings.Replace(validExtraction, `,"value":true`, ``, 1), `must be true`}, + {strings.Replace(validExtraction, `"value":true`, `"value":"true"`, 1), "bool"}, + } { + if _, err := ReadCommandMigrationManifest(strings.NewReader(test.input)); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("ReadCommandMigrationManifest() error=%v, want %q", err, test.want) + } + } + if _, err := ReadCommandMigrationManifest(commandMigrationErrorReader{}); err == nil || !strings.Contains(err.Error(), "read failure") { + t.Fatalf("reader error=%v", err) + } + for _, input := range []string{valid + ` {}`, valid + ` {`} { + if _, err := ReadCommandMigrationManifest(strings.NewReader(input)); err == nil || !strings.Contains(err.Error(), "trailing") { + t.Fatalf("trailing input error=%v", err) + } + } +} + +func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) { + validMove := commandMigrationManifest(CommandMigrationPending).Migrations[0] + validExtraction := commandMigrationManifest(CommandMigrationPending).Migrations[1] + + duplicate := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{validMove, validMove}} + if err := duplicate.Validate(); err == nil || !strings.Contains(err.Error(), "duplicates") { + t.Fatalf("duplicate error=%v", err) + } + if err := (CommandMigrationManifest{Version: CommandMigrationManifestVersion}).Validate(); err == nil || !strings.Contains(err.Error(), "must be an array") { + t.Fatalf("nil migrations error=%v", err) + } + + for _, test := range []struct { + name string + mutate func(*CommandMigration) + want string + }{ + {"same command", func(m *CommandMigration) { m.Replacement.Command = m.Legacy.Command }, "must differ"}, + {"empty reason", func(m *CommandMigration) { m.Reason = " " }, "non-empty trimmed"}, + {"invalid state", func(m *CommandMigration) { m.State = "approved" }, "invalid state"}, + {"invalid command state", func(m *CommandMigration) { m.Replacement.Before.Runnable = true }, "absent state"}, + {"legacy contract", func(m *CommandMigration) { m.Legacy.Before.Hidden = true }, "remain runnable"}, + {"replacement contract", func(m *CommandMigration) { m.Replacement.After.Hidden = true }, "absent to visible"}, + {"schema contract", func(m *CommandMigration) { m.Schema.ProductID = "bad/id" }, "exact identifier"}, + {"move not hidden", func(m *CommandMigration) { m.Legacy.After.Hidden = false }, "must migrate exactly"}, + {"move flag", func(m *CommandMigration) { m.LegacyFlag.Name = "thread" }, "must not declare legacy_flag"}, + {"move tool identity", func(m *CommandMigration) { m.Schema.ReplacementToolID = "chat.new" }, "stable Schema tool"}, + } { + t.Run(test.name, func(t *testing.T) { + migration := validMove + test.mutate(&migration) + if err := migration.validate(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("validate error=%v, want %q", err, test.want) + } + }) + } + for _, test := range []struct { + name string + mutate func(*CommandMigration) + want string + }{ + {"legacy changed", func(m *CommandMigration) { m.Legacy.After.Hidden = true }, "remain visible"}, + {"invalid legacy flag", func(m *CommandMigration) { m.LegacyFlag.Name = "bad name" }, "exact flag"}, + {"same tool", func(m *CommandMigration) { m.Schema.ReplacementToolID = m.Schema.SourceToolID }, "distinct replacement"}, + {"wrong parameter", func(m *CommandMigration) { m.Schema.Parameters[3].From = "other" }, "exact extracted flag"}, + {"wrong type", func(m *CommandMigration) { + m.LegacyFlag.Before.Type = "string" + m.LegacyFlag.After.Type = "string" + }, "optional bool"}, + {"wrong no opt", func(m *CommandMigration) { + m.LegacyFlag.Before.NoOpt = "false" + m.LegacyFlag.After.NoOpt = "false" + }, "no_opt"}, + {"missing extracted mapping", func(m *CommandMigration) { + m.Schema.Parameters = m.Schema.Parameters[:3] + }, "exactly one replacement constant"}, + {"duplicate extracted mapping", func(m *CommandMigration) { + m.Schema.Parameters = append(m.Schema.Parameters, m.Schema.Parameters[3]) + }, "duplicates from"}, + {"multiple constants", func(m *CommandMigration) { + m.Schema.Parameters[0] = CommandParameterMigration{ + From: "name", + ReplacementConstant: &CommandReplacementConstant{ + Property: "name", + Value: true, + }, + } + }, "exactly one replacement constant"}, + } { + t.Run(test.name, func(t *testing.T) { + migration := cloneCommandMigration(validExtraction) + test.mutate(&migration) + if err := migration.validate(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("validate error=%v, want %q", err, test.want) + } + }) + } + falseConstant := cloneCommandMigration(validExtraction) + falseConstant.Schema.Parameters[3].ReplacementConstant.Value = false + falseConstant.LegacyFlag.Before.NoOpt = "false" + falseConstant.LegacyFlag.After.NoOpt = "false" + if err := falseConstant.validate(); err == nil || !strings.Contains(err.Error(), "must be true") { + t.Fatalf("false replacement constant validation error=%v, want v1 true-only rejection", err) + } + + for _, state := range []CommandMigrationState{ + {Runnable: true}, + {Present: true}, + } { + if err := state.validate("state"); err == nil { + t.Fatalf("invalid command state accepted: %#v", state) + } + } + + for _, test := range []struct { + name string + mutate func(*CommandMigrationFlag) + want string + }{ + {"name", func(f *CommandMigrationFlag) { f.Name = "bad name" }, "exact flag"}, + {"before state", func(f *CommandMigrationFlag) { f.Before = FlagMigrationState{Present: true} }, "requires type"}, + {"after state", func(f *CommandMigrationFlag) { f.After = FlagMigrationState{Present: true} }, "requires type"}, + {"visibility", func(f *CommandMigrationFlag) { f.After.Hidden = false }, "visible to hidden"}, + {"attribute", func(f *CommandMigrationFlag) { f.After.Type = "string" }, "only hidden visibility"}, + } { + t.Run("flag "+test.name, func(t *testing.T) { + flag := validExtraction.LegacyFlag + test.mutate(&flag) + if err := flag.validate(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("flag validate error=%v, want %q", err, test.want) + } + }) + } + + validSchema := validMove.Schema + for _, test := range []struct { + name string + kind string + mutate func(*CommandMigrationSchema) + want string + }{ + {"identifier", CommandMigrationMove, func(s *CommandMigrationSchema) { s.ProductID = "bad/id" }, "exact identifier"}, + {"nil parameters", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = nil }, "must be an array"}, + {"bad from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters[0].From = "bad name" }, "exact parameter"}, + {"duplicate from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = append(s.Parameters, s.Parameters[0]) }, "duplicates from"}, + {"bad to", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters[0].To = s.Parameters[0].From }, "distinct exact"}, + {"duplicate to", CommandMigrationMove, func(s *CommandMigrationSchema) { + s.Parameters = append(s.Parameters, CommandParameterMigration{From: "other", To: s.Parameters[0].To}) + }, "duplicates to"}, + {"move constant", CommandMigrationMove, func(s *CommandMigrationSchema) { + s.Parameters[0].ReplacementConstant = &CommandReplacementConstant{Property: "property", Value: true} + }, "must not declare replacement_constant"}, + {"invalid kind", "anything", func(*CommandMigrationSchema) {}, "invalid command migration kind"}, + } { + t.Run("schema "+test.name, func(t *testing.T) { + schema := validSchema + schema.Parameters = append([]CommandParameterMigration(nil), validSchema.Parameters...) + test.mutate(&schema) + if err := schema.validate(test.kind); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("schema validate error=%v, want %q", err, test.want) + } + }) + } + + for _, test := range []struct { + name string + mutate func(*CommandMigrationSchema) + want string + }{ + {"missing target", func(s *CommandMigrationSchema) { s.Parameters[0].To = "" }, "requires an exact to or replacement_constant"}, + {"both targets", func(s *CommandMigrationSchema) { + s.Parameters[0].ReplacementConstant = &CommandReplacementConstant{Property: "name", Value: true} + }, "exactly one target"}, + {"bad to", func(s *CommandMigrationSchema) { s.Parameters[0].To = "bad name" }, "to must be an exact parameter name"}, + {"bad constant property", func(s *CommandMigrationSchema) { s.Parameters[3].ReplacementConstant.Property = "bad/property" }, "exact property"}, + {"duplicate parameter target", func(s *CommandMigrationSchema) { s.Parameters[1].To = s.Parameters[0].To }, "duplicates to"}, + {"duplicate constant target", func(s *CommandMigrationSchema) { + s.Parameters[0] = CommandParameterMigration{From: "name", ReplacementConstant: &CommandReplacementConstant{Property: "convThreadEnabled", Value: true}} + }, "duplicates replacement_constant property"}, + {"constant before parameter target", func(s *CommandMigrationSchema) { + s.Parameters[3].ReplacementConstant.Property = "name" + s.Parameters[0], s.Parameters[3] = s.Parameters[3], s.Parameters[0] + }, "duplicates parameter target"}, + {"parameter target before constant", func(s *CommandMigrationSchema) { + s.Parameters[3].ReplacementConstant.Property = "name" + }, "duplicates parameter target"}, + } { + t.Run("extraction schema "+test.name, func(t *testing.T) { + schema := cloneCommandMigration(validExtraction).Schema + test.mutate(&schema) + if err := schema.validate(CommandMigrationFlagExtraction); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("schema validate error=%v, want %q", err, test.want) + } + }) + } +} + +func TestCrossPlatformCoverageFlagExtractionRejectsRequiredLegacyFlag(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*CommandMigrationFlag) + }{ + {"before required", func(flag *CommandMigrationFlag) { flag.Before.Required = true }}, + {"after required", func(flag *CommandMigrationFlag) { flag.After.Required = true }}, + } { + t.Run(test.name, func(t *testing.T) { + migration := commandMigrationManifest(CommandMigrationPending).Migrations[1] + test.mutate(&migration.LegacyFlag) + + if err := migration.validate(); err == nil || !strings.Contains(err.Error(), "optional") { + t.Fatalf("required legacy flag validation error=%v, want optional-only rejection", err) + } + }) + } +} + +func TestCrossPlatformCoverageCommandMoveRejectsRunnableParent(t *testing.T) { + migration := commandMigrationManifest(CommandMigrationPending).Migrations[0] + migration.Legacy.Command = "dws agoal" + migration.Replacement.Command = "dws goal" + migration.Schema = CommandMigrationSchema{ + ProductID: "agoal", + SourceToolID: "agoal.root", + ReplacementToolID: "agoal.root", + Parameters: []CommandParameterMigration{}, + } + authority := CommandMigrationManifest{ + Version: CommandMigrationManifestVersion, + Migrations: []CommandMigration{migration}, + } + consumed := authority + consumed.Migrations = append([]CommandMigration(nil), authority.Migrations...) + consumed.Migrations[0].State = CommandMigrationConsumed + + before := testSnapshot( + testCommand("dws"), + testCommand("dws agoal"), + testCommand("dws agoal strategy"), + testCommand("dws agoal strategy list"), + ) + after := testSnapshot( + testCommand("dws"), + Command{Path: "dws agoal", Runnable: true, Hidden: true}, + testCommand("dws goal"), + ) + + _, err := AuthorizeCommandMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + authority, + consumed, + ) + if err == nil || !strings.Contains(err.Error(), "must be a leaf") { + t.Fatalf("runnable parent command_move error=%v, want leaf-only rejection", err) + } +} + +func TestCrossPlatformCoverageCommandMoveAllowsReplacementParent(t *testing.T) { + before := commandMigrationSnapshot(false, false) + after := commandMigrationSnapshot(true, false) + after.Commands = append(after.Commands, testCommand("dws chat topic new detail")) + pending := singleCommandMigrationManifest(CommandMigrationPending) + consumed := singleCommandMigrationManifest(CommandMigrationConsumed) + if err := validateCommandMoveLegacyLeaves(testSnapshot(testCommand("dws")), pending); err != nil { + t.Fatalf("absent legacy topology should remain a lifecycle concern: %v", err) + } + + got, err := AuthorizeCommandMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + pending, + consumed, + ) + if err != nil { + t.Fatalf("replacement parent command_move was rejected: %v", err) + } + if len(got) != 1 { + t.Fatalf("replacement parent command_move authorizations=%d, want 1", len(got)) + } +} + +func TestCrossPlatformCoverageCommandMoveRejectsAncestorOverlap(t *testing.T) { + for _, test := range []struct { + name string + legacy string + replacement string + }{ + { + name: "legacy ancestor", + legacy: "dws chat message", + replacement: "dws chat message list", + }, + { + name: "replacement ancestor", + legacy: "dws chat message list", + replacement: "dws chat message", + }, + } { + t.Run(test.name, func(t *testing.T) { + migration := commandMigrationManifest(CommandMigrationPending).Migrations[0] + migration.Legacy.Command = test.legacy + migration.Replacement.Command = test.replacement + + if err := migration.validate(); err == nil || !strings.Contains(err.Error(), "must not have an ancestor relationship") { + t.Fatalf("overlapping command_move error=%v, want ancestor-overlap rejection", err) + } + }) + } +} + +func TestCrossPlatformCoverageCommandMigrationLifecycleEdges(t *testing.T) { + before := commandMigrationSnapshot(false, false) + after := commandMigrationSnapshot(true, false) + partial := commandMigrationSnapshot(false, false) + partial.Commands = append(partial.Commands, testCommand("dws chat topic new")) + pending := singleCommandMigrationManifest(CommandMigrationPending) + consumed := singleCommandMigrationManifest(CommandMigrationConsumed) + empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}} + + if got, err := AuthorizeCommandMigrations(before, map[string]Snapshot{}, empty, empty); err != nil || len(got) != 0 { + t.Fatalf("empty lifecycle=%#v, %v", got, err) + } + for _, test := range []struct { + name string + current Snapshot + references map[string]Snapshot + authority CommandMigrationManifest + candidate CommandMigrationManifest + want string + }{ + {"missing stable", before, map[string]Snapshot{"main": before}, pending, pending, "stable reference"}, + {"missing base", before, map[string]Snapshot{"stable": before}, pending, pending, "main or merge-base"}, + {"pending base after", after, map[string]Snapshot{"main": after, "stable": before}, pending, pending, "want exact before"}, + {"modified approval", before, map[string]Snapshot{"main": before, "stable": before}, pending, modifiedCommandManifest(pending), "modified base-owned"}, + {"pending removed", before, map[string]Snapshot{"main": before, "stable": before}, pending, empty, "removed pending"}, + {"false consumed", before, map[string]Snapshot{"main": before, "stable": before}, pending, consumed, "falsely consumed"}, + {"after pending receipt", after, map[string]Snapshot{"main": before, "stable": before}, pending, pending, "without marking it consumed"}, + {"partial", partial, map[string]Snapshot{"main": before, "stable": before}, pending, consumed, "partially applied"}, + {"consumed drift", before, map[string]Snapshot{"main": after, "stable": before}, consumed, consumed, "drifted from consumed"}, + {"stale receipt", after, map[string]Snapshot{"main": after, "stable": after}, consumed, consumed, "stale after all references"}, + {"early cleanup", after, map[string]Snapshot{"main": after, "stable": before}, consumed, empty, "must retain consumed"}, + {"consumed back to pending", after, map[string]Snapshot{"main": after, "stable": before}, consumed, pending, "must retain consumed"}, + {"candidate added consumed", after, map[string]Snapshot{"main": before, "stable": before}, empty, consumed, "must start pending"}, + {"candidate base mismatch", before, map[string]Snapshot{"main": after, "stable": before}, empty, pending, "does not match"}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := AuthorizeCommandMigrations(test.current, test.references, test.authority, test.candidate) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("lifecycle error=%v, want %q", err, test.want) + } + }) + } + if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": before}, consumed, consumed); err != nil || len(got) != 1 { + t.Fatalf("retained consumed receipt=%#v, %v", got, err) + } + if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": after}, consumed, empty); err != nil || len(got) != 0 { + t.Fatalf("cleaned stale receipt=%#v, %v", got, err) + } + if got, err := AuthorizeCommandMigrations(before, map[string]Snapshot{"main": before, "stable": before}, empty, pending); err != nil || len(got) != 0 { + t.Fatalf("candidate pending plan=%#v, %v", got, err) + } + + invalidSnapshot := before + invalidSnapshot.SchemaVersion = 0 + for _, test := range []struct { + name string + current Snapshot + references map[string]Snapshot + authority CommandMigrationManifest + candidate CommandMigrationManifest + }{ + {"current", invalidSnapshot, map[string]Snapshot{"main": before}, empty, empty}, + {"reference", before, map[string]Snapshot{"main": invalidSnapshot}, empty, empty}, + {"authority", before, map[string]Snapshot{"main": before}, CommandMigrationManifest{}, empty}, + {"candidate", before, map[string]Snapshot{"main": before}, empty, CommandMigrationManifest{}}, + } { + t.Run("invalid "+test.name, func(t *testing.T) { + if _, err := AuthorizeCommandMigrations(test.current, test.references, test.authority, test.candidate); err == nil { + t.Fatal("invalid authorization input accepted") + } + }) + } + invalidFlags := FlagMigrationManifest{} + validFlags := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}} + validCommands := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}} + if _, err := CompareAllWithInterfaceMigrations(before, map[string]Snapshot{"main": before}, invalidFlags, validFlags, validCommands, validCommands); err == nil { + t.Fatal("combined compare accepted invalid flag lifecycle") + } + if _, err := CompareAllWithInterfaceMigrations(before, map[string]Snapshot{"main": before}, validFlags, validFlags, CommandMigrationManifest{}, validCommands); err == nil { + t.Fatal("combined compare accepted invalid command lifecycle") + } + if commandMigrationAuthorizesChange(before, before, Change{Kind: "command_removed", Path: "dws unrelated"}, pending.Migrations) { + t.Fatal("unrelated command change was authorized") + } +} + +func TestCrossPlatformCoverageCommandMigrationLifecycleAndExactFiltering(t *testing.T) { + before := commandMigrationSnapshot(false, false) + after := commandMigrationSnapshot(true, false) + pending := commandMigrationManifest(CommandMigrationPending) + consumed := commandMigrationManifest(CommandMigrationConsumed) + emptyFlags := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}} + + report, err := CompareAllWithInterfaceMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + emptyFlags, + emptyFlags, + pending, + consumed, + ) + if err != nil { + t.Fatalf("governed command migration: %v", err) + } + if !report.Compatible { + t.Fatalf("exact command migration remained blocking: %#v", report.Comparisons) + } + + unrelated := commandMigrationSnapshot(true, true) + report, err = CompareAllWithInterfaceMigrations( + unrelated, + map[string]Snapshot{"merge-base": before, "stable": before}, + emptyFlags, + emptyFlags, + pending, + consumed, + ) + if err != nil { + t.Fatal(err) + } + if report.Compatible || !hasChangeKind(report.Comparisons[0].Blocking, "flag_removed") { + t.Fatalf("unrelated flag removal was hidden: %#v", report.Comparisons) + } + + emptyCommands := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}} + if _, err := AuthorizeCommandMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + emptyCommands, + pending, + ); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") { + t.Fatalf("candidate self-authorization error=%v", err) + } + + partial := commandMigrationSnapshot(true, false) + partial.Commands = partial.Commands[:len(partial.Commands)-1] + if _, err := AuthorizeCommandMigrations( + partial, + map[string]Snapshot{"merge-base": before, "stable": before}, + pending, + consumed, + ); err == nil || !strings.Contains(err.Error(), "partially applied") { + t.Fatalf("partial command migration error=%v", err) + } +} + +func TestCrossPlatformCoverageFlagExtractionRequiresReplacementConstantEvidence(t *testing.T) { + before := commandMigrationSnapshot(false, false) + after := commandMigrationSnapshot(true, false) + pending := flagExtractionCommandMigrationManifest(CommandMigrationPending) + consumed := flagExtractionCommandMigrationManifest(CommandMigrationConsumed) + empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}} + migration := pending.Migrations[0] + + if phase := matchCommandMigrationPhase(before, migration); phase != commandMigrationBefore { + t.Fatalf("before phase=%s, want %s", phase, commandMigrationBefore) + } + if phase := matchCommandMigrationPhase(after, migration); phase != commandMigrationAfter { + t.Fatalf("after phase=%s, want %s", phase, commandMigrationAfter) + } + + for _, test := range []struct { + name string + values map[string]bool + }{ + {name: "missing", values: nil}, + {name: "wrong property", values: map[string]bool{"otherProperty": true}}, + {name: "wrong value", values: map[string]bool{"convThreadEnabled": false}}, + {name: "extra", values: map[string]bool{"convThreadEnabled": true, "otherProperty": true}}, + } { + t.Run(test.name, func(t *testing.T) { + current := withCommandBoolConstParams(after, "dws chat topic create", test.values) + if phase := matchCommandMigrationPhase(current, migration); phase != commandMigrationPartial { + t.Fatalf("phase=%s, want %s", phase, commandMigrationPartial) + } + if _, err := AuthorizeCommandMigrations( + current, + map[string]Snapshot{"merge-base": before, "stable": before}, + pending, + consumed, + ); err == nil || !strings.Contains(err.Error(), "partially applied") { + t.Fatalf("constant evidence lifecycle error=%v, want partial rejection", err) + } + if _, err := AuthorizeCommandMigrations( + current, + map[string]Snapshot{"merge-base": before, "stable": before}, + empty, + pending, + ); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") { + t.Fatalf("constant evidence self-authorization error=%v", err) + } + }) + } +} + +func commandMigrationManifestJSON() string { + return `{ + "version": 1, + "migrations": [{ + "kind": "command_move", + "legacy": { + "command": "dws chat message old", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true, "hidden": true} + }, + "replacement": { + "command": "dws chat topic new", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.old", + "replacement_tool_id": "chat.old", + "parameters": [{"from": "old-id", "to": "new-id"}] + }, + "state": "pending", + "reason": "Reviewed command move." + }] +}` +} + +func flagExtractionCommandMigrationManifestJSON() string { + return `{ + "version": 1, + "migrations": [{ + "kind": "flag_extraction", + "legacy": { + "command": "dws chat group create", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true} + }, + "replacement": { + "command": "dws chat topic create", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "legacy_flag": { + "name": "thread", + "before": {"present": true, "type": "bool", "no_opt": "true", "scope": "local"}, + "after": {"present": true, "type": "bool", "hidden": true, "no_opt": "true", "scope": "local"} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.create_group", + "replacement_tool_id": "chat.create_topic", + "parameters": [ + {"from":"name","to":"name"}, + {"from":"type","to":"type"}, + {"from":"users","to":"users"}, + {"from":"thread","replacement_constant":{"property":"convThreadEnabled","value":true}} + ] + }, + "state": "pending", + "reason": "Reviewed flag extraction." + }] +}` +} + +func commandMigrationManifest(state string) CommandMigrationManifest { + move := CommandMigration{ + Kind: CommandMigrationMove, + Legacy: CommandMigrationSide{ + Command: "dws chat message old", + Before: CommandMigrationState{Present: true, Runnable: true}, + After: CommandMigrationState{Present: true, Runnable: true, Hidden: true}, + }, + Replacement: CommandMigrationSide{ + Command: "dws chat topic new", + Before: CommandMigrationState{}, + After: CommandMigrationState{Present: true, Runnable: true}, + }, + Schema: CommandMigrationSchema{ + ProductID: "chat", + SourceToolID: "chat.old", + ReplacementToolID: "chat.old", + Parameters: []CommandParameterMigration{{From: "old-id", To: "new-id"}}, + }, + State: state, + Reason: "Reviewed command move.", + } + extraction := CommandMigration{ + Kind: CommandMigrationFlagExtraction, + Legacy: CommandMigrationSide{ + Command: "dws chat group create", + Before: CommandMigrationState{Present: true, Runnable: true}, + After: CommandMigrationState{Present: true, Runnable: true}, + }, + Replacement: CommandMigrationSide{ + Command: "dws chat topic create", + Before: CommandMigrationState{}, + After: CommandMigrationState{Present: true, Runnable: true}, + }, + LegacyFlag: CommandMigrationFlag{ + Name: "thread", + Before: FlagMigrationState{Present: true, Type: "bool", NoOpt: "true", Scope: "local"}, + After: FlagMigrationState{Present: true, Type: "bool", Hidden: true, NoOpt: "true", Scope: "local"}, + }, + Schema: CommandMigrationSchema{ + ProductID: "chat", + SourceToolID: "chat.create_group", + ReplacementToolID: "chat.create_topic", + Parameters: []CommandParameterMigration{ + {From: "name", To: "name"}, + {From: "type", To: "type"}, + {From: "users", To: "users"}, + { + From: "thread", + ReplacementConstant: &CommandReplacementConstant{ + Property: "convThreadEnabled", + Value: true, + }, + }, + }, + }, + State: state, + Reason: "Reviewed flag extraction.", + } + return CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{move, extraction}} +} + +func flagExtractionCommandMigrationManifest(state string) CommandMigrationManifest { + manifest := commandMigrationManifest(state) + manifest.Migrations = manifest.Migrations[1:] + return manifest +} + +func cloneCommandMigration(source CommandMigration) CommandMigration { + cloned := source + cloned.Schema.Parameters = append([]CommandParameterMigration(nil), source.Schema.Parameters...) + for index, parameter := range source.Schema.Parameters { + if parameter.ReplacementConstant == nil { + continue + } + constant := *parameter.ReplacementConstant + cloned.Schema.Parameters[index].ReplacementConstant = &constant + } + return cloned +} + +func singleCommandMigrationManifest(state string) CommandMigrationManifest { + manifest := commandMigrationManifest(state) + manifest.Migrations = manifest.Migrations[:1] + return manifest +} + +func modifiedCommandManifest(source CommandMigrationManifest) CommandMigrationManifest { + modified := source + modified.Migrations = append([]CommandMigration(nil), source.Migrations...) + modified.Migrations[0].Reason = "Modified reason." + return modified +} + +func commandMigrationSnapshot(after, removeUnrelated bool) Snapshot { + oldFlags := []Flag{{Name: "old-id", Type: "string", Required: true}, {Name: "keep", Type: "string"}} + groupFlags := []Flag{{Name: "thread", Type: "bool", NoOpt: "true"}} + commands := []Command{ + testCommand("dws"), + testCommand("dws chat group create", groupFlags...), + testCommand("dws chat message old", oldFlags...), + } + if after { + commands[1].LocalFlags[0].Hidden = true + commands[2].Hidden = true + if removeUnrelated { + commands[2].LocalFlags = commands[2].LocalFlags[:1] + } + topicCreate := testCommand("dws chat topic create") + topicCreate.BoolConstParams = map[string]bool{"convThreadEnabled": true} + commands = append(commands, + topicCreate, + testCommand("dws chat topic new", Flag{Name: "new-id", Type: "string", Required: true}), + ) + } + return testSnapshot(commands...) +} + +func withCommandBoolConstParams(snapshot Snapshot, path string, values map[string]bool) Snapshot { + cloned := snapshot + cloned.Commands = append([]Command(nil), snapshot.Commands...) + for index := range cloned.Commands { + if cloned.Commands[index].Path == path { + cloned.Commands[index].BoolConstParams = values + break + } + } + return cloned +} diff --git a/internal/interfacesnapshot/compare.go b/internal/interfacesnapshot/compare.go index e520626d..5de0dd9e 100644 --- a/internal/interfacesnapshot/compare.go +++ b/internal/interfacesnapshot/compare.go @@ -71,6 +71,9 @@ func CompareAll(current Snapshot, references map[string]Snapshot) Report { // alias), // - flags accepted at each command path may not disappear, change type, or // become required; an existing path may not gain a new required flag, +// - once bool ConstParams evidence exists, its exact property/value map is a +// durable contract; adding the first evidence to an older snapshot remains +// a silent bootstrap, // - new commands and flags are allowed. // // The single exception to the type rule is an individually reviewed migration @@ -153,6 +156,14 @@ func Compare(current, baseline Snapshot, reference string) Comparison { } func compareCommandContract(result *Comparison, acceptedPath string, oldCommand, newCommand Command) { + if len(oldCommand.BoolConstParams) > 0 && !reflect.DeepEqual(oldCommand.BoolConstParams, newCommand.BoolConstParams) { + result.Blocking = append(result.Blocking, Change{ + Kind: "bool_const_params_changed", + Path: acceptedPath, + Before: fmt.Sprintf("%v", oldCommand.BoolConstParams), + After: fmt.Sprintf("%v", newCommand.BoolConstParams), + }) + } if oldCommand.Runnable && !newCommand.Runnable { result.Blocking = append(result.Blocking, Change{ Kind: "command_became_non_runnable", diff --git a/internal/interfacesnapshot/compare_test.go b/internal/interfacesnapshot/compare_test.go index 9dab9936..e73aa074 100644 --- a/internal/interfacesnapshot/compare_test.go +++ b/internal/interfacesnapshot/compare_test.go @@ -135,6 +135,63 @@ func TestCrossPlatformCoverageCompareAllowsRenameWhenOldPathIsAlias(t *testing.T } } +func TestCrossPlatformCoverageCompareAllowsBoolConstParamsBootstrapAddition(t *testing.T) { + base := testSnapshot( + testCommand("dws"), + testCommand("dws send"), + ) + current := testSnapshot( + testCommand("dws"), + testCommand("dws send"), + ) + current.Commands[1].BoolConstParams = map[string]bool{"convThreadEnabled": true} + + comparison := Compare(current, base, "base") + if !comparison.Compatible || len(comparison.Blocking) != 0 || len(comparison.Additions) != 0 { + t.Fatalf("ordinary Compare treated bootstrapped bool ConstParams evidence as a compatibility change: %#v", comparison) + } +} + +func TestCrossPlatformCoverageCompareBlocksBoolConstParamsDriftAfterBootstrap(t *testing.T) { + base := testSnapshot( + testCommand("dws"), + testCommand("dws send"), + ) + base.Commands[1].BoolConstParams = map[string]bool{"convThreadEnabled": true} + + for _, test := range []struct { + name string + current map[string]bool + }{ + {name: "removed", current: nil}, + {name: "value flipped", current: map[string]bool{"convThreadEnabled": false}}, + {name: "extra key", current: map[string]bool{"convThreadEnabled": true, "other": false}}, + } { + t.Run(test.name, func(t *testing.T) { + current := testSnapshot( + testCommand("dws"), + testCommand("dws send"), + ) + current.Commands[1].BoolConstParams = test.current + + comparison := Compare(current, base, "base") + if comparison.Compatible { + t.Fatalf("historical bool ConstParams drift was accepted: %#v", comparison) + } + if len(comparison.Blocking) != 1 { + t.Fatalf("blocking=%#v, want exactly one durable contract change", comparison.Blocking) + } + change := comparison.Blocking[0] + if change.Kind != "bool_const_params_changed" || change.Path != "dws send" { + t.Fatalf("blocking=%#v, want bool_const_params_changed at dws send", comparison.Blocking) + } + if len(comparison.Additions) != 0 { + t.Fatalf("bool ConstParams drift also produced additions: %#v", comparison.Additions) + } + }) + } +} + func TestCrossPlatformCoverageCompareBlocksRemovedAlias(t *testing.T) { base := testSnapshot( testCommand("dws"), diff --git a/internal/interfacesnapshot/migrations.go b/internal/interfacesnapshot/migrations.go index 892a7c55..a16395f8 100644 --- a/internal/interfacesnapshot/migrations.go +++ b/internal/interfacesnapshot/migrations.go @@ -91,22 +91,32 @@ func validateFlagMigrationJSONSchema(data []byte) error { } func validateMigrationJSONValue(decoder *json.Decoder, path string, schema reflect.Type) error { + return validateLabeledMigrationJSONValue(decoder, path, schema, "flag") +} + +func validateLabeledMigrationJSONValue(decoder *json.Decoder, path string, schema reflect.Type, label string) error { token, err := decoder.Token() if err != nil { - return fmt.Errorf("read flag migration manifest value at %s: %w", path, err) + return fmt.Errorf("read %s migration manifest value at %s: %w", label, path, err) + } + for schema.Kind() == reflect.Pointer { + if token == nil { + return migrationJSONTypeError(path, schema.Elem(), token, label) + } + schema = schema.Elem() } switch schema.Kind() { case reflect.Struct: if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' { - return migrationJSONTypeError(path, schema, token) + return migrationJSONTypeError(path, schema, token, label) } fields := migrationJSONFields(schema) seen := make(map[string]bool, len(fields)) for decoder.More() { keyToken, keyErr := decoder.Token() if keyErr != nil { - return fmt.Errorf("read flag migration manifest field at %s: %w", path, keyErr) + return fmt.Errorf("read %s migration manifest field at %s: %w", label, path, keyErr) } // encoding/json guarantees object member names are string tokens. key := keyToken.(string) @@ -115,57 +125,58 @@ func validateMigrationJSONValue(decoder *json.Decoder, path string, schema refle for canonical := range fields { if strings.EqualFold(key, canonical) { return fmt.Errorf( - "flag migration manifest contains non-canonical field %q at %s (want %q)", + "%s migration manifest contains non-canonical field %q at %s (want %q)", + label, key, path, canonical, ) } } - return fmt.Errorf("flag migration manifest contains unknown field %q at %s", key, path) + return fmt.Errorf("%s migration manifest contains unknown field %q at %s", label, key, path) } if seen[key] { - return fmt.Errorf("flag migration manifest contains duplicate field %q at %s", key, path) + return fmt.Errorf("%s migration manifest contains duplicate field %q at %s", label, key, path) } seen[key] = true - if err := validateMigrationJSONValue(decoder, path+"."+key, fieldSchema); err != nil { + if err := validateLabeledMigrationJSONValue(decoder, path+"."+key, fieldSchema, label); err != nil { return err } } if _, closeErr := decoder.Token(); closeErr != nil { - return fmt.Errorf("close flag migration manifest object at %s: %w", path, closeErr) + return fmt.Errorf("close %s migration manifest object at %s: %w", label, path, closeErr) } return nil case reflect.Slice: if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' { - return migrationJSONTypeError(path, schema, token) + return migrationJSONTypeError(path, schema, token, label) } for index := 0; decoder.More(); index++ { - if err := validateMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem()); err != nil { + if err := validateLabeledMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem(), label); err != nil { return err } } if _, closeErr := decoder.Token(); closeErr != nil { - return fmt.Errorf("close flag migration manifest array at %s: %w", path, closeErr) + return fmt.Errorf("close %s migration manifest array at %s: %w", label, path, closeErr) } return nil case reflect.String: if _, ok := token.(string); !ok { - return migrationJSONTypeError(path, schema, token) + return migrationJSONTypeError(path, schema, token, label) } return nil case reflect.Int: if _, ok := token.(json.Number); !ok { - return migrationJSONTypeError(path, schema, token) + return migrationJSONTypeError(path, schema, token, label) } return nil case reflect.Bool: if _, ok := token.(bool); !ok { - return migrationJSONTypeError(path, schema, token) + return migrationJSONTypeError(path, schema, token, label) } return nil default: - return fmt.Errorf("flag migration manifest value at %s has unsupported Go schema type %s", path, schema) + return fmt.Errorf("%s migration manifest value at %s has unsupported Go schema type %s", label, path, schema) } } @@ -188,9 +199,10 @@ func migrationJSONFields(schema reflect.Type) map[string]reflect.Type { return fields } -func migrationJSONTypeError(path string, want reflect.Type, token json.Token) error { +func migrationJSONTypeError(path string, want reflect.Type, token json.Token, label string) error { return fmt.Errorf( - "flag migration manifest value at %s must be %s, got %s", + "%s migration manifest value at %s must be %s, got %s", + label, path, migrationJSONKindDescription(want), migrationJSONTokenDescription(token), diff --git a/internal/interfacesnapshot/snapshot.go b/internal/interfacesnapshot/snapshot.go index 408805d3..11a7bf46 100644 --- a/internal/interfacesnapshot/snapshot.go +++ b/internal/interfacesnapshot/snapshot.go @@ -22,12 +22,13 @@ import ( "sort" "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate" "github.com/spf13/cobra" "github.com/spf13/pflag" ) -const SchemaVersion = 2 +const SchemaVersion = 3 // FlagAliasOfAnnotation records a framework-originated reviewed relationship // between a retained compatibility flag and an exact canonical Cobra flag. @@ -66,13 +67,14 @@ type Snapshot struct { // Command contains compatibility-relevant command metadata. Path always // includes the root command name (for example, "dws chat message send"). type Command struct { - Path string `json:"path"` - Runnable bool `json:"runnable"` - Hidden bool `json:"hidden"` - Deprecated string `json:"deprecated,omitempty"` - Aliases []string `json:"aliases"` - LocalFlags []Flag `json:"local_flags"` - InheritedFlags []Flag `json:"inherited_flags"` + Path string `json:"path"` + Runnable bool `json:"runnable"` + Hidden bool `json:"hidden"` + Deprecated string `json:"deprecated,omitempty"` + Aliases []string `json:"aliases"` + LocalFlags []Flag `json:"local_flags"` + InheritedFlags []Flag `json:"inherited_flags"` + BoolConstParams map[string]bool `json:"bool_const_params,omitempty"` } // Flag contains the stable pflag contract visible at a command node. @@ -126,14 +128,16 @@ func Capture(root *cobra.Command) Snapshot { } aliases = compactSorted(aliases) + boolConstParams := corecmd.InterfaceBoolConstParams(cmd) snapshot.Commands = append(snapshot.Commands, Command{ - Path: path, - Runnable: cmd.Runnable(), - Hidden: cmd.Hidden, - Deprecated: strings.TrimSpace(cmd.Deprecated), - Aliases: aliases, - LocalFlags: captureFlags(cmd.LocalFlags()), - InheritedFlags: captureFlags(cmd.InheritedFlags()), + Path: path, + Runnable: cmd.Runnable(), + Hidden: cmd.Hidden, + Deprecated: strings.TrimSpace(cmd.Deprecated), + Aliases: aliases, + LocalFlags: captureFlags(cmd.LocalFlags()), + InheritedFlags: captureFlags(cmd.InheritedFlags()), + BoolConstParams: boolConstParams, }) children := append([]*cobra.Command(nil), cmd.Commands()...) @@ -198,6 +202,9 @@ func (s Snapshot) Validate() error { return fmt.Errorf("interface snapshot contains duplicate command path %q", command.Path) } seenCommands[command.Path] = true + if command.BoolConstParams != nil && len(command.BoolConstParams) == 0 { + return fmt.Errorf("command %q must omit empty bool_const_params", command.Path) + } if err := validateFlags(command.Path, "local", command.LocalFlags); err != nil { return err } diff --git a/internal/interfacesnapshot/snapshot_test.go b/internal/interfacesnapshot/snapshot_test.go index eb27274e..2bb6324d 100644 --- a/internal/interfacesnapshot/snapshot_test.go +++ b/internal/interfacesnapshot/snapshot_test.go @@ -17,6 +17,7 @@ import ( "bytes" "encoding/json" "reflect" + "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" @@ -98,16 +99,105 @@ func TestCrossPlatformCoverageCaptureUsesStableNoiseRulesAndFlagScopes(t *testin func TestCrossPlatformCoverageReadRejectsUnknownSnapshotFields(t *testing.T) { input := bytes.NewBufferString(`{ - "schema_version": 2, - "rules": {"excluded_command_subtrees": [], "excluded_flags": []}, - "commands": [], - "future_field": true + "schema_version": 3, + "rules": {"excluded_command_subtrees": [], "excluded_flags": []}, + "commands": [], + "future_field": true }`) if _, err := Read(input); err == nil { t.Fatal("Read accepted an unknown field") } } +func TestCrossPlatformCoverageCaptureRoundTripsFrameworkBoolConstParams(t *testing.T) { + root := &cobra.Command{Use: "dws"} + leaf := corecmd.New(corecmd.Spec{ + Use: "send", + ConstParams: map[string]any{"convThreadEnabled": true, "precheckOnly": false}, + Invoke: func(*corecmd.Ctx, map[string]any) error { return nil }, + }) + root.AddCommand(leaf) + + snapshot := Capture(root) + got := commandIndex(snapshot)["dws send"].BoolConstParams + want := map[string]bool{"convThreadEnabled": true, "precheckOnly": false} + if !reflect.DeepEqual(got, want) { + t.Fatalf("captured bool ConstParams = %#v, want %#v", got, want) + } + + var encoded bytes.Buffer + if err := Write(&encoded, snapshot); err != nil { + t.Fatalf("Write: %v", err) + } + decoded, err := Read(bytes.NewReader(encoded.Bytes())) + if err != nil { + t.Fatalf("Read: %v", err) + } + if got := commandIndex(decoded)["dws send"].BoolConstParams; !reflect.DeepEqual(got, want) { + t.Fatalf("round-tripped bool ConstParams = %#v, want %#v", got, want) + } +} + +func TestCrossPlatformCoverageCaptureIgnoresHandwrittenLegacyConstParamsAnnotations(t *testing.T) { + root := &cobra.Command{Use: "dws"} + direct := &cobra.Command{ + Use: "direct", + Run: func(*cobra.Command, []string) {}, + Annotations: map[string]string{ + "dws.compat.bool_const_params": `{"forged":true}`, + "dws.compat.const_params_origin": "corecmd.const_params.v1", + }, + } + businessConstructor := func() *cobra.Command { + payloadKey := strings.Join([]string{"dws.compat.bool_", "const_params"}, "") + originKey := strings.Join([]string{"dws.compat.const_", "params_origin"}, "") + origin := strings.Join([]string{"corecmd.const_", "params.v1"}, "") + return &cobra.Command{ + Use: "business", + Run: func(*cobra.Command, []string) {}, + Annotations: map[string]string{ + payloadKey: `{"forged":true}`, + originKey: origin, + }, + } + } + root.AddCommand(direct, businessConstructor()) + + snapshot := Capture(root) + if err := snapshot.Validate(); err != nil { + t.Fatalf("handwritten legacy annotations affected snapshot validity: %v", err) + } + for _, path := range []string{"dws direct", "dws business"} { + if got := commandIndex(snapshot)[path].BoolConstParams; got != nil { + t.Fatalf("%s forged bool ConstParams evidence = %#v", path, got) + } + } + var encoded bytes.Buffer + if err := Write(&encoded, snapshot); err != nil { + t.Fatalf("Write snapshot without forged evidence: %v", err) + } +} + +func TestCrossPlatformCoverageReadRejectsMalformedBoolConstParams(t *testing.T) { + for _, payload := range []string{`{"fixed":"true"}`, `{}`} { + input := bytes.NewBufferString(`{ + "schema_version": 3, + "rules": {"excluded_command_subtrees": [], "excluded_flags": []}, + "commands": [{ + "path": "dws send", + "runnable": true, + "aliases": [], + "local_flags": [], + "inherited_flags": [], + "bool_const_params": ` + payload + ` + }] + }`) + if _, err := Read(input); err == nil { + t.Fatalf("Read accepted malformed bool_const_params %s", payload) + } + } +} + func TestCrossPlatformCoverageCaptureRoundTripsFrameworkFlagAlias(t *testing.T) { if FlagAliasOfAnnotation != runtimeannotate.AnnotationFlagAliasOf { t.Fatalf( diff --git a/internal/shortcut/attendance/attendance.go b/internal/shortcut/attendance/attendance.go index 067f922c..5d62242c 100644 --- a/internal/shortcut/attendance/attendance.go +++ b/internal/shortcut/attendance/attendance.go @@ -26,6 +26,7 @@ package attendance import ( "encoding/json" "fmt" + "math" "strconv" "strings" "time" @@ -34,6 +35,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" ) const ( @@ -86,7 +88,7 @@ func flexDateTime(s string) (string, error) { } // dateToMillis parses YYYY-MM-DD / datetime; when endOfDay is set a bare date -// resolves to 23:59:59 (mirrors helper parseDateToTimestamp "end" behaviour). +// resolves to the final millisecond before the next local calendar day. func dateToMillis(s string, endOfDay bool) (int64, error) { s = strings.TrimSpace(s) if t, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local); err == nil { @@ -94,7 +96,7 @@ func dateToMillis(s string, endOfDay bool) (int64, error) { } if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil { if endOfDay { - return t.Add(23*time.Hour + 59*time.Minute + 59*time.Second).UnixMilli(), nil + return t.AddDate(0, 0, 1).Add(-time.Millisecond).UnixMilli(), nil } return t.UnixMilli(), nil } @@ -197,14 +199,43 @@ var CheckResult = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔,最多 100 人", Required: true}, - {Name: "start", Type: shortcut.FlagString, Desc: "起始日期 YYYY-MM-DD", Required: true}, - {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD,跨度不超过 1 个月", Required: true}, - {Name: "offset", Type: shortcut.FlagInt, Default: "0", Desc: "分页偏移量,默认 0"}, - {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "分页大小,1-1000,默认 100"}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "--users 不能为空,用户 ID 不能重复,最多 100 个,逗号分隔", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "--start 必须是 YYYY-MM-DD", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "--end 必须是 YYYY-MM-DD,不能早于 --start,且与 --start 跨度不超过 1 个月", Required: true}, + {Name: "offset", Type: shortcut.FlagInt, Default: "0", Desc: "--offset 不能小于 0,默认 0"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "--limit 必须大于 0 且不超过 1000,默认 100"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"users"}, Description: "--users 不能为空,用户 ID 不能重复,最多 100 个"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start"}, Description: "--start 必须是 YYYY-MM-DD"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"end"}, Description: "--end 必须是 YYYY-MM-DD,不能早于 --start,且与 --start 跨度不超过 1 个月"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0 且不超过 1000"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"offset"}, Description: "--offset 不能小于 0"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := attendanceValidateUserIDs(rt.StrSlice("users"), 100); err != nil { + return err + } + if err := attendanceValidateMonthRange(rt.Str("start"), rt.Str("end")); err != nil { + return err + } + if limit := rt.Int("limit"); limit < 1 || limit > 1000 { + return fmt.Errorf("--limit 必须在 1 到 1000 之间") + } + if rt.Int("offset") < 0 { + return fmt.Errorf("--offset 不能小于 0") + } + return nil }, Tips: []string{`dws attendance +check-result --users userId1,userId2 --start 2026-04-01 --end 2026-04-30 --limit 50`}, Execute: func(rt *shortcut.RuntimeContext) error { + limit, offset := rt.Int("limit"), rt.Int("offset") + if limit < 1 || limit > 1000 { + return fmt.Errorf("--limit 必须在 1 到 1000 之间") + } + if offset < 0 { + return fmt.Errorf("--offset 不能小于 0") + } from, err := dayDateTime(rt.Str("start")) if err != nil { return err @@ -213,15 +244,40 @@ var CheckResult = shortcut.Shortcut{ if err != nil { return err } - return rt.CallMCP("query_check_result", map[string]any{ + params := map[string]any{ "QueryCheckResultRequest": map[string]any{ "userIds": rt.StrSlice("users"), "workDateFrom": from, "workDateTo": to, - "offset": rt.Int("offset"), - "limit": rt.Int("limit"), + "offset": offset, + "limit": limit, }, - }) + } + data, err := rt.CallMCPData(serverWukong, "query_check_result", params) + if err != nil { + return err + } + records, err := responsecheck.RequireObjectCollection(data, serverWukong+"/query_check_result", "result") + if err != nil { + return err + } + if err := attendanceValidatePositiveIntegerIDs(records, serverWukong+"/query_check_result", "id"); err != nil { + return err + } + startMillis, _ := dayMillis(rt.Str("start")) + endMillis, _ := dateToMillis(rt.Str("end"), true) + if err := attendanceValidateUserAndTimeBinding(records, serverWukong+"/query_check_result", rt.StrSlice("users"), "userId", "workDate", startMillis, endMillis); err != nil { + return err + } + complete := len(records) < limit + extra := map[string]any{"limit": limit} + nextToken := "" + if !complete { + nextOffset := offset + len(records) + extra["nextOffset"] = nextOffset + nextToken = strconv.Itoa(nextOffset) + } + return attendanceOutputCollection(rt, "records", records, complete, extra, true, nextToken) }, } @@ -259,9 +315,20 @@ var CheckRecord = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔", Required: true}, - {Name: "start", Type: shortcut.FlagString, Desc: "起始日期 YYYY-MM-DD", Required: true}, - {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD,跨度不超过 1 个月", Required: true}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "--users 不能为空,用户 ID 不能重复,逗号分隔", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "--start 必须是 YYYY-MM-DD", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "--end 必须是 YYYY-MM-DD,不能早于 --start,且与 --start 跨度不超过 1 个月", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"users"}, Description: "--users 不能为空,用户 ID 不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start"}, Description: "--start 必须是 YYYY-MM-DD"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"end"}, Description: "--end 必须是 YYYY-MM-DD,不能早于 --start,且与 --start 跨度不超过 1 个月"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := attendanceValidateUserIDs(rt.StrSlice("users"), 0); err != nil { + return err + } + return attendanceValidateMonthRange(rt.Str("start"), rt.Str("end")) }, Tips: []string{`dws attendance +check-record --users userId1 --start 2026-04-01 --end 2026-04-30`}, Execute: func(rt *shortcut.RuntimeContext) error { @@ -273,13 +340,30 @@ var CheckRecord = shortcut.Shortcut{ if err != nil { return err } - return rt.CallMCP("query_check_record", map[string]any{ + data, err := rt.CallMCPData(serverWukong, "query_check_record", map[string]any{ "QueryCheckRecordRequest": map[string]any{ "userIds": rt.StrSlice("users"), "checkDateFrom": from, "checkDateTo": to, }, }) + if err != nil { + return err + } + operation := serverWukong + "/query_check_record" + items, err := responsecheck.RequireObjectCollection(data, operation, "result") + if err != nil { + return err + } + if err := attendanceValidatePositiveIntegerIDs(items, operation, "id"); err != nil { + return err + } + startMillis, _ := dayMillis(rt.Str("start")) + endMillis, _ := dateToMillis(rt.Str("end"), true) + if err := attendanceValidateUserAndTimeBinding(items, operation, rt.StrSlice("users"), "userId", "userCheckTime", startMillis, endMillis); err != nil { + return err + } + return attendanceOutputCollection(rt, "records", items, true, nil, false, "") }, } @@ -319,10 +403,47 @@ var ListApprove = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔", Required: true}, - {Name: "types", Type: shortcut.FlagStringSlice, Desc: "审批类型,逗号分隔:overtime/加班、trip/travel/出差/外出、leave/请假、patch/补卡", Required: true}, - {Name: "start", Type: shortcut.FlagString, Desc: "起始日期 YYYY-MM-DD", Required: true}, - {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD", Required: true}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "--users 不能为空,用户 ID 不能重复,逗号分隔", Required: true}, + {Name: "types", Type: shortcut.FlagStringSlice, Desc: "--types 不能为空,映射后的审批类型不能重复;overtime/加班、trip/travel/出差/外出、leave/请假、patch/补卡", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "--start 必须是 YYYY-MM-DD", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "--end 必须是 YYYY-MM-DD,且不能早于 --start", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"users"}, Description: "--users 不能为空,用户 ID 不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"types"}, Description: "--types 不能为空,映射后的审批类型不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start"}, Description: "--start 必须是 YYYY-MM-DD"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"end"}, Description: "--end 必须是 YYYY-MM-DD,且不能早于 --start"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := attendanceValidateUserIDs(rt.StrSlice("users"), 0); err != nil { + return err + } + start, err := dayMillis(rt.Str("start")) + if err != nil { + return err + } + end, err := dayMillis(rt.Str("end")) + if err != nil { + return err + } + if end < start { + return fmt.Errorf("--end 不能早于 --start") + } + seen := map[int]struct{}{} + for _, value := range rt.StrSlice("types") { + mapped, ok := approveTypeMapping[strings.ToLower(strings.TrimSpace(value))] + if !ok { + return fmt.Errorf("无效的审批类型: %s", value) + } + if _, duplicate := seen[mapped]; duplicate { + return fmt.Errorf("审批类型不能重复") + } + seen[mapped] = struct{}{} + } + if len(seen) == 0 { + return fmt.Errorf("--types 不能为空") + } + return nil }, Tips: []string{`dws attendance +list-approve --users userId1 --types overtime,leave --start 2026-04-01 --end 2026-04-30`}, Execute: func(rt *shortcut.RuntimeContext) error { @@ -346,14 +467,25 @@ var ListApprove = shortcut.Shortcut{ } bizTypes = append(bizTypes, bizType) } - return rt.CallMCP("query_user_approve", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "query_user_approve", map[string]any{ "QueryUserApproveRequest": map[string]any{ "userIds": rt.StrSlice("users"), "bizTypes": bizTypes, "fromDate": from, "toDate": to, }, - }) + }, "approvals", true, nil, func(items []map[string]any) error { + if err := attendanceValidatePositiveIntegerIDs(items, serverWukong+"/query_user_approve", "id"); err != nil { + return err + } + requestedTypes := make(map[int]struct{}, len(bizTypes)) + for _, value := range bizTypes { + requestedTypes[value] = struct{}{} + } + startMillis, _ := dayMillis(rt.Str("start")) + endMillis, _ := dateToMillis(rt.Str("end"), true) + return attendanceValidateApprovalBinding(items, serverWukong+"/query_user_approve", rt.StrSlice("users"), requestedTypes, startMillis, endMillis) + }, "result") }, } @@ -405,9 +537,11 @@ var GetApproveTemplate = shortcut.Shortcut{ default: return fmt.Errorf("无效的审批类型: %s,支持: repair-check/补卡、leave/请假、overtime/加班、travel/外出、out/出差,或 REPAIR_CHECK/LEAVE/OVERTIME/TRAVEL/OUT", input) } - return rt.CallMCP("query_at_approve_template", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "query_at_approve_template", map[string]any{ "approveType": approveType, - }) + }, "templates", true, nil, func(items []map[string]any) error { + return attendanceValidateApproveTemplates(items, serverWukong+"/query_at_approve_template", approveType) + }, "result") }, } @@ -450,9 +584,31 @@ var GetSchedule = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔", Required: true}, - {Name: "start", Type: shortcut.FlagString, Desc: "开始日期 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss", Required: true}, - {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss", Required: true}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "--users 不能为空,用户 ID 不能重复,逗号分隔", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "--start 必须是 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "--end 必须是 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss,且不能早于 --start", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"users"}, Description: "--users 不能为空,用户 ID 不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start"}, Description: "--start 必须是 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"end"}, Description: "--end 必须是 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss,且不能早于 --start"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := attendanceValidateUserIDs(rt.StrSlice("users"), 0); err != nil { + return err + } + start, err := dateToMillis(rt.Str("start"), false) + if err != nil { + return err + } + end, err := dateToMillis(rt.Str("end"), true) + if err != nil { + return err + } + if end < start { + return fmt.Errorf("--end 不能早于 --start") + } + return nil }, Tips: []string{`dws attendance +get-schedule --users user001,user002 --start 2026-04-01 --end 2026-04-30`}, Execute: func(rt *shortcut.RuntimeContext) error { @@ -464,13 +620,18 @@ var GetSchedule = shortcut.Shortcut{ if err != nil { return err } - return rt.CallMCP("getScheduleByRange", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "getScheduleByRange", map[string]any{ "GetScheduleByRangeRequest": map[string]any{ "userIdList": rt.StrSlice("users"), "workDateBegin": begin, "workDateEnd": end, }, - }) + }, "schedules", true, nil, func(items []map[string]any) error { + if err := attendanceValidatePositiveIntegerIDs(items, serverWukong+"/getScheduleByRange", "id"); err != nil { + return err + } + return attendanceValidateUserAndTimeBinding(items, serverWukong+"/getScheduleByRange", rt.StrSlice("users"), "userId", "workDate", begin, end) + }, "result") }, } @@ -560,18 +721,21 @@ var SearchClass = shortcut.Shortcut{ Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "班次名称关键字,模糊搜索"}, {Name: "filter-type", Type: shortcut.FlagString, Enum: []string{"ALL", "MINE_OWN"}, Desc: "班次类型:ALL 全部 / MINE_OWN 我负责的"}, - {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,从 1 开始"}, - {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 200"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "--page 必须大于 0,从 1 开始"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "--limit 必须大于 0 且不超过 200"}, }, - Tips: []string{`dws attendance +search-class --query "早班" --filter-type MINE_OWN`}, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0 且不超过 200"}, + }, + Validate: attendanceValidatePageRequest, + Tips: []string{`dws attendance +search-class --query "早班" --filter-type MINE_OWN`}, Execute: func(rt *shortcut.RuntimeContext) error { - pageQuery := map[string]any{} - if v := rt.Int("page"); v > 0 { - pageQuery["pageIndex"] = v - } - if v := rt.Int("limit"); v > 0 { - pageQuery["pageSize"] = v + page, limit, err := attendancePageInput(rt) + if err != nil { + return err } + pageQuery := map[string]any{"pageIndex": page, "pageSize": limit} shiftParam := map[string]any{} if v := rt.Str("query"); v != "" { shiftParam["searchName"] = v @@ -590,69 +754,64 @@ var SearchClass = shortcut.Shortcut{ if err != nil { return err } - classes := searchClassProject(data) - return rt.Output(map[string]any{"count": len(classes), "classes": classes}) + classes, err := searchClassProject(data) + if err != nil { + return err + } + complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_class_list", page, limit, len(classes)) + if err != nil { + return err + } + nextToken := "" + if !complete { + nextToken = strconv.Itoa(page + 1) + } + return attendanceOutputCollection(rt, "classes", classes, complete, extra, true, nextToken) }, } // searchClassProject reshapes the raw get_class_list response into a clean class // list ({classId, name, ownerName}) — clean output projection. Both // the list container and per-item field names are probed defensively across -// candidate keys, so an unknown/empty shape yields an empty list rather than a -// crash or fabricated data. -func searchClassProject(data map[string]any) []map[string]any { - raw := attendanceResolveList(data, "classList", "shiftList", "list", "items") +// result.items is the reviewed wire path. Missing/wrong collections and bad +// rows fail closed instead of becoming an empty list. +func searchClassProject(data map[string]any) ([]map[string]any, error) { + raw, err := responsecheck.RequireObjectCollection(data, serverWukong+"/get_class_list", "result.items") + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } + for index, item := range raw { + m := item // get_class_list wraps each shift's identity/label under shiftVO // (item = {"shiftVO": {id, name, ...}}); unwrap it first, otherwise every // row projects empty and the whole command silently returns nothing. - if vo, ok := m["shiftVO"].(map[string]any); ok { + if value, present := m["shiftVO"]; present { + vo, ok := value.(map[string]any) + if !ok || len(vo) == 0 { + return nil, responsecheck.Error(serverWukong+"/get_class_list", "malformed_item", fmt.Sprintf("result.items[%d].shiftVO 不是非空对象", index)) + } m = vo } row := map[string]any{} - if v, ok := attendanceFirst(m, "id", "classId", "class_id"); ok { - row["classId"] = v + v, ok := attendanceFirst(m, "id", "classId", "class_id") + identity, validIdentity := attendancePositiveInteger(v) + if !ok || !validIdentity { + return nil, responsecheck.Error(serverWukong+"/get_class_list", "missing_item_identity", fmt.Sprintf("result.items[%d] 缺少班次 ID", index)) } + row["classId"] = identity if v, ok := attendanceFirst(m, "name", "className", "class_name"); ok { row["name"] = v } if v, ok := attendanceFirst(m, "ownerName", "owner_name", "owner"); ok { row["ownerName"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out -} - -// attendanceResolveList locates the list payload inside a response, tolerating a -// bare top-level array under a common container key or nesting one level deeper -// under result/data. extraKeys are probed ahead of the generic containers. -func attendanceResolveList(data map[string]any, extraKeys ...string) []any { - keys := append(append([]string{}, extraKeys...), "result", "data", "list", "items") - for _, key := range keys { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range append(append([]string{}, extraKeys...), "list", "items", "result", "data") { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } + if err := attendanceValidatePositiveIntegerIDs(out, serverWukong+"/get_class_list", "classId"); err != nil { + return nil, err } - return []any{} + return out, nil } // attendanceFirst returns the first present candidate key's value. @@ -673,14 +832,58 @@ var GetClass = shortcut.Shortcut{ Description: "根据班次 ID 查询班次详情", Intent: "当你已知某个班次的 ID、想查看它的完整配置(上下班时间段、休息时段、负责人等)时使用,例如更新班次前先读取现状;输入班次 ID,返回该班次的详细设置。若不知道班次 ID,先用 +search-class 搜索。", Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "attendance", + Name: "shortcut_get_class", + CanonicalPath: "attendance.shortcut_get_class", + CLIPath: "attendance +get-class", + PrimaryCLIPath: "attendance +get-class", + }, + Description: "根据班次 ID 查询班次详情", + Interface: &contract.InterfaceSpec{ + Mode: "composite", + Availability: "available", + Reason: "Reviewed built-in shortcut adapter: the executable CLI owns strict response validation and output projection for this attendance detail operation.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "根据班次 ID 查询班次详情", + UseWhen: []string{"当你已知某个班次的 ID、想查看它的完整配置(上下班时间段、休息时段、负责人等)时使用,例如更新班次前先读取现状;输入班次 ID,返回该班次的详细设置。若不知道班次 ID,先用 +search-class 搜索。"}, + AvoidWhen: []string{"不知道班次 ID 时先用 +search-class;不要用本读取命令代替创建或更新班次。"}, + Examples: []string{"dws attendance +get-class --class-id 12345"}, + }, + }, Flags: []shortcut.Flag{ - {Name: "class-id", Type: shortcut.FlagInt, Desc: "班次 ID", Required: true}, + {Name: "class-id", Type: shortcut.FlagInt, Desc: "--class-id 必须大于 0,表示班次 ID", Required: true}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"class-id"}, Description: "--class-id 必须大于 0"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("class-id") <= 0 { + return fmt.Errorf("--class-id 必须大于 0") + } + return nil }, Tips: []string{`dws attendance +get-class --class-id 1170996821`}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_class_detail", map[string]any{ - "classId": int64(rt.Int("class-id")), - }) + requested := int64(rt.Int("class-id")) + data, err := rt.CallMCPData(serverWukong, "get_class_detail", map[string]any{"classId": requested}) + if err != nil { + return err + } + value, err := responsecheck.RequireSingleObjectResult(data, serverWukong+"/get_class_detail") + if err != nil { + return err + } + identityValue, ok := attendanceNestedValue(value, "shiftVO", "id") + identity, valid := attendancePositiveInteger(identityValue) + if !ok || !valid || identity != requested { + return responsecheck.Error(serverWukong+"/get_class_detail", "object_identity_mismatch", "响应 shiftVO.id 与请求 classId 不一致") + } + return rt.Output(map[string]any{"value": value}) }, } @@ -784,9 +987,9 @@ var GetAdjustmentRule = shortcut.Shortcut{ }, Description: "根据补卡规则主键 ID 查询补卡规则详情", Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: attendanceCompatibilityInterfaceReason, }, Selection: contract.SelectionSpec{ AgentSummary: "根据补卡规则主键 ID 查询补卡规则详情", @@ -800,7 +1003,7 @@ var GetAdjustmentRule = shortcut.Shortcut{ }, Tips: []string{`dws attendance +get-adjustment-rule --adjustment-id 12345`}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_adjustment_rule_detail", map[string]any{ + return attendanceCallObject(rt, serverWukong, "get_adjustment_rule_detail", map[string]any{ "adjustmentId": int64(rt.Int("adjustment-id")), }) }, @@ -812,7 +1015,7 @@ var SearchAdjustmentRule = shortcut.Shortcut{ Command: "+search-adjustment-rule", Product: serverWukong, Description: "查询当前用户可管理的补卡规则列表", - Intent: "当你要浏览或按名称查找当前用户可管理的补卡规则、以便拿到规则 ID 做进一步查看时使用;可选按规则名关键字模糊搜索并分页,返回补卡规则列表。要看某条规则的完整内容用 +get-adjustment-rule。", + Intent: "当你要浏览或按名称查找当前用户可管理的补卡规则时使用;可选按规则名关键字模糊搜索并分页,返回带稳定候选 ID 的补卡规则列表。当前下游详情接口对搜索得到的 ID 返回空 result,因此本命令不承诺可继续读取详情。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", @@ -834,29 +1037,30 @@ var SearchAdjustmentRule = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "查询当前用户可管理的补卡规则列表", - UseWhen: []string{"当你要浏览或按名称查找当前用户可管理的补卡规则、以便拿到规则 ID 做进一步查看时使用;可选按规则名关键字模糊搜索并分页,返回补卡规则列表。要看某条规则的完整内容用 +get-adjustment-rule。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, + UseWhen: []string{"当你要浏览或按名称查找当前用户可管理的补卡规则时使用;可选按规则名关键字模糊搜索并分页,返回带稳定候选 ID 的补卡规则列表。当前下游详情接口对搜索得到的 ID 返回空 result,因此本命令不承诺可继续读取详情。"}, + AvoidWhen: []string{"需要完整补卡规则详情时应报告当前下游详情能力 unavailable,不要把搜索摘要或空 result 当作详情成功"}, Examples: []string{"dws attendance +search-adjustment-rule --query \"标准\" --page 1 --limit 50"}, }, }, Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "补卡规则名称关键字,模糊搜索"}, - {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,从 1 开始"}, - {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,200 以内"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "--page 必须大于 0,从 1 开始"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "--limit 必须大于 0 且不超过 200"}, }, - Tips: []string{`dws attendance +search-adjustment-rule --query "标准" --page 1 --limit 50`}, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0 且不超过 200"}, + }, + Validate: attendanceValidatePageRequest, + Tips: []string{`dws attendance +search-adjustment-rule --query "标准" --page 1 --limit 50`}, Execute: func(rt *shortcut.RuntimeContext) error { param := map[string]any{} if v := rt.Str("query"); v != "" { param["name"] = v } - page := rt.Int("page") - if page <= 0 { - page = 1 - } - limit := rt.Int("limit") - if limit <= 0 { - limit = 20 + page, limit, err := attendancePageInput(rt) + if err != nil { + return err } param["currentPage"] = page param["pageSize"] = limit @@ -866,44 +1070,62 @@ var SearchAdjustmentRule = shortcut.Shortcut{ if err != nil { return err } - rules := searchRuleProject(data) - return rt.Output(map[string]any{"count": len(rules), "rules": rules}) + rules, err := searchRuleProject(data, serverWukong+"/get_adjustment_rule", "result.adjustmentList") + if err != nil { + return err + } + complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_adjustment_rule", page, limit, len(rules)) + if err != nil { + return err + } + nextToken := "" + if !complete { + nextToken = strconv.Itoa(page + 1) + } + return attendanceOutputCollection(rt, "rules", rules, complete, extra, true, nextToken) }, } // searchRuleProject reshapes the raw get_adjustment_rule / get_overtime_rule -// response into a clean rule list ({ruleId, name}) — output-projection fidelity -// for clean output. The list container and per-item field names are probed defensively -// across candidate keys, so an unknown/empty shape yields an empty list rather -// than a crash or fabricated data. -func searchRuleProject(data map[string]any) []map[string]any { +// response into a clean rule list ({ruleId, name}). Each caller supplies the +// single reviewed collection path; missing/wrong collections and malformed +// rows fail closed instead of becoming an empty list. +func searchRuleProject(data map[string]any, operation string, paths ...string) ([]map[string]any, error) { // get_overtime_rule nests the list under result.atRuleList and // get_adjustment_rule under result.adjustmentList; both wrap each rule's // identity/label under entityVO. Probe those container keys AND unwrap // entityVO, otherwise +search-overtime-rule / +search-adjustment-rule // silently return empty despite the backend returning rules. - raw := attendanceResolveList(data, "atRuleList", "adjustmentList", "ruleList", "rules", "list", "items") + raw, err := responsecheck.RequireObjectCollection(data, operation, paths...) + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if vo, ok := m["entityVO"].(map[string]any); ok { + for index, item := range raw { + m := item + if value, present := m["entityVO"]; present { + vo, ok := value.(map[string]any) + if !ok || len(vo) == 0 { + return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("规则结果第 %d 项 entityVO 不是非空对象", index)) + } m = vo } row := map[string]any{} - if v, ok := attendanceFirst(m, "id", "ruleId", "rule_id", "adjustmentId", "overtimeId"); ok { - row["ruleId"] = v + v, ok := attendanceFirst(m, "id", "ruleId", "rule_id", "adjustmentId", "overtimeId") + identity, validIdentity := attendancePositiveInteger(v) + if !ok || !validIdentity { + return nil, responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("规则结果第 %d 项缺少规则 ID", index)) } + row["ruleId"] = identity if v, ok := attendanceFirst(m, "name", "ruleName", "rule_name"); ok { row["name"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out + if err := attendanceValidatePositiveIntegerIDs(out, operation, "ruleId"); err != nil { + return nil, err + } + return out, nil } // ── overtime rule ─────────────────────────────────────────── @@ -942,13 +1164,31 @@ var GetOvertimeRule = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "overtime-id", Type: shortcut.FlagInt, Desc: "加班规则主键 ID", Required: true}, + {Name: "overtime-id", Type: shortcut.FlagInt, Desc: "--overtime-id 必须大于 0,表示加班规则主键 ID", Required: true}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"overtime-id"}, Description: "--overtime-id 必须大于 0"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("overtime-id") <= 0 { + return fmt.Errorf("--overtime-id 必须大于 0") + } + return nil }, Tips: []string{`dws attendance +get-overtime-rule --overtime-id 12345`}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_overtime_rule_detail", map[string]any{ - "overtimeId": int64(rt.Int("overtime-id")), - }) + requested := int64(rt.Int("overtime-id")) + data, err := rt.CallMCPData(serverWukong, "get_overtime_rule_detail", map[string]any{"overtimeId": requested}) + if err != nil { + return err + } + value, err := responsecheck.RequireSingleObjectResult(data, serverWukong+"/get_overtime_rule_detail") + if err != nil { + return err + } + identity, valid := attendancePositiveInteger(value["id"]) + if !valid || identity != requested { + return responsecheck.Error(serverWukong+"/get_overtime_rule_detail", "object_identity_mismatch", "响应 id 与请求 overtimeId 不一致") + } + return rt.Output(map[string]any{"value": value}) }, } @@ -987,22 +1227,23 @@ var SearchOvertimeRule = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "加班规则名称关键字,模糊搜索"}, - {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,从 1 开始"}, - {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,200 以内"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "--page 必须大于 0,从 1 开始"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "--limit 必须大于 0 且不超过 200"}, }, - Tips: []string{`dws attendance +search-overtime-rule --query "节假日" --page 1 --limit 50`}, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0 且不超过 200"}, + }, + Validate: attendanceValidatePageRequest, + Tips: []string{`dws attendance +search-overtime-rule --query "节假日" --page 1 --limit 50`}, Execute: func(rt *shortcut.RuntimeContext) error { param := map[string]any{} if v := rt.Str("query"); v != "" { param["name"] = v } - page := rt.Int("page") - if page <= 0 { - page = 1 - } - limit := rt.Int("limit") - if limit <= 0 { - limit = 20 + page, limit, err := attendancePageInput(rt) + if err != nil { + return err } param["currentPage"] = page param["pageSize"] = limit @@ -1012,8 +1253,19 @@ var SearchOvertimeRule = shortcut.Shortcut{ if err != nil { return err } - rules := searchRuleProject(data) - return rt.Output(map[string]any{"count": len(rules), "rules": rules}) + rules, err := searchRuleProject(data, serverWukong+"/get_overtime_rule", "result.atRuleList") + if err != nil { + return err + } + complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_overtime_rule", page, limit, len(rules)) + if err != nil { + return err + } + nextToken := "" + if !complete { + nextToken = strconv.Itoa(page + 1) + } + return attendanceOutputCollection(rt, "rules", rules, complete, extra, true, nextToken) }, } @@ -1041,9 +1293,9 @@ var SearchGroup = shortcut.Shortcut{ }, Description: "查询当前用户可管理的考勤组列表", Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: attendanceCompatibilityInterfaceReason, }, Selection: contract.SelectionSpec{ AgentSummary: "查询当前用户可管理的考勤组列表", @@ -1079,13 +1331,9 @@ var SearchGroup = shortcut.Shortcut{ searchParam["queryPositionAndWifiNames"] = false searchParam["queryBleDeviceList"] = false } - page := rt.Int("page") - if page <= 0 { - page = 1 - } - limit := rt.Int("limit") - if limit <= 0 { - limit = 20 + page, limit, err := attendancePageInput(rt) + if err != nil { + return err } data, err := rt.CallMCPData(serverWukong, "get_simple_groups", map[string]any{ "param": searchParam, @@ -1097,39 +1345,44 @@ var SearchGroup = shortcut.Shortcut{ if err != nil { return err } - groups := searchGroupProject(data) - return rt.Output(map[string]any{"count": len(groups), "groups": groups}) + groups, err := searchGroupProject(data) + if err != nil { + return err + } + complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_simple_groups", page, limit, len(groups)) + if err != nil { + return err + } + return rt.Output(attendanceCollectionPayload("groups", groups, complete, extra)) }, } // searchGroupProject reshapes the raw get_simple_groups response into a clean // attendance-group list ({groupId, name, type}) — clean output projection. -// The list container and per-item field names are probed defensively -// across candidate keys, so an unknown/empty shape yields an empty list rather -// than a crash or fabricated data. -func searchGroupProject(data map[string]any) []map[string]any { - raw := attendanceResolveList(data, "groupList", "groups", "list", "items") +// The exact result.items collection and each row identity are required, so an +// unknown shape cannot masquerade as a legitimate empty search result. +func searchGroupProject(data map[string]any) ([]map[string]any, error) { + raw, err := responsecheck.RequireObjectCollection(data, serverWukong+"/get_simple_groups", "result.items") + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } + for index, m := range raw { row := map[string]any{} - if v, ok := attendanceFirst(m, "id", "groupId", "group_id"); ok { - row["groupId"] = v + v, ok := attendanceFirst(m, "id", "groupId", "group_id") + if !ok || v == nil { + return nil, responsecheck.Error(serverWukong+"/get_simple_groups", "missing_item_identity", fmt.Sprintf("result.items[%d] 缺少考勤组 ID", index)) } + row["groupId"] = v if v, ok := attendanceFirst(m, "name", "groupName", "group_name"); ok { row["name"] = v } if v, ok := attendanceFirst(m, "type", "groupType", "group_type"); ok { row["type"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out + return out, nil } // GetGroup 根据考勤组 ID 查询考勤组全量信息(get_group_detail)。 @@ -1379,7 +1632,7 @@ var GetSummary = shortcut.Shortcut{ if err != nil { return err } - return rt.CallMCP("get_user_attendance_summary", map[string]any{ + return attendanceCallObject(rt, serverWukong, "get_user_attendance_summary", map[string]any{ "userId": rt.Str("user"), "queryDate": queryDate, "statsType": rt.Str("stats-type"), @@ -1427,19 +1680,80 @@ var GetSelfSetting = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "setting-scene", Type: shortcut.FlagString, Enum: []string{"checkRemind", "fastCheck", "checkResultNotify", "lackRemind", "personalAttendStatNotify", "bossAttendStatNotify"}, Desc: "查询设置项场景", Required: true}, - {Name: "user", Type: shortcut.FlagString, Desc: "查询用户 userId", Required: true}, + {Name: "user", Type: shortcut.FlagString, Desc: "--user 不能为空,表示查询用户 userId", Required: true}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"user"}, Description: "--user 不能为空"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Str("user") == "" { + return fmt.Errorf("--user 不能为空") + } + return nil }, Tips: []string{`dws attendance +get-self-setting --setting-scene checkRemind --user USER_ID`}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("query_self_setting", map[string]any{ + userID := rt.Str("user") + scene := rt.Str("setting-scene") + data, err := rt.CallMCPData(serverWukong, "query_self_setting", map[string]any{ "RuleMcpQuerySelfSettingRequest": map[string]any{ - "settingScene": rt.Str("setting-scene"), - "userId": rt.Str("user"), + "settingScene": scene, + "userId": userID, }, }) + if err != nil { + return err + } + value, err := responsecheck.RequireSingleObjectResult(data, serverWukong+"/query_self_setting") + if err != nil { + return err + } + if err := attendanceValidateSelfSetting(value, userID, scene); err != nil { + return err + } + return rt.Output(map[string]any{"value": value}) }, } +func attendanceValidateSelfSetting(value map[string]any, requestedUser, scene string) error { + operation := serverWukong + "/query_self_setting" + requestedUser = strings.TrimSpace(requestedUser) + if requestedUser == "" { + return responsecheck.Error(operation, "object_identity_mismatch", "请求 userId 不能为空") + } + returnedUser, ok := value["userId"].(string) + if !ok || returnedUser == "" || returnedUser != requestedUser { + return responsecheck.Error(operation, "object_identity_mismatch", "响应 userId 与请求用户不一致") + } + type sceneContract struct { + field string + valid func(any) bool + } + contracts := map[string]sceneContract{ + "checkRemind": {field: "checkRemindSetting", valid: func(v any) bool { _, ok := v.(map[string]any); return ok }}, + "fastCheck": {field: "fastCheckLateNeedConfirm", valid: func(v any) bool { _, ok := v.(bool); return ok }}, + "checkResultNotify": {field: "checkResultMsg", valid: attendanceSettingInteger}, + "lackRemind": {field: "lackRemindUser", valid: attendanceSettingInteger}, + "personalAttendStatNotify": {field: "personDailyReportSwitch", valid: attendanceSettingInteger}, + "bossAttendStatNotify": {field: "bossMonthReportType", valid: attendanceSettingInteger}, + } + expected, ok := contracts[scene] + if !ok { + return fmt.Errorf("无效的 --setting-scene: %s", scene) + } + setting, present := value[expected.field] + if !present || setting == nil { + return responsecheck.Error(operation, "setting_scene_mismatch", "响应缺少请求场景对应的非空设置字段") + } + if !expected.valid(setting) { + return responsecheck.Error(operation, "malformed_setting", "响应中请求场景的设置字段类型不正确") + } + return nil +} + +func attendanceSettingInteger(value any) bool { + number, ok := value.(float64) + return ok && !math.IsNaN(number) && !math.IsInf(number, 0) && math.Trunc(number) == number +} + // ── global setting ────────────────────────────────────────── // GetGlobalSetting 查询全局规则设置(query_global_setting,仅管理员)。 @@ -1502,9 +1816,9 @@ var QueryReportData = shortcut.Shortcut{ }, Description: "根据字段查询考勤报表数据(仅管理员)", Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: attendanceCompatibilityInterfaceReason, }, Selection: contract.SelectionSpec{ AgentSummary: "根据字段查询考勤报表数据(仅管理员)", @@ -1541,7 +1855,7 @@ var QueryReportData = shortcut.Shortcut{ } columnIds = append(columnIds, id) } - return rt.CallMCP("get_report_columns_value", map[string]any{ + return attendanceCallValue(rt, serverWukong, "get_report_columns_value", map[string]any{ "McpQueryParam": map[string]any{ "targetUserIds": rt.StrSlice("users"), "columnIds": columnIds, @@ -1624,9 +1938,11 @@ var ListLeaveTypes = shortcut.Shortcut{ }, Tips: []string{`dws attendance +list-leave-types`}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_leave_types", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "get_leave_types", map[string]any{ "McpLeaveTypeRequest": map[string]any{}, - }) + }, "leaveTypes", true, nil, func(items []map[string]any) error { + return attendanceValidateExpectedStrings(items, serverWukong+"/get_leave_types", "leaveCode", "") + }, "result") }, } @@ -1695,6 +2011,27 @@ var GetLeaveRecords = shortcut.Shortcut{ {Name: "start", Type: shortcut.FlagString, Desc: "查询开始日期 YYYY-MM-DD", Required: true}, {Name: "end", Type: shortcut.FlagString, Desc: "查询结束日期 YYYY-MM-DD", Required: true}, }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"user"}, Description: "--user 去空白后必须非空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start/--end 必须是 YYYY-MM-DD 且 end 不早于 start"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Str("user") == "" { + return fmt.Errorf("--user 不能为空") + } + start, err := dayMillis(rt.Str("start")) + if err != nil { + return err + } + end, err := dayMillis(rt.Str("end")) + if err != nil { + return err + } + if end < start { + return fmt.Errorf("--end 不能早于 --start") + } + return nil + }, Tips: []string{`dws attendance +get-leave-records --user USER_ID --leave-code a1b2c3d4-e5f6-7890-abcd-ef1234567890 --start 2026-04-01 --end 2026-04-22`}, Execute: func(rt *shortcut.RuntimeContext) error { start, err := dayMillis(rt.Str("start")) @@ -1713,9 +2050,9 @@ var GetLeaveRecords = shortcut.Shortcut{ if v := rt.Str("leave-code"); v != "" { req["leaveCode"] = v } - return rt.CallMCP("get_leave_balance_records_v2", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "get_leave_balance_records_v2", map[string]any{ "McpLeaveRecordRequest": req, - }) + }, "records", true, nil, nil, "result") }, } @@ -1879,6 +2216,33 @@ var GetCheckinRecord = shortcut.Shortcut{ {Name: "start", Type: shortcut.FlagString, Desc: "开始时间 yyyy-MM-dd HH:mm:ss", Required: true}, {Name: "end", Type: shortcut.FlagString, Desc: "结束时间 yyyy-MM-dd HH:mm:ss,跨度最多 7 天", Required: true}, }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"staff-ids"}, Description: "--staff-ids 去空白后必须为 1..100 个且不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start/--end 必须是 yyyy-MM-dd HH:mm:ss,end 不早于 start,且跨度不超过 7 天"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := attendanceValidateUserIDs(rt.StrSlice("staff-ids"), 100); err != nil { + return err + } + if rt.Str("operator-corp-id") == "" || rt.Str("operator-staff-id") == "" { + return fmt.Errorf("--operator-corp-id 与 --operator-staff-id 不能为空") + } + start, err := time.ParseInLocation("2006-01-02 15:04:05", rt.Str("start"), time.Local) + if err != nil { + return fmt.Errorf("--start 格式错误,应为 yyyy-MM-dd HH:mm:ss: %w", err) + } + end, err := time.ParseInLocation("2006-01-02 15:04:05", rt.Str("end"), time.Local) + if err != nil { + return fmt.Errorf("--end 格式错误,应为 yyyy-MM-dd HH:mm:ss: %w", err) + } + if end.Before(start) { + return fmt.Errorf("--end 不能早于 --start") + } + if end.Sub(start) > 7*24*time.Hour { + return fmt.Errorf("--start 到 --end 的跨度不能超过 7 天") + } + return nil + }, Tips: []string{`dws attendance +get-checkin-record --operator-corp-id dingXXX --operator-staff-id op001 --staff-ids user001,user002 --start "2026-04-01 00:00:00" --end "2026-04-07 00:00:00"`}, Execute: func(rt *shortcut.RuntimeContext) error { startStr := rt.Str("start") @@ -1894,7 +2258,7 @@ var GetCheckinRecord = shortcut.Shortcut{ if endT.Before(startT) { return fmt.Errorf("--end 不能早于 --start") } - return rt.CallMCP("get_checkin_record", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "get_checkin_record", map[string]any{ "QueryMcpUserRecordRequest": map[string]any{ "operatorCorpId": rt.Str("operator-corp-id"), "operatorStaffId": rt.Str("operator-staff-id"), @@ -1902,7 +2266,7 @@ var GetCheckinRecord = shortcut.Shortcut{ "startTime": startStr, "endTime": endStr, }, - }) + }, "records", true, nil, nil, "result.list") }, } @@ -1954,6 +2318,7 @@ var BossCheck = shortcut.Shortcut{ } func init() { + hardenPublicAttendanceContracts() shortcut.Register( CheckResult, CheckRecord, diff --git a/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go b/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go new file mode 100644 index 00000000..53d19d38 --- /dev/null +++ b/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go @@ -0,0 +1,1176 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package attendance + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +func attendanceRuntimeForTest(t *testing.T, declaration shortcut.Shortcut, values map[string]string) *shortcut.RuntimeContext { + t.Helper() + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + return shortcut.RuntimeContextForTest(cmd, declaration) +} + +type attendanceCoverageCaller struct { + text string + err error + calls int + tools []string + arguments []map[string]any +} + +func (caller *attendanceCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) { + caller.calls++ + caller.tools = append(caller.tools, tool) + caller.arguments = append(caller.arguments, arguments) + if caller.err != nil { + return nil, caller.err + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: caller.text}}}, nil +} + +func (*attendanceCoverageCaller) Format() string { return "json" } +func (*attendanceCoverageCaller) DryRun() bool { return false } +func (*attendanceCoverageCaller) Fields() string { return "" } +func (*attendanceCoverageCaller) JQ() string { return "" } + +func executeAttendanceResponse(t *testing.T, declaration shortcut.Shortcut, values map[string]string, response string) (*attendanceCoverageCaller, error) { + t.Helper() + caller := &attendanceCoverageCaller{text: response} + helpers.InitDepsForTest(t, caller) + err := declaration.Execute(attendanceRuntimeForTest(t, declaration, values)) + return caller, err +} + +func TestCrossPlatformCoverageAttendanceRequestBindingMatrices(t *testing.T) { + userCases := []struct { + name string + items []map[string]any + valid bool + }{ + {"valid", []map[string]any{{"userId": "u1", "workDate": float64(1000)}}, true}, + {"missing user", []map[string]any{{"workDate": float64(1000)}}, false}, + {"wrong user type", []map[string]any{{"userId": 1, "workDate": float64(1000)}}, false}, + {"blank user", []map[string]any{{"userId": " ", "workDate": float64(1000)}}, false}, + {"wrong user", []map[string]any{{"userId": "u2", "workDate": float64(1000)}}, false}, + {"missing time", []map[string]any{{"userId": "u1"}}, false}, + {"wrong time type", []map[string]any{{"userId": "u1", "workDate": "1000"}}, false}, + {"before range", []map[string]any{{"userId": "u1", "workDate": float64(899)}}, false}, + {"after range", []map[string]any{{"userId": "u1", "workDate": float64(1101)}}, false}, + } + for _, tc := range userCases { + err := attendanceValidateUserAndTimeBinding(tc.items, "attendance/test", []string{"u1"}, "userId", "workDate", 900, 1100) + if (err == nil) != tc.valid { + t.Errorf("user/time %s: err=%v valid=%t", tc.name, err, tc.valid) + } + } + + approvalCases := []struct { + name string + item map[string]any + valid bool + }{ + {"valid", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(900), "endTime": float64(1100)}, true}, + {"missing user", map[string]any{"bizType": float64(3), "beginTime": float64(900), "endTime": float64(1100)}, false}, + {"wrong user", map[string]any{"userId": "u2", "bizType": float64(3), "beginTime": float64(900), "endTime": float64(1100)}, false}, + {"missing type", map[string]any{"userId": "u1", "beginTime": float64(900), "endTime": float64(1100)}, false}, + {"wrong type", map[string]any{"userId": "u1", "bizType": float64(4), "beginTime": float64(900), "endTime": float64(1100)}, false}, + {"invalid range", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(1001), "endTime": float64(999)}, false}, + {"before range", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(700), "endTime": float64(899)}, false}, + {"crosses start", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(800), "endTime": float64(900)}, true}, + {"crosses end", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(1100), "endTime": float64(1200)}, true}, + {"after range", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(1101), "endTime": float64(1200)}, false}, + } + for _, tc := range approvalCases { + err := attendanceValidateApprovalBinding([]map[string]any{tc.item}, "attendance/test", []string{"u1"}, map[int]struct{}{3: {}}, 900, 1100) + if (err == nil) != tc.valid { + t.Errorf("approval %s: err=%v valid=%t", tc.name, err, tc.valid) + } + } + if err := attendanceValidateExpectedStrings([]map[string]any{{"approveType": "LEAVE"}}, "attendance/test", "approveType", "OVERTIME"); err == nil { + t.Fatal("expected string mismatch was accepted") + } +} + +func TestCrossPlatformCoverageAttendanceCheckRecordActualTimeBinding(t *testing.T) { + const startMillis, endMillis = int64(1000), int64(2000) + items := []map[string]any{{"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000, "userCheckTime": startMillis}} + if err := attendanceValidateUserAndTimeBinding(items, "attendance/test", []string{"u1"}, "userId", "userCheckTime", startMillis, endMillis); err != nil { + t.Fatalf("cross-midnight work date with in-range actual check was rejected: %v", err) + } + + invalid := []struct { + name string + item map[string]any + reason string + }{ + {"before range", map[string]any{"userId": "u1", "userCheckTime": startMillis - 1}, "request_range_mismatch"}, + {"after range", map[string]any{"userId": "u1", "userCheckTime": endMillis + 1}, "request_range_mismatch"}, + {"missing actual time", map[string]any{"userId": "u1", "workDate": startMillis}, "missing_request_binding"}, + {"null actual time", map[string]any{"userId": "u1", "userCheckTime": nil}, "malformed_request_binding"}, + {"wrong actual time type", map[string]any{"userId": "u1", "userCheckTime": "1000"}, "malformed_request_binding"}, + {"zero actual time", map[string]any{"userId": "u1", "userCheckTime": 0}, "malformed_request_binding"}, + } + for _, tc := range invalid { + t.Run(tc.name, func(t *testing.T) { + err := attendanceValidateUserAndTimeBinding([]map[string]any{tc.item}, "attendance/test", []string{"u1"}, "userId", "userCheckTime", startMillis, endMillis) + typed, ok := err.(*apperrors.Error) + reason := "" + if ok { + reason = typed.Reason + } + if err == nil || !ok || typed.Reason != tc.reason { + t.Fatalf("err=%v reason=%q want=%q", err, reason, tc.reason) + } + }) + } +} + +func TestCrossPlatformCoverageAttendanceCheckRecordExecuteBindsActualCheckTime(t *testing.T) { + values := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"} + startMillis, _ := dayMillis(values["start"]) + execute := func(t *testing.T, records []map[string]any) (map[string]any, error) { + t.Helper() + caller := &attendanceCoverageCaller{text: attendanceResponseJSON(t, map[string]any{"success": true, "result": records})} + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(CheckRecord)) + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + err := CheckRecord.Execute(shortcut.RuntimeContextForTest(cmd, CheckRecord)) + if err != nil { + return nil, err + } + if caller.calls != 1 { + t.Fatalf("calls=%d", caller.calls) + } + if code, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted || code != 0 { + t.Fatalf("emit code=%d emitted=%t err=%v", code, emitted, err) + } + var envelope map[string]any + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode envelope: %v", err) + } + return envelope, nil + } + + t.Run("keeps cross-midnight work date when actual check is in range", func(t *testing.T) { + envelope, err := execute(t, []map[string]any{ + {"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000, "userCheckTime": startMillis + 1000}, + {"id": 2, "userId": "u1", "workDate": startMillis, "userCheckTime": startMillis}, + }) + if err != nil { + t.Fatal(err) + } + data, ok := envelope["data"].(map[string]any) + records, recordsOK := data["records"].([]any) + if !ok || !recordsOK || data["count"] != float64(2) || len(records) != 2 || records[0].(map[string]any)["id"] != float64(1) || records[1].(map[string]any)["id"] != float64(2) { + t.Fatalf("data=%#v", envelope["data"]) + } + }) + + t.Run("actual-time overfetch fails closed", func(t *testing.T) { + if _, err := execute(t, []map[string]any{{"id": 1, "userId": "u1", "workDate": startMillis, "userCheckTime": startMillis - 1}}); err == nil { + t.Fatal("out-of-range actual check was silently filtered") + } + }) + + invalid := []struct { + name string + records []map[string]any + }{ + {"missing identity outside range", []map[string]any{{"userId": "u1", "userCheckTime": startMillis - 1}}}, + {"zero identity outside range", []map[string]any{{"id": 0, "userId": "u1", "userCheckTime": startMillis - 1}}}, + {"wrong user outside range", []map[string]any{{"id": 1, "userId": "other", "userCheckTime": startMillis - 1}}}, + {"missing actual time", []map[string]any{{"id": 1, "userId": "u1", "workDate": startMillis - 1}}}, + {"malformed actual time outside range", []map[string]any{{"id": 1, "userId": "u1", "userCheckTime": "bad"}}}, + {"duplicate identities outside range", []map[string]any{{"id": 1, "userId": "u1", "userCheckTime": startMillis - 2}, {"id": 1, "userId": "u1", "userCheckTime": startMillis - 1}}}, + } + for _, tc := range invalid { + t.Run(tc.name, func(t *testing.T) { + if _, err := execute(t, tc.records); err == nil { + t.Fatal("invalid raw overfetch was hidden") + } + }) + } + + t.Run("downstream failure", func(t *testing.T) { + caller := &attendanceCoverageCaller{err: fmt.Errorf("backend unavailable")} + helpers.InitDepsForTest(t, caller) + if err := CheckRecord.Execute(attendanceRuntimeForTest(t, CheckRecord, values)); err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("missing collection", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, CheckRecord, values, `{"success":true,"result":null}`) + if err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) +} + +func TestCrossPlatformCoverageAttendanceDetailExecuteFailsClosed(t *testing.T) { + detailCases := []struct { + name string + declaration shortcut.Shortcut + flags map[string]string + valid string + invalid []string + }{ + { + name: "class", + declaration: GetClass, + flags: map[string]string{"class-id": "7"}, + valid: `{"success":true,"result":{"shiftVO":{"id":7}}}`, + invalid: []string{ + `{"result":{"shiftVO":{"id":7}}}`, + `{"success":false,"result":{"shiftVO":{"id":7}}}`, + `{"success":true,"result":null}`, + `{"success":true,"result":"bad"}`, + `{"success":true,"result":{"shiftVO":{"id":8}}}`, + }, + }, + { + name: "overtime", + declaration: GetOvertimeRule, + flags: map[string]string{"overtime-id": "9"}, + valid: `{"success":true,"result":[{"id":9}]}`, + invalid: []string{ + `{"result":[{"id":9}]}`, + `{"success":false,"result":[{"id":9}]}`, + `{"success":true,"result":null}`, + `{"success":true,"result":["bad"]}`, + `{"success":true,"result":[{"id":10}]}`, + }, + }, + } + for _, detail := range detailCases { + t.Run(detail.name+" valid", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, detail.declaration, detail.flags, detail.valid) + if err != nil || caller.calls != 1 { + t.Fatalf("valid detail err=%v calls=%d", err, caller.calls) + } + }) + for index, response := range detail.invalid { + t.Run(fmt.Sprintf("%s invalid %d", detail.name, index), func(t *testing.T) { + caller, err := executeAttendanceResponse(t, detail.declaration, detail.flags, response) + if err == nil || caller.calls != 1 { + t.Fatalf("invalid detail err=%v calls=%d", err, caller.calls) + } + }) + } + } +} + +func TestCrossPlatformCoverageAttendanceSelfSettingExecuteBinding(t *testing.T) { + invalid := []string{ + `{"result":{"userId":"user","checkRemindSetting":{}}}`, + `{"success":false,"result":{"userId":"user","checkRemindSetting":{}}}`, + `{"success":true,"result":null}`, + `{"success":true,"result":"bad"}`, + `{"success":true,"result":{"userId":"other","checkRemindSetting":{}}}`, + `{"success":true,"result":{"userId":"user"}}`, + `{"success":true,"result":{"userId":"user","checkRemindSetting":null}}`, + `{"success":true,"result":{"userId":"user","checkRemindSetting":true}}`, + } + for index, response := range invalid { + t.Run(fmt.Sprintf("invalid %d", index), func(t *testing.T) { + caller, err := executeAttendanceResponse(t, GetSelfSetting, map[string]string{"user": "user", "setting-scene": "checkRemind"}, response) + if err == nil || caller.calls != 1 { + t.Fatalf("invalid self setting err=%v calls=%d", err, caller.calls) + } + }) + } + t.Run("normalizes user once", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, GetSelfSetting, map[string]string{"user": " user ", "setting-scene": "checkRemind"}, `{"success":true,"result":{"userId":"user","checkRemindSetting":{}}}`) + if err != nil || caller.calls != 1 { + t.Fatalf("normalized self setting err=%v calls=%d", err, caller.calls) + } + request, ok := caller.arguments[0]["RuleMcpQuerySelfSettingRequest"].(map[string]any) + if !ok || request["userId"] != "user" { + t.Fatalf("normalized request = %#v", caller.arguments[0]) + } + }) + + validTypes := []struct { + scene string + field string + value any + }{ + {"checkRemind", "checkRemindSetting", map[string]any{}}, + {"fastCheck", "fastCheckLateNeedConfirm", false}, + {"checkResultNotify", "checkResultMsg", float64(0)}, + {"lackRemind", "lackRemindUser", float64(1)}, + {"personalAttendStatNotify", "personDailyReportSwitch", float64(1)}, + {"bossAttendStatNotify", "bossMonthReportType", float64(3)}, + } + for _, item := range validTypes { + value := map[string]any{"userId": "user", item.field: item.value} + if err := attendanceValidateSelfSetting(value, "user", item.scene); err != nil { + t.Errorf("scene %s rejected observed type: %v", item.scene, err) + } + } + +} + +func TestCrossPlatformCoverageAttendanceParamMappingsAreExplicit(t *testing.T) { + expected := map[*shortcut.Shortcut]map[string]string{ + &CheckResult: {"users": "users", "start": "start", "end": "end", "offset": "offset", "limit": "limit"}, + &CheckRecord: {"users": "users", "start": "start", "end": "end"}, + &ListApprove: {"users": "users", "types": "types", "start": "start", "end": "end"}, + &GetApproveTemplate: {"type": "type"}, + &GetSchedule: {"users": "users", "start": "start", "end": "end"}, + &SearchClass: {"query": "query", "filter-type": "filterType", "page": "page", "limit": "limit"}, + &GetClass: {"class-id": "classId"}, + &SearchAdjustmentRule: {"query": "query", "page": "page", "limit": "limit"}, + &GetOvertimeRule: {"overtime-id": "overtimeId"}, + &SearchOvertimeRule: {"query": "query", "page": "page", "limit": "limit"}, + &GetSelfSetting: {"setting-scene": "settingScene", "user": "user"}, + } + for declaration, want := range expected { + got := make(map[string]string, len(declaration.Contract.Parameters)) + for _, parameter := range declaration.Contract.Parameters { + got[parameter.Name] = parameter.Property + } + if len(got) != len(want) { + t.Errorf("%s parameter count=%d want=%d: %#v", declaration.Command, len(got), len(want), got) + } + for name, property := range want { + if got[name] != property { + t.Errorf("%s --%s property=%q want=%q", declaration.Command, name, got[name], property) + } + } + } +} + +func TestCrossPlatformCoverageAttendanceRuntimeConstraintsFailBeforeCall(t *testing.T) { + manyUsers := make([]string, 101) + for index := range manyUsers { + manyUsers[index] = fmt.Sprintf("u%d", index) + } + cases := []struct { + name string + declaration shortcut.Shortcut + values map[string]string + }{ + {"check result too many users", CheckResult, map[string]string{"users": strings.Join(manyUsers, ","), "start": "2026-01-01", "end": "2026-01-31"}}, + {"check result reversed dates", CheckResult, map[string]string{"users": "u1", "start": "2026-02-01", "end": "2026-01-31"}}, + {"check result over month", CheckResult, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-02-02"}}, + {"check result bad limit", CheckResult, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "1001"}}, + {"check result bad offset", CheckResult, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "offset": "-1"}}, + {"check record duplicate users", CheckRecord, map[string]string{"users": "u1,u1", "start": "2026-01-01", "end": "2026-01-31"}}, + {"approve empty types", ListApprove, map[string]string{"users": "u1", "types": ",", "start": "2026-01-01", "end": "2026-01-31"}}, + {"approve reversed dates", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "2026-02-01", "end": "2026-01-31"}}, + {"schedule empty users", GetSchedule, map[string]string{"users": ",", "start": "2026-01-01", "end": "2026-01-31"}}, + {"schedule reversed dates", GetSchedule, map[string]string{"users": "u1", "start": "2026-02-01", "end": "2026-01-31"}}, + {"search class page zero", SearchClass, map[string]string{"page": "0"}}, + {"search adjustment limit too large", SearchAdjustmentRule, map[string]string{"limit": "201"}}, + {"search overtime limit zero", SearchOvertimeRule, map[string]string{"limit": "0"}}, + {"class id zero", GetClass, map[string]string{"class-id": "0"}}, + {"overtime id zero", GetOvertimeRule, map[string]string{"overtime-id": "0"}}, + {"leave records reversed dates", GetLeaveRecords, map[string]string{"user": "u1", "start": "2026-02-01", "end": "2026-01-31"}}, + {"checkin duplicate users", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "operator", "staff-ids": "u1,u1", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"}}, + {"checkin over seven days", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "operator", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "2026-01-08 00:00:01"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &attendanceCoverageCaller{text: `{"success":true,"result":[]}`} + helpers.InitDepsForTest(t, caller) + rt := attendanceRuntimeForTest(t, tc.declaration, tc.values) + if tc.declaration.Validate == nil { + t.Fatalf("%s has no Validate", tc.declaration.Command) + } + if err := tc.declaration.Validate(rt); err == nil { + t.Fatalf("invalid values accepted for %s", tc.declaration.Command) + } + if caller.calls != 0 { + t.Fatalf("%s made %d downstream calls before validation", tc.declaration.Command, caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageAttendancePaginationLivesInEnvelopeMeta(t *testing.T) { + emit := func(complete bool, nextToken string) map[string]any { + t.Helper() + cmd := corecmd.New(shortcut.FromShortcut(CheckResult)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + rt := shortcut.RuntimeContextForTest(cmd, CheckResult) + items := []map[string]any{{"id": int64(1), "userId": "redacted", "workDate": int64(1)}} + if err := attendanceOutputCollection(rt, "records", items, complete, map[string]any{"limit": 1, "nextOffset": 1}, true, nextToken); err != nil { + t.Fatal(err) + } + if _, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted { + t.Fatalf("emit err=%v emitted=%t", err, emitted) + } + var envelope map[string]any + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode envelope: %v", err) + } + return envelope + } + + envelope := emit(false, "1") + data, ok := envelope["data"].(map[string]any) + if !ok || data["count"] != float64(1) { + t.Fatalf("business data=%#v", envelope["data"]) + } + for _, forbidden := range []string{"complete", "limit", "nextOffset", "nextPage", "totalCount", "totalPage"} { + if _, present := data[forbidden]; present { + t.Errorf("pagination field %q leaked into business data", forbidden) + } + } + meta, ok := envelope["meta"].(map[string]any) + if !ok { + t.Fatalf("meta=%#v", envelope["meta"]) + } + pagination, ok := meta["pagination"].(map[string]any) + if !ok || pagination["endpoint_exhausted"] != false || pagination["next_token"] != "1" { + t.Fatalf("pagination=%#v", meta["pagination"]) + } + exhaustedEnvelope := emit(true, "") + exhaustedMeta := exhaustedEnvelope["meta"].(map[string]any) + exhaustedPagination := exhaustedMeta["pagination"].(map[string]any) + if exhaustedPagination["endpoint_exhausted"] != true { + t.Fatalf("exhausted pagination=%#v", exhaustedPagination) + } + if _, present := exhaustedPagination["next_token"]; present { + t.Fatalf("exhausted pagination published next_token: %#v", exhaustedPagination) + } +} + +func TestCrossPlatformCoverageAttendanceCommonCallersFailClosed(t *testing.T) { + tests := []struct { + name string + run func(*shortcut.RuntimeContext) error + text string + err error + want bool + }{ + { + name: "collection downstream error", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallCollection(rt, "attendance-test", "collection", nil, "items", true, nil, nil, "result") + }, + err: fmt.Errorf("downstream unavailable"), + want: false, + }, + { + name: "collection malformed", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallCollection(rt, "attendance-test", "collection", nil, "items", true, nil, nil, "result") + }, + text: `{"success":true,"result":null}`, + want: false, + }, + { + name: "collection validator", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallCollection(rt, "attendance-test", "collection", nil, "items", true, nil, func([]map[string]any) error { + return fmt.Errorf("binding mismatch") + }, "result") + }, + text: `{"success":true,"result":[]}`, + want: false, + }, + { + name: "collection success", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallCollection(rt, "attendance-test", "collection", nil, "items", true, map[string]any{"source": "test"}, nil, "result") + }, + text: `{"success":true,"result":[]}`, + want: true, + }, + { + name: "value downstream error", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallValue(rt, "attendance-test", "value", nil) + }, + err: fmt.Errorf("downstream unavailable"), + want: false, + }, + { + name: "value malformed", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallValue(rt, "attendance-test", "value", nil) + }, + text: `{"success":true}`, + want: false, + }, + { + name: "value success", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallValue(rt, "attendance-test", "value", nil) + }, + text: `{"success":true,"result":false}`, + want: true, + }, + { + name: "object downstream error", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallObject(rt, "attendance-test", "object", nil) + }, + err: fmt.Errorf("downstream unavailable"), + want: false, + }, + { + name: "object malformed", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallObject(rt, "attendance-test", "object", nil) + }, + text: `{"success":true,"result":[]}`, + want: false, + }, + { + name: "object success", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallObject(rt, "attendance-test", "object", nil) + }, + text: `{"success":true,"result":{"id":1}}`, + want: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &attendanceCoverageCaller{text: tc.text, err: tc.err} + helpers.InitDepsForTest(t, caller) + rt := attendanceRuntimeForTest(t, CheckResult, nil) + err := tc.run(rt) + if (err == nil) != tc.want || caller.calls != 1 { + t.Fatalf("err=%v calls=%d wantSuccess=%t", err, caller.calls, tc.want) + } + }) + } + + t.Run("legacy output", func(t *testing.T) { + rt := attendanceRuntimeForTest(t, ListLeaveTypes, nil) + if err := attendanceOutputCollection(rt, "items", []map[string]any{{"leaveCode": "code"}}, true, map[string]any{"source": "legacy"}, false, ""); err != nil { + t.Fatal(err) + } + }) + + t.Run("invalid continuation", func(t *testing.T) { + rt := attendanceRuntimeForTest(t, CheckResult, nil) + if err := attendanceOutputCollection(rt, "records", nil, false, nil, true, ""); err == nil { + t.Fatal("non-terminal pagination without next token was accepted") + } + }) +} + +func TestCrossPlatformCoverageAttendanceIdentityAndInputHelpers(t *testing.T) { + if value, ok := attendanceNestedValue(map[string]any{"outer": map[string]any{"id": int32(7)}}, "outer", "id"); !ok || value != int32(7) { + t.Fatalf("nested identity=%#v ok=%t", value, ok) + } + for _, path := range [][]string{{"missing"}, {"outer", "id"}} { + item := map[string]any{"outer": "not-an-object"} + if _, ok := attendanceNestedValue(item, path...); ok { + t.Fatalf("invalid nested path accepted: %#v", path) + } + } + + positive := []any{int(1), int32(2), int64(3), float64(4), json.Number("5")} + for _, value := range positive { + if _, ok := attendancePositiveInteger(value); !ok { + t.Errorf("positive identity rejected: %#v", value) + } + } + invalid := []any{int(0), int32(-1), int64(0), 1.5, json.Number("bad"), "1", nil} + for _, value := range invalid { + if _, ok := attendancePositiveInteger(value); ok { + t.Errorf("invalid identity accepted: %#v", value) + } + } + if err := attendanceValidatePositiveIntegerIDs([]map[string]any{{"nested": map[string]any{"id": int32(1)}}}, "attendance/test", "nested", "id"); err != nil { + t.Fatal(err) + } + for _, items := range [][]map[string]any{ + {{}}, + {{"id": 0}}, + {{"id": 1}, {"id": 1}}, + } { + if err := attendanceValidatePositiveIntegerIDs(items, "attendance/test", "id"); err == nil { + t.Fatalf("invalid identity collection accepted: %#v", items) + } + } + if err := attendanceValidateExpectedStrings([]map[string]any{{"code": " A "}, {"code": "B"}}, "attendance/test", "code", ""); err != nil { + t.Fatal(err) + } + for _, items := range [][]map[string]any{ + {{}}, + {{"code": 1}}, + {{"code": " "}}, + {{"code": "A"}, {"code": "A"}}, + } { + if err := attendanceValidateExpectedStrings(items, "attendance/test", "code", ""); err == nil { + t.Fatalf("invalid string identity collection accepted: %#v", items) + } + } + + userCases := []struct { + values []string + maximum int + valid bool + }{ + {[]string{"u1"}, 1, true}, + {nil, 1, false}, + {[]string{"u1", "u2"}, 1, false}, + {[]string{""}, 1, false}, + {[]string{" u1"}, 1, false}, + {[]string{"u1", "u1"}, 2, false}, + } + for _, tc := range userCases { + err := attendanceValidateUserIDs(tc.values, tc.maximum) + if (err == nil) != tc.valid { + t.Errorf("users=%#v maximum=%d err=%v", tc.values, tc.maximum, err) + } + } + + for _, tc := range []struct { + start string + end string + valid bool + }{ + {"2026-01-01", "2026-02-01", true}, + {"bad", "2026-01-01", false}, + {"2026-01-01", "bad", false}, + {"2026-02-01", "2026-01-01", false}, + {"2026-01-01", "2026-02-02", false}, + } { + err := attendanceValidateMonthRange(tc.start, tc.end) + if (err == nil) != tc.valid { + t.Errorf("month range %q..%q err=%v", tc.start, tc.end, err) + } + } +} + +func TestCrossPlatformCoverageAttendancePaginationEvidenceBoundaries(t *testing.T) { + tests := []struct { + name string + data map[string]any + page int + limit int + itemCount int + complete bool + valid bool + }{ + {"invalid request", map[string]any{"success": true, "result": map[string]any{}}, 0, 10, 0, false, false}, + {"malformed result", map[string]any{"success": true, "result": []any{}}, 1, 10, 0, false, false}, + {"current page type", map[string]any{"success": true, "result": map[string]any{"currentPage": "1", "totalPage": 1}}, 1, 10, 0, false, false}, + {"current page mismatch", map[string]any{"success": true, "result": map[string]any{"currentPage": 2, "totalPage": 1}}, 1, 10, 0, false, false}, + {"total page type", map[string]any{"success": true, "result": map[string]any{"totalPage": "1"}}, 1, 10, 0, false, false}, + {"negative total page", map[string]any{"success": true, "result": map[string]any{"totalPage": -1}}, 1, 10, 0, false, false}, + {"zero pages invalid current page", map[string]any{"success": true, "result": map[string]any{"totalPage": 0}}, 2, 10, 0, false, false}, + {"page out of range", map[string]any{"success": true, "result": map[string]any{"totalPage": 1}}, 2, 10, 0, false, false}, + {"total count type", map[string]any{"success": true, "result": map[string]any{"totalCount": "1"}}, 1, 10, 0, false, false}, + {"negative total count", map[string]any{"success": true, "result": map[string]any{"totalCount": -1}}, 1, 10, 0, false, false}, + {"empty with nonzero total", map[string]any{"success": true, "result": map[string]any{"totalCount": 1}}, 1, 10, 0, false, false}, + {"count before page", map[string]any{"success": true, "result": map[string]any{"totalCount": 5}}, 2, 10, 0, false, false}, + {"items exceed total", map[string]any{"success": true, "result": map[string]any{"totalCount": 5}}, 1, 10, 6, false, false}, + {"missing evidence", map[string]any{"success": true, "result": map[string]any{}}, 1, 10, 0, false, false}, + {"conflicting evidence", map[string]any{"success": true, "result": map[string]any{"totalPage": 2, "totalCount": 5}}, 1, 10, 5, false, false}, + {"total mismatch", map[string]any{"success": true, "result": map[string]any{"totalPage": 3, "totalCount": 15}}, 1, 10, 10, false, false}, + {"no progress", map[string]any{"success": true, "result": map[string]any{"totalPage": 2}}, 1, 10, 0, false, false}, + {"short page", map[string]any{"success": true, "result": map[string]any{"totalPage": 2}}, 1, 10, 5, false, false}, + {"terminal count mismatch", map[string]any{"success": true, "result": map[string]any{"totalCount": 15}}, 2, 10, 4, false, false}, + {"empty terminal", map[string]any{"success": true, "result": map[string]any{"totalPage": 0, "totalCount": 0}}, 1, 10, 0, true, true}, + {"full nonterminal", map[string]any{"success": true, "result": map[string]any{"currentPage": 1, "totalPage": 2, "totalCount": 20}}, 1, 10, 10, false, true}, + {"short terminal", map[string]any{"success": true, "result": map[string]any{"currentPage": 2, "totalPage": 2, "totalCount": 15}}, 2, 10, 5, true, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + complete, extra, err := attendancePageEvidence(tc.data, "attendance/test", tc.page, tc.limit, tc.itemCount) + if (err == nil) != tc.valid { + t.Fatalf("err=%v extra=%#v", err, extra) + } + if tc.valid && complete != tc.complete { + t.Fatalf("complete=%t want=%t extra=%#v", complete, tc.complete, extra) + } + if tc.valid && !tc.complete && extra["nextPage"] != tc.page+1 { + t.Fatalf("nonterminal extra=%#v", extra) + } + }) + } + + for _, tc := range []struct { + page string + limit string + valid bool + }{ + {"1", "200", true}, + {"0", "20", false}, + {"1", "0", false}, + {"1", "201", false}, + } { + rt := attendanceRuntimeForTest(t, SearchClass, map[string]string{"page": tc.page, "limit": tc.limit}) + page, limit, err := attendancePageInput(rt) + if (err == nil) != tc.valid { + t.Errorf("page=%s limit=%s got=(%d,%d) err=%v", tc.page, tc.limit, page, limit, err) + } + if err2 := attendanceValidatePageRequest(rt); (err2 == nil) != tc.valid { + t.Errorf("validator page=%s limit=%s err=%v", tc.page, tc.limit, err2) + } + } + + for _, tc := range []struct { + value any + want int + valid bool + }{ + {int(1), 1, true}, {int32(2), 2, true}, {int64(3), 3, true}, {float64(4), 4, true}, + {float64(1.5), 0, false}, {"1", 0, false}, + } { + got, ok := attendanceInt(tc.value) + if got != tc.want || ok != tc.valid { + t.Errorf("attendanceInt(%#v)=(%d,%t), want (%d,%t)", tc.value, got, ok, tc.want, tc.valid) + } + } +} + +func attendanceResponseJSON(t *testing.T, value any) string { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +func TestCrossPlatformCoverageAttendanceChangedValidators(t *testing.T) { + tests := []struct { + name string + declaration shortcut.Shortcut + values map[string]string + valid bool + }{ + {"check result valid", CheckResult, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "1", "offset": "0"}, true}, + {"check record valid", CheckRecord, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, true}, + {"approve empty users", ListApprove, map[string]string{"types": "leave", "start": "2026-01-01", "end": "2026-01-31"}, false}, + {"approve empty types", ListApprove, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, false}, + {"approve invalid start", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "bad", "end": "2026-01-31"}, false}, + {"approve invalid end", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "bad"}, false}, + {"approve duplicate mapped type", ListApprove, map[string]string{"users": "u1", "types": "leave,请假", "start": "2026-01-01", "end": "2026-01-31"}, false}, + {"approve invalid type", ListApprove, map[string]string{"users": "u1", "types": "invalid", "start": "2026-01-01", "end": "2026-01-31"}, false}, + {"approve valid", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "2026-01-31"}, true}, + {"schedule invalid start", GetSchedule, map[string]string{"users": "u1", "start": "bad", "end": "2026-01-31"}, false}, + {"schedule invalid end", GetSchedule, map[string]string{"users": "u1", "start": "2026-01-01", "end": "bad"}, false}, + {"schedule reverse", GetSchedule, map[string]string{"users": "u1", "start": "2026-02-01", "end": "2026-01-31"}, false}, + {"schedule valid", GetSchedule, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, true}, + {"class valid", GetClass, map[string]string{"class-id": "1"}, true}, + {"overtime valid", GetOvertimeRule, map[string]string{"overtime-id": "1"}, true}, + {"self setting empty user", GetSelfSetting, map[string]string{"user": "", "setting-scene": "checkRemind"}, false}, + {"self setting valid", GetSelfSetting, map[string]string{"user": "u1", "setting-scene": "checkRemind"}, true}, + {"leave records empty user", GetLeaveRecords, map[string]string{"user": "", "start": "2026-01-01", "end": "2026-01-31"}, false}, + {"leave records invalid start", GetLeaveRecords, map[string]string{"user": "u1", "start": "bad", "end": "2026-01-31"}, false}, + {"leave records invalid end", GetLeaveRecords, map[string]string{"user": "u1", "start": "2026-01-01", "end": "bad"}, false}, + {"leave records valid", GetLeaveRecords, map[string]string{"user": "u1", "start": "2026-01-01", "end": "2026-01-31"}, true}, + {"checkin missing operator", GetCheckinRecord, map[string]string{"operator-corp-id": "", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"}, false}, + {"checkin invalid start", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "bad", "end": "2026-01-02 00:00:00"}, false}, + {"checkin invalid end", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "bad"}, false}, + {"checkin reverse", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-02 00:00:00", "end": "2026-01-01 00:00:00"}, false}, + {"checkin valid", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"}, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rt := attendanceRuntimeForTest(t, tc.declaration, tc.values) + if tc.declaration.Validate == nil { + t.Fatalf("%s has no validator", tc.declaration.Command) + } + err := tc.declaration.Validate(rt) + if (err == nil) != tc.valid { + t.Fatalf("err=%v wantValid=%t", err, tc.valid) + } + }) + } +} + +func TestCrossPlatformCoverageAttendanceExactRecordExecutors(t *testing.T) { + startMillis, _ := dayMillis("2026-01-01") + endStartMillis, _ := dayMillis("2026-01-31") + endMillis, _ := dateToMillis("2026-01-31", true) + nextDayStartMillis, _ := dayMillis("2026-02-01") + finalDayNoon := endStartMillis + 12*60*60*1000 + validRecord := map[string]any{"id": 1, "userId": "u1", "workDate": startMillis} + if endMillis != nextDayStartMillis-1 { + t.Fatalf("end-of-day=%d want=%d", endMillis, nextDayStartMillis-1) + } + explicitEnd, err := dateToMillis("2026-01-31 12:34:56", true) + explicitWant, _ := flexMillis("2026-01-31 12:34:56") + if err != nil || explicitEnd != explicitWant { + t.Fatalf("explicit datetime end=%d want=%d err=%v", explicitEnd, explicitWant, err) + } + + t.Run("check result bad execute bounds", func(t *testing.T) { + for _, values := range []map[string]string{ + {"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "0"}, + {"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "offset": "-1"}, + } { + caller, err := executeAttendanceResponse(t, CheckResult, values, `{"success":true,"result":[]}`) + if err == nil || caller.calls != 0 { + t.Fatalf("values=%#v err=%v calls=%d", values, err, caller.calls) + } + } + }) + + t.Run("check result downstream and response failures", func(t *testing.T) { + values := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "2"} + responses := []string{ + `{"success":true,"result":null}`, + attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{map[string]any{"id": 0, "userId": "u1", "workDate": startMillis}}}), + attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{map[string]any{"id": 1, "userId": "other", "workDate": startMillis}}}), + } + for _, response := range responses { + caller, err := executeAttendanceResponse(t, CheckResult, values, response) + if err == nil || caller.calls != 1 { + t.Fatalf("response=%s err=%v calls=%d", response, err, caller.calls) + } + } + caller := &attendanceCoverageCaller{err: fmt.Errorf("backend unavailable")} + helpers.InitDepsForTest(t, caller) + if err := CheckResult.Execute(attendanceRuntimeForTest(t, CheckResult, values)); err == nil || caller.calls != 1 { + t.Fatalf("downstream err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("check result short and full pages", func(t *testing.T) { + shortValues := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "2", "offset": "0"} + caller, err := executeAttendanceResponse(t, CheckResult, shortValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{validRecord}})) + if err != nil || caller.calls != 1 { + t.Fatalf("short err=%v calls=%d", err, caller.calls) + } + fullValues := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "1", "offset": "4"} + caller, err = executeAttendanceResponse(t, CheckResult, fullValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{validRecord}})) + if err != nil || caller.calls != 1 { + t.Fatalf("full err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("end date includes the full business day", func(t *testing.T) { + checkValues := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "2"} + checkRow := map[string]any{"id": 1, "userId": "u1", "workDate": finalDayNoon} + caller, err := executeAttendanceResponse(t, CheckResult, checkValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{checkRow}})) + if err != nil || caller.calls != 1 { + t.Fatalf("check result rejected final-day midday row: err=%v calls=%d", err, caller.calls) + } + + approveValues := map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "2026-01-31"} + approval := map[string]any{"id": 1, "userId": "u1", "bizType": 3, "beginTime": finalDayNoon, "endTime": finalDayNoon + 60*60*1000} + caller, err = executeAttendanceResponse(t, ListApprove, approveValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{approval}})) + if err != nil || caller.calls != 1 { + t.Fatalf("approval rejected final-day midday interval: err=%v calls=%d", err, caller.calls) + } + + checkRow["workDate"] = endMillis + caller, err = executeAttendanceResponse(t, CheckResult, checkValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{checkRow}})) + if err != nil || caller.calls != 1 { + t.Fatalf("check result rejected final millisecond: err=%v calls=%d", err, caller.calls) + } + checkRow["workDate"] = nextDayStartMillis + if caller, err = executeAttendanceResponse(t, CheckResult, checkValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{checkRow}})); err == nil || caller.calls != 1 { + t.Fatalf("check result accepted next-day row: err=%v calls=%d", err, caller.calls) + } + + approval["beginTime"] = nextDayStartMillis + approval["endTime"] = nextDayStartMillis + 1000 + caller, err = executeAttendanceResponse(t, ListApprove, approveValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{approval}})) + typed, ok := err.(*apperrors.Error) + reason := "" + if ok { + reason = typed.Reason + } + if err == nil || caller.calls != 1 || !ok || typed.Reason != "request_range_mismatch" { + t.Fatalf("approval next-day result err=%v calls=%d reason=%q", err, caller.calls, reason) + } + }) + + collectionCases := []struct { + name string + declaration shortcut.Shortcut + values map[string]string + valid map[string]any + invalid map[string]any + }{ + {"check record", CheckRecord, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, map[string]any{"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000, "userCheckTime": startMillis}, map[string]any{"id": 1, "userId": "other", "userCheckTime": startMillis}}, + {"approve", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "2026-01-31"}, map[string]any{"id": 1, "userId": "u1", "bizType": 3, "beginTime": startMillis, "endTime": endMillis}, map[string]any{"id": 1, "userId": "u1", "bizType": 4, "beginTime": startMillis, "endTime": endMillis}}, + {"schedule", GetSchedule, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, validRecord, map[string]any{"id": 1, "userId": "other", "workDate": startMillis}}, + } + t.Run("schedule null responses fail closed", func(t *testing.T) { + values := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"} + for _, tc := range []struct { + name string + response string + reason string + }{ + {name: "literal null", response: `null`, reason: "empty_tool_response"}, + {name: "null collection", response: `{"success":true,"result":null}`, reason: "malformed_collection"}, + } { + t.Run(tc.name, func(t *testing.T) { + caller, err := executeAttendanceResponse(t, GetSchedule, values, tc.response) + typed, ok := err.(*apperrors.Error) + actualReason := "" + if ok { + actualReason = typed.Reason + } + if err == nil || caller.calls != 1 || !ok || actualReason != tc.reason { + t.Fatalf("err=%v calls=%d reason=%q want=%q", err, caller.calls, actualReason, tc.reason) + } + }) + } + }) + for _, tc := range collectionCases { + t.Run(tc.name+" valid", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, tc.declaration, tc.values, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{tc.valid}})) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + t.Run(tc.name+" invalid binding", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, tc.declaration, tc.values, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{tc.invalid}})) + if err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + t.Run(tc.name+" invalid identity", func(t *testing.T) { + invalidIdentity := make(map[string]any, len(tc.valid)) + for key, value := range tc.valid { + invalidIdentity[key] = value + } + invalidIdentity["id"] = 0 + caller, err := executeAttendanceResponse(t, tc.declaration, tc.values, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{invalidIdentity}})) + if err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageAttendanceSearchProjectorsFailClosed(t *testing.T) { + classValid := map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"shiftVO": map[string]any{"id": 1, "name": "n", "ownerName": "o"}}}}} + classes, err := searchClassProject(classValid) + if err != nil || len(classes) != 1 || classes[0]["classId"] != int64(1) || classes[0]["name"] != "n" || classes[0]["ownerName"] != "o" { + t.Fatalf("class projection=%#v err=%v", classes, err) + } + for _, data := range []map[string]any{ + {"success": true, "result": map[string]any{"items": []any{map[string]any{"shiftVO": "bad"}}}}, + {"success": true, "result": map[string]any{"items": []any{map[string]any{"name": "missing id"}}}}, + {"success": true, "result": map[string]any{"items": []any{map[string]any{"id": 1}, map[string]any{"classId": 1}}}}, + } { + if _, err := searchClassProject(data); err == nil { + t.Fatalf("invalid class projection accepted: %#v", data) + } + } + + ruleValid := map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"entityVO": map[string]any{"id": 2, "name": "rule"}}}}} + rules, err := searchRuleProject(ruleValid, "attendance/test", "result.items") + if err != nil || len(rules) != 1 || rules[0]["ruleId"] != int64(2) || rules[0]["name"] != "rule" { + t.Fatalf("rule projection=%#v err=%v", rules, err) + } + for _, data := range []map[string]any{ + {"success": true, "result": map[string]any{"items": []any{map[string]any{"entityVO": "bad"}}}}, + {"success": true, "result": map[string]any{"items": []any{map[string]any{"name": "missing id"}}}}, + {"success": true, "result": map[string]any{"items": []any{map[string]any{"id": 1}, map[string]any{"ruleId": 1}}}}, + } { + if _, err := searchRuleProject(data, "attendance/test", "result.items"); err == nil { + t.Fatalf("invalid rule projection accepted: %#v", data) + } + } + + groupValid := map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"group_id": "g1", "group_name": "group", "group_type": "FIXED"}}}} + groups, err := searchGroupProject(groupValid) + if err != nil || len(groups) != 1 || groups[0]["groupId"] != "g1" || groups[0]["name"] != "group" || groups[0]["type"] != "FIXED" { + t.Fatalf("group projection=%#v err=%v", groups, err) + } + if _, err := searchGroupProject(map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"name": "missing id"}}}}); err == nil { + t.Fatal("group without identity was accepted") + } +} + +func TestCrossPlatformCoverageAttendanceSearchExecutorsPagination(t *testing.T) { + type searchCase struct { + name string + declaration shortcut.Shortcut + tool string + resultKey string + item map[string]any + } + tests := []searchCase{ + {"class", SearchClass, "get_class_list", "items", map[string]any{"shiftVO": map[string]any{"id": 1}}}, + {"adjustment", SearchAdjustmentRule, "get_adjustment_rule", "adjustmentList", map[string]any{"entityVO": map[string]any{"id": 1}}}, + {"overtime", SearchOvertimeRule, "get_overtime_rule", "atRuleList", map[string]any{"entityVO": map[string]any{"id": 1}}}, + } + for _, tc := range tests { + t.Run(tc.name+" invalid page", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, tc.declaration, map[string]string{"page": "0", "limit": "1"}, `{"success":true}`) + if err == nil || caller.calls != 0 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + t.Run(tc.name+" malformed collection", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, tc.declaration, map[string]string{"query": "q", "page": "1", "limit": "1"}, `{"success":true,"result":{}}`) + if err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + t.Run(tc.name+" missing pagination evidence", func(t *testing.T) { + result := map[string]any{tc.resultKey: []any{tc.item}} + caller, err := executeAttendanceResponse(t, tc.declaration, map[string]string{"page": "1", "limit": "1"}, attendanceResponseJSON(t, map[string]any{"success": true, "result": result})) + if err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + for _, page := range []struct { + name string + totalPage int + total int + }{ + {"terminal", 1, 1}, + {"nonterminal", 2, 2}, + } { + t.Run(tc.name+" "+page.name, func(t *testing.T) { + result := map[string]any{tc.resultKey: []any{tc.item}, "currentPage": 1, "totalPage": page.totalPage, "totalCount": page.total} + caller, err := executeAttendanceResponse(t, tc.declaration, map[string]string{"query": "q", "page": "1", "limit": "1"}, attendanceResponseJSON(t, map[string]any{"success": true, "result": result})) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + if caller.tools[0] != tc.tool { + t.Fatalf("tool=%q want=%q", caller.tools[0], tc.tool) + } + }) + } + } + + t.Run("group invalid page", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, SearchGroup, map[string]string{"page": "0", "limit": "1"}, `{"success":true}`) + if err == nil || caller.calls != 0 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + t.Run("group malformed and pagination failure", func(t *testing.T) { + for _, response := range []string{ + `{"success":true,"result":{}}`, + `{"success":true,"result":{"items":[{"id":"g1"}]}}`, + } { + caller, err := executeAttendanceResponse(t, SearchGroup, map[string]string{"query": "q", "type": "FIXED", "query-position": "true", "query-ble": "false", "page": "1", "limit": "1"}, response) + if err == nil || caller.calls != 1 { + t.Fatalf("response=%s err=%v calls=%d", response, err, caller.calls) + } + } + }) + t.Run("group terminal", func(t *testing.T) { + response := `{"success":true,"result":{"items":[{"id":"g1","name":"group","type":"FIXED"}],"currentPage":1,"totalPage":1,"totalCount":1}}` + caller, err := executeAttendanceResponse(t, SearchGroup, map[string]string{"page": "1", "limit": "1"}, response) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) +} + +func TestCrossPlatformCoverageAttendanceRemainingChangedExecutors(t *testing.T) { + t.Run("approve template exact type", func(t *testing.T) { + for _, tc := range []struct { + value string + response string + valid bool + calls int + }{ + {"invalid", `{"success":true,"result":[]}`, false, 0}, + {"leave", `{"success":true,"result":[{"processCode":"p1","approveType":"OVERTIME","submitUrl":"https://example.invalid/1"}]}`, false, 1}, + {"leave", `{"success":true,"result":[{"processCode":"p1","approveType":"LEAVE","submitUrl":"https://example.invalid/1"}]}`, true, 1}, + {"TRAVEL", `{"success":true,"result":[{"processCode":"p1","approveType":"TRAVEL","submitUrl":"https://example.invalid/1"},{"processCode":"p2","approveType":"TRAVEL","submitUrl":"https://example.invalid/2"}]}`, true, 1}, + {"OUT", `{"success":true,"result":[{"processCode":"p1","approveType":"OUT","submitUrl":"https://example.invalid/1"},{"processCode":"p2","approveType":"OUT","submitUrl":"https://example.invalid/2"}]}`, true, 1}, + } { + caller, err := executeAttendanceResponse(t, GetApproveTemplate, map[string]string{"type": tc.value}, tc.response) + if (err == nil) != tc.valid || caller.calls != tc.calls { + t.Errorf("type=%q err=%v calls=%d wantValid=%t wantCalls=%d", tc.value, err, caller.calls, tc.valid, tc.calls) + } + } + }) + + t.Run("hidden adjustment detail still fails closed", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, GetAdjustmentRule, map[string]string{"adjustment-id": "7"}, `{"success":true,"result":{"id":7}}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("summary object", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, GetSummary, map[string]string{"user": "u1", "date": "2026-01-01", "stats-type": "week"}, `{"success":true,"result":{"days":1}}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("self setting helper rejects invalid request and scene", func(t *testing.T) { + if err := attendanceValidateSelfSetting(map[string]any{"userId": "u1", "checkRemindSetting": map[string]any{}}, " ", "checkRemind"); err == nil { + t.Fatal("empty requested user accepted") + } + if err := attendanceValidateSelfSetting(map[string]any{"userId": "u1"}, "u1", "not-a-scene"); err == nil { + t.Fatal("invalid setting scene accepted") + } + }) + + t.Run("report value", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, QueryReportData, map[string]string{"users": "u1", "columns": "1, 2", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"}, `{"success":true,"result":false}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("leave type exact collection", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, ListLeaveTypes, nil, `{"success":true,"result":[{"leaveCode":"code"}]}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("leave records optional code", func(t *testing.T) { + values := map[string]string{"user": "u1", "leave-code": "code", "start": "2026-01-01", "end": "2026-01-31"} + caller, err := executeAttendanceResponse(t, GetLeaveRecords, values, `{"success":true,"result":[]}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("checkin record nested collection", func(t *testing.T) { + values := map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"} + caller, err := executeAttendanceResponse(t, GetCheckinRecord, values, `{"success":true,"result":{"list":[]}}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) +} + +var _ edition.ToolCaller = (*attendanceCoverageCaller)(nil) diff --git a/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go b/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..900f2ea2 --- /dev/null +++ b/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go @@ -0,0 +1,267 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package attendance + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoveragePublicAttendanceContractsAreUnifiedAndTyped(t *testing.T) { + shortcuts := []struct { + name string + rollout output.RolloutState + hasResult bool + resultSchema string + identity string + cursor string + }{ + {CheckResult.Command, CheckResult.OutputRollout, CheckResult.Contract.Result != nil, string(CheckResult.Contract.Result.DataSchema), `"id"`, "offset"}, + {CheckRecord.Command, CheckRecord.OutputRollout, CheckRecord.Contract.Result != nil, string(CheckRecord.Contract.Result.DataSchema), `"id"`, ""}, + {ListApprove.Command, ListApprove.OutputRollout, ListApprove.Contract.Result != nil, string(ListApprove.Contract.Result.DataSchema), `"id"`, ""}, + {GetApproveTemplate.Command, GetApproveTemplate.OutputRollout, GetApproveTemplate.Contract.Result != nil, string(GetApproveTemplate.Contract.Result.DataSchema), `"processCode"`, ""}, + {SearchClass.Command, SearchClass.OutputRollout, SearchClass.Contract.Result != nil, string(SearchClass.Contract.Result.DataSchema), `"classId"`, "page"}, + {SearchAdjustmentRule.Command, SearchAdjustmentRule.OutputRollout, SearchAdjustmentRule.Contract.Result != nil, string(SearchAdjustmentRule.Contract.Result.DataSchema), `"ruleId"`, "page"}, + {GetOvertimeRule.Command, GetOvertimeRule.OutputRollout, GetOvertimeRule.Contract.Result != nil, string(GetOvertimeRule.Contract.Result.DataSchema), `"id"`, ""}, + {SearchOvertimeRule.Command, SearchOvertimeRule.OutputRollout, SearchOvertimeRule.Contract.Result != nil, string(SearchOvertimeRule.Contract.Result.DataSchema), `"ruleId"`, "page"}, + } + for _, item := range shortcuts { + if item.rollout != output.RolloutUnifiedActive { + t.Errorf("%s rollout = %q, want unified_active", item.name, item.rollout) + } + if !item.hasResult || strings.TrimSpace(item.resultSchema) == "" { + t.Errorf("%s must publish a non-empty Result", item.name) + } + if !json.Valid([]byte(item.resultSchema)) || !strings.Contains(item.resultSchema, item.identity) { + t.Errorf("%s Result must declare stable identity %s: %s", item.name, item.identity, item.resultSchema) + } + if len(attendanceDeclarationByName(item.name).Contract.Result.SensitivePaths) == 0 { + t.Errorf("%s must publish reviewed sensitive_paths", item.name) + } + if strings.Contains(item.resultSchema, `"complete"`) || strings.Contains(item.resultSchema, `"nextPage"`) || strings.Contains(item.resultSchema, `"nextOffset"`) { + t.Errorf("%s Result must not place pagination controls in business data", item.name) + } + if item.cursor == "" { + if itemPagination := attendanceDeclarationByName(item.name).Contract.Pagination; itemPagination != nil { + t.Errorf("%s unexpectedly publishes Pagination", item.name) + } + } else if pagination := attendanceDeclarationByName(item.name).Contract.Pagination; pagination == nil || pagination.CursorParameter != item.cursor { + t.Errorf("%s Pagination = %#v, want cursor_parameter=%s", item.name, pagination, item.cursor) + } + } +} + +func attendanceDeclarationByName(name string) *shortcut.Shortcut { + for _, declaration := range []*shortcut.Shortcut{&CheckResult, &CheckRecord, &ListApprove, &GetApproveTemplate, &SearchClass, &SearchAdjustmentRule, &GetOvertimeRule, &SearchOvertimeRule} { + if declaration.Command == name { + return declaration + } + } + return &shortcut.Shortcut{} +} + +func TestCrossPlatformCoverageAttendanceEmptyOnlyLeavesAreUnavailable(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&GetSummary, &ListLeaveTypes, &GetLeaveRecords, &GetCheckinRecord, &GetSchedule, &GetSelfSetting} { + if declaration.OutputRollout != output.RolloutLegacyOnly || declaration.Contract.Result != nil || declaration.Contract.Pagination != nil { + t.Errorf("%s unavailable contract = rollout %q result=%v pagination=%v", declaration.Command, declaration.OutputRollout, declaration.Contract.Result, declaration.Contract.Pagination) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != contract.InterfaceAvailable || declaration.Contract.Interface.Reason != attendanceCompatibilityInterfaceReason { + t.Errorf("%s must preserve its Schema-compatible non-public Interface", declaration.Command) + } + } + if GetClass.OutputRollout != output.RolloutLegacyOnly || GetClass.Contract.Result != nil || GetClass.Contract.Pagination != nil || GetClass.Contract.Interface == nil || GetClass.Contract.Interface.Availability != contract.InterfaceUnavailable || strings.TrimSpace(GetClass.Contract.Interface.Reason) == "" { + t.Error("get-class must publish a precise unavailable Interface") + } +} + +func TestCrossPlatformCoverageAttendanceProjectorsDistinguishEmptyFromBroken(t *testing.T) { + explicitEmpty := map[string]any{ + "success": true, + "result": map[string]any{ + "items": []any{}, + "adjustmentList": []any{}, + "atRuleList": []any{}, + }, + } + if got, err := searchClassProject(explicitEmpty); err != nil || len(got) != 0 { + t.Fatalf("explicit empty class list must succeed: got=%v err=%v", got, err) + } + if got, err := searchRuleProject(explicitEmpty, "attendance-wukong/test", "result.adjustmentList"); err != nil || len(got) != 0 { + t.Fatalf("explicit empty rule list must succeed: got=%v err=%v", got, err) + } + if got, err := searchGroupProject(explicitEmpty); err != nil || len(got) != 0 { + t.Fatalf("explicit empty group list must succeed: got=%v err=%v", got, err) + } + + broken := []map[string]any{ + {}, + {"success": false, "result": []any{}}, + {"success": true, "result": map[string]any{}}, + {"success": true, "result": map[string]any{"items": "not-an-array"}}, + {"success": true, "result": map[string]any{"items": []any{map[string]any{"name": "missing-id"}}}}, + } + for index, data := range broken { + if got, err := searchClassProject(data); err == nil { + t.Errorf("broken class response %d returned success: %v", index, got) + } + if got, err := searchGroupProject(data); err == nil { + t.Errorf("broken group response %d returned success: %v", index, got) + } + } + + ruleBroken := map[string]any{ + "success": true, + "result": map[string]any{ + "adjustmentList": []any{map[string]any{"entityVO": map[string]any{"name": "missing-id"}}}, + }, + } + if got, err := searchRuleProject(ruleBroken, "attendance-wukong/test", "result.adjustmentList"); err == nil { + t.Fatalf("rule row without stable ID returned success: %v", got) + } +} + +func TestCrossPlatformCoverageAttendancePaginationRequiresCompletionEvidence(t *testing.T) { + withoutEvidence := map[string]any{"success": true, "result": map[string]any{"items": []any{}}} + if _, _, err := attendancePageEvidence(withoutEvidence, "attendance-wukong/test", 1, 20, 0); err == nil { + t.Fatal("missing totalCount/totalPage must not be reported as a complete page") + } + firstPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalCount": float64(21)}} + if _, _, err := attendancePageEvidence(firstPage, "attendance-wukong/test", 1, 20, 0); err == nil { + t.Fatal("nonzero total with empty page must fail closed") + } + complete, extra, err := attendancePageEvidence(firstPage, "attendance-wukong/test", 1, 20, 20) + if err != nil { + t.Fatalf("valid page evidence: %v", err) + } + if complete || extra["nextPage"] != 2 { + t.Fatalf("first page should be incomplete with nextPage=2: complete=%v extra=%v", complete, extra) + } + lastPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalPage": float64(2)}} + complete, extra, err = attendancePageEvidence(lastPage, "attendance-wukong/test", 2, 20, 1) + if err != nil || !complete { + t.Fatalf("last page should be complete: complete=%v extra=%v err=%v", complete, extra, err) + } + conflicting := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalPage": float64(1), "totalCount": float64(21)}} + if _, _, err := attendancePageEvidence(conflicting, "attendance-wukong/test", 1, 20, 20); err == nil { + t.Fatal("conflicting totalPage/totalCount evidence must fail closed") + } + mismatchedPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(2), "totalCount": float64(0)}} + if _, _, err := attendancePageEvidence(mismatchedPage, "attendance-wukong/test", 1, 20, 0); err == nil { + t.Fatal("mismatched currentPage must fail closed") + } + negativeTotal := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalCount": float64(-1)}} + if _, _, err := attendancePageEvidence(negativeTotal, "attendance-wukong/test", 1, 20, 0); err == nil { + t.Fatal("negative pagination evidence must fail closed") + } + wrongType := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalCount": "0"}} + if _, _, err := attendancePageEvidence(wrongType, "attendance-wukong/test", 1, 20, 0); err == nil { + t.Fatal("wrong pagination type must fail closed") + } +} + +func TestCrossPlatformCoverageAttendanceStableIdentityRejectsZeroBlankAndDuplicates(t *testing.T) { + for _, items := range [][]map[string]any{{{"id": 0}}, {{"id": ""}}, {{"id": 1}, {"id": 1}}} { + if err := attendanceValidatePositiveIntegerIDs(items, "attendance/test", "id"); err == nil { + t.Fatalf("invalid integer identity accepted: %#v", items) + } + } + if err := attendanceValidatePositiveIntegerIDs([]map[string]any{{"id": float64(1)}, {"id": int64(2)}}, "attendance/test", "id"); err != nil { + t.Fatalf("valid integer identities rejected: %v", err) + } + if err := attendanceValidateExpectedStrings([]map[string]any{{"approveType": ""}}, "attendance/test", "approveType", "LEAVE"); err == nil { + t.Fatal("blank string identity accepted") + } + if err := attendanceValidateExpectedStrings([]map[string]any{{"approveType": "LEAVE"}, {"approveType": "LEAVE"}}, "attendance/test", "approveType", "LEAVE"); err == nil { + t.Fatal("duplicate string identity accepted") + } +} + +func TestCrossPlatformCoverageAttendanceApproveTemplateIdentityAndBinding(t *testing.T) { + valid := []map[string]any{ + {"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}, + {"processCode": "process-2", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/2"}, + } + if err := attendanceValidateApproveTemplates(valid, "attendance/test", "TRAVEL"); err != nil { + t.Fatalf("same approveType with unique processCode rejected: %v", err) + } + valid[0]["processCode"] = " process-1 " + if err := attendanceValidateApproveTemplates(valid, "attendance/test", "TRAVEL"); err != nil || valid[0]["processCode"] != "process-1" { + t.Fatalf("processCode was not normalized: value=%#v err=%v", valid[0]["processCode"], err) + } + valid[0]["approveType"] = "OUT" + valid[1]["approveType"] = "OUT" + if err := attendanceValidateApproveTemplates(valid, "attendance/test", "OUT"); err != nil { + t.Fatalf("OUT templates with unique processCode rejected: %v", err) + } + + invalid := []struct { + name string + items []map[string]any + }{ + {"missing processCode", []map[string]any{{"approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}}}, + {"wrong processCode type", []map[string]any{{"processCode": 1, "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}}}, + {"blank processCode", []map[string]any{{"processCode": " ", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}}}, + {"duplicate processCode", []map[string]any{{"processCode": "same", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}, {"processCode": "same", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/2"}}}, + {"missing approveType", []map[string]any{{"processCode": "process-1", "submitUrl": "https://example.invalid/1"}}}, + {"null approveType", []map[string]any{{"processCode": "process-1", "approveType": nil, "submitUrl": "https://example.invalid/1"}}}, + {"wrong approveType type", []map[string]any{{"processCode": "process-1", "approveType": 1, "submitUrl": "https://example.invalid/1"}}}, + {"wrong approveType", []map[string]any{{"processCode": "process-1", "approveType": "OUT", "submitUrl": "https://example.invalid/1"}}}, + {"missing submitUrl", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL"}}}, + {"null submitUrl", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": nil}}}, + {"wrong submitUrl type", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": 1}}}, + {"blank submitUrl", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": " "}}}, + } + for _, tc := range invalid { + t.Run(tc.name, func(t *testing.T) { + if err := attendanceValidateApproveTemplates(tc.items, "attendance/test", "TRAVEL"); err == nil { + t.Fatalf("invalid templates accepted: %#v", tc.items) + } + }) + } + + var schema map[string]any + if err := json.Unmarshal(GetApproveTemplate.Contract.Result.DataSchema, &schema); err != nil { + t.Fatal(err) + } + items := schema["properties"].(map[string]any)["templates"].(map[string]any)["items"].(map[string]any) + required := items["required"].([]any) + for _, name := range []string{"processCode", "approveType", "submitUrl"} { + found := false + for _, value := range required { + if value == name { + found = true + } + } + if !found { + t.Errorf("template schema required=%#v missing %s", required, name) + } + } + if got, want := strings.Join(GetApproveTemplate.Contract.Result.SensitivePaths, ","), "templates.formName,templates.processCode,templates.submitUrl"; got != want { + t.Fatalf("template sensitive paths = %q, want %q", got, want) + } + properties := items["properties"].(map[string]any) + for _, name := range []string{"processCode", "submitUrl"} { + property := properties[name].(map[string]any) + if property["type"] != "string" || property["minLength"] != float64(1) { + t.Errorf("template schema %s = %#v, want nonempty string", name, property) + } + } + approveType := properties["approveType"].(map[string]any) + if approveType["type"] != "string" { + t.Errorf("template schema approveType = %#v, want string", approveType) + } + enum := approveType["enum"].([]any) + values := make([]string, 0, len(enum)) + for _, value := range enum { + values = append(values, value.(string)) + } + if got, want := strings.Join(values, ","), "REPAIR_CHECK,LEAVE,OVERTIME,TRAVEL,OUT"; got != want { + t.Errorf("template approveType enum = %q, want %q", got, want) + } +} diff --git a/internal/shortcut/attendance/common.go b/internal/shortcut/attendance/common.go new file mode 100644 index 00000000..1b1a15e5 --- /dev/null +++ b/internal/shortcut/attendance/common.go @@ -0,0 +1,560 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package attendance + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" +) + +const attendanceCompatibilityInterfaceReason = "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until its live-data or downstream proof gap is closed." + +func attendanceIntegerObjectResult(description, identity string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格校验后的考勤业务结果","properties":{%q:{"type":"integer","minimum":1,"description":"与请求精确绑定的稳定业务 ID"}},"required":[%q],"additionalProperties":true}},"required":["value"],"additionalProperties":false}`, + description, identity, identity, + )), + } +} + +func attendanceApproveTemplateResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格校验且绑定请求类型的考勤审批模板","properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效审批模板数量"},"templates":{"type":"array","description":"可发起的考勤审批模板","items":{"type":"object","description":"具备稳定流程身份与提交入口的考勤审批模板","properties":{"processCode":{"type":"string","minLength":1,"description":"模板的稳定唯一流程编码"},"approveType":{"type":"string","enum":["REPAIR_CHECK","LEAVE","OVERTIME","TRAVEL","OUT"],"description":"与请求精确绑定的审批类型"},"submitUrl":{"type":"string","minLength":1,"description":"非空审批提交入口"},"formName":{"type":"string","description":"审批模板展示名称"}},"required":["processCode","approveType","submitUrl"],"additionalProperties":true}}},"required":["count","templates"],"additionalProperties":false}`), + } +} + +func attendanceCollectionResult(collection, description, identity, identityType string) *contract.ResultSpec { + identityConstraint := `"minLength":1` + if identityType == "integer" { + identityConstraint = `"minimum":1` + } + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效业务记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"具备稳定身份的考勤业务记录","properties":{%q:{"type":%q,%s,"description":"记录的稳定业务身份"}},"required":[%q],"additionalProperties":true}}},"required":["count",%q],"additionalProperties":false}`, + description, collection, description, identity, identityType, identityConstraint, identity, collection, + )), + } +} + +func attendanceCursorPagination(cursorParameter string) *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: cursorParameter, + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +func hardenPublicAttendanceContracts() { + // Composite Shortcut properties are the stable published workflow inputs. + // Execute owns the explicit adapters to the differently named MCP fields; + // redirecting an existing non-empty Schema property requires a versioned + // migration and cannot be folded into this hardening change. + CheckResult.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}, {Name: "offset", Property: "offset"}, {Name: "limit", Property: "limit"}} + CheckRecord.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}} + ListApprove.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "types", Property: "types"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}} + GetApproveTemplate.Contract.Parameters = []contract.ParamDecl{{Name: "type", Property: "type"}} + GetSchedule.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}} + SearchClass.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "filter-type", Property: "filterType"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}} + GetClass.Contract.Parameters = []contract.ParamDecl{{Name: "class-id", Property: "classId"}} + SearchAdjustmentRule.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}} + GetOvertimeRule.Contract.Parameters = []contract.ParamDecl{{Name: "overtime-id", Property: "overtimeId"}} + SearchOvertimeRule.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}} + GetSelfSetting.Contract.Parameters = []contract.ParamDecl{{Name: "setting-scene", Property: "settingScene"}, {Name: "user", Property: "user"}} + + collections := []struct { + declaration *shortcut.Shortcut + collection string + description string + identity string + identityType string + cursor string + }{ + {&CheckResult, "records", "严格校验的员工打卡结果", "id", "integer", "offset"}, + {&CheckRecord, "records", "严格校验的员工打卡流水", "id", "integer", ""}, + {&ListApprove, "approvals", "严格校验的考勤审批记录", "id", "integer", ""}, + {&SearchClass, "classes", "严格校验的班次搜索结果", "classId", "integer", "page"}, + {&SearchAdjustmentRule, "rules", "严格校验的补卡规则搜索结果", "ruleId", "integer", "page"}, + {&SearchOvertimeRule, "rules", "严格校验的加班规则搜索结果", "ruleId", "integer", "page"}, + } + for _, item := range collections { + item.declaration.OutputRollout = output.RolloutUnifiedActive + item.declaration.Contract.Result = attendanceCollectionResult(item.collection, item.description, item.identity, item.identityType) + if item.cursor != "" { + item.declaration.Contract.Pagination = attendanceCursorPagination(item.cursor) + } + } + CheckResult.Contract.Result.SensitivePaths = []string{"records.corpId", "records.record", "records.userId"} + CheckRecord.Contract.Result.SensitivePaths = []string{"records.baseAddress", "records.baseLatitude", "records.baseLongitude", "records.corpId", "records.deviceId", "records.features", "records.userAddress", "records.userId", "records.userLatitude", "records.userLongitude"} + ListApprove.Contract.Result.SensitivePaths = []string{"approvals.corpId", "approvals.originId", "approvals.subType", "approvals.tagName", "approvals.userId"} + GetApproveTemplate.OutputRollout = output.RolloutUnifiedActive + GetApproveTemplate.Contract.Result = attendanceApproveTemplateResult() + GetApproveTemplate.Contract.Result.SensitivePaths = []string{"templates.formName", "templates.processCode", "templates.submitUrl"} + SearchClass.Contract.Result.SensitivePaths = []string{"classes.name", "classes.ownerName"} + SearchAdjustmentRule.Contract.Result.SensitivePaths = []string{"rules.name"} + SearchOvertimeRule.Contract.Result.SensitivePaths = []string{"rules.name"} + GetOvertimeRule.OutputRollout = output.RolloutUnifiedActive + GetOvertimeRule.Contract.Result = attendanceIntegerObjectResult("严格校验的加班规则详情", "id") + GetOvertimeRule.Contract.Result.SensitivePaths = []string{"value.content", "value.groupIdAndNames", "value.name", "value.owner", "value.ownerList", "value.scopes"} + markAttendanceCompatibilityOnly(&GetSummary) + markAttendanceCompatibilityOnly(&ListLeaveTypes) + markAttendanceCompatibilityOnly(&GetLeaveRecords) + markAttendanceCompatibilityOnly(&GetCheckinRecord) + markAttendanceCompatibilityOnly(&GetSchedule) + markAttendanceUnavailable(&GetClass, "下游 get_class_detail 对搜索得到的真实 classId 返回非空详情,但 shiftVO 不回显 id/classId,Shortcut 无法诚实地把详情精确绑定到请求 ID;需要下游补充稳定 ID 回显。") + markAttendanceCompatibilityOnly(&GetSelfSetting) +} + +func markAttendanceCompatibilityOnly(declaration *shortcut.Shortcut) { + declaration.OutputRollout = output.RolloutLegacyOnly + declaration.Contract.Result = nil + declaration.Contract.Pagination = nil + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: attendanceCompatibilityInterfaceReason, + } +} + +func markAttendanceUnavailable(declaration *shortcut.Shortcut, reason string) { + declaration.OutputRollout = output.RolloutLegacyOnly + declaration.Contract.Result = nil + declaration.Contract.Pagination = nil + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceUnavailable, + Reason: reason, + } +} + +func attendanceCallCollection( + rt *shortcut.RuntimeContext, + product, tool string, + params map[string]any, + collection string, + complete bool, + extra map[string]any, + validate func([]map[string]any) error, + paths ...string, +) error { + data, err := rt.CallMCPData(product, tool, params) + if err != nil { + return err + } + items, err := responsecheck.RequireObjectCollection(data, product+"/"+tool, paths...) + if err != nil { + return err + } + if validate != nil { + if err := validate(items); err != nil { + return err + } + } + return attendanceOutputCollection(rt, collection, items, complete, extra, false, "") +} + +func attendanceCallValue(rt *shortcut.RuntimeContext, product, tool string, params map[string]any) error { + data, err := rt.CallMCPData(product, tool, params) + if err != nil { + return err + } + value, err := responsecheck.RequireResult(data, product+"/"+tool) + if err != nil { + return err + } + return rt.Output(map[string]any{"value": value}) +} + +func attendanceCallObject(rt *shortcut.RuntimeContext, product, tool string, params map[string]any) error { + data, err := rt.CallMCPData(product, tool, params) + if err != nil { + return err + } + value, err := responsecheck.RequireSingleObjectResult(data, product+"/"+tool) + if err != nil { + return err + } + return rt.Output(map[string]any{"value": value}) +} + +func attendanceCollectionPayload(collection string, items []map[string]any, complete bool, extra map[string]any) map[string]any { + payload := map[string]any{ + "count": len(items), + collection: items, + "complete": complete, + } + for key, value := range extra { + payload[key] = value + } + return payload +} + +func attendanceOutputCollection(rt *shortcut.RuntimeContext, collection string, items []map[string]any, complete bool, legacyExtra map[string]any, paginated bool, nextToken string) error { + payload := attendanceCollectionPayload(collection, items, complete, legacyExtra) + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + business := map[string]any{"count": len(items), collection: items} + meta := &output.Meta{Count: output.NewCount(len(items))} + if paginated { + pagination, err := output.NewPagination(complete, nextToken) + if err != nil { + return responsecheck.Error("attendance/pagination", "invalid_pagination", err.Error()) + } + meta.Pagination = pagination + } + return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta))) +} + +func attendanceValidatePositiveIntegerIDs(items []map[string]any, operation string, path ...string) error { + seen := make(map[int64]struct{}, len(items)) + for index, item := range items { + value, ok := attendanceNestedValue(item, path...) + if !ok { + return responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("第 %d 项缺少稳定 ID", index)) + } + identity, ok := attendancePositiveInteger(value) + if !ok { + return responsecheck.Error(operation, "invalid_item_identity", fmt.Sprintf("第 %d 项稳定 ID 必须是大于 0 的整数", index)) + } + if _, duplicate := seen[identity]; duplicate { + return responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("第 %d 项稳定 ID 重复", index)) + } + seen[identity] = struct{}{} + } + return nil +} + +func attendanceValidateExpectedStrings(items []map[string]any, operation, field, expected string) error { + expected = strings.TrimSpace(expected) + seen := make(map[string]struct{}, len(items)) + for index, item := range items { + value, ok := item[field].(string) + value = strings.TrimSpace(value) + if !ok || value == "" { + return responsecheck.Error(operation, "invalid_item_identity", fmt.Sprintf("第 %d 项 %s 必须是非空字符串", index, field)) + } + if expected != "" && value != expected { + return responsecheck.Error(operation, "item_identity_mismatch", fmt.Sprintf("第 %d 项 %s 与请求不一致", index, field)) + } + if _, duplicate := seen[value]; duplicate { + return responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("第 %d 项 %s 重复", index, field)) + } + seen[value] = struct{}{} + } + return nil +} + +func attendanceValidateApproveTemplates(items []map[string]any, operation, expectedType string) error { + expectedType = strings.TrimSpace(expectedType) + seenProcessCodes := make(map[string]struct{}, len(items)) + for index, item := range items { + processCodeValue, present := item["processCode"] + if !present { + return responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("第 %d 项缺少稳定 processCode", index)) + } + processCode, ok := processCodeValue.(string) + processCode = strings.TrimSpace(processCode) + if !ok || processCode == "" { + return responsecheck.Error(operation, "invalid_item_identity", fmt.Sprintf("第 %d 项 processCode 必须是非空字符串", index)) + } + item["processCode"] = processCode + if _, duplicate := seenProcessCodes[processCode]; duplicate { + return responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("第 %d 项 processCode 重复", index)) + } + seenProcessCodes[processCode] = struct{}{} + + approveType, ok := item["approveType"].(string) + if !ok || approveType != expectedType { + return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项 approveType 与请求不一致", index)) + } + submitURL, ok := item["submitUrl"].(string) + if !ok || strings.TrimSpace(submitURL) == "" { + return responsecheck.Error(operation, "malformed_item", fmt.Sprintf("第 %d 项缺少非空 submitUrl", index)) + } + } + return nil +} + +func attendanceNestedValue(item map[string]any, path ...string) (any, bool) { + var current any = item + for _, key := range path { + object, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = object[key] + if !ok { + return nil, false + } + } + return current, true +} + +func attendancePositiveInteger(value any) (int64, bool) { + switch number := value.(type) { + case int: + return int64(number), number > 0 + case int32: + return int64(number), number > 0 + case int64: + return number, number > 0 + case float64: + return int64(number), number > 0 && number == float64(int64(number)) + case json.Number: + parsed, err := strconv.ParseInt(string(number), 10, 64) + return parsed, err == nil && parsed > 0 + default: + return 0, false + } +} + +func attendanceValidateUserIDs(values []string, maximum int) error { + if len(values) == 0 { + return fmt.Errorf("用户 ID 列表不能为空") + } + if maximum > 0 && len(values) > maximum { + return fmt.Errorf("用户 ID 数量不能超过 %d", maximum) + } + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return fmt.Errorf("用户 ID 不能为空字符串") + } + if value != trimmed { + return fmt.Errorf("用户 ID 不能包含首尾空白") + } + if _, duplicate := seen[trimmed]; duplicate { + return fmt.Errorf("用户 ID 不能重复") + } + seen[trimmed] = struct{}{} + } + return nil +} + +func attendanceValidateUserAndTimeBinding(items []map[string]any, operation string, requestedUsers []string, userField, timeField string, startMillis, endMillis int64) error { + users := make(map[string]struct{}, len(requestedUsers)) + for _, user := range requestedUsers { + users[user] = struct{}{} + } + for index, item := range items { + rawUser, present := item[userField] + if !present { + return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少用户身份回显", index)) + } + user, ok := rawUser.(string) + user = strings.TrimSpace(user) + if !ok || user == "" { + return responsecheck.Error(operation, "malformed_request_binding", fmt.Sprintf("第 %d 项用户身份回显必须是非空字符串", index)) + } + if _, requested := users[user]; !requested { + return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项用户身份不在请求集合中", index)) + } + rawTimestamp, present := item[timeField] + if !present { + return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少时间回显", index)) + } + timestamp, ok := attendancePositiveInteger(rawTimestamp) + if !ok { + return responsecheck.Error(operation, "malformed_request_binding", fmt.Sprintf("第 %d 项时间回显必须是大于 0 的整数毫秒时间戳", index)) + } + if timestamp < startMillis || timestamp > endMillis { + return responsecheck.Error(operation, "request_range_mismatch", fmt.Sprintf("第 %d 项时间不在请求范围内", index)) + } + } + return nil +} + +func attendanceValidateApprovalBinding(items []map[string]any, operation string, requestedUsers []string, requestedTypes map[int]struct{}, startMillis, endMillis int64) error { + users := make(map[string]struct{}, len(requestedUsers)) + for _, user := range requestedUsers { + users[user] = struct{}{} + } + for index, item := range items { + user, ok := item["userId"].(string) + user = strings.TrimSpace(user) + if !ok || user == "" { + return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少用户身份回显", index)) + } + if _, requested := users[user]; !requested { + return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项用户身份不在请求集合中", index)) + } + bizType, ok := attendancePositiveInteger(item["bizType"]) + if !ok { + return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少审批类型回显", index)) + } + if _, requested := requestedTypes[int(bizType)]; !requested { + return responsecheck.Error(operation, "request_type_mismatch", fmt.Sprintf("第 %d 项审批类型不在请求集合中", index)) + } + begin, beginOK := attendancePositiveInteger(item["beginTime"]) + end, endOK := attendancePositiveInteger(item["endTime"]) + if !beginOK || !endOK || end < begin || end < startMillis || begin > endMillis { + return responsecheck.Error(operation, "request_range_mismatch", fmt.Sprintf("第 %d 项审批时间不与请求范围相交", index)) + } + } + return nil +} + +func attendanceValidateMonthRange(startText, endText string) error { + start, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(startText), time.Local) + if err != nil { + return fmt.Errorf("--start 日期格式错误,应为 YYYY-MM-DD: %w", err) + } + end, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(endText), time.Local) + if err != nil { + return fmt.Errorf("--end 日期格式错误,应为 YYYY-MM-DD: %w", err) + } + if end.Before(start) { + return fmt.Errorf("--end 不能早于 --start") + } + if end.After(start.AddDate(0, 1, 0)) { + return fmt.Errorf("--start 到 --end 的跨度不能超过 1 个月") + } + return nil +} + +func attendanceValidatePageRequest(rt *shortcut.RuntimeContext) error { + _, _, err := attendancePageInput(rt) + return err +} + +func attendancePageEvidence(data map[string]any, operation string, page, limit, itemCount int) (bool, map[string]any, error) { + if page < 1 || limit < 1 || itemCount < 0 || itemCount > limit { + return false, nil, responsecheck.Error(operation, "invalid_pagination_request", "页码、页大小或当前项数无效") + } + result, err := responsecheck.RequireObjectResult(data, operation) + if err != nil { + return false, nil, err + } + extra := map[string]any{"page": page, "limit": limit} + if raw, present := result["currentPage"]; present { + currentPage, ok := attendanceInt(raw) + if !ok { + return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 currentPage 必须是整数") + } + if currentPage != page { + return false, nil, responsecheck.Error(operation, "pagination_page_mismatch", "服务端 currentPage 与请求页码不一致") + } + } + var evidence []bool + var totalPageValue, totalCountValue *int + if raw, present := result["totalPage"]; present { + totalPage, ok := attendanceInt(raw) + if !ok { + return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalPage 必须是整数") + } + if totalPage < 0 { + return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalPage 不能为负数") + } + extra["totalPage"] = totalPage + totalPageValue = &totalPage + if totalPage == 0 { + if page != 1 || itemCount != 0 { + return false, nil, responsecheck.Error(operation, "pagination_count_mismatch", "totalPage=0 只允许第一页为空") + } + evidence = append(evidence, true) + } else { + if page > totalPage { + return false, nil, responsecheck.Error(operation, "pagination_page_out_of_range", "请求页码超过服务端 totalPage") + } + evidence = append(evidence, page == totalPage) + } + } + if raw, present := result["totalCount"]; present { + totalCount, ok := attendanceInt(raw) + if !ok { + return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalCount 必须是整数") + } + if totalCount < 0 { + return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalCount 不能为负数") + } + extra["totalCount"] = totalCount + totalCountValue = &totalCount + if totalCount > 0 && itemCount == 0 { + return false, nil, responsecheck.Error(operation, "empty_page_with_nonzero_total", "totalCount>0 但当前集合为空,不能证明前进或终止") + } + pageStart := (page - 1) * limit + if pageStart > totalCount || pageStart+itemCount > totalCount { + return false, nil, responsecheck.Error(operation, "pagination_count_mismatch", "当前页项数与 totalCount/page/limit 矛盾") + } + evidence = append(evidence, page*limit >= totalCount) + } + if len(evidence) == 0 { + return false, nil, responsecheck.Error(operation, "missing_pagination_evidence", "分页响应缺少 totalCount/totalPage,无法证明当前页是否完整") + } + complete := evidence[0] + for _, candidate := range evidence[1:] { + if candidate != complete { + return false, nil, responsecheck.Error(operation, "conflicting_pagination_evidence", "服务端 totalCount 与 totalPage 对当前页是否完成给出矛盾证据") + } + } + if totalPageValue != nil && totalCountValue != nil { + expectedPages := 0 + if *totalCountValue > 0 { + expectedPages = (*totalCountValue + limit - 1) / limit + } + if *totalPageValue != expectedPages { + return false, nil, responsecheck.Error(operation, "pagination_total_mismatch", "totalPage 与 totalCount/limit 矛盾") + } + } + if !complete && itemCount == 0 { + return false, nil, responsecheck.Error(operation, "pagination_no_progress", "未完成页没有返回任何稳定项,无法安全前进") + } + if !complete && itemCount != limit { + return false, nil, responsecheck.Error(operation, "pagination_short_page", "未完成页返回项数小于 limit,与继续分页证据矛盾") + } + if complete && totalCountValue != nil { + pageStart := (page - 1) * limit + if pageStart+itemCount != *totalCountValue { + return false, nil, responsecheck.Error(operation, "pagination_count_mismatch", "终止页项数未精确覆盖 totalCount") + } + } + if !complete { + extra["nextPage"] = page + 1 + } + return complete, extra, nil +} + +func attendancePageInput(rt *shortcut.RuntimeContext) (int, int, error) { + page, limit := rt.Int("page"), rt.Int("limit") + if page < 1 { + return 0, 0, fmt.Errorf("--page 必须大于等于 1") + } + if limit < 1 || limit > 200 { + return 0, 0, fmt.Errorf("--limit 必须在 1 到 200 之间") + } + return page, limit, nil +} + +func attendanceInt(value any) (int, bool) { + switch number := value.(type) { + case int: + return number, true + case int32: + return int(number), true + case int64: + return int(number), true + case float64: + if number != float64(int(number)) { + return 0, false + } + return int(number), true + default: + return 0, false + } +} diff --git a/internal/shortcut/attendance/rule_class_projection_test.go b/internal/shortcut/attendance/rule_class_projection_test.go index 1d6e0165..149ecb22 100644 --- a/internal/shortcut/attendance/rule_class_projection_test.go +++ b/internal/shortcut/attendance/rule_class_projection_test.go @@ -18,12 +18,12 @@ import ( "testing" ) -// TestSearchClassProjectShiftVOShape guards against projection-data-loss: +// TestCrossPlatformCoverageSearchClassProjectShiftVOShape guards against projection-data-loss: // get_class_list nests the list under result.items and wraps each shift's // identity under shiftVO. The projection must unwrap shiftVO or +search-class // silently returns empty despite the backend returning shifts. -func TestSearchClassProjectShiftVOShape(t *testing.T) { - const raw = `{"result":{"items":[ +func TestCrossPlatformCoverageSearchClassProjectShiftVOShape(t *testing.T) { + const raw = `{"success":true,"result":{"items":[ {"shiftVO":{"id":957395083,"name":"default shift"}}, {"shiftVO":{"id":957395084,"name":"morning shift"}} ]}}` @@ -31,7 +31,10 @@ func TestSearchClassProjectShiftVOShape(t *testing.T) { if err := json.Unmarshal([]byte(raw), &data); err != nil { t.Fatalf("unmarshal fixture: %v", err) } - got := searchClassProject(data) + got, err := searchClassProject(data) + if err != nil { + t.Fatalf("project class response: %v", err) + } if len(got) != 2 { t.Fatalf("lower/upper mismatch: 2 shifts in backend, projection returned %d (%v)", len(got), got) } @@ -40,18 +43,18 @@ func TestSearchClassProjectShiftVOShape(t *testing.T) { } } -// TestSearchRuleProjectEntityVOShape guards get_overtime_rule (result.atRuleList) +// TestCrossPlatformCoverageSearchRuleProjectEntityVOShape guards get_overtime_rule (result.atRuleList) // and get_adjustment_rule (result.adjustmentList), both wrapping the rule under // entityVO. The resolver must probe those keys and unwrap entityVO or // +search-overtime-rule / +search-adjustment-rule silently return empty. -func TestSearchRuleProjectEntityVOShape(t *testing.T) { +func TestCrossPlatformCoverageSearchRuleProjectEntityVOShape(t *testing.T) { for _, tc := range []struct { name, key string }{ {"overtime", "atRuleList"}, {"adjustment", "adjustmentList"}, } { - raw := `{"result":{"` + tc.key + `":[ + raw := `{"success":true,"result":{"` + tc.key + `":[ {"entityVO":{"id":11,"name":"weekday overtime"},"permissionVO":{}}, {"entityVO":{"id":12,"name":"holiday overtime"},"permissionVO":{}} ]}}` @@ -59,7 +62,10 @@ func TestSearchRuleProjectEntityVOShape(t *testing.T) { if err := json.Unmarshal([]byte(raw), &data); err != nil { t.Fatalf("[%s] unmarshal fixture: %v", tc.name, err) } - got := searchRuleProject(data) + got, err := searchRuleProject(data, "attendance-wukong/test", "result."+tc.key) + if err != nil { + t.Fatalf("[%s] project rule response: %v", tc.name, err) + } if len(got) != 2 { t.Fatalf("[%s] lower/upper mismatch: result.%s has 2 entries, projection returned %d", tc.name, tc.key, len(got)) } diff --git a/internal/shortcut/builtin/attendance_semantic_catalog_test.go b/internal/shortcut/builtin/attendance_semantic_catalog_test.go new file mode 100644 index 00000000..9a8b0f65 --- /dev/null +++ b/internal/shortcut/builtin/attendance_semantic_catalog_test.go @@ -0,0 +1,114 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageAttendanceSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_attendance.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + if source.Service != "attendance" { + t.Fatalf("service = %q", source.Service) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service != "attendance" { + continue + } + if _, exists := registered[item.Command]; exists { + t.Fatalf("duplicate %s", item.Command) + } + registered[item.Command] = item + } + if len(registered) != 35 || len(source.Shortcuts) != 35 { + t.Fatalf("registered/catalog = %d/%d, want 35/35", len(registered), len(source.Shortcuts)) + } + public := 0 + compatibilityVisible := 0 + wantCompatibilityVisible := map[string]bool{ + "+get-adjustment-rule": true, + "+get-checkin-record": true, + "+get-leave-records": true, + "+get-self-setting": true, + "+get-schedule": true, + "+get-summary": true, + "+list-leave-types": true, + "+my-attendance": true, + "+query-report-data": true, + "+search-group": true, + "+this-month": true, + } + var missing, stale []string + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + availability := record.Availability + if availability == "" { + availability = source.Availability + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s: semantic review facts drifted", command) + } + if item.Risk != record.Risk { + t.Errorf("%s: risk=%q want=%q", command, item.Risk, record.Risk) + } + if record.Public { + public++ + if availability != shortcut.AvailabilityAvailable || item.Hidden || item.Availability != shortcut.AvailabilityAvailable { + t.Errorf("%s: public available shortcut is not executable/visible", command) + } + if item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s: public shortcut lacks contract/safety/result/unified output", command) + } + } else if availability == shortcut.AvailabilityAvailable || item.Hidden == record.CompatibilityVisible { + t.Errorf("%s: unavailable shortcut compatibility visibility drifted", command) + } + if availability == shortcut.AvailabilityUnavailable && item.Contract.Interface != nil { + wantInterfaceAvailability := "unavailable" + if record.CompatibilityVisible { + wantInterfaceAvailability = "available" + } + if item.Contract.Interface.Availability != wantInterfaceAvailability || strings.TrimSpace(item.Contract.Interface.Reason) == "" { + t.Errorf("%s: runtime compatibility interface is not explicit", command) + } + } + if record.CompatibilityVisible { + compatibilityVisible++ + } + if record.CompatibilityVisible != wantCompatibilityVisible[command] { + t.Errorf("%s: compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command]) + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } + if public != 8 || compatibilityVisible != 11 { + t.Fatalf("public/compatibility-visible attendance shortcuts = %d/%d, want 8/11", public, compatibilityVisible) + } +} diff --git a/internal/shortcut/builtin/chat_semantic_catalog_test.go b/internal/shortcut/builtin/chat_semantic_catalog_test.go index b83ec229..ef390ee2 100644 --- a/internal/shortcut/builtin/chat_semantic_catalog_test.go +++ b/internal/shortcut/builtin/chat_semantic_catalog_test.go @@ -17,13 +17,14 @@ type chatSemanticCatalogFixture struct { Service string `json:"service"` Availability shortcut.Availability `json:"default_availability"` Shortcuts map[string]struct { - Disposition shortcut.SemanticDisposition `json:"disposition"` - SemanticDelta string `json:"semantic_delta"` - Risk shortcut.Risk `json:"risk"` - Availability shortcut.Availability `json:"availability"` - Primary string `json:"primary"` - Public bool `json:"public"` - Reviewed bool `json:"reviewed"` + Disposition shortcut.SemanticDisposition `json:"disposition"` + SemanticDelta string `json:"semantic_delta"` + Risk shortcut.Risk `json:"risk"` + Availability shortcut.Availability `json:"availability"` + Primary string `json:"primary"` + Public bool `json:"public"` + CompatibilityVisible bool `json:"compatibility_visible"` + Reviewed bool `json:"reviewed"` } `json:"shortcuts"` } diff --git a/internal/shortcut/builtin/mail_semantic_catalog_test.go b/internal/shortcut/builtin/mail_semantic_catalog_test.go new file mode 100644 index 00000000..63e3dd03 --- /dev/null +++ b/internal/shortcut/builtin/mail_semantic_catalog_test.go @@ -0,0 +1,111 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageMailSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_mail.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + if source.Service != "mail" { + t.Fatalf("service = %q", source.Service) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "mail" { + registered[item.Command] = item + } + } + if len(registered) != 18 || len(source.Shortcuts) != 18 { + t.Fatalf("registered/catalog = %d/%d, want 18/18", len(registered), len(source.Shortcuts)) + } + var missing, stale []string + public := 0 + unavailable := 0 + compatibilityVisible := 0 + wantCompatibilityVisible := map[string]bool{ + "+contact-list": true, + "+recent-mail": true, + "+tag-list": true, + "+template-list": true, + "+thread-list": true, + "+unread-mail": true, + } + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + availability := record.Availability + if availability == "" { + availability = source.Availability + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s: semantic review facts drifted", command) + } + if item.Risk != record.Risk { + t.Errorf("%s: risk=%q want=%q", command, item.Risk, record.Risk) + } + if record.Public { + public++ + if availability != shortcut.AvailabilityAvailable || item.Hidden || item.Availability != shortcut.AvailabilityAvailable { + t.Errorf("%s: public available shortcut is not executable/visible", command) + } + if item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s: public shortcut lacks contract/safety/result/unified output", command) + } + } + if availability == shortcut.AvailabilityUnavailable { + unavailable++ + if item.Hidden == record.CompatibilityVisible || item.Availability != shortcut.AvailabilityUnavailable { + t.Errorf("%s: unavailable shortcut compatibility visibility drifted", command) + } + wantInterfaceAvailability := "unavailable" + if record.CompatibilityVisible { + wantInterfaceAvailability = "available" + } + if item.Contract.Interface == nil || item.Contract.Interface.Availability != wantInterfaceAvailability || strings.TrimSpace(item.Contract.Interface.Reason) == "" { + t.Errorf("%s: runtime compatibility interface is not explicit", command) + } + if item.Contract.Result != nil || item.Contract.Pagination != nil || item.OutputRollout != output.RolloutLegacyOnly { + t.Errorf("%s: unavailable runtime still publishes result/pagination/unified rollout", command) + } + } + if record.CompatibilityVisible { + compatibilityVisible++ + } + if record.CompatibilityVisible != wantCompatibilityVisible[command] { + t.Errorf("%s: compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command]) + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } + if public != 8 || unavailable != 10 || compatibilityVisible != 6 { + t.Fatalf("mail public/unavailable/compatibility-visible shortcuts = %d/%d/%d, want 8/10/6", public, unavailable, compatibilityVisible) + } +} diff --git a/internal/shortcut/mail/common.go b/internal/shortcut/mail/common.go new file mode 100644 index 00000000..6ac9c5f9 --- /dev/null +++ b/internal/shortcut/mail/common.go @@ -0,0 +1,527 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const mailCompositeReason = "Reviewed Mail Shortcut composite: the executable CLI owns strict response validation, pagination evidence, stable-identity checks, output projection, and confirmation; no single MCP interface represents the complete command contract." + +const mailCompatibilityInterfaceReason = "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until its live-data or cleanup proof gap is closed." + +func mailCollectionResult(collection, description string) *contract.ResultSpec { + identity := mailCollectionIdentitySchema(collection) + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + SensitivePaths: mailCollectionSensitivePaths(collection), + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格校验后的邮件业务记录",%s,"additionalProperties":true}}},"required":["count",%q],"additionalProperties":false}`, + description, collection, description, identity, collection, + )), + } +} + +func mailCollectionSensitivePaths(collection string) []string { + switch collection { + case "threads": + return []string{"threads.subject", "threads.lastUpdated"} + case "folders": + return []string{"folders.name"} + case "tags": + return []string{"tags.name"} + case "users": + return []string{"users.name", "users.email", "users.employeeNo"} + case "templates": + return []string{"templates.name", "templates.subject"} + case "contacts": + return []string{"contacts.contactEmail", "contacts.displayName"} + case "messages": + return []string{"messages.subject", "messages.body", "messages.markdownBody", "messages.from", "messages.toRecipients", "messages.ccRecipients", "messages.bccRecipients", "messages.attachments", "messages.receivedDateTime", "messages.sentDateTime"} + default: + panic("unsupported Mail collection sensitive paths: " + collection) + } +} + +func mailCollectionIdentitySchema(collection string) string { + switch collection { + case "threads": + return `"properties":{"conversationId":{"type":"string","minLength":1,"description":"稳定邮件会话 ID"}},"required":["conversationId"]` + case "folders", "tags", "templates", "contacts", "messages": + return `"properties":{"id":{"type":"string","minLength":1,"description":"稳定邮件业务对象 ID"}},"required":["id"]` + case "users": + return `"properties":{"userId":{"type":["string","number"],"description":"稳定邮箱用户 ID"},"email":{"type":"string","minLength":1,"description":"稳定邮箱地址身份"}},"anyOf":[{"required":["userId"]},{"required":["email"]}]` + default: + panic("unsupported Mail collection Result identity: " + collection) + } +} + +func mailCursorPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: "cursor", + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +func mailObjectResult(description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格校验且身份匹配的邮件业务对象","properties":{"id":{"type":"string","minLength":1,"description":"稳定邮件业务对象 ID"}},"required":["id"],"additionalProperties":true}},"required":["value"],"additionalProperties":false}`, + description, + )), + SensitivePaths: []string{"value.body", "value.markdownBody", "value.subject", "value.summary", "value.name", "value.email", "value.contactEmail", "value.displayName", "value.from", "value.toRecipients", "value.ccRecipients", "value.bccRecipients", "value.senders", "value.attachments", "value.receivedDateTime", "value.sentDateTime"}, + } +} + +func mailReadSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func mailValidatePageSize(rt *shortcut.RuntimeContext, flag string, required bool) error { + if !required && !rt.Changed(flag) { + return nil + } + value := rt.Int(flag) + if value < 1 || value > 100 { + return apperrors.NewValidation(fmt.Sprintf("--%s 必须在 1-100 之间", flag)) + } + return nil +} + +func mailStringPageSize(rt *shortcut.RuntimeContext, flag string, required bool) (string, error) { + if !required && !rt.Changed(flag) { + return "", nil + } + value, err := strconv.Atoi(rt.Str(flag)) + if err != nil || value < 1 || value > 100 { + return "", apperrors.NewValidation(fmt.Sprintf("--%s 必须是 1-100 之间的整数", flag)) + } + return strconv.Itoa(value), nil +} + +func mailValidateStringPageSize(rt *shortcut.RuntimeContext, flag string, required bool) error { + _, err := mailStringPageSize(rt, flag, required) + return err +} + +func mailValidateRequiredText(rt *shortcut.RuntimeContext, flag string) error { + if strings.TrimSpace(rt.Str(flag)) == "" { + return apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", flag)) + } + return nil +} + +func mailValidateThreadList(rt *shortcut.RuntimeContext) error { + if err := mailValidatePageSize(rt, "limit", true); err != nil { + return err + } + parsed := make(map[string]time.Time, 2) + for _, flag := range []string{"start", "end"} { + if !rt.Changed(flag) { + continue + } + value, err := time.Parse(time.RFC3339, strings.TrimSpace(rt.Str(flag))) + if err != nil { + return apperrors.NewValidation(fmt.Sprintf("--%s 必须是 RFC3339 时间", flag)) + } + _, offset := value.Zone() + if offset != 0 { + return apperrors.NewValidation(fmt.Sprintf("--%s 必须使用 UTC 时区", flag)) + } + parsed[flag] = value + } + start, hasStart := parsed["start"] + end, hasEnd := parsed["end"] + if hasStart && hasEnd && end.Before(start) { + return apperrors.NewValidation("--end 不能早于 --start") + } + return nil +} + +func mailWriteSafety(idempotency string) contract.SafetySpec { + return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: idempotency} +} + +func mailReadContract(command, description, intent string, result *contract.ResultSpec, flags []contract.ParamDecl, examples ...string) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") + cliPath := "mail " + command + return corecmd.ContractDecl{ + Description: description, + Result: result, + Identity: contract.ToolIdentitySpec{ + ProductID: "mail", Name: name, CanonicalPath: "mail." + name, + CLIPath: cliPath, PrimaryCLIPath: cliPath, + }, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: mailCompositeReason}, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{intent}, + AvoidWhen: []string{"只需摘要列表时使用 mail +triage 或 +recent-mail;需要原始响应时改用对应原子命令"}, + Examples: examples, + }, + Parameters: flags, + } +} + +func mailWriteContract(command, description, intent string, flags []contract.ParamDecl, examples ...string) corecmd.ContractDecl { + contractDecl := mailReadContract(command, description, intent, mailObjectResult(description), flags, examples...) + contractDecl.Selection.AvoidWhen = []string{"用户尚未确认写入内容或目标时不要执行;只需读取时使用对应只读 shortcut"} + return contractDecl +} + +func mailResponseError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +// mailRequireSuccess accepts only the two success encodings observed from the +// Mail backend: boolean true and the exact string "true". Missing, false, or +// malformed status values fail closed. +func mailRequireSuccess(data map[string]any, operation string) error { + if len(data) == 0 { + return mailResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明调用成功或结果确实为空") + } + switch value := data["success"].(type) { + case bool: + if value { + return nil + } + case string: + if value == "true" { + return nil + } + } + if _, present := data["success"]; !present { + return mailResponseError(operation, "missing_success", "响应缺少 success 业务状态") + } + message := mailFirstString(data, "errorMessage", "errorMsg", "message", "error") + if message == "" { + message = "服务未明确返回成功状态" + } + return mailResponseError(operation, "remote_failure", message) +} + +func mailRequireCollection(data map[string]any, operation, path string) ([]map[string]any, error) { + if err := mailRequireSuccess(data, operation); err != nil { + return nil, err + } + value, present := mailLookup(data, path) + if !present { + return nil, mailResponseError(operation, "missing_collection", fmt.Sprintf("成功响应缺少 %s 数组;不能把未知响应结构当作空结果", path)) + } + raw, ok := value.([]any) + if !ok { + return nil, mailResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value)) + } + items := make([]map[string]any, 0, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, mailResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index)) + } + items = append(items, object) + } + return items, nil +} + +func mailRequireObject(data map[string]any, operation, path string) (map[string]any, error) { + if err := mailRequireSuccess(data, operation); err != nil { + return nil, err + } + value, present := mailLookup(data, path) + if !present || value == nil { + return nil, mailResponseError(operation, "missing_result", fmt.Sprintf("成功响应缺少非空 %s 对象", path)) + } + object, ok := value.(map[string]any) + if !ok || len(object) == 0 { + return nil, mailResponseError(operation, "malformed_result", fmt.Sprintf("响应 %s 应为非空对象,实际为 %T", path, value)) + } + return object, nil +} + +func mailRequireIdentity(object map[string]any, operation, expected string, keys ...string) error { + actual := mailFirstString(object, keys...) + if actual == "" { + return mailResponseError(operation, "missing_stable_id", "业务对象缺少稳定 ID") + } + if expected != "" && actual != expected { + return mailResponseError(operation, "identity_mismatch", "业务对象 ID 与请求目标不一致") + } + return nil +} + +func mailValidateRows(items []map[string]any, operation string, identityKeys ...string) error { + allowNumericID := false + for _, key := range identityKeys { + if key == "email" { + allowNumericID = true + break + } + } + for index, item := range items { + valid := mailFirstString(item, identityKeys...) != "" + if !valid && allowNumericID { + valid = mailNumericIdentity(item["id"]) + } + if !valid { + return mailResponseError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index)) + } + } + return nil +} + +func mailNumericIdentity(value any) bool { + switch value.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, json.Number: + return true + default: + return false + } +} + +func mailProjectValue(operation, outputName string, value any) (any, bool, error) { + if value == nil { + return nil, false, nil + } + switch outputName { + case "email": + text, ok := value.(string) + if !ok { + return nil, false, mailResponseError(operation, "malformed_item_identity", "邮箱用户 email 必须是字符串") + } + text = strings.TrimSpace(text) + return text, text != "", nil + case "userId": + if text, ok := value.(string); ok { + text = strings.TrimSpace(text) + return text, text != "", nil + } + if !mailNumericIdentity(value) { + return nil, false, mailResponseError(operation, "malformed_item_identity", "邮箱用户 userId 必须是字符串或数字") + } + return value, true, nil + default: + if text, ok := value.(string); ok { + if strings.TrimSpace(text) == "" { + return nil, false, nil + } + return text, true, nil + } + return value, true, nil + } +} + +func mailProjectCollection(data map[string]any, operation, path string, identityKeys []string, fields map[string][]string) ([]map[string]any, error) { + items, err := mailRequireCollection(data, operation, path) + if err != nil { + return nil, err + } + if err := mailValidateRows(items, operation, identityKeys...); err != nil { + return nil, err + } + projected := make([]map[string]any, 0, len(items)) + for _, item := range items { + row := make(map[string]any, len(fields)) + for outputName, candidates := range fields { + for _, candidate := range candidates { + if value, present := item[candidate]; present && value != nil { + projectedValue, include, err := mailProjectValue(operation, outputName, value) + if err != nil { + return nil, err + } + if !include { + continue + } + row[outputName] = projectedValue + break + } + } + } + projected = append(projected, row) + } + return projected, nil +} + +func mailPage(data map[string]any, operation, prefix, currentCursor string) (bool, string, error) { + container := data + if prefix != "" { + value, present := mailLookup(data, prefix) + if !present { + return false, "", mailResponseError(operation, "missing_pagination", "分页响应缺少已审核的分页对象") + } + var ok bool + container, ok = value.(map[string]any) + if !ok { + return false, "", mailResponseError(operation, "malformed_pagination", "分页容器不是对象") + } + } + nextValue, nextPresent := container["nextCursor"] + next, nextOK := nextValue.(string) + if !nextPresent || !nextOK { + return false, "", mailResponseError(operation, "missing_pagination", "响应缺少字符串 nextCursor,无法证明结果是否完整") + } + if raw, present := container["hasMore"]; present { + hasMore, ok := mailBool(raw) + if !ok { + return false, "", mailResponseError(operation, "malformed_pagination", "hasMore 应为布尔值") + } + terminal := strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" + if hasMore && terminal { + return false, "", mailResponseError(operation, "missing_next_cursor", "hasMore=true 但 nextCursor 为空") + } + if !hasMore && !terminal { + return false, "", mailResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 非空") + } + if !hasMore { + return true, "", nil + } + if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) { + return false, "", mailResponseError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor") + } + return false, next, nil + } + if strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" { + return true, "", nil + } + if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) { + return false, "", mailResponseError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor") + } + return false, next, nil +} + +func mailBool(value any) (bool, bool) { + switch typed := value.(type) { + case bool: + return typed, true + case string: + if typed == "true" { + return true, true + } + if typed == "false" { + return false, true + } + } + return false, false +} + +func mailLookup(object map[string]any, path string) (any, bool) { + var current any = object + for _, segment := range strings.Split(path, ".") { + mapped, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = mapped[segment] + if !ok { + return nil, false + } + } + return current, true +} + +func mailFirstString(object map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func mailCollectionPayload(collection string, items []map[string]any, complete bool, next string) map[string]any { + payload := map[string]any{"count": len(items), collection: items, "complete": complete} + if !complete { + payload["nextCursor"] = next + } + return payload +} + +func mailBusinessCollectionPayload(collection string, items []map[string]any) map[string]any { + return map[string]any{"count": len(items), collection: items} +} + +// mailOutputPage preserves legacy payload compatibility while keeping cursor +// controls in the unified envelope's meta.pagination contract. +func mailOutputPage(rt *shortcut.RuntimeContext, collection string, items []map[string]any, complete bool, next string) error { + payload := mailCollectionPayload(collection, items, complete, next) + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + pagination, err := output.NewPagination(complete, next) + if err != nil { + return mailResponseError("mail/pagination", "invalid_pagination", err.Error()) + } + business := map[string]any{"count": len(items), collection: items} + meta := &output.Meta{Pagination: pagination, Count: output.NewCount(len(items))} + return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta))) +} + +func hardenPublicMailContracts() { + collections := []struct { + declaration *shortcut.Shortcut + collection string + description string + paginated bool + }{ + {&ThreadList, "threads", "严格校验的邮件会话列表", true}, + {&FolderList, "folders", "严格校验的邮箱文件夹列表", false}, + {&TagList, "tags", "严格校验的邮件标签列表", false}, + {&UserSearch, "users", "严格校验的企业邮箱用户搜索结果", true}, + {&TemplateList, "templates", "严格校验的邮件模板列表", true}, + {&ContactList, "contacts", "严格校验的邮件联系人列表", true}, + } + for _, item := range collections { + item.declaration.OutputRollout = output.RolloutUnifiedActive + item.declaration.Contract.Result = mailCollectionResult(item.collection, item.description) + if item.paginated { + item.declaration.Contract.Pagination = mailCursorPagination() + } + item.declaration.Contract.Interface = &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: mailCompositeReason} + } + markMailCompatibilityOnly(&ThreadList) + markMailCompatibilityOnly(&TagList) + markMailCompatibilityOnly(&TemplateList) + markMailCompatibilityOnly(&ContactList) +} + +func markMailCompatibilityOnly(declaration *shortcut.Shortcut) { + declaration.OutputRollout = output.RolloutLegacyOnly + declaration.Contract.Result = nil + declaration.Contract.Pagination = nil + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: mailCompatibilityInterfaceReason, + } +} + +func markMailUnavailable(declaration *shortcut.Shortcut, reason string) { + declaration.OutputRollout = output.RolloutLegacyOnly + declaration.Contract.Result = nil + declaration.Contract.Pagination = nil + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceUnavailable, + Reason: reason, + } +} diff --git a/internal/shortcut/mail/mail.go b/internal/shortcut/mail/mail.go index 525505ce..2aeab198 100644 --- a/internal/shortcut/mail/mail.go +++ b/internal/shortcut/mail/mail.go @@ -18,8 +18,11 @@ package mail import ( + "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -64,6 +67,13 @@ var ThreadList = shortcut.Shortcut{ PrimaryCLIPath: "mail +thread-list", }, Description: "列出指定邮箱文件夹下的邮件会话(thread)", + Parameters: []contract.ParamDecl{ + {Name: "folder", Property: "folder"}, + {Name: "limit", Property: "limit"}, + {Name: "start", Property: "start"}, + {Name: "end", Property: "end"}, + {Name: "ascending", Property: "ascending"}, + }, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -85,6 +95,13 @@ var ThreadList = shortcut.Shortcut{ {Name: "end", Type: shortcut.FlagString, Desc: "结束 UTC 时间,如 2024-12-31T23:59:59Z"}, {Name: "ascending", Type: shortcut.FlagBool, Desc: "是否按时间升序"}, }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100 之间"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start/--end 必须是 UTC RFC3339 时间,且 end 不能早于 start"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateThreadList(rt) + }, Tips: []string{ `dws mail +thread-list --email user@company.com --folder 104 --limit 20`, }, @@ -110,76 +127,20 @@ var ThreadList = shortcut.Shortcut{ if err != nil { return err } - threads := threadListProject(data) - return rt.Output(map[string]any{"count": len(threads), "threads": threads}) + threads, err := mailProjectCollection(data, "mail/list_mailbox_threads", "result.conversations", []string{"id"}, map[string][]string{ + "conversationId": {"id"}, "subject": {"subject"}, "lastUpdated": {"lastModifiedDateTime"}, "isRead": {"isRead"}, + }) + if err != nil { + return err + } + complete, next, err := mailPage(data, "mail/list_mailbox_threads", "result", rt.Str("cursor")) + if err != nil { + return err + } + return mailOutputPage(rt, "threads", threads, complete, next) }, } -// threadListProject reshapes the raw list_mailbox_threads response into a clean -// {conversationId, subject, lastUpdated, isRead} thread list — -// clean output projection. Both the list container and per-item -// field names are probed defensively across candidate keys, so an empty/unknown -// shape yields an empty list rather than a crash or fabricated data. -func threadListProject(data map[string]any) []map[string]any { - raw := threadListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := threadListFirst(m, "conversationId", "conversation_id", "id", "threadId"); ok { - row["conversationId"] = v - } - if v, ok := threadListFirst(m, "subject", "title", "topic"); ok { - row["subject"] = v - } - if v, ok := threadListFirst(m, "lastUpdated", "last_updated", "lastModifiedDateTime", "updateTime", "modifiedTime", "sentDateTime"); ok { - row["lastUpdated"] = v - } - if v, ok := threadListFirst(m, "isRead", "is_read", "read", "unread"); ok { - row["isRead"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// threadListResolveList locates the list payload inside the response, tolerating -// a bare top-level array container or nesting one level deeper. -func threadListResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "threads", "conversations"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "threads", "conversations", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// threadListFirst returns the first present candidate key's value. -func threadListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // Thread 根据会话 ID 获取会话详情。 // ThreadUpdate 修改单个邮件会话的状态或标签。 // ThreadBatchUpdate 批量修改邮件会话的状态或标签。 @@ -208,6 +169,7 @@ var FolderList = shortcut.Shortcut{ PrimaryCLIPath: "mail +folder-list", }, Description: "列出顶层文件夹或指定父文件夹下的子文件夹", + Parameters: []contract.ParamDecl{{Name: "folder", Property: "folder"}}, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -221,9 +183,13 @@ var FolderList = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "email", Type: shortcut.FlagString, Desc: "邮件所属邮箱地址", Required: true}, + {Name: "email", Type: shortcut.FlagString, Desc: "邮件所属邮箱地址,不能为空", Required: true}, {Name: "folder", Type: shortcut.FlagString, Desc: "父文件夹 ID,不传则返回顶层文件夹"}, }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"email"}, Description: "不能为空"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateRequiredText(rt, "email") + }, Tips: []string{ `dws mail +folder-list --email user@company.com`, }, @@ -238,73 +204,16 @@ var FolderList = shortcut.Shortcut{ if err != nil { return err } - folders := folderListProject(data) - return rt.Output(map[string]any{"count": len(folders), "folders": folders}) + folders, err := mailProjectCollection(data, "mail/list_folders", "folders", []string{"id"}, map[string][]string{ + "id": {"id"}, "name": {"displayName"}, "parentId": {"parentFolderId"}, + }) + if err != nil { + return err + } + return rt.Output(mailBusinessCollectionPayload("folders", folders)) }, } -// folderListProject reshapes the raw list_folders response into a clean -// {id, name, parentId} folder list — clean output projection. Both -// the list container and per-item field names are probed defensively across -// candidate keys, so an empty/unknown shape yields an empty list rather than a -// crash or fabricated data. -func folderListProject(data map[string]any) []map[string]any { - raw := folderListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := folderListFirst(m, "id", "folderId", "folder_id"); ok { - row["id"] = v - } - if v, ok := folderListFirst(m, "name", "folderName", "folder_name", "displayName"); ok { - row["name"] = v - } - if v, ok := folderListFirst(m, "parentId", "parent_id", "parentFolderId"); ok { - row["parentId"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// folderListResolveList locates the list payload inside the response, tolerating -// a bare top-level array container or nesting one level deeper. -func folderListResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "folders"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "folders", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// folderListFirst returns the first present candidate key's value. -func folderListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // FolderCreate 创建邮件文件夹。 // FolderUpdate 更新邮件文件夹名称。 // FolderDelete 删除邮件文件夹。 @@ -356,73 +265,16 @@ var TagList = shortcut.Shortcut{ if err != nil { return err } - tags := tagListProject(data) - return rt.Output(map[string]any{"count": len(tags), "tags": tags}) + tags, err := mailProjectCollection(data, "mail/list_tags", "tags", []string{"id"}, map[string][]string{ + "id": {"id"}, "name": {"name"}, "parentId": {"parentId"}, + }) + if err != nil { + return err + } + return rt.Output(mailBusinessCollectionPayload("tags", tags)) }, } -// tagListProject reshapes the raw list_tags response into a clean -// {id, name, parentId} tag list — clean output projection. Both the -// list container and per-item field names are probed defensively across -// candidate keys, so an empty/unknown shape yields an empty list rather than a -// crash or fabricated data. -func tagListProject(data map[string]any) []map[string]any { - raw := tagListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := tagListFirst(m, "id", "tagId", "tag_id"); ok { - row["id"] = v - } - if v, ok := tagListFirst(m, "name", "tagName", "tag_name", "displayName"); ok { - row["name"] = v - } - if v, ok := tagListFirst(m, "parentId", "parent_id"); ok { - row["parentId"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// tagListResolveList locates the list payload inside the response, tolerating a -// bare top-level array container or nesting one level deeper. -func tagListResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "tags"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "tags", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// tagListFirst returns the first present candidate key's value. -func tagListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // TagCreate 创建邮件标签。 // TagUpdate 更新邮件标签名称。 // TagDelete 删除邮件标签。 @@ -449,6 +301,10 @@ var UserSearch = shortcut.Shortcut{ PrimaryCLIPath: "mail +user-search", }, Description: "按关键词或工号搜索邮箱用户(仅企业邮箱)", + Parameters: []contract.ParamDecl{ + {Name: "employee-no", Property: "employeeNo"}, + {Name: "limit", Property: "limit"}, + }, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -465,20 +321,40 @@ var UserSearch = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "keyword", Type: shortcut.FlagString, Desc: "搜索关键词(未提供 --employee-no 时为必填)"}, - {Name: "employee-no", Type: shortcut.FlagString, Desc: "按工号精确搜索"}, + {Name: "keyword", Type: shortcut.FlagString, Desc: "搜索关键词;显式提供时不能为空(未提供 --employee-no 时为必填)"}, + {Name: "employee-no", Type: shortcut.FlagString, Desc: "按工号精确搜索;显式提供时不能为空"}, {Name: "email", Type: shortcut.FlagString, Desc: "搜索目标邮箱地址"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"}, - {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量"}, + {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量,必须是 1-100 之间的整数"}, }, Constraints: []shortcut.Constraint{ {Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"keyword", "employee-no"}}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"keyword", "employee-no"}, Description: "不能为空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "1-100"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := mailValidateStringPageSize(rt, "limit", false); err != nil { + return err + } + for _, name := range []string{"keyword", "employee-no"} { + if rt.Changed(name) && strings.TrimSpace(rt.Str(name)) == "" { + return apperrors.NewValidation("--" + name + " 显式提供时不能为空") + } + } + if strings.TrimSpace(rt.Str("keyword")) == "" && strings.TrimSpace(rt.Str("employee-no")) == "" { + return apperrors.NewValidation("--keyword 和 --employee-no 至少需要一个非空值") + } + return nil }, Tips: []string{ `dws mail +user-search --keyword "张三"`, `dws mail +user-search --email user@company.com --employee-no "E123456"`, }, Execute: func(rt *shortcut.RuntimeContext) error { + size, err := mailStringPageSize(rt, "limit", false) + if err != nil { + return err + } params := map[string]any{} if rt.Str("keyword") != "" { params["keyword"] = rt.Str("keyword") @@ -493,82 +369,26 @@ var UserSearch = shortcut.Shortcut{ params["cursor"] = rt.Str("cursor") } if rt.Changed("limit") { - params["size"] = rt.Str("limit") + params["size"] = size } data, err := rt.CallMCPData("mail", "search_mail_users", params) if err != nil { return err } - users := userSearchProject(data) - return rt.Output(map[string]any{"count": len(users), "users": users}) + users, err := mailProjectCollection(data, "mail/search_mail_users", "users", []string{"id", "email"}, map[string][]string{ + "name": {"name"}, "email": {"email"}, "employeeNo": {"employeeNo"}, "userId": {"id"}, + }) + if err != nil { + return err + } + complete, next, err := mailPage(data, "mail/search_mail_users", "", rt.Str("cursor")) + if err != nil { + return err + } + return mailOutputPage(rt, "users", users, complete, next) }, } -// userSearchProject reshapes the raw search_mail_users response into a clean -// {name, email, employeeNo, userId} user list — clean output projection. -// Both the list container and per-item field names are probed defensively -// across candidate keys, so an empty/unknown shape yields an empty list rather -// than a crash or fabricated data. -func userSearchProject(data map[string]any) []map[string]any { - raw := userSearchResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := userSearchFirst(m, "name", "userName", "displayName", "nickName"); ok { - row["name"] = v - } - if v, ok := userSearchFirst(m, "email", "mail", "emailAddress"); ok { - row["email"] = v - } - if v, ok := userSearchFirst(m, "employeeNo", "employee_no", "employeeNumber", "jobNumber"); ok { - row["employeeNo"] = v - } - if v, ok := userSearchFirst(m, "userId", "user_id", "id"); ok { - row["userId"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// userSearchResolveList locates the list payload inside the response, tolerating -// a bare top-level array container or nesting one level deeper. -func userSearchResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "users"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "users", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// userSearchFirst returns the first present candidate key's value. -func userSearchFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // ── attachment ───────────────────────────────────────────── // AttachmentList 列举邮件附件。 @@ -596,6 +416,7 @@ var TemplateList = shortcut.Shortcut{ PrimaryCLIPath: "mail +template-list", }, Description: "列出指定邮箱的所有邮件模板", + Parameters: []contract.ParamDecl{{Name: "limit", Property: "limit"}}, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -610,16 +431,24 @@ var TemplateList = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "email", Type: shortcut.FlagString, Desc: "用户邮箱地址", Required: true}, - {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量", Required: true}, + {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量,必须是 1-100 之间的整数", Required: true}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"}, }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100 之间"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateStringPageSize(rt, "limit", true) + }, Tips: []string{ `dws mail +template-list --email user@company.com --limit 20`, }, Execute: func(rt *shortcut.RuntimeContext) error { + size, err := mailStringPageSize(rt, "limit", true) + if err != nil { + return err + } params := map[string]any{ "email": rt.Str("email"), - "size": rt.Str("limit"), + "size": size, } if rt.Changed("cursor") { params["cursor"] = rt.Str("cursor") @@ -628,73 +457,20 @@ var TemplateList = shortcut.Shortcut{ if err != nil { return err } - templates := templateListProject(data) - return rt.Output(map[string]any{"count": len(templates), "templates": templates}) + templates, err := mailProjectCollection(data, "mail/list_user_message_templates", "templates", []string{"id"}, map[string][]string{ + "id": {"id"}, "name": {"name"}, "subject": {"subject"}, + }) + if err != nil { + return err + } + complete, next, err := mailPage(data, "mail/list_user_message_templates", "", rt.Str("cursor")) + if err != nil { + return err + } + return mailOutputPage(rt, "templates", templates, complete, next) }, } -// templateListProject reshapes the raw list_user_message_templates response into -// a clean {id, name, subject} template list — clean output projection. -// Both the list container and per-item field names are probed defensively -// across candidate keys, so an empty/unknown shape yields an empty list rather -// than a crash or fabricated data. -func templateListProject(data map[string]any) []map[string]any { - raw := templateListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := templateListFirst(m, "id", "templateId", "template_id"); ok { - row["id"] = v - } - if v, ok := templateListFirst(m, "name", "templateName", "template_name", "displayName"); ok { - row["name"] = v - } - if v, ok := templateListFirst(m, "subject", "title"); ok { - row["subject"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// templateListResolveList locates the list payload inside the response, -// tolerating a bare top-level array container or nesting one level deeper. -func templateListResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "templates"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "templates", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// templateListFirst returns the first present candidate key's value. -func templateListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // TemplateGet 获取邮件模板详情。 // TemplateUpdate 更新邮件模板。 // TemplateDelete 删除邮件模板。 @@ -722,6 +498,7 @@ var ContactList = shortcut.Shortcut{ PrimaryCLIPath: "mail +contact-list", }, Description: "列出指定邮箱的所有邮件联系人", + Parameters: []contract.ParamDecl{{Name: "limit", Property: "limit"}}, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -736,16 +513,24 @@ var ContactList = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "email", Type: shortcut.FlagString, Desc: "用户邮箱地址", Required: true}, - {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量", Required: true}, + {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量,必须是 1-100 之间的整数", Required: true}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"}, }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100 之间"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateStringPageSize(rt, "limit", true) + }, Tips: []string{ `dws mail +contact-list --email user@company.com --limit 20`, }, Execute: func(rt *shortcut.RuntimeContext) error { + size, err := mailStringPageSize(rt, "limit", true) + if err != nil { + return err + } params := map[string]any{ "email": rt.Str("email"), - "size": rt.Str("limit"), + "size": size, } if rt.Changed("cursor") { params["cursor"] = rt.Str("cursor") @@ -754,73 +539,20 @@ var ContactList = shortcut.Shortcut{ if err != nil { return err } - contacts := contactListProject(data) - return rt.Output(map[string]any{"count": len(contacts), "contacts": contacts}) + contacts, err := mailProjectCollection(data, "mail/list_user_mail_contacts", "contacts", []string{"id"}, map[string][]string{ + "id": {"id"}, "contactEmail": {"contactEmail", "email"}, "displayName": {"displayName", "name"}, + }) + if err != nil { + return err + } + complete, next, err := mailPage(data, "mail/list_user_mail_contacts", "", rt.Str("cursor")) + if err != nil { + return err + } + return mailOutputPage(rt, "contacts", contacts, complete, next) }, } -// contactListProject reshapes the raw list_user_mail_contacts response into a -// clean {id, contactEmail, displayName} contact list — output-projection -// clean output projection. Both the list container and per-item field names are probed -// defensively across candidate keys, so an empty/unknown shape yields an empty -// list rather than a crash or fabricated data. -func contactListProject(data map[string]any) []map[string]any { - raw := contactListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := contactListFirst(m, "id", "contactId", "contact_id"); ok { - row["id"] = v - } - if v, ok := contactListFirst(m, "contactEmail", "contact_email", "email"); ok { - row["contactEmail"] = v - } - if v, ok := contactListFirst(m, "displayName", "display_name", "name"); ok { - row["displayName"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// contactListResolveList locates the list payload inside the response, -// tolerating a bare top-level array container or nesting one level deeper. -func contactListResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "contacts"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "contacts", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// contactListFirst returns the first present candidate key's value. -func contactListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // ContactUpdate 更新邮件联系人。 // ContactBatchDelete 批量删除邮件联系人。 // ── auto-reply ───────────────────────────────────────────── @@ -834,6 +566,7 @@ func contactListFirst(m map[string]any, keys ...string) (any, bool) { // RuleDelete 删除个人收信规则。 // RuleAdjust 调整收信规则排序。 func init() { + hardenPublicMailContracts() shortcut.Register( ThreadList, FolderList, diff --git a/internal/shortcut/mail/mail_changed_line_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_changed_line_cross_platform_coverage_test.go new file mode 100644 index 00000000..248ecd6c --- /dev/null +++ b/internal/shortcut/mail/mail_changed_line_cross_platform_coverage_test.go @@ -0,0 +1,422 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "context" + "fmt" + "io" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func runMailDeclarationForCoverage(t *testing.T, declaration shortcut.Shortcut, caller *mailWriteContractCaller, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd.Execute() +} + +func mailRuntimeForCoverage(t *testing.T, declaration shortcut.Shortcut, values map[string]string) *shortcut.RuntimeContext { + t.Helper() + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + return shortcut.RuntimeContextForTest(cmd, declaration) +} + +func TestCrossPlatformCoverageMailReadLeavesExerciseExactIdentityAndMailboxBranches(t *testing.T) { + for _, tc := range []struct { + name string + args []string + responses map[string][]string + errors map[string]error + wantErr bool + wantCalls int + }{ + {name: "message explicit mailbox", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 1}, + {name: "message auto mailbox", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"emailAccounts":[{"email":"mail@example.invalid"}]}`}, "get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 2}, + {name: "message auto nested string mailbox", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"result":{"emailAccounts":["mail@example.invalid"]}}`}, "get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 2}, + {name: "mailbox transport", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1}, + {name: "mailbox missing collection", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true}`}}, wantErr: true, wantCalls: 1}, + {name: "mailbox empty", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"emailAccounts":[]}`}}, wantErr: true, wantCalls: 1}, + {name: "mailbox missing identity", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"emailAccounts":[{"name":"mailbox"}]}`}}, wantErr: true, wantCalls: 1}, + {name: "message transport", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{}, errors: map[string]error{"get_email_by_message_id": fmt.Errorf("message transport")}, wantErr: true, wantCalls: 1}, + {name: "message malformed", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":[]}`}}, wantErr: true, wantCalls: 1}, + {name: "message wrong identity", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"other"}}`}}, wantErr: true, wantCalls: 1}, + {name: "messages ordered", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", "message-1,message-2"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`, `{"success":true,"message":{"id":"message-2"}}`}}, wantCalls: 2}, + {name: "messages auto data mailbox", args: []string{"mail", "+messages", "--ids", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"data":{"emailAccounts":[{"email":"mail@example.invalid"}]}}`}, "get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 2}, + {name: "messages second fails", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", "message-1,message-2"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`, `{"success":true,"message":{"id":"other"}}`}}, wantErr: true, wantCalls: 2}, + {name: "messages mailbox resolution fails", args: []string{"mail", "+messages", "--ids", "message-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1}, + {name: "thread exact", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{"get_thread": {`{"success":true,"conversation":{"id":"thread-1"}}`}}, wantCalls: 1}, + {name: "thread auto result mailbox", args: []string{"mail", "+thread", "--id", "thread-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"result":{"emailAccounts":[{"email":"mail@example.invalid"}]}}`}, "get_thread": {`{"success":true,"conversation":{"id":"thread-1"}}`}}, wantCalls: 2}, + {name: "thread transport", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{}, errors: map[string]error{"get_thread": fmt.Errorf("thread transport")}, wantErr: true, wantCalls: 1}, + {name: "thread mailbox resolution fails", args: []string{"mail", "+thread", "--id", "thread-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1}, + {name: "thread missing result", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{"get_thread": {`{"success":true}`}}, wantErr: true, wantCalls: 1}, + {name: "thread wrong identity", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{"get_thread": {`{"success":true,"conversation":{"id":"other"}}`}}, wantErr: true, wantCalls: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: tc.responses, errors: tc.errors} + if caller.responses == nil { + caller.responses = map[string][]string{} + } + if caller.errors == nil { + caller.errors = map[string]error{} + } + err := runConfirmedMailWriteContract(t, caller, tc.args...) + if tc.wantErr && err == nil { + t.Fatal("invalid read unexpectedly succeeded") + } + if !tc.wantErr && err != nil { + t.Fatalf("valid read failed: %v", err) + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } + + if err := mailValidateMessageIDs(nil); err == nil { + t.Fatal("empty ids unexpectedly valid") + } + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + helpers.InitDepsForTest(t, caller) + rt := mailRuntimeForCoverage(t, Messages, map[string]string{"email": "mail@example.invalid", "ids": " "}) + if err := Messages.Execute(rt); err == nil { + t.Fatal("defensive empty-id guard unexpectedly succeeded") + } + if len(caller.calls) != 0 { + t.Fatalf("defensive empty-id guard made %d calls", len(caller.calls)) + } +} + +func TestCrossPlatformCoverageMailMailboxResolverSupportsReviewedShapesAndFailsClosed(t *testing.T) { + for _, tc := range []struct { + name string + data map[string]any + }{ + { + name: "top string", + data: map[string]any{"success": true, "emailAccounts": []any{" mail@example.invalid "}}, + }, + { + name: "top object", + data: map[string]any{"success": true, "emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}, + }, + { + name: "result string", + data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}}, + }, + { + name: "result object", + data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}}, + }, + { + name: "data string", + data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}}, + }, + { + name: "data object", + data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := mailMailboxAddresses(tc.data) + if err != nil || len(got) != 1 || got[0] != "mail@example.invalid" { + t.Fatalf("addresses=%#v err=%v", got, err) + } + }) + } + + for name, data := range map[string]map[string]any{ + "remote failure": {"success": false}, + "missing collection": {"success": true}, + "malformed collection": {"success": true, "emailAccounts": map[string]any{}}, + "blank string": {"success": true, "emailAccounts": []any{" "}}, + "missing object email": {"success": true, "emailAccounts": []any{map[string]any{"name": "mailbox"}}}, + "wrong object email": {"success": true, "emailAccounts": []any{map[string]any{"email": 7}}}, + "bad later item": {"success": true, "emailAccounts": []any{"mail@example.invalid", 7}}, + "multiple paths": {"success": true, "emailAccounts": []any{"one@example.invalid"}, "result": map[string]any{"emailAccounts": []any{"two@example.invalid"}}}, + } { + t.Run(name, func(t *testing.T) { + if _, err := mailMailboxAddresses(data); err == nil { + t.Fatal("invalid mailbox response unexpectedly succeeded") + } + }) + } +} + +func TestCrossPlatformCoverageMailListLeavesFailAtOwningBoundary(t *testing.T) { + for _, tc := range []struct { + name string + args []string + tool string + response string + remoteErr bool + wantErr bool + }{ + {name: "folder happy", args: []string{"mail", "+folder-list", "--email", "mail@example.invalid", "--folder", "parent-1"}, tool: "list_folders", response: `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`}, + {name: "folder remote", args: []string{"mail", "+folder-list", "--email", "mail@example.invalid"}, tool: "list_folders", remoteErr: true, wantErr: true}, + {name: "folder malformed", args: []string{"mail", "+folder-list", "--email", "mail@example.invalid"}, tool: "list_folders", response: `{"success":true,"folders":{}}`, wantErr: true}, + {name: "tag happy", args: []string{"mail", "+tag-list", "--email", "mail@example.invalid"}, tool: "list_tags", response: `{"success":true,"tags":[{"id":"tag-1"}]}`}, + {name: "tag remote", args: []string{"mail", "+tag-list", "--email", "mail@example.invalid"}, tool: "list_tags", remoteErr: true, wantErr: true}, + {name: "tag malformed", args: []string{"mail", "+tag-list", "--email", "mail@example.invalid"}, tool: "list_tags", response: `{"success":true,"tags":{}}`, wantErr: true}, + {name: "thread malformed rows", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":{},"nextCursor":""}}`, wantErr: true}, + {name: "thread missing page", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":[{"id":"thread-1"}]}}`, wantErr: true}, + {name: "user malformed rows", args: []string{"mail", "+user-search", "--keyword", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":{}}`, wantErr: true}, + {name: "user missing page", args: []string{"mail", "+user-search", "--keyword", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":"user-1"}]}`, wantErr: true}, + {name: "template malformed rows", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_message_templates", response: `{"success":true,"templates":{}}`, wantErr: true}, + {name: "template missing page", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_message_templates", response: `{"success":true,"templates":[{"id":"template-1"}]}`, wantErr: true}, + {name: "contact malformed rows", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":{}}`, wantErr: true}, + {name: "contact missing page", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":[{"id":"contact-1"}]}`, wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{tc.tool: {tc.response}}, errors: map[string]error{}} + if tc.remoteErr { + caller.errors[tc.tool] = fmt.Errorf("remote failure") + } + err := runConfirmedMailWriteContract(t, caller, tc.args...) + if tc.wantErr && err == nil { + t.Fatal("invalid list response unexpectedly succeeded") + } + if !tc.wantErr && err != nil { + t.Fatalf("valid list response failed: %v", err) + } + }) + } +} + +func TestCrossPlatformCoverageMailStrictHelpersCoverMalformedWireAndPagination(t *testing.T) { + assertPanic := func(name string, fn func()) { + t.Helper() + t.Run(name, func(t *testing.T) { + deferred := false + defer func() { + if recover() == nil { + t.Fatal("unsupported declaration did not panic") + } + deferred = true + }() + fn() + _ = deferred + }) + } + assertPanic("sensitive collection", func() { mailCollectionSensitivePaths("unknown") }) + assertPanic("identity collection", func() { mailCollectionIdentitySchema("unknown") }) + + if _, err := mailRequireObject(map[string]any{}, "mail/get", "message"); err == nil { + t.Fatal("object accepted missing success") + } + if object, err := mailRequireObject(map[string]any{"success": true, "message": map[string]any{"id": "message-1"}}, "mail/get", "message"); err != nil || object["id"] != "message-1" { + t.Fatalf("valid object rejected: %#v %v", object, err) + } + if _, _, err := mailProjectValue("mail/users", "email", 7); err == nil { + t.Fatal("numeric email unexpectedly projected") + } + if _, include, err := mailProjectValue("mail/users", "userId", " "); err != nil || include { + t.Fatalf("blank user id include=%v err=%v", include, err) + } + if _, include, err := mailProjectValue("mail/test", "name", nil); err != nil || include { + t.Fatalf("nil projection include=%v err=%v", include, err) + } + if _, include, err := mailProjectValue("mail/test", "name", " "); err != nil || include { + t.Fatalf("blank projection include=%v err=%v", include, err) + } + if value, include, err := mailProjectValue("mail/test", "rank", 7); err != nil || !include || value != 7 { + t.Fatalf("numeric projection value=%v include=%v err=%v", value, include, err) + } + + for name, data := range map[string]map[string]any{ + "missing prefix": {}, + "malformed prefix": {"result": []any{}}, + } { + t.Run(name, func(t *testing.T) { + if _, _, err := mailPage(data, "mail/page", "result", ""); err == nil { + t.Fatal("invalid prefixed page unexpectedly succeeded") + } + }) + } + for name, data := range map[string]map[string]any{ + "missing cursor": {}, + "malformed cursor": {"nextCursor": 7}, + "malformed hasMore": {"nextCursor": "cursor-2", "hasMore": []any{}}, + "conflicting terminal": {"nextCursor": "cursor-2", "hasMore": false}, + } { + t.Run(name, func(t *testing.T) { + if _, _, err := mailPage(data, "mail/page", "", ""); err == nil { + t.Fatal("invalid page unexpectedly succeeded") + } + }) + } + if complete, next, err := mailPage(map[string]any{"nextCursor": "cursor-2"}, "mail/page", "", ""); err != nil || complete || next != "cursor-2" { + t.Fatalf("continuing cursor rejected: %v %q %v", complete, next, err) + } + if _, _, err := mailPage(map[string]any{"nextCursor": "cursor-1"}, "mail/page", "", "cursor-1"); err == nil { + t.Fatal("unstated hasMore repeated cursor unexpectedly succeeded") + } + if value, ok := mailBool("true"); !ok || !value { + t.Fatal("string true not normalized") + } + if _, ok := mailBool(7); ok { + t.Fatal("invalid boolean unexpectedly normalized") + } + if _, ok := mailLookup(map[string]any{"result": "bad"}, "result.items"); ok { + t.Fatal("non-object intermediate lookup unexpectedly succeeded") + } + declaration := UserSearch + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + rt := shortcut.RuntimeContextForTest(cmd, declaration) + if err := mailOutputPage(rt, "users", nil, true, "unexpected-next"); err == nil { + t.Fatal("invalid terminal pagination unexpectedly stored") + } +} + +func TestCrossPlatformCoverageMailWriteVerificationHelpersAndDryRun(t *testing.T) { + for name, message := range map[string]map[string]any{ + "missing addresses": {}, + "wrong address collection": {"toRecipients": "bad"}, + "wrong address item": {"toRecipients": []any{7}}, + "blank address item": {"toRecipients": []any{map[string]any{"email": " "}}}, + } { + t.Run(name, func(t *testing.T) { + if _, err := mailReadAddresses(message, "mail/verify", "toRecipients"); err == nil { + t.Fatal("malformed recipients unexpectedly succeeded") + } + }) + } + addresses, err := mailReadAddresses(map[string]any{"toRecipients": []any{" A@example.invalid ", map[string]any{"address": "b@example.invalid"}}}, "mail/verify", "toRecipients") + if err != nil || len(addresses) != 2 { + t.Fatalf("valid recipients rejected: %v %v", addresses, err) + } + if err := mailVerifyAddresses(map[string]any{"toRecipients": []any{"a@example.invalid"}}, "mail/verify", "toRecipients", []string{"a@example.invalid", "b@example.invalid"}); err == nil { + t.Fatal("recipient length mismatch accepted") + } + if err := mailVerifyAddresses(map[string]any{"toRecipients": []any{"b@example.invalid"}}, "mail/verify", "toRecipients", []string{"a@example.invalid"}); err == nil { + t.Fatal("recipient value mismatch accepted") + } + if err := mailVerifyAddresses(map[string]any{}, "mail/verify", "toRecipients", []string{"a@example.invalid"}); err == nil { + t.Fatal("recipient parser error was not propagated") + } + if _, err := mailDraftMessage(map[string]any{"success": false}, "mail/create"); err == nil { + t.Fatal("failed draft receipt unexpectedly succeeded") + } + for name, message := range map[string]map[string]any{ + "missing sender": {}, + "wrong sender": {"from": []any{}}, + "blank sender": {"from": " "}, + } { + t.Run(name, func(t *testing.T) { + if err := mailVerifySender(message, "mail/verify", "sender@example.invalid"); err == nil { + t.Fatal("malformed sender unexpectedly accepted") + } + }) + } + if err := mailVerifySender(map[string]any{"from": "sender@example.invalid"}, "mail/verify", "SENDER@example.invalid"); err != nil { + t.Fatalf("case-insensitive sender rejected: %v", err) + } + + for _, args := range [][]string{ + {"mail", "+draft-create", "--from", "sender@example.invalid", "--subject", "subject", "--dry-run"}, + {"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--subject", "subject", "--dry-run"}, + {"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body", "--dry-run"}, + {"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject", "--dry-run"}, + } { + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, args...); err != nil { + t.Fatalf("dry-run failed: %v", err) + } + if len(caller.calls) != 0 { + t.Fatalf("dry-run made %d remote calls", len(caller.calls)) + } + } + + for _, declaration := range []shortcut.Shortcut{DraftEdit, TemplateUpdate} { + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + helpers.InitDepsForTest(t, caller) + values := map[string]string{"email": "sender@example.invalid", "id": "template-1"} + if declaration.Command == "+draft-edit" { + values = map[string]string{"from": "sender@example.invalid", "id": "draft-1"} + } + rt := mailRuntimeForCoverage(t, declaration, values) + if err := declaration.Execute(rt); err == nil { + t.Fatal("execute-level no-change guard unexpectedly succeeded") + } + if len(caller.calls) != 0 { + t.Fatalf("no-change guard made %d calls", len(caller.calls)) + } + } + + for _, tc := range []struct { + name string + readback string + }{ + {name: "body mismatch", readback: mailDraftReadback("draft-1", "subject", "other", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")}, + {name: "sender mismatch", readback: mailDraftReadback("draft-1", "subject", "body", "other@example.invalid", "to@example.invalid", "cc@example.invalid")}, + {name: "cc mismatch", readback: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "other@example.invalid")}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{ + "create_draft": {`{"success":true,"result":{"message":{"id":"draft-1"}}}`}, + "get_email_by_message_id": {tc.readback}, + }, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, "mail", "+draft-create", "--from", "sender@example.invalid", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body"); err == nil { + t.Fatal("draft verification mismatch unexpectedly succeeded") + } + }) + } + + caller := &mailWriteContractCaller{responses: map[string][]string{ + "create_user_message_template": {`{"success":true,"id":"template-1"}`}, + "get_user_message_template": {`{"success":"false"}`}, + }, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, "mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"); err == nil { + t.Fatal("failed template readback unexpectedly succeeded") + } + + for _, tc := range []struct { + declaration shortcut.Shortcut + values map[string]string + }{ + {declaration: Message, values: map[string]string{"id": " "}}, + {declaration: FolderList, values: map[string]string{"email": " "}}, + } { + rt := mailRuntimeForCoverage(t, tc.declaration, tc.values) + if err := tc.declaration.Validate(rt); err == nil { + t.Fatal("direct required-text validator accepted whitespace") + } + } + rt := mailRuntimeForCoverage(t, UserSearch, map[string]string{}) + if err := UserSearch.Validate(rt); err == nil { + t.Fatal("direct user-search validator accepted missing identities") + } +} + +func TestCrossPlatformCoverageMailTemplateReadbackRejectsBusinessFailure(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{ + "get_user_message_template": {`{"id":"template-1"}`}, + }, errors: map[string]error{}} + helpers.InitDepsForTest(t, caller) + rt := mailRuntimeForCoverage(t, TemplateCreate, map[string]string{}) + if _, err := mailReadTemplate(rt, "mail@example.invalid", "template-1"); err == nil { + t.Fatal("failed template detail response unexpectedly succeeded") + } + if len(caller.calls) != 1 { + t.Fatalf("calls=%d want=1", len(caller.calls)) + } +} diff --git a/internal/shortcut/mail/mail_confirmation_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_confirmation_cross_platform_coverage_test.go new file mode 100644 index 00000000..99b7ea65 --- /dev/null +++ b/internal/shortcut/mail/mail_confirmation_cross_platform_coverage_test.go @@ -0,0 +1,62 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "context" + "io" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type mailConfirmationCaller struct{ calls int } + +func (caller *mailConfirmationCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) { + caller.calls++ + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true}`}}}, nil +} + +func (*mailConfirmationCaller) Format() string { return "json" } +func (*mailConfirmationCaller) DryRun() bool { return false } +func (*mailConfirmationCaller) Fields() string { return "" } +func (*mailConfirmationCaller) JQ() string { return "" } + +func runUnconfirmedMailWrite(t *testing.T, caller *mailConfirmationCaller, args ...string) error { + t.Helper() + helpers.InitDeps(caller) + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.AddCommand(shortcut.Commands()...) + root.SetArgs(args) + return root.Execute() +} + +func TestCrossPlatformCoverageMailConfirmationPrecedesRemoteCall(t *testing.T) { + cases := [][]string{ + {"mail", "+draft-create", "--from", "sender@example.invalid", "--subject", "fixture"}, + {"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-id", "--subject", "fixture"}, + {"mail", "+template-create", "--email", "sender@example.invalid", "--name", "fixture", "--subject", "fixture", "--body", "fixture"}, + {"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-id", "--subject", "fixture"}, + } + for _, args := range cases { + caller := &mailConfirmationCaller{} + if err := runUnconfirmedMailWrite(t, caller, args...); err == nil { + t.Fatalf("unconfirmed %v unexpectedly succeeded", args) + } + if caller.calls != 0 { + t.Fatalf("unconfirmed %v reached MCP %d times", args, caller.calls) + } + } +} diff --git a/internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..4134fc99 --- /dev/null +++ b/internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go @@ -0,0 +1,310 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageMailContractsAreUnifiedAndTyped(t *testing.T) { + declarations := []*struct { + rollout output.RolloutState + result bool + }{ + {FolderList.OutputRollout, FolderList.Contract.Result != nil}, + {UserSearch.OutputRollout, UserSearch.Contract.Result != nil}, + {Message.OutputRollout, Message.Contract.Result != nil}, + {Messages.OutputRollout, Messages.Contract.Result != nil}, + {Thread.OutputRollout, Thread.Contract.Result != nil}, + } + for index, declaration := range declarations { + if declaration.rollout != output.RolloutUnifiedActive || !declaration.result { + t.Fatalf("mail declaration %d is not unified with Result", index) + } + } +} + +func TestCrossPlatformCoverageMailUnavailableWritesMatchRuntimeBoundary(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&DraftCreate, &DraftEdit, &TemplateCreate, &TemplateUpdate} { + if declaration.OutputRollout != output.RolloutLegacyOnly { + t.Errorf("%s rollout=%q, want legacy_only", declaration.Command, declaration.OutputRollout) + } + if declaration.Contract.Result != nil || declaration.Contract.Pagination != nil { + t.Errorf("%s unavailable contract still publishes result/pagination", declaration.Command) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "unavailable" || declaration.Contract.Interface.Reason == "" { + t.Errorf("%s missing precise unavailable interface", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageMailCompatibilityReadsMatchRuntimeBoundary(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&ThreadList, &TagList, &TemplateList, &ContactList} { + if declaration.OutputRollout != output.RolloutLegacyOnly { + t.Errorf("%s rollout=%q, want legacy_only", declaration.Command, declaration.OutputRollout) + } + if declaration.Contract.Result != nil || declaration.Contract.Pagination != nil { + t.Errorf("%s unavailable contract still publishes result/pagination", declaration.Command) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != contract.InterfaceAvailable || declaration.Contract.Interface.Reason != mailCompatibilityInterfaceReason { + t.Errorf("%s missing Schema-compatible non-public interface", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageMailMutationConstraintsAreDeclared(t *testing.T) { + want := map[string]bool{"to": false, "cc": false, "subject": false, "body": false} + for _, constraint := range DraftEdit.Constraints { + if constraint.Kind != shortcut.ConstraintAtLeastOne { + continue + } + for _, flag := range constraint.Flags { + if _, ok := want[flag]; ok { + want[flag] = true + } + } + } + for flag, found := range want { + if !found { + t.Fatalf("draft edit missing at-least-one declaration for %s", flag) + } + } + if len(Messages.Constraints) != 1 || Messages.Constraints[0].Kind != shortcut.ConstraintCustom || Messages.Validate == nil { + t.Fatal("messages must declare and execute the 1-100 nonempty ID contract") + } + userSearchNonempty := false + for _, constraint := range UserSearch.Constraints { + if constraint.Kind == shortcut.ConstraintCustom && len(constraint.Flags) == 2 && constraint.Flags[0] == "keyword" && constraint.Flags[1] == "employee-no" && constraint.Description != "" { + userSearchNonempty = true + } + } + if !userSearchNonempty { + t.Fatal("user search must declare its explicit nonempty string contract") + } +} + +func TestCrossPlatformCoverageMailPaginationDeclarationsMatchLeaves(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&UserSearch} { + if declaration.Contract.Pagination == nil || declaration.Contract.Pagination.CursorParameter != "cursor" { + t.Fatalf("%s missing cursor pagination declaration", declaration.Command) + } + } + for _, declaration := range []*shortcut.Shortcut{&ThreadList, &FolderList, &TagList, &TemplateList, &ContactList, &Messages} { + if declaration.Contract.Pagination != nil { + t.Fatalf("%s must not declare pagination", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageMailUnifiedPaginationLivesOnlyInMeta(t *testing.T) { + for _, tc := range []struct { + name string + complete bool + next string + exhausted bool + }{ + {name: "continuing", complete: false, next: "cursor-2", exhausted: false}, + {name: "terminal", complete: true, next: "", exhausted: true}, + } { + t.Run(tc.name, func(t *testing.T) { + declaration := UserSearch + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + rt := shortcut.RuntimeContextForTest(cmd, declaration) + if err := mailOutputPage(rt, "users", []map[string]any{{"email": "fixture@example.invalid"}}, tc.complete, tc.next); err != nil { + t.Fatalf("store pagination: %v", err) + } + if code, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted || code != 0 { + t.Fatalf("emit code=%d emitted=%v err=%v", code, emitted, err) + } + var envelope struct { + Data map[string]any `json:"data"` + Meta struct { + Pagination *struct { + EndpointExhausted bool `json:"endpoint_exhausted"` + NextToken string `json:"next_token"` + } `json:"pagination"` + } `json:"meta"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode output: %v", err) + } + for _, field := range []string{"complete", "hasMore", "nextCursor"} { + if _, exists := envelope.Data[field]; exists { + t.Fatalf("pagination field %s leaked into data", field) + } + } + if envelope.Meta.Pagination == nil || envelope.Meta.Pagination.EndpointExhausted != tc.exhausted || envelope.Meta.Pagination.NextToken != tc.next { + t.Fatalf("pagination=%+v", envelope.Meta.Pagination) + } + }) + } +} + +func TestCrossPlatformCoverageMailResultSchemasRequireStableIdentity(t *testing.T) { + collections := []struct { + declaration *shortcut.Shortcut + collection string + identity string + }{ + {&FolderList, "folders", "id"}, + {&UserSearch, "users", "email"}, + {&Messages, "messages", "id"}, + } + for _, tc := range collections { + if len(tc.declaration.Contract.Result.SensitivePaths) == 0 { + t.Fatalf("%s missing sensitive_paths", tc.declaration.Command) + } + var schema map[string]any + if err := json.Unmarshal(tc.declaration.Contract.Result.DataSchema, &schema); err != nil { + t.Fatalf("%s schema: %v", tc.declaration.Command, err) + } + properties := schema["properties"].(map[string]any) + count := properties["count"].(map[string]any) + if count["minimum"] != float64(0) { + t.Fatalf("%s count minimum=%v, want 0", tc.declaration.Command, count["minimum"]) + } + items := properties[tc.collection].(map[string]any)["items"].(map[string]any) + identity, ok := items["properties"].(map[string]any)[tc.identity].(map[string]any) + if !ok || identity["description"] == "" { + t.Fatalf("%s missing identity %s", tc.declaration.Command, tc.identity) + } + } + for _, declaration := range []*shortcut.Shortcut{&Message, &Thread} { + if len(declaration.Contract.Result.SensitivePaths) == 0 { + t.Fatalf("%s missing sensitive_paths", declaration.Command) + } + var schema map[string]any + if err := json.Unmarshal(declaration.Contract.Result.DataSchema, &schema); err != nil { + t.Fatalf("%s schema: %v", declaration.Command, err) + } + value := schema["properties"].(map[string]any)["value"].(map[string]any) + required := value["required"].([]any) + if len(required) != 1 || required[0] != "id" { + t.Fatalf("%s value does not require id", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageMailWritesRequireConfirmation(t *testing.T) { + for _, declaration := range []struct { + name string + confirmation string + }{ + {DraftCreate.Command, DraftCreate.Safety.Confirmation}, + {DraftEdit.Command, DraftEdit.Safety.Confirmation}, + {TemplateCreate.Command, TemplateCreate.Safety.Confirmation}, + {TemplateUpdate.Command, TemplateUpdate.Safety.Confirmation}, + } { + if declaration.confirmation != "user_required" { + t.Errorf("%s confirmation=%q, want user_required", declaration.name, declaration.confirmation) + } + } +} + +func TestCrossPlatformCoverageMailStrictResponseMatrix(t *testing.T) { + validEmpty := map[string]any{"success": "true", "folders": []any{}} + items, err := mailRequireCollection(validEmpty, "mail/list_folders", "folders") + if err != nil || len(items) != 0 { + t.Fatalf("explicit empty collection must succeed: items=%v err=%v", items, err) + } + for name, fixture := range map[string]map[string]any{ + "missing success": {"folders": []any{}}, + "missing collection": {"success": "true"}, + "wrong collection": {"success": true, "folders": map[string]any{}}, + "bad item": {"success": "true", "folders": []any{"bad"}}, + } { + if _, err := mailRequireCollection(fixture, "mail/list_folders", "folders"); err == nil { + t.Fatalf("%s must fail closed", name) + } + } + if err := mailRequireSuccess(map[string]any{"success": "false"}, "mail/test"); err == nil { + t.Fatal("string false must not be success") + } + if err := mailRequireSuccess(map[string]any{"success": true}, "mail/test"); err != nil { + t.Fatalf("boolean true must be accepted: %v", err) + } +} + +func TestCrossPlatformCoverageMailPaginationAndIdentity(t *testing.T) { + complete, next, err := mailPage(map[string]any{"nextCursor": "$"}, "mail/search", "", "") + if err != nil || !complete || next != "" { + t.Fatalf("terminal dollar cursor mismatch: complete=%v next=%q err=%v", complete, next, err) + } + complete, next, err = mailPage(map[string]any{"hasMore": "false", "nextCursor": ""}, "mail/list", "", "") + if err != nil || !complete || next != "" { + t.Fatalf("string false pagination mismatch: complete=%v next=%q err=%v", complete, next, err) + } + if _, _, err := mailPage(map[string]any{"hasMore": true, "nextCursor": ""}, "mail/list", "", ""); err == nil { + t.Fatal("hasMore without a progressing cursor must fail") + } + if _, _, err := mailPage(map[string]any{"hasMore": true, "nextCursor": "cursor-1"}, "mail/list", "", "cursor-1"); err == nil { + t.Fatal("repeated cursor must fail closed") + } + if err := mailRequireIdentity(map[string]any{"id": "actual"}, "mail/get", "expected", "id"); err == nil { + t.Fatal("identity mismatch must fail") + } +} + +func TestCrossPlatformCoverageMailUserProjectionOmitsEmptyOptionalIdentity(t *testing.T) { + for _, tc := range []struct { + name string + user map[string]any + wantKey string + absentKey string + }{ + {name: "user id only", user: map[string]any{"id": "user-1", "email": ""}, wantKey: "userId", absentKey: "email"}, + {name: "email only", user: map[string]any{"id": "", "email": "user@example.invalid"}, wantKey: "email", absentKey: "userId"}, + {name: "numeric user id only", user: map[string]any{"id": float64(7), "email": " "}, wantKey: "userId", absentKey: "email"}, + } { + t.Run(tc.name, func(t *testing.T) { + rows, err := mailProjectCollection( + map[string]any{"success": true, "users": []any{tc.user}}, + "mail/search_mail_users", "users", []string{"id", "email"}, + map[string][]string{"userId": {"id"}, "email": {"email"}}, + ) + if err != nil || len(rows) != 1 { + t.Fatalf("rows=%v err=%v", rows, err) + } + if _, ok := rows[0][tc.wantKey]; !ok { + t.Fatalf("projection missing %s: %#v", tc.wantKey, rows[0]) + } + if _, ok := rows[0][tc.absentKey]; ok { + t.Fatalf("projection retained empty %s: %#v", tc.absentKey, rows[0]) + } + }) + } + if _, err := mailProjectCollection( + map[string]any{"success": true, "users": []any{map[string]any{"id": "", "email": ""}}}, + "mail/search_mail_users", "users", []string{"id", "email"}, + map[string][]string{"userId": {"id"}, "email": {"email"}}, + ); err == nil { + t.Fatal("user without a stable identity unexpectedly projected") + } + for name, user := range map[string]map[string]any{ + "malformed email with valid id": {"id": "user-1", "email": 7}, + "malformed id with valid email": {"id": map[string]any{"value": "user-1"}, "email": "user@example.invalid"}, + } { + t.Run(name, func(t *testing.T) { + if _, err := mailProjectCollection( + map[string]any{"success": true, "users": []any{user}}, + "mail/search_mail_users", "users", []string{"id", "email"}, + map[string][]string{"userId": {"id"}, "email": {"email"}}, + ); err == nil { + t.Fatal("malformed optional identity unexpectedly passed projection") + } + }) + } +} diff --git a/internal/shortcut/mail/mail_write_contract_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_write_contract_cross_platform_coverage_test.go new file mode 100644 index 00000000..722ee756 --- /dev/null +++ b/internal/shortcut/mail/mail_write_contract_cross_platform_coverage_test.go @@ -0,0 +1,504 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "context" + "fmt" + "io" + "maps" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type mailWriteCall struct { + tool string + args map[string]any +} + +type mailWriteContractCaller struct { + responses map[string][]string + errors map[string]error + calls []mailWriteCall +} + +func (caller *mailWriteContractCaller) CallTool(_ context.Context, _ string, tool string, args map[string]any) (*edition.ToolResult, error) { + caller.calls = append(caller.calls, mailWriteCall{tool: tool, args: maps.Clone(args)}) + if err := caller.errors[tool]; err != nil { + return nil, err + } + queue := caller.responses[tool] + if len(queue) == 0 { + return nil, fmt.Errorf("unexpected Mail tool call: %s", tool) + } + text := queue[0] + caller.responses[tool] = queue[1:] + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} + +func (*mailWriteContractCaller) Format() string { return "json" } +func (*mailWriteContractCaller) DryRun() bool { return false } +func (*mailWriteContractCaller) Fields() string { return "" } +func (*mailWriteContractCaller) JQ() string { return "" } + +func runConfirmedMailWriteContract(t *testing.T, caller *mailWriteContractCaller, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.AddCommand(shortcut.Commands()...) + root.SetArgs(append(args, "--yes", "--format", "json")) + return root.Execute() +} + +func mailDraftReadback(id, subject, body, sender, to, cc string) string { + return fmt.Sprintf(`{"success":true,"message":{"id":%q,"subject":%q,"markdownBody":%q,"from":{"email":%q},"toRecipients":[{"email":%q}],"ccRecipients":[{"email":%q}]}}`, id, subject, body, sender, to, cc) +} + +func TestCrossPlatformCoverageMailWriteHappyPathsUseReceiptThenExactReadback(t *testing.T) { + tests := []struct { + name string + args []string + responses map[string][]string + wantTools []string + }{ + { + name: "draft create", + args: []string{"mail", "+draft-create", "--from", "sender@example.invalid", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body"}, + responses: map[string][]string{ + "create_draft": {`{"success":true,"result":{"message":{"id":"draft-1"}}}`}, + "get_email_by_message_id": {mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")}, + }, + wantTools: []string{"create_draft", "get_email_by_message_id"}, + }, + { + name: "draft edit", + args: []string{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body"}, + responses: map[string][]string{ + "update_draft": {`{"success":true,"result":{"message":{"id":"draft-1"}}}`}, + "get_email_by_message_id": {mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")}, + }, + wantTools: []string{"update_draft", "get_email_by_message_id"}, + }, + { + name: "template create core fields", + args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, + responses: map[string][]string{ + "create_user_message_template": {`{"success":true,"id":"template-1"}`}, + "get_user_message_template": {`{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}`}, + }, + wantTools: []string{"create_user_message_template", "get_user_message_template"}, + }, + { + name: "template update success plus requested id readback", + args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--name", "name", "--subject", "subject", "--body", "body"}, + responses: map[string][]string{ + "update_user_message_template": {`{"success":true}`}, + "get_user_message_template": {`{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}`}, + }, + wantTools: []string{"update_user_message_template", "get_user_message_template"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: tc.responses, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, tc.args...); err != nil { + t.Fatalf("write execution failed: %v", err) + } + if len(caller.calls) != len(tc.wantTools) { + t.Fatalf("calls=%d want=%d", len(caller.calls), len(tc.wantTools)) + } + for index, tool := range tc.wantTools { + if caller.calls[index].tool != tool { + t.Fatalf("call[%d]=%s want=%s", index, caller.calls[index].tool, tool) + } + } + }) + } +} + +func TestCrossPlatformCoverageMailDraftWritesFailClosedOnReceiptAndReadback(t *testing.T) { + tests := []struct { + name string + writeBody string + readBody string + writeErr error + wantCalls int + }{ + {name: "empty write body", writeBody: "", wantCalls: 1}, + {name: "missing receipt", writeBody: `{"success":true}`, wantCalls: 1}, + {name: "malformed receipt", writeBody: `{"success":true,"result":{"message":"bad"}}`, wantCalls: 1}, + {name: "missing receipt id", writeBody: `{"success":true,"result":{"message":{"subject":"x"}}}`, wantCalls: 1}, + {name: "missing readback object", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: `{"success":true}`, wantCalls: 2}, + {name: "malformed readback object", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: `{"success":true,"message":"bad"}`, wantCalls: 2}, + {name: "wrong readback id", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: mailDraftReadback("other", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "request field mismatch", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: mailDraftReadback("draft-1", "other", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "recipient mismatch", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "other@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "write failure", writeErr: fmt.Errorf("write failed"), wantCalls: 1}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{ + responses: map[string][]string{"create_draft": {tc.writeBody}, "get_email_by_message_id": {tc.readBody}}, + errors: map[string]error{"create_draft": tc.writeErr}, + } + err := runConfirmedMailWriteContract(t, caller, "mail", "+draft-create", "--from", "sender@example.invalid", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body") + if err == nil { + t.Fatal("invalid receipt/readback unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} + +func TestCrossPlatformCoverageMailEditAndTemplateNegativeCallHistory(t *testing.T) { + tests := []struct { + name string + args []string + responses map[string][]string + wantCalls int + }{ + {name: "draft edit no change", args: []string{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1"}, responses: map[string][]string{}, wantCalls: 0}, + {name: "draft edit wrong receipt id", args: []string{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--subject", "subject"}, responses: map[string][]string{"update_draft": {`{"success":true,"result":{"message":{"id":"other"}}}`}}, wantCalls: 1}, + {name: "template update no change", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1"}, responses: map[string][]string{}, wantCalls: 0}, + {name: "template update missing success", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject"}, responses: map[string][]string{"update_user_message_template": {`{}`}}, wantCalls: 1}, + {name: "template update wrong readback id", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject"}, responses: map[string][]string{"update_user_message_template": {`{"success":true}`}, "get_user_message_template": {`{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`}}, wantCalls: 2}, + {name: "template update field mismatch", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject"}, responses: map[string][]string{"update_user_message_template": {`{"success":true}`}, "get_user_message_template": {`{"success":true,"id":"template-1","name":"name","message":{"subject":"other","markdownBody":"body"}}`}}, wantCalls: 2}, + {name: "template create missing id", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, responses: map[string][]string{"create_user_message_template": {`{"success":true}`}}, wantCalls: 1}, + {name: "template create wrong readback id", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, responses: map[string][]string{"create_user_message_template": {`{"success":true,"id":"template-1"}`}, "get_user_message_template": {`{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`}}, wantCalls: 2}, + {name: "template create unverifiable from", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--from", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, responses: map[string][]string{}, wantCalls: 0}, + {name: "template create unverifiable draft", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body", "--draft"}, responses: map[string][]string{}, wantCalls: 0}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: tc.responses, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, tc.args...); err == nil { + t.Fatal("invalid write unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} + +func TestCrossPlatformCoverageMailCursorPassthroughAndStall(t *testing.T) { + tests := []struct { + name string + args []string + tool string + response string + }{ + {name: "thread list", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":[{"id":"thread-1"}],"hasMore":true,"nextCursor":"cursor-2"}}`}, + {name: "user search", args: []string{"mail", "+user-search", "--keyword", "fixture", "--limit", "20", "--cursor", "cursor-1"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "template list", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_user_message_templates", response: `{"success":true,"templates":[{"id":"template-1"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "contact list", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":[{"id":"contact-1"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{tc.tool: {tc.response}}, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, tc.args...); err != nil { + t.Fatalf("execution failed: %v", err) + } + if len(caller.calls) != 1 || caller.calls[0].args["cursor"] != "cursor-1" { + t.Fatalf("cursor was not passed through: %#v", caller.calls) + } + }) + } + caller := &mailWriteContractCaller{ + responses: map[string][]string{"search_mail_users": {`{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-1"}`}}, + errors: map[string]error{}, + } + if err := runConfirmedMailWriteContract(t, caller, "mail", "+user-search", "--keyword", "fixture", "--cursor", "cursor-1"); err == nil { + t.Fatal("repeated cursor unexpectedly succeeded") + } +} + +func TestCrossPlatformCoverageMailValidationRejectsBeforeRemoteCall(t *testing.T) { + tooManyIDs := make([]string, 101) + for index := range tooManyIDs { + tooManyIDs[index] = fmt.Sprintf("message-%d", index) + } + for _, tc := range []struct { + name string + args []string + }{ + {name: "thread zero limit", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "0"}}, + {name: "thread high limit", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "101"}}, + {name: "thread bad start", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--start", "bad"}}, + {name: "thread non utc", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--start", "2026-08-18T10:00:00+08:00"}}, + {name: "thread reversed", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--start", "2026-08-18T10:00:00Z", "--end", "2026-08-18T09:00:00Z"}}, + {name: "user zero limit", args: []string{"mail", "+user-search", "--keyword", "fixture", "--limit", "0"}}, + {name: "template nonnumeric limit", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "bad"}}, + {name: "contact high limit", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "101"}}, + {name: "messages empty item", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", "message-1", "--ids", " "}}, + {name: "messages too many", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", strings.Join(tooManyIDs, ",")}}, + {name: "user empty keyword", args: []string{"mail", "+user-search", "--keyword", " "}}, + {name: "user empty employee number", args: []string{"mail", "+user-search", "--keyword", "fixture", "--employee-no", " "}}, + {name: "message empty id", args: []string{"mail", "+message", "--id", " "}}, + {name: "thread empty id", args: []string{"mail", "+thread", "--id", " "}}, + {name: "folder empty email", args: []string{"mail", "+folder-list", "--email", " "}}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, tc.args...); err == nil { + t.Fatal("invalid input unexpectedly succeeded") + } + if len(caller.calls) != 0 { + t.Fatalf("invalid input reached remote calls: %d", len(caller.calls)) + } + }) + } +} + +func TestCrossPlatformCoverageMailLegacyStringLimitsStayCompatibleAndStrict(t *testing.T) { + if err := mailValidatePageSize(mailRuntimeForCoverage(t, ThreadList, nil), "limit", false); err != nil { + t.Fatalf("omitted optional integer limit should remain valid: %v", err) + } + + tests := []struct { + name string + declaration *shortcut.Shortcut + args []string + tool string + response string + wantSize string + wantPresent bool + }{ + {name: "user limit", declaration: &UserSearch, args: []string{"mail", "+user-search", "--keyword", "fixture", "--limit", "1"}, tool: "search_mail_users", response: `{"success":true,"users":[],"hasMore":false,"nextCursor":""}`, wantSize: "1", wantPresent: true}, + {name: "user omitted limit", declaration: &UserSearch, args: []string{"mail", "+user-search", "--keyword", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":[],"hasMore":false,"nextCursor":""}`}, + {name: "template limit", declaration: &TemplateList, args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "100"}, tool: "list_user_message_templates", response: `{"success":true,"templates":[],"hasMore":false,"nextCursor":""}`, wantSize: "100", wantPresent: true}, + {name: "contact limit", declaration: &ContactList, args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":[],"hasMore":false,"nextCursor":""}`, wantSize: "20", wantPresent: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + found := false + for _, flag := range tc.declaration.Flags { + if flag.Name != "limit" { + continue + } + found = true + if flag.Type != shortcut.FlagString || flag.Default != "" { + t.Fatalf("declaration --limit type/default = %q/%q, want string/empty", flag.Type, flag.Default) + } + } + if !found { + t.Fatal("declaration missing --limit") + } + cmd := corecmd.New(shortcut.FromShortcut(*tc.declaration)) + if flag := cmd.Flags().Lookup("limit"); flag == nil || flag.Value.Type() != "string" || flag.DefValue != "" { + t.Fatalf("Cobra --limit = %#v, want historical string with empty default", flag) + } + + caller := &mailWriteContractCaller{responses: map[string][]string{tc.tool: {tc.response}}, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, tc.args...); err != nil { + t.Fatalf("compatible execution failed: %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls=%d, want 1", len(caller.calls)) + } + gotSize, present := caller.calls[0].args["size"] + if present != tc.wantPresent || (present && gotSize != tc.wantSize) { + t.Fatalf("size=%#v present=%v, want %q present=%v", gotSize, present, tc.wantSize, tc.wantPresent) + } + }) + } + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + values map[string]string + }{ + {name: "user execute guard", declaration: UserSearch, values: map[string]string{"keyword": "fixture", "limit": "bad"}}, + {name: "template execute guard", declaration: TemplateList, values: map[string]string{"email": "mail@example.invalid", "limit": "bad"}}, + {name: "contact execute guard", declaration: ContactList, values: map[string]string{"email": "mail@example.invalid", "limit": "bad"}}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + helpers.InitDepsForTest(t, caller) + rt := mailRuntimeForCoverage(t, tc.declaration, tc.values) + if err := tc.declaration.Execute(rt); err == nil { + t.Fatal("direct Execute accepted malformed string limit") + } + if len(caller.calls) != 0 { + t.Fatalf("direct Execute reached %d remote calls", len(caller.calls)) + } + }) + } +} + +func TestCrossPlatformCoverageMailLegacyStringLimitsRejectEveryInvalidClassWithZeroCalls(t *testing.T) { + for _, leaf := range []struct { + name string + args []string + }{ + {name: "user", args: []string{"mail", "+user-search", "--keyword", "fixture"}}, + {name: "template", args: []string{"mail", "+template-list", "--email", "mail@example.invalid"}}, + {name: "contact", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid"}}, + } { + for _, invalid := range []struct { + name string + value string + }{ + {name: "blank", value: " "}, + {name: "nonnumeric", value: "bad"}, + {name: "zero", value: "0"}, + {name: "negative", value: "-1"}, + {name: "too large", value: "101"}, + } { + t.Run(leaf.name+"/"+invalid.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + args := append(append([]string{}, leaf.args...), "--limit", invalid.value) + if err := runConfirmedMailWriteContract(t, caller, args...); err == nil { + t.Fatal("invalid legacy string limit unexpectedly succeeded") + } + if len(caller.calls) != 0 { + t.Fatalf("invalid legacy string limit reached %d remote calls", len(caller.calls)) + } + }) + } + } +} + +func TestCrossPlatformCoverageMailDraftEditOwnNegativeBranches(t *testing.T) { + goodReceipt := `{"success":true,"result":{"message":{"id":"draft-1"}}}` + goodReadback := mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid") + for _, tc := range []struct { + name string + writeBody string + readBody string + errors map[string]error + wantCalls int + }{ + {name: "receipt remote failure", writeBody: `{"success":false}`, wantCalls: 1}, + {name: "receipt missing", writeBody: `{"success":true}`, wantCalls: 1}, + {name: "receipt malformed", writeBody: `{"success":true,"result":{"message":[]}}`, wantCalls: 1}, + {name: "receipt wrong id", writeBody: `{"success":true,"result":{"message":{"id":"other"}}}`, wantCalls: 1}, + {name: "readback transport", writeBody: goodReceipt, errors: map[string]error{"get_email_by_message_id": fmt.Errorf("read failed")}, wantCalls: 2}, + {name: "readback missing", writeBody: goodReceipt, readBody: `{"success":true}`, wantCalls: 2}, + {name: "readback malformed", writeBody: goodReceipt, readBody: `{"success":true,"message":[]}`, wantCalls: 2}, + {name: "readback wrong id", writeBody: goodReceipt, readBody: mailDraftReadback("other", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "subject mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "other", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "body mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "other", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "from mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "body", "other@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "to mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "other@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "cc mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "other@example.invalid"), wantCalls: 2}, + {name: "happy control", writeBody: goodReceipt, readBody: goodReadback, wantCalls: 2}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{ + responses: map[string][]string{"update_draft": {tc.writeBody}, "get_email_by_message_id": {tc.readBody}}, + errors: tc.errors, + } + if caller.errors == nil { + caller.errors = map[string]error{} + } + err := runConfirmedMailWriteContract(t, caller, "mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body") + if tc.name == "happy control" { + if err != nil { + t.Fatalf("happy control: %v", err) + } + } else if err == nil { + t.Fatal("negative branch unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} + +func TestCrossPlatformCoverageMailTemplateCreateOwnNegativeBranches(t *testing.T) { + goodReadback := `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}` + for _, tc := range []struct { + name string + createBody string + readBody string + errors map[string]error + wantCalls int + }{ + {name: "missing success", createBody: `{"id":"template-1"}`, wantCalls: 1}, + {name: "remote failure", createBody: `{"success":false,"id":"template-1"}`, wantCalls: 1}, + {name: "missing id", createBody: `{"success":true}`, wantCalls: 1}, + {name: "readback transport", createBody: `{"success":true,"id":"template-1"}`, errors: map[string]error{"get_user_message_template": fmt.Errorf("read failed")}, wantCalls: 2}, + {name: "readback missing shape", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true}`, wantCalls: 2}, + {name: "readback wrong id", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2}, + {name: "name mismatch", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"template-1","name":"other","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2}, + {name: "subject mismatch", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"other","markdownBody":"body"}}`, wantCalls: 2}, + {name: "body mismatch", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"other"}}`, wantCalls: 2}, + {name: "happy control", createBody: `{"success":true,"id":"template-1"}`, readBody: goodReadback, wantCalls: 2}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{"create_user_message_template": {tc.createBody}, "get_user_message_template": {tc.readBody}}, errors: tc.errors} + if caller.errors == nil { + caller.errors = map[string]error{} + } + err := runConfirmedMailWriteContract(t, caller, "mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body") + if tc.name == "happy control" { + if err != nil { + t.Fatal(err) + } + } else if err == nil { + t.Fatal("negative branch unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} + +func TestCrossPlatformCoverageMailTemplateUpdateOwnNegativeBranches(t *testing.T) { + goodReadback := `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}` + for _, tc := range []struct { + name string + updateBody string + readBody string + errors map[string]error + wantCalls int + }{ + {name: "missing success", updateBody: `{}`, wantCalls: 1}, + {name: "remote failure", updateBody: `{"success":false}`, wantCalls: 1}, + {name: "readback transport", updateBody: `{"success":true}`, errors: map[string]error{"get_user_message_template": fmt.Errorf("read failed")}, wantCalls: 2}, + {name: "readback missing shape", updateBody: `{"success":true}`, readBody: `{"success":true}`, wantCalls: 2}, + {name: "readback wrong id", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2}, + {name: "name mismatch", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"template-1","name":"other","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2}, + {name: "subject mismatch", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"other","markdownBody":"body"}}`, wantCalls: 2}, + {name: "body mismatch", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"other"}}`, wantCalls: 2}, + {name: "happy control", updateBody: `{"success":true}`, readBody: goodReadback, wantCalls: 2}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{"update_user_message_template": {tc.updateBody}, "get_user_message_template": {tc.readBody}}, errors: tc.errors} + if caller.errors == nil { + caller.errors = map[string]error{} + } + err := runConfirmedMailWriteContract(t, caller, "mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--name", "name", "--subject", "subject", "--body", "body") + if tc.name == "happy control" { + if err != nil { + t.Fatal(err) + } + } else if err == nil { + t.Fatal("negative branch unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} diff --git a/internal/shortcut/mail/reads.go b/internal/shortcut/mail/reads.go new file mode 100644 index 00000000..21feb47c --- /dev/null +++ b/internal/shortcut/mail/reads.go @@ -0,0 +1,222 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "fmt" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func mailResolveMailbox(rt *shortcut.RuntimeContext) (string, error) { + if email := strings.TrimSpace(rt.Str("email")); email != "" { + return email, nil + } + data, err := rt.CallMCPData("mail", "list_user_mailboxes", nil) + if err != nil { + return "", err + } + mailboxes, err := mailMailboxAddresses(data) + if err != nil { + return "", err + } + if len(mailboxes) == 0 { + return "", apperrors.NewValidation("当前身份没有可用邮箱,请用 --email 指定") + } + return mailboxes[0], nil +} + +// mailMailboxAddresses accepts the reviewed mailbox collection paths and item +// shapes used by the Mail gateway. The complete selected array is validated +// before the first address is returned so a malformed later row cannot be +// hidden behind an otherwise usable first mailbox. +func mailMailboxAddresses(data map[string]any) ([]string, error) { + const operation = "mail/list_user_mailboxes" + if err := mailRequireSuccess(data, operation); err != nil { + return nil, err + } + + paths := []string{"emailAccounts", "result.emailAccounts", "data.emailAccounts"} + var value any + selectedPath := "" + for _, path := range paths { + candidate, present := mailLookup(data, path) + if !present { + continue + } + if selectedPath != "" { + return nil, mailResponseError(operation, "conflicting_collection", fmt.Sprintf("响应同时包含 %s 与 %s,无法选择唯一邮箱集合", selectedPath, path)) + } + value = candidate + selectedPath = path + } + if selectedPath == "" { + return nil, mailResponseError(operation, "missing_collection", "成功响应缺少 emailAccounts 数组;不能把未知响应结构当作空结果") + } + + raw, ok := value.([]any) + if !ok { + return nil, mailResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", selectedPath, value)) + } + addresses := make([]string, 0, len(raw)) + for index, item := range raw { + var address string + switch typed := item.(type) { + case string: + address = strings.TrimSpace(typed) + case map[string]any: + rawEmail, present := typed["email"] + if present && rawEmail != nil { + var valid bool + address, valid = rawEmail.(string) + if !valid { + return nil, mailResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d].email 必须是字符串", selectedPath, index)) + } + address = strings.TrimSpace(address) + } + default: + return nil, mailResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 必须是邮箱字符串或含 email 的对象", selectedPath, index)) + } + if address == "" { + return nil, mailResponseError(operation, "missing_item_identity", fmt.Sprintf("响应 %s[%d] 缺少非空邮箱地址", selectedPath, index)) + } + addresses = append(addresses, address) + } + return addresses, nil +} + +func mailReadMessage(rt *shortcut.RuntimeContext, email, id string) (map[string]any, error) { + data, err := rt.CallMCPData("mail", "get_email_by_message_id", map[string]any{"email": email, "messageId": id}) + if err != nil { + return nil, err + } + message, err := mailRequireObject(data, "mail/get_email_by_message_id", "message") + if err != nil { + return nil, err + } + if err := mailRequireIdentity(message, "mail/get_email_by_message_id", id, "id"); err != nil { + return nil, err + } + return message, nil +} + +var Message = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+message", Product: "mail", + Description: "读取一封邮件的完整正文与附件元数据", Intent: "已知单个 messageId,需要读取完整正文和附件元数据时使用;自动解析当前邮箱,并要求返回邮件 ID 与请求精确一致。", + Risk: shortcut.RiskRead, Safety: mailReadSafety(), + Contract: mailReadContract("+message", "读取一封邮件的完整正文与附件元数据", "已知单个 messageId,需要读取完整正文和附件元数据时使用;自动解析当前邮箱,并要求返回邮件 ID 与请求精确一致。", mailObjectResult("身份匹配的单封邮件详情"), []contract.ParamDecl{{Name: "id", Property: "messageId"}}, `dws mail +message --id --format json`), + Flags: []shortcut.Flag{ + {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "邮件 messageId,不能为空"}, + {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"id"}, Description: "不能为空"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateRequiredText(rt, "id") + }, + Execute: func(rt *shortcut.RuntimeContext) error { + email, err := mailResolveMailbox(rt) + if err != nil { + return err + } + message, err := mailReadMessage(rt, email, rt.Str("id")) + if err != nil { + return err + } + return rt.Output(map[string]any{"value": message}) + }, +} + +var Messages = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+messages", Product: "mail", + Description: "按请求顺序读取多封邮件并逐封验证身份", Intent: "需要一次读取多个 messageId 时使用;按输入顺序逐封读取,任何缺失、错型或身份不匹配都会使整次任务失败。", + Risk: shortcut.RiskRead, Safety: mailReadSafety(), + Contract: mailReadContract("+messages", "按请求顺序读取多封邮件并逐封验证身份", "需要一次读取多个 messageId 时使用;按输入顺序逐封读取,任何缺失、错型或身份不匹配都会使整次任务失败。", mailCollectionResult("messages", "身份匹配且保持请求顺序的邮件详情"), []contract.ParamDecl{{Name: "ids", Property: "messageIds"}}, `dws mail +messages --ids , --format json`), + Flags: []shortcut.Flag{ + {Name: "ids", Type: shortcut.FlagStringSlice, Required: true, Desc: "邮件 messageId 列表,1-100 个且每项不能为空"}, + {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"}, + }, + Constraints: []shortcut.Constraint{{ + Kind: shortcut.ConstraintCustom, Flags: []string{"ids"}, + Description: "1-100 个且不能为空", + }}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateMessageIDs(rt.StrSlice("ids")) + }, + Execute: func(rt *shortcut.RuntimeContext) error { + ids := rt.StrSlice("ids") + email, err := mailResolveMailbox(rt) + if err != nil { + return err + } + messages := make([]map[string]any, 0, len(ids)) + for index, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + return apperrors.NewValidation(fmt.Sprintf("--ids 第 %d 项为空", index)) + } + message, err := mailReadMessage(rt, email, id) + if err != nil { + return err + } + messages = append(messages, message) + } + return rt.Output(mailBusinessCollectionPayload("messages", messages)) + }, +} + +func mailValidateMessageIDs(ids []string) error { + if len(ids) == 0 || len(ids) > 100 { + return apperrors.NewValidation("--ids 需要 1 到 100 个邮件 ID") + } + for index, id := range ids { + if strings.TrimSpace(id) == "" { + return apperrors.NewValidation(fmt.Sprintf("--ids 第 %d 项为空", index)) + } + } + return nil +} + +var Thread = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+thread", Product: "mail", + Description: "读取完整邮件会话并精确验证 conversationId", Intent: "已知一个 conversationId,需要查看同一主题的会话上下文时使用;自动解析邮箱并拒绝空对象或错会话。", + Risk: shortcut.RiskRead, Safety: mailReadSafety(), + Contract: mailReadContract("+thread", "读取完整邮件会话并精确验证 conversationId", "已知一个 conversationId,需要查看同一主题的会话上下文时使用;自动解析邮箱并拒绝空对象或错会话。", mailObjectResult("身份匹配的邮件会话详情"), []contract.ParamDecl{{Name: "id", Property: "conversationId"}}, `dws mail +thread --id --format json`), + Flags: []shortcut.Flag{ + {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "邮件会话 conversationId,不能为空"}, + {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"id"}, Description: "不能为空"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateRequiredText(rt, "id") + }, + Execute: func(rt *shortcut.RuntimeContext) error { + email, err := mailResolveMailbox(rt) + if err != nil { + return err + } + data, err := rt.CallMCPData("mail", "get_thread", map[string]any{"email": email, "conversationId": rt.Str("id")}) + if err != nil { + return err + } + conversation, err := mailRequireObject(data, "mail/get_thread", "conversation") + if err != nil { + return err + } + if err := mailRequireIdentity(conversation, "mail/get_thread", rt.Str("id"), "id"); err != nil { + return err + } + return rt.Output(map[string]any{"value": conversation}) + }, +} + +func init() { + shortcut.Register(Message, Messages, Thread) +} diff --git a/internal/shortcut/mail/thread_list_projection_test.go b/internal/shortcut/mail/thread_list_projection_test.go index c2f178d5..d34f639e 100644 --- a/internal/shortcut/mail/thread_list_projection_test.go +++ b/internal/shortcut/mail/thread_list_projection_test.go @@ -21,18 +21,23 @@ import ( // TestThreadListProjectConversationsShape guards the exact // list_mailbox_threads response contract, including lastModifiedDateTime. func TestThreadListProjectConversationsShape(t *testing.T) { - const raw = `{"result":{"conversations":[{ + const raw = `{"success":true,"result":{"conversations":[{ "id":"thread-1", "subject":"Projection contract", "lastModifiedDateTime":"2026-07-26T10:00:00Z", "isRead":false - }]}}` + }],"hasMore":false,"nextCursor":""}}` var data map[string]any if err := json.Unmarshal([]byte(raw), &data); err != nil { t.Fatalf("unmarshal fixture: %v", err) } - got := threadListProject(data) + got, err := mailProjectCollection(data, "mail/list_mailbox_threads", "result.conversations", []string{"id"}, map[string][]string{ + "conversationId": {"id"}, "subject": {"subject"}, "lastUpdated": {"lastModifiedDateTime"}, "isRead": {"isRead"}, + }) + if err != nil { + t.Fatalf("strict projection: %v", err) + } if len(got) != 1 { t.Fatalf("lower/upper mismatch: result.conversations has 1 entry, projection returned %d (%v)", len(got), got) } diff --git a/internal/shortcut/mail/writes.go b/internal/shortcut/mail/writes.go new file mode 100644 index 00000000..63989e89 --- /dev/null +++ b/internal/shortcut/mail/writes.go @@ -0,0 +1,397 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "fmt" + "sort" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func mailRecipients(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + for _, part := range strings.Split(value, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + out = append(out, trimmed) + } + } + } + return out +} + +func mailNormalizedAddresses(values []string) []string { + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + normalized := strings.ToLower(strings.TrimSpace(value)) + if normalized != "" { + seen[normalized] = struct{}{} + } + } + out := make([]string, 0, len(seen)) + for value := range seen { + out = append(out, value) + } + sort.Strings(out) + return out +} + +func mailReadAddresses(message map[string]any, operation, field string) ([]string, error) { + raw, present := message[field] + if !present { + return nil, mailResponseError(operation, "missing_verification_field", fmt.Sprintf("读回邮件缺少 %s", field)) + } + items, ok := raw.([]any) + if !ok { + return nil, mailResponseError(operation, "malformed_verification_field", fmt.Sprintf("读回邮件 %s 应为数组", field)) + } + addresses := make([]string, 0, len(items)) + for index, item := range items { + var address string + switch typed := item.(type) { + case string: + address = typed + case map[string]any: + address = mailFirstString(typed, "email", "address") + default: + return nil, mailResponseError(operation, "malformed_verification_field", fmt.Sprintf("读回邮件 %s[%d] 类型无效", field, index)) + } + if strings.TrimSpace(address) == "" { + return nil, mailResponseError(operation, "malformed_verification_field", fmt.Sprintf("读回邮件 %s[%d] 缺少邮箱地址", field, index)) + } + addresses = append(addresses, address) + } + return mailNormalizedAddresses(addresses), nil +} + +func mailVerifyAddresses(message map[string]any, operation, field string, requested []string) error { + actual, err := mailReadAddresses(message, operation, field) + if err != nil { + return err + } + expected := mailNormalizedAddresses(requested) + if len(actual) != len(expected) { + return mailResponseError(operation, "verification_mismatch", fmt.Sprintf("读回邮件 %s 与请求不一致", field)) + } + for index := range expected { + if actual[index] != expected[index] { + return mailResponseError(operation, "verification_mismatch", fmt.Sprintf("读回邮件 %s 与请求不一致", field)) + } + } + return nil +} + +func mailVerifySender(message map[string]any, operation, requested string) error { + raw, present := message["from"] + if !present { + return mailResponseError(operation, "missing_verification_field", "读回邮件缺少 from") + } + var actual string + switch typed := raw.(type) { + case string: + actual = typed + case map[string]any: + actual = mailFirstString(typed, "email", "address") + default: + return mailResponseError(operation, "malformed_verification_field", "读回邮件 from 类型无效") + } + if !strings.EqualFold(strings.TrimSpace(actual), strings.TrimSpace(requested)) { + return mailResponseError(operation, "verification_mismatch", "读回邮件发件地址与请求不一致") + } + return nil +} + +func mailDraftMessage(data map[string]any, operation string) (map[string]any, error) { + if err := mailRequireSuccess(data, operation); err != nil { + return nil, err + } + value, present := mailLookup(data, "result.message") + if !present { + return nil, mailResponseError(operation, "missing_write_receipt", "写响应缺少 result.message") + } + message, ok := value.(map[string]any) + if !ok || len(message) == 0 { + return nil, mailResponseError(operation, "malformed_write_receipt", fmt.Sprintf("result.message 应为非空对象,实际为 %T", value)) + } + if err := mailRequireIdentity(message, operation, "", "id"); err != nil { + return nil, err + } + return message, nil +} + +func mailReadTemplate(rt *shortcut.RuntimeContext, email, id string) (map[string]any, error) { + data, err := rt.CallMCPData("mail", "get_user_message_template", map[string]any{"email": email, "id": id}) + if err != nil { + return nil, err + } + if err := mailRequireSuccess(data, "mail/get_user_message_template"); err != nil { + return nil, err + } + template := make(map[string]any, len(data)) + for key, value := range data { + switch key { + case "success", "errorCode", "errorMsg", "arguments": + continue + default: + template[key] = value + } + } + if len(template) == 0 { + return nil, mailResponseError("mail/get_user_message_template", "missing_result", "模板详情为空") + } + if err := mailRequireIdentity(template, "mail/get_user_message_template", id, "id"); err != nil { + return nil, err + } + return template, nil +} + +func mailTemplateSubject(template map[string]any) string { + message, _ := template["message"].(map[string]any) + return mailFirstString(message, "subject") +} + +func mailTemplateBody(template map[string]any) string { + message, _ := template["message"].(map[string]any) + return mailFirstString(message, "markdownBody", "body") +} + +var DraftCreate = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+draft-create", Product: "mail", + Description: "创建邮件草稿并按 messageId 读回验证", Intent: "撰写一封新邮件但暂不发送;取得稳定草稿 ID 后读取同一对象,并核对主题和正文。", + Risk: shortcut.RiskWrite, Safety: mailWriteSafety("non_idempotent"), + Contract: mailWriteContract("+draft-create", "创建邮件草稿并按 messageId 读回验证", "撰写一封新邮件但暂不发送;取得稳定草稿 ID 后读取同一对象,并核对主题和正文。", []contract.ParamDecl{{Name: "from", Property: "from"}, {Name: "to", Property: "toRecipients"}, {Name: "cc", Property: "ccRecipients"}, {Name: "body", Property: "body"}}, `dws mail +draft-create --from user@company.com --subject "草稿" --body "正文" --format json`), + Flags: []shortcut.Flag{ + {Name: "from", Type: shortcut.FlagString, Required: true, Desc: "发件邮箱"}, + {Name: "to", Type: shortcut.FlagStringSlice, Desc: "收件邮箱,可多次指定或逗号分隔"}, + {Name: "cc", Type: shortcut.FlagStringSlice, Desc: "抄送邮箱,可多次指定或逗号分隔"}, + {Name: "subject", Type: shortcut.FlagString, Required: true, Desc: "草稿主题"}, + {Name: "body", Type: shortcut.FlagString, Desc: "草稿正文"}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"to", "cc", "subject", "body"}}}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"from": rt.Str("from"), "subject": rt.Str("subject")} + if recipients := mailRecipients(rt.StrSlice("to")); len(recipients) > 0 { + params["toRecipients"] = recipients + } + if recipients := mailRecipients(rt.StrSlice("cc")); len(recipients) > 0 { + params["ccRecipients"] = recipients + } + if rt.Changed("body") { + params["body"] = rt.Str("body") + } + if rt.DryRun() { + return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/create_draft"}}) + } + written, err := rt.CallMCPWriteDataStrict("mail", "create_draft", params) + if err != nil { + return err + } + receipt, err := mailDraftMessage(written, "mail/create_draft") + if err != nil { + return err + } + id := mailFirstString(receipt, "id") + verified, err := mailReadMessage(rt, rt.Str("from"), id) + if err != nil { + return err + } + if mailFirstString(verified, "subject") != rt.Str("subject") { + return mailResponseError("mail/create_draft", "verification_mismatch", "草稿读回主题与请求不一致") + } + if rt.Changed("body") && mailFirstString(verified, "markdownBody", "body") != rt.Str("body") { + return mailResponseError("mail/create_draft", "verification_mismatch", "草稿读回正文与请求不一致") + } + if err := mailVerifySender(verified, "mail/create_draft", rt.Str("from")); err != nil { + return err + } + if rt.Changed("to") { + if err := mailVerifyAddresses(verified, "mail/create_draft", "toRecipients", mailRecipients(rt.StrSlice("to"))); err != nil { + return err + } + } + if rt.Changed("cc") { + if err := mailVerifyAddresses(verified, "mail/create_draft", "ccRecipients", mailRecipients(rt.StrSlice("cc"))); err != nil { + return err + } + } + return rt.Output(map[string]any{"value": verified}) + }, +} + +var DraftEdit = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+draft-edit", Product: "mail", + Description: "更新已有草稿并精确读回验证", Intent: "修改尚未发送的草稿;要求至少提供一个变化字段,并在写后按原 messageId 核对修改内容。", + Risk: shortcut.RiskWrite, Safety: mailWriteSafety("unknown"), + Contract: mailWriteContract("+draft-edit", "更新已有草稿并精确读回验证", "修改尚未发送的草稿;要求至少提供一个变化字段,并在写后按原 messageId 核对修改内容。", []contract.ParamDecl{{Name: "id", Property: "id"}, {Name: "from", Property: "from"}, {Name: "to", Property: "toRecipients"}, {Name: "cc", Property: "ccRecipients"}, {Name: "body", Property: "body"}}, `dws mail +draft-edit --from user@company.com --id --subject "新主题" --format json`), + Flags: []shortcut.Flag{ + {Name: "from", Type: shortcut.FlagString, Required: true, Desc: "发件邮箱"}, + {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "草稿 messageId"}, + {Name: "to", Type: shortcut.FlagStringSlice, Desc: "新收件邮箱"}, + {Name: "cc", Type: shortcut.FlagStringSlice, Desc: "新抄送邮箱"}, + {Name: "subject", Type: shortcut.FlagString, Desc: "新主题"}, + {Name: "body", Type: shortcut.FlagString, Desc: "新正文"}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"to", "cc", "subject", "body"}}}, + Execute: func(rt *shortcut.RuntimeContext) error { + if !rt.Changed("to") && !rt.Changed("cc") && !rt.Changed("subject") && !rt.Changed("body") { + return apperrors.NewValidation("至少指定 --to、--cc、--subject、--body 之一") + } + params := map[string]any{"from": rt.Str("from"), "id": rt.Str("id")} + if rt.Changed("to") { + params["toRecipients"] = mailRecipients(rt.StrSlice("to")) + } + if rt.Changed("cc") { + params["ccRecipients"] = mailRecipients(rt.StrSlice("cc")) + } + if rt.Changed("subject") { + params["subject"] = rt.Str("subject") + } + if rt.Changed("body") { + params["body"] = rt.Str("body") + } + if rt.DryRun() { + return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/update_draft"}}) + } + written, err := rt.CallMCPWriteDataStrict("mail", "update_draft", params) + if err != nil { + return err + } + receipt, err := mailDraftMessage(written, "mail/update_draft") + if err != nil { + return err + } + if mailFirstString(receipt, "id") != rt.Str("id") { + return mailResponseError("mail/update_draft", "identity_mismatch", "更新回执的 messageId 与请求不一致") + } + verified, err := mailReadMessage(rt, rt.Str("from"), rt.Str("id")) + if err != nil { + return err + } + if rt.Changed("subject") && mailFirstString(verified, "subject") != rt.Str("subject") { + return mailResponseError("mail/update_draft", "verification_mismatch", "草稿读回主题与请求不一致") + } + if rt.Changed("body") && mailFirstString(verified, "markdownBody", "body") != rt.Str("body") { + return mailResponseError("mail/update_draft", "verification_mismatch", "草稿读回正文与请求不一致") + } + if err := mailVerifySender(verified, "mail/update_draft", rt.Str("from")); err != nil { + return err + } + if rt.Changed("to") { + if err := mailVerifyAddresses(verified, "mail/update_draft", "toRecipients", mailRecipients(rt.StrSlice("to"))); err != nil { + return err + } + } + if rt.Changed("cc") { + if err := mailVerifyAddresses(verified, "mail/update_draft", "ccRecipients", mailRecipients(rt.StrSlice("cc"))); err != nil { + return err + } + } + return rt.Output(map[string]any{"value": verified}) + }, +} + +var TemplateCreate = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+template-create", Product: "mail", + Description: "创建个人邮件模板并按模板 ID 读回", Intent: "保存可复用邮件主题和正文;创建后按稳定模板 ID 读取并核对名称、主题和正文。", + Risk: shortcut.RiskWrite, Safety: mailWriteSafety("non_idempotent"), + Contract: mailWriteContract("+template-create", "创建个人邮件模板并按模板 ID 读回", "保存可复用邮件主题和正文;创建后按稳定模板 ID 读取并核对名称、主题和正文。", []contract.ParamDecl{{Name: "email", Property: "email"}, {Name: "body", Property: "body"}}, `dws mail +template-create --email user@company.com --name "模板" --subject "主题" --body "正文" --format json`), + Flags: []shortcut.Flag{ + {Name: "email", Type: shortcut.FlagString, Required: true, Desc: "模板所属邮箱"}, + {Name: "name", Type: shortcut.FlagString, Required: true, Desc: "模板名称"}, + {Name: "subject", Type: shortcut.FlagString, Required: true, Desc: "模板主题"}, + {Name: "body", Type: shortcut.FlagString, Required: true, Desc: "模板正文"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"email": rt.Str("email"), "name": rt.Str("name"), "subject": rt.Str("subject"), "body": rt.Str("body")} + if rt.DryRun() { + return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/create_user_message_template"}}) + } + written, err := rt.CallMCPWriteDataStrict("mail", "create_user_message_template", params) + if err != nil { + return err + } + if err := mailRequireSuccess(written, "mail/create_user_message_template"); err != nil { + return err + } + id := mailFirstString(written, "id") + if id == "" { + return mailResponseError("mail/create_user_message_template", "missing_stable_id", "创建模板响应缺少稳定 ID") + } + verified, err := mailReadTemplate(rt, rt.Str("email"), id) + if err != nil { + return err + } + if mailFirstString(verified, "name") != rt.Str("name") || mailTemplateSubject(verified) != rt.Str("subject") || mailTemplateBody(verified) != rt.Str("body") { + return mailResponseError("mail/create_user_message_template", "verification_mismatch", "模板读回内容与请求不一致") + } + return rt.Output(map[string]any{"value": verified}) + }, +} + +var TemplateUpdate = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+template-update", Product: "mail", + Description: "更新草稿模板并按原 ID 读回验证", Intent: "修改可编辑的草稿模板;写响应成功后按原模板 ID 读取并核对所有请求变更。", + Risk: shortcut.RiskWrite, Safety: mailWriteSafety("unknown"), + Contract: mailWriteContract("+template-update", "更新草稿模板并按原 ID 读回验证", "修改可编辑的草稿模板;写响应成功后按原模板 ID 读取并核对所有请求变更。", []contract.ParamDecl{{Name: "email", Property: "email"}, {Name: "id", Property: "id"}, {Name: "body", Property: "body"}}, `dws mail +template-update --email user@company.com --id --subject "新主题" --format json`), + Flags: []shortcut.Flag{ + {Name: "email", Type: shortcut.FlagString, Required: true, Desc: "模板所属邮箱"}, + {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "模板 ID;仅草稿模板可更新"}, + {Name: "name", Type: shortcut.FlagString, Desc: "新模板名称"}, + {Name: "subject", Type: shortcut.FlagString, Desc: "新主题"}, + {Name: "body", Type: shortcut.FlagString, Desc: "新正文"}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"name", "subject", "body"}}}, + Execute: func(rt *shortcut.RuntimeContext) error { + if !rt.Changed("name") && !rt.Changed("subject") && !rt.Changed("body") { + return apperrors.NewValidation("至少指定 --name、--subject、--body 之一") + } + params := map[string]any{"email": rt.Str("email"), "id": rt.Str("id")} + for _, flag := range []string{"name", "subject", "body"} { + if rt.Changed(flag) { + params[flag] = rt.Str(flag) + } + } + if rt.DryRun() { + return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/update_user_message_template"}}) + } + written, err := rt.CallMCPWriteDataStrict("mail", "update_user_message_template", params) + if err != nil { + return err + } + if err := mailRequireSuccess(written, "mail/update_user_message_template"); err != nil { + return err + } + verified, err := mailReadTemplate(rt, rt.Str("email"), rt.Str("id")) + if err != nil { + return err + } + if rt.Changed("name") && mailFirstString(verified, "name") != rt.Str("name") { + return mailResponseError("mail/update_user_message_template", "verification_mismatch", "模板读回名称与请求不一致") + } + if rt.Changed("subject") && mailTemplateSubject(verified) != rt.Str("subject") { + return mailResponseError("mail/update_user_message_template", "verification_mismatch", "模板读回主题与请求不一致") + } + if rt.Changed("body") && mailTemplateBody(verified) != rt.Str("body") { + return mailResponseError("mail/update_user_message_template", "verification_mismatch", "模板读回正文与请求不一致") + } + return rt.Output(map[string]any{"value": verified}) + }, +} + +func init() { + markMailUnavailable(&DraftCreate, "真实草稿连续两次 batch-delete 后仍能按同 messageId 读取,无法证明安全 fixture 的清理终态。") + markMailUnavailable(&DraftEdit, "真实草稿连续两次 batch-delete 后仍能按同 messageId 读取,无法证明安全 fixture 的清理终态。") + markMailUnavailable(&TemplateCreate, "模板 delete 后 get 仅返回未分类失败,无 typed nonfound/tombstone 证据可证明零残留。") + markMailUnavailable(&TemplateUpdate, "模板 delete 后 get 仅返回未分类失败,无 typed nonfound/tombstone 证据可证明零残留。") + shortcut.Register(DraftCreate, DraftEdit, TemplateCreate, TemplateUpdate) +} diff --git a/internal/shortcut/public_catalog_consistency_test.go b/internal/shortcut/public_catalog_consistency_test.go new file mode 100644 index 00000000..afd3a2a1 --- /dev/null +++ b/internal/shortcut/public_catalog_consistency_test.go @@ -0,0 +1,59 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package shortcut + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestCrossPlatformCoveragePublicCatalogArtifactsStayConsistent(t *testing.T) { + _, testFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate public catalog test file") + } + catalogPath := filepath.Join(filepath.Dir(testFile), "..", "..", "docs", "shortcut-public-catalog.json") + raw, err := os.ReadFile(catalogPath) + if err != nil { + t.Fatalf("read public catalog: %v", err) + } + var catalog struct { + Count int `json:"count"` + Results []struct { + Service string `json:"service"` + Command string `json:"command"` + } `json:"results"` + } + if err := json.Unmarshal(raw, &catalog); err != nil { + t.Fatalf("decode public catalog: %v", err) + } + if catalog.Count != len(catalog.Results) { + t.Fatalf("public catalog count = %d, results = %d", catalog.Count, len(catalog.Results)) + } + if catalog.Count != len(publicShortcutCatalog) { + t.Fatalf("public catalog count = %d, generated Go keys = %d", catalog.Count, len(publicShortcutCatalog)) + } + seen := make(map[string]struct{}, len(catalog.Results)) + for i, row := range catalog.Results { + key := publicCatalogKey(row.Service, row.Command) + if row.Service == "" || row.Command == "" { + t.Fatalf("public catalog result %d has an empty identity", i) + } + if _, duplicate := seen[key]; duplicate { + t.Fatalf("public catalog result %d duplicates %q", i, key) + } + seen[key] = struct{}{} + if _, generated := publicShortcutCatalog[key]; !generated { + t.Fatalf("public catalog result %d is missing from generated Go keys: %q", i, key) + } + } + for key := range publicShortcutCatalog { + if _, documented := seen[key]; !documented { + t.Fatalf("generated Go key is missing from public catalog JSON: %q", key) + } + } +} diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index 0aba731a..51dd4a94 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -101,23 +101,12 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "aitable\u0000+workflow-list": {}, "attendance\u0000+check-record": {}, "attendance\u0000+check-result": {}, - "attendance\u0000+get-adjustment-rule": {}, "attendance\u0000+get-approve-template": {}, - "attendance\u0000+get-checkin-record": {}, - "attendance\u0000+get-leave-records": {}, "attendance\u0000+get-overtime-rule": {}, - "attendance\u0000+get-schedule": {}, - "attendance\u0000+get-self-setting": {}, - "attendance\u0000+get-summary": {}, "attendance\u0000+list-approve": {}, - "attendance\u0000+list-leave-types": {}, - "attendance\u0000+my-attendance": {}, - "attendance\u0000+query-report-data": {}, "attendance\u0000+search-adjustment-rule": {}, "attendance\u0000+search-class": {}, - "attendance\u0000+search-group": {}, "attendance\u0000+search-overtime-rule": {}, - "attendance\u0000+this-month": {}, "calendar\u0000+agenda": {}, "calendar\u0000+attendee-list": {}, "calendar\u0000+book": {}, @@ -353,15 +342,13 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "drive\u0000+version-get": {}, "drive\u0000+version-history": {}, "drive\u0000+version-revert": {}, - "mail\u0000+contact-list": {}, "mail\u0000+find-mail-user": {}, "mail\u0000+folder-list": {}, - "mail\u0000+recent-mail": {}, + "mail\u0000+message": {}, + "mail\u0000+messages": {}, "mail\u0000+search-mail": {}, - "mail\u0000+tag-list": {}, - "mail\u0000+template-list": {}, - "mail\u0000+thread-list": {}, - "mail\u0000+unread-mail": {}, + "mail\u0000+thread": {}, + "mail\u0000+triage": {}, "mail\u0000+user-search": {}, "minutes\u0000+action-items": {}, "minutes\u0000+apply-permission": {}, diff --git a/internal/shortcut/responsecheck/responsecheck.go b/internal/shortcut/responsecheck/responsecheck.go new file mode 100644 index 00000000..1b175933 --- /dev/null +++ b/internal/shortcut/responsecheck/responsecheck.go @@ -0,0 +1,181 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Package responsecheck contains fail-closed response validators shared by +// built-in shortcuts. It deliberately does not guess that a missing collection +// means an empty result: callers must name the exact reviewed wire paths. +package responsecheck + +import ( + "fmt" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +// Error returns a non-retryable response-validation error. The remote request +// has completed, but the response is not sufficient evidence of success. +func Error(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +// RequireSuccess validates the common service envelope and returns it. A +// missing success field is rejected because the products using this helper +// have been observed to publish an explicit boolean success field. +func RequireSuccess(data map[string]any, operation string) (map[string]any, error) { + if len(data) == 0 { + return nil, Error(operation, "empty_tool_response", "服务返回空响应,无法证明调用成功或结果确实为空") + } + envelope := data + if content, ok := data["content"].(map[string]any); ok { + envelope = content + } + raw, present := envelope["success"] + if !present { + return nil, Error(operation, "missing_success", "响应缺少 success 业务状态,无法证明调用成功") + } + success, ok := raw.(bool) + if !ok { + return nil, Error(operation, "malformed_success", "响应 success 字段不是布尔值") + } + if !success { + message := firstString(envelope, "errorMessage", "errorMsg", "message", "error") + if message == "" { + message = "服务明确返回 success=false" + } + return nil, Error(operation, "remote_failure", message) + } + return envelope, nil +} + +// RequireResult returns the explicit non-null result value from a successful +// response. Empty arrays and empty objects remain valid values; absence/null do +// not prove an empty business result. +func RequireResult(data map[string]any, operation string) (any, error) { + envelope, err := RequireSuccess(data, operation) + if err != nil { + return nil, err + } + result, present := envelope["result"] + if !present || result == nil { + return nil, Error(operation, "missing_result", "成功响应缺少非空 result 字段") + } + return result, nil +} + +// RequireObjectResult returns an explicit object result. +func RequireObjectResult(data map[string]any, operation string) (map[string]any, error) { + result, err := RequireResult(data, operation) + if err != nil { + return nil, err + } + object, ok := result.(map[string]any) + if !ok { + return nil, Error(operation, "malformed_result", fmt.Sprintf("响应 result 应为对象,实际为 %T", result)) + } + return object, nil +} + +// RequireSingleObjectResult returns one non-empty business object. It accepts +// the two reviewed detail shapes used by product services: result={...} and +// result=[{...}]. Empty arrays, multiple rows, empty objects, and scalar values +// cannot prove that the requested unique resource was read. +func RequireSingleObjectResult(data map[string]any, operation string) (map[string]any, error) { + result, err := RequireResult(data, operation) + if err != nil { + return nil, err + } + var object map[string]any + switch typed := result.(type) { + case map[string]any: + object = typed + case []any: + if len(typed) != 1 { + return nil, Error(operation, "unexpected_detail_count", fmt.Sprintf("详情响应应唯一,实际返回 %d 项", len(typed))) + } + var ok bool + object, ok = typed[0].(map[string]any) + if !ok { + return nil, Error(operation, "malformed_result", fmt.Sprintf("详情响应 result[0] 应为对象,实际为 %T", typed[0])) + } + default: + return nil, Error(operation, "malformed_result", fmt.Sprintf("详情响应 result 应为对象或单元素对象数组,实际为 %T", result)) + } + if len(object) == 0 { + return nil, Error(operation, "empty_result_object", "成功响应的详情对象为空,无法证明资源读取成功") + } + return object, nil +} + +// RequireObjectCollection resolves the first present reviewed path, requires +// an array at that exact path, and rejects malformed elements instead of +// silently dropping them. A present empty array is a valid empty result. +func RequireObjectCollection(data map[string]any, operation string, paths ...string) ([]map[string]any, error) { + envelope, err := RequireSuccess(data, operation) + if err != nil { + return nil, err + } + for _, path := range paths { + value, present := lookup(envelope, path) + if !present { + continue + } + raw, ok := value.([]any) + if !ok { + return nil, Error(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value)) + } + items := make([]map[string]any, 0, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, Error(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index)) + } + items = append(items, object) + } + return items, nil + } + return nil, Error(operation, "missing_collection", "成功响应缺少已审核的结果数组;不能把未知响应结构当作空结果") +} + +// LookupObject returns the object at a reviewed dotted path. +func LookupObject(data map[string]any, operation, path string) (map[string]any, error) { + value, present := lookup(data, path) + if !present { + return nil, Error(operation, "missing_object", fmt.Sprintf("响应缺少 %s 对象", path)) + } + object, ok := value.(map[string]any) + if !ok { + return nil, Error(operation, "malformed_object", fmt.Sprintf("响应 %s 应为对象,实际为 %T", path, value)) + } + return object, nil +} + +func lookup(data map[string]any, path string) (any, bool) { + var current any = data + for _, segment := range strings.Split(path, ".") { + object, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = object[segment] + if !ok { + return nil, false + } + } + return current, true +} + +func firstString(object map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/internal/shortcut/responsecheck/responsecheck_test.go b/internal/shortcut/responsecheck/responsecheck_test.go new file mode 100644 index 00000000..41a5c3c2 --- /dev/null +++ b/internal/shortcut/responsecheck/responsecheck_test.go @@ -0,0 +1,161 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package responsecheck + +import "testing" + +func TestCrossPlatformCoverageRequireObjectCollection(t *testing.T) { + tests := []struct { + name string + data map[string]any + wantLen int + wantErr bool + }{ + {name: "non-empty", data: map[string]any{"success": true, "result": []any{map[string]any{"id": "one"}}}, wantLen: 1}, + {name: "content envelope", data: map[string]any{"content": map[string]any{"success": true, "result": []any{map[string]any{"id": "one"}}}}, wantLen: 1}, + {name: "explicit empty", data: map[string]any{"success": true, "result": []any{}}, wantLen: 0}, + {name: "missing reviewed path", data: map[string]any{"success": true}, wantErr: true}, + {name: "missing collection", data: map[string]any{"success": true, "result": map[string]any{}}, wantErr: true}, + {name: "wrong collection type", data: map[string]any{"success": true, "result": "bad"}, wantErr: true}, + {name: "malformed item", data: map[string]any{"success": true, "result": []any{"bad"}}, wantErr: true}, + {name: "empty item", data: map[string]any{"success": true, "result": []any{map[string]any{}}}, wantErr: true}, + {name: "missing success", data: map[string]any{"result": []any{}}, wantErr: true}, + {name: "remote failure", data: map[string]any{"success": false, "result": []any{}}, wantErr: true}, + {name: "empty response", data: map[string]any{}, wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := RequireObjectCollection(tc.data, "test/read", "result") + if (err != nil) != tc.wantErr { + t.Fatalf("error = %v, wantErr %v", err, tc.wantErr) + } + if err == nil && len(got) != tc.wantLen { + t.Fatalf("len = %d, want %d", len(got), tc.wantLen) + } + }) + } +} + +func TestCrossPlatformCoverageRequireObjectCollectionNested(t *testing.T) { + data := map[string]any{ + "success": true, + "result": map[string]any{ + "items": []any{map[string]any{"id": "one"}}, + }, + } + items, err := RequireObjectCollection(data, "test/nested", "result.items") + if err != nil { + t.Fatalf("RequireObjectCollection: %v", err) + } + if len(items) != 1 { + t.Fatalf("items = %#v", items) + } +} + +func TestCrossPlatformCoverageRequireResultRejectsNull(t *testing.T) { + if _, err := RequireResult(map[string]any{"success": true, "result": nil}, "test/null"); err == nil { + t.Fatal("expected null result to fail closed") + } + if _, err := RequireObjectResult(map[string]any{"success": true, "result": []any{}}, "test/object"); err == nil { + t.Fatal("expected array result to fail object validation") + } +} + +func TestCrossPlatformCoverageRequireSingleObjectResult(t *testing.T) { + for _, data := range []map[string]any{ + {"success": true, "result": map[string]any{"id": "one"}}, + {"success": true, "result": []any{map[string]any{"id": "one"}}}, + } { + object, err := RequireSingleObjectResult(data, "test/detail") + if err != nil || object["id"] != "one" { + t.Fatalf("valid detail shape rejected: object=%v err=%v", object, err) + } + } + for _, data := range []map[string]any{ + {}, + {"success": true, "result": map[string]any{}}, + {"success": true, "result": []any{}}, + {"success": true, "result": []any{map[string]any{"id": "one"}, map[string]any{"id": "two"}}}, + {"success": true, "result": []any{"bad"}}, + {"success": true, "result": "bad"}, + } { + if object, err := RequireSingleObjectResult(data, "test/detail"); err == nil { + t.Fatalf("ambiguous/malformed detail returned success: %v", object) + } + } +} + +func TestCrossPlatformCoverageResponseEnvelopeFailures(t *testing.T) { + for _, tc := range []struct { + name string + data map[string]any + }{ + {name: "missing success", data: map[string]any{"result": map[string]any{}}}, + {name: "malformed success", data: map[string]any{"success": "true"}}, + {name: "failure with explicit message", data: map[string]any{"success": false, "errorMsg": " reviewed failure "}}, + {name: "failure without message", data: map[string]any{"success": false, "error": 42}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := RequireSuccess(tc.data, "test/envelope"); err == nil { + t.Fatal("expected fail-closed envelope validation") + } + }) + } + + if _, err := RequireResult(map[string]any{}, "test/result"); err == nil { + t.Fatal("expected upstream envelope failure") + } + if _, err := RequireResult(map[string]any{"success": true}, "test/result"); err == nil { + t.Fatal("expected missing result failure") + } + if object, err := RequireObjectResult(map[string]any{"success": true, "result": map[string]any{"id": "one"}}, "test/object"); err != nil || object["id"] != "one" { + t.Fatalf("valid object result rejected: object=%v err=%v", object, err) + } + if _, err := RequireObjectResult(map[string]any{}, "test/object"); err == nil { + t.Fatal("expected upstream object envelope failure") + } +} + +func TestCrossPlatformCoverageLookupObject(t *testing.T) { + data := map[string]any{ + "result": map[string]any{ + "detail": map[string]any{"id": "one"}, + "scalar": "bad", + }, + } + object, err := LookupObject(data, "test/lookup", "result.detail") + if err != nil || object["id"] != "one" { + t.Fatalf("valid nested object rejected: object=%v err=%v", object, err) + } + if _, err := LookupObject(data, "test/lookup", "result.missing"); err == nil { + t.Fatal("expected missing nested object failure") + } + if _, err := LookupObject(data, "test/lookup", "result.scalar"); err == nil { + t.Fatal("expected malformed nested object failure") + } + if _, present := lookup(map[string]any{"result": "scalar"}, "result.detail"); present { + t.Fatal("scalar parent must not satisfy a nested lookup") + } +} + +func TestCrossPlatformCoverageCollectionPathFallbackAndHelpers(t *testing.T) { + items, err := RequireObjectCollection( + map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"id": "one"}}}}, + "test/fallback", + "result.records", + "result.items", + ) + if err != nil || len(items) != 1 { + t.Fatalf("reviewed path fallback failed: items=%v err=%v", items, err) + } + if _, err := RequireObjectCollection(map[string]any{}, "test/fallback", "result"); err == nil { + t.Fatal("expected collection envelope failure") + } + if got := firstString(map[string]any{"first": 7, "second": " ", "third": " value "}, "first", "second", "third"); got != "value" { + t.Fatalf("firstString = %q, want value", got) + } + if got := firstString(map[string]any{"only": " "}, "only"); got != "" { + t.Fatalf("firstString whitespace = %q, want empty", got) + } +} diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index fbb5b312..12a00442 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -34,6 +34,12 @@ var calendarSemanticCatalogJSON []byte //go:embed semantic_catalog_todo.json var todoSemanticCatalogJSON []byte +//go:embed semantic_catalog_attendance.json +var attendanceSemanticCatalogJSON []byte + +//go:embed semantic_catalog_mail.json +var mailSemanticCatalogJSON []byte + type semanticCatalogFile struct { Version int `json:"version"` Service string `json:"service"` @@ -42,13 +48,14 @@ type semanticCatalogFile struct { } type semanticCatalogRecord struct { - Disposition SemanticDisposition `json:"disposition"` - SemanticDelta string `json:"semantic_delta"` - Risk Risk `json:"risk"` - Availability Availability `json:"availability,omitempty"` - Primary string `json:"primary,omitempty"` - Public bool `json:"public"` - Reviewed bool `json:"reviewed"` + Disposition SemanticDisposition `json:"disposition"` + SemanticDelta string `json:"semantic_delta"` + Risk Risk `json:"risk"` + Availability Availability `json:"availability,omitempty"` + Primary string `json:"primary,omitempty"` + Public bool `json:"public"` + CompatibilityVisible bool `json:"compatibility_visible,omitempty"` + Reviewed bool `json:"reviewed"` } var reviewedSemanticCatalog = mustLoadSemanticCatalogs( @@ -60,6 +67,8 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs( wikiSemanticCatalogJSON, calendarSemanticCatalogJSON, todoSemanticCatalogJSON, + attendanceSemanticCatalogJSON, + mailSemanticCatalogJSON, ) func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord { @@ -120,6 +129,13 @@ func loadSemanticCatalog(raw []byte, out map[string]semanticCatalogRecord) { panic(fmt.Sprintf("semantic catalog command %q cannot be public with availability %q", command, record.Availability)) } + // A command that was already part of the visible CLI contract cannot be + // hidden in the same feature change merely because Agent publication is + // withdrawn. This narrow fact preserves historical discovery/argv while + // the unavailable Interface and public=false keep it out of Agent routes. + if record.CompatibilityVisible && (record.Public || record.Availability != AvailabilityUnavailable) { + panic(fmt.Sprintf("semantic catalog command %q can be compatibility-visible only when non-public and unavailable", command)) + } key := publicCatalogKey(source.Service, command) if _, exists := out[key]; exists { panic(fmt.Sprintf("duplicate shortcut semantic catalog entry %s %s", source.Service, command)) @@ -138,6 +154,7 @@ func applyReviewedSemanticCatalog(s Shortcut) (Shortcut, bool) { s.Availability = record.Availability s.PrimaryCommand = record.Primary s.SemanticReviewed = record.Reviewed - s.Hidden = !record.Public + s.CompatibilityVisible = record.CompatibilityVisible + s.Hidden = !record.Public && !record.CompatibilityVisible return s, true } diff --git a/internal/shortcut/semantic_catalog_attendance.json b/internal/shortcut/semantic_catalog_attendance.json new file mode 100644 index 00000000..ab478fcf --- /dev/null +++ b/internal/shortcut/semantic_catalog_attendance.json @@ -0,0 +1,44 @@ +{ + "version": 1, + "service": "attendance", + "default_availability": "unavailable", + "shortcuts": { + "+check-result": {"disposition":"semantic_adapter","semantic_delta":"对齐 Lark attendance user_tasks query;严格校验正整数 ID 及请求用户/日期绑定,以 cursor_parameter=offset 将保守续页证据发布到 meta.pagination。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+check-record": {"disposition":"semantic_adapter","semantic_delta":"查询原始打卡流水;只有显式成功数组可表示空结果,每条必须有唯一正整数 ID 并绑定请求用户和日期。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+list-approve": {"disposition":"semantic_adapter","semantic_delta":"把审批类型语义映射为业务枚举;每条审批必须有唯一正整数 ID,并精确绑定请求用户、类型与时间范围。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-approve-template": {"disposition":"semantic_adapter","semantic_delta":"把补卡、请假、加班、外出、出差映射为审批模板类型;每项以非空唯一 processCode 作为稳定身份,approveType 精确绑定请求且 submitUrl 必须非空,允许同类型返回多个模板。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-schedule": {"disposition":"semantic_adapter","semantic_delta":"两次独立 clean HEAD 的已知非空与保证零命中请求中,getScheduleByRange 均以退出码 0 返回 literal null;Shortcut 正确非零拒绝,但下游没有提供可验证的显式排班数组或 typed error,修复前不公开。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+search-class": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.items、展开 shiftVO、要求唯一正整数 classId,并将无矛盾的 totalCount/totalPage 续页证据发布到 meta.pagination。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-class": {"disposition":"schema_leaf","semantic_delta":"真实搜索 classId 可取得非空详情,但 get_class_detail 的 shiftVO 不回显 id/classId,无法诚实证明详情属于请求 ID;等待下游补充稳定 ID 回显。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+search-adjustment-rule": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.adjustmentList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+search-overtime-rule": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.atRuleList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-overtime-rule": {"disposition":"schema_leaf","semantic_delta":"使用搜索得到的加班规则 ID 读取详情,严格拒绝空 result,且响应 id 必须与请求 overtimeId 精确一致。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-summary": {"disposition":"schema_leaf","semantic_delta":"真实响应只返回统计项,未回显请求用户、周期或统计类型,无法将结果精确绑定到请求;在下游补齐身份回显前保持不可用。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+get-self-setting": {"disposition":"schema_leaf","semantic_delta":"前五个设置场景可严格绑定用户和返回类型,但 bossAttendStatNotify 在当前安全身份下返回 NO_PERMISSION;缺少 capability discovery、逐场景权限合同和安全权限 fixture,暂不公开。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+list-leave-types": {"disposition":"semantic_adapter","semantic_delta":"当前安全身份只有非空假期类型列表且命令没有筛选参数;缺少合法空结果专用租户,无法完成 empty/nonempty 双态发布证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+get-leave-records": {"disposition":"semantic_adapter","semantic_delta":"使用真实用户和已发现 leaveCode 只能得到合法空集合;缺少已知非空变更流水 fixture,无法排除空结果假阳性。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+get-checkin-record": {"disposition":"semantic_adapter","semantic_delta":"当前安全身份只有合法空签到结果;缺少已知非空签到 fixture,无法排除空结果假阳性。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+my-attendance": {"disposition":"primary_smart","semantic_delta":"当前安全身份在固定的今天时间窗只返回合法空集合;缺少已知非空当天打卡 fixture,无法完成 exact Shortcut 非空证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+this-month": {"disposition":"primary_smart","semantic_delta":"当前安全身份在固定的本月时间窗只返回合法空集合;缺少已知非空本月打卡 fixture,无法完成 exact Shortcut 非空证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + + "+get-adjustment-rule": {"disposition":"schema_leaf","semantic_delta":"真实搜索返回规则,但详情接口对搜索得到的候选主键明确 success=true 且 result=null;在下游修复主键或详情返回前不可宣称可用。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+search-group": {"disposition":"semantic_adapter","semantic_delta":"严格投影已实现,但当前安全账号只有显式空结果,缺少已知非空考勤组 fixture,不能完成真实数据证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+query-report-data": {"disposition":"semantic_adapter","semantic_delta":"报表列发现接口返回 null,无法取得已验证 columnId;使用未验证列只得到空数组,不能证明报表查询正确。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+list-report-columns": {"disposition":"schema_leaf","semantic_delta":"下游接口当前以退出码 0 返回 null,既不是列数组也不是可证明的合法空结果。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+query-report-leave": {"disposition":"schema_leaf","semantic_delta":"下游接口当前以退出码 0 返回 null,无法证明假期报表任务成功或确实为空。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+get-global-setting": {"disposition":"schema_leaf","semantic_delta":"读取企业全局设置需要当前测试身份没有的管理员权限;缺少最小权限 fixture 前保持不可用。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+get-leave-balance": {"disposition":"schema_leaf","semantic_delta":"使用真实用户和已发现假期编码仍无法得到可验证结果;下游需补齐稳定响应契约或可控余额 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+get-group": {"disposition":"schema_leaf","semantic_delta":"详情读取需要稳定考勤组 ID;当前搜索没有已知非空安全 fixture,不能完成精确身份读回。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+get-group-filtered": {"disposition":"schema_leaf","semantic_delta":"过滤详情同样依赖稳定考勤组 ID;当前没有安全非空 fixture 可验证字段选择和身份绑定。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + + "+import-schedule": {"disposition":"schema_leaf","semantic_delta":"会改写员工排班;缺少隔离考勤组、班次、成员和写后读回/清理 fixture,不能安全开放。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+create-class": {"disposition":"schema_leaf","semantic_delta":"会创建企业班次;缺少隔离命名、稳定创建回执、精确详情读回和删除清理能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+update-class": {"disposition":"schema_leaf","semantic_delta":"会影响使用该班次的员工;缺少可恢复测试班次和严格更新后读回证据。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+update-group-members": {"disposition":"schema_leaf","semantic_delta":"会改变考勤组成员范围;缺少隔离成员 fixture、逐项结果和可验证回滚。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+create-group": {"disposition":"schema_leaf","semantic_delta":"会创建考勤组并影响打卡规则;缺少安全班次、成员、稳定回执和删除清理能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+update-group": {"disposition":"schema_leaf","semantic_delta":"会修改考勤组规则;当前无安全非空考勤组 fixture 和完整读回/恢复路径。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+update-leave-type": {"disposition":"schema_leaf","semantic_delta":"会修改企业假期规则并影响适用员工;缺少隔离假期类型和精确恢复路径。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+save-leave-balance": {"disposition":"schema_leaf","semantic_delta":"会直接改写员工假期余额;缺少隔离员工、幂等写入语义、余额读回和补偿恢复能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+boss-check": {"disposition":"schema_leaf","semantic_delta":"会直接改写打卡结果;缺少隔离排班/结果 fixture、强确认、精确终态读回和恢复能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true} + } +} diff --git a/internal/shortcut/semantic_catalog_coverage_test.go b/internal/shortcut/semantic_catalog_coverage_test.go index 61d00569..d555aced 100644 --- a/internal/shortcut/semantic_catalog_coverage_test.go +++ b/internal/shortcut/semantic_catalog_coverage_test.go @@ -121,6 +121,36 @@ func TestCrossPlatformCoverageSemanticCatalogRejectsInvalidRecords(t *testing.T) } } }`, + "compatibility-visible public": `{ + "version": 1, + "service": "chat", + "default_availability": "available", + "shortcuts": { + "+messages": { + "disposition": "semantic_adapter", + "semantic_delta": "reviewed", + "risk": "read", + "public": true, + "compatibility_visible": true, + "reviewed": true + } + } + }`, + "compatibility-visible available": `{ + "version": 1, + "service": "chat", + "default_availability": "available", + "shortcuts": { + "+messages": { + "disposition": "semantic_adapter", + "semantic_delta": "reviewed", + "risk": "read", + "public": false, + "compatibility_visible": true, + "reviewed": true + } + } + }`, } original := semanticCatalogJSON t.Cleanup(func() { semanticCatalogJSON = original }) @@ -172,6 +202,16 @@ func TestCrossPlatformCoveragePublicCatalogSemanticAndGeneratedLookups(t *testin } } +func TestCrossPlatformCoverageCompatibilityVisibleStaysNonPublic(t *testing.T) { + item, ok := applyReviewedSemanticCatalog(Shortcut{Service: "attendance", Command: "+get-summary"}) + if !ok || item.Hidden || !item.CompatibilityVisible || item.Availability != AvailabilityUnavailable { + t.Fatalf("compatibility-visible semantic item = %#v, found=%v", item, ok) + } + if InPublicCatalog(item.Service, item.Command) { + t.Fatal("compatibility-visible unavailable shortcut entered the public catalog") + } +} + func TestCrossPlatformCoverageRuntimeReadDataBranches(t *testing.T) { caller := &runtimeReadCoverageCaller{} old := helpers.GetCaller() diff --git a/internal/shortcut/semantic_catalog_mail.json b/internal/shortcut/semantic_catalog_mail.json new file mode 100644 index 00000000..8e3b260a --- /dev/null +++ b/internal/shortcut/semantic_catalog_mail.json @@ -0,0 +1,25 @@ +{ + "version": 1, + "service": "mail", + "default_availability": "unavailable", + "shortcuts": { + "+triage": {"disposition":"primary_smart","semantic_delta":"对齐 Lark triage 任务入口,并超过其显式邮箱要求:可自动解析当前邮箱与收件箱,严格处理邮件搜索的终止游标和零命中哨兵。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+search-mail": {"disposition":"semantic_adapter","semantic_delta":"用 KQL 搜索邮件摘要;只接受已观测 messages 数组、稳定 messageId 和完整分页证据,窄化支持 Mail 专属 total=0 占位哨兵。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+unread-mail": {"disposition":"primary_smart","semantic_delta":"固定 isRead:false 查询缺少可控 guaranteed-zero 条件;当前没有专用空邮箱 fixture,且不得修改真实邮件已读状态制造空结果,因此保持 unavailable。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+recent-mail": {"disposition":"primary_smart","semantic_delta":"严格读取 result.conversations、稳定会话 ID 与分页证据;但当前全部可见文件夹都有会话,缺少已验证空文件夹 fixture,无法完成双态发布审计。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+message": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 单封完整读入口;自动解析邮箱,要求顶层 message 非空且返回 ID 与请求 messageId 精确一致。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+messages": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 多封完整读入口;最多 100 个 ID,保持请求顺序,任何单封缺失、错型或身份不一致都会使整次调用失败。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+thread": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 完整会话读取;自动解析邮箱并要求 conversation.id 与请求精确一致。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+thread-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.conversations、稳定 ID 和分页;但当前全部可见文件夹都有会话,且时间过滤未提供可靠零命中,缺少已验证空文件夹 fixture。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+folder-list": {"disposition":"semantic_adapter","semantic_delta":"只接受显式 folders 数组及稳定 folder ID;缺字段、错型和坏元素不再退化为空列表。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+tag-list": {"disposition":"semantic_adapter","semantic_delta":"只接受显式 tags 数组及稳定 tag ID;但入口没有 query/parent 过滤且没有专用空邮箱 fixture,无法证明 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+user-search": {"disposition":"semantic_adapter","semantic_delta":"搜索企业邮箱用户并保留 hasMore/nextCursor;显式空数组合法,坏用户或缺稳定 ID 失败。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+find-mail-user": {"disposition":"primary_smart","semantic_delta":"面向联系人解析的精简用户搜索;修复零命中被误报为调用失败,并严格验证用户数组、身份和分页。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+template-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 templates 数组、稳定模板 ID 和 nextCursor;但入口没有 query,模板删除后也没有 typed nonfound,不能把列表消失当 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+contact-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 contacts 数组、稳定联系人 ID 和分页;但入口没有 query,且 create/delete 缺稳定身份闭环,无法安全构造 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+draft-create": {"disposition":"primary_smart","semantic_delta":"已实现确认、稳定 messageId 回执和精确读回;但真实 fixture 连续两次 batch-delete 后仍能按同 ID 读取,无法证明安全清理终态。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+draft-edit": {"disposition":"primary_smart","semantic_delta":"已实现至少一项变更、严格回执和按原 messageId 读回;但草稿删除后仍可读,缺少可隔离且可证明零残留的真实 fixture。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+template-create": {"disposition":"primary_smart","semantic_delta":"核心字段可按稳定模板 ID 读回,已移除无法验证的 from/isDraft 新入口;但 delete 后 get 仅返回未分类失败,没有 typed nonfound 证据可证明清理终态。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+template-update": {"disposition":"primary_smart","semantic_delta":"真实更新回执仅含明确 success,上层使用请求稳定 ID 精确读回核对变更;但模板删除后缺少 typed nonfound,无法证明安全 fixture 零残留。","risk":"write","availability":"unavailable","public":false,"reviewed":true} + } +} diff --git a/internal/shortcut/smart/attendance_strict.go b/internal/shortcut/smart/attendance_strict.go new file mode 100644 index 00000000..12ebddf8 --- /dev/null +++ b/internal/shortcut/smart/attendance_strict.go @@ -0,0 +1,62 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" +) + +func attendanceRecordsResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格校验的个人考勤打卡流水","properties":{"count":{"type":"integer","description":"有效打卡流水数量"},"records":{"type":"array","description":"个人打卡流水","items":{"type":"object","description":"打卡流水记录","additionalProperties":true}},"complete":{"type":"boolean","description":"当前时间窗内结果是否完整"}},"required":["count","records","complete"],"additionalProperties":false}`), + } +} + +func outputStrictAttendanceRecords(rt *shortcut.RuntimeContext, data map[string]any) error { + records, err := responsecheck.RequireObjectCollection(data, "attendance-wukong/query_check_record", "result") + if err != nil { + return err + } + seen := make(map[int64]struct{}, len(records)) + for index, record := range records { + identity, ok := smartAttendancePositiveInteger(record["id"]) + if !ok { + return responsecheck.Error("attendance-wukong/query_check_record", "invalid_item_identity", fmt.Sprintf("第 %d 项缺少大于 0 的稳定打卡 ID", index)) + } + if _, duplicate := seen[identity]; duplicate { + return responsecheck.Error("attendance-wukong/query_check_record", "duplicate_item_identity", fmt.Sprintf("第 %d 项稳定打卡 ID 重复", index)) + } + seen[identity] = struct{}{} + } + return rt.Output(map[string]any{ + "count": len(records), + "records": records, + "complete": true, + }) +} + +func smartAttendancePositiveInteger(value any) (int64, bool) { + switch number := value.(type) { + case int: + return int64(number), number > 0 + case int32: + return int64(number), number > 0 + case int64: + return number, number > 0 + case float64: + return int64(number), number > 0 && number == float64(int64(number)) + case json.Number: + parsed, err := strconv.ParseInt(string(number), 10, 64) + return parsed, err == nil && parsed > 0 + default: + return 0, false + } +} diff --git a/internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go b/internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..cb50b30c --- /dev/null +++ b/internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go @@ -0,0 +1,245 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +func TestCrossPlatformCoverageSmartAttendanceEmptyOnlyLeavesAreUnavailable(t *testing.T) { + for _, declaration := range []struct { + name string + rollout output.RolloutState + hasResult bool + availability string + }{ + {MyAttendance.Command, MyAttendance.OutputRollout, MyAttendance.Contract.Result != nil, MyAttendance.Contract.Interface.Availability}, + {ThisMonthAttendance.Command, ThisMonthAttendance.OutputRollout, ThisMonthAttendance.Contract.Result != nil, ThisMonthAttendance.Contract.Interface.Availability}, + } { + if declaration.rollout != output.RolloutLegacyOnly { + t.Errorf("%s rollout = %q, want legacy_only while unavailable", declaration.name, declaration.rollout) + } + if declaration.hasResult || declaration.availability != contract.InterfaceAvailable { + t.Errorf("%s must omit Result and preserve its Schema-compatible non-public Interface", declaration.name) + } + } +} + +func TestCrossPlatformCoverageSmartAttendanceProfileFailsClosed(t *testing.T) { + valid := map[string]any{"success": true, "result": []any{map[string]any{"orgEmployeeModel": map[string]any{"userId": "u1"}}}} + if got := strictAttendanceCurrentUserID(valid); got != "u1" { + t.Fatalf("valid profile identity = %q", got) + } + invalid := []map[string]any{ + nil, + {"result": map[string]any{"userId": "stale"}}, + {"success": false, "result": map[string]any{"userId": "stale"}}, + {"success": true, "result": nil}, + {"success": true, "result": []any{"malformed", map[string]any{"userId": "u1"}}}, + {"success": true, "result": []any{map[string]any{"userId": "u1"}, map[string]any{"userId": "u2"}}}, + {"success": true, "result": map[string]any{"userId": "u1", "orgEmployeeModel": map[string]any{"userId": "u2"}}}, + } + for index, profile := range invalid { + if got := strictAttendanceCurrentUserID(profile); got != "" { + t.Errorf("invalid profile %d resolved %q", index, got) + } + } +} + +type smartAttendanceProfileCaller struct { + profile string + attendance string + profileErr error + attendanceErr error + calls []string + arguments []map[string]any +} + +func (c *smartAttendanceProfileCaller) CallTool(_ context.Context, server, tool string, arguments map[string]any) (*edition.ToolResult, error) { + c.calls = append(c.calls, server+"/"+tool) + c.arguments = append(c.arguments, arguments) + text := c.profile + callErr := c.profileErr + if len(c.calls) > 1 { + text = c.attendance + callErr = c.attendanceErr + } + if callErr != nil { + return nil, callErr + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} +func (*smartAttendanceProfileCaller) Format() string { return "json" } +func (*smartAttendanceProfileCaller) DryRun() bool { return false } +func (*smartAttendanceProfileCaller) Fields() string { return "" } +func (*smartAttendanceProfileCaller) JQ() string { return "" } + +func TestCrossPlatformCoverageSmartAttendanceInvalidProfileMakesZeroAttendanceCalls(t *testing.T) { + caller := &smartAttendanceProfileCaller{} + helpers.InitDepsForTest(t, caller) + for _, declaration := range []*shortcut.Shortcut{&MyAttendance, &ThisMonthAttendance} { + caller.calls = nil + caller.arguments = nil + caller.profile = `{"success":false,"result":{"userId":"stale"}}` + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration) + if err := declaration.Execute(rt); err == nil { + t.Errorf("%s accepted success=false profile", declaration.Command) + } + if len(caller.calls) != 1 || !strings.HasSuffix(caller.calls[0], "/get_current_user_profile") { + t.Errorf("%s calls = %#v, want profile call only", declaration.Command, caller.calls) + } + } +} + +func TestCrossPlatformCoverageSmartAttendanceStrictResolverShapes(t *testing.T) { + valid := []map[string]any{ + {"success": true, "result": map[string]any{"userId": " u1 "}}, + {"success": true, "result": map[string]any{"orgEmployeeModel": map[string]any{"userId": "u1"}}}, + {"success": true, "result": map[string]any{"userId": "u1", "orgEmployeeModel": map[string]any{"userId": "u1"}}}, + {"success": true, "result": []any{map[string]any{"userId": "u1"}, map[string]any{"orgEmployeeModel": map[string]any{"userId": "u1"}}}}, + } + for index, profile := range valid { + if got := strictAttendanceCurrentUserID(profile); got != "u1" { + t.Errorf("valid profile %d resolved %q", index, got) + } + } + invalid := []map[string]any{ + {"success": true, "result": []any{}}, + {"success": true, "result": map[string]any{}}, + {"success": true, "result": map[string]any{"userId": 1}}, + {"success": true, "result": map[string]any{"userId": " "}}, + {"success": true, "result": map[string]any{"orgEmployeeModel": "bad"}}, + {"success": true, "result": map[string]any{"orgEmployeeModel": map[string]any{"userId": 1}}}, + {"success": true, "result": map[string]any{"orgEmployeeModel": map[string]any{"userId": " "}}}, + {"success": true, "result": true}, + } + for index, profile := range invalid { + if got := strictAttendanceCurrentUserID(profile); got != "" { + t.Errorf("invalid profile %d resolved %q", index, got) + } + } +} + +func TestCrossPlatformCoverageSmartAttendanceRecordIdentityFailsClosed(t *testing.T) { + for _, data := range []map[string]any{ + {"success": true, "result": []any{map[string]any{"id": 0}}}, + {"success": true, "result": []any{map[string]any{"id": ""}}}, + {"success": true, "result": []any{map[string]any{"id": 1}, map[string]any{"id": 1}}}, + } { + if records, err := responsecheck.RequireObjectCollection(data, "attendance/test", "result"); err == nil { + seen := map[int64]struct{}{} + valid := true + for _, record := range records { + identity, ok := smartAttendancePositiveInteger(record["id"]) + if !ok { + valid = false + break + } + if _, duplicate := seen[identity]; duplicate { + valid = false + break + } + seen[identity] = struct{}{} + } + if valid { + t.Fatalf("invalid records accepted: %#v", data) + } + } + } +} + +func TestCrossPlatformCoverageSmartAttendanceRecordOutputContract(t *testing.T) { + result := attendanceRecordsResult() + if result == nil || len(result.Outcomes) != 2 || !json.Valid(result.DataSchema) { + t.Fatalf("invalid strict attendance result declaration: %#v", result) + } + + for _, value := range []any{int(1), int32(2), int64(3), float64(4), json.Number("5")} { + if _, ok := smartAttendancePositiveInteger(value); !ok { + t.Errorf("valid record identity rejected: %#v", value) + } + } + for _, value := range []any{int(0), int32(-1), int64(0), float64(1.5), json.Number("bad"), "1", nil} { + if _, ok := smartAttendancePositiveInteger(value); ok { + t.Errorf("invalid record identity accepted: %#v", value) + } + } + + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "attendance-test"}, shortcut.Shortcut{}) + invalid := []map[string]any{ + nil, + {"success": false, "result": []any{}}, + {"success": true, "result": nil}, + {"success": true, "result": []any{"bad"}}, + {"success": true, "result": []any{map[string]any{"id": 0}}}, + {"success": true, "result": []any{map[string]any{"id": 1}, map[string]any{"id": 1}}}, + } + for index, data := range invalid { + if err := outputStrictAttendanceRecords(rt, data); err == nil { + t.Errorf("invalid strict output %d accepted", index) + } + } + if err := outputStrictAttendanceRecords(rt, map[string]any{"success": true, "result": []any{map[string]any{"id": int64(1)}}}); err != nil { + t.Fatalf("valid strict output rejected: %v", err) + } +} + +func TestCrossPlatformCoverageSmartAttendanceExecuteSuccessAndFailures(t *testing.T) { + validProfile := `{"success":true,"result":{"userId":"u1"}}` + validAttendance := `{"success":true,"result":[{"id":1}]}` + for _, declaration := range []*shortcut.Shortcut{&MyAttendance, &ThisMonthAttendance} { + t.Run(declaration.Command+" success", func(t *testing.T) { + caller := &smartAttendanceProfileCaller{profile: validProfile, attendance: validAttendance} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration) + if err := declaration.Execute(rt); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 2 || !strings.HasSuffix(caller.calls[1], "/query_check_record") { + t.Fatalf("calls=%#v", caller.calls) + } + request, ok := caller.arguments[1]["QueryCheckRecordRequest"].(map[string]any) + users, usersOK := request["userIds"].([]string) + if !ok || !usersOK || len(users) != 1 || users[0] != "u1" || strings.TrimSpace(fmt.Sprint(request["checkDateFrom"])) == "" || strings.TrimSpace(fmt.Sprint(request["checkDateTo"])) == "" { + t.Fatalf("request=%#v", caller.arguments[1]) + } + }) + t.Run(declaration.Command+" profile call error", func(t *testing.T) { + caller := &smartAttendanceProfileCaller{profileErr: fmt.Errorf("profile unavailable")} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration) + if err := declaration.Execute(rt); err == nil || len(caller.calls) != 1 { + t.Fatalf("err=%v calls=%#v", err, caller.calls) + } + }) + t.Run(declaration.Command+" attendance call error", func(t *testing.T) { + caller := &smartAttendanceProfileCaller{profile: validProfile, attendanceErr: fmt.Errorf("attendance unavailable")} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration) + if err := declaration.Execute(rt); err == nil || len(caller.calls) != 2 { + t.Fatalf("err=%v calls=%#v", err, caller.calls) + } + }) + t.Run(declaration.Command+" malformed attendance", func(t *testing.T) { + caller := &smartAttendanceProfileCaller{profile: validProfile, attendance: `{"success":true,"result":null}`} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration) + if err := declaration.Execute(rt); err == nil || len(caller.calls) != 2 { + t.Fatalf("err=%v calls=%#v", err, caller.calls) + } + }) + } +} diff --git a/internal/shortcut/smart/find_mail_user.go b/internal/shortcut/smart/find_mail_user.go index 98d2be5f..db2fe0d2 100644 --- a/internal/shortcut/smart/find_mail_user.go +++ b/internal/shortcut/smart/find_mail_user.go @@ -15,11 +15,11 @@ package smart import ( "strconv" + "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -71,6 +71,13 @@ var FindMailUser = shortcut.Shortcut{ PrimaryCLIPath: "mail +find-mail-user", }, Description: "按关键词搜索邮箱联系人并投影列表(姓名/昵称/邮箱/工号等)", + Parameters: []contract.ParamDecl{ + // Keep the published composite Shortcut property stable. Execute owns + // the explicit query -> keyword adapter for search_mail_users. + {Name: "query", Property: "query"}, + {Name: "limit", Property: "limit"}, + {Name: "cursor", Property: "cursor"}, + }, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -87,8 +94,19 @@ var FindMailUser = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词(姓名/花名/邮箱片段,必填)", Required: true}, - {Name: "limit", Type: shortcut.FlagInt, Desc: "返回条数上限(可选)", Required: false}, + {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词(姓名/花名/邮箱片段,必填且不能为空)", Required: true}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "返回条数上限(可选,1-100)", Required: false}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "不能为空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "1-100"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := smartMailValidateRequiredText(rt, "query"); err != nil { + return err + } + return smartMailValidatePageSize(rt, "limit", false) }, Tips: []string{ `dws mail +find-mail-user --query "张三"`, @@ -101,69 +119,109 @@ var FindMailUser = shortcut.Shortcut{ if rt.Changed("limit") { args["size"] = strconv.Itoa(rt.Int("limit")) } + if rt.Changed("cursor") { + args["cursor"] = rt.Str("cursor") + } data, err := rt.CallMCPData("mail", "search_mail_users", args) if err != nil { return err } // Step 2 — project matched users. - users := findMailUserUnwrap(data) + users, err := smartMailCollection(data, "mail/search_mail_users", "users") + if err != nil { + return err + } results := make([]map[string]any, 0, len(users)) for _, u := range users { - results = append(results, map[string]any{ - "name": findMailUserString(u, "name", "displayName", "userName", "nick"), - "nickname": findMailUserString(u, "nickname", "nick", "displayName"), - "email": findMailUserString(u, "email", "mail", "emailAddress", "address", "account"), - "employeeNo": findMailUserAny(u, "employeeNo", "employeeNumber", "jobNumber"), - "jobTitle": findMailUserString(u, "jobTitle", "title", "position"), - "workLocation": findMailUserString(u, "workLocation", "location", "workPlace"), - "id": findMailUserAny(u, "id", "userId", "userid"), - }) + projected, err := findMailUserProjection(u) + if err != nil { + return err + } + results = append(results, projected) } - // Step 3 — empty result guard. - if len(results) == 0 { - return apperrors.NewValidation("没搜到邮箱联系人") + complete, next, err := smartMailPage(data, "mail/search_mail_users", "", rt.Str("cursor")) + if err != nil { + return err } - - return rt.Output(map[string]any{"users": results, "count": len(results)}) + return smartMailOutputPage(rt, "users", results, complete, next) }, } -// findMailUserUnwrap extracts the user list from a search_mail_users response. -// The helper documents the list under "users"; we probe several container keys -// at the top level and one level deep, defensively. -func findMailUserUnwrap(data map[string]any) []map[string]any { - keys := []string{"users", "contacts", "result", "data", "list", "items", "records"} - for _, key := range keys { - if arr, ok := data[key].([]any); ok { - return findMailUserToMaps(arr) - } - if inner, ok := data[key].(map[string]any); ok { - for _, k2 := range keys { - if arr, ok := inner[k2].([]any); ok { - return findMailUserToMaps(arr) - } - } - } +func findMailUserProjection(user map[string]any) (map[string]any, error) { + result := map[string]any{ + "name": findMailUserString(user, "name", "displayName", "userName", "nick"), + "nickname": findMailUserString(user, "nickname", "nick", "displayName"), + "employeeNo": findMailUserAny(user, "employeeNo", "employeeNumber", "jobNumber"), + "jobTitle": findMailUserString(user, "jobTitle", "title", "position"), + "workLocation": findMailUserString(user, "workLocation", "location", "workPlace"), } - return nil + email, emailPresent, err := findMailUserIdentityString(user, "email", "mail", "emailAddress", "address", "account") + if err != nil { + return nil, err + } + if emailPresent { + result["email"] = email + } + id, idPresent, err := findMailUserIdentity(user, "id", "userId", "userid") + if err != nil { + return nil, err + } + if idPresent { + result["id"] = id + } + if !emailPresent && !idPresent { + return nil, smartMailError("mail/search_mail_users", "missing_item_identity", "邮箱用户缺少非空 id 或 email") + } + return result, nil } -func findMailUserToMaps(arr []any) []map[string]any { - out := make([]map[string]any, 0, len(arr)) - for _, it := range arr { - if m, ok := it.(map[string]any); ok { - out = append(out, m) +func findMailUserIdentityString(m map[string]any, keys ...string) (string, bool, error) { + for _, key := range keys { + value, present := m[key] + if !present || value == nil { + continue + } + text, ok := value.(string) + if !ok { + return "", false, smartMailError("mail/search_mail_users", "malformed_item_identity", "邮箱用户 email 必须是字符串") + } + text = strings.TrimSpace(text) + if text != "" { + return text, true, nil } } - return out + return "", false, nil +} + +func findMailUserIdentity(m map[string]any, keys ...string) (any, bool, error) { + for _, key := range keys { + value, present := m[key] + if !present || value == nil { + continue + } + if text, ok := value.(string); ok { + text = strings.TrimSpace(text) + if text != "" { + return text, true, nil + } + continue + } + switch value.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return value, true, nil + default: + return nil, false, smartMailError("mail/search_mail_users", "malformed_item_identity", "邮箱用户 id 必须是字符串或数字") + } + } + return nil, false, nil } func findMailUserString(m map[string]any, keys ...string) string { for _, key := range keys { - if s, ok := m[key].(string); ok && s != "" { - return s + if s, ok := m[key].(string); ok && strings.TrimSpace(s) != "" { + return strings.TrimSpace(s) } } return "" @@ -172,8 +230,11 @@ func findMailUserString(m map[string]any, keys ...string) string { func findMailUserAny(m map[string]any, keys ...string) any { for _, key := range keys { if v, ok := m[key]; ok && v != nil { - if s, isStr := v.(string); isStr && s == "" { - continue + if s, isStr := v.(string); isStr { + if strings.TrimSpace(s) == "" { + continue + } + return strings.TrimSpace(s) } return v } @@ -182,5 +243,6 @@ func findMailUserAny(m map[string]any, keys ...string) any { } func init() { + hardenSmartMail(&FindMailUser, "users", "严格校验的邮箱用户搜索结果") shortcut.Register(FindMailUser) } diff --git a/internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go b/internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go new file mode 100644 index 00000000..bd62a5f7 --- /dev/null +++ b/internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go @@ -0,0 +1,389 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "context" + "fmt" + "io" + "reflect" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func smartMailRuntimeForCoverage(t *testing.T, declaration shortcut.Shortcut, values map[string]string) *shortcut.RuntimeContext { + t.Helper() + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + return shortcut.RuntimeContextForTest(cmd, declaration) +} + +func TestCrossPlatformCoverageSmartMailStrictWireBranches(t *testing.T) { + for _, declaration := range []shortcut.Shortcut{SearchMail, FindMailUser} { + rt := smartMailRuntimeForCoverage(t, declaration, map[string]string{"query": " "}) + if err := declaration.Validate(rt); err == nil { + t.Fatal("direct required query validator accepted whitespace") + } + } + if err := smartMailSuccess(map[string]any{}, "mail/test"); err == nil { + t.Fatal("empty response unexpectedly succeeded") + } + if err := smartMailSuccess(map[string]any{"success": false}, "mail/test"); err == nil { + t.Fatal("explicit failure unexpectedly succeeded") + } + if err := smartMailSuccess(map[string]any{"success": "true"}, "mail/test"); err != nil { + t.Fatalf("string success rejected: %v", err) + } + if _, ok := smartMailLookup(map[string]any{"result": "bad"}, "result.items"); ok { + t.Fatal("non-object lookup unexpectedly succeeded") + } + if _, err := smartMailRows(map[string]any{}, "mail/test", "items", "id"); err == nil { + t.Fatal("rows accepted invalid response") + } + if _, err := smartMailRows(map[string]any{"success": true, "items": []any{map[string]any{"name": "missing id"}}}, "mail/test", "items", "id"); err == nil { + t.Fatal("rows accepted missing identity") + } + + items, err := smartMailSearchRows(map[string]any{"success": true, "messages": []any{}, "total": 0, "nextCursor": "$"}, "mail/search") + if err != nil || len(items) != 0 { + t.Fatalf("explicit empty search rejected: %v %v", items, err) + } + if _, err := smartMailSearchRows(map[string]any{"success": true, "messages": []any{}, "total": -1, "nextCursor": "$"}, "mail/search"); err == nil { + t.Fatal("negative total unexpectedly succeeded") + } + if _, err := smartMailSearchRows(map[string]any{"success": true, "messages": []any{map[string]any{"subject": "missing id"}}, "total": 1, "nextCursor": "$"}, "mail/search"); err == nil { + t.Fatal("search row without id unexpectedly succeeded") + } + if smartMailEmptySearchSentinel(map[string]any{}) { + t.Fatal("empty object treated as reviewed sentinel") + } + if !smartMailEmptySearchSentinel(map[string]any{"tags": nil}) { + t.Fatal("reviewed null-only sentinel rejected") + } + + for _, tc := range []struct { + value any + want int + ok bool + }{ + {value: 7, want: 7, ok: true}, + {value: float64(8), want: 8, ok: true}, + {value: 1.5, ok: false}, + {value: "9", want: 9, ok: true}, + {value: []any{}, ok: false}, + } { + got, ok := smartMailInt(tc.value) + if got != tc.want || ok != tc.ok { + t.Fatalf("smartMailInt(%#v)=(%d,%v), want (%d,%v)", tc.value, got, ok, tc.want, tc.ok) + } + } + + for name, data := range map[string]map[string]any{ + "missing prefix": {}, + "malformed prefix": {"result": []any{}}, + } { + t.Run(name, func(t *testing.T) { + if _, _, err := smartMailPage(data, "mail/page", "result", ""); err == nil { + t.Fatal("invalid prefixed page unexpectedly succeeded") + } + }) + } + for name, data := range map[string]map[string]any{ + "missing cursor": {}, + "wrong cursor": {"nextCursor": 7}, + "wrong hasMore": {"nextCursor": "cursor-2", "hasMore": []any{}}, + "conflicting true": {"nextCursor": "", "hasMore": true}, + "conflicting false": {"nextCursor": "cursor-2", "hasMore": false}, + } { + t.Run(name, func(t *testing.T) { + if _, _, err := smartMailPage(data, "mail/page", "", ""); err == nil { + t.Fatal("invalid page unexpectedly succeeded") + } + }) + } + if complete, next, err := smartMailPage(map[string]any{"nextCursor": "$"}, "mail/page", "", ""); err != nil || !complete || next != "" { + t.Fatalf("terminal page rejected: %v %q %v", complete, next, err) + } + if complete, next, err := smartMailPage(map[string]any{"nextCursor": "cursor-2"}, "mail/page", "", ""); err != nil || complete || next != "cursor-2" { + t.Fatalf("continuing page rejected: %v %q %v", complete, next, err) + } + if _, _, err := smartMailPage(map[string]any{"nextCursor": "cursor-1"}, "mail/page", "", "cursor-1"); err == nil { + t.Fatal("unstated hasMore repeated cursor unexpectedly succeeded") + } + if value, ok := smartMailBool("true"); !ok || !value { + t.Fatal("string true not normalized") + } + if value, ok := smartMailBool("false"); !ok || value { + t.Fatal("string false not normalized") + } + if _, ok := smartMailBool(7); ok { + t.Fatal("invalid boolean unexpectedly normalized") + } +} + +func TestCrossPlatformCoverageSmartMailSchemaAndOutputBoundaries(t *testing.T) { + assertPanic := func(name string, fn func()) { + t.Helper() + t.Run(name, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("unsupported schema did not panic") + } + }() + fn() + }) + } + assertPanic("sensitive collection", func() { smartMailSensitivePaths("unknown") }) + assertPanic("identity collection", func() { smartMailIdentitySchema("unknown") }) + + declaration := SearchMail + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + rt := shortcut.RuntimeContextForTest(cmd, declaration) + if err := smartMailOutputPage(rt, "messages", nil, true, "unexpected-next"); err == nil { + t.Fatal("invalid terminal pagination unexpectedly stored") + } +} + +func TestCrossPlatformCoverageSmartMailSenderAndProjectionVariants(t *testing.T) { + for _, tc := range []struct { + name string + data map[string]any + want string + }{ + {name: "top string", data: map[string]any{"success": true, "emailAccounts": []any{" mail@example.invalid "}}, want: "mail@example.invalid"}, + {name: "top object", data: map[string]any{"success": true, "emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}, want: "mail@example.invalid"}, + {name: "result string", data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}}, want: "mail@example.invalid"}, + {name: "result object", data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}}, want: "mail@example.invalid"}, + {name: "data string", data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}}, want: "mail@example.invalid"}, + {name: "data object", data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}}, want: "mail@example.invalid"}, + } { + t.Run("mailbox "+tc.name, func(t *testing.T) { + got, err := searchMailMailboxAddresses(tc.data) + if err != nil || len(got) != 1 || got[0] != tc.want { + t.Fatalf("addresses=%#v want=%q err=%v", got, tc.want, err) + } + }) + } + for name, data := range map[string]map[string]any{ + "remote failure": {"success": false}, + "missing": {"success": true}, + "malformed object": {"success": true, "result": map[string]any{"emailAccounts": []any{map[string]any{"email": 7}}}}, + "bad later item": {"success": true, "emailAccounts": []any{"mail@example.invalid", 7}}, + "multiple paths": {"success": true, "emailAccounts": []any{"one@example.invalid"}, "data": map[string]any{"emailAccounts": []any{"two@example.invalid"}}}, + } { + t.Run("mailbox "+name, func(t *testing.T) { + if _, err := searchMailMailboxAddresses(data); err == nil { + t.Fatal("invalid mailbox response unexpectedly succeeded") + } + }) + } + + for _, tc := range []struct { + message map[string]any + want any + }{ + {message: map[string]any{"from": "sender@example.invalid"}, want: "sender@example.invalid"}, + {message: map[string]any{"from": map[string]any{"name": "Sender", "email": "sender@example.invalid"}}, want: "Sender "}, + {message: map[string]any{"from": map[string]any{"email": "sender@example.invalid"}}, want: "sender@example.invalid"}, + {message: map[string]any{"from": map[string]any{"name": "Sender"}}, want: "Sender"}, + {message: map[string]any{"sender": "sender@example.invalid"}, want: "sender@example.invalid"}, + {message: map[string]any{"from": " ", "sender": "sender@example.invalid"}, want: "sender@example.invalid"}, + } { + got, err := searchMailFrom(tc.message) + if err != nil || got != tc.want { + t.Fatalf("sender=%#v want=%#v err=%v", got, tc.want, err) + } + } + for _, message := range []map[string]any{{}, {"from": " "}, {"from": nil}, {"from": map[string]any{}}, {"from": 7}} { + if _, err := searchMailFrom(message); err == nil { + t.Fatal("malformed sender unexpectedly succeeded") + } + } + if got := searchMailFirstString(map[string]any{"id": " stable-id "}, "id"); got != "stable-id" { + t.Fatalf("string normalization=%q", got) + } + if got := searchMailFirstAny(map[string]any{"first": " ", "second": " value "}, "first", "second"); got != "value" { + t.Fatalf("any string normalization=%#v", got) + } + if got := searchMailFirstAny(map[string]any{"value": 7}, "value"); got != 7 { + t.Fatalf("numeric projection=%#v", got) + } + + projected, err := findMailUserProjection(map[string]any{ + "id": " user-1 ", "email": " user@example.invalid ", "name": " User ", "nickname": " Nick ", + "employeeNo": " E1 ", "jobTitle": " Engineer ", "workLocation": " HQ ", + }) + if err != nil || projected["id"] != "user-1" || projected["email"] != "user@example.invalid" || projected["name"] != "User" || projected["employeeNo"] != "E1" { + t.Fatalf("normalized user projection=%#v err=%v", projected, err) + } + if got := findMailUserAny(map[string]any{"first": " ", "second": 7}, "first", "second"); got != 7 { + t.Fatalf("find user any=%#v", got) + } + if got := findMailUserAny(map[string]any{}, "missing"); got != nil { + t.Fatalf("missing optional value=%#v", got) + } +} + +func TestCrossPlatformCoverageSmartMailLeafFailureBoundaries(t *testing.T) { + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + args []string + responses map[string]string + errors map[string]error + wantCalls int + }{ + {name: "search mailbox malformed", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":{}}`}, wantCalls: 1}, + {name: "search mailbox blank string", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":[" "]}`}, wantCalls: 1}, + {name: "search mailbox malformed item", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":[7]}`}, wantCalls: 1}, + {name: "search mailbox empty", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":[]}`}, wantCalls: 1}, + {name: "search rows malformed", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":{}}`}, wantCalls: 1}, + {name: "search sender malformed", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":[]}],"hasMore":false,"nextCursor":""}`}, wantCalls: 1}, + {name: "search page missing", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}]}`}, wantCalls: 1}, + {name: "find rows malformed", declaration: FindMailUser, args: []string{"--query", "fixture"}, responses: map[string]string{"search_mail_users": `{"success":true,"users":{}}`}, wantCalls: 1}, + {name: "find page missing", declaration: FindMailUser, args: []string{"--query", "fixture"}, responses: map[string]string{"search_mail_users": `{"success":true,"users":[{"id":"user-1"}]}`}, wantCalls: 1}, + {name: "triage inbox folders malformed", declaration: TriageMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"list_folders": `{"success":true,"folders":{}}`}, wantCalls: 1}, + {name: "triage rows malformed", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":{}}`}, wantCalls: 1}, + {name: "triage sender malformed", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":[]}],"hasMore":false,"nextCursor":""}`}, wantCalls: 1}, + {name: "triage page missing", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}]}`}, wantCalls: 1}, + {name: "triage remote failure", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{}, errors: map[string]error{"search_emails": fmt.Errorf("search transport")}, wantCalls: 1}, + {name: "triage mailbox failure", declaration: TriageMail, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":{}}`}, wantCalls: 1}, + {name: "recent rows malformed", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1"}, responses: map[string]string{"list_mailbox_threads": `{"success":true,"result":{"conversations":{}}}`}, wantCalls: 1}, + {name: "recent sender malformed", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1"}, responses: map[string]string{"list_mailbox_threads": `{"success":true,"result":{"conversations":[{"id":"thread-1","senders":[{}]}]}}`}, wantCalls: 1}, + {name: "recent page malformed", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1"}, responses: map[string]string{"list_mailbox_threads": `{"success":true,"result":{"conversations":[{"id":"thread-1","senders":[{"email":"sender@example.invalid"}]}],"nextCursor":7}}`}, wantCalls: 1}, + {name: "unread rows malformed", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"search_emails": `{"success":true,"messages":{}}`}, wantCalls: 1}, + {name: "unread sender malformed", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":[]}],"hasMore":false,"nextCursor":""}`}, wantCalls: 1}, + {name: "unread page missing", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}]}`}, wantCalls: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: tc.responses, errors: tc.errors} + if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err == nil { + t.Fatal("invalid leaf response unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} + +func TestCrossPlatformCoverageSmartMailInboxAndSenderFallbacks(t *testing.T) { + for _, tc := range []struct { + name string + message map[string]any + want any + wantErr bool + }{ + {name: "name and address", message: map[string]any{"senders": []any{map[string]any{"name": "Sender", "email": "sender@example.invalid"}}}, want: []string{"Sender "}}, + {name: "address only", message: map[string]any{"senders": []any{map[string]any{"email": "sender@example.invalid"}}}, want: []string{"sender@example.invalid"}}, + {name: "name only", message: map[string]any{"senders": []any{map[string]any{"name": "Sender"}}}, want: []string{"Sender"}}, + {name: "wrong sender collection", message: map[string]any{"senders": "bad"}, wantErr: true}, + {name: "fallback sender", message: map[string]any{"senders": []any{}, "from": "sender@example.invalid"}, want: "sender@example.invalid"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := recentMailSenders(tc.message) + if tc.wantErr && err == nil { + t.Fatal("malformed senders unexpectedly succeeded") + } + if !tc.wantErr && err != nil { + t.Fatalf("valid senders failed: %v", err) + } + if !tc.wantErr && !reflect.DeepEqual(got, tc.want) { + t.Fatalf("senders=%#v want=%#v", got, tc.want) + } + }) + } + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + args []string + responses map[string]string + wantErr bool + wantCalls int + }{ + { + name: "triage resolves mailbox and inbox", declaration: TriageMail, + responses: map[string]string{ + "list_user_mailboxes": `{"success":true,"emailAccounts":[{"email":"mail@example.invalid"}]}`, + "list_folders": `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`, + "search_emails": `{"success":true,"messages":[],"total":0,"hasMore":false,"nextCursor":""}`, + }, wantCalls: 3, + }, + { + name: "search resolves wrapped string mailbox", declaration: SearchMail, args: []string{"--query", "fixture"}, + responses: map[string]string{ + "list_user_mailboxes": `{"success":true,"result":{"emailAccounts":["mail@example.invalid"]}}`, + "search_emails": `{"success":true,"messages":[],"total":0,"hasMore":false,"nextCursor":""}`, + }, wantCalls: 2, + }, + { + name: "triage resolves string mailbox and inbox", declaration: TriageMail, + responses: map[string]string{ + "list_user_mailboxes": `{"success":true,"emailAccounts":["mail@example.invalid"]}`, + "list_folders": `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`, + "search_emails": `{"success":true,"messages":[],"total":0,"hasMore":false,"nextCursor":""}`, + }, wantCalls: 3, + }, + { + name: "recent resolves inbox", declaration: RecentMail, args: []string{"--email", "mail@example.invalid"}, + responses: map[string]string{ + "list_folders": `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`, + "list_mailbox_threads": `{"success":true,"result":{"conversations":[],"hasMore":false,"nextCursor":""}}`, + }, wantCalls: 2, + }, + { + name: "inbox not found", declaration: TriageMail, args: []string{"--email", "mail@example.invalid"}, + responses: map[string]string{"list_folders": `{"success":true,"folders":[{"id":"folder-1","displayName":"Archive"}]}`}, + wantErr: true, wantCalls: 1, + }, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: tc.responses} + err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...) + if tc.wantErr && err == nil { + t.Fatal("invalid inbox resolution unexpectedly succeeded") + } + if !tc.wantErr && err != nil { + t.Fatalf("valid inbox resolution failed: %v", err) + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } + + for _, tc := range []struct { + name string + response string + reason string + }{ + {name: "blank sender", response: `{"success":true,"messages":[{"id":"message-1","from":" "}],"hasMore":false,"nextCursor":""}`, reason: "malformed_sender"}, + {name: "missing sender", response: `{"success":true,"messages":[{"id":"message-1"}],"hasMore":false,"nextCursor":""}`, reason: "missing_sender"}, + } { + t.Run(tc.name+" typed reason", func(t *testing.T) { + caller := &smartMailCursorCaller{responses: map[string]string{"search_emails": tc.response}} + err := runSmartMailDeclaration(t, SearchMail, caller, "--email", "mail@example.invalid", "--query", "fixture") + typed, ok := err.(*apperrors.Error) + if err == nil || !ok || typed.Reason != tc.reason || len(caller.calls) != 1 { + reason := "" + if ok { + reason = typed.Reason + } + t.Fatalf("err=%v reason=%q calls=%d", err, reason, len(caller.calls)) + } + }) + } +} diff --git a/internal/shortcut/smart/mail_space_todo_projection_test.go b/internal/shortcut/smart/mail_space_todo_projection_test.go index ba41a90c..fd18632e 100644 --- a/internal/shortcut/smart/mail_space_todo_projection_test.go +++ b/internal/shortcut/smart/mail_space_todo_projection_test.go @@ -31,34 +31,6 @@ import ( var errStubTool = errors.New("stub tool error") -// TestSearchMailPickMailbox covers every list_user_mailboxes shape: objects with -// an email field and bare string arrays, each at the top level or one level -// under a result/data wrapper. The nested bare-string case regressed silently -// (searchMailToMaps drops strings), making all smart mail shortcuts wrongly -// demand an explicit --email. -func TestSearchMailPickMailbox(t *testing.T) { - cases := []struct { - name, raw, want string - }{ - {"object top-level", `{"emailAccounts":[{"email":"a@x.com"}]}`, "a@x.com"}, - {"object under result", `{"result":{"emailAccounts":[{"email":"b@x.com"}]}}`, "b@x.com"}, - {"bare string top-level", `{"emailAccounts":["c@x.com"]}`, "c@x.com"}, - {"bare string under result", `{"result":{"emailAccounts":["d@x.com"]}}`, "d@x.com"}, - {"empty", `{"result":{"emailAccounts":[]}}`, ""}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - var data map[string]any - if err := json.Unmarshal([]byte(tc.raw), &data); err != nil { - t.Fatal(err) - } - if got := searchMailPickMailbox(data); got != tc.want { - t.Fatalf("searchMailPickMailbox(%s) = %q, want %q", tc.raw, got, tc.want) - } - }) - } -} - // TestResolveSpaceItemsWikiSpaces guards that search_wikiSpaces' result.wikiSpaces // container is probed — otherwise +resolve-space silently finds no candidates. func TestResolveSpaceItemsWikiSpaces(t *testing.T) { @@ -142,7 +114,7 @@ func runShortcutErr(t *testing.T, fake *stubMailboxCaller, argv ...string) error // "provide --email" validation error rather than silently proceeding. func TestSmartSearchMailNoMailbox(t *testing.T) { fake := &stubMailboxCaller{byTool: map[string]string{ - "list_user_mailboxes": `{"result":{"emailAccounts":[]}}`, + "list_user_mailboxes": `{"success":"true","emailAccounts":[]}`, }} err := runShortcutErr(t, fake, "mail", "+search-mail", "--query", "x", "--format", "json") if err == nil || !strings.Contains(err.Error(), "未找到可用邮箱") { @@ -200,8 +172,8 @@ func TestCreatedTodosProjectsCards(t *testing.T) { // the no-mailbox validation error because emailAccounts was not probed. func TestSmartSearchMailAutoResolvesMailbox(t *testing.T) { fake := &stubMailboxCaller{byTool: map[string]string{ - "list_user_mailboxes": `{"emailAccounts":[{"email":"me@example.com"}]}`, - "search_emails": `{"result":{"messages":[{"subject":"weekly report","from":"a@x.com"}]}}`, + "list_user_mailboxes": `{"success":"true","emailAccounts":[{"email":"me@example.com"}]}`, + "search_emails": `{"success":"true","messages":[{"id":"message-1","subject":"weekly report","from":"a@x.com"}],"nextCursor":""}`, }} out := runShortcut(t, fake, "mail", "+search-mail", "--query", "subject:weekly report", "--format", "json") if strings.Contains(out, "未找到可用邮箱") || out == "" { diff --git a/internal/shortcut/smart/mail_strict.go b/internal/shortcut/smart/mail_strict.go new file mode 100644 index 00000000..0240bbff --- /dev/null +++ b/internal/shortcut/smart/mail_strict.go @@ -0,0 +1,340 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func smartMailError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +func smartMailValidatePageSize(rt *shortcut.RuntimeContext, flag string, required bool) error { + if !required && !rt.Changed(flag) { + return nil + } + value := rt.Int(flag) + if value < 1 || value > 100 { + return apperrors.NewValidation(fmt.Sprintf("--%s 必须在 1-100 之间", flag)) + } + return nil +} + +func smartMailStringPageSize(rt *shortcut.RuntimeContext, flag, fallback string) (string, error) { + if !rt.Changed(flag) { + return fallback, nil + } + value, err := strconv.Atoi(rt.Str(flag)) + if err != nil || value < 1 || value > 100 { + return "", apperrors.NewValidation(fmt.Sprintf("--%s 必须是 1-100 之间的整数", flag)) + } + return strconv.Itoa(value), nil +} + +func smartMailValidateStringPageSize(rt *shortcut.RuntimeContext, flag string) error { + _, err := smartMailStringPageSize(rt, flag, "") + return err +} + +func smartMailValidateRequiredText(rt *shortcut.RuntimeContext, flag string) error { + if strings.TrimSpace(rt.Str(flag)) == "" { + return apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", flag)) + } + return nil +} + +func smartMailSuccess(data map[string]any, operation string) error { + if len(data) == 0 { + return smartMailError(operation, "empty_tool_response", "服务返回空响应,无法证明结果确实为空") + } + switch value := data["success"].(type) { + case bool: + if value { + return nil + } + case string: + if value == "true" { + return nil + } + } + if _, present := data["success"]; !present { + return smartMailError(operation, "missing_success", "响应缺少 success 业务状态") + } + return smartMailError(operation, "remote_failure", "服务未明确返回成功状态") +} + +func smartMailLookup(data map[string]any, path string) (any, bool) { + var current any = data + for _, segment := range strings.Split(path, ".") { + object, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = object[segment] + if !ok { + return nil, false + } + } + return current, true +} + +func smartMailCollection(data map[string]any, operation, path string) ([]map[string]any, error) { + if err := smartMailSuccess(data, operation); err != nil { + return nil, err + } + value, present := smartMailLookup(data, path) + if !present { + return nil, smartMailError(operation, "missing_collection", fmt.Sprintf("成功响应缺少 %s 数组;不能把未知响应结构当作空结果", path)) + } + raw, ok := value.([]any) + if !ok { + return nil, smartMailError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value)) + } + items := make([]map[string]any, 0, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, smartMailError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index)) + } + items = append(items, object) + } + return items, nil +} + +func smartMailRows(data map[string]any, operation, path string, identityKeys ...string) ([]map[string]any, error) { + items, err := smartMailCollection(data, operation, path) + if err != nil { + return nil, err + } + for index, item := range items { + if searchMailFirstString(item, identityKeys...) == "" { + return nil, smartMailError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index)) + } + } + return items, nil +} + +// smartMailSearchRows handles the one reviewed Mail-specific zero-hit wire +// shape observed from search_emails: total="0", terminal nextCursor="$", and +// exactly one placeholder whose only fields are null recipient metadata. It is +// deliberately narrower than a general "drop bad item" rule. +func smartMailSearchRows(data map[string]any, operation string) ([]map[string]any, error) { + items, err := smartMailCollection(data, operation, "messages") + if err != nil { + return nil, err + } + total, totalOK := smartMailInt(data["total"]) + next, nextOK := data["nextCursor"].(string) + if totalOK && total == 0 && nextOK && (next == "" || next == "$") { + if len(items) == 0 { + return items, nil + } + if len(items) == 1 && smartMailEmptySearchSentinel(items[0]) { + return []map[string]any{}, nil + } + return nil, smartMailError(operation, "conflicting_empty_result", "total=0 的搜索响应包含真实或未审核的消息对象") + } + if totalOK && total < 0 { + return nil, smartMailError(operation, "invalid_total", "邮件搜索 total 不能为负数") + } + for index, item := range items { + if searchMailFirstString(item, "id") == "" { + return nil, smartMailError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index)) + } + } + return items, nil +} + +func smartMailEmptySearchSentinel(item map[string]any) bool { + if len(item) == 0 { + return false + } + allowed := map[string]bool{"toRecipients": true, "ccRecipients": true, "bccRecipients": true, "replyTo": true, "tags": true} + for key, value := range item { + if !allowed[key] || value != nil { + return false + } + } + return true +} + +func smartMailInt(value any) (int, bool) { + switch typed := value.(type) { + case int: + return typed, true + case float64: + if typed != float64(int(typed)) { + return 0, false + } + return int(typed), true + case string: + parsed, err := strconv.Atoi(typed) + return parsed, err == nil + default: + return 0, false + } +} + +func smartMailPage(data map[string]any, operation, prefix, currentCursor string) (bool, string, error) { + container := data + if prefix != "" { + value, present := smartMailLookup(data, prefix) + if !present { + return false, "", smartMailError(operation, "missing_pagination", "响应缺少分页对象") + } + var ok bool + container, ok = value.(map[string]any) + if !ok { + return false, "", smartMailError(operation, "malformed_pagination", "分页对象类型错误") + } + } + raw, present := container["nextCursor"] + next, ok := raw.(string) + if !present || !ok { + return false, "", smartMailError(operation, "missing_pagination", "响应缺少字符串 nextCursor") + } + if rawMore, present := container["hasMore"]; present { + hasMore, ok := smartMailBool(rawMore) + if !ok { + return false, "", smartMailError(operation, "malformed_pagination", "hasMore 应为布尔值") + } + terminal := strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" + if hasMore == terminal { + return false, "", smartMailError(operation, "conflicting_pagination", "hasMore 与 nextCursor 互相矛盾") + } + if !hasMore { + return true, "", nil + } + if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) { + return false, "", smartMailError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor") + } + return false, next, nil + } + if strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" { + return true, "", nil + } + if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) { + return false, "", smartMailError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor") + } + return false, next, nil +} + +func smartMailBool(value any) (bool, bool) { + switch typed := value.(type) { + case bool: + return typed, true + case string: + if typed == "true" { + return true, true + } + if typed == "false" { + return false, true + } + } + return false, false +} + +func smartMailResult(collection, description string) *contract.ResultSpec { + identity := smartMailIdentitySchema(collection) + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + SensitivePaths: smartMailSensitivePaths(collection), + DataSchema: json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格校验后的邮件记录",%s,"additionalProperties":true}}},"required":["count",%q],"additionalProperties":false}`, + description, collection, description, identity, collection)), + } +} + +func smartMailSensitivePaths(collection string) []string { + switch collection { + case "messages": + return []string{"messages.subject", "messages.from", "messages.date"} + case "mails": + return []string{"mails.subject", "mails.from", "mails.date"} + case "users": + return []string{"users.name", "users.nickname", "users.email", "users.employeeNo", "users.jobTitle", "users.workLocation"} + default: + panic("unsupported smart Mail sensitive paths: " + collection) + } +} + +func smartMailIdentitySchema(collection string) string { + switch collection { + case "messages": + return `"properties":{"messageId":{"type":"string","minLength":1,"description":"稳定邮件 ID"}},"required":["messageId"]` + case "mails": + return `"properties":{"threadId":{"type":"string","minLength":1,"description":"稳定邮件会话 ID"}},"required":["threadId"]` + case "users": + return `"properties":{"id":{"type":["string","number"],"description":"稳定邮箱用户 ID"},"email":{"type":"string","minLength":1,"description":"稳定邮箱地址身份"}},"anyOf":[{"required":["id"]},{"required":["email"]}]` + default: + panic("unsupported smart Mail Result identity: " + collection) + } +} + +func smartMailCursorPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: "cursor", + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +func hardenSmartMail(declaration *shortcut.Shortcut, collection, description string) { + declaration.OutputRollout = output.RolloutUnifiedActive + declaration.Contract.Result = smartMailResult(collection, description) + declaration.Contract.Pagination = smartMailCursorPagination() + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, + Reason: "Reviewed Mail Shortcut composite: strict success, collection, stable-ID and pagination validation are owned by the executable CLI.", + } +} + +func markSmartMailCompatibilityOnly(declaration *shortcut.Shortcut) { + declaration.OutputRollout = output.RolloutLegacyOnly + declaration.Contract.Result = nil + declaration.Contract.Pagination = nil + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until its live-data proof gap is closed.", + } +} + +func smartMailPayload(collection string, items []map[string]any, complete bool, next string) map[string]any { + payload := map[string]any{"count": len(items), collection: items, "complete": complete} + if !complete { + payload["nextCursor"] = next + } + return payload +} + +func smartMailOutputPage(rt *shortcut.RuntimeContext, collection string, items []map[string]any, complete bool, next string) error { + payload := smartMailPayload(collection, items, complete, next) + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + pagination, err := output.NewPagination(complete, next) + if err != nil { + return smartMailError("mail/pagination", "invalid_pagination", err.Error()) + } + business := map[string]any{"count": len(items), collection: items} + meta := &output.Meta{Pagination: pagination, Count: output.NewCount(len(items))} + return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta))) +} diff --git a/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go b/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..14022542 --- /dev/null +++ b/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go @@ -0,0 +1,405 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "maps" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type smartMailCursorCall struct { + tool string + args map[string]any +} + +type smartMailCursorCaller struct { + responses map[string]string + errors map[string]error + calls []smartMailCursorCall +} + +func (caller *smartMailCursorCaller) CallTool(_ context.Context, _ string, tool string, args map[string]any) (*edition.ToolResult, error) { + caller.calls = append(caller.calls, smartMailCursorCall{tool: tool, args: maps.Clone(args)}) + if err := caller.errors[tool]; err != nil { + return nil, err + } + text, ok := caller.responses[tool] + if !ok { + return nil, fmt.Errorf("unexpected smart Mail tool: %s", tool) + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} + +func (*smartMailCursorCaller) Format() string { return "json" } +func (*smartMailCursorCaller) DryRun() bool { return false } +func (*smartMailCursorCaller) Fields() string { return "" } +func (*smartMailCursorCaller) JQ() string { return "" } + +func runSmartMailDeclaration(t *testing.T, declaration shortcut.Shortcut, caller *smartMailCursorCaller, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd.Execute() +} + +func TestCrossPlatformCoverageSmartMailContractsAreUnifiedAndTyped(t *testing.T) { + for _, declaration := range []struct { + name string + rollout output.RolloutState + result bool + }{ + {"search", SearchMail.OutputRollout, SearchMail.Contract.Result != nil}, + {"find-user", FindMailUser.OutputRollout, FindMailUser.Contract.Result != nil}, + {"triage", TriageMail.OutputRollout, TriageMail.Contract.Result != nil}, + } { + if declaration.rollout != output.RolloutUnifiedActive || !declaration.result { + t.Fatalf("%s is not unified with Result", declaration.name) + } + } + for _, declaration := range []*shortcut.Shortcut{&SearchMail, &FindMailUser, &TriageMail} { + if declaration.Contract.Pagination == nil || declaration.Contract.Pagination.CursorParameter != "cursor" { + t.Fatalf("%s missing cursor Pagination", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageSmartMailCompatibilityReadsMatchRuntimeBoundary(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&UnreadMail, &RecentMail} { + if declaration.OutputRollout != output.RolloutLegacyOnly || declaration.Contract.Result != nil || declaration.Contract.Pagination != nil { + t.Errorf("%s unavailable runtime still publishes unified result/pagination", declaration.Command) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != contract.InterfaceAvailable || declaration.Contract.Interface.Reason == "" { + t.Errorf("%s missing Schema-compatible non-public interface", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageSmartMailHistoricalStringSizeAndRuntimeDefault(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&SearchMail, &UnreadMail} { + found := false + for _, flag := range declaration.Flags { + if flag.Name != "size" { + continue + } + found = true + if flag.Type != shortcut.FlagString || flag.Default != "" { + t.Fatalf("%s --size type/default=%q/%q, want string/empty", declaration.Command, flag.Type, flag.Default) + } + } + if !found { + t.Fatalf("%s missing --size", declaration.Command) + } + cmd := corecmd.New(shortcut.FromShortcut(*declaration)) + if flag := cmd.Flags().Lookup("size"); flag == nil || flag.Value.Type() != "string" || flag.DefValue != "" { + t.Fatalf("%s Cobra --size=%#v, want historical string with empty default", declaration.Command, flag) + } + } + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + args []string + wantSize string + }{ + {name: "search default", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, wantSize: "20"}, + {name: "search explicit", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "100"}, wantSize: "100"}, + {name: "unread default", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, wantSize: "20"}, + {name: "unread explicit", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "1"}, wantSize: "1"}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: map[string]string{"search_emails": `{"success":true,"messages":[],"hasMore":false,"nextCursor":""}`}} + if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err != nil { + t.Fatalf("compatible execution failed: %v", err) + } + if len(caller.calls) != 1 || caller.calls[0].args["size"] != tc.wantSize { + t.Fatalf("calls=%#v, want one call with size=%q", caller.calls, tc.wantSize) + } + }) + } + + for _, declaration := range []shortcut.Shortcut{SearchMail, UnreadMail} { + caller := &smartMailCursorCaller{responses: map[string]string{}} + helpers.InitDepsForTest(t, caller) + values := map[string]string{"email": "mail@example.invalid", "size": "bad"} + if declaration.Command == "+search-mail" { + values["query"] = "fixture" + } + rt := smartMailRuntimeForCoverage(t, declaration, values) + if err := declaration.Execute(rt); err == nil { + t.Fatalf("%s direct Execute accepted malformed string size", declaration.Command) + } + if len(caller.calls) != 0 { + t.Fatalf("%s direct Execute reached %d remote calls", declaration.Command, len(caller.calls)) + } + } +} + +func TestCrossPlatformCoverageSmartMailZeroHitSentinelIsNarrow(t *testing.T) { + fixture := map[string]any{ + "success": "true", "total": "0", "nextCursor": "$", + "messages": []any{map[string]any{"ccRecipients": nil, "toRecipients": nil}}, + } + items, err := smartMailSearchRows(fixture, "mail/search_emails") + if err != nil || len(items) != 0 { + t.Fatalf("reviewed zero-hit sentinel must normalize to empty: items=%v err=%v", items, err) + } + for name, bad := range map[string]map[string]any{ + "unexpected field": {"success": "true", "total": "0", "nextCursor": "$", "messages": []any{map[string]any{"subject": nil}}}, + "non-null field": {"success": "true", "total": "0", "nextCursor": "$", "messages": []any{map[string]any{"ccRecipients": "x"}}}, + "multiple rows": {"success": "true", "total": "0", "nextCursor": "$", "messages": []any{map[string]any{"ccRecipients": nil}, map[string]any{"toRecipients": nil}}}, + } { + if _, err := smartMailSearchRows(bad, "mail/search_emails"); err == nil { + t.Fatalf("%s must fail closed", name) + } + } +} + +func TestCrossPlatformCoverageSmartMailMalformedCollectionsFail(t *testing.T) { + for name, fixture := range map[string]map[string]any{ + "missing success": {"messages": []any{}}, + "missing collection": {"success": "true", "total": "0", "nextCursor": "$"}, + "wrong type": {"success": "true", "messages": map[string]any{}}, + "bad item": {"success": "true", "messages": []any{"bad"}}, + } { + if _, err := smartMailCollection(fixture, "mail/search_emails", "messages"); err == nil { + t.Fatalf("%s must fail closed", name) + } + } +} + +func TestCrossPlatformCoverageSmartMailNestedSendersFailClosed(t *testing.T) { + if _, err := searchMailFrom(map[string]any{"from": []any{"bad"}}); err == nil { + t.Fatal("malformed message from field must fail") + } + if _, err := recentMailSenders(map[string]any{"senders": []any{"bad"}}); err == nil { + t.Fatal("non-object conversation sender must fail") + } + if _, err := recentMailSenders(map[string]any{"senders": []any{map[string]any{"name": ""}}}); err == nil { + t.Fatal("sender without identity must fail") + } +} + +func TestCrossPlatformCoverageSmartMailPaginationOutputAndIdentitySchema(t *testing.T) { + declaration := SearchMail + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + rt := shortcut.RuntimeContextForTest(cmd, declaration) + if err := smartMailOutputPage(rt, "messages", []map[string]any{{"messageId": "message-1"}}, false, "cursor-2"); err != nil { + t.Fatal(err) + } + if code, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted || code != 0 { + t.Fatalf("emit code=%d emitted=%v err=%v", code, emitted, err) + } + var envelope struct { + Data map[string]any `json:"data"` + Meta struct { + Pagination *struct { + EndpointExhausted bool `json:"endpoint_exhausted"` + NextToken string `json:"next_token"` + } `json:"pagination"` + } `json:"meta"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + for _, field := range []string{"complete", "hasMore", "nextCursor"} { + if _, exists := envelope.Data[field]; exists { + t.Fatalf("pagination field %s leaked into business data", field) + } + } + if envelope.Meta.Pagination == nil || envelope.Meta.Pagination.EndpointExhausted || envelope.Meta.Pagination.NextToken != "cursor-2" { + t.Fatalf("pagination=%+v", envelope.Meta.Pagination) + } + + for _, tc := range []struct { + declaration *shortcut.Shortcut + collection string + identity string + }{ + {&SearchMail, "messages", "messageId"}, + {&TriageMail, "messages", "messageId"}, + {&FindMailUser, "users", "email"}, + } { + if len(tc.declaration.Contract.Result.SensitivePaths) == 0 { + t.Fatalf("%s missing sensitive_paths", tc.declaration.Command) + } + var schema map[string]any + if err := json.Unmarshal(tc.declaration.Contract.Result.DataSchema, &schema); err != nil { + t.Fatalf("%s schema: %v", tc.declaration.Command, err) + } + properties := schema["properties"].(map[string]any) + if minimum := properties["count"].(map[string]any)["minimum"]; minimum != float64(0) { + t.Fatalf("%s count minimum=%v, want 0", tc.declaration.Command, minimum) + } + items := properties[tc.collection].(map[string]any)["items"].(map[string]any) + identity := items["properties"].(map[string]any)[tc.identity].(map[string]any) + if identity["description"] == "" { + t.Fatalf("%s missing stable identity", tc.declaration.Command) + } + } +} + +func TestCrossPlatformCoverageSmartMailCursorPassthroughAndStall(t *testing.T) { + tests := []struct { + name string + declaration shortcut.Shortcut + args []string + tool string + response string + }{ + {name: "search", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "20", "--cursor", "cursor-1"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "unread", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "20", "--cursor", "cursor-1"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "triage", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--limit", "20", "--cursor", "cursor-1"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "find user", declaration: FindMailUser, args: []string{"--query", "fixture", "--limit", "20", "--cursor", "cursor-1"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "recent", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":[{"id":"thread-1","senders":[{"email":"sender@example.invalid"}]}],"hasMore":true,"nextCursor":"cursor-2"}}`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: map[string]string{tc.tool: tc.response}} + if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 || caller.calls[0].args["cursor"] != "cursor-1" { + t.Fatalf("cursor not passed through: %#v", caller.calls) + } + }) + } + caller := &smartMailCursorCaller{responses: map[string]string{"search_mail_users": `{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-1"}`}} + if err := runSmartMailDeclaration(t, FindMailUser, caller, "--query", "fixture", "--cursor", "cursor-1"); err == nil { + t.Fatal("repeated cursor unexpectedly succeeded") + } +} + +func TestCrossPlatformCoverageSmartMailLimitValidationHasZeroCalls(t *testing.T) { + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + args []string + }{ + {name: "search blank", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", " "}}, + {name: "search nonnumeric", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "bad"}}, + {name: "search zero", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "0"}}, + {name: "search negative", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "-1"}}, + {name: "search high", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "101"}}, + {name: "unread blank", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", " "}}, + {name: "unread nonnumeric", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "bad"}}, + {name: "unread zero", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "0"}}, + {name: "unread negative", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "-1"}}, + {name: "unread high", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "101"}}, + {name: "triage zero", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--limit", "0"}}, + {name: "find high", declaration: FindMailUser, args: []string{"--query", "fixture", "--limit", "101"}}, + {name: "recent negative", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "-1"}}, + {name: "search blank query", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", " "}}, + {name: "find blank query", declaration: FindMailUser, args: []string{"--query", " "}}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: map[string]string{}} + if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err == nil { + t.Fatal("invalid limit unexpectedly succeeded") + } + if len(caller.calls) != 0 { + t.Fatalf("invalid limit reached remote: %d", len(caller.calls)) + } + }) + } +} + +func TestCrossPlatformCoverageFindMailUserProjectionMatchesResultIdentity(t *testing.T) { + for _, tc := range []struct { + name string + user map[string]any + wantKey string + absentKey string + }{ + {name: "id only", user: map[string]any{"id": "user-1", "email": ""}, wantKey: "id", absentKey: "email"}, + {name: "email only", user: map[string]any{"id": "", "email": "user@example.invalid"}, wantKey: "email", absentKey: "id"}, + {name: "numeric id only", user: map[string]any{"id": float64(7), "email": " "}, wantKey: "id", absentKey: "email"}, + } { + t.Run(tc.name, func(t *testing.T) { + projected, err := findMailUserProjection(tc.user) + if err != nil { + t.Fatal(err) + } + if _, ok := projected[tc.wantKey]; !ok { + t.Fatalf("projection missing %s: %#v", tc.wantKey, projected) + } + if _, ok := projected[tc.absentKey]; ok { + t.Fatalf("projection retained empty %s: %#v", tc.absentKey, projected) + } + }) + } + + caller := &smartMailCursorCaller{responses: map[string]string{ + "search_mail_users": `{"success":true,"users":[{"id":"","email":""}],"hasMore":false,"nextCursor":""}`, + }} + if err := runSmartMailDeclaration(t, FindMailUser, caller, "--query", "fixture"); err == nil { + t.Fatal("identity-free user unexpectedly satisfied runtime Result contract") + } + for name, user := range map[string]map[string]any{ + "malformed email with valid id": {"id": "user-1", "email": 7}, + "malformed id with valid email": {"id": map[string]any{"value": "user-1"}, "email": "user@example.invalid"}, + } { + t.Run(name, func(t *testing.T) { + if _, err := findMailUserProjection(user); err == nil { + t.Fatal("malformed optional identity unexpectedly passed projection") + } + }) + } +} + +func TestCrossPlatformCoverageSmartMailWhitespaceIdentityFailsClosed(t *testing.T) { + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + args []string + tool string + response string + }{ + {name: "search message id", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":" ","from":"sender@example.invalid"}],"hasMore":false,"nextCursor":""}`}, + {name: "triage message id", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":" ","from":"sender@example.invalid"}],"hasMore":false,"nextCursor":""}`}, + {name: "find user identity", declaration: FindMailUser, args: []string{"--query", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":" ","email":" "}],"hasMore":false,"nextCursor":""}`}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: map[string]string{tc.tool: tc.response}} + if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err == nil { + t.Fatal("whitespace stable identity unexpectedly succeeded") + } + if len(caller.calls) != 1 { + t.Fatalf("calls=%d, want one response validation call", len(caller.calls)) + } + }) + } +} + +func TestCrossPlatformCoverageFindMailUserKeepsStableQueryProperty(t *testing.T) { + for _, parameter := range FindMailUser.Contract.Parameters { + if parameter.Name == "query" { + if parameter.Property != "query" { + t.Fatalf("query property = %q, want stable composite property query", parameter.Property) + } + return + } + } + t.Fatal("FindMailUser query parameter declaration is missing") +} diff --git a/internal/shortcut/smart/my_attendance.go b/internal/shortcut/smart/my_attendance.go index 1c445d14..1cbc99a5 100644 --- a/internal/shortcut/smart/my_attendance.go +++ b/internal/shortcut/smart/my_attendance.go @@ -14,12 +14,14 @@ package smart import ( + "strings" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -41,10 +43,11 @@ import ( // // dws attendance +my-attendance var MyAttendance = shortcut.Shortcut{ - Service: "attendance", - Command: "+my-attendance", - Product: "attendance", - Description: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)", + OutputRollout: output.RolloutLegacyOnly, + Service: "attendance", + Command: "+my-attendance", + Product: "attendance", + Description: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)", Intent: "当你想快速看自己今天的打卡流水(几点上下班打卡、打卡地址/定位方式)、又不想先查自己的 userId " + "再手动填写今天的起止时间时使用;内部先取当前登录用户的 userId,再按本地时区算出今天 00:00 到次日 00:00 的时间窗," + "最后查询你今天的打卡流水记录。只读操作,不会修改任何考勤数据;今天若还没有任何打卡则返回空结果。", @@ -63,9 +66,9 @@ var MyAttendance = shortcut.Shortcut{ }, Description: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)", Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until a known-nonempty current-day fixture closes the false-empty proof gap.", }, Selection: contract.SelectionSpec{ AgentSummary: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)", @@ -84,7 +87,7 @@ var MyAttendance = shortcut.Shortcut{ if err != nil { return err } - userID := myAttendanceCurrentUserID(profile) + userID := strictAttendanceCurrentUserID(profile) if userID == "" { return apperrors.NewValidation( "没能解析出当前登录用户的 userId,无法查询你的打卡记录;请确认已登录后重试。") @@ -110,15 +113,89 @@ var MyAttendance = shortcut.Shortcut{ if err != nil { return err } - return rt.Output(data) + return outputStrictAttendanceRecords(rt, data) }, } -// myAttendanceCurrentUserID extracts the current user's userId from a +// strictAttendanceCurrentUserID extracts the current user's userId from a // get_current_user_profile response. The gateway returns one of several shapes, // so probe them defensively (mirrors helpers.getCurrentUserID): // - {"result": [ {"orgEmployeeModel": {"userId": ...}} ]} // - {"result": {"userId": ...}} +func strictAttendanceCurrentUserID(data map[string]any) string { + if data == nil { + return "" + } + success, ok := data["success"].(bool) + if !ok || !success { + return "" + } + identities := map[string]struct{}{} + collect := func(item map[string]any) bool { + var direct, nested string + if value, present := item["userId"]; present { + text, valid := value.(string) + if !valid || strings.TrimSpace(text) == "" { + return false + } + direct = strings.TrimSpace(text) + } + if value, present := item["orgEmployeeModel"]; present { + object, valid := value.(map[string]any) + if !valid || object == nil { + return false + } + text, valid := object["userId"].(string) + if !valid || strings.TrimSpace(text) == "" { + return false + } + nested = strings.TrimSpace(text) + } + if direct == "" && nested == "" { + return false + } + if direct != "" && nested != "" && direct != nested { + return false + } + if direct != "" { + identities[direct] = struct{}{} + } else { + identities[nested] = struct{}{} + } + return true + } + switch result := data["result"].(type) { + case []any: + if len(result) == 0 { + return "" + } + for _, item := range result { + m, ok := item.(map[string]any) + if !ok || !collect(m) { + return "" + } + } + case map[string]any: + if !collect(result) { + return "" + } + default: + return "" + } + if len(identities) != 1 { + return "" + } + var identity string + for candidate := range identities { + identity = candidate + } + return identity +} + +// myAttendanceCurrentUserID preserves the compatibility resolver used by +// older non-Attendance smart workflows. New Attendance shortcuts must use the +// strict resolver above so a missing business success flag cannot consume a +// stale result. func myAttendanceCurrentUserID(data map[string]any) string { if data == nil { return "" diff --git a/internal/shortcut/smart/recent_mail.go b/internal/shortcut/smart/recent_mail.go index c5b920ed..5651fdc8 100644 --- a/internal/shortcut/smart/recent_mail.go +++ b/internal/shortcut/smart/recent_mail.go @@ -14,6 +14,9 @@ package smart import ( + "fmt" + "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" @@ -71,6 +74,12 @@ var RecentMail = shortcut.Shortcut{ PrimaryCLIPath: "mail +recent-mail", }, Description: "列出收件箱近期邮件会话并投影列表(主题/发件人/时间/threadId)", + Parameters: []contract.ParamDecl{ + {Name: "limit", Property: "limit"}, + {Name: "email", Property: "email"}, + {Name: "folder", Property: "folder"}, + {Name: "cursor", Property: "cursor"}, + }, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -90,6 +99,11 @@ var RecentMail = shortcut.Shortcut{ {Name: "limit", Type: shortcut.FlagInt, Desc: "返回会话条数上限(可选,默认 20,最大 100)", Required: false}, {Name: "email", Type: shortcut.FlagString, Desc: "要查看的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false}, {Name: "folder", Type: shortcut.FlagString, Desc: "文件夹 ID(可选,默认定位收件箱)", Required: false}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100 之间"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return smartMailValidatePageSize(rt, "limit", false) }, Tips: []string{ `dws mail +recent-mail`, @@ -119,40 +133,47 @@ var RecentMail = shortcut.Shortcut{ // Step 3 — list threads. email/folderId/size mirror the helpers.mail // thread-list call; size is an int in 1..100. - size := rt.Int("limit") - if size <= 0 { - size = 20 + limit := 20 + if rt.Changed("limit") { + limit = rt.Int("limit") } - if size > 100 { - size = 100 - } - data, err := rt.CallMCPData("mail", "list_mailbox_threads", map[string]any{ + args := map[string]any{ "email": email, "folderId": folderID, - "size": size, - }) + "size": limit, + } + if rt.Changed("cursor") { + args["cursor"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("mail", "list_mailbox_threads", args) if err != nil { return err } // Step 4 — project each conversation to a compact record. - threads := searchMailUnwrapList(data, "conversations", "threads", "result", "data", "list", "items", "records") + threads, err := smartMailRows(data, "mail/list_mailbox_threads", "result.conversations", "id") + if err != nil { + return err + } results := make([]map[string]any, 0, len(threads)) for _, t := range threads { + from, err := recentMailSenders(t) + if err != nil { + return err + } results = append(results, map[string]any{ "subject": searchMailFirstString(t, "subject", "title", "topic"), - "from": recentMailSenders(t), + "from": from, "date": searchMailFirstAny(t, "lastModifiedDateTime", "date", "sentTime", "sentDate", "receivedDate", "createTime"), "threadId": searchMailFirstString(t, "threadId", "id", "conversationId"), }) } - // Empty result guard. - if len(results) == 0 { - return apperrors.NewValidation("最近没有邮件") + complete, next, err := smartMailPage(data, "mail/list_mailbox_threads", "result", rt.Str("cursor")) + if err != nil { + return err } - - return rt.Output(map[string]any{"count": len(results), "mails": results}) + return smartMailOutputPage(rt, "mails", results, complete, next) }, } @@ -166,12 +187,15 @@ func recentMailInboxFolder(rt *shortcut.RuntimeContext, email string) (string, e if err != nil { return "", err } - folders := searchMailUnwrapList(data, "folders", "list", "items", "data", "result", "records") + folders, err := smartMailRows(data, "mail/list_folders", "folders", "id") + if err != nil { + return "", err + } inboxNames := map[string]bool{ "收件箱": true, "inbox": true, } for _, f := range folders { - name := searchMailFirstString(f, "displayName", "name", "folderName") + name := strings.ToLower(strings.TrimSpace(searchMailFirstString(f, "displayName", "name", "folderName"))) if inboxNames[name] { if id := searchMailFirstString(f, "id", "folderId"); id != "" { return id, nil @@ -184,16 +208,14 @@ func recentMailInboxFolder(rt *shortcut.RuntimeContext, email string) (string, e // recentMailSenders reads a conversation's sender list. The helper documents a // "senders" array of {email, name}; we tolerate a plain string, a single object, // and fall back to the shared searchMailFrom logic. -func recentMailSenders(t map[string]any) any { - if arr, ok := t["senders"].([]any); ok && len(arr) > 0 { +func recentMailSenders(t map[string]any) (any, error) { + raw, present := t["senders"] + if arr, ok := raw.([]any); ok && len(arr) > 0 { names := make([]string, 0, len(arr)) - for _, it := range arr { + for index, it := range arr { m, ok := it.(map[string]any) - if !ok { - if s, ok := it.(string); ok && s != "" { - names = append(names, s) - } - continue + if !ok || len(m) == 0 { + return nil, smartMailError("mail/list_mailbox_threads", "malformed_sender", fmt.Sprintf("senders[%d] 不是非空对象", index)) } name := searchMailFirstString(m, "name", "displayName") addr := searchMailFirstString(m, "email", "emailAddress", "address") @@ -204,15 +226,23 @@ func recentMailSenders(t map[string]any) any { names = append(names, addr) case name != "": names = append(names, name) + default: + return nil, smartMailError("mail/list_mailbox_threads", "malformed_sender", fmt.Sprintf("senders[%d] 缺少姓名或邮箱", index)) } } if len(names) > 0 { - return names + return names, nil + } + } else if present { + if _, ok := raw.([]any); !ok { + return nil, smartMailError("mail/list_mailbox_threads", "malformed_sender", "senders 应为数组") } } return searchMailFrom(t) } func init() { + hardenSmartMail(&RecentMail, "mails", "严格校验的近期邮件会话摘要") + markSmartMailCompatibilityOnly(&RecentMail) shortcut.Register(RecentMail) } diff --git a/internal/shortcut/smart/search_mail.go b/internal/shortcut/smart/search_mail.go index a6d824e2..d0d2a22b 100644 --- a/internal/shortcut/smart/search_mail.go +++ b/internal/shortcut/smart/search_mail.go @@ -14,6 +14,9 @@ package smart import ( + "fmt" + "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" @@ -79,9 +82,20 @@ var SearchMail = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "query", Type: shortcut.FlagString, Desc: "KQL 搜索表达式(如 subject:周报、from:alice、folderId:2)", Required: true}, + {Name: "query", Type: shortcut.FlagString, Desc: "KQL 搜索表达式(如 subject:周报、from:alice、folderId:2),不能为空", Required: true}, {Name: "email", Type: shortcut.FlagString, Desc: "要搜索的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false}, - {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20)", Required: false}, + {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20;显式提供时必须是 1-100 之间的整数)", Required: false}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "不能为空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "1-100"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := smartMailValidateRequiredText(rt, "query"); err != nil { + return err + } + return smartMailValidateStringPageSize(rt, "size") }, Tips: []string{ `dws mail +search-mail --query "subject:周报"`, @@ -92,6 +106,10 @@ var SearchMail = shortcut.Shortcut{ if err := rt.RequireAll("query"); err != nil { return err } + size, err := smartMailStringPageSize(rt, "size", "20") + if err != nil { + return err + } // Step 1 — resolve the mailbox address. email := rt.Str("email") @@ -105,31 +123,42 @@ var SearchMail = shortcut.Shortcut{ // Step 2 — search that mailbox. email/query/size mirror the search_emails // call in helpers.messageSearch; size is passed as a string. - size := rt.Str("size") - if size == "" { - size = "20" - } - data, err := rt.CallMCPData("mail", "search_emails", map[string]any{ + args := map[string]any{ "email": email, "query": rt.Str("query"), "size": size, - }) + } + if rt.Changed("cursor") { + args["cursor"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("mail", "search_emails", args) if err != nil { return err } // Step 3 — project each message to a compact record. - messages := searchMailMessages(data) + messages, err := smartMailSearchRows(data, "mail/search_emails") + if err != nil { + return err + } out := make([]map[string]any, 0, len(messages)) for _, m := range messages { + from, err := searchMailFrom(m) + if err != nil { + return err + } out = append(out, map[string]any{ "subject": searchMailFirstString(m, "subject", "title", "topic"), - "from": searchMailFrom(m), + "from": from, "date": searchMailFirstAny(m, "date", "sentDate", "receivedDate", "sentTime", "internalDate", "createTime"), "messageId": searchMailFirstString(m, "messageId", "id", "mailId", "emailId", "internetMessageId"), }) } - return rt.Output(map[string]any{"messages": out, "email": email}) + complete, next, err := smartMailPage(data, "mail/search_emails", "", rt.Str("cursor")) + if err != nil { + return err + } + return smartMailOutputPage(rt, "messages", out, complete, next) }, } @@ -142,115 +171,106 @@ func searchMailFirstMailbox(rt *shortcut.RuntimeContext) (string, error) { if err != nil { return "", err } - if addr := searchMailPickMailbox(data); addr != "" { - return addr, nil + mailboxes, err := searchMailMailboxAddresses(data) + if err != nil { + return "", err + } + if len(mailboxes) > 0 { + return mailboxes[0], nil } return "", apperrors.NewValidation("未找到可用邮箱,请用 --email 指定要搜索的邮箱地址") } -// searchMailPickMailbox resolves the first usable mailbox address from a -// list_user_mailboxes response. It tolerates every observed shape: objects with -// an email field (result.emailAccounts[].email) and bare string arrays -// (result.emailAccounts[]), each at the top level or one level under a -// result/data wrapper. searchMailToMaps drops plain strings, so the bare-string -// case is scanned separately — otherwise all three smart mail shortcuts wrongly -// report that no mailbox exists. -func searchMailPickMailbox(data map[string]any) string { - containers := []string{"emailAccounts", "mailboxes", "list", "items", "data", "result", "records"} - for _, m := range searchMailUnwrapList(data, containers...) { - if addr := searchMailFirstString(m, "email", "emailAddress", "mailbox", "address", "account"); addr != "" { - return addr - } +// searchMailMailboxAddresses accepts the two reviewed mailbox wire shapes: +// non-empty address strings and non-empty objects carrying an email field. +// The collection itself and every item remain fail-closed, so malformed rows +// cannot be skipped and misreported as an empty mailbox list. +func searchMailMailboxAddresses(data map[string]any) ([]string, error) { + const operation = "mail/list_user_mailboxes" + if err := smartMailSuccess(data, operation); err != nil { + return nil, err } - scanStrings := func(m map[string]any) string { - for _, key := range containers { - if arr, ok := m[key].([]any); ok { - for _, it := range arr { - if s, ok := it.(string); ok && s != "" { - return s - } - } - } + paths := []string{"emailAccounts", "result.emailAccounts", "data.emailAccounts"} + var value any + selectedPath := "" + for _, path := range paths { + candidate, present := smartMailLookup(data, path) + if !present { + continue } - return "" - } - if s := scanStrings(data); s != "" { - return s - } - for _, wrap := range []string{"result", "data"} { - if inner, ok := data[wrap].(map[string]any); ok { - if s := scanStrings(inner); s != "" { - return s - } + if selectedPath != "" { + return nil, smartMailError(operation, "conflicting_collection", fmt.Sprintf("响应同时包含 %s 与 %s,无法选择唯一邮箱集合", selectedPath, path)) } + value = candidate + selectedPath = path } - return "" -} - -// searchMailMessages extracts the message list from a search_emails response. -// The helper documents the list under "messages". -func searchMailMessages(data map[string]any) []map[string]any { - return searchMailUnwrapList(data, "messages", "emails", "list", "items", "data", "result", "records") -} - -// searchMailUnwrapList probes the given container keys at the top level, and one -// level deep, returning the first list found as []map[string]any. -func searchMailUnwrapList(data map[string]any, keys ...string) []map[string]any { - for _, key := range keys { - if arr, ok := data[key].([]any); ok { - return searchMailToMaps(arr) - } - if inner, ok := data[key].(map[string]any); ok { - for _, k2 := range keys { - if arr, ok := inner[k2].([]any); ok { - return searchMailToMaps(arr) - } - } - } + if selectedPath == "" { + return nil, smartMailError(operation, "missing_collection", "成功响应缺少 emailAccounts 数组;不能把未知响应结构当作空结果") } - return nil -} - -func searchMailToMaps(arr []any) []map[string]any { - out := make([]map[string]any, 0, len(arr)) - for _, it := range arr { - if m, ok := it.(map[string]any); ok { - out = append(out, m) - } + raw, ok := value.([]any) + if !ok { + return nil, smartMailError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", selectedPath, value)) } - return out + addresses := make([]string, 0, len(raw)) + for index, item := range raw { + var address string + switch typed := item.(type) { + case string: + address = strings.TrimSpace(typed) + case map[string]any: + address = searchMailFirstString(typed, "email") + default: + return nil, smartMailError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 必须是邮箱字符串或含 email 的对象", selectedPath, index)) + } + if address == "" { + return nil, smartMailError(operation, "missing_item_identity", fmt.Sprintf("响应 %s[%d] 缺少非空邮箱地址", selectedPath, index)) + } + addresses = append(addresses, address) + } + return addresses, nil } // searchMailFrom reads a message's sender, tolerating both a plain string and a // nested object ({name, email/address}). -func searchMailFrom(m map[string]any) any { - switch v := m["from"].(type) { +func searchMailFrom(m map[string]any) (any, error) { + raw, present := m["from"] + switch v := raw.(type) { case string: - if v != "" { - return v + if normalized := strings.TrimSpace(v); normalized != "" { + return normalized, nil } case map[string]any: name := searchMailFirstString(v, "name", "displayName") addr := searchMailFirstString(v, "email", "emailAddress", "address") switch { case name != "" && addr != "": - return name + " <" + addr + ">" + return name + " <" + addr + ">", nil case addr != "": - return addr + return addr, nil case name != "": - return name + return name, nil } + return nil, smartMailError("mail/search_emails", "malformed_sender", "from 对象缺少姓名或邮箱") + case nil: + if present { + return nil, smartMailError("mail/search_emails", "malformed_sender", "from 不能为 null") + } + default: + return nil, smartMailError("mail/search_emails", "malformed_sender", fmt.Sprintf("from 字段类型错误:%T", raw)) } if s := searchMailFirstString(m, "sender", "fromAddress", "fromName", "fromEmail"); s != "" { - return s + return s, nil } - return "" + if present { + return nil, smartMailError("mail/search_emails", "malformed_sender", "from 不能为空") + } + return nil, smartMailError("mail/search_emails", "missing_sender", "消息缺少可验证的发件人字段") } func searchMailFirstString(m map[string]any, keys ...string) string { for _, key := range keys { - if s, ok := m[key].(string); ok && s != "" { - return s + if s, ok := m[key].(string); ok && strings.TrimSpace(s) != "" { + return strings.TrimSpace(s) } } return "" @@ -259,8 +279,11 @@ func searchMailFirstString(m map[string]any, keys ...string) string { func searchMailFirstAny(m map[string]any, keys ...string) any { for _, key := range keys { if v, ok := m[key]; ok && v != nil { - if s, isStr := v.(string); isStr && s == "" { - continue + if s, isStr := v.(string); isStr { + if strings.TrimSpace(s) == "" { + continue + } + return strings.TrimSpace(s) } return v } @@ -269,5 +292,6 @@ func searchMailFirstAny(m map[string]any, keys ...string) any { } func init() { + hardenSmartMail(&SearchMail, "messages", "严格校验的邮件搜索摘要") shortcut.Register(SearchMail) } diff --git a/internal/shortcut/smart/this_month_attendance.go b/internal/shortcut/smart/this_month_attendance.go index 3363e6f8..641c5576 100644 --- a/internal/shortcut/smart/this_month_attendance.go +++ b/internal/shortcut/smart/this_month_attendance.go @@ -20,6 +20,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -41,10 +42,11 @@ import ( // // dws attendance +this-month var ThisMonthAttendance = shortcut.Shortcut{ - Service: "attendance", - Command: "+this-month", - Product: "attendance", - Description: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)", + OutputRollout: output.RolloutLegacyOnly, + Service: "attendance", + Command: "+this-month", + Product: "attendance", + Description: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)", Intent: "当你想快速看自己本月的打卡流水(几点上下班打卡、打卡地址/定位方式)、又不想先查自己的 userId " + "再手动填写本月的起止时间时使用;内部先取当前登录用户的 userId,再按本地时区算出本月 1 号 00:00 到下月 1 号 00:00 的时间窗," + "最后查询你本月的打卡流水记录。只读操作,不会修改任何考勤数据;本月若还没有任何打卡则返回空结果。", @@ -63,9 +65,9 @@ var ThisMonthAttendance = shortcut.Shortcut{ }, Description: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)", Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until a known-nonempty current-month fixture closes the false-empty proof gap.", }, Selection: contract.SelectionSpec{ AgentSummary: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)", @@ -84,7 +86,7 @@ var ThisMonthAttendance = shortcut.Shortcut{ if err != nil { return err } - userID := myAttendanceCurrentUserID(profile) + userID := strictAttendanceCurrentUserID(profile) if userID == "" { return apperrors.NewValidation( "没能解析出当前登录用户的 userId,无法查询你的打卡记录;请确认已登录后重试。") @@ -110,7 +112,7 @@ var ThisMonthAttendance = shortcut.Shortcut{ if err != nil { return err } - return rt.Output(data) + return outputStrictAttendanceRecords(rt, data) }, } diff --git a/internal/shortcut/smart/triage_mail.go b/internal/shortcut/smart/triage_mail.go new file mode 100644 index 00000000..7f32ee13 --- /dev/null +++ b/internal/shortcut/smart/triage_mail.go @@ -0,0 +1,102 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "strconv" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// TriageMail aligns the Lark task-level triage entry while preserving DWS's +// easier mailbox and inbox resolution. It returns summaries only; callers use +// +message or +messages for full bodies. +var TriageMail = shortcut.Shortcut{ + Service: "mail", Command: "+triage", Product: "mail", + Description: "列出或筛选邮件摘要,自动解析邮箱与收件箱", Intent: "快速浏览收件箱摘要,或用 KQL 条件筛选邮件;不提供条件时自动定位当前邮箱的收件箱,返回可继续交给 +message/+messages 的稳定 ID。", + Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Description: "列出或筛选邮件摘要,自动解析邮箱与收件箱", + Identity: contract.ToolIdentitySpec{ + ProductID: "mail", Name: "shortcut_triage", CanonicalPath: "mail.shortcut_triage", + CLIPath: "mail +triage", PrimaryCLIPath: "mail +triage", + }, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: "Resolves the current mailbox and inbox locally, then strictly validates search_emails summaries and pagination."}, + Selection: contract.SelectionSpec{ + AgentSummary: "列出或筛选邮件摘要,自动解析邮箱与收件箱", + UseWhen: []string{"快速浏览收件箱摘要,或用 KQL 条件筛选邮件;不提供条件时自动定位当前邮箱的收件箱,返回可继续交给 +message/+messages 的稳定 ID。"}, + AvoidWhen: []string{"已知单个 messageId 并要读正文时用 mail +message;需要多封完整正文时用 mail +messages"}, + Examples: []string{`dws mail +triage --limit 20 --format json`, `dws mail +triage --query "isRead:false" --format json`}, + }, + Parameters: []contract.ParamDecl{{Name: "limit", Property: "size"}, {Name: "cursor", Property: "cursor"}, {Name: "query", Property: "query"}}, + }, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "可选 KQL 条件;不传时列出收件箱"}, + {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页摘要数量,必须在 1-100 之间"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "1-100"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return smartMailValidatePageSize(rt, "limit", true) + }, + Execute: func(rt *shortcut.RuntimeContext) error { + email := rt.Str("email") + if email == "" { + resolved, err := searchMailFirstMailbox(rt) + if err != nil { + return err + } + email = resolved + } + query := rt.Str("query") + if query == "" { + folderID, err := recentMailInboxFolder(rt, email) + if err != nil { + return err + } + query = "folderId:" + folderID + } + args := map[string]any{"email": email, "query": query, "size": strconv.Itoa(rt.Int("limit"))} + if rt.Changed("cursor") { + args["cursor"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("mail", "search_emails", args) + if err != nil { + return err + } + messages, err := smartMailSearchRows(data, "mail/search_emails") + if err != nil { + return err + } + rows := make([]map[string]any, 0, len(messages)) + for _, message := range messages { + from, err := searchMailFrom(message) + if err != nil { + return err + } + rows = append(rows, map[string]any{ + "messageId": searchMailFirstString(message, "id"), + "subject": searchMailFirstString(message, "subject"), + "from": from, + "date": searchMailFirstAny(message, "receivedDateTime", "date", "sentDateTime"), + }) + } + complete, next, err := smartMailPage(data, "mail/search_emails", "", rt.Str("cursor")) + if err != nil { + return err + } + return smartMailOutputPage(rt, "messages", rows, complete, next) + }, +} + +func init() { + hardenSmartMail(&TriageMail, "messages", "严格校验的邮件摘要与分页证据") + shortcut.Register(TriageMail) +} diff --git a/internal/shortcut/smart/unread_mail.go b/internal/shortcut/smart/unread_mail.go index e72a91fa..8299663d 100644 --- a/internal/shortcut/smart/unread_mail.go +++ b/internal/shortcut/smart/unread_mail.go @@ -80,7 +80,12 @@ var UnreadMail = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "email", Type: shortcut.FlagString, Desc: "要查询的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false}, - {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20)", Required: false}, + {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20;显式提供时必须是 1-100 之间的整数)", Required: false}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "显式 --size 必须在 1-100 之间"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return smartMailValidateStringPageSize(rt, "size") }, Tips: []string{ `dws mail +unread-mail`, @@ -88,6 +93,10 @@ var UnreadMail = shortcut.Shortcut{ `dws mail +unread-mail --size 50`, }, Execute: func(rt *shortcut.RuntimeContext) error { + size, err := smartMailStringPageSize(rt, "size", "20") + if err != nil { + return err + } // Step 1 — resolve the mailbox address. email := rt.Str("email") if email == "" { @@ -101,34 +110,47 @@ var UnreadMail = shortcut.Shortcut{ // Step 2 — search unread mail. email/query/size mirror the search_emails // call in helpers.messageSearch; the isRead:false KQL filter matches the // isRead field documented there; size is passed as a string. - size := rt.Str("size") - if size == "" { - size = "20" - } - data, err := rt.CallMCPData("mail", "search_emails", map[string]any{ + args := map[string]any{ "email": email, "query": "isRead:false", "size": size, - }) + } + if rt.Changed("cursor") { + args["cursor"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("mail", "search_emails", args) if err != nil { return err } // Step 3 — project each message to a compact record (shared helpers). - messages := searchMailMessages(data) + messages, err := smartMailSearchRows(data, "mail/search_emails") + if err != nil { + return err + } out := make([]map[string]any, 0, len(messages)) for _, m := range messages { + from, err := searchMailFrom(m) + if err != nil { + return err + } out = append(out, map[string]any{ "subject": searchMailFirstString(m, "subject", "title", "topic"), - "from": searchMailFrom(m), + "from": from, "date": searchMailFirstAny(m, "date", "sentDate", "receivedDate", "sentTime", "internalDate", "createTime"), "messageId": searchMailFirstString(m, "messageId", "id", "mailId", "emailId", "internetMessageId"), }) } - return rt.Output(map[string]any{"messages": out, "email": email}) + complete, next, err := smartMailPage(data, "mail/search_emails", "", rt.Str("cursor")) + if err != nil { + return err + } + return smartMailOutputPage(rt, "messages", out, complete, next) }, } func init() { + hardenSmartMail(&UnreadMail, "messages", "严格校验的未读邮件摘要") + markSmartMailCompatibilityOnly(&UnreadMail) shortcut.Register(UnreadMail) } diff --git a/internal/shortcut/types.go b/internal/shortcut/types.go index b3545f36..9d9f5836 100644 --- a/internal/shortcut/types.go +++ b/internal/shortcut/types.go @@ -195,6 +195,10 @@ type Shortcut struct { Tips []string // Hidden hides the command from listings while keeping it invocable. Hidden bool + // CompatibilityVisible preserves a historically visible CLI command while + // keeping it out of the Agent/public Shortcut catalog. Such a command is + // shown only by `dws shortcut list --all` and must remain unavailable. + CompatibilityVisible bool // Disposition is the reviewed semantic relation to the Runtime Schema leaf // surface. It determines default Agent discovery independently from live // fixture evidence. diff --git a/internal/shortcut/usage/command.go b/internal/shortcut/usage/command.go index cd59dd51..f4677bcf 100644 --- a/internal/shortcut/usage/command.go +++ b/internal/shortcut/usage/command.go @@ -56,7 +56,7 @@ func newListCommand() *cobra.Command { if svc != "" && s.Service != svc { continue } - if s.Hidden && !includeHidden { + if (s.Hidden || s.CompatibilityVisible) && !includeHidden { continue } rows = append(rows, newShortcutListRow(s)) @@ -75,23 +75,24 @@ func newListCommand() *cobra.Command { } type shortcutListRow struct { - Service string `json:"service"` - Command string `json:"command"` - CLIPath string `json:"cli_path"` - Product string `json:"product"` - Risk string `json:"risk"` - Confirmation string `json:"confirmation"` - Description string `json:"description"` - Intent string `json:"intent,omitempty"` - Flags []shortcut.Flag `json:"flags"` - Constraints []shortcut.Constraint `json:"constraints"` - Examples []string `json:"examples"` - Public bool `json:"public"` - Disposition string `json:"disposition,omitempty"` - SemanticDelta string `json:"semantic_delta,omitempty"` - Availability string `json:"availability,omitempty"` - Primary string `json:"primary,omitempty"` - Reviewed bool `json:"reviewed"` + Service string `json:"service"` + Command string `json:"command"` + CLIPath string `json:"cli_path"` + Product string `json:"product"` + Risk string `json:"risk"` + Confirmation string `json:"confirmation"` + Description string `json:"description"` + Intent string `json:"intent,omitempty"` + Flags []shortcut.Flag `json:"flags"` + Constraints []shortcut.Constraint `json:"constraints"` + Examples []string `json:"examples"` + Public bool `json:"public"` + Disposition string `json:"disposition,omitempty"` + SemanticDelta string `json:"semantic_delta,omitempty"` + Availability string `json:"availability,omitempty"` + Primary string `json:"primary,omitempty"` + Reviewed bool `json:"reviewed"` + CompatibilityVisible bool `json:"compatibility_visible,omitempty"` } func newShortcutListRow(s shortcut.Shortcut) shortcutListRow { @@ -120,23 +121,24 @@ func newShortcutListRow(s shortcut.Shortcut) shortcutListRow { constraints := append([]shortcut.Constraint{}, s.Constraints...) examples := append([]string{}, s.Tips...) return shortcutListRow{ - Service: s.Service, - Command: s.Command, - CLIPath: s.Service + " " + s.Command, - Product: product, - Risk: risk, - Confirmation: confirmation, - Description: s.Description, - Intent: s.Intent, - Flags: flags, - Constraints: constraints, - Examples: examples, - Public: !s.Hidden, - Disposition: string(s.Disposition), - SemanticDelta: s.SemanticDelta, - Availability: string(s.Availability), - Primary: s.PrimaryCommand, - Reviewed: s.SemanticReviewed, + Service: s.Service, + Command: s.Command, + CLIPath: s.Service + " " + s.Command, + Product: product, + Risk: risk, + Confirmation: confirmation, + Description: s.Description, + Intent: s.Intent, + Flags: flags, + Constraints: constraints, + Examples: examples, + Public: !s.Hidden && !s.CompatibilityVisible, + Disposition: string(s.Disposition), + SemanticDelta: s.SemanticDelta, + Availability: string(s.Availability), + Primary: s.PrimaryCommand, + Reviewed: s.SemanticReviewed, + CompatibilityVisible: s.CompatibilityVisible, } } diff --git a/internal/shortcut/usage/usage_test.go b/internal/shortcut/usage/usage_test.go index 3d1a103c..45040408 100644 --- a/internal/shortcut/usage/usage_test.go +++ b/internal/shortcut/usage/usage_test.go @@ -47,6 +47,11 @@ func TestCrossPlatformCoverageShortcutListFiltersHiddenAndService(t *testing.T) Service: "coverage-usage", Command: "+hidden", }) + shortcut.Register(shortcut.Shortcut{ + Service: "coverage-usage", + Command: "+compatibility-visible", + CompatibilityVisible: true, + }) execute := func(args ...string) map[string]any { t.Helper() @@ -66,9 +71,20 @@ func TestCrossPlatformCoverageShortcutListFiltersHiddenAndService(t *testing.T) publicRows := execute("--service", "coverage-usage") allRows := execute("--service", "coverage-usage", "--all") - if publicRows["count"].(float64) != 0 || allRows["count"].(float64) != 1 { + if publicRows["count"].(float64) != 0 || allRows["count"].(float64) != 2 { t.Fatalf("hidden shortcuts were not filtered: public=%v all=%v", publicRows["count"], allRows["count"]) } + rows := allRows["shortcuts"].([]any) + foundCompatibilityVisible := false + for _, value := range rows { + row := value.(map[string]any) + if row["command"] == "+compatibility-visible" { + foundCompatibilityVisible = row["compatibility_visible"] == true && row["public"] == false + } + } + if !foundCompatibilityVisible { + t.Fatalf("compatibility-visible shortcut row lost its non-public marker: %#v", rows) + } missing := execute("--service", "__missing__") if missing["count"].(float64) != 0 { t.Fatalf("missing service returned shortcuts: %#v", missing) diff --git a/scripts/dev/test-multi-profile-e2e.sh b/scripts/dev/test-multi-profile-e2e.sh index 50cba792..d24371b7 100755 --- a/scripts/dev/test-multi-profile-e2e.sh +++ b/scripts/dev/test-multi-profile-e2e.sh @@ -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 diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index 0b37827d..5c1e6546 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -27,6 +27,8 @@ SEMANTIC_PATHS = [ ROOT / "internal" / "shortcut" / "semantic_catalog_wiki.json", ROOT / "internal" / "shortcut" / "semantic_catalog_calendar.json", ROOT / "internal" / "shortcut" / "semantic_catalog_todo.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_attendance.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_mail.json", ] diff --git a/scripts/policy/check-authoritative-schema-compatibility.sh b/scripts/policy/check-authoritative-schema-compatibility.sh index 16453ad4..45441cfd 100755 --- a/scripts/policy/check-authoritative-schema-compatibility.sh +++ b/scripts/policy/check-authoritative-schema-compatibility.sh @@ -13,8 +13,13 @@ STABLE_REF="" CANDIDATE_REF="HEAD" SCHEMA_CHECKER_REL="scripts/policy/schema-compat/main.go" MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-flag-migrations-v1.json" +COMMAND_MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-command-migrations-v1.json" MIGRATIONS_REL="internal/interfacesnapshot/migrations.go" +COMMAND_MIGRATIONS_REL="internal/interfacesnapshot/command_migrations.go" +CORECMD_BRIDGE_REL="internal/corecmd/corecmd.go" ALIAS_CONTRACT_REL="internal/corecmd/runtimeannotate/interface_alias.go" +CONST_PARAMS_REGISTRY_REL="internal/corecmd/interface_const_params.go" +LEAF_ADAPTER_REL="internal/helpers/leaf.go" usage() { printf '%s\n' "usage: $0 --base-ref [--stable-ref ] [--candidate-ref ]" >&2 @@ -144,6 +149,17 @@ commit_path_is_regular_file() { [ "$2" = blob ] && [ "$4" = "$tree_path" ] } +commit_path_blob() { + tree_commit="$1" + tree_path="$2" + tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")" + [ -n "$tree_entry" ] || return 1 + set -- $tree_entry + [ "$#" -eq 4 ] || return 1 + [ "$2" = blob ] && [ "$4" = "$tree_path" ] || return 1 + printf '%s\n' "$3" +} + has_modern_interface_helper() { governance_commit="$1" commit_path_is_regular_file "$governance_commit" cmd/interface-snapshot/main.go && @@ -173,6 +189,94 @@ has_any_schema_migration_governance_artifact() { return 1 } +has_complete_schema_command_migration_governance() { + governance_commit="$1" + has_complete_schema_migration_governance "$governance_commit" && + commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATIONS_REL" && + commit_path_is_regular_file "$governance_commit" "$CONST_PARAMS_REGISTRY_REL" && + commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATION_MANIFEST_REL" +} + +has_any_schema_command_migration_governance_artifact() { + governance_commit="$1" + for relative_path in "$COMMAND_MIGRATIONS_REL" "$CONST_PARAMS_REGISTRY_REL" "$COMMAND_MIGRATION_MANIFEST_REL"; do + if commit_path_exists "$governance_commit" "$relative_path"; then + return 0 + fi + done + return 1 +} + +check_candidate_const_params_source_policy() { + source_policy_failed=false + for token in attachInterfaceBoolConstParams InterfaceBoolConstParams interfaceBoolConstParamsRegistry; do + matches="$(git -C "$CANDIDATE_WORKTREE" grep -l -w -F -e "$token" -- '*.go' || true)" + [ -z "$matches" ] && continue + while IFS= read -r relative_path; do + [ -n "$relative_path" ] || continue + case "$relative_path" in + *_test.go) + continue + ;; + internal/corecmd/interface_const_params.go) + continue + ;; + internal/corecmd/corecmd.go) + [ "$token" = attachInterfaceBoolConstParams ] && continue + ;; + internal/interfacesnapshot/snapshot.go) + [ "$token" = InterfaceBoolConstParams ] && continue + ;; + esac + if [ "$source_policy_failed" = false ]; then + printf 'error: candidate production source may not access framework-owned bool ConstParams registry:\n' >&2 + fi + printf ' %s: protected bool ConstParams registry identifier %s\n' "$relative_path" "$token" >&2 + source_policy_failed=true + done <&2 + exit 2 + fi +} + +require_base_identical_command_migration_bridges() { + base_command_manifest_blob="$(commit_path_blob "$BASE_COMMIT" "$COMMAND_MIGRATION_MANIFEST_REL")" + candidate_command_manifest_blob="$(commit_path_blob "$CANDIDATE_COMMIT" "$COMMAND_MIGRATION_MANIFEST_REL")" + [ "$base_command_manifest_blob" != "$candidate_command_manifest_blob" ] || return 0 + + for protected_bridge_path in "$CORECMD_BRIDGE_REL" "$CONST_PARAMS_REGISTRY_REL" "$LEAF_ADAPTER_REL"; do + base_protected_bridge_blob="$(commit_path_blob "$BASE_COMMIT" "$protected_bridge_path" || true)" + candidate_protected_bridge_blob="$(commit_path_blob "$CANDIDATE_COMMIT" "$protected_bridge_path" || true)" + if [ -z "$base_protected_bridge_blob" ] || + [ "$candidate_protected_bridge_blob" != "$base_protected_bridge_blob" ]; then + printf 'candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: %s\n' \ + "$protected_bridge_path" >&2 + exit 2 + fi + done +} + check_candidate_alias_source_policy() { source_policy_failed=false for token in \ @@ -225,10 +329,12 @@ if ! has_complete_schema_migration_governance "$CANDIDATE_COMMIT"; then exit 2 fi check_candidate_alias_source_policy +check_candidate_const_params_source_policy EMPTY_MANIFEST="$TMP_ROOT/empty-migrations.json" printf '%s\n' '{"version":1,"migrations":[]}' >"$EMPTY_MANIFEST" USE_MIGRATION_GOVERNANCE=false +USE_COMMAND_MIGRATION_GOVERNANCE=false if has_complete_schema_migration_governance "$BASE_COMMIT"; then USE_MIGRATION_GOVERNANCE=true @@ -252,6 +358,27 @@ else fi fi +if has_complete_schema_command_migration_governance "$BASE_COMMIT"; then + USE_COMMAND_MIGRATION_GOVERNANCE=true + APPROVED_COMMAND_MANIFEST="$BASE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL" + CANDIDATE_COMMAND_MANIFEST="$CANDIDATE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL" + require_complete_candidate_schema_command_governance + require_base_identical_command_migration_bridges +elif has_any_schema_command_migration_governance_artifact "$BASE_COMMIT"; then + printf 'error: merge-base contains an incomplete Schema command migration governance artifact set: %s\n' \ + "$BASE_REF" >&2 + exit 2 +elif has_any_schema_command_migration_governance_artifact "$CANDIDATE_COMMIT"; then + # Bootstrap keeps the old base-owned checker in control and therefore grants + # no command migration authorization in the governance PR itself. + require_complete_candidate_schema_command_governance +fi + +if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then + install_authority_const_params_registry "$BASE_WORKTREE" "$CANDIDATE_WORKTREE" + install_authority_const_params_registry "$BASE_WORKTREE" "$STABLE_WORKTREE" +fi + BASE_BIN="$TMP_ROOT/base-dws" STABLE_BIN="$TMP_ROOT/stable-dws" CANDIDATE_BIN="$TMP_ROOT/candidate-dws" @@ -337,16 +464,28 @@ generate_interface_snapshot "$CANDIDATE_WORKTREE" "$CURRENT_INTERFACE_SNAPSHOT" generate_interface_snapshot "$BASE_WORKTREE" "$BASE_INTERFACE_SNAPSHOT" generate_interface_snapshot "$STABLE_WORKTREE" "$STABLE_INTERFACE_SNAPSHOT" -check_schema_contract "PR merge-base" "$BASE_REF" "$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" +check_with_migrations() { + historical_kind="$1" + historical_ref="$2" + historical_baseline="$3" + 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" \ + --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" + fi +} -check_schema_contract "stable" "$STABLE_REF" "$STABLE_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" +check_with_migrations "PR merge-base" "$BASE_REF" "$BASELINE" +check_with_migrations "stable" "$STABLE_REF" "$STABLE_BASELINE" diff --git a/scripts/policy/check-command-compatibility.sh b/scripts/policy/check-command-compatibility.sh index d26a7abd..2292bd77 100755 --- a/scripts/policy/check-command-compatibility.sh +++ b/scripts/policy/check-command-compatibility.sh @@ -12,7 +12,12 @@ BASE_REF="" STABLE_REF="" CANDIDATE_REF="HEAD" MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-flag-migrations-v1.json" +COMMAND_MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-command-migrations-v1.json" +COMMAND_MIGRATIONS_REL="internal/interfacesnapshot/command_migrations.go" +CORECMD_BRIDGE_REL="internal/corecmd/corecmd.go" ALIAS_CONTRACT_REL="internal/corecmd/runtimeannotate/interface_alias.go" +CONST_PARAMS_REGISTRY_REL="internal/corecmd/interface_const_params.go" +LEAF_ADAPTER_REL="internal/helpers/leaf.go" usage() { printf '%s\n' "usage: $0 --base-ref --stable-ref [--candidate-ref ]" >&2 @@ -130,6 +135,17 @@ commit_path_is_regular_file() { [ "$2" = blob ] && [ "$4" = "$tree_path" ] } +commit_path_blob() { + tree_commit="$1" + tree_path="$2" + tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")" + [ -n "$tree_entry" ] || return 1 + set -- $tree_entry + [ "$#" -eq 4 ] || return 1 + [ "$2" = blob ] && [ "$4" = "$tree_path" ] || return 1 + printf '%s\n' "$3" +} + has_modern_interface_helper() { governance_commit="$1" commit_path_is_regular_file "$governance_commit" cmd/interface-snapshot/main.go && @@ -166,6 +182,84 @@ require_complete_candidate_governance() { fi } +has_complete_command_migration_governance() { + governance_commit="$1" + has_complete_migration_governance "$governance_commit" && + commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATIONS_REL" && + commit_path_is_regular_file "$governance_commit" "$CONST_PARAMS_REGISTRY_REL" && + commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATION_MANIFEST_REL" +} + +has_any_command_migration_governance_artifact() { + governance_commit="$1" + for relative_path in "$COMMAND_MIGRATIONS_REL" "$CONST_PARAMS_REGISTRY_REL" "$COMMAND_MIGRATION_MANIFEST_REL"; do + if commit_path_exists "$governance_commit" "$relative_path"; then + return 0 + fi + done + return 1 +} + +check_candidate_const_params_source_policy() { + source_policy_failed=false + for token in attachInterfaceBoolConstParams InterfaceBoolConstParams interfaceBoolConstParamsRegistry; do + matches="$(git -C "$CANDIDATE_WORKTREE" grep -l -w -F -e "$token" -- '*.go' || true)" + [ -z "$matches" ] && continue + while IFS= read -r relative_path; do + [ -n "$relative_path" ] || continue + case "$relative_path" in + *_test.go) + continue + ;; + internal/corecmd/interface_const_params.go) + continue + ;; + internal/corecmd/corecmd.go) + [ "$token" = attachInterfaceBoolConstParams ] && continue + ;; + internal/interfacesnapshot/snapshot.go) + [ "$token" = InterfaceBoolConstParams ] && continue + ;; + esac + if [ "$source_policy_failed" = false ]; then + printf 'candidate production source may not access framework-owned bool ConstParams registry:\n' >&2 + fi + printf ' %s: protected bool ConstParams registry identifier %s\n' "$relative_path" "$token" >&2 + source_policy_failed=true + done <&2 + exit 2 + fi +} + +require_base_identical_command_migration_bridges() { + base_command_manifest_blob="$(commit_path_blob "$BASE_COMMIT" "$COMMAND_MIGRATION_MANIFEST_REL")" + candidate_command_manifest_blob="$(commit_path_blob "$CANDIDATE_COMMIT" "$COMMAND_MIGRATION_MANIFEST_REL")" + [ "$base_command_manifest_blob" != "$candidate_command_manifest_blob" ] || return 0 + + for protected_bridge_path in "$CORECMD_BRIDGE_REL" "$CONST_PARAMS_REGISTRY_REL" "$LEAF_ADAPTER_REL"; do + base_protected_bridge_blob="$(commit_path_blob "$BASE_COMMIT" "$protected_bridge_path" || true)" + candidate_protected_bridge_blob="$(commit_path_blob "$CANDIDATE_COMMIT" "$protected_bridge_path" || true)" + if [ -z "$base_protected_bridge_blob" ] || + [ "$candidate_protected_bridge_blob" != "$base_protected_bridge_blob" ]; then + printf 'candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: %s\n' \ + "$protected_bridge_path" >&2 + exit 2 + fi + done +} + check_candidate_alias_source_policy() { source_policy_failed=false for token in \ @@ -222,9 +316,36 @@ install_authority_helper() { fi } +install_authority_const_params_registry() { + source_root="$1" + worktree="$2" + + [ "$source_root" = "$worktree" ] && return + mkdir -p "$worktree/$(dirname "$CONST_PARAMS_REGISTRY_REL")" + rm -f "$worktree/$CONST_PARAMS_REGISTRY_REL" + cp "$source_root/$CONST_PARAMS_REGISTRY_REL" "$worktree/$CONST_PARAMS_REGISTRY_REL" +} + EMPTY_MANIFEST="$TMP_ROOT/empty-migrations.json" printf '%s\n' '{"version":1,"migrations":[]}' >"$EMPTY_MANIFEST" USE_MIGRATION_GOVERNANCE=false +USE_COMMAND_MIGRATION_GOVERNANCE=false + +if has_complete_command_migration_governance "$BASE_COMMIT"; then + USE_COMMAND_MIGRATION_GOVERNANCE=true + APPROVED_COMMAND_MANIFEST="$BASE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL" + CANDIDATE_COMMAND_MANIFEST="$CANDIDATE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL" + require_complete_candidate_command_governance + require_base_identical_command_migration_bridges +elif has_any_command_migration_governance_artifact "$BASE_COMMIT"; then + printf 'merge-base contains an incomplete command migration governance artifact set: %s\n' \ + "$BASE_REF" >&2 + exit 2 +elif has_any_command_migration_governance_artifact "$CANDIDATE_COMMIT"; then + # Bootstrap is safe because the base-owned comparator receives no new + # authorization input and still performs the ordinary compatibility check. + require_complete_candidate_command_governance +fi if has_complete_migration_governance "$BASE_COMMIT"; then USE_MIGRATION_GOVERNANCE=true @@ -233,6 +354,7 @@ if has_complete_migration_governance "$BASE_COMMIT"; then CANDIDATE_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL" require_complete_candidate_governance check_candidate_alias_source_policy + check_candidate_const_params_source_policy install_authority_helper "$AUTHORITY_ROOT" "$CANDIDATE_WORKTREE" true else @@ -252,6 +374,7 @@ else AUTHORITY_ROOT="$BASE_WORKTREE" require_complete_candidate_governance check_candidate_alias_source_policy + check_candidate_const_params_source_policy BOOTSTRAP_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL" if ! cmp -s "$BOOTSTRAP_MANIFEST" "$EMPTY_MANIFEST"; then printf 'initial flag migration governance requires the canonical empty manifest: %s\n' "$BOOTSTRAP_MANIFEST" >&2 @@ -262,6 +385,11 @@ fi install_authority_helper "$AUTHORITY_ROOT" "$BASE_WORKTREE" "$USE_MIGRATION_GOVERNANCE" install_authority_helper "$AUTHORITY_ROOT" "$STABLE_WORKTREE" "$USE_MIGRATION_GOVERNANCE" +if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then + install_authority_const_params_registry "$AUTHORITY_ROOT" "$CANDIDATE_WORKTREE" + install_authority_const_params_registry "$AUTHORITY_ROOT" "$BASE_WORKTREE" + install_authority_const_params_registry "$AUTHORITY_ROOT" "$STABLE_WORKTREE" +fi generate_snapshot() { worktree="$1" @@ -280,7 +408,19 @@ generate_snapshot "$CANDIDATE_WORKTREE" "$CANDIDATE" generate_snapshot "$BASE_WORKTREE" "$BASELINE" generate_snapshot "$STABLE_WORKTREE" "$STABLE" -if [ "$USE_MIGRATION_GOVERNANCE" = true ]; then +if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then + ( + cd "$AUTHORITY_ROOT" + go run ./cmd/interface-snapshot compare \ + --current "$CANDIDATE" \ + --base "$BASELINE" \ + --stable "$STABLE" \ + --approved-flag-migrations "$APPROVED_MANIFEST" \ + --candidate-flag-migrations "$CANDIDATE_MANIFEST" \ + --approved-command-migrations "$APPROVED_COMMAND_MANIFEST" \ + --candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST" + ) +elif [ "$USE_MIGRATION_GOVERNANCE" = true ]; then ( cd "$AUTHORITY_ROOT" go run ./cmd/interface-snapshot compare \ diff --git a/scripts/policy/commandcompat/authority_integration_test.go b/scripts/policy/commandcompat/authority_integration_test.go index 9f1ddd8b..494a50e2 100644 --- a/scripts/policy/commandcompat/authority_integration_test.go +++ b/scripts/policy/commandcompat/authority_integration_test.go @@ -51,6 +51,8 @@ func main() { base := flags.String("base", "", "base snapshot") stable := flags.String("stable", "", "stable snapshot") _ = flags.String("approved-flag-migrations", "", "approved manifest") + _ = flags.String("approved-command-migrations", "", "approved command manifest") + _ = flags.String("candidate-command-migrations", "", "candidate command manifest") _ = flags.Parse(os.Args[2:]) for _, path := range []string{*current, *base, *stable} { content, err := os.ReadFile(path) @@ -97,6 +99,27 @@ var ( func AuthorityMarker() string { return "BASE" } ` +const commandGovernedSnapshotSource = `package interfacesnapshot + +import ( + "example.com/dws-command-compat-authority-test/internal/corecmd" + "example.com/dws-command-compat-authority-test/internal/corecmd/runtimeannotate" +) + +var ( + _ = runtimeannotate.AnnotationFlagAliasOf + _ = runtimeannotate.AnnotationFlagAliasOrigin + _ = runtimeannotate.FlagAliasOriginCorecmdV1 +) + +func AuthorityMarker() string { + if corecmd.InterfaceBoolConstParams() != "BASE" { + return "UNTRUSTED_CONST_PARAMS" + } + return "BASE" +} +` + const baseSnapshotSource = `package interfacesnapshot func AuthorityMarker() string { return "BASE" } @@ -121,6 +144,54 @@ const ( ) ` +func constParamsRegistrySource(marker string) string { + return fmt.Sprintf(`package corecmd + +type constParamsRegistry struct{} + +func (constParamsRegistry) Store(any, any) {} + +var interfaceBoolConstParamsRegistry constParamsRegistry + +func attachInterfaceBoolConstParams() {} + +func InterfaceBoolConstParams() string { return %q } +`, marker) +} + +const baseCorecmdBridgeSource = `package corecmd + +func boolConstParams(map[string]any) map[string]bool { + return map[string]bool{"convThreadEnabled": true} +} + +func installConstParamsEvidence() { attachInterfaceBoolConstParams() } +` + +const forgedCorecmdBridgeSource = `package corecmd + +func boolConstParams(map[string]any) map[string]bool { + return map[string]bool{"convThreadEnabled": false} +} + +func installConstParamsEvidence() { attachInterfaceBoolConstParams() } +` + +const baseLeafAdapterSource = `package helpers + +func forwardToolArgs(toolArgs map[string]any) map[string]any { + return toolArgs +} +` + +const forgedLeafAdapterSource = `package helpers + +func forwardToolArgs(toolArgs map[string]any) map[string]any { + delete(toolArgs, "convThreadEnabled") + return toolArgs +} +` + const candidateNoopSource = `package main import "fmt" @@ -131,11 +202,13 @@ func main() { ` type authorityScenario struct { - name string - baseGovernance string - candidateMutation string - want string - wantAuthorityDecision bool + name string + baseGovernance string + commandGovernance string + commandManifestChanged bool + candidateMutation string + want string + wantAuthorityDecision bool } func TestCrossPlatformCoverageCommandCompatibilityUsesBaseOwnedAuthority(t *testing.T) { @@ -150,6 +223,85 @@ func TestCrossPlatformCoverageCommandCompatibilityUsesBaseOwnedAuthority(t *test baseGovernance: "none", wantAuthorityDecision: true, }, + { + name: "command governed base owns bool ConstParams registry", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "change-const-params-protocol", + wantAuthorityDecision: true, + }, + { + name: "command governance bootstrap preserves candidate bool ConstParams registry", + baseGovernance: "complete", + commandGovernance: "bootstrap", + wantAuthorityDecision: true, + }, + { + name: "command governed candidate cannot delete bool ConstParams registry", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "delete-const-params-contract", + want: "must preserve the complete command migration governance artifact set", + }, + { + name: "same package candidate cannot call private ConstParams writer", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-token", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "candidate cannot reopen private ConstParams reader", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-reader", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "same package candidate cannot store directly in ConstParams registry", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-registry-store", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "changed command manifest rejects forged corecmd bool ConstParams bridge", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "forge-corecmd-bool-const-params", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/corecmd.go", + }, + { + name: "changed command manifest rejects tampered ConstParams protocol", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "change-const-params-protocol", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/interface_const_params.go", + }, + { + name: "changed command manifest rejects tampered leaf adapter", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "forge-leaf-adapter", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/helpers/leaf.go", + }, + { + name: "changed command manifest allows base identical bridges", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + wantAuthorityDecision: true, + }, + { + name: "unchanged command manifest allows independent corecmd change", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-corecmd-bool-const-params", + wantAuthorityDecision: true, + }, { name: "partial base fails closed", baseGovernance: "partial", @@ -274,6 +426,19 @@ func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) { default: t.Fatalf("unknown base governance state %q", test.baseGovernance) } + switch test.commandGovernance { + case "": + case "complete": + writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), commandGovernedSnapshotSource, 0o644) + writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644) + writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), baseCorecmdBridgeSource, 0o644) + writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), constParamsRegistrySource("BASE"), 0o644) + writeFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), baseLeafAdapterSource, 0o644) + writeFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), "{\"version\":1,\"migrations\":[]}\n", 0o644) + case "bootstrap": + default: + t.Fatalf("unknown command governance state %q", test.commandGovernance) + } run(t, fixtureRoot, "git", "add", ".") run(t, fixtureRoot, "git", "commit", "-m", "base authority") baseRef := strings.TrimSpace(run(t, fixtureRoot, "git", "rev-parse", "HEAD")) @@ -302,6 +467,17 @@ func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) { 0o644, ) } + if test.commandGovernance == "complete" || test.commandGovernance == "bootstrap" { + writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644) + if test.commandGovernance == "bootstrap" { + writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), constParamsRegistrySource("CANDIDATE"), 0o644) + } + commandManifest := "{\"version\":1,\"migrations\":[]}\n" + if test.commandManifestChanged { + commandManifest = "{\"version\":1,\"migrations\":[{\"candidate\":\"PENDING\"}]}\n" + } + writeFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), commandManifest, 0o644) + } switch test.candidateMutation { case "": case "delete-alias-contract": @@ -314,6 +490,34 @@ func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) { "package evil\n\nconst forged = \"dws.compat.alias_of\"\n", 0o644, ) + case "delete-const-params-contract": + if err := os.Remove(filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go")); err != nil { + t.Fatalf("remove candidate bool ConstParams registry: %v", err) + } + case "forge-const-params-token": + writeFile(t, + filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"), + "package corecmd\n\nfunc forgeConstParams() { attachInterfaceBoolConstParams() }\n", + 0o644, + ) + case "forge-const-params-reader": + writeFile(t, + filepath.Join(fixtureRoot, "internal", "evil", "forged.go"), + "package evil\n\nconst forged = \"InterfaceBoolConstParams\"\n", + 0o644, + ) + case "forge-const-params-registry-store": + writeFile(t, + filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"), + "package corecmd\n\nfunc forgeConstParamsStore() { interfaceBoolConstParamsRegistry.Store(nil, map[string]bool{\"forged\": true}) }\n", + 0o644, + ) + case "forge-corecmd-bool-const-params": + writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), forgedCorecmdBridgeSource, 0o644) + case "change-const-params-protocol": + writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), constParamsRegistrySource("CANDIDATE"), 0o644) + case "forge-leaf-adapter": + writeFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), forgedLeafAdapterSource, 0o644) case "symlink-helper-parent": helperPath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot") alternatePath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot-alt") diff --git a/scripts/policy/interface-migrations/approved-command-migrations-v1.json b/scripts/policy/interface-migrations/approved-command-migrations-v1.json new file mode 100644 index 00000000..52a6bff1 --- /dev/null +++ b/scripts/policy/interface-migrations/approved-command-migrations-v1.json @@ -0,0 +1,91 @@ +{ + "version": 1, + "migrations": [ + { + "kind": "flag_extraction", + "legacy": { + "command": "dws chat group create", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true} + }, + "replacement": { + "command": "dws chat topic create", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "legacy_flag": { + "name": "thread", + "before": {"present": true, "type": "bool", "no_opt": "true", "scope": "local"}, + "after": {"present": true, "type": "bool", "hidden": true, "no_opt": "true", "scope": "local"} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.create_group_conversation", + "replacement_tool_id": "chat.create_topic_conversation", + "parameters": [ + {"from": "name", "to": "name"}, + {"from": "type", "to": "type"}, + {"from": "users", "to": "users"}, + { + "from": "thread", + "replacement_constant": { + "property": "convThreadEnabled", + "value": true + } + } + ] + }, + "state": "pending", + "reason": "Move topic-conversation creation to the dedicated chat topic command while retaining the legacy --thread execution path." + }, + { + "kind": "command_move", + "legacy": { + "command": "dws chat message list-topic-replies", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true, "hidden": true} + }, + "replacement": { + "command": "dws chat topic list-replies", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.list_topic_replies", + "replacement_tool_id": "chat.list_topic_replies", + "parameters": [ + {"from": "conversation-id", "to": "open-topic-id"}, + {"from": "topic-id", "to": "open-conv-thread-id"} + ] + }, + "state": "pending", + "reason": "Move topic reply listing from chat message to the dedicated chat topic command while preserving the legacy executable command." + }, + { + "kind": "command_move", + "legacy": { + "command": "dws chat message forward-topic", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true, "hidden": true} + }, + "replacement": { + "command": "dws chat topic forward", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.forward_topic", + "replacement_tool_id": "chat.forward_topic", + "parameters": [ + {"from": "src-conversation-id", "to": "src-open-topic-id"}, + {"from": "src-thread-id", "to": "src-open-conv-thread-id"}, + {"from": "dest-conversation-id", "to": "dest-open-conversation-id"} + ] + }, + "state": "pending", + "reason": "Move topic forwarding from chat message to the dedicated chat topic command while preserving the legacy executable command." + } + ] +} diff --git a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json index 700f46d0..c1c596fa 100644 --- a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json +++ b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json @@ -790,7 +790,7 @@ "required": true } }, - "state": "pending", + "state": "consumed", "reason": "保留旧 argv 兼容性,并将单值群身份 flag 设为唯一可见入口" }, { diff --git a/scripts/policy/schema-compat/command_migrations_test.go b/scripts/policy/schema-compat/command_migrations_test.go new file mode 100644 index 00000000..ada5dcc7 --- /dev/null +++ b/scripts/policy/schema-compat/command_migrations_test.go @@ -0,0 +1,867 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot" +) + +func TestCrossPlatformCoverageSchemaCommandMigrationsAuthorizeOnlyExactProjection(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + normalized, err := normalizeSchemaCommandMigrations(baseline, current, migrations) + if err != nil { + t.Fatalf("normalize exact command migrations: %v", err) + } + if failures := checkCompatibility(normalized, current); len(failures) != 0 { + t.Fatalf("exact command migrations remained incompatible: %v", failures) + } + + unrelated := cloneContract(current) + product := unrelated.Products["chat"] + tool := product.Tools["chat.move"] + delete(tool.Parameters, "keep") + product.Tools["chat.move"] = tool + unrelated.Products["chat"] = product + normalized, err = normalizeSchemaCommandMigrations(baseline, unrelated, migrations) + if err != nil { + t.Fatal(err) + } + if failures := strings.Join(checkCompatibility(normalized, unrelated), "\n"); !strings.Contains(failures, `lost parameter "keep"`) { + t.Fatalf("unrelated parameter removal was hidden: %s", failures) + } +} + +func TestCrossPlatformCoverageSchemaCommandMigrationsFailClosedOnDrift(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + migrations := schemaCommandMigrationAuthorizations() + + parameterDrift := schemaCommandMigrationContract(true) + product := parameterDrift.Products["chat"] + tool := product.Tools["chat.move"] + parameter := tool.Parameters["new-id"] + parameter.Property = "differentProperty" + tool.Parameters["new-id"] = parameter + product.Tools["chat.move"] = tool + parameterDrift.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, parameterDrift, migrations); err == nil || !strings.Contains(err.Error(), "changed a non-name field") { + t.Fatalf("parameter drift error=%v", err) + } + + safetyDrift := schemaCommandMigrationContract(true) + product = safetyDrift.Products["chat"] + replacement := product.Tools["chat.create_topic"] + replacement.Risk = "high" + product.Tools["chat.create_topic"] = replacement + safetyDrift.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, safetyDrift, migrations); err == nil || !strings.Contains(err.Error(), "changed interface or safety identity") { + t.Fatalf("extraction safety drift error=%v", err) + } + + missingReplacement := schemaCommandMigrationContract(true) + product = missingReplacement.Products["chat"] + delete(product.Tools, "chat.create_topic") + missingReplacement.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, missingReplacement, migrations); err == nil || !strings.Contains(err.Error(), "lacks replacement Schema tool") { + t.Fatalf("missing replacement error=%v", err) + } +} + +func TestCrossPlatformCoverageSchemaCommandMigrationRejectsUnregisteredRequiredParameters(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + migrations := schemaCommandMigrationAuthorizations() + + for _, test := range []struct { + name string + parameter parameterSchema + }{ + {name: "required", parameter: parameterSchema{Type: `"string"`, Property: "must", Required: true}}, + {name: "cli_required", parameter: parameterSchema{Type: `"string"`, Property: "must", CLIRequired: true}}, + {name: "required_when", parameter: parameterSchema{Type: `"string"`, Property: "must", RequiredWhen: "mode=forced"}}, + } { + t.Run(test.name, func(t *testing.T) { + current := schemaCommandMigrationContract(true) + product := current.Products["chat"] + tool := product.Tools["chat.move"] + tool.Parameters["must"] = test.parameter + product.Tools["chat.move"] = tool + current.Products["chat"] = product + + if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil || + !strings.Contains(err.Error(), `introduced unregistered required Schema parameter "must"`) { + t.Fatalf("unregistered required parameter error=%v", err) + } + }) + } + + current := schemaCommandMigrationContract(true) + product := current.Products["chat"] + tool := product.Tools["chat.move"] + tool.Parameters["optional"] = parameterSchema{Type: `"string"`, Property: "optional"} + product.Tools["chat.move"] = tool + current.Products["chat"] = product + normalized, err := normalizeSchemaCommandMigrations(baseline, current, migrations) + if err != nil { + t.Fatalf("optional addition should remain compatible: %v", err) + } + if failures := checkCompatibility(normalized, current); len(failures) != 0 { + t.Fatalf("optional addition should remain compatible: %v", failures) + } +} + +func TestCrossPlatformCoverageSchemaCommandMigrationRejectsParameterTargetCollision(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + migrations[0].Schema.Parameters = []interfacesnapshot.CommandParameterMigration{{From: "old-id", To: "keep"}} + + product := current.Products["chat"] + tool := product.Tools["chat.move"] + delete(tool.Parameters, "new-id") + tool.Parameters["keep"] = baseline.Products["chat"].Tools["chat.move"].Parameters["old-id"] + tool.Constraints = baseline.Products["chat"].Tools["chat.move"].Constraints + product.Tools["chat.move"] = tool + current.Products["chat"] = product + + normalized, err := normalizeSchemaCommandMigrations(baseline, current, migrations) + if err == nil { + if failures := checkCompatibility(normalized, current); len(failures) != 0 { + t.Fatalf("hostile target-collision fixture should otherwise pass compatibility: %v", failures) + } + t.Fatal("parameter target collision was accepted") + } + if !strings.Contains(err.Error(), `target "keep" already exists in historical Schema tool`) { + t.Fatalf("parameter target collision error=%v", err) + } +} + +func TestCrossPlatformCoverageSchemaCommandMigrationNormalizationEdges(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + + missingHistorical := cloneContract(baseline) + product := missingHistorical.Products["chat"] + delete(product.Tools, "chat.move") + missingHistorical.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(missingHistorical, current, migrations); err != nil { + t.Fatalf("missing historical tool should be a no-op: %v", err) + } + + missingCurrent := cloneContract(current) + product = missingCurrent.Products["chat"] + delete(product.Tools, "chat.move") + missingCurrent.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, missingCurrent, migrations); err != nil { + t.Fatalf("missing current source should remain for ordinary checker: %v", err) + } + + alreadyAfter := schemaCommandMigrationContract(true) + if _, err := normalizeSchemaCommandMigrations(alreadyAfter, current, migrations[:1]); err != nil { + t.Fatalf("already-after baseline should be a no-op: %v", err) + } + + wrongHistoricalPath := cloneContract(baseline) + product = wrongHistoricalPath.Products["chat"] + tool := product.Tools["chat.move"] + tool.PrimaryCLIPath = "chat unrelated" + product.Tools["chat.move"] = tool + wrongHistoricalPath.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(wrongHistoricalPath, current, migrations); err == nil || !strings.Contains(err.Error(), "historical Schema tool") { + t.Fatalf("wrong historical path error=%v", err) + } + + wrongCurrentPath := cloneContract(current) + product = wrongCurrentPath.Products["chat"] + tool = product.Tools["chat.move"] + tool.PrimaryCLIPath = "chat unrelated" + product.Tools["chat.move"] = tool + wrongCurrentPath.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, wrongCurrentPath, migrations); err != nil { + t.Fatalf("wrong current path should remain for ordinary checker: %v", err) + } + + missingHistoricalParameter := cloneContract(baseline) + product = missingHistoricalParameter.Products["chat"] + tool = product.Tools["chat.move"] + delete(tool.Parameters, "old-id") + product.Tools["chat.move"] = tool + missingHistoricalParameter.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(missingHistoricalParameter, current, migrations); err == nil || !strings.Contains(err.Error(), "lacks parameter") { + t.Fatalf("missing historical parameter error=%v", err) + } + + legacyPublished := cloneContract(current) + product = legacyPublished.Products["chat"] + tool = product.Tools["chat.move"] + tool.Parameters["old-id"] = baseline.Products["chat"].Tools["chat.move"].Parameters["old-id"] + product.Tools["chat.move"] = tool + legacyPublished.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, legacyPublished, migrations); err == nil || !strings.Contains(err.Error(), "still publishes legacy") { + t.Fatalf("legacy parameter error=%v", err) + } + + missingReplacementParameter := cloneContract(current) + product = missingReplacementParameter.Products["chat"] + tool = product.Tools["chat.move"] + delete(tool.Parameters, "new-id") + product.Tools["chat.move"] = tool + missingReplacementParameter.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, missingReplacementParameter, migrations); err == nil || !strings.Contains(err.Error(), "does not publish replacement") { + t.Fatalf("missing replacement parameter error=%v", err) + } + + constraintDrift := cloneContract(current) + product = constraintDrift.Products["chat"] + tool = product.Tools["chat.move"] + tool.Constraints = `{"require_one_of":[["new-id"]]}` + product.Tools["chat.move"] = tool + constraintDrift.Products["chat"] = product + normalized, err := normalizeSchemaCommandMigrations(baseline, constraintDrift, migrations) + if err != nil { + t.Fatal(err) + } + if failures := strings.Join(checkCompatibility(normalized, constraintDrift), "\n"); !strings.Contains(failures, "changed constraints") { + t.Fatalf("constraint drift was hidden: %s", failures) + } + + extractionWrongSource := cloneContract(current) + product = extractionWrongSource.Products["chat"] + tool = product.Tools["chat.create_group"] + tool.PrimaryCLIPath = "chat unrelated" + product.Tools["chat.create_group"] = tool + extractionWrongSource.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, extractionWrongSource, migrations[1:]); err != nil { + t.Fatalf("wrong extraction source path should remain for ordinary checker: %v", err) + } + + extractionHistoricalMissing := cloneContract(baseline) + product = extractionHistoricalMissing.Products["chat"] + tool = product.Tools["chat.create_group"] + delete(tool.Parameters, "thread") + product.Tools["chat.create_group"] = tool + extractionHistoricalMissing.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(extractionHistoricalMissing, current, migrations[1:]); err == nil || !strings.Contains(err.Error(), "historical Schema tool lacks") { + t.Fatalf("missing extracted historical parameter error=%v", err) + } + + extractionStillPublished := cloneContract(current) + product = extractionStillPublished.Products["chat"] + tool = product.Tools["chat.create_group"] + tool.Parameters["thread"] = baseline.Products["chat"].Tools["chat.create_group"].Parameters["thread"] + product.Tools["chat.create_group"] = tool + extractionStillPublished.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, extractionStillPublished, migrations[1:]); err == nil || !strings.Contains(err.Error(), "still publishes extracted") { + t.Fatalf("still-published extraction error=%v", err) + } +} + +func TestCrossPlatformCoverageSchemaFlagExtractionRequiresCompleteReplacementProjection(t *testing.T) { + t.Run("missing historical mapping", func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + parameters := make([]interfacesnapshot.CommandParameterMigration, 0, len(migrations[1].Schema.Parameters)-1) + for _, parameter := range migrations[1].Schema.Parameters { + if parameter.From != "users" { + parameters = append(parameters, parameter) + } + } + migrations[1].Schema.Parameters = parameters + + if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil || + !strings.Contains(err.Error(), `does not map historical Schema parameter "users"`) { + t.Fatalf("missing historical mapping error=%v", err) + } + }) + + for _, test := range []struct { + name string + mutate func([]interfacesnapshot.CommandMigration) + want string + }{ + { + name: "duplicate historical mapping", + mutate: func(migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters = append( + migrations[1].Schema.Parameters, + migrations[1].Schema.Parameters[0], + ) + }, + want: "maps historical Schema parameter \"name\" more than once", + }, + { + name: "legacy mapping has two targets", + mutate: func(migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters[3].To = "thread" + }, + want: "must map to one replacement constant", + }, + { + name: "shared mapping has no target", + mutate: func(migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters[0].To = "" + }, + want: "must map to one replacement parameter", + }, + { + name: "duplicate replacement target", + mutate: func(migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters[1].To = "name" + }, + want: "is mapped from both \"name\" and \"type\"", + }, + } { + t.Run(test.name, func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + test.mutate(migrations) + if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil || + !strings.Contains(err.Error(), test.want) { + t.Fatalf("invalid mapping error=%v, want %q", err, test.want) + } + }) + } + + t.Run("replacement removes historical dry run", func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + mutateSchemaCommandMigrationTool(&baseline, "chat.create_group", func(tool *toolSchema) { + tool.DryRun = `{"mode":"native"}` + }) + current := schemaCommandMigrationContract(true) + mutateSchemaCommandMigrationTool(¤t, "chat.create_group", func(tool *toolSchema) { + tool.DryRun = `{"mode":"native"}` + }) + + if _, err := normalizeSchemaCommandMigrations(baseline, current, schemaCommandMigrationAuthorizations()); err == nil || + !strings.Contains(err.Error(), "changed or removed dry_run") { + t.Fatalf("replacement dry_run error=%v, want monotonic preservation rejection", err) + } + }) + + t.Run("replacement adds dry run", func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + mutateSchemaCommandMigrationTool(¤t, "chat.create_topic", func(tool *toolSchema) { + tool.DryRun = `{"mode":"native"}` + }) + + if _, err := normalizeSchemaCommandMigrations(baseline, current, schemaCommandMigrationAuthorizations()); err != nil { + t.Fatalf("additive replacement dry_run was rejected: %v", err) + } + }) + + for _, test := range []struct { + name string + mutate func(*schemaContract) + want string + }{ + { + name: "missing shared parameter", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + delete(tool.Parameters, "users") + }) + }, + want: `does not publish mapped Schema parameter "users"`, + }, + { + name: "shared parameter field drift", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationParameter(current, "chat.create_topic", "users", func(parameter *parameterSchema) { + parameter.Property = "differentUserIds" + }) + }, + want: "changed a non-name field", + }, + { + name: "extra replacement parameter", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + tool.Parameters["extra"] = parameterSchema{Type: `"string"`, Property: "extra"} + }) + }, + want: `publishes unmapped Schema parameter "extra"`, + }, + { + name: "constraints drift", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + tool.Constraints = `{"require_one_of":[["name","type"]]}` + }) + }, + want: "changed constraints", + }, + { + name: "positionals drift", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + tool.Positionals[0].Name = "topic-name" + }) + }, + want: "changed positionals", + }, + { + name: "positionals length drift", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + tool.Positionals = append(tool.Positionals, positionalSchema{Name: "type", Index: 1, Type: "string"}) + }) + }, + want: "changed positionals", + }, + } { + t.Run(test.name, func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + test.mutate(¤t) + if _, err := normalizeSchemaCommandMigrations(baseline, current, schemaCommandMigrationAuthorizations()); err == nil || + !strings.Contains(err.Error(), test.want) { + t.Fatalf("replacement projection error=%v, want %q", err, test.want) + } + }) + } +} + +func TestCrossPlatformCoverageSchemaFlagExtractionValidatesBooleanConstant(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*schemaContract, *schemaContract, []interfacesnapshot.CommandMigration) + want string + }{ + { + name: "constant property mismatch", + mutate: func(_, _ *schemaContract, migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters[3].ReplacementConstant.Property = "differentProperty" + }, + want: "constant property", + }, + { + name: "constant false", + mutate: func(_, _ *schemaContract, migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters[3].ReplacementConstant.Value = false + }, + want: "constant true", + }, + { + name: "legacy type", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.Type = `"string"` + }) + }, + want: "must have boolean type", + }, + { + name: "legacy required", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.Required = true + }) + }, + want: "must remain optional", + }, + { + name: "legacy cli required", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.CLIRequired = true + }) + }, + want: "must remain optional", + }, + { + name: "legacy required when", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.RequiredWhen = "mode=topic" + }) + }, + want: "must not declare required_when", + }, + { + name: "legacy default true", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.Default = "true" + }) + }, + want: "default must be absent or false", + }, + { + name: "legacy interface default true", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.InterfaceDefault = "true" + }) + }, + want: "interface_default must be absent or false", + }, + { + name: "legacy interface type", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.InterfaceType = "string" + }) + }, + want: "interface_type must be empty or boolean", + }, + { + name: "legacy enum excludes true", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.Enum = []string{"false"} + }) + }, + want: "enum must allow true", + }, + { + name: "legacy format", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.Format = "custom-bool" + }) + }, + want: "format must be empty", + }, + { + name: "replacement publishes constant property", + mutate: func(_, current *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + tool.Parameters["thread-copy"] = parameterSchema{Type: `"boolean"`, Property: "threadEnabled"} + }) + }, + want: "still publishes replacement constant property", + }, + { + name: "source renames constant property", + mutate: func(_, current *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(current, "chat.create_group", func(tool *toolSchema) { + tool.Parameters["thread-copy"] = parameterSchema{Type: `"boolean"`, Property: "threadEnabled"} + }) + }, + want: "source Schema tool still publishes replacement constant property", + }, + } { + t.Run(test.name, func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + test.mutate(&baseline, ¤t, migrations) + if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil || + !strings.Contains(err.Error(), test.want) { + t.Fatalf("replacement constant error=%v, want %q", err, test.want) + } + }) + } +} + +func TestCrossPlatformCoverageSchemaCommandMigrationLifecycleAndRun(t *testing.T) { + directory := t.TempDir() + baselinePath := filepath.Join(directory, "baseline.json") + currentPath := filepath.Join(directory, "current.json") + approvedPath := filepath.Join(directory, "approved.json") + candidatePath := filepath.Join(directory, "candidate.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, schemaCommandMigrationContract(false)) + writeRawSchemaContractFile(t, currentPath, schemaCommandMigrationContract(true)) + writeCommandMigrationManifestFile(t, approvedPath, schemaCommandMigrationManifest(interfacesnapshot.CommandMigrationPending)) + writeCommandMigrationManifestFile(t, candidatePath, schemaCommandMigrationManifest(interfacesnapshot.CommandMigrationConsumed)) + writeInterfaceSnapshotFile(t, currentSnapshotPath, schemaCommandMigrationSnapshot(true)) + writeInterfaceSnapshotFile(t, baseSnapshotPath, schemaCommandMigrationSnapshot(false)) + writeInterfaceSnapshotFile(t, stableSnapshotPath, schemaCommandMigrationSnapshot(false)) + + args := []string{ + "--check", baselinePath, + "--current", currentPath, + "--approved-command-migrations", approvedPath, + "--candidate-command-migrations", candidatePath, + "--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("command migration run code=%d stderr=%s", code, stderr.String()) + } + + paths := []string{approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath} + for index := range paths { + invalid := append([]string(nil), paths...) + invalid[index] = filepath.Join(directory, "missing.json") + if _, err := authorizeSchemaCommandMigrations(invalid[0], invalid[1], invalid[2], invalid[3], invalid[4]); err == nil { + t.Fatalf("missing command migration input %d was accepted", index) + } + } + + stderr.Reset() + if code := run([]string{"--check", baselinePath, "--current", currentPath, "--approved-command-migrations", approvedPath}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "both command manifests") { + t.Fatalf("partial command pair code=%d stderr=%q", code, stderr.String()) + } + stderr.Reset() + if code := run([]string{ + "--check", baselinePath, + "--current", currentPath, + "--approved-command-migrations", approvedPath, + "--candidate-command-migrations", candidatePath, + }, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "all three interface snapshots") { + t.Fatalf("missing command snapshots code=%d stderr=%q", code, stderr.String()) + } + stderr.Reset() + if code := run([]string{"--check", baselinePath, "--current", currentPath, "--migration-current-snapshot", currentSnapshotPath}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "require a flag or command") { + t.Fatalf("orphan snapshot code=%d stderr=%q", code, stderr.String()) + } + + badArgs := append([]string(nil), args...) + badArgs[5] = filepath.Join(directory, "missing-approved.json") + stderr.Reset() + if code := run(badArgs, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "authorize Schema command migrations") { + t.Fatalf("command authorization error code=%d stderr=%q", code, stderr.String()) + } + + legacyCurrent := schemaCommandMigrationContract(true) + product := legacyCurrent.Products["chat"] + tool := product.Tools["chat.move"] + tool.Parameters["old-id"] = schemaCommandMigrationContract(false).Products["chat"].Tools["chat.move"].Parameters["old-id"] + product.Tools["chat.move"] = tool + legacyCurrent.Products["chat"] = product + writeRawSchemaContractFile(t, currentPath, legacyCurrent) + stderr.Reset() + if code := run(args, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "normalize approved Schema command migrations") { + t.Fatalf("command normalization error code=%d stderr=%q", code, stderr.String()) + } + + requiredAddition := schemaCommandMigrationContract(true) + product = requiredAddition.Products["chat"] + tool = product.Tools["chat.move"] + tool.Parameters["must"] = parameterSchema{Type: `"string"`, Property: "must", Required: true, CLIRequired: true} + product.Tools["chat.move"] = tool + requiredAddition.Products["chat"] = product + writeRawSchemaContractFile(t, currentPath, requiredAddition) + stderr.Reset() + if code := run(args, &stdout, &stderr); code != 2 || + !strings.Contains(stderr.String(), `introduced unregistered required Schema parameter "must"`) { + t.Fatalf("unregistered required addition code=%d stderr=%q", code, stderr.String()) + } +} + +func schemaCommandMigrationContract(after bool) schemaContract { + id := parameterSchema{Type: `"string"`, Property: "resourceId", Required: true, CLIRequired: true} + keep := parameterSchema{Type: `"string"`, Property: "keep"} + name := parameterSchema{Type: `"string"`, Property: "name"} + groupType := parameterSchema{Type: `"string"`, Property: "type"} + users := parameterSchema{Type: `"array"`, Property: "userIds"} + thread := parameterSchema{ + Type: `"boolean"`, + Property: "threadEnabled", + InterfaceType: "boolean", + Default: "false", + InterfaceDefault: "false", + Enum: []string{"false", "true"}, + } + group := toolSchema{ + PrimaryCLIPath: "chat group create", + InterfaceMode: "mcp", + InterfaceRef: `{"product_id":"im","rpc_name":"create_group"}`, + Availability: "available", + Parameters: map[string]parameterSchema{ + "name": name, + "thread": thread, + "type": groupType, + "users": users, + }, + Constraints: `{"require_together":[["name","type"]]}`, + Positionals: []positionalSchema{{Name: "name", Index: 0, Type: "string", Required: true}}, + Effect: "write", + Risk: "medium", + Confirmation: "not_required", + Idempotency: "unknown", + } + move := toolSchema{ + PrimaryCLIPath: "chat message old", + InterfaceMode: "mcp", + InterfaceRef: `{"product_id":"chat","rpc_name":"move"}`, + Availability: "available", + Parameters: map[string]parameterSchema{"old-id": id, "keep": keep}, + Constraints: `{"require_together":[["keep","old-id"]]}`, + Effect: "read", + Risk: "low", + Confirmation: "not_required", + Idempotency: "idempotent", + } + tools := map[string]toolSchema{"chat.create_group": group, "chat.move": move} + if after { + delete(group.Parameters, "thread") + tools["chat.create_group"] = group + replacement := group + replacement.Parameters = make(map[string]parameterSchema, len(group.Parameters)) + for name, parameter := range group.Parameters { + replacement.Parameters[name] = parameter + } + replacement.Positionals = append([]positionalSchema(nil), group.Positionals...) + replacement.PrimaryCLIPath = "chat topic create" + tools["chat.create_topic"] = replacement + delete(move.Parameters, "old-id") + move.Parameters["new-id"] = id + move.PrimaryCLIPath = "chat topic new" + move.Constraints = `{"require_together":[["keep","new-id"]]}` + tools["chat.move"] = move + } + return schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{"chat": {Tools: tools}}} +} + +func schemaCommandMigrationAuthorizations() []interfacesnapshot.CommandMigration { + return []interfacesnapshot.CommandMigration{ + { + Kind: interfacesnapshot.CommandMigrationMove, + Legacy: interfacesnapshot.CommandMigrationSide{ + Command: "dws chat message old", + Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true, Hidden: true}, + }, + Replacement: interfacesnapshot.CommandMigrationSide{ + Command: "dws chat topic new", + After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + }, + Schema: interfacesnapshot.CommandMigrationSchema{ + ProductID: "chat", + SourceToolID: "chat.move", + ReplacementToolID: "chat.move", + Parameters: []interfacesnapshot.CommandParameterMigration{{From: "old-id", To: "new-id"}}, + }, + }, + { + Kind: interfacesnapshot.CommandMigrationFlagExtraction, + Legacy: interfacesnapshot.CommandMigrationSide{ + Command: "dws chat group create", + Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + }, + Replacement: interfacesnapshot.CommandMigrationSide{ + Command: "dws chat topic create", + After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + }, + LegacyFlag: interfacesnapshot.CommandMigrationFlag{ + Name: "thread", + Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "bool", NoOpt: "true", Scope: "local"}, + After: interfacesnapshot.FlagMigrationState{Present: true, Type: "bool", Hidden: true, NoOpt: "true", Scope: "local"}, + }, + Schema: interfacesnapshot.CommandMigrationSchema{ + ProductID: "chat", + SourceToolID: "chat.create_group", + ReplacementToolID: "chat.create_topic", + Parameters: []interfacesnapshot.CommandParameterMigration{ + {From: "name", To: "name"}, + {From: "type", To: "type"}, + {From: "users", To: "users"}, + { + From: "thread", + ReplacementConstant: &interfacesnapshot.CommandReplacementConstant{ + Property: "threadEnabled", + Value: true, + }, + }, + }, + }, + }, + } +} + +func schemaCommandMigrationManifest(state string) interfacesnapshot.CommandMigrationManifest { + migrations := schemaCommandMigrationAuthorizations() + for index := range migrations { + migrations[index].State = state + migrations[index].Reason = "Reviewed Schema command migration." + } + return interfacesnapshot.CommandMigrationManifest{ + Version: interfacesnapshot.CommandMigrationManifestVersion, + Migrations: migrations, + } +} + +func schemaCommandMigrationSnapshot(after bool) interfacesnapshot.Snapshot { + commands := []interfacesnapshot.Command{ + {Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}}, + { + Path: "dws chat group create", + Runnable: true, + Aliases: []string{}, + LocalFlags: []interfacesnapshot.Flag{{ + Name: "thread", Type: "bool", NoOpt: "true", + }}, + InheritedFlags: []interfacesnapshot.Flag{}, + }, + {Path: "dws chat message old", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}}, + } + if after { + commands[1].LocalFlags[0].Hidden = true + commands[2].Hidden = true + commands = append(commands, + interfacesnapshot.Command{ + Path: "dws chat topic create", + Runnable: true, + Aliases: []string{}, + LocalFlags: []interfacesnapshot.Flag{}, + InheritedFlags: []interfacesnapshot.Flag{}, + BoolConstParams: map[string]bool{"threadEnabled": true}, + }, + interfacesnapshot.Command{Path: "dws chat topic new", 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 mutateSchemaCommandMigrationTool(contract *schemaContract, toolID string, mutate func(*toolSchema)) { + product := contract.Products["chat"] + tool := product.Tools[toolID] + mutate(&tool) + product.Tools[toolID] = tool + contract.Products["chat"] = product +} + +func mutateSchemaCommandMigrationParameter( + contract *schemaContract, + toolID string, + parameterName string, + mutate func(*parameterSchema), +) { + mutateSchemaCommandMigrationTool(contract, toolID, func(tool *toolSchema) { + parameter := tool.Parameters[parameterName] + mutate(¶meter) + tool.Parameters[parameterName] = parameter + }) +} + +func writeCommandMigrationManifestFile(t *testing.T, path string, manifest interfacesnapshot.CommandMigrationManifest) { + t.Helper() + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + if err := json.NewEncoder(file).Encode(manifest); err != nil { + _ = file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } +} diff --git a/scripts/policy/schema-compat/main.go b/scripts/policy/schema-compat/main.go index 1f55eae4..7496d4f3 100644 --- a/scripts/policy/schema-compat/main.go +++ b/scripts/policy/schema-compat/main.go @@ -86,6 +86,7 @@ func main() { func run(args []string, stdout, stderr io.Writer) int { var normalizePath, checkPath, mergePath, currentPath string var approvedFlagMigrationsPath, candidateFlagMigrationsPath string + var approvedCommandMigrationsPath, candidateCommandMigrationsPath string var migrationCurrentSnapshotPath, migrationBaseSnapshotPath, migrationStableSnapshotPath string flags := flag.NewFlagSet("schema-compat", flag.ContinueOnError) flags.SetOutput(stderr) @@ -95,6 +96,8 @@ func run(args []string, stdout, stderr io.Writer) int { flags.StringVar(¤tPath, "current", "", "raw current complete Schema response") flags.StringVar(&approvedFlagMigrationsPath, "approved-flag-migrations", "", "base-owned approved flag migration manifest") 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(&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") @@ -112,25 +115,38 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "exactly one of --normalize, --check, or --merge is required") return 2 } - migrationInputs := []string{ - approvedFlagMigrationsPath, - candidateFlagMigrationsPath, + flagMigrationPair := approvedFlagMigrationsPath != "" || candidateFlagMigrationsPath != "" + commandMigrationPair := approvedCommandMigrationsPath != "" || candidateCommandMigrationsPath != "" + if flagMigrationPair && (approvedFlagMigrationsPath == "" || candidateFlagMigrationsPath == "") { + fmt.Fprintln(stderr, "Schema flag migration authorization requires both flag manifests") + return 2 + } + if commandMigrationPair && (approvedCommandMigrationsPath == "" || candidateCommandMigrationsPath == "") { + fmt.Fprintln(stderr, "Schema command migration authorization requires both command manifests") + return 2 + } + migrationSnapshots := []string{ migrationCurrentSnapshotPath, migrationBaseSnapshotPath, migrationStableSnapshotPath, } - migrationInputCount := 0 - for _, path := range migrationInputs { + migrationSnapshotCount := 0 + for _, path := range migrationSnapshots { if path != "" { - migrationInputCount++ + migrationSnapshotCount++ } } - if migrationInputCount != 0 && migrationInputCount != len(migrationInputs) { - fmt.Fprintln(stderr, "Schema flag migration authorization requires all five migration inputs") + migrationsEnabled := flagMigrationPair || commandMigrationPair + if migrationsEnabled && migrationSnapshotCount != len(migrationSnapshots) { + fmt.Fprintln(stderr, "Schema migration authorization requires all three interface snapshots") return 2 } - if migrationInputCount != 0 && checkPath == "" { - fmt.Fprintln(stderr, "Schema flag migration authorization is only valid with --check") + if !migrationsEnabled && migrationSnapshotCount != 0 { + fmt.Fprintln(stderr, "Schema migration snapshots require a flag or command migration manifest pair") + return 2 + } + if migrationsEnabled && checkPath == "" { + fmt.Fprintln(stderr, "Schema migration authorization is only valid with --check") return 2 } @@ -159,7 +175,7 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "read schema baseline: %v\n", err) return 2 } - if migrationInputCount != 0 { + if flagMigrationPair { migrations, err := authorizeSchemaFlagMigrations( approvedFlagMigrationsPath, candidateFlagMigrationsPath, @@ -177,6 +193,24 @@ func run(args []string, stdout, stderr io.Writer) int { return 2 } } + if commandMigrationPair { + migrations, err := authorizeSchemaCommandMigrations( + approvedCommandMigrationsPath, + candidateCommandMigrationsPath, + migrationCurrentSnapshotPath, + migrationBaseSnapshotPath, + migrationStableSnapshotPath, + ) + if err != nil { + fmt.Fprintf(stderr, "authorize Schema command migrations: %v\n", err) + return 2 + } + baseline, err = normalizeSchemaCommandMigrations(baseline, current, migrations) + if err != nil { + fmt.Fprintf(stderr, "normalize approved Schema command migrations: %v\n", err) + return 2 + } + } failures := checkCompatibility(baseline, current) if len(failures) > 0 { fmt.Fprintln(stderr, "Schema backwards-compatibility check failed:") @@ -251,6 +285,49 @@ func readFlagMigrationManifestFile(path string) (interfacesnapshot.FlagMigration return interfacesnapshot.ReadFlagMigrationManifest(bytes.NewReader(data)) } +func authorizeSchemaCommandMigrations( + approvedManifestPath string, + candidateManifestPath string, + currentSnapshotPath string, + baseSnapshotPath string, + stableSnapshotPath string, +) ([]interfacesnapshot.CommandMigration, error) { + approved, err := readCommandMigrationManifestFile(approvedManifestPath) + if err != nil { + return nil, fmt.Errorf("read approved command migrations: %w", err) + } + candidate, err := readCommandMigrationManifestFile(candidateManifestPath) + if err != nil { + return nil, fmt.Errorf("read candidate command migrations: %w", err) + } + current, err := readInterfaceSnapshotFile(currentSnapshotPath) + if err != nil { + return nil, fmt.Errorf("read migration current snapshot: %w", err) + } + base, err := readInterfaceSnapshotFile(baseSnapshotPath) + if err != nil { + return nil, fmt.Errorf("read migration base snapshot: %w", err) + } + stable, err := readInterfaceSnapshotFile(stableSnapshotPath) + if err != nil { + return nil, fmt.Errorf("read migration stable snapshot: %w", err) + } + return interfacesnapshot.AuthorizeCommandMigrations( + current, + map[string]interfacesnapshot.Snapshot{"merge-base": base, "stable": stable}, + approved, + candidate, + ) +} + +func readCommandMigrationManifestFile(path string) (interfacesnapshot.CommandMigrationManifest, error) { + data, err := os.ReadFile(path) + if err != nil { + return interfacesnapshot.CommandMigrationManifest{}, err + } + return interfacesnapshot.ReadCommandMigrationManifest(bytes.NewReader(data)) +} + func readInterfaceSnapshotFile(path string) (interfacesnapshot.Snapshot, error) { data, err := os.ReadFile(path) if err != nil { @@ -1290,6 +1367,472 @@ func normalizeSchemaFlagMigrations( return normalized, 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 +// not proven equivalent here. +func normalizeSchemaCommandMigrations( + baseline schemaContract, + current schemaContract, + migrations []interfacesnapshot.CommandMigration, +) (schemaContract, error) { + normalized := cloneContract(baseline) + for _, migration := range migrations { + oldProduct, productExists := baseline.Products[migration.Schema.ProductID] + oldTool, toolExists := oldProduct.Tools[migration.Schema.SourceToolID] + if !productExists || !toolExists { + // The historical baseline predates this Schema tool, so it has no + // compatibility surface for this migration. + continue + } + newProduct, productExists := current.Products[migration.Schema.ProductID] + newSource, sourceExists := newProduct.Tools[migration.Schema.SourceToolID] + if !productExists || !sourceExists { + // Preserve the baseline so the ordinary checker reports the removal. + continue + } + legacyPath := strings.TrimPrefix(migration.Legacy.Command, "dws ") + replacementPath := strings.TrimPrefix(migration.Replacement.Command, "dws ") + if oldTool.PrimaryCLIPath == replacementPath { + // A consumed receipt can still be needed for the stable baseline after + // main has already reached the after state. + continue + } + if oldTool.PrimaryCLIPath != legacyPath { + return schemaContract{}, fmt.Errorf( + "approved command migration %s historical Schema tool %q has primary_cli_path %q", + migration.Kind, + migration.Schema.SourceToolID, + oldTool.PrimaryCLIPath, + ) + } + + normalizedProduct := normalized.Products[migration.Schema.ProductID] + normalizedTool := normalizedProduct.Tools[migration.Schema.SourceToolID] + switch migration.Kind { + case interfacesnapshot.CommandMigrationMove: + if newSource.PrimaryCLIPath != replacementPath { + continue + } + renames := make(map[string]string, len(migration.Schema.Parameters)) + renameTargets := make(map[string]struct{}, len(migration.Schema.Parameters)) + for _, parameter := range migration.Schema.Parameters { + oldParameter, existed := oldTool.Parameters[parameter.From] + if !existed { + return schemaContract{}, fmt.Errorf( + "approved command migration %q historical Schema tool lacks parameter %q", + migration.Legacy.Command, + parameter.From, + ) + } + if _, exists := oldTool.Parameters[parameter.To]; exists { + return schemaContract{}, fmt.Errorf( + "approved command migration %q Schema parameter target %q already exists in historical Schema tool %q", + migration.Legacy.Command, + parameter.To, + migration.Schema.SourceToolID, + ) + } + if _, exists := newSource.Parameters[parameter.From]; exists { + return schemaContract{}, fmt.Errorf( + "approved command migration %q still publishes legacy Schema parameter %q", + migration.Legacy.Command, + parameter.From, + ) + } + newParameter, exists := newSource.Parameters[parameter.To] + if !exists { + return schemaContract{}, fmt.Errorf( + "approved command migration %q does not publish replacement Schema parameter %q", + migration.Replacement.Command, + parameter.To, + ) + } + if err := validateEquivalentCommandSchemaParameter(migration, parameter, oldParameter, newParameter); err != nil { + return schemaContract{}, err + } + delete(normalizedTool.Parameters, parameter.From) + normalizedTool.Parameters[parameter.To] = newParameter + renames[parameter.From] = parameter.To + renameTargets[parameter.To] = struct{}{} + } + parameterNames := make([]string, 0, len(newSource.Parameters)) + for name := range newSource.Parameters { + parameterNames = append(parameterNames, name) + } + sort.Strings(parameterNames) + for _, name := range parameterNames { + parameter := newSource.Parameters[name] + if _, existed := oldTool.Parameters[name]; existed { + continue + } + if _, approvedRename := renameTargets[name]; approvedRename { + continue + } + if parameter.Required || parameter.CLIRequired || parameter.RequiredWhen != "" { + return schemaContract{}, fmt.Errorf( + "approved command migration %q replacement Schema tool %q introduced unregistered required Schema parameter %q", + migration.Legacy.Command, + migration.Schema.SourceToolID, + name, + ) + } + } + 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 + } + } + normalizedTool.PrimaryCLIPath = replacementPath + + case interfacesnapshot.CommandMigrationFlagExtraction: + if newSource.PrimaryCLIPath != legacyPath { + continue + } + replacement, exists := newProduct.Tools[migration.Schema.ReplacementToolID] + if !exists || replacement.PrimaryCLIPath != replacementPath { + return schemaContract{}, fmt.Errorf( + "approved flag extraction %q lacks replacement Schema tool %q at %q", + migration.Legacy.Command, + migration.Schema.ReplacementToolID, + replacementPath, + ) + } + if oldTool.InterfaceMode != replacement.InterfaceMode || + oldTool.InterfaceRef != replacement.InterfaceRef || + oldTool.Availability != replacement.Availability || + oldTool.Effect != replacement.Effect || + oldTool.Risk != replacement.Risk || + oldTool.Confirmation != replacement.Confirmation || + oldTool.Idempotency != replacement.Idempotency { + return schemaContract{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool changed interface or safety identity", + migration.Legacy.Command, + ) + } + if oldTool.DryRun != "" && oldTool.DryRun != replacement.DryRun { + return schemaContract{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool changed or removed dry_run", + migration.Legacy.Command, + ) + } + var err error + normalizedTool, err = normalizeFlagExtractionSchemaMigration( + migration, + oldTool, + newSource, + replacement, + normalizedTool, + ) + if err != nil { + return schemaContract{}, err + } + } + normalizedProduct.Tools[migration.Schema.SourceToolID] = normalizedTool + normalized.Products[migration.Schema.ProductID] = normalizedProduct + } + return normalized, nil +} + +func normalizeFlagExtractionSchemaMigration( + migration interfacesnapshot.CommandMigration, + oldTool toolSchema, + newSource toolSchema, + replacement toolSchema, + normalizedSource toolSchema, +) (toolSchema, error) { + mappingsBySource := make(map[string]interfacesnapshot.CommandParameterMigration, len(migration.Schema.Parameters)) + for _, mapping := range migration.Schema.Parameters { + if _, duplicate := mappingsBySource[mapping.From]; duplicate { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q maps historical Schema parameter %q more than once", + migration.Legacy.Command, + mapping.From, + ) + } + if _, exists := oldTool.Parameters[mapping.From]; !exists { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q historical Schema tool lacks parameter %q", + migration.Legacy.Command, + mapping.From, + ) + } + mappingsBySource[mapping.From] = mapping + } + + historicalNames := make([]string, 0, len(oldTool.Parameters)) + for name := range oldTool.Parameters { + historicalNames = append(historicalNames, name) + } + sort.Strings(historicalNames) + for _, name := range historicalNames { + if _, mapped := mappingsBySource[name]; !mapped { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q does not map historical Schema parameter %q", + migration.Legacy.Command, + name, + ) + } + } + + legacyName := migration.LegacyFlag.Name + legacyMapping, mapped := mappingsBySource[legacyName] + if !mapped || legacyMapping.To != "" || legacyMapping.ReplacementConstant == nil { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q must map to one replacement constant", + migration.Legacy.Command, + legacyName, + ) + } + legacyParameter := oldTool.Parameters[legacyName] + if err := validateFlagExtractionBooleanConstant(migration, legacyParameter, legacyMapping.ReplacementConstant); err != nil { + return toolSchema{}, err + } + constantProperty := legacyMapping.ReplacementConstant.Property + if _, exists := newSource.Parameters[legacyName]; exists { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q still publishes extracted Schema parameter %q", + migration.Legacy.Command, + legacyName, + ) + } + sourceNames := make([]string, 0, len(newSource.Parameters)) + for name := range newSource.Parameters { + sourceNames = append(sourceNames, name) + } + sort.Strings(sourceNames) + for _, name := range sourceNames { + if newSource.Parameters[name].Property == constantProperty { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q source Schema tool still publishes replacement constant property %q through parameter %q on %q", + migration.Legacy.Command, + constantProperty, + name, + migration.Schema.SourceToolID, + ) + } + } + + replacementNames := make([]string, 0, len(replacement.Parameters)) + for name := range replacement.Parameters { + replacementNames = append(replacementNames, name) + } + sort.Strings(replacementNames) + for _, name := range replacementNames { + if replacement.Parameters[name].Property == constantProperty { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool %q still publishes replacement constant property %q through parameter %q", + migration.Legacy.Command, + migration.Schema.ReplacementToolID, + constantProperty, + name, + ) + } + } + + replacementTargets := make(map[string]string, len(historicalNames)-1) + renames := make(map[string]string, len(historicalNames)-1) + for _, sourceName := range historicalNames { + if sourceName == legacyName { + continue + } + mapping := mappingsBySource[sourceName] + if mapping.To == "" || mapping.ReplacementConstant != nil { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q shared Schema parameter %q must map to one replacement parameter", + migration.Legacy.Command, + sourceName, + ) + } + if previous, duplicate := replacementTargets[mapping.To]; duplicate { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema parameter %q is mapped from both %q and %q", + migration.Legacy.Command, + mapping.To, + previous, + sourceName, + ) + } + replacementParameter, exists := replacement.Parameters[mapping.To] + if !exists { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool %q does not publish mapped Schema parameter %q", + migration.Legacy.Command, + migration.Schema.ReplacementToolID, + mapping.To, + ) + } + if err := validateEquivalentCommandSchemaParameter(migration, mapping, oldTool.Parameters[sourceName], replacementParameter); err != nil { + return toolSchema{}, err + } + replacementTargets[mapping.To] = sourceName + renames[sourceName] = mapping.To + } + for _, name := range replacementNames { + if _, mapped := replacementTargets[name]; !mapped { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool %q publishes unmapped Schema parameter %q", + migration.Legacy.Command, + migration.Schema.ReplacementToolID, + name, + ) + } + } + + oldConstraints, oldConstraintsOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames) + replacementConstraints, replacementConstraintsOK := canonicalizeMigratedConstraints(replacement.Constraints, nil) + if !oldConstraintsOK || !replacementConstraintsOK || oldConstraints != replacementConstraints { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool changed constraints", + migration.Legacy.Command, + ) + } + if !equivalentMigratedPositionals(oldTool.Positionals, replacement.Positionals, renames) { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool changed positionals", + migration.Legacy.Command, + ) + } + + delete(normalizedSource.Parameters, legacyName) + return normalizedSource, nil +} + +func validateFlagExtractionBooleanConstant( + migration interfacesnapshot.CommandMigration, + parameter parameterSchema, + constant *interfacesnapshot.CommandReplacementConstant, +) error { + if strings.TrimSpace(constant.Property) == "" || parameter.Property != constant.Property { + return fmt.Errorf( + "approved flag extraction %q replacement constant property %q does not match historical Schema property %q", + migration.Legacy.Command, + constant.Property, + parameter.Property, + ) + } + if !constant.Value { + return fmt.Errorf( + "approved flag extraction %q replacement constant for Schema property %q must be constant true", + migration.Legacy.Command, + constant.Property, + ) + } + if parameter.Type != `"boolean"` { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q must have boolean type", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.Required || parameter.CLIRequired { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q must remain optional", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.RequiredWhen != "" { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q must not declare required_when", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.Default != "" && parameter.Default != "false" { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q default must be absent or false", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.InterfaceDefault != "" && parameter.InterfaceDefault != "false" { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q interface_default must be absent or false", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.InterfaceType != "" && parameter.InterfaceType != "boolean" { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q interface_type must be empty or boolean", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.Format != "" { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q format must be empty", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if len(parameter.Enum) > 0 { + allowsTrue := false + for _, value := range parameter.Enum { + if value == "true" { + allowsTrue = true + break + } + } + if !allowsTrue { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q enum must allow true", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + } + return nil +} + +func equivalentMigratedPositionals( + historical []positionalSchema, + replacement []positionalSchema, + renames map[string]string, +) bool { + if len(historical) != len(replacement) { + return false + } + for index, positional := range historical { + if renamed := renames[positional.Name]; renamed != "" { + positional.Name = renamed + } + if positional != replacement[index] { + return false + } + } + return true +} + +func validateEquivalentCommandSchemaParameter( + migration interfacesnapshot.CommandMigration, + parameter interfacesnapshot.CommandParameterMigration, + oldParameter parameterSchema, + newParameter parameterSchema, +) error { + if oldParameter.Type != newParameter.Type || + oldParameter.Property != newParameter.Property || + oldParameter.InterfaceType != newParameter.InterfaceType || + oldParameter.Required != newParameter.Required || + oldParameter.CLIRequired != newParameter.CLIRequired || + oldParameter.RequiredWhen != newParameter.RequiredWhen || + oldParameter.Default != newParameter.Default || + oldParameter.InterfaceDefault != newParameter.InterfaceDefault || + oldParameter.Format != newParameter.Format || + !equalStringSlices(oldParameter.Enum, newParameter.Enum) { + return fmt.Errorf( + "approved command migration %q Schema parameter %q -> %q changed a non-name field", + migration.Legacy.Command, + parameter.From, + parameter.To, + ) + } + return nil +} + func schemaToolsByPrimaryPath(contract schemaContract, primaryPath string) []schemaToolRef { var matches []schemaToolRef for productID, product := range contract.Products { diff --git a/scripts/policy/schema-compat/main_test.go b/scripts/policy/schema-compat/main_test.go index f33f09c5..c5eaa912 100644 --- a/scripts/policy/schema-compat/main_test.go +++ b/scripts/policy/schema-compat/main_test.go @@ -1589,7 +1589,7 @@ func TestCrossPlatformCoverageSchemaMigrationCLIRequiresCompleteCheckInputs(t *t "--migration-stable-snapshot", "stable.json", } var stdout, stderr bytes.Buffer - if code := run([]string{"--check", "baseline.json", "--current", "schema.json", "--approved-flag-migrations", "approved.json"}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "all five") { + if code := run([]string{"--check", "baseline.json", "--current", "schema.json", "--approved-flag-migrations", "approved.json"}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "both flag manifests") { t.Fatalf("partial migration inputs code=%d stderr=%q", code, stderr.String()) } diff --git a/scripts/policy/schemacompat/authority_integration_test.go b/scripts/policy/schemacompat/authority_integration_test.go index 5866ee50..be8857f4 100644 --- a/scripts/policy/schemacompat/authority_integration_test.go +++ b/scripts/policy/schemacompat/authority_integration_test.go @@ -32,6 +32,8 @@ 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") flag.Parse() if *normalize != "" { @@ -171,6 +173,8 @@ 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") flag.Parse() if *normalize != "" { @@ -282,6 +286,27 @@ var ( func AuthorityMarker() string { return "BASE" } ` +const schemaCommandGovernedSnapshotSource = `package interfacesnapshot + +import ( + "example.com/dws-schema-compat-authority-test/internal/corecmd" + "example.com/dws-schema-compat-authority-test/internal/corecmd/runtimeannotate" +) + +var ( + _ = runtimeannotate.AnnotationFlagAliasOf + _ = runtimeannotate.AnnotationFlagAliasOrigin + _ = runtimeannotate.FlagAliasOriginCorecmdV1 +) + +func AuthorityMarker() string { + if corecmd.InterfaceBoolConstParams() != "BASE" { + return "UNTRUSTED_CONST_PARAMS" + } + return "BASE" +} +` + const schemaBaseSnapshotSource = `package interfacesnapshot func AuthorityMarker() string { return "BASE" } @@ -306,6 +331,54 @@ const ( ) ` +func schemaConstParamsRegistrySource(marker string) string { + return fmt.Sprintf(`package corecmd + +type constParamsRegistry struct{} + +func (constParamsRegistry) Store(any, any) {} + +var interfaceBoolConstParamsRegistry constParamsRegistry + +func attachInterfaceBoolConstParams() {} + +func InterfaceBoolConstParams() string { return %q } +`, marker) +} + +const schemaBaseCorecmdBridgeSource = `package corecmd + +func boolConstParams(map[string]any) map[string]bool { + return map[string]bool{"convThreadEnabled": true} +} + +func installConstParamsEvidence() { attachInterfaceBoolConstParams() } +` + +const schemaForgedCorecmdBridgeSource = `package corecmd + +func boolConstParams(map[string]any) map[string]bool { + return map[string]bool{"convThreadEnabled": false} +} + +func installConstParamsEvidence() { attachInterfaceBoolConstParams() } +` + +const schemaBaseLeafAdapterSource = `package helpers + +func forwardToolArgs(toolArgs map[string]any) map[string]any { + return toolArgs +} +` + +const schemaForgedLeafAdapterSource = `package helpers + +func forwardToolArgs(toolArgs map[string]any) map[string]any { + delete(toolArgs, "convThreadEnabled") + return toolArgs +} +` + const candidateNoopSource = `package main import "fmt" @@ -327,12 +400,14 @@ func main() { } type authorityScenario struct { - name string - baseGovernance string - candidateMutation string - checkerMode string - want string - authorityMarker string + name string + baseGovernance string + commandGovernance string + commandManifestChanged bool + candidateMutation string + checkerMode string + want string + authorityMarker string } func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testing.T) { @@ -353,6 +428,85 @@ func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testi baseGovernance: "none", authorityMarker: "OLD_BASE_STABLE_SCHEMA_CHECKER_ENFORCED", }, + { + name: "command governed base owns bool ConstParams contract", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "change-const-params-protocol", + authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED", + }, + { + name: "command governance bootstrap preserves candidate bool ConstParams contract", + baseGovernance: "complete", + commandGovernance: "bootstrap", + authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED", + }, + { + name: "command governed candidate cannot delete bool ConstParams contract", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "delete-const-params-contract", + want: "must preserve the complete Schema command migration governance artifact set", + }, + { + name: "same package candidate cannot call private ConstParams writer", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-token", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "candidate cannot reopen private ConstParams reader", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-reader", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "same package candidate cannot store directly in ConstParams registry", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-registry-store", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "changed command manifest rejects forged corecmd bool ConstParams bridge", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "forge-corecmd-bool-const-params", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/corecmd.go", + }, + { + name: "changed command manifest rejects tampered ConstParams protocol", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "change-const-params-protocol", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/interface_const_params.go", + }, + { + name: "changed command manifest rejects tampered leaf adapter", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "forge-leaf-adapter", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/helpers/leaf.go", + }, + { + name: "changed command manifest allows base identical bridges", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED", + }, + { + name: "unchanged command manifest allows independent corecmd change", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-corecmd-bool-const-params", + authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED", + }, { name: "partial base fails closed", baseGovernance: "partial", @@ -479,6 +633,19 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) { default: t.Fatalf("unknown base governance state %q", test.baseGovernance) } + switch test.commandGovernance { + case "": + case "complete": + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), schemaCommandGovernedSnapshotSource, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), schemaBaseCorecmdBridgeSource, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), schemaConstParamsRegistrySource("BASE"), 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), schemaBaseLeafAdapterSource, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), "{\"version\":1,\"migrations\":[]}\n", 0o644) + case "bootstrap": + default: + t.Fatalf("unknown command governance state %q", test.commandGovernance) + } schemaRun(t, fixtureRoot, "git", "add", ".") schemaRun(t, fixtureRoot, "git", "commit", "-m", "base authority") baseRef := strings.TrimSpace(schemaRun(t, fixtureRoot, "git", "rev-parse", "HEAD")) @@ -508,6 +675,17 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) { candidateLedger = "{\"version\":1,\"migrations\":[{\"candidate\":\"COMMITTED\"}]}\n" } schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"), candidateLedger, 0o644) + if test.commandGovernance == "complete" || test.commandGovernance == "bootstrap" { + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644) + if test.commandGovernance == "bootstrap" { + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), schemaConstParamsRegistrySource("CANDIDATE"), 0o644) + } + commandManifest := "{\"version\":1,\"migrations\":[]}\n" + if test.commandManifestChanged { + commandManifest = "{\"version\":1,\"migrations\":[{\"candidate\":\"PENDING\"}]}\n" + } + schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), commandManifest, 0o644) + } schemaWriteFile(t, filepath.Join(fixtureRoot, "SNAPSHOT_MARKER"), "COMMITTED_CANDIDATE\n", 0o644) switch test.candidateMutation { case "": @@ -534,6 +712,34 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) { "package evil\n\nconst forged = \"dws.compat.alias_of\"\n", 0o644, ) + case "delete-const-params-contract": + if err := os.Remove(filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go")); err != nil { + t.Fatalf("remove candidate bool ConstParams registry: %v", err) + } + case "forge-const-params-token": + schemaWriteFile(t, + filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"), + "package corecmd\n\nfunc forgeConstParams() { attachInterfaceBoolConstParams() }\n", + 0o644, + ) + case "forge-const-params-reader": + schemaWriteFile(t, + filepath.Join(fixtureRoot, "internal", "evil", "forged.go"), + "package evil\n\nconst forged = \"InterfaceBoolConstParams\"\n", + 0o644, + ) + case "forge-const-params-registry-store": + schemaWriteFile(t, + filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"), + "package corecmd\n\nfunc forgeConstParamsStore() { interfaceBoolConstParamsRegistry.Store(nil, map[string]bool{\"forged\": true}) }\n", + 0o644, + ) + case "forge-corecmd-bool-const-params": + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), schemaForgedCorecmdBridgeSource, 0o644) + case "change-const-params-protocol": + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), schemaConstParamsRegistrySource("CANDIDATE"), 0o644) + case "forge-leaf-adapter": + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), schemaForgedLeafAdapterSource, 0o644) case "symlink-helper-parent": helperPath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot") alternatePath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot-alt") diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index 5e97dd1e..d695e969 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -44,7 +44,7 @@ cli_version: ">=1.0.15" | 服务 | shortcut 数 | multi skill | |---|---:|---| | `aitable` | 93 | `dingtalk-aitable` | -| `attendance` | 19 | `dingtalk-misc` | +| `attendance` | 8 | `dingtalk-misc` | | `calendar` | 27 | `dingtalk-calendar` | | `chat` | 98 | `dingtalk-chat` | | `contact` | 14 | `dingtalk-contact` | @@ -52,7 +52,7 @@ cli_version: ">=1.0.15" | `ding` | 4 | `dingtalk-misc` | | `doc` | 45 | `dingtalk-doc` | | `drive` | 28 | `dingtalk-drive` | -| `mail` | 10 | `dingtalk-mail` | +| `mail` | 8 | `dingtalk-mail` | | `minutes` | 27 | `dingtalk-minutes` | | `oa` | 7 | `dingtalk-misc` | | `report` | 2 | `dingtalk-misc` | diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md index 22157bc8..90159f7b 100644 --- a/skills/mono/references/products/chat.md +++ b/skills/mono/references/products/chat.md @@ -437,23 +437,23 @@ Flags: --role-id string 群身份 openRoleId,由 group-role list 返回 (必填) ``` -#### 设置用户群身份 — 覆盖指定用户在群中的全部群身份(传空则清除所有身份) +#### 设置用户群身份 — 覆盖指定用户在群中的全部群身份 ``` Usage: dws chat group-role set-user [flags] Example: - dws chat group-role set-user --group --user --role-ids roleId1,roleId2 + dws chat group-role set-user --group --user --role-id # 查询人员: dws aisearch person --query "姓名" --dimension name # 查询 role-id: dws chat group-role list --group Flags: - --group string 群聊 openConversationId (必填) - --user string 用户 userId(必填) - --role-ids string 群身份 openRoleId 列表,逗号分隔 (必填),传空字符串则清除该用户所有群身份 + --group string 群聊 openConversationId (必填) + --user string 用户 userId(必填) + --role-id string 群身份 openRoleId,由 group-role list 返回 ``` #### 移除用户的指定群身份 — 从用户身上移除指定的群身份(不影响其他群身份) -> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。需要清除某人身份时,用 `chat group-role set-user --role-ids ""`(传空 role-ids 覆盖为无身份)兜底;但 set-user 会清掉该用户的**全部**身份,无法只移除其中一个。 +> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。set-user 会清掉该用户的**全部**身份,无法只移除其中一个。 ``` Usage: dws chat group-role remove-user [flags] @@ -2506,7 +2506,7 @@ Flags: | `chat conversation-info` | `newCSpaceIdIM` | 独立钉盘存储流程的共享空间 ID;不是发送本地聊天附件的前置条件 | | `chat file upload` | 无(已下线) | 不要调用;常规发图/发文件用 `chat message send --msg-type file --file` | | `chat message list` | `openMsgId` | message read-status 的 --message-id | -| `chat group-role list` | `openRoleId` | group-role update/remove/set-user/remove-user 的 --role-id | +| `chat group-role list` | `openRoleId` | group-role update/remove/set-user 的 --role-id;remove-user 的 --role-ids | | `chat message create-text-emotion` | `emotionId` | add-text-emotion 的 --emotion-id | | `chat category list` | `categoryId` | category list-conversations 的 --category-id | | `chat group get-by-group-id` | `openConversationId` | 同 chat search,将群号转为 openConversationId | @@ -2543,7 +2543,7 @@ Flags: - `send-by-bot` 群聊传 `--conversation-id`,单聊传 `--users` 或 `--open-dingtalk-ids`,与群目标互斥且必选其一;机器人群聊 Markdown 引用回复必须同时传 `--reply ` 与 `--ref-sender `,单聊、图片和文件不支持引用回复;群聊时可选 `--at-user-ids` @指定成员(传 userId 列表)或 `--at-open-dingtalk-ids` @指定成员(传 openDingTalkId 列表),content 中需包含对应 @标识;`--at-all` @所有人;群聊场景如果返回"机器人不存在"错误,需先通过 `chat group members add-bot --conversation-id --robot-code ` 将机器人邀请进群后再发送 - `recall-by-bot` 群聊传 `--group` + `--keys`,单聊仅传 `--keys`(不传 `--group` 即为单聊撤回) - `send-by-webhook` 支持 `--at-all`、`--at-mobiles`、`--at-users` 进行 @ 操作,但需在 `--content` 中包含 `@userId` 或 `@手机号` 才能生效;`--at-all` @所有人时需在 `--content` 中包含 `@10` -- `chat group-role` 系列命令用于管理群的自定义身份标签:`list` 查列表,`add` 创建,`update` 改名,`remove` 删除;`set-user` 覆盖某人全部身份(传空 --role-ids 则清除),`remove-user` 仅移除指定身份,`query-user` 查询某人当前身份;用户用 `--user ` +- `chat group-role` 系列命令用于管理群的自定义身份标签:`list` 查列表,`add` 创建,`update` 改名,`remove` 删除;`set-user` 用单个 `--role-id` 覆盖某人全部身份(不支持传空值清除),`remove-user` 仅移除指定身份,`query-user` 查询某人当前身份;用户用 `--user ` - 消息**换行符**(`send` / `send-by-webhook` 使用 `--content`,`send-by-bot` 使用 `--text`)有两层要求:(1) 必须是**真实换行符** `U+000A`,不是字面量 `\n`;(2) Markdown 规范下单换行不生效,需用空行 `\n\n`(段落分隔)或行尾两空格 + 换行 / `
`(硬换行) - `chat group transfer-owner` 转让群主,需传 --group(openConversationId);新群主 userId 用 `--user`,openDingTalkId 用 `--new-owner` - `chat group invite-url` 获取群邀请链接,需传 --group(openConversationId),可选 --expires-seconds 指定有效期(秒,0=永久) diff --git a/skills/mono/references/products/oa.md b/skills/mono/references/products/oa.md index 332d8649..a568a9ce 100644 --- a/skills/mono/references/products/oa.md +++ b/skills/mono/references/products/oa.md @@ -634,6 +634,30 @@ Flags: --query string 查询关键词,可选 ``` +### 以管理员身份查询审批实例列表 + +> **IMPORTANT:** 需要当前用户具备 OA 审批管理员权限,否则查不到数据。只查个人维度的审批时改用 `list-pending` / `list-executed` / `list-initiated` / `list-cc`。 + +``` +Usage: + dws oa approval list-by-admin [flags] +Example: + dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20 + dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --statuses RUNNING,COMPLETED --user-ids "userId1,userId2" + # 高级用法:传入完整 JSON(startTime/endTime 为 yyyy-MM-dd HH:mm:ss 格式字符串) + dws oa approval list-by-admin --request '{"processCode":"PROC-xxx","startTime":"2026-03-10 00:00:00","cursor":0,"pageSize":20}' +Flags: + --process-code string 审批模板 processCode(简单模式必填) + --start string 开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)(简单模式必填) + --end string 结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)(可选) + --cursor string 分页游标,首次传 0(默认 "0") + --limit string 每页大小,最大 20(默认 "20") + --user-ids string 按发起人 userId 过滤,多个用逗号分隔(可选) + --statuses string 按审批状态过滤,多个用逗号分隔(可选,如 RUNNING、TERMINATED、COMPLETED) + --request string 完整请求体 JSON(高级模式,与简单模式互斥) +``` +MCP 工具: `get_process_instances_by_admin`;参数封装在 `ProcessInstanceListQueryRequest`(processCode、startTime 必填,endTime、userIds、statuses、cursor、pageSize 可选;startTime/endTime 为 `yyyy-MM-dd HH:mm:ss` 格式字符串,简单模式的 ISO-8601 入参会自动转换)。processCode 可从 `list-forms` / `search-forms` 获取,返回的 processInstanceId 可用于 `detail` / `records` / `tasks`。 + ### 转交审批任务 ``` Usage: @@ -743,6 +767,7 @@ Flags: 用户说"我发起的审批单" -> `approval list-submitted` 用户说"我审批/处理过的审批单" -> `approval list-executed` 用户说"抄送我的审批单" -> `approval list-cc` +用户说"以管理员身份查审批/全员审批单/统计某个模板的审批单/企业内审批记录" → `approval list-by-admin`(需 --process-code 和 --start,且当前用户需具备 OA 管理员权限) 用户说"转交审批/转交任务" → `approval redirect-task`(需 --task-id 和 --to-actioner-id) 用户说"评论审批/添加评论/写评论" → `approval oa-comments`(需 --instance-id 和 --content) 用户说"抄送审批/添加抄送人" → `approval oa-cc-noticer`(需 --instance-id 和 --users) @@ -792,6 +817,9 @@ dws oa approval list-submitted --limit --page --query # 11. 抄送我的审批单 dws oa approval list-cc --limit --page --query 关键词 --format json +# 11b. 以管理员身份跨用户查询某模板的审批实例列表(需 OA 管理员权限) +dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20 --format json + # 12. 转交审批任务(taskId 来自 tasks,toActionerId 来自 contact user search) dws oa approval redirect-task --task-id --to-actioner-id --format json dws oa approval redirect-task --task-id --to-actioner-id --remark "请帮忙处理" --format json @@ -834,6 +862,7 @@ dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1 | `forecast-process` | `workflowActivityRuleVOs`(`activityId`, `targetSelect`, `activityActioners`, `workflowActor`) | ① 向用户展示流程走向和各节点处理人;② `targetSelect: true` 的节点需用户自选审批人,`workflowActor.actorKey` 作为 `targetSelectActioners` 的 `actionerKey` 传入 create-instance | | `search-forms` → `form-schema` → `forecast-process` | `processCode` → 字段定义 → 流程走向 + 自选节点 | create-instance 的完整上下文:表单值 + 流程路径 + targetSelectActioners | | `create-instance` | `result`(processInstanceId) | detail / tasks / records / revoke 等的 --instance-id,可跟踪已发起的审批 | +| `list-by-admin` | `processInstanceId` | detail / records / tasks 的 --instance-id | ## 注意事项 @@ -844,6 +873,7 @@ dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1 - `--remark` 审批意见虽为可选,但建议填写以留存审批痕迹 - `list-initiated` 的 `--process-code` 可从 `list-forms`、`search-forms` 或 `detail` 返回中提取。当 `list-forms` 返回 `processCodeList` 为空(`totalCount -1`)时,用 `search-forms --query <表单名>`(如 `--query 报销`)按名称精准拿 `processCode` 更稳 - `list-initiated` 的 `--start` / `--end` 区间有后端上限(约 120 天)。超过上限会返回误导性的 `business_error: 时间戳无效`(实为区间过长,不是时间格式问题)。跨度大时请拆成多段短区间分别查询 +- `list-by-admin` 需要当前用户具备 OA 审批管理员权限,否则查不到数据;只查个人维度审批时改用 `list-pending` / `list-executed` / `list-initiated` / `list-cc`。高级模式 `--request` 中 `startTime`/`endTime` 为 `yyyy-MM-dd HH:mm:ss` 格式字符串(不再接受毫秒时间戳);`pageSize` 上限为 20,超过会报错(简单模式为 `--limit`) - `form-schema` 的 `--process-code` 可从 `list-forms`、`search-forms` 或 `detail` 返回中提取;返回的 `content` 字段为 JSON 字符串,需解析后查看表单组件(items)定义。 - `create-instance` 发起前**必须先阅读** [oa-form-components.md](oa/oa-form-components.md)(控件值格式)和 [oa-process-nodes.md](oa/oa-process-nodes.md)(流程节点规则),再调用 `form-schema` 获取表单字段定义,确保 `--form-values` 中的 key 与控件 label 完全一致。 diff --git a/skills/multi/dingtalk-mail/SKILL.md b/skills/multi/dingtalk-mail/SKILL.md index 19b89174..8b2580a1 100644 --- a/skills/multi/dingtalk-mail/SKILL.md +++ b/skills/multi/dingtalk-mail/SKILL.md @@ -24,15 +24,13 @@ metadata: | Shortcut | 风险 | 适用场景 | |---|---|---| -| `dws mail +contact-list` | read | 列出指定邮箱的所有邮件联系人 | | `dws mail +find-mail-user` | read | 按关键词搜索邮箱联系人并投影列表(姓名/昵称/邮箱/工号等) | | `dws mail +folder-list` | read | 列出顶层文件夹或指定父文件夹下的子文件夹 | -| `dws mail +recent-mail` | read | 列出收件箱近期邮件会话并投影列表(主题/发件人/时间/threadId) | +| `dws mail +message` | read | 读取一封邮件的完整正文与附件元数据 | +| `dws mail +messages` | read | 按请求顺序读取多封邮件并逐封验证身份 | | `dws mail +search-mail` | read | 按 KQL 关键词搜索邮件并投影列表(主题/发件人/时间/messageId) | -| `dws mail +tag-list` | read | 列出指定邮箱下的所有邮件标签 | -| `dws mail +template-list` | read | 列出指定邮箱的所有邮件模板 | -| `dws mail +thread-list` | read | 列出指定邮箱文件夹下的邮件会话(thread) | -| `dws mail +unread-mail` | read | 列出未读邮件并投影列表(主题/发件人/时间/messageId) | +| `dws mail +thread` | read | 读取完整邮件会话并精确验证 conversationId | +| `dws mail +triage` | read | 列出或筛选邮件摘要,自动解析邮箱与收件箱 | | `dws mail +user-search` | read | 按关键词或工号搜索邮箱用户(仅企业邮箱) | diff --git a/skills/multi/dingtalk-misc/references/attendance.md b/skills/multi/dingtalk-misc/references/attendance.md index 00c70d03..994a6c4a 100644 --- a/skills/multi/dingtalk-misc/references/attendance.md +++ b/skills/multi/dingtalk-misc/references/attendance.md @@ -1417,23 +1417,12 @@ dws attendance boss-check --plan-id 948964045503 --time "2026-05-13 18:00" --res |---|---|---| | `dws attendance +check-record` | read | 查询用户打卡流水(打卡时间/地点/定位方式) | | `dws attendance +check-result` | read | 查询用户打卡结果(迟到/早退/缺卡等) | -| `dws attendance +get-adjustment-rule` | read | 根据补卡规则主键 ID 查询补卡规则详情 | | `dws attendance +get-approve-template` | read | 查询补卡/请假/加班/外出/出差审批提交链接 | -| `dws attendance +get-checkin-record` | read | 查询指定员工一段时间内的签到记录 | -| `dws attendance +get-leave-records` | read | 查询指定员工的假期余额变更记录 | | `dws attendance +get-overtime-rule` | read | 根据加班规则主键 ID 查询加班规则详情 | -| `dws attendance +get-schedule` | read | 获取指定用户一段时间内的排班记录 | -| `dws attendance +get-self-setting` | read | 查询个人规则设置(打卡提醒/极速打卡/缺卡提醒等) | -| `dws attendance +get-summary` | read | 查询某个人的考勤统计摘要(周/月) | | `dws attendance +list-approve` | read | 查询用户考勤审批单(补卡/加班/请假/出差外出) | -| `dws attendance +list-leave-types` | read | 查询当前用户可用的假期规则列表 | -| `dws attendance +my-attendance` | read | 查我今天的考勤打卡记录(打卡流水,自动解析当前用户) | -| `dws attendance +query-report-data` | read | 根据字段查询考勤报表数据(仅管理员) | | `dws attendance +search-adjustment-rule` | read | 查询当前用户可管理的补卡规则列表 | | `dws attendance +search-class` | read | 查询当前用户可管理的班次详情列表 | -| `dws attendance +search-group` | read | 查询当前用户可管理的考勤组列表 | | `dws attendance +search-overtime-rule` | read | 查询当前用户可管理的加班规则列表 | -| `dws attendance +this-month` | read | 查我本月的考勤打卡记录(打卡流水,自动解析当前用户) | ## 意图表 diff --git a/skills/multi/dingtalk-misc/references/oa.md b/skills/multi/dingtalk-misc/references/oa.md index 0f218814..f526c23c 100644 --- a/skills/multi/dingtalk-misc/references/oa.md +++ b/skills/multi/dingtalk-misc/references/oa.md @@ -638,6 +638,30 @@ Flags: --query string 查询关键词,可选 ``` +### 以管理员身份查询审批实例列表 + +> **IMPORTANT:** 需要当前用户具备 OA 审批管理员权限,否则查不到数据。只查个人维度的审批时改用 `list-pending` / `list-executed` / `list-initiated` / `list-cc`。 + +``` +Usage: + dws oa approval list-by-admin [flags] +Example: + dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20 + dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --statuses RUNNING,COMPLETED --user-ids "userId1,userId2" + # 高级用法:传入完整 JSON(startTime/endTime 为 yyyy-MM-dd HH:mm:ss 格式字符串) + dws oa approval list-by-admin --request '{"processCode":"PROC-xxx","startTime":"2026-03-10 00:00:00","cursor":0,"pageSize":20}' +Flags: + --process-code string 审批模板 processCode(简单模式必填) + --start string 开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)(简单模式必填) + --end string 结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)(可选) + --cursor string 分页游标,首次传 0(默认 "0") + --limit string 每页大小,最大 20(默认 "20") + --user-ids string 按发起人 userId 过滤,多个用逗号分隔(可选) + --statuses string 按审批状态过滤,多个用逗号分隔(可选,如 RUNNING、TERMINATED、COMPLETED) + --request string 完整请求体 JSON(高级模式,与简单模式互斥) +``` +MCP 工具: `get_process_instances_by_admin`;参数封装在 `ProcessInstanceListQueryRequest`(processCode、startTime 必填,endTime、userIds、statuses、cursor、pageSize 可选;startTime/endTime 为 `yyyy-MM-dd HH:mm:ss` 格式字符串,简单模式的 ISO-8601 入参会自动转换)。processCode 可从 `list-forms` / `search-forms` 获取,返回的 processInstanceId 可用于 `detail` / `records` / `tasks`。 + ### 转交审批任务 ``` Usage: @@ -757,6 +781,7 @@ Flags: 用户说"抄送我的审批单/抄送我的XX审批/CC我的XX/查抄送我的XX" → `approval list-cc`,将 XX 作为 `--query` 关键字传入(可搜索表单名称或表单详情内容) - 示例:"查抄送我的补卡审批单" → `approval list-cc --query 补卡` - 示例:"抄送我的审批单"(无关键词)→ `approval list-cc` +用户说"以管理员身份查审批/全员审批单/统计某个模板的审批单/企业内审批记录" → `approval list-by-admin`(需 --process-code 和 --start,且当前用户需具备 OA 管理员权限) 用户说"转交审批/转交任务" → `approval redirect-task`(需 --task-id 和 --to-actioner-id) 用户说"评论审批/添加评论/写评论" → `approval oa-comments`(需 --instance-id 和 --content) 用户说"抄送审批/添加抄送人" → `approval oa-cc-noticer`(需 --instance-id 和 --users) @@ -815,6 +840,9 @@ dws oa approval list-submitted --limit --page --query # 11. 抄送我的审批单 dws oa approval list-cc --limit --page --query 关键词 --format json +# 11b. 以管理员身份跨用户查询某模板的审批实例列表(需 OA 管理员权限) +dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20 --format json + # 12. 转交审批任务(taskId 来自 tasks,toActionerId 来自 aisearch person) dws oa approval redirect-task --task-id --to-actioner-id --format json dws oa approval redirect-task --task-id --to-actioner-id --remark "请帮忙处理" --format json @@ -879,6 +907,7 @@ dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1 | `create-instance` | `result`(processInstanceId) | detail / tasks / records / revoke 等的 --instance-id,可跟踪已发起的审批 | | `ding-info` | `userId` | ding message send 的 --users(多个逗号拼接);**robotCode 优先走 `$DINGTALK_DING_ROBOT_CODE` 环境变量,content 由 agent 根据审批上下文撰写;返回空时报错并停止** | | `revert-activities` | `activityId`, `revertAction`, `activityName` | revert-task 的 --target-activity-id 和 --action;**返回空时必须告知用户"无可回退节点"** | +| `list-by-admin` | `processInstanceId` | detail / records / tasks 的 --instance-id | ## 注意事项 @@ -900,6 +929,7 @@ dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1 - `--remark` 审批意见虽为可选,但建议填写以留存审批痕迹 - `list-initiated` 的 `--process-code` 可从 `list-forms`、`search-forms` 或 `detail` 返回中提取 - 已知表单名称关键字时优先用 `search-forms`;需枚举全部表单时用 `list-forms` +- `list-by-admin` 需要当前用户具备 OA 审批管理员权限,否则查不到数据;只查个人维度审批时改用 `list-pending` / `list-executed` / `list-initiated` / `list-cc`。高级模式 `--request` 中 `startTime`/`endTime` 为 `yyyy-MM-dd HH:mm:ss` 格式字符串(不再接受毫秒时间戳);`pageSize` 上限为 20,超过会报错(简单模式为 `--limit`) - 催办必须两步串联:`ding-info` 仅返回被催办人 `userId`,不返回 robotCode/content;需再调用 `dws ding message send`,其中 `--robot-code` 优先使用环境变量 `$DINGTALK_DING_ROBOT_CODE`,若无则向用户确认;`--content` 由 agent 根据审批上下文撰写催办文案;**严禁跳过 `ding-info` 直接猜测 userId** - 催办文案建议格式:`"请尽快审批《{表单名}》(提交人:{发起人},提交时间:{时间})"` - `ding-info` 返回空或报错时,必须明确告知用户"无法获取该任务的被催办人信息"并停止