From 066094a68cd849e56f274f1ec2a3702eb8287c5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Sat, 1 Aug 2026 12:00:47 +0800 Subject: [PATCH] fix(schema): restore ParamDecl mappings and drop messages-send RequiredWhen Co-authored-by: Cursor --- internal/cli/api_coverage_more_test.go | 2 + .../cli/catalogcodegen/probemain/main_test.go | 53 + internal/cli/contract_final_test.go | 26 + internal/cli/schema_catalog/catalog.json | 2 +- .../cli/schema_catalog/tools/aitable.json | 284 ++++- internal/cli/schema_catalog/tools/chat.json | 78 +- .../cli/schema_catalog/tools/contact.json | 16 +- internal/cli/schema_catalog/tools/doc.json | 48 +- internal/cli/schema_catalog/tools/drive.json | 64 +- internal/cli/schema_catalog/tools/mail.json | 32 +- .../cli/schema_catalog/tools/minutes.json | 48 +- internal/cli/schema_catalog/tools/wiki.json | 16 +- internal/cli/schema_catalog_test.go | 1033 +++++++++++++++++ internal/corecmd/schema_decl_test.go | 1 + internal/helpers/aitable.go | 19 +- internal/helpers/doc.go | 6 +- internal/helpers/drive.go | 8 + internal/helpers/mail.go | 6 + internal/helpers/markdown.go | 4 +- internal/helpers/report.go | 7 + internal/shortcut/aitable/aitable.go | 9 + .../aitable/query_keyword_paramdecl_test.go | 40 + internal/shortcut/chat/chat_bot.go | 4 + internal/shortcut/chat/chat_group.go | 4 + .../chat/query_keyword_paramdecl_test.go | 45 + internal/shortcut/chat/resource_download.go | 22 +- internal/shortcut/chat/unified_send.go | 7 +- internal/shortcut/contact/contact.go | 4 + .../contact/query_keyword_paramdecl_test.go | 37 + internal/shortcut/doc/doc.go | 4 + .../doc/query_keyword_paramdecl_test.go | 37 + internal/shortcut/drive/drive.go | 8 + .../drive/query_keyword_paramdecl_test.go | 45 + internal/shortcut/minutes/minutes.go | 12 + .../minutes/query_keyword_paramdecl_test.go | 46 + internal/shortcut/smart/find_doc.go | 4 + internal/shortcut/smart/find_file.go | 4 + internal/shortcut/smart/find_mail_user.go | 4 + internal/shortcut/smart/find_record.go | 4 + internal/shortcut/smart/minutes_search.go | 23 + .../smart/query_keyword_paramdecl_test.go | 48 + internal/shortcut/smart/search_msg.go | 4 + .../wiki/query_keyword_paramdecl_test.go | 37 + internal/shortcut/wiki/wiki.go | 4 + scripts/dev/migrate_product_params.py | 486 ++++++-- 45 files changed, 2420 insertions(+), 275 deletions(-) create mode 100644 internal/cli/catalogcodegen/probemain/main_test.go create mode 100644 internal/shortcut/aitable/query_keyword_paramdecl_test.go create mode 100644 internal/shortcut/chat/query_keyword_paramdecl_test.go create mode 100644 internal/shortcut/contact/query_keyword_paramdecl_test.go create mode 100644 internal/shortcut/doc/query_keyword_paramdecl_test.go create mode 100644 internal/shortcut/drive/query_keyword_paramdecl_test.go create mode 100644 internal/shortcut/minutes/query_keyword_paramdecl_test.go create mode 100644 internal/shortcut/smart/query_keyword_paramdecl_test.go create mode 100644 internal/shortcut/wiki/query_keyword_paramdecl_test.go diff --git a/internal/cli/api_coverage_more_test.go b/internal/cli/api_coverage_more_test.go index 2304b7be..0e57d498 100644 --- a/internal/cli/api_coverage_more_test.go +++ b/internal/cli/api_coverage_more_test.go @@ -14,6 +14,8 @@ func TestCrossPlatformCoverageRuntimeAnnotationAPIsCoverage(t *testing.T) { AnnotateRuntimeFlag(nil, "x", "x", "string", false, "") AnnotateRuntimeFlagProperty(nil, "x", "x") AnnotateRuntimeRequiredFlags(nil, "x") + AnnotateRuntimeFlagRequiredValue(nil, "x", false) + AnnotateRuntimeFlagInterfaceType(nil, "x", "string") AnnotateRuntimeFlagRequiredWhen(nil, "x", "when") AnnotateRuntimeFlagFormat(nil, "x", "uri") AnnotateRuntimeFlagEnum(nil, "x", "a") diff --git a/internal/cli/catalogcodegen/probemain/main_test.go b/internal/cli/catalogcodegen/probemain/main_test.go new file mode 100644 index 00000000..e4fb0ec7 --- /dev/null +++ b/internal/cli/catalogcodegen/probemain/main_test.go @@ -0,0 +1,53 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bytes" + "io" + "os" + "strconv" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli/catalogcodegen" +) + +func TestCrossPlatformCoverageProbeMainPrintsToolTotals(t *testing.T) { + if len(catalogcodegen.Tools) == 0 { + t.Fatal("generated catalog tools must be non-empty") + } + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + main() + _ = w.Close() + os.Stdout = old + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatal(err) + } + _ = r.Close() + fields := strings.Fields(strings.TrimSpace(buf.String())) + if len(fields) != 2 { + t.Fatalf("main output = %q, want \" \"", buf.String()) + } + tools, err := strconv.Atoi(fields[0]) + if err != nil || tools != len(catalogcodegen.Tools) { + t.Fatalf("tool count = %q, want %d", fields[0], len(catalogcodegen.Tools)) + } +} diff --git a/internal/cli/contract_final_test.go b/internal/cli/contract_final_test.go index e54fae19..9a81a256 100644 --- a/internal/cli/contract_final_test.go +++ b/internal/cli/contract_final_test.go @@ -171,6 +171,32 @@ func TestCrossPlatformCoverageContractFinalNilCommandGuards(t *testing.T) { ClearRuntimeContractFinalForTest(nil) } +func TestCrossPlatformCoverageApplyParamDeclsSkipsBlankAndAnnotatesEnum(t *testing.T) { + cmd := &cobra.Command{Use: "apply-params"} + cmd.Flags().String("mode", "", "mode") + required := false + ApplyParamDecls(cmd, []ParamDecl{ + {Name: " "}, // blank names are skipped + { + Name: "mode", Property: "mode", Required: &required, + InterfaceType: "string", Description: "mode desc", + RequiredWhen: "when create", Enum: []string{"a", "b"}, + }, + }) + flag := cmd.Flags().Lookup("mode") + if flag == nil { + t.Fatal("missing mode flag") + } + if got := flag.Annotations["dws.schema.property"]; len(got) == 0 || got[0] != "mode" { + t.Fatalf("property = %#v", flag.Annotations["dws.schema.property"]) + } + if got := flag.Annotations["x-cli-enum"]; len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Fatalf("enum = %#v", flag.Annotations["x-cli-enum"]) + } + ApplyParamDecls(nil, []ParamDecl{{Name: "mode"}}) + ApplyParamDecls(cmd, nil) +} + func TestCrossPlatformCoverageRuntimeContractFinalRejectsForeignStoredValue(t *testing.T) { cmd := &cobra.Command{Use: "x"} t.Cleanup(func() { ClearRuntimeContractFinalForTest(cmd) }) diff --git a/internal/cli/schema_catalog/catalog.json b/internal/cli/schema_catalog/catalog.json index 2084f723..ea5b5127 100644 --- a/internal/cli/schema_catalog/catalog.json +++ b/internal/cli/schema_catalog/catalog.json @@ -1,7 +1,7 @@ { "version": 1, "surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16", - "source_hash": "sha256:d1cf513fe3999ea4affd2218ee54f0ce746c0b267ce156587b5836ea3a8dc611", + "source_hash": "sha256:26f44334e2182ab20b691e9e6b6221288d4ff9a3c9e13e436b4b52a98eae70b8", "catalog": { "agent_metadata": { "products_with_metadata": 26, diff --git a/internal/cli/schema_catalog/tools/aitable.json b/internal/cli/schema_catalog/tools/aitable.json index d1f1d3d1..08a2b970 100644 --- a/internal/cli/schema_catalog/tools/aitable.json +++ b/internal/cli/schema_catalog/tools/aitable.json @@ -5010,8 +5010,14 @@ "required": { "candidates": [ { - "precedence": "tool_schema_hint", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": true + }, + { + "precedence": "tool_schema_hint", + "selected": false, "source": "tool_schema_hint", "value": true }, @@ -5034,9 +5040,9 @@ "value": false } ], - "precedence": "tool_schema_hint", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "tool_schema_hint", + "source": "native_annotation", "value": true }, "required_when": { @@ -14285,8 +14291,14 @@ "interface_type": { "candidates": [ { - "precedence": "mcp_metadata", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "object" + }, + { + "precedence": "mcp_metadata", + "selected": false, "source": "mcp_metadata", "value": "object" }, @@ -14297,9 +14309,9 @@ "value": "string" } ], - "precedence": "mcp_metadata", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "mcp_metadata", + "source": "native_annotation", "value": "object" }, "property": { @@ -14319,8 +14331,14 @@ "required": { "candidates": [ { - "precedence": "tool_schema_hint", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": true + }, + { + "precedence": "tool_schema_hint", + "selected": false, "source": "tool_schema_hint", "value": true }, @@ -14337,9 +14355,9 @@ "value": false } ], - "precedence": "tool_schema_hint", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "tool_schema_hint", + "source": "native_annotation", "value": true }, "required_when": { @@ -39016,8 +39034,14 @@ "interface_type": { "candidates": [ { - "precedence": "tool_schema_hint", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "array" + }, + { + "precedence": "tool_schema_hint", + "selected": false, "source": "tool_schema_hint", "value": "array" }, @@ -39034,23 +39058,29 @@ "value": "string" } ], - "precedence": "tool_schema_hint", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "tool_schema_hint", + "source": "native_annotation", "value": "array" }, "property": { "candidates": [ { - "precedence": "inference", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "fieldIds" + }, + { + "precedence": "inference", + "selected": false, "source": "flag_name_inference", "value": "fieldIds" } ], - "precedence": "inference", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "flag_name_inference", + "source": "native_annotation", "value": "fieldIds" }, "required": { @@ -39140,8 +39170,14 @@ "interface_type": { "candidates": [ { - "precedence": "tool_schema_hint", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "object" + }, + { + "precedence": "tool_schema_hint", + "selected": false, "source": "tool_schema_hint", "value": "object" }, @@ -39158,23 +39194,29 @@ "value": "string" } ], - "precedence": "tool_schema_hint", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "tool_schema_hint", + "source": "native_annotation", "value": "object" }, "property": { "candidates": [ { - "precedence": "inference", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "filters" + }, + { + "precedence": "inference", + "selected": false, "source": "flag_name_inference", "value": "filters" } ], - "precedence": "inference", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "flag_name_inference", + "source": "native_annotation", "value": "filters" }, "required": { @@ -39479,6 +39521,12 @@ "source": "versioned_parameter_binding", "value": "keyword" }, + { + "precedence": "native_annotation", + "selected": false, + "source": "native_annotation", + "value": "keyword" + }, { "precedence": "inference", "selected": false, @@ -39577,8 +39625,14 @@ "interface_type": { "candidates": [ { - "precedence": "tool_schema_hint", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "array" + }, + { + "precedence": "tool_schema_hint", + "selected": false, "source": "tool_schema_hint", "value": "array" }, @@ -39595,23 +39649,29 @@ "value": "string" } ], - "precedence": "tool_schema_hint", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "tool_schema_hint", + "source": "native_annotation", "value": "array" }, "property": { "candidates": [ { - "precedence": "inference", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "recordIds" + }, + { + "precedence": "inference", + "selected": false, "source": "flag_name_inference", "value": "recordIds" } ], - "precedence": "inference", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "flag_name_inference", + "source": "native_annotation", "value": "recordIds" }, "required": { @@ -39707,8 +39767,14 @@ "interface_type": { "candidates": [ { - "precedence": "tool_schema_hint", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "array" + }, + { + "precedence": "tool_schema_hint", + "selected": false, "source": "tool_schema_hint", "value": "array" }, @@ -39725,23 +39791,29 @@ "value": "string" } ], - "precedence": "tool_schema_hint", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "tool_schema_hint", + "source": "native_annotation", "value": "array" }, "property": { "candidates": [ { - "precedence": "inference", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "sort" + }, + { + "precedence": "inference", + "selected": false, "source": "flag_name_inference", "value": "sort" } ], - "precedence": "inference", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "flag_name_inference", + "source": "native_annotation", "value": "sort" }, "required": { @@ -42577,8 +42649,14 @@ "interface_type": { "candidates": [ { - "precedence": "mcp_metadata", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "array" + }, + { + "precedence": "mcp_metadata", + "selected": false, "source": "mcp_metadata", "value": "array" }, @@ -42589,23 +42667,29 @@ "value": "string" } ], - "precedence": "mcp_metadata", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "mcp_metadata", + "source": "native_annotation", "value": "array" }, "property": { "candidates": [ { - "precedence": "inference", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "fieldIds" + }, + { + "precedence": "inference", + "selected": false, "source": "flag_name_inference", "value": "fieldIds" } ], - "precedence": "inference", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "flag_name_inference", + "source": "native_annotation", "value": "fieldIds" }, "required": { @@ -42689,8 +42773,14 @@ "interface_type": { "candidates": [ { - "precedence": "mcp_metadata", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "array" + }, + { + "precedence": "mcp_metadata", + "selected": false, "source": "mcp_metadata", "value": "array" }, @@ -42701,23 +42791,29 @@ "value": "string" } ], - "precedence": "mcp_metadata", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "mcp_metadata", + "source": "native_annotation", "value": "array" }, "property": { "candidates": [ { - "precedence": "inference", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "recordIds" + }, + { + "precedence": "inference", + "selected": false, "source": "flag_name_inference", "value": "recordIds" } ], - "precedence": "inference", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "flag_name_inference", + "source": "native_annotation", "value": "recordIds" }, "required": { @@ -48026,8 +48122,14 @@ "interface_type": { "candidates": [ { - "precedence": "mcp_metadata", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "array" + }, + { + "precedence": "mcp_metadata", + "selected": false, "source": "mcp_metadata", "value": "array" }, @@ -48038,9 +48140,9 @@ "value": "string" } ], - "precedence": "mcp_metadata", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "mcp_metadata", + "source": "native_annotation", "value": "array" }, "property": { @@ -48060,8 +48162,14 @@ "required": { "candidates": [ { - "precedence": "tool_schema_hint", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": true + }, + { + "precedence": "tool_schema_hint", + "selected": false, "source": "tool_schema_hint", "value": true }, @@ -48084,9 +48192,9 @@ "value": false } ], - "precedence": "tool_schema_hint", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "tool_schema_hint", + "source": "native_annotation", "value": true }, "required_when": { @@ -57845,16 +57953,22 @@ "property": { "candidates": [ { - "precedence": "inference", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "keyword" + }, + { + "precedence": "inference", + "selected": false, "source": "flag_name_inference", "value": "query" } ], - "precedence": "inference", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "flag_name_inference", - "value": "query" + "source": "native_annotation", + "value": "keyword" }, "required": { "candidates": [ @@ -57899,7 +58013,7 @@ "value": "string" } }, - "property": "query", + "property": "keyword", "required": false, "type": "string" }, @@ -61796,6 +61910,26 @@ "source": "cobra_usage", "value": "结构化过滤条件 JSON(可选)" }, + "interface_type": { + "candidates": [ + { + "precedence": "native_annotation", + "selected": true, + "source": "native_annotation", + "value": "object" + }, + { + "precedence": "fallback", + "selected": false, + "source": "cobra_flag_type", + "value": "string" + } + ], + "precedence": "native_annotation", + "resolution": "highest_precedence", + "source": "native_annotation", + "value": "object" + }, "property": { "candidates": [ { @@ -61853,6 +61987,7 @@ "value": "string" } }, + "interface_type": "object", "property": "filters", "required": false, "type": "string" @@ -61973,16 +62108,22 @@ "property": { "candidates": [ { - "precedence": "inference", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "keyword" + }, + { + "precedence": "inference", + "selected": false, "source": "flag_name_inference", "value": "query" } ], - "precedence": "inference", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "flag_name_inference", - "value": "query" + "source": "native_annotation", + "value": "keyword" }, "required": { "candidates": [ @@ -62027,7 +62168,7 @@ "value": "string" } }, - "property": "query", + "property": "keyword", "required": false, "type": "string" }, @@ -62138,6 +62279,26 @@ "source": "cobra_usage", "value": "排序条件 JSON 数组(可选)" }, + "interface_type": { + "candidates": [ + { + "precedence": "native_annotation", + "selected": true, + "source": "native_annotation", + "value": "array" + }, + { + "precedence": "fallback", + "selected": false, + "source": "cobra_flag_type", + "value": "string" + } + ], + "precedence": "native_annotation", + "resolution": "highest_precedence", + "source": "native_annotation", + "value": "array" + }, "property": { "candidates": [ { @@ -62195,6 +62356,7 @@ "value": "string" } }, + "interface_type": "array", "property": "sort", "required": false, "type": "string" diff --git a/internal/cli/schema_catalog/tools/chat.json b/internal/cli/schema_catalog/tools/chat.json index de75e2e4..90ae3974 100644 --- a/internal/cli/schema_catalog/tools/chat.json +++ b/internal/cli/schema_catalog/tools/chat.json @@ -54493,16 +54493,22 @@ "property": { "candidates": [ { - "precedence": "inference", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "keyword" + }, + { + "precedence": "inference", + "selected": false, "source": "flag_name_inference", "value": "query" } ], - "precedence": "inference", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "flag_name_inference", - "value": "query" + "source": "native_annotation", + "value": "keyword" }, "required": { "candidates": [ @@ -54553,7 +54559,7 @@ "value": "string" } }, - "property": "query", + "property": "keyword", "required": true, "type": "string" } @@ -66200,16 +66206,22 @@ "property": { "candidates": [ { - "precedence": "inference", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "keyword" + }, + { + "precedence": "inference", + "selected": false, "source": "flag_name_inference", "value": "query" } ], - "precedence": "inference", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "flag_name_inference", - "value": "query" + "source": "native_annotation", + "value": "keyword" }, "required": { "candidates": [ @@ -66260,7 +66272,7 @@ "value": "string" } }, - "property": "query", + "property": "keyword", "required": true, "type": "string" } @@ -77948,23 +77960,17 @@ }, "required_when": { "candidates": [ - { - "precedence": "native_annotation", - "selected": true, - "source": "native_annotation", - "value": "identity=bot" - }, { "precedence": "default", - "selected": false, + "selected": true, "source": "default", "value": "" } ], - "precedence": "native_annotation", + "precedence": "default", "resolution": "highest_precedence", - "source": "native_annotation", - "value": "identity=bot" + "source": "default", + "value": "" }, "type": { "candidates": [ @@ -77983,7 +77989,6 @@ }, "property": "robotCode", "required": false, - "required_when": "identity=bot", "type": "string" }, "text": { @@ -78465,23 +78470,17 @@ }, "required_when": { "candidates": [ - { - "precedence": "native_annotation", - "selected": true, - "source": "native_annotation", - "value": "identity=webhook" - }, { "precedence": "default", - "selected": false, + "selected": true, "source": "default", "value": "" } ], - "precedence": "native_annotation", + "precedence": "default", "resolution": "highest_precedence", - "source": "native_annotation", - "value": "identity=webhook" + "source": "default", + "value": "" }, "type": { "candidates": [ @@ -78500,7 +78499,6 @@ }, "property": "webhookToken", "required": false, - "required_when": "identity=webhook", "type": "string" } }, @@ -84012,16 +84010,22 @@ "property": { "candidates": [ { - "precedence": "inference", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "keyword" + }, + { + "precedence": "inference", + "selected": false, "source": "flag_name_inference", "value": "query" } ], - "precedence": "inference", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "flag_name_inference", - "value": "query" + "source": "native_annotation", + "value": "keyword" }, "required": { "candidates": [ @@ -84072,7 +84076,7 @@ "value": "string" } }, - "property": "query", + "property": "keyword", "required": false, "type": "string" }, diff --git a/internal/cli/schema_catalog/tools/contact.json b/internal/cli/schema_catalog/tools/contact.json index 2f03a7ca..82c7485c 100644 --- a/internal/cli/schema_catalog/tools/contact.json +++ b/internal/cli/schema_catalog/tools/contact.json @@ -17797,16 +17797,22 @@ "property": { "candidates": [ { - "precedence": "inference", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "keyword" + }, + { + "precedence": "inference", + "selected": false, "source": "flag_name_inference", "value": "query" } ], - "precedence": "inference", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "flag_name_inference", - "value": "query" + "source": "native_annotation", + "value": "keyword" }, "required": { "candidates": [ @@ -17863,7 +17869,7 @@ "value": "string" } }, - "property": "query", + "property": "keyword", "required": true, "type": "string" } diff --git a/internal/cli/schema_catalog/tools/doc.json b/internal/cli/schema_catalog/tools/doc.json index 031ed5c7..f6347257 100644 --- a/internal/cli/schema_catalog/tools/doc.json +++ b/internal/cli/schema_catalog/tools/doc.json @@ -19394,19 +19394,13 @@ "parameter_count": 2, "parameters": { "name": { - "description": "新显示名称;实际执行前读取节点类型与当前扩展名,仅对非文件夹且末尾后缀与当前扩展名一致的名称去掉一层,避免双扩展名", + "description": "新名称 (必填;原样传给服务端,不做扩展名规范化;如需根据节点类型和当前后缀规范化,请使用 drive rename)", "field_provenance": { "description": { "candidates": [ - { - "precedence": "native_annotation", - "selected": true, - "source": "native_annotation", - "value": "新显示名称;实际执行前读取节点类型与当前扩展名,仅对非文件夹且末尾后缀与当前扩展名一致的名称去掉一层,避免双扩展名" - }, { "precedence": "cobra_contract", - "selected": false, + "selected": true, "source": "cobra_usage", "value": "新名称 (必填;原样传给服务端,不做扩展名规范化;如需根据节点类型和当前后缀规范化,请使用 drive rename)" }, @@ -19423,10 +19417,10 @@ "value": "" } ], - "precedence": "native_annotation", + "precedence": "cobra_contract", "resolution": "highest_precedence", - "source": "native_annotation", - "value": "新显示名称;实际执行前读取节点类型与当前扩展名,仅对非文件夹且末尾后缀与当前扩展名一致的名称去掉一层,避免双扩展名" + "source": "cobra_usage", + "value": "新名称 (必填;原样传给服务端,不做扩展名规范化;如需根据节点类型和当前后缀规范化,请使用 drive rename)" }, "property": { "candidates": [ @@ -27250,16 +27244,22 @@ "property": { "candidates": [ { - "precedence": "inference", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "keyword" + }, + { + "precedence": "inference", + "selected": false, "source": "flag_name_inference", "value": "query" } ], - "precedence": "inference", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "flag_name_inference", - "value": "query" + "source": "native_annotation", + "value": "keyword" }, "required": { "candidates": [ @@ -27316,7 +27316,7 @@ "value": "string" } }, - "property": "query", + "property": "keyword", "required": true, "type": "string" } @@ -29725,16 +29725,22 @@ "property": { "candidates": [ { - "precedence": "inference", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "keyword" + }, + { + "precedence": "inference", + "selected": false, "source": "flag_name_inference", "value": "query" } ], - "precedence": "inference", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "flag_name_inference", - "value": "query" + "source": "native_annotation", + "value": "keyword" }, "required": { "candidates": [ @@ -29779,7 +29785,7 @@ "value": "string" } }, - "property": "query", + "property": "keyword", "required": false, "type": "string" }, diff --git a/internal/cli/schema_catalog/tools/drive.json b/internal/cli/schema_catalog/tools/drive.json index c6ff6911..05b2b6ff 100644 --- a/internal/cli/schema_catalog/tools/drive.json +++ b/internal/cli/schema_catalog/tools/drive.json @@ -19620,13 +19620,19 @@ "parameter_count": 2, "parameters": { "name": { - "description": "新名称 (必填;实际执行时仅去掉与节点当前扩展名完全匹配的一层后缀)", + "description": "新显示名称;实际执行前读取节点类型与当前扩展名,仅对非文件夹且末尾后缀与当前扩展名一致的名称去掉一层,避免双扩展名", "field_provenance": { "description": { "candidates": [ { - "precedence": "cobra_contract", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "新显示名称;实际执行前读取节点类型与当前扩展名,仅对非文件夹且末尾后缀与当前扩展名一致的名称去掉一层,避免双扩展名" + }, + { + "precedence": "cobra_contract", + "selected": false, "source": "cobra_usage", "value": "新名称 (必填;实际执行时仅去掉与节点当前扩展名完全匹配的一层后缀)" }, @@ -19643,10 +19649,10 @@ "value": "" } ], - "precedence": "cobra_contract", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "cobra_usage", - "value": "新名称 (必填;实际执行时仅去掉与节点当前扩展名完全匹配的一层后缀)" + "source": "native_annotation", + "value": "新显示名称;实际执行前读取节点类型与当前扩展名,仅对非文件夹且末尾后缀与当前扩展名一致的名称去掉一层,避免双扩展名" }, "property": { "candidates": [ @@ -22966,16 +22972,22 @@ "property": { "candidates": [ { - "precedence": "inference", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "keyword" + }, + { + "precedence": "inference", + "selected": false, "source": "flag_name_inference", "value": "query" } ], - "precedence": "inference", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "flag_name_inference", - "value": "query" + "source": "native_annotation", + "value": "keyword" }, "required": { "candidates": [ @@ -23032,7 +23044,7 @@ "value": "string" } }, - "property": "query", + "property": "keyword", "required": true, "type": "string" } @@ -26074,16 +26086,22 @@ "property": { "candidates": [ { - "precedence": "inference", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "keyword" + }, + { + "precedence": "inference", + "selected": false, "source": "flag_name_inference", "value": "query" } ], - "precedence": "inference", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "flag_name_inference", - "value": "query" + "source": "native_annotation", + "value": "keyword" }, "required": { "candidates": [ @@ -26140,7 +26158,7 @@ "value": "string" } }, - "property": "query", + "property": "keyword", "required": true, "type": "string" }, @@ -26731,16 +26749,22 @@ "property": { "candidates": [ { - "precedence": "inference", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "keyword" + }, + { + "precedence": "inference", + "selected": false, "source": "flag_name_inference", "value": "query" } ], - "precedence": "inference", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "flag_name_inference", - "value": "query" + "source": "native_annotation", + "value": "keyword" }, "required": { "candidates": [ @@ -26797,7 +26821,7 @@ "value": "string" } }, - "property": "query", + "property": "keyword", "required": true, "type": "string" } diff --git a/internal/cli/schema_catalog/tools/mail.json b/internal/cli/schema_catalog/tools/mail.json index 4adebe52..9e00f8eb 100644 --- a/internal/cli/schema_catalog/tools/mail.json +++ b/internal/cli/schema_catalog/tools/mail.json @@ -13155,16 +13155,22 @@ "property": { "candidates": [ { - "precedence": "inference", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "size" + }, + { + "precedence": "inference", + "selected": false, "source": "flag_name_inference", "value": "limit" } ], - "precedence": "inference", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "flag_name_inference", - "value": "limit" + "source": "native_annotation", + "value": "size" }, "required": { "candidates": [ @@ -13215,7 +13221,7 @@ "value": "string" } }, - "property": "limit", + "property": "size", "required": false, "type": "string" } @@ -20954,16 +20960,22 @@ "property": { "candidates": [ { - "precedence": "inference", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "keyword" + }, + { + "precedence": "inference", + "selected": false, "source": "flag_name_inference", "value": "query" } ], - "precedence": "inference", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "flag_name_inference", - "value": "query" + "source": "native_annotation", + "value": "keyword" }, "required": { "candidates": [ @@ -21020,7 +21032,7 @@ "value": "string" } }, - "property": "query", + "property": "keyword", "required": true, "type": "string" } diff --git a/internal/cli/schema_catalog/tools/minutes.json b/internal/cli/schema_catalog/tools/minutes.json index 3b54e513..2da0d19f 100644 --- a/internal/cli/schema_catalog/tools/minutes.json +++ b/internal/cli/schema_catalog/tools/minutes.json @@ -18344,16 +18344,22 @@ "property": { "candidates": [ { - "precedence": "inference", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "keyword" + }, + { + "precedence": "inference", + "selected": false, "source": "flag_name_inference", "value": "query" } ], - "precedence": "inference", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "flag_name_inference", - "value": "query" + "source": "native_annotation", + "value": "keyword" }, "required": { "candidates": [ @@ -18398,7 +18404,7 @@ "value": "string" } }, - "property": "query", + "property": "keyword", "required": false, "type": "string" } @@ -18966,16 +18972,22 @@ "property": { "candidates": [ { - "precedence": "inference", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "keyword" + }, + { + "precedence": "inference", + "selected": false, "source": "flag_name_inference", "value": "query" } ], - "precedence": "inference", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "flag_name_inference", - "value": "query" + "source": "native_annotation", + "value": "keyword" }, "required": { "candidates": [ @@ -19020,7 +19032,7 @@ "value": "string" } }, - "property": "query", + "property": "keyword", "required": false, "type": "string" } @@ -19588,16 +19600,22 @@ "property": { "candidates": [ { - "precedence": "inference", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "keyword" + }, + { + "precedence": "inference", + "selected": false, "source": "flag_name_inference", "value": "query" } ], - "precedence": "inference", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "flag_name_inference", - "value": "query" + "source": "native_annotation", + "value": "keyword" }, "required": { "candidates": [ @@ -19642,7 +19660,7 @@ "value": "string" } }, - "property": "query", + "property": "keyword", "required": false, "type": "string" } diff --git a/internal/cli/schema_catalog/tools/wiki.json b/internal/cli/schema_catalog/tools/wiki.json index b0199865..b502b783 100644 --- a/internal/cli/schema_catalog/tools/wiki.json +++ b/internal/cli/schema_catalog/tools/wiki.json @@ -9794,16 +9794,22 @@ "property": { "candidates": [ { - "precedence": "inference", + "precedence": "native_annotation", "selected": true, + "source": "native_annotation", + "value": "keyword" + }, + { + "precedence": "inference", + "selected": false, "source": "flag_name_inference", "value": "query" } ], - "precedence": "inference", + "precedence": "native_annotation", "resolution": "highest_precedence", - "source": "flag_name_inference", - "value": "query" + "source": "native_annotation", + "value": "keyword" }, "required": { "candidates": [ @@ -9860,7 +9866,7 @@ "value": "string" } }, - "property": "query", + "property": "keyword", "required": true, "type": "string" } diff --git a/internal/cli/schema_catalog_test.go b/internal/cli/schema_catalog_test.go index 5a65dffd..0d621350 100644 --- a/internal/cli/schema_catalog_test.go +++ b/internal/cli/schema_catalog_test.go @@ -162,6 +162,268 @@ func TestEmbeddedCatalogPreservesRegistryIdentityAndManualParameterContract(t *t } } +func TestEmbeddedCatalogMailMessageListLimitMapsToSize(t *testing.T) { + // mail message list is a composite wrapper over search_emails and shares + // that RPC with mail message search. list has no versioned binding entry + // (composite identity), so clearing hints without a leaf-local ParamDecl + // previously fell back to flag_name_inference → "limit". + listLeaf, err := embeddedSchemaPayload([]string{"mail message list"}) + if err != nil { + t.Fatal(err) + } + listParams := schemaMap(listLeaf["parameters"]) + listLimit := listParams["limit"] + if listLimit["property"] != "size" { + t.Fatalf("mail message list --limit property = %#v, want size", listLimit["property"]) + } + listProv := schemaMap(listLimit["field_provenance"])["property"] + if src, _ := listProv["source"].(string); src != "native_annotation" { + t.Fatalf("mail message list --limit property source = %#v, want native_annotation", listProv) + } + if _, ok := listParams["folder-id"]; !ok { + t.Fatal("mail message list must keep --folder-id on its composite surface") + } + if _, ok := listParams["query"]; ok { + t.Fatal("mail message list must not publish search-only --query") + } + + // Sibling search leaf maps the same --limit → size via binding + ParamDecl + // and must keep the free-form KQL surface (not fold into list). + searchLeaf, err := embeddedSchemaPayload([]string{"mail message search"}) + if err != nil { + t.Fatal(err) + } + searchParams := schemaMap(searchLeaf["parameters"]) + searchLimit := searchParams["limit"] + if searchLimit["property"] != "size" { + t.Fatalf("mail message search --limit property = %#v, want size", searchLimit["property"]) + } + searchProv := schemaMap(searchLimit["field_provenance"])["property"] + src, _ := searchProv["source"].(string) + if src != "native_annotation" && src != "versioned_parameter_binding" { + t.Fatalf("mail message search --limit property source = %#v, want native_annotation or versioned_parameter_binding", searchProv) + } + if _, ok := searchParams["query"]; !ok { + t.Fatal("mail message search must keep --query") + } + if _, ok := searchParams["folder-id"]; ok { + t.Fatal("mail message search must not publish list-only --folder-id") + } +} + +func TestEmbeddedCatalogSharedRenameDocumentParamDeclsStayOnDriveLeaf(t *testing.T) { + // drive rename and doc rename share RPC rename_document. The reviewed + // extension-stripping description must stay on drive rename only. + driveLeaf, err := embeddedSchemaPayload([]string{"drive rename"}) + if err != nil { + t.Fatal(err) + } + driveName := schemaMap(driveLeaf["parameters"])["name"] + driveDesc, _ := driveName["description"].(string) + if driveDesc == "" || !strings.Contains(driveDesc, "扩展名") { + t.Fatalf("drive rename --name description = %q, want reviewed extension-stripping prose", driveDesc) + } + driveDescProv := schemaMap(driveName["field_provenance"])["description"] + if src, _ := driveDescProv["source"].(string); src != "native_annotation" { + t.Fatalf("drive rename --name description source = %#v, want native_annotation", driveDescProv) + } + + docLeaf, err := embeddedSchemaPayload([]string{"doc rename"}) + if err != nil { + t.Fatal(err) + } + docName := schemaMap(docLeaf["parameters"])["name"] + docDesc, _ := docName["description"].(string) + // Cobra usage may mention 扩展名 when pointing users at drive rename; the + // failure mode is inheriting the drive-only reviewed overlay prose. + if strings.Contains(docDesc, "避免双扩展名") || strings.Contains(docDesc, "仅对非文件夹") { + t.Fatalf("doc rename --name description incorrectly inherited drive rename prose: %q", docDesc) + } + docDescProv := schemaMap(docName["field_provenance"])["description"] + if src, _ := docDescProv["source"].(string); src == "native_annotation" && strings.Contains(docDesc, "读取节点类型") { + t.Fatalf("doc rename --name description source/value look like a misplaced drive ParamDecl: %#v", docDescProv) + } +} + +func TestEmbeddedCatalogDriveUploadCompositeNodeParamDecl(t *testing.T) { + // drive.upload is a composite multi-step leaf (no single RPCName / + // interface_ref). The reviewed --node → nodeId overlay must stay on the + // upload leaf via ParamDecl, not on the manual upload-info / commit steps. + leaf, err := embeddedSchemaPayload([]string{"drive upload"}) + if err != nil { + t.Fatal(err) + } + if got := schemaString(leaf["interface_mode"]); got != "composite" { + t.Fatalf("drive upload interface_mode = %q, want composite", got) + } + if leaf["interface_ref"] != nil { + t.Fatalf("drive upload interface_ref = %#v, want nil for multi-step composite", leaf["interface_ref"]) + } + parameters := schemaMap(leaf["parameters"]) + node := parameters["node"] + if node["property"] != "nodeId" || node["required"] != false { + t.Fatalf("drive upload --node = %#v, want property=nodeId required=false", node) + } + for _, field := range []string{"property", "required"} { + provenance := schemaMap(node["field_provenance"])[field] + if src, _ := provenance["source"].(string); src != "native_annotation" { + t.Fatalf("drive upload --node %s source = %#v, want native_annotation", field, provenance) + } + } + + for _, path := range []string{"drive upload-info", "drive commit"} { + sibling, err := embeddedSchemaPayload([]string{path}) + if err != nil { + t.Fatal(err) + } + siblingParams := schemaMap(sibling["parameters"]) + if _, ok := siblingParams["node"]; ok { + t.Fatalf("%s unexpectedly publishes --node (upload overwrite ParamDecl leaked)", path) + } + if got := schemaString(sibling["interface_mode"]); got != "mcp" { + t.Fatalf("%s interface_mode = %q, want mcp", path, got) + } + } +} + +func TestEmbeddedCatalogDocReadParamDeclsMatchMergeBaseContract(t *testing.T) { + // Former schema_hints overlays for get_document_content must stay on the + // doc read leaf after ParamDecl migration (87910880 value parity). + leaf, err := embeddedSchemaPayload([]string{"doc read"}) + if err != nil { + t.Fatal(err) + } + parameters := schemaMap(leaf["parameters"]) + + contentFormat := parameters["content-format"] + if contentFormat["property"] != "format" { + t.Fatalf("doc read --content-format property = %#v, want format", contentFormat["property"]) + } + if contentFormat["required"] != false { + t.Fatalf("doc read --content-format required = %#v, want false", contentFormat["required"]) + } + + for _, flagName := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id"} { + if parameters[flagName]["required"] != false { + t.Fatalf("doc read --%s required = %#v, want false", flagName, parameters[flagName]["required"]) + } + } + if got := parameters["tags"]["required_when"]; got != "--scope=tags" { + t.Fatalf("doc read --tags required_when = %#v", got) + } + if got := parameters["start-block-id"]["required_when"]; got != "--scope=range or --scope=section" { + t.Fatalf("doc read --start-block-id required_when = %#v", got) + } + if parameters["max-depth"]["type"] != "integer" { + t.Fatalf("doc read --max-depth type = %#v, want integer", parameters["max-depth"]["type"]) + } +} + +func TestEmbeddedCatalogDocCommentParamDeclsMatchMergeBaseContract(t *testing.T) { + // create/reply/update mentioned-open-conversation-id overlays + update + // mention interface_type must survive hint clearance. + for _, path := range []string{"doc comment create", "doc comment reply"} { + leaf, err := embeddedSchemaPayload([]string{path}) + if err != nil { + t.Fatal(err) + } + param := schemaMap(leaf["parameters"])["mentioned-open-conversation-id"] + if param["required"] != false { + t.Fatalf("%s --mentioned-open-conversation-id required = %#v, want false", path, param["required"]) + } + if param["type"] != "array" { + t.Fatalf("%s --mentioned-open-conversation-id type = %#v, want array", path, param["type"]) + } + prov := schemaMap(param["field_provenance"])["required"] + if src, _ := prov["source"].(string); src != "native_annotation" { + t.Fatalf("%s --mentioned-open-conversation-id required source = %#v, want native_annotation", path, prov) + } + } + + updateLeaf, err := embeddedSchemaPayload([]string{"doc comment update"}) + if err != nil { + t.Fatal(err) + } + updateParams := schemaMap(updateLeaf["parameters"]) + mention := updateParams["mention"] + if mention["interface_type"] != "array" { + t.Fatalf("doc comment update --mention interface_type = %#v, want array", mention["interface_type"]) + } + mentionTypeProv := schemaMap(mention["field_provenance"])["interface_type"] + if src, _ := mentionTypeProv["source"].(string); src != "native_annotation" { + t.Fatalf("doc comment update --mention interface_type source = %#v, want native_annotation", mentionTypeProv) + } + groupMention := updateParams["mentioned-open-conversation-id"] + if groupMention["property"] != "mentionedOpenConversationIds" || groupMention["required"] != false { + t.Fatalf("doc comment update --mentioned-open-conversation-id = %#v", groupMention) + } + groupPropProv := schemaMap(groupMention["field_provenance"])["property"] + if src, _ := groupPropProv["source"].(string); src != "native_annotation" { + t.Fatalf("doc comment update --mentioned-open-conversation-id property source = %#v, want native_annotation", groupPropProv) + } +} + +func TestEmbeddedCatalogDocRenameKeepsPassthroughNameDescription(t *testing.T) { + // Shared-RPC sibling of drive rename: doc must keep the merge-base + // passthrough usage, not the drive extension-stripping ParamDecl. + leaf, err := embeddedSchemaPayload([]string{"doc rename"}) + if err != nil { + t.Fatal(err) + } + name := schemaMap(leaf["parameters"])["name"] + desc, _ := name["description"].(string) + if !strings.Contains(desc, "原样传给服务端") { + t.Fatalf("doc rename --name description = %q, want passthrough Cobra usage", desc) + } + if strings.Contains(desc, "避免双扩展名") || strings.Contains(desc, "仅对非文件夹") { + t.Fatalf("doc rename --name description incorrectly inherited drive ParamDecl: %q", desc) + } + descProv := schemaMap(name["field_provenance"])["description"] + if src, _ := descProv["source"].(string); src != "cobra_usage" { + t.Fatalf("doc rename --name description source = %#v, want cobra_usage", descProv) + } +} + +func TestEmbeddedCatalogDevdocQueryRemainsOptionalInRequireOneOf(t *testing.T) { + // Former hint required=false keeps query optional; positional keyword is + // the other require_one_of member (constraint may outrank the annotation). + leaf, err := embeddedSchemaPayload([]string{"devdoc article search"}) + if err != nil { + t.Fatal(err) + } + query := schemaMap(leaf["parameters"])["query"] + if query["required"] != false { + t.Fatalf("devdoc article search --query required = %#v, want false", query["required"]) + } + prov := schemaMap(query["field_provenance"])["required"] + src, _ := prov["source"].(string) + if src != "native_annotation" && src != "require_one_of_constraint" { + t.Fatalf("devdoc article search --query required source = %#v, want native_annotation or require_one_of_constraint", prov) + } + constraints, ok := leaf["constraints"].(map[string]any) + if !ok { + t.Fatalf("constraints = %#v", leaf["constraints"]) + } + groups, _ := constraints["require_one_of"].([]any) + found := false + for _, raw := range groups { + group, _ := raw.([]any) + names := make([]string, 0, len(group)) + for _, item := range group { + if s, ok := item.(string); ok { + names = append(names, s) + } + } + if len(names) == 2 && ((names[0] == "query" && names[1] == "keyword") || (names[0] == "keyword" && names[1] == "query")) { + found = true + break + } + } + if !found { + t.Fatalf("devdoc article search require_one_of missing [query,keyword]: %#v", groups) + } +} + func TestEmbeddedCatalogModelsAitableExportBranches(t *testing.T) { leaf, err := embeddedSchemaPayload([]string{"aitable export data"}) if err != nil { @@ -357,3 +619,774 @@ func TestStripSchemaPayloadCompactProduct(t *testing.T) { } } } + +func TestEmbeddedCatalogHrbrainUnpinnedParamDeclsKeepExplicitMappings(t *testing.T) { + // hrbrain leaves are Reviewed unpinned remote adapters. Clearing hint + // overlays must keep non-inferable properties on native_annotation + // (page→currentPage, order-by→orderByClauses), not flag_name_inference. + listLeaf, err := embeddedSchemaPayload([]string{"hrbrain talent-pool list"}) + if err != nil { + t.Fatal(err) + } + listParams := schemaMap(listLeaf["parameters"]) + page := listParams["page"] + if page["property"] != "currentPage" || page["required"] != false { + t.Fatalf("hrbrain talent-pool list --page = %#v, want property=currentPage required=false", page) + } + pageProv := schemaMap(page["field_provenance"])["property"] + if src, _ := pageProv["source"].(string); src != "native_annotation" { + t.Fatalf("hrbrain talent-pool list --page property source = %#v, want native_annotation", pageProv) + } + + structuredLeaf, err := embeddedSchemaPayload([]string{"hrbrain search employees-structured"}) + if err != nil { + t.Fatal(err) + } + structuredParams := schemaMap(structuredLeaf["parameters"]) + orderBy := structuredParams["order-by"] + if orderBy["property"] != "orderByClauses" || orderBy["required"] != false { + t.Fatalf("hrbrain search employees-structured --order-by = %#v, want property=orderByClauses required=false", orderBy) + } + orderByProv := schemaMap(orderBy["field_provenance"])["property"] + if src, _ := orderByProv["source"].(string); src != "native_annotation" { + t.Fatalf("hrbrain search employees-structured --order-by property source = %#v, want native_annotation", orderByProv) + } +} + +func TestEmbeddedCatalogAttendanceSearchRequiredStaysNativeWithoutProperty(t *testing.T) { + // attendance * search overlays only reviewed required=false (no property). + // After clearing hints, Required must stay native_annotation and property + // must remain the reviewed mapping exclusion (empty), not inference. + for _, path := range []string{ + "attendance adjustment search", + "attendance group search", + "attendance overtime search", + } { + leaf, err := embeddedSchemaPayload([]string{path}) + if err != nil { + t.Fatalf("%s: %v", path, err) + } + parameters := schemaMap(leaf["parameters"]) + for _, flagName := range []string{"page", "limit"} { + param := parameters[flagName] + if param["required"] != false { + t.Fatalf("%s --%s required = %#v, want false", path, flagName, param["required"]) + } + if param["property"] != nil && param["property"] != "" { + t.Fatalf("%s --%s property = %#v, want omitted/empty mapping exclusion", path, flagName, param["property"]) + } + reqProv := schemaMap(param["field_provenance"])["required"] + if src, _ := reqProv["source"].(string); src != "native_annotation" { + t.Fatalf("%s --%s required source = %#v, want native_annotation", path, flagName, reqProv) + } + propProv := schemaMap(param["field_provenance"])["property"] + if src, _ := propProv["source"].(string); src != "reviewed_mapping_exclusion" { + t.Fatalf("%s --%s property source = %#v, want reviewed_mapping_exclusion", path, flagName, propProv) + } + } + } +} + +func TestEmbeddedCatalogMarkdownFetchOmitsHiddenIDAlias(t *testing.T) { + // markdown fetch keeps a hidden --id Cobra compat alias, but Schema must + // publish only --node (merge-base / 87910880 catalog parity). + leaf, err := embeddedSchemaPayload([]string{"markdown fetch"}) + if err != nil { + t.Fatal(err) + } + parameters := schemaMap(leaf["parameters"]) + if _, ok := parameters["id"]; ok { + t.Fatal("markdown fetch must not publish hidden --id in Schema parameters") + } + node := parameters["node"] + if node["property"] != "nodeId" || node["required"] != true { + t.Fatalf("markdown fetch --node = %#v, want property=nodeId required=true", node) + } + prov := schemaMap(node["field_provenance"])["property"] + if src, _ := prov["source"].(string); src != "native_annotation" { + t.Fatalf("markdown fetch --node property source = %#v, want native_annotation", prov) + } +} + +func TestEmbeddedCatalogMarkdownPatchContentMapsToReplacement(t *testing.T) { + // --content is the CLI flag; MCP/workflow property is replacement. + leaf, err := embeddedSchemaPayload([]string{"markdown patch"}) + if err != nil { + t.Fatal(err) + } + content := schemaMap(leaf["parameters"])["content"] + if content["property"] != "replacement" || content["required"] != true { + t.Fatalf("markdown patch --content = %#v, want property=replacement required=true", content) + } + prov := schemaMap(content["field_provenance"])["property"] + if src, _ := prov["source"].(string); src != "native_annotation" { + t.Fatalf("markdown patch --content property source = %#v, want native_annotation", prov) + } +} + +func TestEmbeddedCatalogMinutesReplaceBatchPairDescriptionIsNative(t *testing.T) { + // Former metadata overlay on minutes +replace-batch --pair description. + leaf, err := embeddedSchemaPayload([]string{"minutes +replace-batch"}) + if err != nil { + t.Fatal(err) + } + pair := schemaMap(leaf["parameters"])["pair"] + desc, _ := pair["description"].(string) + if desc == "" || !strings.Contains(desc, "原文=>替换") { + t.Fatalf("minutes +replace-batch --pair description = %q, want reviewed pair-format prose", desc) + } + prov := schemaMap(pair["field_provenance"])["description"] + if src, _ := prov["source"].(string); src != "native_annotation" { + t.Fatalf("minutes +replace-batch --pair description source = %#v, want native_annotation", prov) + } +} + +func TestEmbeddedCatalogTodoParamDeclsFrom87910880Hints(t *testing.T) { + // Spot-check former schema_hints/metadata/todo.json overlays (87910880). + // --query-all is a Cobra Bool: merge-base catalog publishes type=boolean from + // cobra_flag_type (no separate interface_type field), while required/description + // stay native_annotation after ParamDecl migration. + listLeaf, err := embeddedSchemaPayload([]string{"todo task list"}) + if err != nil { + t.Fatal(err) + } + listParams := schemaMap(listLeaf["parameters"]) + queryAll := listParams["query-all"] + if queryAll["required"] != false || queryAll["type"] != "boolean" { + t.Fatalf("todo task list --query-all = %#v, want required=false type=boolean", queryAll) + } + if desc, _ := queryAll["description"].(string); !strings.Contains(desc, "跨组织") { + t.Fatalf("todo task list --query-all description = %q, want cross-org prose", desc) + } + for _, field := range []string{"required", "description"} { + prov := schemaMap(queryAll["field_provenance"])[field] + if src, _ := prov["source"].(string); src != "native_annotation" { + t.Fatalf("todo task list --query-all %s source = %#v, want native_annotation", field, prov) + } + } + typeProv := schemaMap(queryAll["field_provenance"])["type"] + if src, _ := typeProv["source"].(string); src != "cobra_flag_type" { + t.Fatalf("todo task list --query-all type source = %#v, want cobra_flag_type", typeProv) + } + roleTypes := listParams["role-types"] + if roleTypes["property"] != "roleTypes" || roleTypes["required"] != false { + t.Fatalf("todo task list --role-types = %#v, want property=roleTypes required=false", roleTypes) + } + rolePropProv := schemaMap(roleTypes["field_provenance"])["property"] + if src, _ := rolePropProv["source"].(string); src != "native_annotation" { + t.Fatalf("todo task list --role-types property source = %#v, want native_annotation", rolePropProv) + } + if desc, _ := roleTypes["description"].(string); !strings.Contains(desc, "executor") { + t.Fatalf("todo task list --role-types description = %q, want executor default prose", desc) + } + roleDescProv := schemaMap(roleTypes["field_provenance"])["description"] + if src, _ := roleDescProv["source"].(string); src != "native_annotation" { + t.Fatalf("todo task list --role-types description source = %#v, want native_annotation", roleDescProv) + } + + resetLeaf, err := embeddedSchemaPayload([]string{"todo task reset-reminder"}) + if err != nil { + t.Fatal(err) + } + rules := schemaMap(resetLeaf["parameters"])["reminder-rules"] + desc, _ := rules["description"].(string) + if !strings.Contains(desc, "不传表示清除") { + t.Fatalf("todo task reset-reminder --reminder-rules description = %q", desc) + } + rulesDescProv := schemaMap(rules["field_provenance"])["description"] + if src, _ := rulesDescProv["source"].(string); src != "native_annotation" { + t.Fatalf("todo task reset-reminder --reminder-rules description source = %#v, want native_annotation", rulesDescProv) + } + + createTag, err := embeddedSchemaPayload([]string{"todo tag create"}) + if err != nil { + t.Fatal(err) + } + name := schemaMap(createTag["parameters"])["name"] + if name["property"] != "name" || name["required"] != true { + t.Fatalf("todo tag create --name = %#v, want property=name required=true", name) + } + + deleteTag, err := embeddedSchemaPayload([]string{"todo tag delete"}) + if err != nil { + t.Fatal(err) + } + deleteCodes := schemaMap(deleteTag["parameters"])["tag-codes"] + if deleteCodes["property"] != "tagCodes" || deleteCodes["interface_type"] != "array" || deleteCodes["required"] != true { + t.Fatalf("todo tag delete --tag-codes = %#v", deleteCodes) + } + + addTag, err := embeddedSchemaPayload([]string{"todo tag add"}) + if err != nil { + t.Fatal(err) + } + addParams := schemaMap(addTag["parameters"]) + if addParams["task-id"]["property"] != "taskId" || addParams["task-id"]["required"] != true { + t.Fatalf("todo tag add --task-id = %#v", addParams["task-id"]) + } + if addParams["tag-codes"]["property"] != "tagCodes" || addParams["tag-codes"]["interface_type"] != "array" || addParams["tag-codes"]["required"] != true { + t.Fatalf("todo tag add --tag-codes = %#v", addParams["tag-codes"]) + } + + updateTag, err := embeddedSchemaPayload([]string{"todo tag update"}) + if err != nil { + t.Fatal(err) + } + userTags := schemaMap(updateTag["parameters"])["user-tags"] + if userTags["property"] != "userTags" || userTags["interface_type"] != "array" || userTags["required"] != true { + t.Fatalf("todo tag update --user-tags = %#v", userTags) + } + for _, tc := range []struct { + path string + flag string + field string + }{ + {"todo tag create", "name", "property"}, + {"todo tag delete", "tag-codes", "interface_type"}, + {"todo tag add", "task-id", "property"}, + {"todo tag update", "user-tags", "property"}, + } { + leaf, err := embeddedSchemaPayload([]string{tc.path}) + if err != nil { + t.Fatalf("%s: %v", tc.path, err) + } + prov := schemaMap(schemaMap(leaf["parameters"])[tc.flag]["field_provenance"])[tc.field] + if src, _ := prov["source"].(string); src != "native_annotation" { + t.Fatalf("%s --%s %s source = %#v, want native_annotation", tc.path, tc.flag, tc.field, prov) + } + } +} + +func TestEmbeddedCatalogSheetParamDeclsFrom87910880Hints(t *testing.T) { + // Former schema_hints/metadata/sheet.json overlays (87910880). + for _, tc := range []struct { + path string + flag string + want string + }{ + {"sheet pivot-table create", "properties", "object"}, + {"sheet pivot-table update", "properties", "object"}, + {"sheet table-put", "sheets", "array"}, + } { + leaf, err := embeddedSchemaPayload([]string{tc.path}) + if err != nil { + t.Fatalf("%s: %v", tc.path, err) + } + param := schemaMap(leaf["parameters"])[tc.flag] + if param["interface_type"] != tc.want { + t.Fatalf("%s --%s interface_type = %#v, want %q", tc.path, tc.flag, param["interface_type"], tc.want) + } + prov := schemaMap(param["field_provenance"])["interface_type"] + if src, _ := prov["source"].(string); src != "native_annotation" { + t.Fatalf("%s --%s interface_type source = %#v, want native_annotation", tc.path, tc.flag, prov) + } + } +} + +func TestEmbeddedCatalogReportCreateToChatRequiredAndAliasView(t *testing.T) { + // Former report.create_report overlay: --to-chat required=false (MCP marks + // it required). Alias "report create" must keep the same contract. + primary, err := embeddedSchemaPayload([]string{"report entry submit"}) + if err != nil { + t.Fatal(err) + } + if schemaString(primary["canonical_path"]) != "report.create_report" { + t.Fatalf("report entry submit canonical = %q", schemaString(primary["canonical_path"])) + } + toChat := schemaMap(primary["parameters"])["to-chat"] + if toChat["required"] != false { + t.Fatalf("report entry submit --to-chat required = %#v, want false", toChat["required"]) + } + reqProv := schemaMap(toChat["field_provenance"])["required"] + if src, _ := reqProv["source"].(string); src != "native_annotation" { + t.Fatalf("report entry submit --to-chat required source = %#v, want native_annotation", reqProv) + } + + alias, err := embeddedSchemaPayload([]string{"report create"}) + if err != nil { + t.Fatal(err) + } + if alias["is_alias"] != true || schemaString(alias["cli_path"]) != "report create" { + t.Fatalf("report create alias view = %#v", alias) + } + if schemaString(alias["canonical_path"]) != "report.create_report" { + t.Fatalf("report create canonical = %q", schemaString(alias["canonical_path"])) + } + aliasToChat := schemaMap(alias["parameters"])["to-chat"] + if aliasToChat["required"] != false { + t.Fatalf("report create --to-chat required = %#v, want false (same as primary)", aliasToChat["required"]) + } + aliasReqProv := schemaMap(aliasToChat["field_provenance"])["required"] + if src, _ := aliasReqProv["source"].(string); src != "native_annotation" { + t.Fatalf("report create --to-chat required source = %#v, want native_annotation", aliasReqProv) + } +} + +func TestEmbeddedCatalogAitableParamDeclsMatchMergeBaseContract(t *testing.T) { + // Former schema_hints overlays (87910880) must stay on helpers leaves via + // ParamDecl after hint clearance — not on aitable +shortcuts. + queryLeaf, err := embeddedSchemaPayload([]string{"aitable record query"}) + if err != nil { + t.Fatal(err) + } + queryRecordIDs := schemaMap(queryLeaf["parameters"])["record-ids"] + if queryRecordIDs["required"] != false { + t.Fatalf("aitable record query --record-ids required = %#v, want false", queryRecordIDs["required"]) + } + queryReqProv := schemaMap(queryRecordIDs["field_provenance"])["required"] + if src, _ := queryReqProv["source"].(string); src != "native_annotation" { + t.Fatalf("aitable record query --record-ids required source = %#v, want native_annotation", queryReqProv) + } + + // Sibling atomic get keeps Cobra-required --record-ids; shortcut has its + // own optional StringSlice surface and must not inherit helpers ParamDecl. + getLeaf, err := embeddedSchemaPayload([]string{"aitable record get"}) + if err != nil { + t.Fatal(err) + } + if schemaMap(getLeaf["parameters"])["record-ids"]["required"] != true { + t.Fatalf("aitable record get --record-ids must stay required") + } + shortcutLeaf, err := embeddedSchemaPayload([]string{"aitable +record-query"}) + if err != nil { + t.Fatal(err) + } + shortcutRecordIDs := schemaMap(shortcutLeaf["parameters"])["record-ids"] + if shortcutRecordIDs["required"] != false { + t.Fatalf("aitable +record-query --record-ids required = %#v, want false", shortcutRecordIDs["required"]) + } + if shortcutRecordIDs["type"] != "array" { + t.Fatalf("aitable +record-query --record-ids type = %#v, want array (shortcut StringSlice)", shortcutRecordIDs["type"]) + } + + for _, path := range []string{ + "aitable view update aggregate", + "aitable view update card", + "aitable view update field-widths", + "aitable view update timebar", + } { + leaf, err := embeddedSchemaPayload([]string{path}) + if err != nil { + t.Fatal(err) + } + jsonParam := schemaMap(leaf["parameters"])["json"] + if jsonParam["required"] != false { + t.Fatalf("%s --json required = %#v, want false", path, jsonParam["required"]) + } + reqProv := schemaMap(jsonParam["field_provenance"])["required"] + src, _ := reqProv["source"].(string) + if src != "native_annotation" && src != "require_one_of_constraint" { + t.Fatalf("%s --json required source = %#v, want native_annotation or require_one_of_constraint", path, reqProv) + } + } + + for _, tc := range []struct { + path string + flag string + prop string + }{ + {"aitable workflow create", "base-id", "baseId"}, + {"aitable workflow create", "dsl", "dsl"}, + {"aitable workflow create", "locale", "locale"}, + {"aitable workflow update", "base-id", "baseId"}, + {"aitable workflow update", "workflow-id", "workflowId"}, + {"aitable workflow update", "dsl", "dsl"}, + {"aitable workflow update", "locale", "locale"}, + } { + leaf, err := embeddedSchemaPayload([]string{tc.path}) + if err != nil { + t.Fatal(err) + } + param := schemaMap(leaf["parameters"])[tc.flag] + if param["property"] != tc.prop { + t.Fatalf("%s --%s property = %#v, want %s", tc.path, tc.flag, param["property"], tc.prop) + } + propProv := schemaMap(param["field_provenance"])["property"] + if src, _ := propProv["source"].(string); src != "native_annotation" { + t.Fatalf("%s --%s property source = %#v, want native_annotation", tc.path, tc.flag, propProv) + } + } + + createLeaf, err := embeddedSchemaPayload([]string{"aitable workflow create"}) + if err != nil { + t.Fatal(err) + } + createParams := schemaMap(createLeaf["parameters"]) + for _, flagName := range []string{"base-id", "dsl"} { + if createParams[flagName]["required"] != true { + t.Fatalf("aitable workflow create --%s required = %#v, want true", flagName, createParams[flagName]["required"]) + } + } + if createParams["locale"]["required"] != false { + t.Fatalf("aitable workflow create --locale required = %#v, want false", createParams["locale"]["required"]) + } + dslTypeProv := schemaMap(createParams["dsl"]["field_provenance"])["interface_type"] + if src, _ := dslTypeProv["source"].(string); src != "native_annotation" { + t.Fatalf("aitable workflow create --dsl interface_type source = %#v, want native_annotation", dslTypeProv) + } +} + +func TestEmbeddedCatalogAitableQueryKeywordAndHintParamDecls(t *testing.T) { + // helpers record query: --query → keyword must not fall back to + // flag_name_inference; JSON list flags keep wire interface_type via ParamDecl + // (outranking RegisterSchemaHints tool_schema_hint). + queryLeaf, err := embeddedSchemaPayload([]string{"aitable record query"}) + if err != nil { + t.Fatal(err) + } + queryParams := schemaMap(queryLeaf["parameters"]) + queryFlag := queryParams["query"] + if queryFlag["property"] != "keyword" { + t.Fatalf("aitable record query --query property = %#v, want keyword", queryFlag["property"]) + } + queryPropProv := schemaMap(queryFlag["field_provenance"])["property"] + src, _ := queryPropProv["source"].(string) + if src != "native_annotation" && src != "versioned_parameter_binding" { + t.Fatalf("aitable record query --query property source = %#v, want native_annotation or versioned_parameter_binding", queryPropProv) + } + for flagName, wantType := range map[string]string{ + "filters": "object", + "sort": "array", + "record-ids": "array", + "field-ids": "array", + } { + param := queryParams[flagName] + if param["interface_type"] != wantType { + t.Fatalf("aitable record query --%s interface_type = %#v, want %s", flagName, param["interface_type"], wantType) + } + typeProv := schemaMap(param["field_provenance"])["interface_type"] + if got, _ := typeProv["source"].(string); got != "native_annotation" { + t.Fatalf("aitable record query --%s interface_type source = %#v, want native_annotation", flagName, typeProv) + } + } + + // shortcut +record-query Execute maps --query → keyword; without ParamDecl + // composite leaves published property=query via flag_name_inference. + shortcutLeaf, err := embeddedSchemaPayload([]string{"aitable +record-query"}) + if err != nil { + t.Fatal(err) + } + shortcutQuery := schemaMap(shortcutLeaf["parameters"])["query"] + if shortcutQuery["property"] != "keyword" { + t.Fatalf("aitable +record-query --query property = %#v, want keyword", shortcutQuery["property"]) + } + shortcutPropProv := schemaMap(shortcutQuery["field_provenance"])["property"] + if got, _ := shortcutPropProv["source"].(string); got != "native_annotation" { + t.Fatalf("aitable +record-query --query property source = %#v, want native_annotation", shortcutPropProv) + } + + // Former RegisterSchemaHints required pins that match runtime validation. + for _, tc := range []struct { + path string + flag string + }{ + {"aitable attachment upload", "size"}, + {"aitable chart update", "config"}, + {"aitable record upsert", "records"}, + } { + leaf, err := embeddedSchemaPayload([]string{tc.path}) + if err != nil { + t.Fatal(err) + } + param := schemaMap(leaf["parameters"])[tc.flag] + if param["required"] != true { + t.Fatalf("%s --%s required = %#v, want true", tc.path, tc.flag, param["required"]) + } + reqProv := schemaMap(param["field_provenance"])["required"] + if got, _ := reqProv["source"].(string); got != "native_annotation" { + t.Fatalf("%s --%s required source = %#v, want native_annotation", tc.path, tc.flag, reqProv) + } + } +} + +func TestEmbeddedCatalogSmartQueryMapsToKeyword(t *testing.T) { + // smart shortcuts whose Execute maps --query → MCP keyword need an explicit + // ParamDecl; without it composite leaves publish property=query via + // flag_name_inference. +minutes-search is not Schema-published yet, so it + // is covered by the declaration check in package smart. + for _, path := range []string{ + "aitable +find-record", + "doc +find-doc", + "drive +find-file", + "mail +find-mail-user", + "chat +search-msg", + } { + leaf, err := embeddedSchemaPayload([]string{path}) + if err != nil { + t.Fatalf("%s: %v", path, err) + } + query := schemaMap(leaf["parameters"])["query"] + if query["property"] != "keyword" { + t.Fatalf("%s --query property = %#v, want keyword", path, query["property"]) + } + propProv := schemaMap(query["field_provenance"])["property"] + if got, _ := propProv["source"].(string); got != "native_annotation" { + t.Fatalf("%s --query property source = %#v, want native_annotation", path, propProv) + } + } +} + +func TestEmbeddedCatalogShortcutQueryMapsToKeyword(t *testing.T) { + // Non-smart shortcuts whose Execute maps --query → MCP keyword must publish + // property=keyword via ParamDecl; without it, composite leaves fall back to + // flag_name_inference (property=query). + for _, path := range []string{ + "doc +search", + "drive +search", + "drive +search-docs", + "minutes +list-mine", + "minutes +list-shared", + "minutes +list-all", + "wiki +space-search", + "contact +search-user", + "chat +chat-search", + "chat +bot-find", + } { + leaf, err := embeddedSchemaPayload([]string{path}) + if err != nil { + t.Fatalf("%s: %v", path, err) + } + query := schemaMap(leaf["parameters"])["query"] + if query["property"] != "keyword" { + t.Fatalf("%s --query property = %#v, want keyword", path, query["property"]) + } + propProv := schemaMap(query["field_provenance"])["property"] + if got, _ := propProv["source"].(string); got != "native_annotation" { + t.Fatalf("%s --query property source = %#v, want native_annotation", path, propProv) + } + } +} + +func TestEmbeddedCatalogContactParamDeclsMatchMergeBaseContract(t *testing.T) { + // Former schema_hints/metadata/contact.json overlays at 87910880 must stay + // on the published primary flags after ParamDecl migration. Hidden Cobra + // aliases (--id/--userid/--dept-name/--super-dept*) must not appear. + type wantParam struct { + property string + required bool + interfaceType string // empty → do not assert + } + cases := []struct { + path string + params map[string]wantParam + absent []string + }{ + { + path: "contact user invite", + params: map[string]wantParam{ + "org-user-name": {property: "orgUserName", required: true}, + "org-user-mobile": {property: "orgUserMobile", required: true}, + "depts": {property: "depts", required: false, interfaceType: "array"}, + }, + }, + { + path: "contact user update", + params: map[string]wantParam{ + "user-id": {property: "userId", required: true}, + "org-user-name": {property: "orgUserName", required: false}, + "depts": {property: "depts", required: false, interfaceType: "array"}, + "master-user-id": {property: "masterUserId", required: false}, + }, + absent: []string{"id", "userid"}, + }, + { + path: "contact user update-self", + params: map[string]wantParam{ + "nick": {property: "nick", required: false}, + "avatar-file-id": {property: "avatarFileId", required: false}, + }, + }, + { + path: "contact user profile get", + params: map[string]wantParam{ + "fields": {property: "fieldCodeList", required: false}, + "staff-id": {property: "staffId", required: false}, + }, + }, + { + path: "contact dept create", + params: map[string]wantParam{ + "name": {property: "deptName", required: true}, + "parent": {property: "superDeptId", required: false, interfaceType: "integer"}, + "create-dept-group": {property: "createDeptGroup", required: true}, + }, + absent: []string{"dept-name", "super-dept", "super-dept-id"}, + }, + { + path: "contact dept update", + params: map[string]wantParam{ + "dept": {property: "deptId", required: true, interfaceType: "integer"}, + "name": {property: "deptName", required: true}, + "parent": {property: "superDeptId", required: false, interfaceType: "integer"}, + }, + absent: []string{"id", "ids", "dept-id", "dept-ids", "dept-name", "super-dept", "super-dept-id"}, + }, + { + path: "contact org create", + params: map[string]wantParam{ + "org-name": {property: "orgName", required: true}, + "creator-username": {property: "creatorUsername", required: true}, + }, + }, + { + path: "contact account create", + params: map[string]wantParam{ + "org-user-name": {property: "orgUserName", required: true}, + "login-id": {property: "loginId", required: true}, + "org-user-mobile": {property: "orgUserMobile", required: false}, + "email": {property: "email", required: false}, + "dept-ids": {property: "deptIds", required: false, interfaceType: "array"}, + "send-pwd-via-sms": {property: "sendPwdViaSms", required: false}, + }, + }, + { + path: "contact account update", + params: map[string]wantParam{ + "user-id": {property: "userId", required: true}, + "org-user-name": {property: "orgUserName", required: false}, + "depts": {property: "depts", required: false, interfaceType: "array"}, + "master-user-id": {property: "masterUserId", required: false}, + "nick": {property: "nick", required: false}, + "avatar-file-id": {property: "avatarFileId", required: false}, + }, + absent: []string{"id", "userid"}, + }, + } + + for _, tc := range cases { + leaf, err := embeddedSchemaPayload([]string{tc.path}) + if err != nil { + t.Fatal(err) + } + parameters := schemaMap(leaf["parameters"]) + for flagName, want := range tc.params { + param := parameters[flagName] + if param["property"] != want.property || param["required"] != want.required { + t.Fatalf("%s --%s = %#v, want property=%s required=%v", + tc.path, flagName, param, want.property, want.required) + } + if want.interfaceType != "" && param["interface_type"] != want.interfaceType { + t.Fatalf("%s --%s interface_type = %#v, want %s", + tc.path, flagName, param["interface_type"], want.interfaceType) + } + propProv := schemaMap(param["field_provenance"])["property"] + if src, _ := propProv["source"].(string); src != "native_annotation" && + src != "versioned_parameter_binding" && src != "flag_name_inference" { + t.Fatalf("%s --%s property source = %#v, want native_annotation/binding/inference", + tc.path, flagName, propProv) + } + reqProv := schemaMap(param["field_provenance"])["required"] + if src, _ := reqProv["source"].(string); src != "native_annotation" && src != "require_one_of_constraint" { + t.Fatalf("%s --%s required source = %#v, want native_annotation or require_one_of_constraint", + tc.path, flagName, reqProv) + } + if want.interfaceType != "" { + prov := schemaMap(param["field_provenance"])["interface_type"] + if src, _ := prov["source"].(string); src != "native_annotation" && src != "mcp_metadata" { + t.Fatalf("%s --%s interface_type source = %#v", tc.path, flagName, prov) + } + } + } + for _, flagName := range tc.absent { + if _, ok := parameters[flagName]; ok { + t.Fatalf("%s unexpectedly publishes hidden alias --%s", tc.path, flagName) + } + } + } +} + +func TestEmbeddedCatalogChatParamDeclsFrom87910880Hints(t *testing.T) { + // CI P1: +messages-send must not publish RequiredWhen for bot/webhook + // credentials. Runtime validateMessagesSend already enforces them; + // re-adding Flag.RequiredWhen breaks merge-base schema-compatibility. + sendLeaf, err := embeddedSchemaPayload([]string{"chat +messages-send"}) + if err != nil { + t.Fatal(err) + } + sendParams := schemaMap(sendLeaf["parameters"]) + for _, flagName := range []string{"robot-code", "webhook-token"} { + param := sendParams[flagName] + if param == nil { + t.Fatalf("chat +messages-send missing --%s", flagName) + } + if rw := param["required_when"]; rw != nil && rw != "" { + t.Fatalf("chat +messages-send --%s required_when = %#v, want absent/empty (no Schema gate)", flagName, rw) + } + rwProv := schemaMap(param["field_provenance"])["required_when"] + if src, _ := rwProv["source"].(string); src != "default" && src != "" { + t.Fatalf("chat +messages-send --%s required_when source = %#v, want default", flagName, rwProv) + } + } + + // Former schema_hints/metadata/chat.json overlays (87910880) must stay on + // helpers/shortcut leaves via ParamDecl after hint clearance. + for _, tc := range []struct { + path string + flag string + property string + required any + interfaceType string + }{ + {"chat message edit", "conversation-id", "openConversationId", true, ""}, + {"chat message edit", "msg-id", "openMessageId", true, ""}, + {"chat message edit", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"}, + {"chat message update-text-emotion", "msg-id", "openMsgId", true, ""}, + {"chat message update-text-emotion", "old-emotion-id", "oldEmotionId", true, ""}, + {"chat category batch-info", "category-ids", "categoryIds", true, "array"}, + {"chat category list-by-conv", "group", "openConversationId", true, ""}, + {"chat group update-nick", "group", "openConversationId", true, ""}, + {"chat group upgrade-to-external", "extension", "extension", false, "object"}, + {"chat +messages-send-card", "receiver-open-dingtalk-id", "receiverOpenDingTalkId", false, ""}, + {"chat message list-favorites", "size", "", false, "string"}, + } { + leaf, err := embeddedSchemaPayload([]string{tc.path}) + if err != nil { + t.Fatal(err) + } + param := schemaMap(leaf["parameters"])[tc.flag] + if tc.property != "" && param["property"] != tc.property { + t.Fatalf("%s --%s property = %#v, want %s", tc.path, tc.flag, param["property"], tc.property) + } + if tc.required != nil && param["required"] != tc.required { + t.Fatalf("%s --%s required = %#v, want %#v", tc.path, tc.flag, param["required"], tc.required) + } + if tc.interfaceType != "" && param["interface_type"] != tc.interfaceType { + t.Fatalf("%s --%s interface_type = %#v, want %s", tc.path, tc.flag, param["interface_type"], tc.interfaceType) + } + if tc.property != "" { + propProv := schemaMap(param["field_provenance"])["property"] + src, _ := propProv["source"].(string) + if src != "native_annotation" && src != "versioned_parameter_binding" && src != "reviewed_mapping_exclusion" { + t.Fatalf("%s --%s property source = %#v, want native_annotation/binding/exclusion", tc.path, tc.flag, propProv) + } + } + if tc.interfaceType != "" { + typeProv := schemaMap(param["field_provenance"])["interface_type"] + if src, _ := typeProv["source"].(string); src != "native_annotation" { + t.Fatalf("%s --%s interface_type source = %#v, want native_annotation", tc.path, tc.flag, typeProv) + } + } + } + + // Hidden conversation aliases must stay unpublished (merge-base parity). + editLeaf, err := embeddedSchemaPayload([]string{"chat message edit"}) + if err != nil { + t.Fatal(err) + } + editParams := schemaMap(editLeaf["parameters"]) + for _, hidden := range []string{"group", "id", "chat"} { + if _, ok := editParams[hidden]; ok { + t.Fatalf("chat message edit unexpectedly publishes hidden alias --%s", hidden) + } + } + listByConv, err := embeddedSchemaPayload([]string{"chat category list-by-conv"}) + if err != nil { + t.Fatal(err) + } + listParams := schemaMap(listByConv["parameters"]) + for _, hidden := range []string{"conversation-id", "id"} { + if _, ok := listParams[hidden]; ok { + t.Fatalf("chat category list-by-conv unexpectedly publishes hidden alias --%s", hidden) + } + } +} diff --git a/internal/corecmd/schema_decl_test.go b/internal/corecmd/schema_decl_test.go index 1af81d8d..920a4f31 100644 --- a/internal/corecmd/schema_decl_test.go +++ b/internal/corecmd/schema_decl_test.go @@ -305,6 +305,7 @@ func TestCrossPlatformCoverageSchemaDeclEmptyReportsEveryAuthoredSection(t *test "title": {Title: "T"}, "description": {Description: "D"}, "positionals": {Positionals: []PositionalDecl{{Name: "id"}}}, + "parameters": {Parameters: []ParamDecl{{Name: "mode"}}}, "dry_run": {DryRun: &DryRunDecl{PreviewKind: "request"}}, "interface": {Interface: &InterfaceDecl{Mode: "mcp"}}, "selection": {Selection: SelectionDecl{Tips: []string{"tip"}}}, diff --git a/internal/helpers/aitable.go b/internal/helpers/aitable.go index b35adcd2..c5fdd932 100644 --- a/internal/helpers/aitable.go +++ b/internal/helpers/aitable.go @@ -1919,7 +1919,13 @@ newFieldName、config、aiConfig 至少传入一项。 Examples: []string{"dws aitable record query --base-id --table-id "}, }, Parameters: []corecmd.ParamDecl{ - {Name: "record-ids", Required: boolPtr(false)}, + // query→keyword is also in versioned bindings; keep a leaf-local + // ParamDecl so the mapping survives binding/hint churn. + {Name: "query", Property: "keyword"}, + {Name: "record-ids", Property: "recordIds", Required: boolPtr(false), InterfaceType: "array"}, + {Name: "field-ids", Property: "fieldIds", InterfaceType: "array"}, + {Name: "filters", Property: "filters", InterfaceType: "object"}, + {Name: "sort", Property: "sort", InterfaceType: "array"}, }, }, }) @@ -2424,6 +2430,9 @@ Windows 用户注意:如果 --records JSON 很长,请使用 --records-file AvoidWhen: []string{"纯新建用 create;纯更新用 update"}, Examples: []string{"dws aitable record upsert --base-id --table-id --records '[{\"cells\":{\"fldXXX\":\"值\"}}]'"}, }, + Parameters: []corecmd.ParamDecl{ + {Name: "records", Required: boolPtr(true), InterfaceType: "array"}, + }, }, }) @@ -2607,6 +2616,11 @@ fieldId 必须是 primaryDoc 类型的字段。`, AvoidWhen: []string{"禁止改用 drive upload;电子表格附件用 sheet media-upload"}, Examples: []string{"dws aitable attachment upload --base-id BASE_ID --file-name report.xlsx --size 204800"}, }, + // size is validated in RunE (Int64); publish required via ParamDecl + // instead of relying on RegisterSchemaHints tool_schema_hint. + Parameters: []corecmd.ParamDecl{ + {Name: "size", Required: boolPtr(true)}, + }, }, }) @@ -4954,6 +4968,9 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`, "dws aitable chart update --base-id BASE_ID --dashboard-id DASHBOARD_ID --chart-id CHART_ID --layout '{\"x\":0,\"y\":4,\"w\":12,\"h\":4}'", }, }, + Parameters: []corecmd.ParamDecl{ + {Name: "config", Required: boolPtr(true), InterfaceType: "object"}, + }, }, }) diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go index 8cbfe63a..1f1361e7 100644 --- a/internal/helpers/doc.go +++ b/internal/helpers/doc.go @@ -1978,9 +1978,9 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。 }, Examples: []string{"dws doc rename --node --name \"新名称\" --format json"}, }, - Parameters: []corecmd.ParamDecl{ - {Name: "name", Description: "新显示名称;实际执行前读取节点类型与当前扩展名,仅对非文件夹且末尾后缀与当前扩展名一致的名称去掉一层,避免双扩展名"}, - }, + // No ParamDecl for --name here: the extension-stripping description + // belongs to drive rename (shared RPC rename_document). doc rename + // keeps the Cobra usage ("原样传给服务端"). }, }) diff --git a/internal/helpers/drive.go b/internal/helpers/drive.go index 7bb22666..92851833 100644 --- a/internal/helpers/drive.go +++ b/internal/helpers/drive.go @@ -976,6 +976,9 @@ func newDriveCommand() *cobra.Command { "dws drive upload --file ./README.md --node --format json", }, }, + // Composite multi-step leaf (get_upload_info → PUT → commit_upload): + // no single RPCName / interface_ref. Keep --node→nodeId on this leaf + // only; do not hang it on upload-info or commit. Parameters: []corecmd.ParamDecl{ {Name: "node", Property: "nodeId", Required: boolPtr(false)}, }, @@ -1451,6 +1454,11 @@ func newDriveCommand() *cobra.Command { }, Examples: []string{"dws drive rename --node --name \"新名称\" --format json"}, }, + // Shared RPC rename_document with doc rename; keep this description + // on the drive leaf only (doc rename does not strip extensions). + Parameters: []corecmd.ParamDecl{ + {Name: "name", Description: "新显示名称;实际执行前读取节点类型与当前扩展名,仅对非文件夹且末尾后缀与当前扩展名一致的名称去掉一层,避免双扩展名"}, + }, }, }) driveRenameCmd.Flags().String("node", "", "文档/文件 ID 或 URL (必填)") diff --git a/internal/helpers/mail.go b/internal/helpers/mail.go index c7c84d14..6d1f1f03 100644 --- a/internal/helpers/mail.go +++ b/internal/helpers/mail.go @@ -327,6 +327,12 @@ func newMailCommand() *cobra.Command { "dws mail message list --email user@company.com --folder-id 1 --limit 50", }, }, + // Composite wrapper maps --limit onto search_emails.size (same RPC as + // mail message search). Declare here so clearing hints does not fall + // back to flag_name_inference → "limit". + Parameters: []corecmd.ParamDecl{ + {Name: "limit", Property: "size"}, + }, }, }) diff --git a/internal/helpers/markdown.go b/internal/helpers/markdown.go index 7963ec1b..7892e1c6 100644 --- a/internal/helpers/markdown.go +++ b/internal/helpers/markdown.go @@ -90,7 +90,9 @@ func newMarkdownFetchCmd() *cobra.Command { Examples: []string{"dws markdown fetch --node "}, }, Parameters: []corecmd.ParamDecl{ - {Name: "id", Property: "nodeId", Required: boolPtr(false)}, + // --id remains a hidden Cobra compat alias (flagOrFallback), but + // runtime schema skips Hidden flags — do not ParamDecl it or it + // suggests a published Schema surface that 87910880 never had. {Name: "node", Property: "nodeId", Required: boolPtr(true)}, {Name: "output", Property: "output", Required: boolPtr(false)}, {Name: "space-id", Property: "spaceId", Required: boolPtr(false)}, diff --git a/internal/helpers/report.go b/internal/helpers/report.go index ada6f6f7..807b3d0a 100644 --- a/internal/helpers/report.go +++ b/internal/helpers/report.go @@ -355,6 +355,13 @@ func newReportCommand() *cobra.Command { RunE: withReportDeprecationWarning("create", "entry submit", runReportCreate), } addReportCreateFlags(createCmd) + // Mirror entry submit's reviewed --to-chat ParamDecl onto the deprecated + // create alias. Schema assembly still reads ContractFinal from the primary + // leaf; this keeps compatibility-equivalence NativeRequired annotations + // symmetric instead of relying only on one-sided tolerance. + cli.ApplyParamDecls(createCmd, []cli.ParamDecl{ + {Name: "to-chat", Required: boolPtr(false)}, + }) detailCmd := &cobra.Command{ Use: "detail", diff --git a/internal/shortcut/aitable/aitable.go b/internal/shortcut/aitable/aitable.go index b98ba8ef..e188fae4 100644 --- a/internal/shortcut/aitable/aitable.go +++ b/internal/shortcut/aitable/aitable.go @@ -598,6 +598,15 @@ var RecordQuery = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws aitable +record-query --base-id B --table-id T --query \"关键词\" --limit 50"}, }, + // Execute maps --query → MCP keyword (not inferable); JSON flags keep + // wire types so Agents do not treat comma-strings as opaque text. + Parameters: []corecmd.ParamDecl{ + {Name: "query", Property: "keyword"}, + {Name: "filters", InterfaceType: "object"}, + {Name: "sort", InterfaceType: "array"}, + {Name: "record-ids", InterfaceType: "array"}, + {Name: "field-ids", InterfaceType: "array"}, + }, }, Flags: []shortcut.Flag{ {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, diff --git a/internal/shortcut/aitable/query_keyword_paramdecl_test.go b/internal/shortcut/aitable/query_keyword_paramdecl_test.go new file mode 100644 index 00000000..6dc446ef --- /dev/null +++ b/internal/shortcut/aitable/query_keyword_paramdecl_test.go @@ -0,0 +1,40 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package aitable + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageQueryToKeywordParamDecls(t *testing.T) { + // Force package-level Schema Parameter blocks into the coverage graph and + // pin Execute's --query → MCP keyword mapping for Schema publication. + assertQueryKeywordParamDecl(t, "+record-query", RecordQuery) + spec := shortcut.FromShortcut(RecordQuery) + if len(spec.Schema.Parameters) == 0 { + t.Fatal("+record-query Schema.Parameters empty after FromShortcut") + } +} + +func assertQueryKeywordParamDecl(t *testing.T, name string, sc shortcut.Shortcut) { + t.Helper() + for _, p := range sc.Schema.Parameters { + if p.Name == "query" && p.Property == "keyword" { + return + } + } + t.Fatalf("%s missing ParamDecl query→keyword; got %#v", name, sc.Schema.Parameters) +} diff --git a/internal/shortcut/chat/chat_bot.go b/internal/shortcut/chat/chat_bot.go index 581ef6dd..b3764bb3 100644 --- a/internal/shortcut/chat/chat_bot.go +++ b/internal/shortcut/chat/chat_bot.go @@ -154,6 +154,10 @@ var BotFind = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws chat +bot-find --query \"日报\""}, }, + // Execute maps --query → MCP keyword (not inferable from flag name). + Parameters: []corecmd.ParamDecl{ + {Name: "query", Property: "keyword"}, + }, }, Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词"}, diff --git a/internal/shortcut/chat/chat_group.go b/internal/shortcut/chat/chat_group.go index 37e8915e..246d04da 100644 --- a/internal/shortcut/chat/chat_group.go +++ b/internal/shortcut/chat/chat_group.go @@ -48,6 +48,10 @@ var ChatSearch = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws chat +chat-search --query \"项目冲刺\""}, }, + // Execute maps --query → MCP keyword (not inferable from flag name). + Parameters: []corecmd.ParamDecl{ + {Name: "query", Property: "keyword"}, + }, }, Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "群名称关键词"}, diff --git a/internal/shortcut/chat/query_keyword_paramdecl_test.go b/internal/shortcut/chat/query_keyword_paramdecl_test.go new file mode 100644 index 00000000..bb010d46 --- /dev/null +++ b/internal/shortcut/chat/query_keyword_paramdecl_test.go @@ -0,0 +1,45 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package chat + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageQueryToKeywordParamDecls(t *testing.T) { + for _, tc := range []struct { + name string + sc shortcut.Shortcut + }{ + {"+chat-search", ChatSearch}, + {"+bot-find", BotFind}, + } { + assertQueryKeywordParamDecl(t, tc.name, tc.sc) + if len(shortcut.FromShortcut(tc.sc).Schema.Parameters) == 0 { + t.Fatalf("%s Schema.Parameters empty after FromShortcut", tc.name) + } + } +} + +func assertQueryKeywordParamDecl(t *testing.T, name string, sc shortcut.Shortcut) { + t.Helper() + for _, p := range sc.Schema.Parameters { + if p.Name == "query" && p.Property == "keyword" { + return + } + } + t.Fatalf("%s missing ParamDecl query→keyword; got %#v", name, sc.Schema.Parameters) +} diff --git a/internal/shortcut/chat/resource_download.go b/internal/shortcut/chat/resource_download.go index 59e279db..280b69bd 100644 --- a/internal/shortcut/chat/resource_download.go +++ b/internal/shortcut/chat/resource_download.go @@ -204,16 +204,26 @@ func validateResourceDownloadOutputFlag(output, flagName string) error { if output == "" { return apperrors.NewValidation(flagName + " 不能为空") } - if filepath.IsAbs(output) { + // Reject OS-absolute paths and Unix-rooted forms ("/x") even on Windows, + // where filepath.IsAbs("/x") is false but the path is still not a cwd-relative output. + if filepath.IsAbs(output) || strings.HasPrefix(filepath.ToSlash(output), "/") { return apperrors.NewValidation(flagName + " 只接受工作目录内的相对路径") } - clean := filepath.Clean(output) - if clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) { + clean := filepath.Clean(filepath.FromSlash(output)) + if resourcePathEscapesBase(clean) { return apperrors.NewValidation(flagName + " 不允许使用 .. 逃逸工作目录") } return nil } +// resourcePathEscapesBase reports whether rel escapes its base directory. +// Rel values may use '/' or the OS separator (for example mocked filepath.Rel +// results), so normalize before comparing against "..". +func resourcePathEscapesBase(rel string) bool { + rel = filepath.Clean(filepath.FromSlash(strings.TrimSpace(rel))) + return rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) +} + func resourceDownloadInfo(data map[string]any) (string, map[string]string, error) { for { result, ok := data["result"].(map[string]any) @@ -364,8 +374,7 @@ func resolveResourceDownloadPath( return "", "", apperrors.NewInternal(fmt.Sprintf("解析输出目录失败: %v", err)) } parentRel, err := resourceRel(realBase, realParent) - if err != nil || parentRel == ".." || - strings.HasPrefix(parentRel, ".."+string(os.PathSeparator)) { + if err != nil || resourcePathEscapesBase(parentRel) { return "", "", apperrors.NewValidation("--output 解析后逃逸工作目录") } @@ -393,8 +402,7 @@ func resolveResourceDownloadPath( func ensureResourceDownloadParent(baseDir, parent string) error { relative, err := resourceRel(baseDir, parent) - if err != nil || relative == ".." || - strings.HasPrefix(relative, ".."+string(os.PathSeparator)) { + if err != nil || resourcePathEscapesBase(relative) { return apperrors.NewValidation("--output 解析后逃逸工作目录") } if relative == "." { diff --git a/internal/shortcut/chat/unified_send.go b/internal/shortcut/chat/unified_send.go index 32883a9c..6495ba22 100644 --- a/internal/shortcut/chat/unified_send.go +++ b/internal/shortcut/chat/unified_send.go @@ -61,8 +61,11 @@ var MessagesSend = shortcut.Shortcut{ {Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊接收者 openDingTalkId(user);受发送身份能力矩阵约束"}, {Name: "users", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 userId(bot);受发送身份能力矩阵约束"}, {Name: "open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 openDingTalkId(bot);受发送身份能力矩阵约束"}, - {Name: "robot-code", Type: shortcut.FlagString, RequiredWhen: "identity=bot", Desc: "机器人 Code(identity=bot 时使用);受发送身份能力矩阵约束"}, - {Name: "webhook-token", Type: shortcut.FlagString, RequiredWhen: "identity=webhook", Desc: "自定义机器人 Webhook token(identity=webhook 时使用);受发送身份能力矩阵约束"}, + // Keep required_when out of Schema: validateMessagesSend already enforces + // bot/webhook credentials, and publishing RequiredWhen breaks merge-base + // schema-compatibility (null → expression). + {Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code(identity=bot 时使用);受发送身份能力矩阵约束"}, + {Name: "webhook-token", Type: shortcut.FlagString, Desc: "自定义机器人 Webhook token(identity=webhook 时使用);受发送身份能力矩阵约束"}, {Name: "title", Type: shortcut.FlagString, Desc: "消息标题(不传则从正文生成)"}, {Name: "text", Type: shortcut.FlagString, Desc: "纯文本正文"}, {Name: "markdown", Type: shortcut.FlagString, Desc: "Markdown 正文"}, diff --git a/internal/shortcut/contact/contact.go b/internal/shortcut/contact/contact.go index 82d0b48c..fffd8944 100644 --- a/internal/shortcut/contact/contact.go +++ b/internal/shortcut/contact/contact.go @@ -122,6 +122,10 @@ var SearchUser = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws contact +search-user --query \"张三\""}, }, + // Execute maps --query → MCP keyword (not inferable from flag name). + Parameters: []corecmd.ParamDecl{ + {Name: "query", Property: "keyword"}, + }, }, Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词", Required: true}, diff --git a/internal/shortcut/contact/query_keyword_paramdecl_test.go b/internal/shortcut/contact/query_keyword_paramdecl_test.go new file mode 100644 index 00000000..97a06067 --- /dev/null +++ b/internal/shortcut/contact/query_keyword_paramdecl_test.go @@ -0,0 +1,37 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package contact + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageQueryToKeywordParamDecls(t *testing.T) { + assertQueryKeywordParamDecl(t, "+search-user", SearchUser) + if len(shortcut.FromShortcut(SearchUser).Schema.Parameters) == 0 { + t.Fatal("+search-user Schema.Parameters empty after FromShortcut") + } +} + +func assertQueryKeywordParamDecl(t *testing.T, name string, sc shortcut.Shortcut) { + t.Helper() + for _, p := range sc.Schema.Parameters { + if p.Name == "query" && p.Property == "keyword" { + return + } + } + t.Fatalf("%s missing ParamDecl query→keyword; got %#v", name, sc.Schema.Parameters) +} diff --git a/internal/shortcut/doc/doc.go b/internal/shortcut/doc/doc.go index 543896b0..7a933576 100644 --- a/internal/shortcut/doc/doc.go +++ b/internal/shortcut/doc/doc.go @@ -55,6 +55,10 @@ var Search = shortcut.Shortcut{ "dws doc +search --extensions pdf,docx", }, }, + // Execute maps --query → MCP keyword (not inferable from flag name). + Parameters: []corecmd.ParamDecl{ + {Name: "query", Property: "keyword"}, + }, }, Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词,不传返回最近访问的文档"}, diff --git a/internal/shortcut/doc/query_keyword_paramdecl_test.go b/internal/shortcut/doc/query_keyword_paramdecl_test.go new file mode 100644 index 00000000..24e5731b --- /dev/null +++ b/internal/shortcut/doc/query_keyword_paramdecl_test.go @@ -0,0 +1,37 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package doc + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageQueryToKeywordParamDecls(t *testing.T) { + assertQueryKeywordParamDecl(t, "+search", Search) + if len(shortcut.FromShortcut(Search).Schema.Parameters) == 0 { + t.Fatal("+search Schema.Parameters empty after FromShortcut") + } +} + +func assertQueryKeywordParamDecl(t *testing.T, name string, sc shortcut.Shortcut) { + t.Helper() + for _, p := range sc.Schema.Parameters { + if p.Name == "query" && p.Property == "keyword" { + return + } + } + t.Fatalf("%s missing ParamDecl query→keyword; got %#v", name, sc.Schema.Parameters) +} diff --git a/internal/shortcut/drive/drive.go b/internal/shortcut/drive/drive.go index 8d5bec0a..45a79496 100644 --- a/internal/shortcut/drive/drive.go +++ b/internal/shortcut/drive/drive.go @@ -226,6 +226,10 @@ var Search = shortcut.Shortcut{ "dws drive +search --query \"合同\" --target file --extensions pdf,docx", }, }, + // Execute maps --query → MCP keyword (not inferable from flag name). + Parameters: []corecmd.ParamDecl{ + {Name: "query", Property: "keyword"}, + }, }, Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词", Required: true}, @@ -374,6 +378,10 @@ var SearchDocs = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws drive +search-docs --query \"季度汇报\""}, }, + // Execute maps --query → MCP keyword (not inferable from flag name). + Parameters: []corecmd.ParamDecl{ + {Name: "query", Property: "keyword"}, + }, }, Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词", Required: true}, diff --git a/internal/shortcut/drive/query_keyword_paramdecl_test.go b/internal/shortcut/drive/query_keyword_paramdecl_test.go new file mode 100644 index 00000000..48c22240 --- /dev/null +++ b/internal/shortcut/drive/query_keyword_paramdecl_test.go @@ -0,0 +1,45 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package drive + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageQueryToKeywordParamDecls(t *testing.T) { + for _, tc := range []struct { + name string + sc shortcut.Shortcut + }{ + {"+search", Search}, + {"+search-docs", SearchDocs}, + } { + assertQueryKeywordParamDecl(t, tc.name, tc.sc) + if len(shortcut.FromShortcut(tc.sc).Schema.Parameters) == 0 { + t.Fatalf("%s Schema.Parameters empty after FromShortcut", tc.name) + } + } +} + +func assertQueryKeywordParamDecl(t *testing.T, name string, sc shortcut.Shortcut) { + t.Helper() + for _, p := range sc.Schema.Parameters { + if p.Name == "query" && p.Property == "keyword" { + return + } + } + t.Fatalf("%s missing ParamDecl query→keyword; got %#v", name, sc.Schema.Parameters) +} diff --git a/internal/shortcut/minutes/minutes.go b/internal/shortcut/minutes/minutes.go index 0b646b97..959d8a47 100644 --- a/internal/shortcut/minutes/minutes.go +++ b/internal/shortcut/minutes/minutes.go @@ -51,6 +51,10 @@ var ListMine = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws minutes +list-mine --query \"周会\" --limit 10"}, }, + // Execute maps --query → MCP keyword (not inferable from flag name). + Parameters: []corecmd.ParamDecl{ + {Name: "query", Property: "keyword"}, + }, }, Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "关键字筛选"}, @@ -86,6 +90,10 @@ var ListShared = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws minutes +list-shared --limit 20"}, }, + // Execute maps --query → MCP keyword (not inferable from flag name). + Parameters: []corecmd.ParamDecl{ + {Name: "query", Property: "keyword"}, + }, }, Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "关键字筛选"}, @@ -121,6 +129,10 @@ var ListAll = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws minutes +list-all --query \"周会\" --limit 20"}, }, + // Execute maps --query → MCP keyword (not inferable from flag name). + Parameters: []corecmd.ParamDecl{ + {Name: "query", Property: "keyword"}, + }, }, Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "关键字筛选"}, diff --git a/internal/shortcut/minutes/query_keyword_paramdecl_test.go b/internal/shortcut/minutes/query_keyword_paramdecl_test.go new file mode 100644 index 00000000..c8eea49f --- /dev/null +++ b/internal/shortcut/minutes/query_keyword_paramdecl_test.go @@ -0,0 +1,46 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package minutes + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageQueryToKeywordParamDecls(t *testing.T) { + for _, tc := range []struct { + name string + sc shortcut.Shortcut + }{ + {"+list-mine", ListMine}, + {"+list-shared", ListShared}, + {"+list-all", ListAll}, + } { + assertQueryKeywordParamDecl(t, tc.name, tc.sc) + if len(shortcut.FromShortcut(tc.sc).Schema.Parameters) == 0 { + t.Fatalf("%s Schema.Parameters empty after FromShortcut", tc.name) + } + } +} + +func assertQueryKeywordParamDecl(t *testing.T, name string, sc shortcut.Shortcut) { + t.Helper() + for _, p := range sc.Schema.Parameters { + if p.Name == "query" && p.Property == "keyword" { + return + } + } + t.Fatalf("%s missing ParamDecl query→keyword; got %#v", name, sc.Schema.Parameters) +} diff --git a/internal/shortcut/smart/find_doc.go b/internal/shortcut/smart/find_doc.go index 80c8e3c3..3f5ef986 100644 --- a/internal/shortcut/smart/find_doc.go +++ b/internal/shortcut/smart/find_doc.go @@ -67,6 +67,10 @@ var FindDoc = shortcut.Shortcut{ "dws doc +find-doc --query 合同 --limit 10", }, }, + // Execute maps --query → MCP keyword (not inferable from flag name). + Parameters: []corecmd.ParamDecl{ + {Name: "query", Property: "keyword"}, + }, }, Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "按关键词搜索云文档(必填)", Required: true}, diff --git a/internal/shortcut/smart/find_file.go b/internal/shortcut/smart/find_file.go index 972c33b7..8cd1b0f2 100644 --- a/internal/shortcut/smart/find_file.go +++ b/internal/shortcut/smart/find_file.go @@ -66,6 +66,10 @@ var FindFile = shortcut.Shortcut{ "dws drive +find-file --query 合同", }, }, + // Execute maps --query → MCP keyword (not inferable from flag name). + Parameters: []corecmd.ParamDecl{ + {Name: "query", Property: "keyword"}, + }, }, Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "文件名关键词(必填)", Required: true}, diff --git a/internal/shortcut/smart/find_mail_user.go b/internal/shortcut/smart/find_mail_user.go index 82553538..5bf7bab6 100644 --- a/internal/shortcut/smart/find_mail_user.go +++ b/internal/shortcut/smart/find_mail_user.go @@ -76,6 +76,10 @@ var FindMailUser = shortcut.Shortcut{ "dws mail +find-mail-user --query alice --limit 10", }, }, + // Execute maps --query → MCP keyword (not inferable from flag name). + Parameters: []corecmd.ParamDecl{ + {Name: "query", Property: "keyword"}, + }, }, Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词(姓名/花名/邮箱片段,必填)", Required: true}, diff --git a/internal/shortcut/smart/find_record.go b/internal/shortcut/smart/find_record.go index b7851ebc..89f3b520 100644 --- a/internal/shortcut/smart/find_record.go +++ b/internal/shortcut/smart/find_record.go @@ -62,6 +62,10 @@ var FindRecord = shortcut.Shortcut{ "dws aitable +find-record --base B --table T --query 张三", }, }, + // Execute maps --query → MCP keyword (not inferable from flag name). + Parameters: []corecmd.ParamDecl{ + {Name: "query", Property: "keyword"}, + }, }, Flags: []shortcut.Flag{ {Name: "base", Type: shortcut.FlagString, Desc: "Base ID(多维表所属 base)", Required: true}, diff --git a/internal/shortcut/smart/minutes_search.go b/internal/shortcut/smart/minutes_search.go index ca89d34f..6dcd7053 100644 --- a/internal/shortcut/smart/minutes_search.go +++ b/internal/shortcut/smart/minutes_search.go @@ -14,6 +14,8 @@ package smart import ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -41,6 +43,27 @@ var MinutesSearch = shortcut.Shortcut{ "内部按 --query 关键词列出你创建的听记(最多 20 条),再在本地投影出每条的标题、创建时间和 taskUuid。" + "这是纯只读操作,只做搜索与本地投影,不会修改任何听记;若没有匹配的听记则提示「没搜到妙记」。", Risk: shortcut.RiskRead, + Safety: cli.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Schema: corecmd.SchemaDecl{ + Description: "按关键词搜索我的妙记并投影列表", + Interface: &corecmd.InterfaceDecl{ + Mode: "composite", Availability: "available", + Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + }, + Selection: corecmd.SelectionDecl{ + AgentSummary: "按关键词搜索我的妙记并投影列表", + UseWhen: []string{"当你想按关键词快速找回自己创建的会议听记(妙记),只需要看到匹配到的标题、创建时间和 taskUuid 列表、而不想拿到一大坨原始字段时使用;内部按 --query 关键词列出你创建的听记(最多 20 条),再在本地投影出每条的标题、创建时间和 taskUuid。这是纯只读操作,只做搜索与本地投影,不会修改任何听记;若没有匹配的听记则提示「没搜到妙记」。"}, + AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, + Examples: []string{"dws minutes +minutes-search --query 周会"}, + }, + // Execute maps --query → MCP keyword (not inferable from flag name). + Parameters: []corecmd.ParamDecl{ + {Name: "query", Property: "keyword"}, + }, + }, Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "按关键词搜索听记(必填)", Required: true}, }, diff --git a/internal/shortcut/smart/query_keyword_paramdecl_test.go b/internal/shortcut/smart/query_keyword_paramdecl_test.go new file mode 100644 index 00000000..52280d2d --- /dev/null +++ b/internal/shortcut/smart/query_keyword_paramdecl_test.go @@ -0,0 +1,48 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package smart + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageQueryToKeywordParamDecls(t *testing.T) { + // Execute paths that feed --query into MCP "keyword" must declare the + // mapping; flag_name_inference alone would publish property=query. + for _, tc := range []struct { + name string + sc shortcut.Shortcut + }{ + {"+find-doc", FindDoc}, + {"+find-file", FindFile}, + {"+find-mail-user", FindMailUser}, + {"+minutes-search", MinutesSearch}, + {"+search-msg", SearchMsg}, + {"+find-record", FindRecord}, + } { + var found bool + for _, p := range tc.sc.Schema.Parameters { + if p.Name == "query" && p.Property == "keyword" { + found = true + break + } + } + if !found { + t.Fatalf("%s missing ParamDecl query→keyword; got %#v", + tc.name, tc.sc.Schema.Parameters) + } + } +} diff --git a/internal/shortcut/smart/search_msg.go b/internal/shortcut/smart/search_msg.go index 9ffc31c3..7c5e6ab4 100644 --- a/internal/shortcut/smart/search_msg.go +++ b/internal/shortcut/smart/search_msg.go @@ -60,6 +60,10 @@ var SearchMsg = shortcut.Shortcut{ "dws chat +search-msg --group --message-type file --download-resources --output-dir ./downloads", }, }, + // Execute maps --query (or hidden --keyword) → MCP keyword. + Parameters: []corecmd.ParamDecl{ + {Name: "query", Property: "keyword"}, + }, }, Flags: append([]shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词"}, diff --git a/internal/shortcut/wiki/query_keyword_paramdecl_test.go b/internal/shortcut/wiki/query_keyword_paramdecl_test.go new file mode 100644 index 00000000..e7527e6a --- /dev/null +++ b/internal/shortcut/wiki/query_keyword_paramdecl_test.go @@ -0,0 +1,37 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wiki + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageQueryToKeywordParamDecls(t *testing.T) { + assertQueryKeywordParamDecl(t, "+space-search", SpaceSearch) + if len(shortcut.FromShortcut(SpaceSearch).Schema.Parameters) == 0 { + t.Fatal("+space-search Schema.Parameters empty after FromShortcut") + } +} + +func assertQueryKeywordParamDecl(t *testing.T, name string, sc shortcut.Shortcut) { + t.Helper() + for _, p := range sc.Schema.Parameters { + if p.Name == "query" && p.Property == "keyword" { + return + } + } + t.Fatalf("%s missing ParamDecl query→keyword; got %#v", name, sc.Schema.Parameters) +} diff --git a/internal/shortcut/wiki/wiki.go b/internal/shortcut/wiki/wiki.go index cb8318de..ece29b8c 100644 --- a/internal/shortcut/wiki/wiki.go +++ b/internal/shortcut/wiki/wiki.go @@ -150,6 +150,10 @@ var SpaceSearch = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws wiki +space-search --query \"产品文档\""}, }, + // Execute maps --query → MCP keyword (not inferable from flag name). + Parameters: []corecmd.ParamDecl{ + {Name: "query", Property: "keyword"}, + }, }, Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词", Required: true}, diff --git a/scripts/dev/migrate_product_params.py b/scripts/dev/migrate_product_params.py index 7264a44e..f3c5ce8f 100644 --- a/scripts/dev/migrate_product_params.py +++ b/scripts/dev/migrate_product_params.py @@ -4,11 +4,16 @@ Usage: python3 scripts/dev/migrate_product_params.py For each tool with parameter overlays in schema_hints/metadata/.json: -1. Finds the DeclareLeafMetadata block containing the tool's RPCName. +1. Finds the DeclareLeafMetadata block for the tool's reviewed cli_path. 2. Inserts Parameters: []corecmd.ParamDecl{...} into the Schema block. 3. Moves any overlaid flag registrations before the DeclareLeafMetadata call. 4. Clears the tool's parameters from the hints file. +Shared-RPC siblings (e.g. drive rename / doc rename both use rename_document, +mail message list / search both call search_emails) MUST be disambiguated by +cli_path. If cli_path cannot uniquely select a leaf, the script refuses to +insert or clear hints — never pick the first RPC hit. + Run for one product at a time; verify compilation and schema output between runs. """ import json @@ -112,6 +117,366 @@ def find_declare_for_var(lines, var_name, after_line=0): return -1 +# Minimum score to accept a multi-leaf shared-RPC candidate. +# Example/Examples primary invoke = 100; Use+product file = 40. +SCORE_MULTI_LEAF_MIN = 40 + + +def cli_path_tokens(cli_path): + """Normalize a reviewed cli_path into path tokens (no flags).""" + if not cli_path: + return [] + # "mail message list" or "minutes +replace-batch" + parts = [] + for tok in str(cli_path).split(): + if tok.startswith("-"): + break + parts.append(tok) + return parts + + +def example_blobs(block_text): + """Extract Example / Examples string bodies (ignore UseWhen/AvoidWhen prose).""" + blobs = [] + for m in re.finditer(r"Example:\s*`([^`]*)`", block_text, re.S): + blobs.append(m.group(1)) + for m in re.finditer(r"Examples:\s*\[\]string\{([^{}]*)\}", block_text, re.S): + blobs.append(m.group(1)) + return "\n".join(blobs) + + +def file_matches_cli_product(fp, product): + """True when helper/shortcut file clearly belongs to the cli_path product.""" + if not product or not fp: + return False + base = os.path.basename(fp) + norm = fp.replace("\\", "/") + if base == f"{product}.go" or base.startswith(f"{product}_"): + return True + if f"/shortcut/{product}/" in norm or norm.endswith(f"/shortcut/{product}.go"): + return True + return False + + +def score_block_for_cli_path(block_text, cli_path, fp=""): + """Score how strongly a command/Declare block owns reviewed cli_path. + + Sibling AvoidWhen/UseWhen often mention the other leaf's path (e.g. doc rename + says "prefer dws drive rename"). Bare substring matching is therefore unsafe. + Only Example/Examples primary invokes and Use+product-file evidence count. + """ + tokens = cli_path_tokens(cli_path) + if not tokens: + return 0 + path = " ".join(tokens) + product = tokens[0] + leaf = tokens[-1] + score = 0 + + examples = example_blobs(block_text) + # Primary: this leaf's Example documents `dws ...` + if re.search( + r'(?:^|[\s"\'`])dws\s+' + re.escape(path) + r'(?:\s|--|["\'`]|$)', + examples, + re.M, + ): + score += 100 + + if re.search(r'Use:\s*"' + re.escape(leaf) + r'"', block_text): + if file_matches_cli_product(fp, product): + score += 40 + elif len(tokens) >= 2 and tokens[-2] in block_text: + # Same-file siblings (mail list/search): Use leaf differs; parent token + # is weak confirmation only and must not beat Example evidence. + score += 10 + else: + score += 5 + + return score + + +def block_matches_cli_path(block_text, cli_path, fp=""): + """True when score shows clear ownership of cli_path (not bare substring).""" + return score_block_for_cli_path(block_text, cli_path, fp) >= SCORE_MULTI_LEAF_MIN + + +def collect_rpc_sites(file_contents, rpc_name): + """Return [(file, line_idx)] for every RPCName / callMCPTool site.""" + rpc_pattern = re.compile(r'RPCName:\s*"' + re.escape(rpc_name) + '"') + call_patterns = [ + re.compile(r'callMCPTool\("' + re.escape(rpc_name) + '"'), + re.compile(r'callMCPToolOnServer\([^,]+,\s*"' + re.escape(rpc_name) + '"'), + re.compile(r'callAitableTool\("' + re.escape(rpc_name) + '"'), + re.compile(r'rt\.CallMCP\w*\("' + re.escape(rpc_name) + '"'), + ] + sites = [] + for fp, src in file_contents.items(): + lines_src = src.split("\n") + for i, line in enumerate(lines_src): + if rpc_pattern.search(line) or any(cp.search(line) for cp in call_patterns): + sites.append((fp, i)) + return sites + + +def _dedupe_candidates(candidates): + """Keep highest-scoring candidate per DeclareLeafMetadata site.""" + best = {} + for fp, decl_start, cmd_var, score, call_line in candidates: + key = (fp, decl_start) + prev = best.get(key) + if prev is None or score > prev[3]: + best[key] = (fp, decl_start, cmd_var, score, call_line) + return list(best.values()) + + +def _pick_unique_candidate(tool_name, rpc_name, cli_path, candidates, *, multi_leaf): + """Return (fp, decl_start, cmd_var) or (None, -1, "") when unsafe.""" + ranked = sorted(candidates, key=lambda c: (-c[3], c[0], c[1])) + if not ranked: + return None, -1, "" + + if multi_leaf: + if not cli_path: + print(f" WARNING: {tool_name} — RPC '{rpc_name}' has multiple DeclareLeafMetadata " + f"candidates; reviewed cli_path is required for disambiguation") + return None, -1, "" + if ranked[0][3] < SCORE_MULTI_LEAF_MIN: + print(f" WARNING: {tool_name} — RPC '{rpc_name}' has {len(ranked)} leaf candidates; " + f"cli_path={cli_path!r} did not uniquely select a leaf; refusing to insert") + return None, -1, "" + top = [c for c in ranked if c[3] == ranked[0][3]] + if len(top) > 1: + print(f" WARNING: {tool_name} — ambiguous cli_path match for RPC '{rpc_name}' " + f"({len(top)} DeclareLeafMetadata candidates at score {ranked[0][3]}); " + f"refusing to insert") + return None, -1, "" + fp, decl_start, cmd_var, _, _ = top[0] + return fp, decl_start, cmd_var + + # Single leaf: allow RPC-only resolution (no cli_path required). + fp, decl_start, cmd_var, _, _ = ranked[0] + return fp, decl_start, cmd_var + + +def resolve_declare_target(file_contents, tool_name, rpc_name, cli_path): + """Resolve (file, decl_start, cmd_var) for a tool, or (None, -1, ""). + + Shared RPCs (e.g. mail list/search both call search_emails) must be + disambiguated by reviewed cli_path. Never return the first RPC hit blindly. + """ + sites = collect_rpc_sites(file_contents, rpc_name) + candidates = [] # (fp, decl_start, cmd_var, score, call_line) + + for fp, call_line in sites: + lines = file_contents[fp].split("\n") + cmd_var, cmd_line = find_cmd_var_for_call(lines, call_line) + decl_start = find_declare_for_var(lines, cmd_var, after_line=cmd_line) if cmd_var else -1 + if decl_start < 0: + continue + block_end = find_block_end(lines, decl_start) + # Include the command literal block for Use/Example matching. + window_start = cmd_line if cmd_line >= 0 else decl_start + block_text = "\n".join(lines[window_start:block_end + 1]) + score = score_block_for_cli_path(block_text, cli_path, fp) + candidates.append((fp, decl_start, cmd_var, score, call_line)) + + candidates = _dedupe_candidates(candidates) + multi_leaf = len(candidates) > 1 + if candidates: + picked = _pick_unique_candidate( + tool_name, rpc_name, cli_path, candidates, multi_leaf=multi_leaf) + if picked[0] is not None: + return picked + if multi_leaf: + return None, -1, "" + + # Strategy: composite / shortcut helpers keyed by cli_path or Use leaf. + tokens = cli_path_tokens(cli_path) + use_name = tokens[-1] if tokens else tool_name.split(".", 1)[-1].replace("_", "-") + for prefix in ("shortcut-", "+"): + if use_name.startswith(prefix): + use_name = use_name[len(prefix):] + use_pat = re.compile(r'Use:\s*"' + re.escape(use_name) + '"') + use_hits = [] + for fp, src in file_contents.items(): + lines_src = src.split("\n") + for i, line in enumerate(lines_src): + if not use_pat.search(line): + continue + cmd_var, cmd_line = find_cmd_var_for_call(lines_src, i) + decl_start = find_declare_for_var(lines_src, cmd_var, after_line=cmd_line) if cmd_var else -1 + if decl_start < 0: + # shortcut.FromShortcut path: Schema may be on Shortcut literal. + continue + block_end = find_block_end(lines_src, decl_start) + window_start = cmd_line if cmd_line >= 0 else decl_start + block_text = "\n".join(lines_src[window_start:block_end + 1]) + score = score_block_for_cli_path(block_text, cli_path, fp) + use_hits.append((fp, decl_start, cmd_var, score, i)) + use_hits = _dedupe_candidates(use_hits) + if use_hits: + picked = _pick_unique_candidate( + tool_name, rpc_name, cli_path, use_hits, multi_leaf=len(use_hits) > 1) + if picked[0] is not None: + return picked + return None, -1, "" + + print(f" WARNING: {tool_name} — RPCName '{rpc_name}' not found in any helper file") + return None, -1, "" + + +def schema_has_param_decls(schema_text, params): + """True when Schema already declares every overlay flag Name.""" + if "Parameters:" not in schema_text: + return False + for pname in params: + if not re.search(r'Name:\s*"' + re.escape(pname) + r'"', schema_text): + return False + return True + + +def _self_test_fixtures(): + """Synthetic shared-RPC helpers mirroring mail list/search and doc/drive rename.""" + mail_src = r''' + messageSearchCmd := &cobra.Command{ + Use: "search", + Example: ` dws mail message search --email user@company.com --query "subject:周报"`, + RunE: func(cmd *cobra.Command, args []string) error { + return callMCPTool("search_emails", toolArgs) + }, + } + DeclareLeafMetadata(messageSearchCmd, LeafSpec{ + Schema: LeafSchema{ + Interface: &LeafInterfaceDecl{ + ProductID: "mail", RPCName: "search_emails", + }, + Selection: LeafSelectionDecl{ + AvoidWhen: []string{ + "只列某文件夹邮件且无需复杂条件时优先 mail message list", + }, + Examples: []string{"dws mail message search --email user@company.com --query \"subject:周报\""}, + }, + }, + }) + + messageListCmd := &cobra.Command{ + Use: "list", + Example: ` dws mail message list --email user@company.com`, + RunE: func(cmd *cobra.Command, args []string) error { + return callMCPTool("search_emails", toolArgs) + }, + } + DeclareLeafMetadata(messageListCmd, LeafSpec{ + Schema: LeafSchema{ + Interface: &LeafInterfaceDecl{ + Mode: "composite", + Reason: "wrapper around search_emails", + }, + Selection: LeafSelectionDecl{ + AvoidWhen: []string{"需要组合条件时用 mail message search"}, + Examples: []string{ + "dws mail message list --email user@company.com", + }, + }, + }, + }) +''' + doc_src = r''' + renameCmd := &cobra.Command{ + Use: "rename", + Example: ` dws doc rename --node DOC_ID --name "新名称"`, + RunE: func(cmd *cobra.Command, args []string) error { + return callMCPTool("rename_document", map[string]any{}) + }, + } + DeclareLeafMetadata(renameCmd, LeafSpec{ + Schema: LeafSchema{ + Interface: &LeafInterfaceDecl{ + ProductID: "doc", RPCName: "rename_document", + }, + Selection: LeafSelectionDecl{ + AvoidWhen: []string{ + "文件或文件夹重命名优先用 dws drive rename,由该命令读取真实节点类型", + }, + Examples: []string{"dws doc rename --node --name \"新名称\" --format json"}, + }, + }, + }) +''' + drive_src = r''' + driveRenameCmd := &cobra.Command{ + Use: "rename", + Example: ` dws drive rename --node DOC_ID --name "新名称"`, + RunE: func(cmd *cobra.Command, args []string) error { + return callMCPToolOnServer("doc", "rename_document", map[string]any{}) + }, + } + DeclareLeafMetadata(driveRenameCmd, LeafSpec{ + Schema: LeafSchema{ + Interface: &LeafInterfaceDecl{ + ProductID: "doc", RPCName: "rename_document", + }, + Selection: LeafSelectionDecl{ + AvoidWhen: []string{ + "要改正文里的标题改用 dws doc block update,不要用 rename", + }, + Examples: []string{"dws drive rename --node --name \"新名称\" --format json"}, + }, + Parameters: []corecmd.ParamDecl{ + {Name: "name", Description: "drive-only extension stripping"}, + }, + }, + }) +''' + return { + "internal/helpers/mail.go": mail_src, + "internal/helpers/doc.go": doc_src, + "internal/helpers/drive.go": drive_src, + } + + +def run_self_test(): + """Verify shared-RPC cli_path disambiguation; exit 0/1.""" + fc = _self_test_fixtures() + cases = [ + ("mail.list_emails", "search_emails", "mail message list", "mail.go", "messageListCmd"), + ("mail.search_emails", "search_emails", "mail message search", "mail.go", "messageSearchCmd"), + ("drive.rename_document", "rename_document", "drive rename", "drive.go", "driveRenameCmd"), + ("doc.rename_document", "rename_document", "doc rename", "doc.go", "renameCmd"), + ] + failed = 0 + for tool, rpc, cli, want_file, want_var in cases: + fp, decl, var = resolve_declare_target(fc, tool, rpc, cli) + base = os.path.basename(fp) if fp else None + ok = base == want_file and var == want_var and decl >= 0 + status = "OK" if ok else "FAIL" + print(f" {status}: {tool} cli_path={cli!r} -> {base}:{var} (want {want_file}:{want_var})") + if not ok: + failed += 1 + + # Bare AvoidWhen cross-links must not make block_matches true alone. + doc_block = fc["internal/helpers/doc.go"] + if block_matches_cli_path(doc_block, "drive rename", "internal/helpers/doc.go"): + print(" FAIL: doc rename AvoidWhen must not own cli_path 'drive rename'") + failed += 1 + else: + print(" OK: doc AvoidWhen cross-link does not claim drive rename") + + # Multi-leaf without cli_path must refuse. + fp, decl, var = resolve_declare_target(fc, "mail.list_emails", "search_emails", "") + if fp is not None: + print(f" FAIL: multi-leaf without cli_path should refuse, got {fp}:{var}") + failed += 1 + else: + print(" OK: multi-leaf without cli_path refused") + + if failed: + print(f"self-test: {failed} failure(s)") + return False + print("self-test: all passed") + return True + + def migrate_product(product): hint_path = os.path.join(HINTS_DIR, f"{product}.json") hints = json.load(open(hint_path, encoding="utf-8")) @@ -131,100 +496,38 @@ def migrate_product(product): "devdoc.search_open_platform_docs_rag": "search_open_platform_docs", } - # Load all helper files into memory + # Also search shortcut packages for composite / no-single-RPC commands. file_contents = {} for fp in helper_files_for_product(product): file_contents[fp] = open(fp, encoding="utf-8").read() + shortcut_root = "internal/shortcut" + if os.path.isdir(shortcut_root): + for root, _, fns in os.walk(shortcut_root): + for fn in fns: + if fn.endswith(".go") and not fn.endswith("_test.go"): + fp = os.path.join(root, fn) + file_contents[fp] = open(fp, encoding="utf-8").read() modified_files = set() flags_to_move = [] migrated_tools = set() # only clear hints for tools we actually migrated for tool_name, params in sorted(overlays.items()): + meta = tools.get(tool_name) or {} + cli_path = (meta.get("cli_path") or "").strip() rpc_name = RPC_ALIASES.get(tool_name, tool_name.split(".")[-1]) + if not cli_path: + # Shared RPCs cannot be safely resolved without reviewed cli_path. + print(f" WARNING: {tool_name} — parameters overlay missing cli_path; " + f"shared-RPC siblings will refuse multi-leaf resolution") - # Strategy 1: RPCName field in Schema.Interface - rpc_pattern = re.compile(r'RPCName:\s*"' + re.escape(rpc_name) + '"') - # Strategy 2: callMCPTool / CallMCP call sites - call_patterns = [ - re.compile(r'callMCPTool\("' + re.escape(rpc_name) + '"'), - re.compile(r'callMCPToolOnServer\([^,]+,\s*"' + re.escape(rpc_name) + '"'), - re.compile(r'callAitableTool\("' + re.escape(rpc_name) + '"'), - re.compile(r'rt\.CallMCP\w*\("' + re.escape(rpc_name) + '"'), - ] - - found_file = None - found_line = -1 - for fp, src in file_contents.items(): - lines_src = src.split("\n") - # Try strategy 1 first - for i, line in enumerate(lines_src): - if rpc_pattern.search(line): - found_file = fp - found_line = i - break - if found_file: - break - # Try strategy 2 - for i, line in enumerate(lines_src): - for cp in call_patterns: - if cp.search(line): - found_file = fp - found_line = i - break - if found_file: - break - if found_file: - break - - if not found_file: - # Strategy 3: composite helpers keyed by cobra Use (e.g. markdown.create). - use_name = tool_name.split(".", 1)[-1].replace("_", "-") - # Prefer last segment after dropping common prefixes. - for prefix in ("shortcut-",): - if use_name.startswith(prefix): - use_name = use_name[len(prefix):] - use_pat = re.compile(r'Use:\s*"' + re.escape(use_name) + '"') - for fp, src in file_contents.items(): - lines_src = src.split("\n") - for i, line in enumerate(lines_src): - if use_pat.search(line): - found_file = fp - found_line = i - break - if found_file: - break - - if not found_file: - print(f" WARNING: {tool_name} — RPCName '{rpc_name}' not found in any helper file") + found_file, decl_start, cmd_var = resolve_declare_target( + file_contents, tool_name, rpc_name, cli_path) + if not found_file or decl_start < 0: continue lines = file_contents[found_file].split("\n") - # Prefer command-variable association: call site → cmd var → DeclareLeafMetadata(cmd) - decl_start = -1 - cmd_var, cmd_line = find_cmd_var_for_call(lines, found_line) - if cmd_var: - decl_start = find_declare_for_var(lines, cmd_var, after_line=cmd_line) - - # Fallback: any DeclareLeafMetadata whose block mentions the RPC name - if decl_start < 0: - for i, line in enumerate(lines): - if "DeclareLeafMetadata(" not in line: - continue - block_end = find_block_end(lines, i) - block_text = "\n".join(lines[i:block_end + 1]) - if rpc_name in block_text or tool_name in block_text: - decl_start = i - break - if f'callMCPTool("{rpc_name}"' in block_text or \ - (f'callMCPToolOnServer(' in block_text and rpc_name in block_text): - decl_start = i - break - if decl_start < 0: - print(f" WARNING: {tool_name} — no matching DeclareLeafMetadata near line {found_line + 1} in {os.path.basename(found_file)} (cmd_var={cmd_var!r})") - continue - # Find Schema block schema_start = -1 for i in range(decl_start, min(decl_start + 80, len(lines))): @@ -237,10 +540,16 @@ def migrate_product(product): schema_end = find_block_end(lines, schema_start) schema_text = "\n".join(lines[schema_start:schema_end + 1]) - if "Parameters:" in schema_text: - print(f" SKIP: {tool_name} — Parameters already declared") + if schema_has_param_decls(schema_text, params): + # Only treat as migrated when THIS leaf already has the overlay names. + print(f" SKIP: {tool_name} — Parameters already declared on matched leaf " + f"(var={cmd_var}, cli_path={cli_path!r})") migrated_tools.add(tool_name) continue + if "Parameters:" in schema_text: + print(f" WARNING: {tool_name} — matched leaf already has Parameters but is missing " + f"overlay flags {sorted(params)}; refusing to clear hints (var={cmd_var})") + continue # Build and insert Parameters param_lines = ["\t\t\tParameters: []corecmd.ParamDecl{"] @@ -260,7 +569,8 @@ def migrate_product(product): modified_files.add(found_file) file_contents[found_file] = "\n".join(lines) migrated_tools.add(tool_name) - print(f" OK: {tool_name} — inserted {len(params)} param decls in {os.path.basename(found_file)} at line {insert_at + 1} (var={cmd_var})") + print(f" OK: {tool_name} — inserted {len(params)} param decls in {os.path.basename(found_file)} " + f"at line {insert_at + 1} (var={cmd_var}, cli_path={cli_path!r})") # Check flag registration order for pname in params: @@ -271,7 +581,7 @@ def migrate_product(product): flags_to_move.append((pname, i, decl_start, os.path.basename(found_file))) break - # Clear hints for migrated/skipped tools (even if no source files changed) + # Clear hints ONLY for tools we migrated onto the matched leaf. if migrated_tools: for tool_name in migrated_tools: if tool_name in hints.get("tools", {}): @@ -281,7 +591,8 @@ def migrate_product(product): if not hints.get("tools"): hints["tools"] = {} json.dump(hints, open(hint_path, "w", encoding="utf-8"), ensure_ascii=False, indent=2) - print(f"{product}: cleared hints for {len(migrated_tools)} tools ({len(overlays) - len(migrated_tools)} remaining)") + print(f"{product}: cleared hints for {len(migrated_tools)} tools " + f"({len(overlays) - len(migrated_tools)} remaining)") if not modified_files: if not migrated_tools: @@ -291,7 +602,7 @@ def migrate_product(product): # Write modified files and ensure corecmd import for fp in modified_files: src = file_contents[fp] - if "internal/corecmd" not in src: + if fp.startswith(HELPER_DIR) and "internal/corecmd" not in src: m = re.search(r'(import \(\n(?:.*\n)*?\))', src) if m: block = m.group(1) @@ -309,8 +620,11 @@ def migrate_product(product): if __name__ == "__main__": + if len(sys.argv) == 2 and sys.argv[1] in ("--self-test", "self-test"): + sys.exit(0 if run_self_test() else 1) if len(sys.argv) != 2: print("Usage: migrate_product_params.py ") + print(" migrate_product_params.py --self-test") sys.exit(1) ok = migrate_product(sys.argv[1]) sys.exit(0 if ok else 1)