From bfd53df9b20e605763d132c9ac1ba3d4f71d5264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Tue, 28 Jul 2026 10:31:20 +0800 Subject: [PATCH] feat(param): expand reviewed IM parameter normalization --- internal/app/flag_error_test.go | 17 + internal/app/param_alias_e2e_test.go | 174 ++- .../param_alias_payload_equivalence_test.go | 314 ++++- internal/cli/param_aliases.go | 10 - internal/cli/param_aliases_generated.go | 1101 ++++++++++++++++- internal/cli/param_aliases_lookup.go | 5 +- internal/cli/param_aliases_test.go | 226 ++++ internal/cli/param_concepts.json | 190 ++- internal/cli/param_concepts_contract_test.go | 131 ++ internal/generator/cmd_param_aliases/main.go | 35 +- .../generator/cmd_param_aliases/main_test.go | 175 +++ internal/pipeline/cobra.go | 3 - internal/pipeline/cobra_test.go | 27 + internal/pipeline/engine_test.go | 27 + internal/pipeline/handlers/alias_test.go | 6 + .../pipeline/handlers/semantic_alias_test.go | 11 +- 16 files changed, 2314 insertions(+), 138 deletions(-) create mode 100644 internal/generator/cmd_param_aliases/main_test.go diff --git a/internal/app/flag_error_test.go b/internal/app/flag_error_test.go index f1f440ce..af9e11f1 100644 --- a/internal/app/flag_error_test.go +++ b/internal/app/flag_error_test.go @@ -128,3 +128,20 @@ func TestFlagErrorWithSuggestionsReviewedProtectionRoutes(t *testing.T) { }) } } + +func TestReviewedFlagProtectionAndInstallerEdges(t *testing.T) { + if flag, protection, ok := reviewedFlagProtection(nil, "unknown flag: --time"); ok || flag != "" || protection != "" { + t.Fatalf("nil command protection = %q, %q, %v", flag, protection, ok) + } + installReviewedFlagProtectionHandlers(nil) + + root := NewRootCommand() + cmd := mustFindCommand(t, root, "chat", "message", "list-by-sender") + flag, protection, ok := reviewedFlagProtection(cmd, "unknown flag: --time=value") + if !ok || flag != "time" || protection != "blocked" { + t.Fatalf("delimited protected flag = %q, %q, %v", flag, protection, ok) + } + if flag, protection, ok := reviewedFlagProtection(cmd, "unknown flag: --not-reviewed"); ok || flag != "" || protection != "" { + t.Fatalf("unreviewed flag protection = %q, %q, %v", flag, protection, ok) + } +} diff --git a/internal/app/param_alias_e2e_test.go b/internal/app/param_alias_e2e_test.go index cabbb5b5..5a8a58c0 100644 --- a/internal/app/param_alias_e2e_test.go +++ b/internal/app/param_alias_e2e_test.go @@ -313,7 +313,9 @@ func TestChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) { t.Fatalf("canonical payload = %#v", canonicalCaller.calls[0].args) } - for _, alias := range []string{"group-id", "chat-id", "open-conversation-id"} { + // Numeric --group-id is a different identifier domain and is covered + // by TestAllReviewedParamAliasGuardsReachFinalErrorsWithoutDispatch. + for _, alias := range []string{"chat-id", "open-conversation-id"} { t.Run(alias, func(t *testing.T) { aliasArgs := append([]string(nil), test.command...) aliasArgs = append(aliasArgs, "--"+alias, "conversation-1") @@ -335,6 +337,176 @@ func TestChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) { } } +func TestAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) { + entries, err := cli.ReduceParamAliases(NewRootCommand()) + if err != nil { + t.Fatalf("ReduceParamAliases() error = %v", err) + } + + chatEntries := 0 + aliasCases := 0 + guardCases := map[pipeline.FlagProtection]int{} + for _, entry := range entries { + if !strings.HasPrefix(entry.CLIPath, "chat ") { + continue + } + chatEntries++ + + aliases := make([]string, 0, len(entry.Aliases)) + for emitted := range entry.Aliases { + aliases = append(aliases, emitted) + } + sort.Strings(aliases) + for _, emitted := range aliases { + emitted := emitted + canonical := entry.Aliases[emitted] + aliasCases++ + t.Run(entry.CLIPath+"/alias/"+emitted, func(t *testing.T) { + root := NewRootCommand() + leaf := resolveParamLeaf(root, entry.CLIPath) + if leaf == nil { + t.Fatalf("generated chat alias path %q is not runnable", entry.CLIPath) + } + value := paramFixtureValue(leaf, emitted, canonical) + rawArgs := append(strings.Fields(entry.CLIPath), "--"+emitted, value) + ctx, runErr := pipeline.RunPreParseArgs(root, newPipelineEngine(), rawArgs) + if runErr != nil { + t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, runErr) + } + if ctx == nil { + t.Fatal("RunPreParseArgs returned nil context") + } + flagArgs := ctx.Args[len(strings.Fields(entry.CLIPath)):] + if len(flagArgs) < 2 || flagArgs[0] != "--"+canonical || flagArgs[1] != value { + t.Fatalf("runtime alias %q => %q produced args %v", emitted, canonical, ctx.Args) + } + if parseErr := leaf.ParseFlags(flagArgs); parseErr != nil { + t.Fatalf("canonical Cobra ParseFlags(%v) error = %v", flagArgs, parseErr) + } + }) + } + + for _, guard := range []struct { + protection pipeline.FlagProtection + emitted []string + }{ + {protection: pipeline.FlagProtectionBlocked, emitted: entry.Blocked}, + {protection: pipeline.FlagProtectionAmbiguous, emitted: entry.Ambiguous}, + } { + for _, emitted := range guard.emitted { + emitted := emitted + protection := guard.protection + guardCases[protection]++ + t.Run(entry.CLIPath+"/"+string(protection)+"/"+emitted, func(t *testing.T) { + root := NewRootCommand() + leaf := resolveParamLeaf(root, entry.CLIPath) + if leaf == nil { + t.Fatalf("generated chat guard path %q is not runnable", entry.CLIPath) + } + value := paramFixtureValue(leaf, emitted, "did-you-mean:"+string(protection)) + rawArgs := append(strings.Fields(entry.CLIPath), "--"+emitted, value) + ctx, runErr := pipeline.RunPreParseArgs(root, newPipelineEngine(), rawArgs) + if runErr != nil { + t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, runErr) + } + morphed := cmdutil.Morph(emitted) + if ctx == nil || ctx.ProtectedFlags[morphed] != protection { + t.Fatalf("runtime guard %q protection = %#v, want %s", emitted, ctx, protection) + } + assertLeftUnchanged(t, ctx, emitted, value) + flagArgs := ctx.Args[len(strings.Fields(entry.CLIPath)):] + if parseErr := leaf.ParseFlags(flagArgs); parseErr == nil || !strings.Contains(parseErr.Error(), "unknown flag") { + t.Fatalf("guarded Cobra ParseFlags(%v) error = %v, want unknown flag", flagArgs, parseErr) + } + }) + } + } + } + + if chatEntries == 0 || aliasCases == 0 || guardCases[pipeline.FlagProtectionBlocked] == 0 || guardCases[pipeline.FlagProtectionAmbiguous] == 0 { + t.Fatalf("chat parameter coverage is vacuous: entries=%d aliases=%d blocked=%d ambiguous=%d", chatEntries, aliasCases, guardCases[pipeline.FlagProtectionBlocked], guardCases[pipeline.FlagProtectionAmbiguous]) + } + t.Logf("verified generated chat parameter routes: entries=%d aliases=%d blocked=%d ambiguous=%d", chatEntries, aliasCases, guardCases[pipeline.FlagProtectionBlocked], guardCases[pipeline.FlagProtectionAmbiguous]) +} + +func TestIMUserIDHallucinationRoutes(t *testing.T) { + tests := []struct { + command string + want string + }{ + // These paths are reduced by the reviewed user_id concept. + {command: "chat +chat-role-query-user", want: "user"}, + {command: "chat +chat-role-set-user", want: "user"}, + {command: "chat +messages-list-direct", want: "user"}, + {command: "chat chmod", want: "user"}, + {command: "chat message list", want: "user"}, + {command: "chat message send", want: "user"}, + + // These commands already own a hidden --userId compatibility flag. + // The format/spelling handler rewrites --user-id to that real flag, and + // the command's existing flagOrFallback wiring preserves its semantics. + {command: "chat conversation-info", want: "userId"}, + {command: "chat group transfer-owner", want: "userId"}, + {command: "chat group-role query-user", want: "userId"}, + {command: "chat group-role remove-user", want: "userId"}, + {command: "chat group-role set-user", want: "userId"}, + {command: "chat group set-admin", want: "userId"}, + {command: "chat group-mute-member", want: "userId"}, + {command: "chat message read-status", want: "userId"}, + {command: "chat message search-advanced", want: "userId"}, + } + + for _, test := range tests { + t.Run(test.command, func(t *testing.T) { + root := NewRootCommand() + leaf := resolveParamLeaf(root, test.command) + if leaf == nil { + t.Fatalf("IM command %q is not runnable", test.command) + } + rawArgs := append(strings.Fields(test.command), "--user-id", "fixture-user") + ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), rawArgs) + if err != nil { + t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, err) + } + if ctx == nil { + t.Fatal("RunPreParseArgs returned nil context") + } + flagArgs := ctx.Args[len(strings.Fields(test.command)):] + if len(flagArgs) != 2 || flagArgs[0] != "--"+test.want || flagArgs[1] != "fixture-user" { + t.Fatalf("--user-id route = %v, want --%s fixture-user", flagArgs, test.want) + } + if err := leaf.ParseFlags(flagArgs); err != nil { + t.Fatalf("Cobra ParseFlags(%v) error = %v", flagArgs, err) + } + }) + } +} + +func TestHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) { + const command = "chat message list-direct" + if _, ok := cli.LookupParamAlias(command); ok { + t.Fatalf("hidden command %q unexpectedly entered the public generated alias table", command) + } + + root := NewRootCommand() + leaf := resolveParamLeaf(root, command) + if leaf == nil || !leaf.Hidden { + t.Fatalf("%q must remain a live hidden compatibility command", command) + } + rawArgs := append(strings.Fields(command), "--user-id", "fixture-user") + ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), rawArgs) + if err != nil { + t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, err) + } + if ctx == nil { + t.Fatal("RunPreParseArgs returned nil context") + } + flagArgs := ctx.Args[len(strings.Fields(command)):] + if err := leaf.ParseFlags(flagArgs); err == nil || !strings.Contains(err.Error(), "unknown flag") { + t.Fatalf("hidden command ParseFlags(%v) error = %v, want unknown flag", flagArgs, err) + } +} + func TestSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing.T) { tests := []struct { name string diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go index 771a8c01..138c5e27 100644 --- a/internal/app/param_alias_payload_equivalence_test.go +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -4,11 +4,15 @@ package app import ( + "fmt" + "io" + "net/http" "reflect" "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" ) // paramAliasCompleteCommands is deliberately keyed by the exact reviewed @@ -18,50 +22,134 @@ import ( // target canonical flag must occur exactly once so the test can replace only // its spelling while holding every other input constant. var paramAliasCompleteCommands = map[string][]string{ - "aitable +base-search": {"aitable", "+base-search", "--query", "fixture"}, - "aitable +field-get": {"aitable", "+field-get", "--base-id", "base-1", "--table-id", "table-1"}, - "aitable +list-tables": {"aitable", "+list-tables", "--base", "base-1"}, - "aitable +record-query": {"aitable", "+record-query", "--base-id", "base-1", "--table-id", "table-1", "--query", "fixture"}, - "aitable +record-share-url": {"aitable", "+record-share-url", "--base-id", "base-1", "--table-id", "table-1", "--record-ids", "record-1"}, - "aitable +table-get": {"aitable", "+table-get", "--base-id", "base-1"}, - "aitable record query": {"aitable", "record", "query", "--base-id", "base-1", "--table-id", "table-1", "--limit", "7"}, - "attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"}, - "calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"}, - "chat +group-members": {"chat", "+group-members", "--group", "Fixture Group"}, - "chat group members": {"chat", "group", "members", "--id", "fixture-conversation"}, - "chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", "D-user-1"}, - "chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", "D-user-1", "--yes"}, - "chat group rename": {"chat", "group", "rename", "--id", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"}, - "chat group set-admin": {"chat", "group", "set-admin", "--group", "fixture-conversation", "--user", "user-1", "--yes"}, - "chat message list": {"chat", "message", "list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"}, - "chat message list-all": {"chat", "message", "list-all", "--start", "2026-03-10 00:00:00", "--end", "2026-03-11 00:00:00"}, - "chat message search-advanced": {"chat", "message", "search-advanced", "--conversation-ids", "fixture-conversation", "--query", "fixture"}, - "chat message send": {"chat", "message", "send", "--user", "D-recipient", "--text", "hello fixture", "--uuid", "param-alias-equivalence", "--yes"}, - "contact +dept-members": {"contact", "+dept-members", "--dept", "Fixture Dept"}, - "contact +list-sub-depts": {"contact", "+list-sub-depts", "--dept", "1"}, - "contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"}, - "contact +search-user": {"contact", "+search-user", "--query", "Fixture User"}, - "contact dept list-children": {"contact", "dept", "list-children", "--dept", "1"}, - "contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1"}, - "dev app get": {"dev", "app", "get", "--unified-app-id", "app-1"}, - "devdoc article search": {"devdoc", "article", "search", "--query", "fixture", "--page", "2", "--size", "7"}, - "ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"}, - "ding message receiver-status": {"ding", "message", "receiver-status", "--ding-id", "ding-1"}, - "ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1", "--yes"}, - "doc +template-search": {"doc", "+template-search", "--query", "fixture", "--source", "MY", "--limit", "7"}, - "doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"}, - "doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"}, - "drive info": {"drive", "info", "--node", "node-1", "--space-id", "space-1"}, - "drive list": {"drive", "list", "--folder", "folder-1", "--limit", "7"}, - "mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"}, - "mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder", "--yes"}, - "mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"}, - "mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"}, - "mail user search": {"mail", "user", "search", "--keyword", "fixture"}, - "oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "1"}, - "oa +search-forms": {"oa", "+search-forms", "--query", "fixture"}, - "oa approval search-forms": {"oa", "approval", "search-forms", "--query", "fixture"}, - "report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"}, + "aitable +base-search": {"aitable", "+base-search", "--query", "fixture"}, + "aitable +field-get": {"aitable", "+field-get", "--base-id", "base-1", "--table-id", "table-1"}, + "aitable +list-tables": {"aitable", "+list-tables", "--base", "base-1"}, + "aitable +record-query": {"aitable", "+record-query", "--base-id", "base-1", "--table-id", "table-1", "--query", "fixture"}, + "aitable +record-share-url": {"aitable", "+record-share-url", "--base-id", "base-1", "--table-id", "table-1", "--record-ids", "record-1"}, + "aitable +table-get": {"aitable", "+table-get", "--base-id", "base-1"}, + "aitable record query": {"aitable", "record", "query", "--base-id", "base-1", "--table-id", "table-1", "--limit", "7"}, + "attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"}, + "attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"}, + "calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"}, + "chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"}, + "chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"}, + "chat +category-create": {"chat", "+category-create", "--title", "Fixture Category", "--yes"}, + "chat +category-rename": {"chat", "+category-rename", "--category-id", "7", "--title", "Fixture Renamed Category", "--yes"}, + "chat +group-members": {"chat", "+group-members", "--group", "Fixture Group"}, + "chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"}, + "chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"}, + "chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"}, + "chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"}, + "chat +unread-chats": {"chat", "+unread-chats", "--count", "7", "--exclude-muted"}, + "chat bot find": {"chat", "bot", "find", "--query", "fixture", "--limit", "7"}, + "chat bot search": {"chat", "bot", "search", "--name", "Fixture Bot", "--page", "2", "--size", "7"}, + "chat category create": {"chat", "category", "create", "--title", "Fixture Category", "--yes"}, + "chat category create-smart": {"chat", "category", "create-smart", "--name", "Fixture Smart Category", "--keywords", "fixture,priority", "--yes"}, + "chat category rename": {"chat", "category", "rename", "--category-id", "7", "--title", "Fixture Renamed Category", "--yes"}, + "chat group members": {"chat", "group", "members", "--id", "fixture-conversation"}, + "chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", "D-user-1"}, + "chat group members add-bot": {"chat", "group", "members", "add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"}, + "chat group members list-by-ids": {"chat", "group", "members", "list-by-ids", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"}, + "chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", "D-user-1", "--yes"}, + "chat group members remove-bot": {"chat", "group", "members", "remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"}, + "chat group rename": {"chat", "group", "rename", "--id", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"}, + "chat group set-admin": {"chat", "group", "set-admin", "--group", "fixture-conversation", "--user", "user-1", "--yes"}, + "chat message add-emoji": {"chat", "message", "add-emoji", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--emoji", "赞", "--yes"}, + "chat message add-favorite": {"chat", "message", "add-favorite", "--open-message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--yes"}, + "chat message combine-forward": {"chat", "message", "combine-forward", "--src-conversation-id", "fixture-source", "--msg-ids", "message-1,message-2", "--dest-conversation-id", "fixture-destination", "--yes"}, + "chat message forward-topic": {"chat", "message", "forward-topic", "--src-msg-id", "message-1", "--src-conversation-id", "fixture-source", "--src-thread-id", "convThread-fixture", "--dest-conversation-id", "fixture-destination", "--yes"}, + "chat message list": {"chat", "message", "list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"}, + "chat message list-all": {"chat", "message", "list-all", "--start", "2026-03-10 00:00:00", "--end", "2026-03-11 00:00:00"}, + "chat message list-by-sender": {"chat", "message", "list-by-sender", "--sender-user-id", "user-1", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"}, + "chat message list-favorites": {"chat", "message", "list-favorites", "--cursor", "2", "--size", "7"}, + "chat message list-by-ids": {"chat", "message", "list-by-ids", "--msg-ids", "message-1,message-2"}, + "chat message list-unread-conversations": {"chat", "message", "list-unread-conversations", "--count", "7", "--exclude-muted"}, + "chat message recall": {"chat", "message", "recall", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"}, + "chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"}, + "chat message search-advanced": {"chat", "message", "search-advanced", "--conversation-ids", "fixture-conversation", "--query", "fixture"}, + "chat message send": {"chat", "message", "send", "--user", "D-recipient", "--text", "hello fixture", "--uuid", "param-alias-equivalence", "--yes"}, + "chat message send-by-bot": {"chat", "message", "send-by-bot", "--robot-code", "robot-1", "--group", "fixture-conversation", "--title", "Fixture Alert", "--text", "@user-1 @user-2 fixture", "--at-user-ids", "user-1,user-2", "--yes"}, + "chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"}, + "chat media upload": {"chat", "media", "upload", "--file", "../../go.mod", "--type", "image"}, + "contact +dept-members": {"contact", "+dept-members", "--dept", "Fixture Dept"}, + "contact +list-sub-depts": {"contact", "+list-sub-depts", "--dept", "1"}, + "contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"}, + "contact +search-user": {"contact", "+search-user", "--query", "Fixture User"}, + "contact dept list-children": {"contact", "dept", "list-children", "--dept", "1"}, + "contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1"}, + "dev app get": {"dev", "app", "get", "--unified-app-id", "app-1"}, + "devdoc article search": {"devdoc", "article", "search", "--query", "fixture", "--page", "2", "--size", "7"}, + "ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"}, + "ding message receiver-status": {"ding", "message", "receiver-status", "--ding-id", "ding-1"}, + "ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1", "--yes"}, + "doc +template-search": {"doc", "+template-search", "--query", "fixture", "--source", "MY", "--limit", "7"}, + "doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"}, + "doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"}, + "drive info": {"drive", "info", "--node", "node-1", "--space-id", "space-1"}, + "drive list": {"drive", "list", "--folder", "folder-1", "--limit", "7"}, + "mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"}, + "mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder", "--yes"}, + "mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"}, + "mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"}, + "mail user search": {"mail", "user", "search", "--keyword", "fixture"}, + "oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "1"}, + "oa +search-forms": {"oa", "+search-forms", "--query", "fixture"}, + "oa approval search-forms": {"oa", "approval", "search-forms", "--query", "fixture"}, + "report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"}, +} + +// A command can expose more than one mutually exclusive canonical route. In +// that case the shared command template above cannot contain every canonical +// flag at once, so select a fixture-specific complete invocation here. +var paramAliasCompleteCommandVariants = map[string]map[string][]string{ + "chat message list": { + "user": {"chat", "message", "list", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"}, + }, + "chat message list-by-sender": { + "sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", "D-sender", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"}, + }, + "chat message send": { + "group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--uuid", "param-alias-equivalence-group", "--yes"}, + "file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--uuid", "param-alias-equivalence-file", "--yes"}, + }, +} + +// paramAliasNewIMCases is the exact set of aliases added by the reviewed IM +// optimization. The dedicated gate below requires every one to remain active +// in the embedded generated table and equivalent at the final transport. +var paramAliasNewIMCases = []struct { + command string + emitted string + canonical string +}{ + {command: "chat +bot-find", emitted: "name", canonical: "query"}, + {command: "chat bot find", emitted: "name", canonical: "query"}, + {command: "chat +bot-search", emitted: "query", canonical: "name"}, + {command: "chat +bot-search", emitted: "current-page", canonical: "page"}, + {command: "chat +category-create", emitted: "name", canonical: "title"}, + {command: "chat +category-rename", emitted: "name", canonical: "title"}, + {command: "chat +messages-list-direct", emitted: "start", canonical: "time"}, + {command: "chat +messages-list-unread-conversations", emitted: "limit", canonical: "count"}, + {command: "chat +messages-list-unread-conversations", emitted: "size", canonical: "count"}, + {command: "chat +messages-send-by-webhook", emitted: "at-user-ids", canonical: "at-users"}, + {command: "chat +unread-chats", emitted: "limit", canonical: "count"}, + {command: "chat +unread-chats", emitted: "size", canonical: "count"}, + {command: "chat bot search", emitted: "query", canonical: "name"}, + {command: "chat bot search", emitted: "current-page", canonical: "page"}, + {command: "chat category create", emitted: "name", canonical: "title"}, + {command: "chat category create-smart", emitted: "title", canonical: "name"}, + {command: "chat category rename", emitted: "name", canonical: "title"}, + {command: "chat media upload", emitted: "file-path", canonical: "file"}, + {command: "chat message list", emitted: "start", canonical: "time"}, + {command: "chat message list-by-sender", emitted: "user-id", canonical: "sender-user-id"}, + {command: "chat message list-by-sender", emitted: "open-dingtalk-id", canonical: "sender-open-dingtalk-id"}, + {command: "chat message list-favorites", emitted: "limit", canonical: "size"}, + {command: "chat message list-unread-conversations", emitted: "limit", canonical: "count"}, + {command: "chat message list-unread-conversations", emitted: "size", canonical: "count"}, + {command: "chat message send", emitted: "file", canonical: "file-path"}, + {command: "chat message send-by-bot", emitted: "at-users", canonical: "at-user-ids"}, + {command: "chat message send-by-webhook", emitted: "at-user-ids", canonical: "at-users"}, } func TestReviewedParamAliasesProduceCanonicalEquivalentFinalPayloads(t *testing.T) { @@ -79,7 +167,7 @@ func TestReviewedParamAliasesProduceCanonicalEquivalentFinalPayloads(t *testing. activeCommands[fixture.Command] = true activeCases++ t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) { - complete, ok := paramAliasCompleteCommands[fixture.Command] + complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect) if !ok { t.Fatalf("reviewed active fixture has no complete-command E2E template") } @@ -90,7 +178,7 @@ func TestReviewedParamAliasesProduceCanonicalEquivalentFinalPayloads(t *testing. } canonicalCaller := ¶mAliasCaptureCaller{} - _, canonicalErr := executeParamAliasE2E(t, canonicalCaller, canonicalArgs...) + _, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...) if canonicalErr != nil { t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls) } @@ -99,7 +187,7 @@ func TestReviewedParamAliasesProduceCanonicalEquivalentFinalPayloads(t *testing. } aliasCaller := ¶mAliasCaptureCaller{} - ctx, aliasErr := executeParamAliasE2E(t, aliasCaller, aliasArgs...) + ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...) if aliasErr != nil { t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls) } @@ -120,11 +208,143 @@ func TestReviewedParamAliasesProduceCanonicalEquivalentFinalPayloads(t *testing. t.Errorf("complete-command E2E template %q has no active reviewed fixture", command) } } + for command := range activeCommands { + if _, ok := paramAliasCompleteCommands[command]; !ok { + t.Errorf("active reviewed command %q has no complete-command E2E template", command) + } + } if len(activeCommands) != len(paramAliasCompleteCommands) { t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(paramAliasCompleteCommands), len(activeCommands), activeCases) } } +func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) { + activeAliases := 0 + for _, test := range paramAliasNewIMCases { + test := test + t.Run(test.command+"/"+test.emitted, func(t *testing.T) { + complete, ok := paramAliasCompleteCommand(test.command, test.canonical) + if !ok { + t.Fatal("reviewed IM alias has no complete-command E2E template") + } + canonicalArgs := append([]string(nil), complete...) + aliasArgs, replacements := replaceLongFlag(canonicalArgs, test.canonical, test.emitted) + if replacements != 1 { + t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, canonicalArgs) + } + + canonicalCaller := ¶mAliasCaptureCaller{} + if _, err := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...); err != nil { + t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", err, canonicalArgs, canonicalCaller.calls) + } + if len(canonicalCaller.calls) == 0 { + t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs) + } + + entry, exists := cli.LookupParamAlias(test.command) + target, active := entry.ResolveAlias(test.emitted) + if !exists || !active { + return + } + if target != test.canonical { + t.Fatalf("active reviewed IM alias --%s resolves to --%s, want --%s", test.emitted, target, test.canonical) + } + activeAliases++ + aliasCaller := ¶mAliasCaptureCaller{} + ctx, err := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...) + if err != nil { + t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", err, aliasArgs, aliasCaller.calls) + } + if ctx == nil { + t.Fatal("complete alias command skipped PreParse") + } + if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) { + t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls) + } + }) + } + if activeAliases != len(paramAliasNewIMCases) { + t.Fatalf("new IM aliases active in embedded table = %d, want %d", activeAliases, len(paramAliasNewIMCases)) + } +} + +func paramAliasCompleteCommand(command, canonical string) ([]string, bool) { + complete, ok := paramAliasCompleteCommands[command] + if variants := paramAliasCompleteCommandVariants[command]; variants != nil { + if variant, exists := variants[canonical]; exists { + return variant, true + } + } + return complete, ok +} + +type paramAliasRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f paramAliasRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +// executeParamAliasPayloadE2E captures the final HTTP multipart request for +// chat media upload. Other commands continue to use the MCP/runner capture in +// executeParamAliasE2E. This keeps the equivalence assertion at the actual +// transport boundary for both kinds of command. +func executeParamAliasPayloadE2E(t *testing.T, caller *paramAliasCaptureCaller, args ...string) (*pipeline.Context, error) { + t.Helper() + if len(args) < 3 || args[0] != "chat" || args[1] != "media" || args[2] != "upload" { + return executeParamAliasE2E(t, caller, args...) + } + + t.Setenv("DWS_CLIENT_ID", "fixture-client") + t.Setenv("DWS_CLIENT_SECRET", "fixture-secret") + originalTransport := http.DefaultTransport + http.DefaultTransport = paramAliasRoundTripFunc(func(req *http.Request) (*http.Response, error) { + switch req.URL.Path { + case "/gettoken": + return paramAliasHTTPResponse(req, `{"access_token":"fixture-access-token","errcode":0}`), nil + case "/media/upload": + if err := req.ParseMultipartForm(1 << 20); err != nil { + return nil, fmt.Errorf("parse media upload multipart form: %w", err) + } + if req.MultipartForm != nil { + defer req.MultipartForm.RemoveAll() + } + file, header, err := req.FormFile("media") + if err != nil { + return nil, fmt.Errorf("read media upload part: %w", err) + } + defer file.Close() + content, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("read media upload content: %w", err) + } + caller.calls = append(caller.calls, paramAliasToolCall{ + server: "oapi.dingtalk.com", + tool: "media/upload", + args: map[string]any{ + "method": req.Method, + "type": req.URL.Query().Get("type"), + "filename": header.Filename, + "content": string(content), + }, + }) + return paramAliasHTTPResponse(req, `{"media_id":"fixture-media-id","errcode":0}`), nil + default: + return nil, fmt.Errorf("unexpected parameter-alias HTTP request: %s %s", req.Method, req.URL.String()) + } + }) + defer func() { http.DefaultTransport = originalTransport }() + return executeParamAliasE2E(t, caller, args...) +} + +func paramAliasHTTPResponse(req *http.Request, body string) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + } +} + func replaceLongFlag(args []string, canonical, emitted string) ([]string, int) { out := append([]string(nil), args...) replacements := 0 diff --git a/internal/cli/param_aliases.go b/internal/cli/param_aliases.go index 054d9a4b..54b7e50c 100644 --- a/internal/cli/param_aliases.go +++ b/internal/cli/param_aliases.go @@ -336,16 +336,6 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc blocked = append(blocked, b) } - // A name cannot be both auto-reduced and ambiguous: the alias table says - // "silently rewrite it" while the ambiguous list says "stop and ask". This - // usually means a reviewer hand-listed a name in the ambiguous whitelist - // that the concept can in fact resolve to a single real flag. - for _, a := range ambiguous { - if canon, ok := aliasMap[a]; ok { - problems = append(problems, fmt.Sprintf("command %q name %q is both auto-reduced to %q and marked ambiguous; a name cannot be aliased and ambiguous at once", path, a, canon)) - } - } - if len(aliasMap) == 0 && len(blocked) == 0 && len(ambiguous) == 0 { return nil, problems } diff --git a/internal/cli/param_aliases_generated.go b/internal/cli/param_aliases_generated.go index 733bae69..a6d1140b 100644 --- a/internal/cli/param_aliases_generated.go +++ b/internal/cli/param_aliases_generated.go @@ -69,7 +69,10 @@ var generatedParamAliases = []ParamAliasEntry{ }, { CLIPath: "attendance +check-result", - Blocked: []string{"user-id"}, + Aliases: map[string]string{ + "user-ids": "users", + }, + Blocked: []string{"at-user-ids", "staff-id", "uid", "user", "user-id", "userid"}, }, { CLIPath: "attendance check result", @@ -94,85 +97,861 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"offset", "page", "time"}, }, + { + CLIPath: "chat +bot-find", + Aliases: map[string]string{ + "name": "query", + }, + }, + { + CLIPath: "chat +bot-search", + Aliases: map[string]string{ + "current-page": "page", + "query": "name", + }, + Blocked: []string{"cursor"}, + }, + { + CLIPath: "chat +category-create", + Aliases: map[string]string{ + "name": "title", + }, + }, + { + CLIPath: "chat +category-delete", + Blocked: []string{"category-ids"}, + }, + { + CLIPath: "chat +category-rename", + Aliases: map[string]string{ + "name": "title", + }, + Blocked: []string{"category-ids"}, + }, + { + CLIPath: "chat +chat-bots", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat +chat-dismiss", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat +chat-invite-url", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat +chat-mute", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat +chat-role-add", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat +chat-role-list", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat +chat-role-query-user", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + "staff-id": "user", + "uid": "user", + "user-id": "user", + "userid": "user", + }, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + }, + { + CLIPath: "chat +chat-role-set-user", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + "staff-id": "user", + "uid": "user", + "user-id": "user", + "userid": "user", + }, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + }, + { + CLIPath: "chat +chat-role-update", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat +chat-set-admin", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "open-dingtalk-id", "source", "src-conversation-id", "target", "user-id"}, + }, + { + CLIPath: "chat +chat-set-history", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat +chat-update-alias", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat +chat-update-nick", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat +conversation-info", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "user", "user-id", "userid"}, + }, { CLIPath: "chat +group-members", Aliases: map[string]string{ "group-name": "group", }, + Blocked: []string{"chat-id", "conversation-id", "group-id", "id", "open-conversation-id"}, + }, + { + CLIPath: "chat +messages-list-direct", + Aliases: map[string]string{ + "staff-id": "user", + "start": "time", + "uid": "user", + "user-id": "user", + "userid": "user", + }, + Blocked: []string{"at-user-ids", "end", "name", "to-user", "user-ids", "users"}, + }, + { + CLIPath: "chat +messages-list-pin", + Aliases: map[string]string{ + "chat": "open-conversation-id", + "chat-id": "open-conversation-id", + "conversation-id": "open-conversation-id", + "group": "open-conversation-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat +messages-list-unread-conversations", + Aliases: map[string]string{ + "limit": "count", + "size": "count", + }, + }, + { + CLIPath: "chat +messages-mget", + Aliases: map[string]string{ + "message-ids": "msg-ids", + "open-message-ids": "msg-ids", + }, + Blocked: []string{"message-id", "msg-id", "open-message-id", "ref-msg-id", "src-msg-id"}, + }, + { + CLIPath: "chat +messages-read-status", + Aliases: map[string]string{ + "chat": "conversation-id", + "chat-id": "conversation-id", + "msg-id": "message-id", + "open-conversation-id": "conversation-id", + "open-message-id": "message-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-dingtalk-id", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id", "user-id"}, + }, + { + CLIPath: "chat +messages-send-by-webhook", + Aliases: map[string]string{ + "at-user-ids": "at-users", + }, + }, + { + CLIPath: "chat +send-to-group", + Aliases: map[string]string{ + "group-name": "group", + }, + Blocked: []string{"chat-id", "conversation-id", "group-id", "id", "open-conversation-id"}, + }, + { + CLIPath: "chat +unread-chats", + Aliases: map[string]string{ + "limit": "count", + "size": "count", + }, + }, + { + CLIPath: "chat bot find", + Aliases: map[string]string{ + "name": "query", + }, + }, + { + CLIPath: "chat bot search", + Aliases: map[string]string{ + "current-page": "page", + "query": "name", + }, + Blocked: []string{"cursor"}, + }, + { + CLIPath: "chat category add-conv", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat category create", + Aliases: map[string]string{ + "name": "title", + }, + }, + { + CLIPath: "chat category create-smart", + Aliases: map[string]string{ + "open-dingtalk-ids": "members", + "title": "name", + }, + Blocked: []string{"staff-id", "user", "user-id", "user-ids", "users"}, + }, + { + CLIPath: "chat category delete", + Blocked: []string{"category-ids"}, + }, + { + CLIPath: "chat category list-conversations", + Blocked: []string{"category-ids"}, + }, + { + CLIPath: "chat category remove-conv", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat category rename", + Aliases: map[string]string{ + "name": "title", + }, + Blocked: []string{"category-ids"}, + }, + { + CLIPath: "chat chmod", + Aliases: map[string]string{ + "chat": "conversation-id", + "chat-id": "conversation-id", + "group": "conversation-id", + "open-conversation-id": "conversation-id", + "staff-id": "user", + "uid": "user", + "user-id": "user", + "userid": "user", + }, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + }, + { + CLIPath: "chat clear-messages", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "group": "conversation-id", + "open-conversation-id": "conversation-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat clear-red-point", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "group": "conversation-id", + "open-conversation-id": "conversation-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat conversation-info", + Aliases: map[string]string{ + "chat-id": "group", + "open-conversation-id": "group", + "staff-id": "user", + "uid": "user", + "userid": "user", + }, + 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 audit-join-validation", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + Ambiguous: []string{"staff-id", "uid", "user", "user-id", "userid"}, + }, + { + CLIPath: "chat group bots", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group create", + Blocked: []string{"open-dingtalk-id", "user-id"}, + }, + { + CLIPath: "chat group dismiss", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group get-by-group-id", + Blocked: []string{"chat", "chat-id", "conversation-id", "conversation-ids", "group", "group-name", "id", "name", "open-conversation-id", "open-conversation-ids"}, + }, + { + CLIPath: "chat group invite-url", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, }, { CLIPath: "chat group members", Aliases: map[string]string{ + "chat": "id", "chat-id": "id", "conversation-id": "id", "group": "id", - "group-id": "id", "open-conversation-id": "id", }, - Blocked: []string{"conversation-ids", "group-ids", "name"}, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, }, { CLIPath: "chat group members add", Aliases: map[string]string{ + "chat": "id", "chat-id": "id", "conversation-id": "id", "group": "id", - "group-id": "id", "open-conversation-id": "id", }, - Blocked: []string{"conversation-ids", "group-ids", "name"}, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "open-dingtalk-id", "source", "src-conversation-id", "target", "user-id"}, + }, + { + CLIPath: "chat group members add-bot", + Aliases: map[string]string{ + "chat": "id", + "chat-id": "id", + "conversation-id": "id", + "group": "id", + "open-conversation-id": "id", + "robot": "robot-code", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group members list-by-ids", + Aliases: map[string]string{ + "chat": "id", + "chat-id": "id", + "conversation-id": "id", + "group": "id", + "open-conversation-id": "id", + "open-dingtalk-ids": "users", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "user", "user-id", "user-ids"}, }, { CLIPath: "chat group members remove", Aliases: map[string]string{ + "chat": "id", "chat-id": "id", "conversation-id": "id", "group": "id", - "group-id": "id", + "open-conversation-id": "id", + "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", "staff-id", "target", "uid", "user", "user-id", "userid"}, + }, + { + CLIPath: "chat group members remove-bot", + Aliases: map[string]string{ + "chat": "id", + "chat-id": "id", + "conversation-id": "id", + "group": "id", + "open-bot-id": "bot-id", "open-conversation-id": "id", }, - Blocked: []string{"conversation-ids", "group-ids", "name"}, + Blocked: []string{"bot-code", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "robot", "robot-code", "robot-id", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group notice create", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group notice edit", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group notice get", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group notice list", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group quit", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, }, { CLIPath: "chat group rename", Aliases: map[string]string{ + "chat": "id", "chat-id": "id", "conversation-id": "id", "group": "id", - "group-id": "id", "open-conversation-id": "id", }, - Blocked: []string{"conversation-ids", "group-ids"}, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group set-admin", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + "user-ids": "users", + }, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"}, + }, + { + CLIPath: "chat group set-history", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group share-invite", + Aliases: map[string]string{ + "source-conversation-id": "source", + "target-conversation-id": "target", + }, + Blocked: []string{"group-id", "group-ids", "staff-id", "uid", "user", "user-id", "userid"}, + Ambiguous: []string{"chat", "chat-id", "conversation-id", "group", "id", "open-conversation-id"}, + }, + { + CLIPath: "chat group transfer-owner", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + "staff-id": "user", + "uid": "user", + "userid": "user", + }, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + }, + { + CLIPath: "chat group update-alias", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group update-icon", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group update-nick", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group update-settings", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group-mute", + Aliases: map[string]string{ + "chat-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group-mute-member", + Aliases: map[string]string{ + "chat-id": "group", + "open-conversation-id": "group", + "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", "staff-id", "target", "uid", "userid"}, + }, + { + CLIPath: "chat group-role add", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group-role list", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group-role query-user", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + "staff-id": "user", + "uid": "user", + "userid": "user", + }, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + }, + { + CLIPath: "chat group-role remove", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat group-role remove-user", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + "staff-id": "user", + "uid": "user", + "userid": "user", + }, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + }, + { + CLIPath: "chat group-role set-user", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + "staff-id": "user", + "uid": "user", + "userid": "user", + }, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + }, + { + CLIPath: "chat group-role update", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat hide", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "group": "conversation-id", + "open-conversation-id": "conversation-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat mark-read", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "group": "conversation-id", + "msg-id": "message-id", + "open-conversation-id": "conversation-id", + "open-message-id": "message-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"}, + }, + { + CLIPath: "chat mark-unread", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "group": "conversation-id", + "open-conversation-id": "conversation-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat media upload", + Aliases: map[string]string{ + "file-path": "file", + }, }, { CLIPath: "chat message add-emoji", Aliases: map[string]string{ "chat-id": "conversation-id", - "group-id": "conversation-id", + "message-id": "msg-id", "open-conversation-id": "conversation-id", + "open-message-id": "msg-id", }, - Blocked: []string{"conversation-ids"}, + Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"}, + }, + { + CLIPath: "chat message add-favorite", + Aliases: map[string]string{ + "chat": "open-conversation-id", + "chat-id": "open-conversation-id", + "conversation-id": "open-conversation-id", + "group": "open-conversation-id", + "message-id": "open-message-id", + "msg-id": "open-message-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"}, }, { CLIPath: "chat message add-text-emotion", Aliases: map[string]string{ "chat-id": "conversation-id", - "group-id": "conversation-id", + "message-id": "msg-id", "open-conversation-id": "conversation-id", + "open-message-id": "msg-id", }, - Blocked: []string{"conversation-ids"}, + Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"}, + }, + { + CLIPath: "chat message combine-forward", + Aliases: map[string]string{ + "dest-open-cid": "dest-conversation-id", + "destination-conversation-id": "dest-conversation-id", + "message-ids": "msg-ids", + "open-message-ids": "msg-ids", + "source-conversation-id": "src-conversation-id", + "src-open-cid": "src-conversation-id", + "target-conversation-id": "dest-conversation-id", + }, + Blocked: []string{"group-id", "group-ids", "message-id", "msg-id", "open-message-id", "ref-msg-id", "src-msg-id"}, + Ambiguous: []string{"chat", "chat-id", "conversation-id", "group", "id", "open-conversation-id"}, + }, + { + CLIPath: "chat message download-media", + Aliases: map[string]string{ + "chat": "open-conversation-id", + "chat-id": "open-conversation-id", + "conversation-id": "open-conversation-id", + "group": "open-conversation-id", + "msg-id": "message-id", + "open-message-id": "message-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"}, + }, + { + CLIPath: "chat message forward", + Aliases: map[string]string{ + "dest-open-cid": "dest-conversation-id", + "destination-conversation-id": "dest-conversation-id", + "message-id": "msg-id", + "open-message-id": "msg-id", + "source-conversation-id": "src-conversation-id", + "src-open-cid": "src-conversation-id", + "target-conversation-id": "dest-conversation-id", + }, + Blocked: []string{"group-id", "group-ids", "message-ids", "msg-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"}, + Ambiguous: []string{"chat", "chat-id", "conversation-id", "group", "id", "open-conversation-id"}, + }, + { + CLIPath: "chat message forward-topic", + Aliases: map[string]string{ + "dest-open-conversation-id": "dest-conversation-id", + "destination-conversation-id": "dest-conversation-id", + "source-conversation-id": "src-conversation-id", + "source-message-id": "src-msg-id", + "src-open-conversation-id": "src-conversation-id", + "src-open-message-id": "src-msg-id", + "target-conversation-id": "dest-conversation-id", + }, + Blocked: []string{"group-id", "group-ids", "message-id", "msg-id", "open-message-id"}, + Ambiguous: []string{"chat", "chat-id", "conversation-id", "group", "id", "open-conversation-id"}, }, { CLIPath: "chat message list", Aliases: map[string]string{ - "max-result": "limit", - "max-results": "limit", - "page-size": "limit", - "per-page": "limit", - "take": "limit", - "top": "limit", + "chat-id": "group", + "max-result": "limit", + "max-results": "limit", + "open-conversation-id": "group", + "page-size": "limit", + "per-page": "limit", + "staff-id": "user", + "start": "time", + "take": "limit", + "top": "limit", + "uid": "user", + "user-id": "user", + "userid": "user", }, - Blocked: []string{"count", "cursor", "page"}, + Blocked: []string{"at-user-ids", "conversation-ids", "count", "cursor", "dest-conversation-id", "end", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "page", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, }, { CLIPath: "chat message list-all", @@ -188,47 +967,296 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"date", "time"}, }, + { + CLIPath: "chat message list-by-ids", + Aliases: map[string]string{ + "message-ids": "msg-ids", + "open-message-ids": "msg-ids", + }, + Blocked: []string{"message-id", "msg-id", "open-message-id", "ref-msg-id", "src-msg-id"}, + }, { CLIPath: "chat message list-by-sender", + Aliases: map[string]string{ + "open-dingtalk-id": "sender-open-dingtalk-id", + "user-id": "sender-user-id", + }, Blocked: []string{"time"}, }, + { + CLIPath: "chat message list-emotion-replies", + Aliases: map[string]string{ + "message-ids": "msg-ids", + "open-message-ids": "msg-ids", + }, + Blocked: []string{"message-id", "msg-id", "open-message-id", "ref-msg-id", "src-msg-id"}, + }, + { + CLIPath: "chat message list-favorites", + Aliases: map[string]string{ + "limit": "size", + }, + }, + { + CLIPath: "chat message list-mentions", + Aliases: map[string]string{ + "chat-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat message list-pin-msg", + Aliases: map[string]string{ + "chat": "open-conversation-id", + "chat-id": "open-conversation-id", + "conversation-id": "open-conversation-id", + "group": "open-conversation-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat message list-topic-replies", + Aliases: map[string]string{ + "chat-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat message list-unread-conversations", + Aliases: map[string]string{ + "limit": "count", + "size": "count", + }, + }, + { + CLIPath: "chat message read-status", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "msg-id": "message-id", + "open-conversation-id": "conversation-id", + "open-message-id": "message-id", + "user-ids": "users", + }, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "staff-id", "target", "topic-id", "uid", "userid"}, + }, + { + CLIPath: "chat message recall", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "message-id": "msg-id", + "open-conversation-id": "conversation-id", + "open-message-id": "msg-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"}, + }, + { + CLIPath: "chat message recall-by-bot", + Aliases: map[string]string{ + "chat-id": "group", + "open-conversation-id": "group", + "robot": "robot-code", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "target"}, + }, { CLIPath: "chat message remove-emoji", Aliases: map[string]string{ "chat-id": "conversation-id", - "group-id": "conversation-id", + "message-id": "msg-id", "open-conversation-id": "conversation-id", + "open-message-id": "msg-id", }, - Blocked: []string{"conversation-ids"}, + Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"}, + }, + { + CLIPath: "chat message remove-favorite", + Aliases: map[string]string{ + "chat": "open-conversation-id", + "chat-id": "open-conversation-id", + "conversation-id": "open-conversation-id", + "group": "open-conversation-id", + "message-id": "open-message-id", + "msg-id": "open-message-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"}, }, { CLIPath: "chat message remove-text-emotion", Aliases: map[string]string{ "chat-id": "conversation-id", - "group-id": "conversation-id", + "message-id": "msg-id", "open-conversation-id": "conversation-id", + "open-message-id": "msg-id", }, - Blocked: []string{"conversation-ids"}, + Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"}, + }, + { + CLIPath: "chat message reply", + Aliases: map[string]string{ + "chat": "conversation-id", + "chat-id": "conversation-id", + "group": "conversation-id", + "open-conversation-id": "conversation-id", + "ref-message-id": "ref-msg-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-id", "msg-id", "msg-ids", "name", "open-conversation-ids", "open-message-id", "source", "src-conversation-id", "src-msg-id", "staff-id", "target", "uid", "user", "user-id", "userid"}, + }, + { + CLIPath: "chat message search", + Aliases: map[string]string{ + "chat-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat message search-advanced", + Aliases: map[string]string{ + "open-conversation-ids": "conversation-ids", + "user-ids": "users", + }, + Blocked: []string{"at-user-ids", "chat-id", "conversation-id", "group-id", "group-ids", "open-conversation-id", "staff-id", "uid", "userid"}, }, { CLIPath: "chat message send", Aliases: map[string]string{ - "to-user": "user", + "chat-id": "group", + "file": "file-path", + "open-conversation-id": "group", + "staff-id": "user", + "to-user": "user", + "uid": "user", + "user-id": "user", + "userid": "user", }, + 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", "user-ids", "users"}, }, { CLIPath: "chat message send-by-bot", Aliases: map[string]string{ - "user-ids": "users", + "at-users": "at-user-ids", + "chat-id": "group", + "open-conversation-id": "group", + "robot": "robot-code", + "user-ids": "users", }, - Blocked: []string{"staff-id", "to-user-id", "uid", "user", "user-id", "userid"}, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "staff-id", "target", "to-user-id", "uid", "user", "user-id", "userid"}, + Ambiguous: []string{"at-ids"}, + }, + { + CLIPath: "chat message send-by-webhook", + Aliases: map[string]string{ + "at-user-ids": "at-users", + }, + }, + { + CLIPath: "chat message send-card", + Aliases: map[string]string{ + "chat": "group", + "chat-id": "group", + "conversation-id": "group", + "open-conversation-id": "group", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "user", "user-id", "userid"}, + }, + { + CLIPath: "chat message set-pin-msg", + Aliases: map[string]string{ + "chat": "open-conversation-id", + "chat-id": "open-conversation-id", + "conversation-id": "open-conversation-id", + "group": "open-conversation-id", + "message-id": "msg-id", + "open-message-id": "msg-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"}, + }, + { + CLIPath: "chat message set-top-msg", + Aliases: map[string]string{ + "chat": "open-conversation-id", + "chat-id": "open-conversation-id", + "conversation-id": "open-conversation-id", + "group": "open-conversation-id", + "message-id": "msg-id", + "open-message-id": "msg-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"}, + }, + { + CLIPath: "chat message unset-pin-msg", + Aliases: map[string]string{ + "chat": "open-conversation-id", + "chat-id": "open-conversation-id", + "conversation-id": "open-conversation-id", + "group": "open-conversation-id", + "message-id": "msg-id", + "open-message-id": "msg-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"}, + }, + { + CLIPath: "chat message unset-top-msg", + Aliases: map[string]string{ + "chat": "open-conversation-id", + "chat-id": "open-conversation-id", + "conversation-id": "open-conversation-id", + "group": "open-conversation-id", + "message-id": "msg-id", + "open-message-id": "msg-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"}, + }, + { + CLIPath: "chat mute", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "group": "conversation-id", + "open-conversation-id": "conversation-id", + }, + Blocked: []string{"conversation-ids", "group-id", "group-ids", "open-conversation-ids"}, + }, + { + CLIPath: "chat mute-at-all", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "group": "conversation-id", + "open-conversation-id": "conversation-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat mute-red-envelope", + Aliases: map[string]string{ + "chat-id": "conversation-id", + "group": "conversation-id", + "open-conversation-id": "conversation-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, + }, + { + CLIPath: "chat set-top", + Aliases: map[string]string{ + "chat": "conversation-id", + "chat-id": "conversation-id", + "group": "conversation-id", + "open-conversation-id": "conversation-id", + }, + Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, }, { CLIPath: "contact +dept-members", Aliases: map[string]string{ - "name": "dept", - "query": "dept", + "keyword": "dept", + "keywords": "dept", + "name": "dept", + "q": "dept", + "query": "dept", + "search-word": "dept", }, + Blocked: []string{"subject", "text", "title"}, }, { CLIPath: "contact +list-dept-members", @@ -244,14 +1272,21 @@ var generatedParamAliases = []ParamAliasEntry{ "department": "dept", "department-id": "dept", "dept-id": "dept", + "parent": "dept", + "parent-id": "dept", }, - Blocked: []string{"department-ids", "dept-ids", "depts", "name", "parent", "query"}, + Blocked: []string{"department-ids", "dept-ids", "depts", "name", "query"}, }, { CLIPath: "contact +resolve-dept", Aliases: map[string]string{ - "query": "name", + "keyword": "name", + "keywords": "name", + "q": "name", + "query": "name", + "search-word": "name", }, + Blocked: []string{"subject", "text", "title"}, }, { CLIPath: "contact +search-user", @@ -482,3 +1517,7 @@ var generatedParamAliases = []ParamAliasEntry{ Blocked: []string{"template-type"}, }, } + +func loadGeneratedParamAliases() []ParamAliasEntry { + return generatedParamAliases +} diff --git a/internal/cli/param_aliases_lookup.go b/internal/cli/param_aliases_lookup.go index b8e431dd..92eb7168 100644 --- a/internal/cli/param_aliases_lookup.go +++ b/internal/cli/param_aliases_lookup.go @@ -24,8 +24,9 @@ var ( ) func buildParamAliasIndex() { - paramAliasIndex = make(map[string]ParamAliasEntry, len(generatedParamAliases)) - for _, e := range generatedParamAliases { + entries := loadGeneratedParamAliases() + paramAliasIndex = make(map[string]ParamAliasEntry, len(entries)) + for _, e := range entries { paramAliasIndex[e.CLIPath] = e } } diff --git a/internal/cli/param_aliases_test.go b/internal/cli/param_aliases_test.go index 98b0efb0..989fc055 100644 --- a/internal/cli/param_aliases_test.go +++ b/internal/cli/param_aliases_test.go @@ -5,10 +5,13 @@ package cli import ( + "errors" + "reflect" "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/spf13/cobra" ) // realMap builds a per-leaf real-flag table keyed by the shared Morph so the @@ -32,6 +35,229 @@ func conceptFixture() []Concept { } } +func useParamConceptLoader(t *testing.T, concepts ParamConcepts, err error) { + t.Helper() + previous := loadReviewedParamConcepts + loadReviewedParamConcepts = func() (ParamConcepts, error) { return concepts, err } + t.Cleanup(func() { loadReviewedParamConcepts = previous }) +} + +func TestReduceParamAliasesLoadsAndValidatesSourceTree(t *testing.T) { + t.Run("load failure", func(t *testing.T) { + useParamConceptLoader(t, ParamConcepts{}, errors.New("fixture load")) + if _, err := ReduceParamAliases(&cobra.Command{Use: "dws"}); err == nil || !strings.Contains(err.Error(), "fixture load") { + t.Fatalf("ReduceParamAliases() error = %v", err) + } + }) + + t.Run("nil root", func(t *testing.T) { + useParamConceptLoader(t, ParamConcepts{Version: 1}, nil) + if _, err := ReduceParamAliases(nil); err == nil || !strings.Contains(err.Error(), "root is nil") { + t.Fatalf("ReduceParamAliases(nil) error = %v", err) + } + }) + + t.Run("real tree", func(t *testing.T) { + concepts := ParamConcepts{ + Version: 1, + Concepts: []Concept{ + {ID: "query", Members: []string{"query", "keyword"}, Commands: []string{"demo run"}}, + {ID: "user_id", Members: []string{"user-id", "uid"}, Commands: []string{"demo run"}}, + }, + Overrides: []CommandOverride{ + {CommandPath: "alpha", Block: []string{"unsafe"}}, + {CommandPath: "demo run", Bind: map[string]string{"id": "user_id"}}, + }, + } + useParamConceptLoader(t, concepts, nil) + + root := &cobra.Command{Use: "dws"} + alpha := &cobra.Command{Use: "alpha", Run: func(*cobra.Command, []string) {}} + alpha.Flags().String("name", "", "name") + demo := &cobra.Command{Use: "demo", Run: func(*cobra.Command, []string) {}} + run := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}} + run.Flags().String("query", "", "query") + run.Flags().String("id", "", "id") + demo.AddCommand(run) + root.AddCommand(alpha, demo) + root.AddCommand(&cobra.Command{Use: "help", Run: func(*cobra.Command, []string) {}}) + root.AddCommand(&cobra.Command{Use: "hidden", Hidden: true, Run: func(*cobra.Command, []string) {}}) + + entries, err := ReduceParamAliases(root) + if err != nil { + t.Fatalf("ReduceParamAliases() error = %v", err) + } + if len(entries) != 2 || entries[0].CLIPath != "alpha" || entries[1].CLIPath != "demo run" { + t.Fatalf("entries = %#v", entries) + } + if entries[1].Aliases["keyword"] != "query" || entries[1].Aliases["uid"] != "id" { + t.Fatalf("demo aliases = %#v", entries[1].Aliases) + } + }) + + t.Run("stale and unbound review inputs", func(t *testing.T) { + concepts := ParamConcepts{ + Version: 1, + Concepts: []Concept{ + {ID: "missing", Members: []string{"missing"}, Commands: []string{"demo run"}}, + {ID: "stale", Members: []string{"stale"}, Commands: []string{"ghost run"}}, + }, + Overrides: []CommandOverride{{CommandPath: "ghost run", Block: []string{"unsafe"}}}, + } + useParamConceptLoader(t, concepts, nil) + root := &cobra.Command{Use: "dws"} + demo := &cobra.Command{Use: "demo"} + run := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}} + run.Flags().String("query", "", "query") + demo.AddCommand(run) + root.AddCommand(demo) + + _, err := ReduceParamAliases(root) + for _, want := range []string{"has no matching real flag", "does not match any runnable Cobra leaf", "does not match any runnable Cobra command"} { + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("ReduceParamAliases() error = %v, want %q", err, want) + } + } + }) +} + +func TestParamAliasHelperEdges(t *testing.T) { + walkRunnableParamCommands(nil, func(*cobra.Command) { t.Fatal("nil root was visited") }) + helpOnly := &cobra.Command{Use: "demo"} + helpOnly.Flags().String("help", "", "help") + if got := realFlagsByMorph(helpOnly); len(got) != 0 { + t.Fatalf("help flag entered the real parameter table: %#v", got) + } + + flags := []realFlag{{name: "same", hidden: true}} + flags = appendRealFlag(flags, realFlag{name: "same"}) + if len(flags) != 1 || flags[0].hidden { + t.Fatalf("visible duplicate did not replace hidden registration: %#v", flags) + } + flags = appendRealFlag(flags, realFlag{name: "same", hidden: true}) + if len(flags) != 1 || flags[0].hidden { + t.Fatalf("hidden duplicate replaced visible registration: %#v", flags) + } + flags = appendRealFlag(flags, realFlag{name: "alpha"}) + if !reflect.DeepEqual([]string{flags[0].name, flags[1].name}, []string{"alpha", "same"}) { + t.Fatalf("new real flags are not sorted: %#v", flags) + } + + candidates := []realFlag{{name: "hidden", hidden: true}, {name: "visible"}, {name: "visible"}} + if got := distinctRealNames(candidates, true); !reflect.DeepEqual(got, []string{"visible"}) { + t.Fatalf("visible names = %v", got) + } + if got := canonicalRealName([]realFlag{{name: "hidden", hidden: true}}); got != "hidden" { + t.Fatalf("hidden-only canonical = %q", got) + } + if got := canonicalRealName(nil); got != "" { + t.Fatalf("empty canonical = %q", got) + } + if got := sortedUnique(nil); got != nil { + t.Fatalf("sortedUnique(nil) = %#v", got) + } + if got := sortedUnique([]string{"b", "a", "b"}); !reflect.DeepEqual(got, []string{"a", "b"}) { + t.Fatalf("sortedUnique() = %v", got) + } + + real := realMap(realFlag{name: "query"}, realFlag{name: "id"}) + if !conceptHasRealFlag(Concept{ID: "query", Members: []string{"query"}}, CommandOverride{}, real) { + t.Fatal("concept member did not match a real flag") + } + if !conceptHasRealFlag(Concept{ID: "user", Members: []string{"user-id"}}, CommandOverride{Bind: map[string]string{"id": "user"}}, real) { + t.Fatal("reviewed bind did not match a real flag") + } + if conceptHasRealFlag(Concept{ID: "user", Members: []string{"user-id"}}, CommandOverride{Bind: map[string]string{"missing": "user"}}, real) { + t.Fatal("missing reviewed bind matched a real flag") + } +} + +func TestReduceLeafParamAliasesRemainingEdges(t *testing.T) { + t.Run("pending reviewed bind blocks non-real members", func(t *testing.T) { + entry, problems := reduceLeafParamAliases( + "demo cmd", + realMap(realFlag{name: "id"}), + []Concept{{ID: "user_id", Members: []string{"user-id", "uid"}}}, + CommandOverride{Bind: map[string]string{"id": "user_id"}, Investigate: true}, + ) + if len(problems) != 0 || entry == nil || + !containsParamAlias(entry.Blocked, "user-id") || !containsParamAlias(entry.Blocked, "uid") { + t.Fatalf("pending bind entry = %#v, problems = %v", entry, problems) + } + }) + + t.Run("hidden-only canonical", func(t *testing.T) { + entry, problems := reduceLeafParamAliases( + "demo cmd", + realMap(realFlag{name: "query", hidden: true}), + []Concept{{ID: "query", Members: []string{"query", "keyword"}}}, + CommandOverride{}, + ) + if len(problems) != 0 || entry == nil || entry.Aliases["keyword"] != "query" { + t.Fatalf("hidden-only entry = %#v, problems = %v", entry, problems) + } + }) + + t.Run("multiple hidden candidates stay unresolved", func(t *testing.T) { + entry, problems := reduceLeafParamAliases( + "demo cmd", + realMap(realFlag{name: "first", hidden: true}, realFlag{name: "second", hidden: true}), + []Concept{{ID: "choice", Members: []string{"first", "second", "choice"}}}, + CommandOverride{}, + ) + if len(problems) != 0 || entry != nil { + t.Fatalf("multiple hidden candidates entry = %#v, problems = %v", entry, problems) + } + }) + + t.Run("two concepts cannot claim one emitted spelling", func(t *testing.T) { + _, problems := reduceLeafParamAliases( + "demo cmd", + realMap(realFlag{name: "first"}, realFlag{name: "second"}), + []Concept{ + {ID: "first", Members: []string{"first", "shared"}}, + {ID: "second", Members: []string{"second", "shared"}}, + }, + CommandOverride{}, + ) + if len(problems) == 0 || !strings.Contains(strings.Join(problems, "\n"), "reduces to both") { + t.Fatalf("alias collision problems = %v", problems) + } + }) + + t.Run("unclaimed exclude becomes blocked", func(t *testing.T) { + entry, problems := reduceLeafParamAliases( + "demo cmd", + realMap(realFlag{name: "query"}), + []Concept{{ID: "query", Members: []string{"query", "keyword"}, Excludes: []string{"name"}}}, + CommandOverride{}, + ) + if len(problems) != 0 || entry == nil || !containsParamAlias(entry.Blocked, "name") { + t.Fatalf("exclude entry = %#v, problems = %v", entry, problems) + } + }) +} + +func TestParamAliasEntryLookupMethods(t *testing.T) { + entry := ParamAliasEntry{ + Aliases: map[string]string{"uid": "user"}, + Blocked: []string{"count"}, + Ambiguous: []string{"user-id"}, + } + if got, ok := entry.ResolveAlias("uid"); !ok || got != "user" { + t.Fatalf("ResolveAlias(uid) = %q, %v", got, ok) + } + if _, ok := entry.ResolveAlias("missing"); ok { + t.Fatal("ResolveAlias(missing) unexpectedly matched") + } + if !entry.IsBlocked("count") || entry.IsBlocked("missing") { + t.Fatalf("blocked lookup mismatch: %#v", entry.Blocked) + } + if !entry.IsAmbiguous("user-id") || entry.IsAmbiguous("missing") { + t.Fatalf("ambiguous lookup mismatch: %#v", entry.Ambiguous) + } +} + func TestReduceLeafParamAliasesAutoReduction(t *testing.T) { entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "limit"}), conceptFixture(), CommandOverride{}) if len(problems) != 0 { diff --git a/internal/cli/param_concepts.json b/internal/cli/param_concepts.json index e52730ca..3b17a0c6 100644 --- a/internal/cli/param_concepts.json +++ b/internal/cli/param_concepts.json @@ -10,7 +10,7 @@ }, "concepts": { - "search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +search-user", "doc +template-search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"}, + "search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +template-search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"}, "pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "mail thread list", "oa +list-executed"], "risk": "green"}, "page_number": {"denotes": "one-based page number", "canonical_hint": "page", "members": ["page", "page-no", "current-page", "page-num"], "excludes": ["cursor", "page-index", "page-size", "page-token"], "commands": ["devdoc article search"], "risk": "green"}, "page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list"], "risk": "green"}, @@ -18,48 +18,99 @@ "time_start": {"denotes": "start time point with unchanged value format and unit", "canonical_hint": "start", "members": ["start", "start-time", "start-date", "from", "from-date", "begin", "since", "time-min", "min-time"], "excludes": ["date", "time", "end"], "commands": ["calendar event list", "chat message list-all", "report list"], "risk": "yellow"}, "time_end": {"denotes": "end time point with unchanged value format and unit", "canonical_hint": "end", "members": ["end", "end-time", "end-date", "time-max", "max-time"], "excludes": ["date", "time", "start"], "commands": ["calendar event list"], "risk": "yellow"}, "base_id": {"denotes": "multi-dimensional table Base id", "canonical_hint": "base-id", "members": ["base", "base-id", "base-token"], "excludes": [], "commands": ["aitable +field-get", "aitable +list-tables", "aitable +record-query", "aitable +record-share-url", "aitable +table-get"], "risk": "green"}, - "dept_id": {"denotes": "single department id", "canonical_hint": "dept", "members": ["dept", "dept-id", "department", "department-id"], "excludes": ["depts", "dept-ids", "department-ids", "name", "query", "parent"], "commands": ["contact +list-sub-depts", "contact dept list-children"], "risk": "yellow"}, + "dept_id": {"denotes": "single department id", "canonical_hint": "dept", "members": ["dept", "dept-id", "department", "department-id", "parent", "parent-id"], "excludes": ["depts", "dept-ids", "department-ids", "name", "query"], "commands": ["contact +list-sub-depts", "contact dept list-children"], "risk": "yellow"}, "dept_ids": {"denotes": "department id list", "canonical_hint": "dept-ids", "members": ["depts", "dept-ids", "department-ids"], "excludes": ["dept", "dept-id", "department-id", "name", "query"], "commands": ["contact +list-dept-members"], "risk": "yellow"}, - "group_id": {"denotes": "single group/conversation id", "canonical_hint": "conversation-id", "members": ["group", "group-id", "conversation-id", "chat-id", "open-conversation-id"], "excludes": ["conversation-ids", "group-ids", "id", "name"], "commands": ["chat group members", "chat group members add", "chat group members remove", "chat group rename"], "risk": "yellow"}, - "user_id": {"denotes": "single user id", "canonical_hint": "user-id", "members": ["user", "user-id", "userid", "uid", "staff-id"], "excludes": ["at-user-ids", "to-user", "users", "user-ids", "name"], "commands": ["contact user profile get"], "risk": "yellow"}, - "user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance check result", "chat message send-by-bot"], "risk": "yellow"}, + "group_id": {"denotes": "single DingTalk numeric groupId", "canonical_hint": "group-id", "members": ["group-id"], "excludes": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "commands": ["chat group get-by-group-id"], "risk": "yellow"}, + "open_conversation_id": {"denotes": "single DingTalk openConversationId with unchanged value", "canonical_hint": "conversation-id", "members": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id"], "excludes": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids", "group-name", "name", "id", "source", "target", "src-conversation-id", "dest-conversation-id"], "commands": ["chat +chat-bots", "chat +chat-dismiss", "chat +chat-invite-url", "chat +chat-mute", "chat +chat-role-add", "chat +chat-role-list", "chat +chat-role-query-user", "chat +chat-role-set-user", "chat +chat-role-update", "chat +chat-set-admin", "chat +chat-set-history", "chat +chat-update-alias", "chat +chat-update-nick", "chat +conversation-info", "chat +messages-list-pin", "chat +messages-read-status", "chat category add-conv", "chat category remove-conv", "chat chmod", "chat clear-messages", "chat clear-red-point", "chat conversation-info", "chat group audit-join-validation", "chat group bots", "chat group dismiss", "chat group invite-url", "chat group members", "chat group members add", "chat group members add-bot", "chat group members list-by-ids", "chat group members remove", "chat group members remove-bot", "chat group notice create", "chat group notice edit", "chat group notice get", "chat group notice list", "chat group quit", "chat group rename", "chat group set-admin", "chat group set-history", "chat group transfer-owner", "chat group update-alias", "chat group update-icon", "chat group update-nick", "chat group update-settings", "chat group-mute", "chat group-mute-member", "chat group-role add", "chat group-role list", "chat group-role query-user", "chat group-role remove", "chat group-role remove-user", "chat group-role set-user", "chat group-role update", "chat hide", "chat mark-read", "chat mark-unread", "chat message add-favorite", "chat message download-media", "chat message list", "chat message list-mentions", "chat message list-pin-msg", "chat message list-topic-replies", "chat message read-status", "chat message recall", "chat message recall-by-bot", "chat message remove-favorite", "chat message reply", "chat message search", "chat message send", "chat message send-by-bot", "chat message send-card", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg", "chat mute-at-all", "chat mute-red-envelope", "chat set-top"], "risk": "yellow"}, + "open_conversation_ids": {"denotes": "DingTalk openConversationId list with unchanged element values", "canonical_hint": "conversation-ids", "members": ["conversation-ids", "open-conversation-ids", "groups"], "excludes": ["group-id", "group-ids", "conversation-id", "open-conversation-id", "chat-id"], "commands": ["chat message search-advanced"], "risk": "yellow"}, + "group_name": {"denotes": "group-name search keyword, not a group identifier", "canonical_hint": "group-name", "members": ["group-name"], "excludes": ["group-id", "conversation-id", "open-conversation-id", "chat-id", "id"], "commands": ["chat +group-members", "chat +send-to-group"], "risk": "yellow"}, + "open_message_id": {"denotes": "single DingTalk openMessageId with unchanged value", "canonical_hint": "open-message-id", "members": ["msg-id", "message-id", "open-message-id"], "excludes": ["msg-ids", "message-ids", "open-message-ids", "ref-msg-id", "src-msg-id", "open-task-id", "topic-id", "resource-id"], "commands": ["chat +messages-read-status", "chat mark-read", "chat message add-emoji", "chat message add-favorite", "chat message add-text-emotion", "chat message download-media", "chat message forward", "chat message read-status", "chat message recall", "chat message remove-emoji", "chat message remove-favorite", "chat message remove-text-emotion", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg"], "risk": "yellow"}, + "open_message_ids": {"denotes": "DingTalk openMessageId list with unchanged element values", "canonical_hint": "msg-ids", "members": ["msg-ids", "message-ids", "open-message-ids"], "excludes": ["msg-id", "message-id", "open-message-id", "ref-msg-id", "src-msg-id"], "commands": ["chat +messages-mget", "chat message combine-forward", "chat message list-by-ids", "chat message list-emotion-replies"], "risk": "yellow"}, + "referenced_open_message_id": {"denotes": "referenced DingTalk openMessageId in a reply", "canonical_hint": "ref-msg-id", "members": ["ref-msg-id", "ref-message-id"], "excludes": ["msg-id", "message-id", "open-message-id", "msg-ids", "src-msg-id"], "commands": ["chat message reply"], "risk": "yellow"}, + "user_id": {"denotes": "single user id", "canonical_hint": "user-id", "members": ["user", "user-id", "userid", "uid", "staff-id"], "excludes": ["at-user-ids", "to-user", "users", "user-ids", "name"], "commands": ["chat +chat-role-query-user", "chat +chat-role-set-user", "chat +messages-list-direct", "chat chmod", "chat conversation-info", "chat group transfer-owner", "chat group-role query-user", "chat group-role remove-user", "chat group-role set-user", "chat message list", "chat message send", "contact user profile get"], "risk": "yellow"}, + "user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"}, + "open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"}, "ding_id": {"denotes": "DING id", "canonical_hint": "ding-id", "members": ["ding-id", "open-ding-id"], "excludes": ["id"], "commands": ["ding message receiver-status"], "risk": "yellow"}, "folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive list", "mail folder update"], "risk": "green"}, "space_id": {"denotes": "drive/wiki space id", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["drive info"], "risk": "yellow"}, "app_id": {"denotes": "application id", "canonical_hint": "unified-app-id", "members": ["app-id", "unified-app-id", "application-id"], "excludes": ["app-key", "app-secret", "agent-id"], "commands": ["dev app get"], "risk": "yellow"}, - "robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id"], "commands": ["ding message send"], "risk": "yellow"} + "robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id"], "commands": ["chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"}, + "open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat group members remove-bot"], "risk": "yellow"} }, "command_overrides": { - "chat group rename": {"bind": {"id": "group_id"}, "note": "this command's --id is the group id; --group thus reduces to --id"}, - "chat group members": {"bind": {"id": "group_id"}}, - "chat group members add": {"bind": {"id": "group_id"}}, - "chat group members remove": {"bind": {"id": "group_id"}}, - "chat message add-emoji": {"scoped_aliases": {"group-id": "conversation-id", "chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["conversation-ids"], "note": "reviewed against RunE and final add_emoji_reaction payload: all singular aliases pass the same unchanged openConversationId; plural --conversation-ids is blocked from the singular field"}, - "chat message add-text-emotion": {"scoped_aliases": {"group-id": "conversation-id", "chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["conversation-ids"], "note": "reviewed against RunE and final add_text_emotion payload: all singular aliases pass the same unchanged openConversationId; plural --conversation-ids is blocked from the singular field"}, - "chat message remove-emoji": {"scoped_aliases": {"group-id": "conversation-id", "chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["conversation-ids"], "note": "reviewed against RunE and final remove_emoji_reaction payload: all singular aliases pass the same unchanged openConversationId; plural --conversation-ids is blocked from the singular field"}, - "chat message remove-text-emotion": {"scoped_aliases": {"group-id": "conversation-id", "chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["conversation-ids"], "note": "reviewed against RunE and final remove_text_emotion payload: all singular aliases pass the same unchanged openConversationId; plural --conversation-ids is blocked from the singular field"}, + "chat group rename": {"bind": {"id": "open_conversation_id"}, "note": "This command's real --id carries one openConversationId; aliases reduce to --id without changing the value."}, + "chat group members": {"bind": {"id": "open_conversation_id"}}, + "chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."}, + "chat group members remove": {"bind": {"id": "open_conversation_id"}}, + "chat message add-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."}, + "chat message add-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."}, + "chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."}, + "chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."}, + "chat mute": {"scoped_aliases": {"group": "conversation-id", "chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id/--id/--chat remain unchanged; other reviewed openConversationId spellings reduce to --conversation-id."}, "drive list": {"ambiguous": ["space"], "note": "both --space-id and native compatibility --workspace-id/--workspace exist; bare --space cannot choose one"}, "drive upload": {"ambiguous": ["space"], "note": "both --space-id and native compatibility --workspace-id/--workspace exist; bare --space cannot choose one"}, "ding +receiver-status": {"scoped_aliases": {"id": "ding-id"}, "note": "generic id reduces to ding-id"}, "ding message receiver-status": {"scoped_aliases": {"id": "ding-id"}}, "contact user profile get": {"scoped_aliases": {"id": "staff-id", "ids": "staff-id"}, "note": "user-id is reduced by the user_id concept; generic id/ids bound explicitly"}, "mail folder update": {"bind": {"id": "folder_id"}, "note": "this command's --id is the folder id; --folder-id reduces to --id"}, - "contact dept list-children": {"scoped_aliases": {"parent": "dept", "parent-id": "dept"}, "note": "reviewed against command payload: --dept is the parent department id"}, "mail message search": {"scoped_aliases": {"subject": "query"}, "scope_strict": true, "note": "never globalize: mail template create has a real and different --subject"}, "calendar event list": {"scoped_aliases": {"date": "start"}, "note": "reviewed against ParseISOTimeToMillis and final list_calendar_events payload; --date is normalized centrally while the command's existing hidden compatibility flags remain native fallbacks"}, - "chat message list-by-sender": {"block": ["time"], "note": "--time does not state the RFC3339 start-boundary semantics required by this command"}, - "contact +resolve-dept": {"scoped_aliases": {"query": "name"}, "note": "reviewed against shortcut source: --name is passed unchanged as search_dept_by_keyword.query"}, + "chat +bot-find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search shortcut, --name and --query denote the same search keyword; --name must not become a global search alias."}, + "chat bot find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search command, --name and --query denote the same search keyword; --name must not become a global search alias."}, + "chat +bot-search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."}, + "chat bot search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."}, + "chat message list-favorites": {"scoped_aliases": {"limit": "size"}, "scope_strict": true, "note": "On this exact command, both names denote the same bounded result count; the numeric value is unchanged."}, + "chat +messages-list-unread-conversations": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."}, + "chat +unread-chats": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."}, + "chat message list-unread-conversations": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."}, + "chat +messages-list-direct": {"scoped_aliases": {"start": "time"}, "block": ["end"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented."}, + "chat message list": {"scoped_aliases": {"start": "time"}, "block": ["end"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented."}, + "chat message list-by-sender": {"scoped_aliases": {"user-id": "sender-user-id", "open-dingtalk-id": "sender-open-dingtalk-id"}, "block": ["time"], "scope_strict": true, "note": "Only same-role sender identifiers are mapped; --time cannot supply the required RFC3339 start/end range."}, + "contact +resolve-dept": {"bind": {"name": "search_query"}, "note": "The real --name is a department-name search keyword and carries the search_query concept on this shortcut."}, "contact +list-sub-depts": {"block": ["name", "query"], "note": "--dept is an integer department id; names and search queries require a separate resolution command"}, - "contact +dept-members": {"scoped_aliases": {"name": "dept", "query": "dept"}, "note": "reviewed against shortcut source: --dept is explicitly a department name/search keyword"}, - "chat message send": {"scoped_aliases": {"to-user": "user"}, "note": "reviewed write mapping: both names denote one direct-message recipient user id; final payload is asserted by an end-to-end test"}, - "chat +group-members": {"scoped_aliases": {"group-name": "group"}, "note": "reviewed against shortcut source: --group is explicitly the group-name search keyword"}, + "contact +dept-members": {"bind": {"dept": "search_query"}, "scoped_aliases": {"name": "dept"}, "note": "The real --dept is a department-name search keyword; search spellings come from search_query, while --name remains command-scoped."}, + "chat message send": {"scoped_aliases": {"to-user": "user", "file": "file-path"}, "note": "Recipient and local-file-path aliases are exact to this command; obsolete file metadata flags remain unsupported."}, + "chat +group-members": {"bind": {"group": "group_name"}, "note": "The real --group is a group-name search keyword on this shortcut, not an identifier."}, + "chat +category-create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact shortcut."}, + "chat category create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact command."}, + "chat +category-rename": {"scoped_aliases": {"name": "title"}, "block": ["category-ids"], "scope_strict": true, "note": "The display-name alias is exact; a category-id list is not accepted where one category is required."}, + "chat category rename": {"scoped_aliases": {"name": "title"}, "block": ["category-ids"], "scope_strict": true, "note": "The display-name alias is exact; a category-id list is not accepted where one category is required."}, + "chat +category-delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."}, + "chat category delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."}, + "chat category list-conversations": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."}, + "chat category add-conv": {"block": ["category-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input."}, + "chat category remove-conv": {"block": ["category-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input."}, + "chat +chat-role-update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."}, + "chat group-role remove": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."}, + "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 media upload": {"scoped_aliases": {"file-path": "file"}, "scope_strict": true, "note": "Both names denote one local file path on this exact upload command; the value is unchanged."}, + "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": "requires a two-parameter conversion to --ref-block plus --where before; name-only normalization would silently default to after"}, - "chat message send-by-bot": {"block": ["user-id", "to-user-id"], "note": "--users is a recipient list, so singular --user-id/--to-user-id are not silently promoted to list cardinality; group mentions must use the explicit --at-user-ids"}, - "attendance +check-result": {"block": ["user-id"], "note": "--users is a list; singular --user-id is not silently promoted to list cardinality"}, + "chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."}, "doc +export-get": {"block": ["node"], "note": "node is a document node id, a different entity from job-id"}, "doc block delete": {"block": ["index"], "note": "index (position) vs node (node id) are different"}, - "report outbox list": {"block": ["template-type"], "note": "type vs name are different fields"} + "report outbox list": {"block": ["template-type"], "note": "type vs name are different fields"}, + "chat group members add-bot": {"bind": {"id": "open_conversation_id"}}, + "chat group members list-by-ids": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["user-id", "user-ids"], "note": "This command's --id carries openConversationId, while --users carries an openDingTalkId list."}, + "chat group members remove-bot": {"bind": {"id": "open_conversation_id"}}, + "chat +send-to-group": {"bind": {"group": "group_name"}, "note": "The real --group is a group-name search keyword on this shortcut, not an identifier."}, + "chat group share-invite": {"scoped_aliases": {"source-conversation-id": "source", "target-conversation-id": "target"}, "block": ["group-id", "group-ids", "user", "user-id", "userid", "uid", "staff-id"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "note": "A role-free conversation identifier cannot choose between source and target; --receiver requires openDingTalkId and must not accept userId spellings."}, + "chat message combine-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "source-conversation-id": "src-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "note": "Source and destination conversation roles are preserved; a role-free identifier is ambiguous."}, + "chat message forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "source-conversation-id": "src-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "note": "Source and destination conversation roles are preserved; a role-free identifier is ambiguous."}, + "chat message forward-topic": {"scoped_aliases": {"src-open-conversation-id": "src-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "source-conversation-id": "src-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id", "src-open-message-id": "src-msg-id", "source-message-id": "src-msg-id"}, "block": ["group-id", "group-ids", "msg-id", "message-id", "open-message-id"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "note": "Conversation and message source/destination roles are preserved; role-free identifiers are rejected."}, + "chat +conversation-info": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "This shortcut accepts --open-dingtalk-id, not userId; use stable chat conversation-info when userId resolution is needed."}, + "chat group create": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed identifier domains."}, + "chat +chat-set-admin": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a mixed userId/openDingTalkId list."}, + "chat +messages-read-status": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a mixed userId/openDingTalkId list."}, + "chat category create-smart": {"bind": {"members": "open_dingtalk_ids"}, "scoped_aliases": {"title": "name"}, "note": "The real --members is an openDingTalkId list; the reviewed title/name alias is exact to the category display name."}, + "chat group audit-join-validation": {"ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "note": "A role-free user identifier cannot choose between the required --applicant and --inviter roles."}, + "chat message reply": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The required --ref-sender is a role-specific openDingTalkId and must not accept generic userId spellings."}, + "chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."} }, "validation_fixture": { @@ -71,7 +122,7 @@ {"command": "aitable +record-query", "emitted": "base", "expect": "base-id", "via": "concept:base_id+morph", "occ": 8}, {"command": "aitable +table-get", "emitted": "base", "expect": "base-id", "via": "concept:base_id+morph", "occ": 8}, {"command": "doc block update", "emitted": "content", "expect": "text", "via": "concept:content_text", "occ": 6}, - {"command": "contact +resolve-dept", "emitted": "query", "expect": "name", "via": "override:scoped(reviewed)", "occ": 4}, + {"command": "contact +resolve-dept", "emitted": "query", "expect": "name", "via": "concept:search_query+bind", "occ": 4}, {"command": "devdoc article search", "emitted": "limit", "expect": "size", "via": "concept:pagination_size", "occ": 2}, {"command": "devdoc article search", "emitted": "page-size", "expect": "size", "via": "concept:pagination_size", "occ": 2}, {"command": "devdoc article search", "emitted": "current-page", "expect": "page", "via": "concept:page_number", "occ": 2}, @@ -99,9 +150,9 @@ {"command": "report list", "emitted": "from-date", "expect": "start", "via": "concept:time_start", "occ": 2}, {"command": "aitable +base-search", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1}, {"command": "contact +search-user", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1}, - {"command": "chat group rename", "emitted": "group", "expect": "id", "via": "override:bind(group_id)", "occ": 31}, + {"command": "chat group rename", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 31}, {"command": "ding +receiver-status", "emitted": "id", "expect": "ding-id", "via": "override:scoped(ding_id)", "occ": 24}, - {"command": "chat group members", "emitted": "group", "expect": "id", "via": "override:bind(group_id)", "occ": 8}, + {"command": "chat group members", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 8}, {"command": "contact +list-sub-depts", "emitted": "dept-id", "expect": "dept", "via": "concept:dept_id+morph", "occ": 4}, {"command": "contact +list-sub-depts", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:name-vs-id", "occ": 2}, {"command": "contact +list-sub-depts", "emitted": "query", "expect": "did-you-mean:blocked", "via": "guard:query-vs-id", "occ": 2}, @@ -116,15 +167,15 @@ {"command": "chat message search-advanced", "emitted": "group", "expect": "conversation-ids", "via": "native:reviewed-single-to-list", "occ": 4}, {"command": "chat message send", "emitted": "to-user", "expect": "user", "via": "override:scoped(reviewed+payload)", "occ": 4}, {"command": "contact +dept-members", "emitted": "name", "expect": "dept", "via": "override:scoped(reviewed)", "occ": 2}, - {"command": "contact +dept-members", "emitted": "query", "expect": "dept", "via": "override:scoped(reviewed)", "occ": 2}, - {"command": "contact dept list-children", "emitted": "parent-id", "expect": "dept", "via": "override:scoped(reviewed)", "occ": 2}, - {"command": "contact dept list-children", "emitted": "parent", "expect": "dept", "via": "override:scoped(reviewed)", "occ": 2}, + {"command": "contact +dept-members", "emitted": "query", "expect": "dept", "via": "concept:search_query+bind", "occ": 2}, + {"command": "contact dept list-children", "emitted": "parent-id", "expect": "dept", "via": "concept:dept_id", "occ": 2}, + {"command": "contact dept list-children", "emitted": "parent", "expect": "dept", "via": "concept:dept_id", "occ": 2}, {"command": "ding message receiver-status", "emitted": "id", "expect": "ding-id", "via": "override:scoped(ding_id)", "occ": 2}, {"command": "ding message receiver-status", "emitted": "open-ding-id", "expect": "ding-id", "via": "concept:ding_id", "occ": 2}, - {"command": "chat group members add", "emitted": "group", "expect": "id", "via": "override:bind(group_id)", "occ": 3}, - {"command": "attendance +check-result", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list", "occ": 2}, + {"command": "chat group members add", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 3}, + {"command": "attendance +check-result", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "concept:user_ids+exclude", "occ": 2}, {"command": "attendance check result", "emitted": "user-ids", "expect": "users", "via": "concept:user_ids", "occ": 2}, - {"command": "chat group members remove", "emitted": "group", "expect": "id", "via": "override:bind(group_id)", "occ": 2}, + {"command": "chat group members remove", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 2}, {"command": "chat group set-admin", "emitted": "user-id", "expect": "user", "via": "native:reviewed-compatibility-alias", "occ": 2}, {"command": "ding message send", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code", "occ": 2}, {"command": "doc +export-get", "emitted": "node", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2}, @@ -133,7 +184,80 @@ {"command": "drive info", "emitted": "workspace", "expect": "space-id", "via": "concept:space_id", "occ": 2}, {"command": "mail folder update", "emitted": "folder-id", "expect": "id", "via": "override:bind(folder_id)", "occ": 2}, {"command": "report outbox list", "emitted": "template-type", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2}, - {"command": "chat +group-members", "emitted": "group-name", "expect": "group", "via": "override:scoped(reviewed)", "occ": 1} + {"command": "chat +group-members", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"}, + {"command": "chat group get-by-group-id", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:open-conversation-id-vs-group-id"}, + {"command": "chat group get-by-group-id", "emitted": "id", "expect": "did-you-mean:blocked", "via": "guard:generic-id-vs-group-id"}, + {"command": "chat group rename", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"}, + {"command": "chat group rename", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"}, + {"command": "chat message send", "emitted": "conversation-id", "expect": "group", "via": "concept:open_conversation_id"}, + {"command": "chat message add-emoji", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "override:scoped"}, + {"command": "chat message add-emoji", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"}, + {"command": "chat +group-members", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"}, + {"command": "chat +send-to-group", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"}, + {"command": "chat message search-advanced", "emitted": "open-conversation-ids", "expect": "conversation-ids", "via": "concept:open_conversation_ids"}, + {"command": "chat message search-advanced", "emitted": "group-ids", "expect": "did-you-mean:blocked", "via": "guard:group-id-list-vs-open-conversation-id-list"}, + {"command": "chat message recall", "emitted": "message-id", "expect": "msg-id", "via": "concept:open_message_id"}, + {"command": "chat message add-favorite", "emitted": "msg-id", "expect": "open-message-id", "via": "concept:open_message_id"}, + {"command": "chat message list-by-ids", "emitted": "message-ids", "expect": "msg-ids", "via": "concept:open_message_ids"}, + {"command": "chat message list-by-ids", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"}, + {"command": "chat message reply", "emitted": "ref-message-id", "expect": "ref-msg-id", "via": "concept:referenced_open_message_id"}, + {"command": "chat message reply", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:message-role"}, + {"command": "chat message forward-topic", "emitted": "src-open-message-id", "expect": "src-msg-id", "via": "override:scoped-role"}, + {"command": "chat message forward-topic", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:message-role"}, + {"command": "chat message combine-forward", "emitted": "src-open-cid", "expect": "src-conversation-id", "via": "override:scoped-role"}, + {"command": "chat message forward-topic", "emitted": "dest-open-conversation-id", "expect": "dest-conversation-id", "via": "override:scoped-role"}, + {"command": "chat message forward", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-destination-role"}, + {"command": "chat group share-invite", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-target-role"}, + {"command": "chat message send", "emitted": "user-id", "expect": "user", "via": "concept:user_id"}, + {"command": "chat message list", "emitted": "user-id", "expect": "user", "via": "concept:user_id"}, + {"command": "chat +messages-list-direct", "emitted": "user-id", "expect": "user", "via": "concept:user_id"}, + {"command": "attendance +check-result", "emitted": "user-ids", "expect": "users", "via": "concept:user_ids"}, + {"command": "contact +list-sub-depts", "emitted": "parent-id", "expect": "dept", "via": "concept:dept_id"}, + {"command": "chat +conversation-info", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:user-id-vs-open-dingtalk-id"}, + {"command": "chat group members remove", "emitted": "user-ids", "expect": "users", "via": "concept:user_ids"}, + {"command": "chat group members remove", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"}, + {"command": "chat group create", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"}, + {"command": "chat +chat-set-admin", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"}, + {"command": "chat +messages-read-status", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"}, + {"command": "chat group members list-by-ids", "emitted": "open-dingtalk-ids", "expect": "users", "via": "concept:open_dingtalk_ids+bind"}, + {"command": "chat group members remove-bot", "emitted": "open-bot-id", "expect": "bot-id", "via": "concept:open_bot_id"}, + {"command": "chat group members remove-bot", "emitted": "robot-code", "expect": "did-you-mean:blocked", "via": "guard:robot-code-vs-open-bot-id"}, + {"command": "chat group members add-bot", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code"}, + {"command": "chat +bot-find", "emitted": "name", "expect": "query", "via": "override:scoped"}, + {"command": "chat bot find", "emitted": "name", "expect": "query", "via": "override:scoped"}, + {"command": "chat +bot-search", "emitted": "query", "expect": "name", "via": "override:scoped"}, + {"command": "chat bot search", "emitted": "query", "expect": "name", "via": "override:scoped"}, + {"command": "chat message list-favorites", "emitted": "limit", "expect": "size", "via": "override:scoped"}, + {"command": "chat bot search", "emitted": "current-page", "expect": "page", "via": "override:scoped"}, + {"command": "chat bot search", "emitted": "cursor", "expect": "did-you-mean:blocked", "via": "guard:page-number-vs-cursor"}, + {"command": "chat message list-unread-conversations", "emitted": "limit", "expect": "count", "via": "override:scoped"}, + {"command": "chat +messages-list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"}, + {"command": "chat message list", "emitted": "start", "expect": "time", "via": "override:scoped"}, + {"command": "chat message list", "emitted": "end", "expect": "did-you-mean:blocked", "via": "guard:single-time-vs-range"}, + {"command": "chat message list-all", "emitted": "time", "expect": "did-you-mean:blocked", "via": "guard:time-range-required"}, + {"command": "chat message list-by-sender", "emitted": "user-id", "expect": "sender-user-id", "via": "override:scoped-role"}, + {"command": "chat message list-by-sender", "emitted": "open-dingtalk-id", "expect": "sender-open-dingtalk-id", "via": "override:scoped-role"}, + {"command": "chat category create-smart", "emitted": "title", "expect": "name", "via": "override:scoped"}, + {"command": "chat category create", "emitted": "name", "expect": "title", "via": "override:scoped"}, + {"command": "chat media upload", "emitted": "file-path", "expect": "file", "via": "override:scoped"}, + {"command": "chat message send", "emitted": "file", "expect": "file-path", "via": "override:scoped"}, + {"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"}, + {"command": "chat message send-by-bot", "emitted": "at-ids", "expect": "did-you-mean:ambiguous", "via": "guard:user-id-vs-open-dingtalk-id"}, + {"command": "chat +bot-search", "emitted": "current-page", "expect": "page", "via": "override:scoped"}, + {"command": "chat +category-create", "emitted": "name", "expect": "title", "via": "override:scoped"}, + {"command": "chat +category-rename", "emitted": "name", "expect": "title", "via": "override:scoped"}, + {"command": "chat +messages-list-direct", "emitted": "start", "expect": "time", "via": "override:scoped"}, + {"command": "chat +messages-list-unread-conversations", "emitted": "limit", "expect": "count", "via": "override:scoped"}, + {"command": "chat +messages-send-by-webhook", "emitted": "at-user-ids", "expect": "at-users", "via": "override:scoped-role"}, + {"command": "chat +unread-chats", "emitted": "limit", "expect": "count", "via": "override:scoped"}, + {"command": "chat +unread-chats", "emitted": "size", "expect": "count", "via": "override:scoped"}, + {"command": "chat category rename", "emitted": "name", "expect": "title", "via": "override:scoped"}, + {"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"} ] } } diff --git a/internal/cli/param_concepts_contract_test.go b/internal/cli/param_concepts_contract_test.go index 4caac7dc..c53c7b29 100644 --- a/internal/cli/param_concepts_contract_test.go +++ b/internal/cli/param_concepts_contract_test.go @@ -179,3 +179,134 @@ func TestDecodeParamConceptsEnforcesReviewedConstraints(t *testing.T) { t.Fatalf("decodeParamConcepts() concepts = %#v", got.Concepts) } } + +func validParamConceptSpecFixture() paramConceptSpec { + return paramConceptSpec{ + Denotes: "a reviewed value", + CanonicalHint: "query", + Members: []string{"query"}, + Excludes: []string{"name"}, + Commands: []string{"demo run"}, + Risk: "green", + } +} + +func TestDecodeParamConceptsRemainingSyntaxEdges(t *testing.T) { + valid := `{"$schema":"./param_concepts.schema.json","version":1,` + + `"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":["query"],"commands":["demo run"],"risk":"green"}}}` + if _, err := decodeParamConcepts([]byte(valid + ` {}`)); err == nil || !strings.Contains(err.Error(), "multiple JSON values") { + t.Fatalf("multiple JSON values error = %v", err) + } + + withEmptyMorphDescription := strings.Replace( + valid, + `"concepts":`, + `"morphological_rules":{"camel":{"desc":""}},"concepts":`, + 1, + ) + if _, err := decodeParamConcepts([]byte(withEmptyMorphDescription)); err == nil || !strings.Contains(err.Error(), "empty desc") { + t.Fatalf("empty morph description error = %v", err) + } +} + +func TestDecodeParamConceptSpecsRemainingValidationEdges(t *testing.T) { + base := validParamConceptSpecFixture() + invalidCanonical := base + invalidCanonical.CanonicalHint = "bad token" + invalidMember := base + invalidMember.Members = []string{"bad token"} + repeatedMember := base + repeatedMember.Members = []string{"query", "query"} + invalidExclude := base + invalidExclude.Excludes = []string{"bad token"} + repeatedExclude := base + repeatedExclude.Excludes = []string{"name", "name"} + invalidCommand := base + invalidCommand.Commands = []string{"demo /bad"} + repeatedCommand := base + repeatedCommand.Commands = []string{"demo run", "demo run"} + + tests := map[string]map[string]paramConceptSpec{ + "invalid canonical hint": {"c_one": invalidCanonical}, + "invalid member": {"c_one": invalidMember}, + "repeated member": {"c_one": repeatedMember}, + "invalid exclude": {"c_one": invalidExclude}, + "repeated exclude": {"c_one": repeatedExclude}, + "invalid command": {"c_one": invalidCommand}, + "repeated command": {"c_one": repeatedCommand}, + "cross-concept member": { + "c_one": base, + "c_two": base, + }, + } + for name, specs := range tests { + t.Run(name, func(t *testing.T) { + if _, _, err := decodeParamConceptSpecs(specs); err == nil { + t.Fatal("decodeParamConceptSpecs() unexpectedly accepted invalid input") + } + }) + } +} + +func TestDecodeParamCommandOverridesRemainingValidationEdges(t *testing.T) { + byConcept := map[string]Concept{"c_one": {ID: "c_one"}} + tests := map[string]map[string]paramCommandOverride{ + "invalid path": { + " demo run": {Block: []string{"name"}}, + }, + "invalid bind flag": { + "demo run": {Bind: map[string]string{"bad token": "c_one"}}, + }, + "invalid scoped emitted": { + "demo run": {ScopedAliases: map[string]string{"bad token": "query"}}, + }, + "invalid scoped target": { + "demo run": {ScopedAliases: map[string]string{"keyword": "bad token"}}, + }, + "invalid block token": { + "demo run": {Block: []string{"bad token"}}, + }, + "repeated ambiguous token": { + "demo run": {Ambiguous: []string{"uid", "uid"}}, + }, + } + for name, specs := range tests { + t.Run(name, func(t *testing.T) { + if _, err := decodeParamCommandOverrides(specs, byConcept); err == nil { + t.Fatal("decodeParamCommandOverrides() unexpectedly accepted invalid input") + } + }) + } +} + +func TestDecodeParamFixtureCasesRemainingValidationEdges(t *testing.T) { + tests := map[string]paramFixtureCaseSpec{ + "invalid command": {Command: " demo run", Emitted: "query", Expect: "query"}, + "invalid emitted": {Command: "demo run", Emitted: "bad token", Expect: "query"}, + "empty expect": {Command: "demo run", Emitted: "query", Expect: " "}, + "invalid expect": {Command: "demo run", Emitted: "query", Expect: "bad token"}, + "negative occ": {Command: "demo run", Emitted: "query", Expect: "query", Occ: -1}, + } + for name, fixture := range tests { + t.Run(name, func(t *testing.T) { + if _, err := decodeParamFixtureCases(¶mValidationFixtureSpec{Cases: []paramFixtureCaseSpec{fixture}}); err == nil { + t.Fatal("decodeParamFixtureCases() unexpectedly accepted invalid input") + } + }) + } +} + +func TestParamConceptPathAndTokenValidationEdges(t *testing.T) { + if validParamCommandPath(" demo run") { + t.Fatal("command path with surrounding whitespace was accepted") + } + if validParamCommandPath("demo /bad") { + t.Fatal("command path with an invalid token was accepted") + } + if err := validParamTokenList("demo run", "block", []string{"bad token"}); err == nil { + t.Fatal("invalid parameter token was accepted") + } + if err := validParamTokenList("demo run", "block", []string{"uid", "uid"}); err == nil { + t.Fatal("repeated parameter token was accepted") + } +} diff --git a/internal/generator/cmd_param_aliases/main.go b/internal/generator/cmd_param_aliases/main.go index a9c07c27..76e049b8 100644 --- a/internal/generator/cmd_param_aliases/main.go +++ b/internal/generator/cmd_param_aliases/main.go @@ -39,6 +39,14 @@ import ( const defaultOutput = "internal/cli/param_aliases_generated.go" +var ( + newParamAliasRoot = app.NewRootCommand + reduceParamAliasEntries = cli.ReduceParamAliases + formatParamAliasSource = format.Source + writeParamAliasFile = os.WriteFile + exitParamAliasProcess = os.Exit +) + func main() { var rootPath string var outputPath string @@ -46,25 +54,31 @@ func main() { flag.StringVar(&outputPath, "output", defaultOutput, "Output generated parameter-alias table") flag.Parse() - if err := validateOutputIsolation(rootPath, outputPath); err != nil { + if err := generateParamAliases(rootPath, outputPath); err != nil { fail(err) } +} - root := app.NewRootCommand() - entries, err := cli.ReduceParamAliases(root) +func generateParamAliases(rootPath, outputPath string) error { + if err := validateOutputIsolation(rootPath, outputPath); err != nil { + return err + } + + entries, err := reduceParamAliasEntries(newParamAliasRoot()) if err != nil { - fail(err) + return err } source, err := renderParamAliases(entries) if err != nil { - fail(err) + return err } - if err := os.WriteFile(outputPath, source, 0o644); err != nil { - fail(fmt.Errorf("write generated parameter aliases: %w", err)) + if err := writeParamAliasFile(outputPath, source, 0o644); err != nil { + return fmt.Errorf("write generated parameter aliases: %w", err) } _, _ = fmt.Fprintf(os.Stderr, "generated parameter aliases: output=%s commands=%d\n", outputPath, len(entries)) + return nil } func validateOutputIsolation(rootPath, outputPath string) error { @@ -113,8 +127,11 @@ func renderParamAliases(entries []cli.ParamAliasEntry) ([]byte, error) { b.WriteString("\t},\n") } b.WriteString("}\n") + b.WriteString("\nfunc loadGeneratedParamAliases() []ParamAliasEntry {\n") + b.WriteString("\treturn generatedParamAliases\n") + b.WriteString("}\n") - formatted, err := format.Source(b.Bytes()) + formatted, err := formatParamAliasSource(b.Bytes()) if err != nil { return nil, fmt.Errorf("format generated parameter aliases: %w", err) } @@ -140,5 +157,5 @@ func sortedKeys(m map[string]string) []string { func fail(err error) { _, _ = fmt.Fprintln(os.Stderr, "error:", err) - os.Exit(1) + exitParamAliasProcess(1) } diff --git a/internal/generator/cmd_param_aliases/main_test.go b/internal/generator/cmd_param_aliases/main_test.go new file mode 100644 index 00000000..eb6bbd55 --- /dev/null +++ b/internal/generator/cmd_param_aliases/main_test.go @@ -0,0 +1,175 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package main + +import ( + "context" + "errors" + "flag" + "go/format" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/spf13/cobra" +) + +func preserveParamAliasGeneratorGlobals(t *testing.T) { + t.Helper() + oldArgs, oldFlags := os.Args, flag.CommandLine + oldRoot := newParamAliasRoot + oldReduce := reduceParamAliasEntries + oldFormat := formatParamAliasSource + oldWrite := writeParamAliasFile + oldExit := exitParamAliasProcess + t.Cleanup(func() { + os.Args, flag.CommandLine = oldArgs, oldFlags + newParamAliasRoot = oldRoot + reduceParamAliasEntries = oldReduce + formatParamAliasSource = oldFormat + writeParamAliasFile = oldWrite + exitParamAliasProcess = oldExit + }) +} + +func repositoryRoot(t *testing.T) string { + t.Helper() + root, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + return root +} + +func TestCrossPlatformCoverageParamAliasMainWritesGeneratedFile(t *testing.T) { + preserveParamAliasGeneratorGlobals(t) + output := filepath.Join(t.TempDir(), "param_aliases_generated.go") + flag.CommandLine = flag.NewFlagSet("param-alias-success", flag.ContinueOnError) + os.Args = []string{"cmd_param_aliases", "-root", repositoryRoot(t), "-output", output} + newParamAliasRoot = func(...context.Context) *cobra.Command { return &cobra.Command{Use: "dws"} } + reduceParamAliasEntries = func(*cobra.Command) ([]cli.ParamAliasEntry, error) { + return []cli.ParamAliasEntry{{CLIPath: "demo get", Aliases: map[string]string{"uid": "user"}}}, nil + } + + main() + data, err := os.ReadFile(output) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "func loadGeneratedParamAliases() []ParamAliasEntry") || + !strings.Contains(string(data), `"uid": "user"`) { + t.Fatalf("generated source missing runtime table:\n%s", data) + } +} + +func TestCrossPlatformCoverageParamAliasMainReportsFailure(t *testing.T) { + preserveParamAliasGeneratorGlobals(t) + flag.CommandLine = flag.NewFlagSet("param-alias-failure", flag.ContinueOnError) + os.Args = []string{ + "cmd_param_aliases", + "-root", repositoryRoot(t), + "-output", filepath.Join(repositoryRoot(t), "internal", "cli", "param_concepts.json"), + } + exitParamAliasProcess = func(code int) { panic(code) } + defer func() { + if recovered := recover(); recovered != 1 { + t.Fatalf("main exit = %#v, want 1", recovered) + } + }() + main() +} + +func TestGenerateParamAliasesFailurePaths(t *testing.T) { + preserveParamAliasGeneratorGlobals(t) + root := repositoryRoot(t) + output := filepath.Join(t.TempDir(), "param_aliases_generated.go") + newParamAliasRoot = func(...context.Context) *cobra.Command { return &cobra.Command{Use: "dws"} } + + if err := generateParamAliases(root, filepath.Join(root, "internal", "cli", "param_concepts.json")); err == nil { + t.Fatal("generateParamAliases accepted an output that overlaps a reviewed input") + } + + reduceParamAliasEntries = func(*cobra.Command) ([]cli.ParamAliasEntry, error) { + return nil, errors.New("reduce") + } + if err := generateParamAliases(root, output); err == nil || !strings.Contains(err.Error(), "reduce") { + t.Fatalf("reduction error = %v", err) + } + + reduceParamAliasEntries = func(*cobra.Command) ([]cli.ParamAliasEntry, error) { return nil, nil } + formatParamAliasSource = func([]byte) ([]byte, error) { return nil, errors.New("format") } + if err := generateParamAliases(root, output); err == nil || !strings.Contains(err.Error(), "format generated") { + t.Fatalf("format error = %v", err) + } + + formatParamAliasSource = format.Source + writeParamAliasFile = func(string, []byte, os.FileMode) error { return errors.New("write") } + if err := generateParamAliases(root, output); err == nil || !strings.Contains(err.Error(), "write generated") { + t.Fatalf("write error = %v", err) + } +} + +func TestValidateParamAliasOutputIsolation(t *testing.T) { + root := repositoryRoot(t) + if err := validateOutputIsolation(root, filepath.Join(t.TempDir(), "aliases.go")); err != nil { + t.Fatalf("temporary output rejected: %v", err) + } + if err := validateOutputIsolation(root, filepath.Join(root, "internal", "cli", "not_a_delivery_target.go")); err == nil || + !strings.Contains(err.Error(), "not a canonical generated delivery target") { + t.Fatalf("non-canonical repository output error = %v", err) + } +} + +func TestRenderParamAliasesDeterministicShape(t *testing.T) { + preserveParamAliasGeneratorGlobals(t) + entries := []cli.ParamAliasEntry{ + { + CLIPath: "demo get", + Aliases: map[string]string{"z-name": "name", "a-name": "name"}, + Blocked: []string{"page", "count"}, + Ambiguous: []string{"user-id"}, + }, + {CLIPath: "demo empty"}, + } + data, err := renderParamAliases(entries) + if err != nil { + t.Fatal(err) + } + text := string(data) + if strings.Index(text, `"a-name": "name"`) > strings.Index(text, `"z-name": "name"`) { + t.Fatalf("alias keys are not sorted:\n%s", text) + } + for _, want := range []string{ + "func loadGeneratedParamAliases() []ParamAliasEntry", + `Blocked: []string{"page", "count"}`, + `Ambiguous: []string{"user-id"}`, + `CLIPath: "demo empty"`, + } { + if !strings.Contains(text, want) { + t.Fatalf("generated source missing %q:\n%s", want, text) + } + } + + empty, err := renderParamAliases(nil) + if err != nil || !strings.Contains(string(empty), "var generatedParamAliases = []ParamAliasEntry{") { + t.Fatalf("empty render = %q, error = %v", empty, err) + } + if got := renderStringSlice(nil); got != "[]string{}" { + t.Fatalf("renderStringSlice(nil) = %q", got) + } + if got := sortedKeys(nil); len(got) != 0 { + t.Fatalf("sortedKeys(nil) = %v", got) + } + if got := sortedKeys(map[string]string{"b": "2", "a": "1"}); !reflect.DeepEqual(got, []string{"a", "b"}) { + t.Fatalf("sortedKeys() = %v", got) + } + + formatParamAliasSource = func([]byte) ([]byte, error) { return nil, errors.New("broken formatter") } + if _, err := renderParamAliases(entries); err == nil || !strings.Contains(err.Error(), "broken formatter") { + t.Fatalf("formatter error = %v", err) + } +} diff --git a/internal/pipeline/cobra.go b/internal/pipeline/cobra.go index d8703587..63dfd18f 100644 --- a/internal/pipeline/cobra.go +++ b/internal/pipeline/cobra.go @@ -139,9 +139,6 @@ func persistentFlagToken(flags *pflag.FlagSet, argument string) (*pflag.Flag, bo } body := strings.TrimPrefix(argument, "-") - if body == "" { - return nil, false, false - } shorthands := []rune(body) for index, shorthand := range shorthands { flag := flags.ShorthandLookup(string(shorthand)) diff --git a/internal/pipeline/cobra_test.go b/internal/pipeline/cobra_test.go index def1ed19..f31146b7 100644 --- a/internal/pipeline/cobra_test.go +++ b/internal/pipeline/cobra_test.go @@ -182,3 +182,30 @@ func TestRunPreParseResolvesCommandPastLeadingPersistentFlags(t *testing.T) { }) } } + +func TestCommandTraversalFlagTokenEdges(t *testing.T) { + raw := []string{"child"} + if got := argsForCommandTraversal(nil, raw); !reflect.DeepEqual(got, raw) { + t.Fatalf("nil-root traversal args = %v", got) + } + root := &cobra.Command{Use: "root"} + if got := argsForCommandTraversal(root, nil); got != nil { + t.Fatalf("empty traversal args = %v", got) + } + root.PersistentFlags().BoolP("verbose", "v", false, "") + root.PersistentFlags().StringP("format", "f", "", "") + if got := argsForCommandTraversal(root, []string{"--", "--verbose", "child"}); !reflect.DeepEqual(got, []string{"--", "--verbose", "child"}) { + t.Fatalf("double-dash traversal args = %v", got) + } + + if flag, inline, matched := persistentFlagToken(nil, "--verbose"); flag != nil || inline || matched { + t.Fatalf("nil flag set matched: %#v, %v, %v", flag, inline, matched) + } + if flag, inline, matched := persistentFlagToken(root.PersistentFlags(), "-x"); flag != nil || inline || matched { + t.Fatalf("unknown shorthand matched: %#v, %v, %v", flag, inline, matched) + } + flag, inline, matched := persistentFlagToken(root.PersistentFlags(), "-vv") + if !matched || !inline || flag == nil || flag.Name != "verbose" { + t.Fatalf("boolean shorthand cluster = %#v, %v, %v", flag, inline, matched) + } +} diff --git a/internal/pipeline/engine_test.go b/internal/pipeline/engine_test.go index ce798769..fc55597d 100644 --- a/internal/pipeline/engine_test.go +++ b/internal/pipeline/engine_test.go @@ -240,6 +240,33 @@ func TestContextAddCorrection(t *testing.T) { } } +func TestContextFlagProtectionAndConflictError(t *testing.T) { + var nilContext *Context + nilContext.ProtectFlag("uid", FlagProtectionBlocked) + if nilContext.IsFlagProtected("uid") { + t.Fatal("nil context reported a protected flag") + } + + ctx := &Context{} + ctx.ProtectFlag("", FlagProtectionBlocked) + if ctx.ProtectedFlags != nil { + t.Fatalf("empty flag initialized protection map: %#v", ctx.ProtectedFlags) + } + ctx.ProtectFlag("uid", FlagProtectionAmbiguous) + if !ctx.IsFlagProtected("uid") || ctx.IsFlagProtected("missing") { + t.Fatalf("protection lookup mismatch: %#v", ctx.ProtectedFlags) + } + + err := (&FlagConflictError{ + Command: "dws demo run", + Canonical: "user", + Spellings: []string{"--user-id", "uid"}, + }).Error() + if !strings.Contains(err, `for --user on "dws demo run": --user-id, --uid`) { + t.Fatalf("FlagConflictError.Error() = %q", err) + } +} + func TestPhaseString(t *testing.T) { tests := []struct { phase Phase diff --git a/internal/pipeline/handlers/alias_test.go b/internal/pipeline/handlers/alias_test.go index dd76a2fd..760da416 100644 --- a/internal/pipeline/handlers/alias_test.go +++ b/internal/pipeline/handlers/alias_test.go @@ -184,3 +184,9 @@ func TestAliasHandlerNameAndPhase(t *testing.T) { t.Errorf("Phase() = %v, want PreParse", h.Phase()) } } + +func TestTryNormaliseFlagRejectsBareDoubleDash(t *testing.T) { + if got, ok := tryNormaliseFlag("--", map[string]bool{"limit": true}); ok || got != "" { + t.Fatalf("tryNormaliseFlag(--) = %q, %v", got, ok) + } +} diff --git a/internal/pipeline/handlers/semantic_alias_test.go b/internal/pipeline/handlers/semantic_alias_test.go index bdffdd09..f59f4681 100644 --- a/internal/pipeline/handlers/semantic_alias_test.go +++ b/internal/pipeline/handlers/semantic_alias_test.go @@ -55,6 +55,13 @@ func TestSemanticAliasHandlerRewritesSynonym(t *testing.T) { } } +func TestSemanticAliasHandlerNameAndPhase(t *testing.T) { + h := SemanticAliasHandler{} + if h.Name() != "semantic-alias" || h.Phase() != pipeline.PreParse { + t.Fatalf("handler identity = %q / %s", h.Name(), h.Phase()) + } +} + func TestSemanticAliasHandlerPreservesEqualsValueSyntax(t *testing.T) { ctx := &pipeline.Context{Command: "dws demo cmd", Args: []string{"--pageSize=50"}} if err := newSemanticHandler().Handle(ctx); err != nil { @@ -145,9 +152,9 @@ func TestSemanticAliasHandlerNoOpCases(t *testing.T) { } // A real flag that also appears nowhere in the table is left alone. - ctx = &pipeline.Context{Command: "dws demo cmd", Args: []string{"--query", "x", "positional", "-n"}} + ctx = &pipeline.Context{Command: "dws demo cmd", Args: []string{"--query", "x", "--unknown", "y", "positional", "-n"}} _ = h.Handle(ctx) - if !reflect.DeepEqual(ctx.Args, []string{"--query", "x", "positional", "-n"}) { + if !reflect.DeepEqual(ctx.Args, []string{"--query", "x", "--unknown", "y", "positional", "-n"}) { t.Fatalf("canonical/positional/short tokens must be untouched: %v", ctx.Args) } }