From b1e7b43f85a18ad1c92fd9d9be4cd5f539494c4f Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 23 Jul 2026 22:55:42 +0800 Subject: [PATCH] fix(shortcut): fix projection-data-loss silent-empty returns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several read shortcuts returned an empty list with exit 0 and no error envelope even though the underlying MCP tool returned data, so agents misread "no data" and made wrong decisions. Root causes: - Container key mismatch: the resolver probed the wrong key — processCodeList / values / wikiSpaces / itemList / groupList / recentItems / emailAccounts / deptUserList / labelUserList / roles / report_list, plus get_org_labels grouped labels[] needing a descend. - Item fields nested under a VO wrapper, not unwrapped: shiftVO / entityVO / userInfo. - Param exceeded a backend limit: todo +created-todos sent pageSize=50 while the backend silently returns empty for pageSize>20; now uses the shared pager (pageSize=20). Affected: contact/oa/wiki/drive/minutes/calendar/attendance/chat/report/smart resolvers. Every fix ships a guard test that feeds the real backend response shape (and, for minutes, both the taskUuid and minutesId item shapes) and asserts the projection is non-empty with a usable id. scripts/shortcut_real_result.py now compares the upper (projection) output against the lower (raw backend) layer, and record_real_shortcut_run.py captures the lower layer in memory (persisting only derived counts, never raw PII) so an exit-0 empty projection over a non-empty backend is scored as projection-data-loss instead of real-ok. The Python self-test runs in CI via test/scripts. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + internal/shortcut/attendance/attendance.go | 16 +- .../attendance/rule_class_projection_test.go | 70 ++++++ internal/shortcut/calendar/calendar.go | 5 +- .../calendar/room_groups_projection_test.go | 37 ++++ internal/shortcut/chat/chat_group.go | 9 +- .../chat/chat_role_list_projection_test.go | 54 +++++ internal/shortcut/contact/contact.go | 55 ++++- .../contact/list_roles_projection_test.go | 84 ++++++++ .../contact/member_list_projection_test.go | 76 +++++++ internal/shortcut/drive/drive.go | 30 ++- .../drive/recent_list_projection_test.go | 66 ++++++ .../shortcut/minutes/list_projection_test.go | 46 ++++ internal/shortcut/minutes/minutes.go | 9 +- .../shortcut/oa/list_forms_projection_test.go | 82 +++++++ internal/shortcut/oa/oa.go | 17 +- internal/shortcut/report/report.go | 8 +- .../report/report_list_projection_test.go | 38 ++++ internal/shortcut/smart/created_todos.go | 13 +- .../smart/mail_space_todo_projection_test.go | 203 +++++++++++++++++ internal/shortcut/smart/resolve_space.go | 2 +- internal/shortcut/smart/search_mail.go | 46 +++- .../wiki/space_list_projection_test.go | 49 +++++ internal/shortcut/wiki/wiki.go | 7 +- scripts/record_real_shortcut_run.py | 39 +++- scripts/shortcut_real_result.py | 157 +++++++++++++- scripts/shortcut_real_result_test.py | 204 ++++++++++++++++++ .../shortcut_real_result_selftest_test.go | 40 ++++ 28 files changed, 1418 insertions(+), 45 deletions(-) create mode 100644 internal/shortcut/attendance/rule_class_projection_test.go create mode 100644 internal/shortcut/calendar/room_groups_projection_test.go create mode 100644 internal/shortcut/chat/chat_role_list_projection_test.go create mode 100644 internal/shortcut/contact/list_roles_projection_test.go create mode 100644 internal/shortcut/contact/member_list_projection_test.go create mode 100644 internal/shortcut/drive/recent_list_projection_test.go create mode 100644 internal/shortcut/minutes/list_projection_test.go create mode 100644 internal/shortcut/oa/list_forms_projection_test.go create mode 100644 internal/shortcut/report/report_list_projection_test.go create mode 100644 internal/shortcut/smart/mail_space_todo_projection_test.go create mode 100644 internal/shortcut/wiki/space_list_projection_test.go create mode 100644 scripts/shortcut_real_result_test.py create mode 100644 test/scripts/shortcut_real_result_selftest_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 599bc8fc..f6237647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ### Fixed +- **Shortcut projection silent-empty returns** (#783) — a batch of read shortcuts returned an empty list with exit 0 and no error envelope even when the underlying MCP tool returned data, so agents misread "no data". The projection resolvers now probe the real container keys (`processCodeList`, `values`, `wikiSpaces`, `itemList`, `groupList`, `recentItems`, `emailAccounts`, `deptUserList`, `labelUserList`, `roles`, `report_list`, and the grouped `get_org_labels` `labels[]`), unwrap items nested under a VO wrapper (`shiftVO` / `entityVO` / `userInfo`), and `todo +created-todos` uses the shared pager (`pageSize=20`) because the backend silently returns an empty page for `pageSize>20`. Affects contact/oa/wiki/drive/minutes/calendar/attendance/chat/report/smart shortcuts, each with a guard test asserting the real response shape projects non-empty. `scripts/shortcut_real_result.py` also gains an upper-vs-lower layer comparison so an exit-0 empty projection over a non-empty backend is scored as `projection-data-loss` in the real read-audit path rather than `real-ok`. - **Message-read shortcut projection** (#706) — the message-list shortcuts (`chat +chat-messages` / `+messages-list` / `+messages-list-direct` / `+at-me` / `+search-msg` / `+thread-replies`) now render card and out-of-office rich-content JSON as readable text (without ever rewriting ordinary text that merely embeds a JSON fragment), expand a forwarded chat record's nested `forwardMessages` instead of collapsing to a "[卡片]" summary, and mark undecryptable encrypted card messages as `[加密消息]`; the speaker is read from the bare `sender` key, nested `{name:…}` sender objects yield their display name, and the literal string `"null"` is treated as absent. Shared projection helpers now live in `internal/shortcut/chatmsg`. `chat message download-media` also gains `--msg-id` / `--open-message-id` aliases for its `--message-id` flag so agents copying the `openMessageId`/`msgId` output field no longer hit "unknown flag". ## [1.0.54] - 2026-07-21 diff --git a/internal/shortcut/attendance/attendance.go b/internal/shortcut/attendance/attendance.go index ebfa940d..ff0d2bee 100644 --- a/internal/shortcut/attendance/attendance.go +++ b/internal/shortcut/attendance/attendance.go @@ -455,6 +455,12 @@ func searchClassProject(data map[string]any) []map[string]any { if !ok { continue } + // get_class_list wraps each shift's identity/label under shiftVO + // (item = {"shiftVO": {id, name, ...}}); unwrap it first, otherwise every + // row projects empty and the whole command silently returns nothing. + if vo, ok := m["shiftVO"].(map[string]any); ok { + m = vo + } row := map[string]any{} if v, ok := attendanceFirst(m, "id", "classId", "class_id"); ok { row["classId"] = v @@ -668,13 +674,21 @@ var SearchAdjustmentRule = shortcut.Shortcut{ // across candidate keys, so an unknown/empty shape yields an empty list rather // than a crash or fabricated data. func searchRuleProject(data map[string]any) []map[string]any { - raw := attendanceResolveList(data, "ruleList", "rules", "list", "items") + // get_overtime_rule nests the list under result.atRuleList and + // get_adjustment_rule under result.adjustmentList; both wrap each rule's + // identity/label under entityVO. Probe those container keys AND unwrap + // entityVO, otherwise +search-overtime-rule / +search-adjustment-rule + // silently return empty despite the backend returning rules. + raw := attendanceResolveList(data, "atRuleList", "adjustmentList", "ruleList", "rules", "list", "items") out := make([]map[string]any, 0, len(raw)) for _, item := range raw { m, ok := item.(map[string]any) if !ok { continue } + if vo, ok := m["entityVO"].(map[string]any); ok { + m = vo + } row := map[string]any{} if v, ok := attendanceFirst(m, "id", "ruleId", "rule_id", "adjustmentId", "overtimeId"); ok { row["ruleId"] = v diff --git a/internal/shortcut/attendance/rule_class_projection_test.go b/internal/shortcut/attendance/rule_class_projection_test.go new file mode 100644 index 00000000..82406e0f --- /dev/null +++ b/internal/shortcut/attendance/rule_class_projection_test.go @@ -0,0 +1,70 @@ +// 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 attendance + +import ( + "encoding/json" + "testing" +) + +// TestSearchClassProjectShiftVOShape guards against projection-data-loss: +// get_class_list nests the list under result.items and wraps each shift's +// identity under shiftVO. The projection must unwrap shiftVO or +search-class +// silently returns empty despite the backend returning shifts. +func TestSearchClassProjectShiftVOShape(t *testing.T) { + const raw = `{"result":{"items":[ + {"shiftVO":{"id":957395083,"name":"默认班次"}}, + {"shiftVO":{"id":957395084,"name":"早班"}} + ]}}` + var data map[string]any + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + got := searchClassProject(data) + if len(got) != 2 { + t.Fatalf("上下层数据不一致: 底层 2 个班次,投影返回 %d 个 (%v)", len(got), got) + } + if got[0]["name"] == nil || got[0]["classId"] == nil { + t.Fatalf("shiftVO fields not unwrapped: %v", got[0]) + } +} + +// TestSearchRuleProjectEntityVOShape guards get_overtime_rule (result.atRuleList) +// and get_adjustment_rule (result.adjustmentList), both wrapping the rule under +// entityVO. The resolver must probe those keys and unwrap entityVO or +// +search-overtime-rule / +search-adjustment-rule silently return empty. +func TestSearchRuleProjectEntityVOShape(t *testing.T) { + for _, tc := range []struct { + name, key string + }{ + {"overtime", "atRuleList"}, + {"adjustment", "adjustmentList"}, + } { + raw := `{"result":{"` + tc.key + `":[ + {"entityVO":{"id":11,"name":"工作日加班"},"permissionVO":{}}, + {"entityVO":{"id":12,"name":"节假日加班"},"permissionVO":{}} + ]}}` + var data map[string]any + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("[%s] unmarshal fixture: %v", tc.name, err) + } + got := searchRuleProject(data) + if len(got) != 2 { + t.Fatalf("[%s] 上下层数据不一致: 底层 result.%s 有 2 条,投影返回 %d 条", tc.name, tc.key, len(got)) + } + if got[0]["ruleId"] == nil || got[0]["name"] == nil { + t.Fatalf("[%s] entityVO fields not unwrapped: %v", tc.name, got[0]) + } + } +} diff --git a/internal/shortcut/calendar/calendar.go b/internal/shortcut/calendar/calendar.go index 33a72661..b554a195 100644 --- a/internal/shortcut/calendar/calendar.go +++ b/internal/shortcut/calendar/calendar.go @@ -494,7 +494,10 @@ func roomGroupsProject(data map[string]any) []map[string]any { // roomGroupsContainer locates the group slice across candidate wrapper keys, // unwrapping one nested object layer (e.g. result.list) when needed. func roomGroupsContainer(data map[string]any) []any { - keys := []string{"result", "data", "list", "items", "groups"} + // list_meeting_room_groups nests the groups under result.groupList; + // "groupList" MUST be probed or +room-groups silently returns empty despite + // the backend returning meeting-room groups. + keys := []string{"result", "data", "list", "items", "groupList", "groups"} for _, k := range keys { v, ok := data[k] if !ok { diff --git a/internal/shortcut/calendar/room_groups_projection_test.go b/internal/shortcut/calendar/room_groups_projection_test.go new file mode 100644 index 00000000..71257cea --- /dev/null +++ b/internal/shortcut/calendar/room_groups_projection_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 calendar + +import ( + "encoding/json" + "testing" +) + +// TestRoomGroupsProjectGroupListShape guards against projection-data-loss: +// list_meeting_room_groups nests the groups under result.groupList; the resolver +// must probe "groupList" or +room-groups silently returns empty despite the +// backend returning meeting-room groups. +func TestRoomGroupsProjectGroupListShape(t *testing.T) { + const raw = `{"result":{"groupList":[ + {"groupId":"g1","groupName":"北楼会议室"}, + {"groupId":"g2","groupName":"南楼会议室"} + ]}}` + var data map[string]any + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + if got := roomGroupsProject(data); len(got) != 2 { + t.Fatalf("上下层数据不一致: 底层 result.groupList 有 2 条,投影返回 %d 条", len(got)) + } +} diff --git a/internal/shortcut/chat/chat_group.go b/internal/shortcut/chat/chat_group.go index 0c180340..fb535145 100644 --- a/internal/shortcut/chat/chat_group.go +++ b/internal/shortcut/chat/chat_group.go @@ -413,7 +413,12 @@ func chatGroupResolveList(data map[string]any) []any { if data == nil { return []any{} } - for _, key := range []string{"result", "data", "list", "items", "groups", "conversations"} { + // "roles" is probed too: chatRoleListProject reuses this resolver and + // list_custom_group_roles nests its list under result.roles — without it + // +chat-role-list silently returns empty despite the group having roles. + // Group listings key on groups/conversations, which are probed first, so + // adding roles is harmless to them. + for _, key := range []string{"result", "data", "list", "items", "groups", "conversations", "roles"} { v, ok := data[key] if !ok { continue @@ -422,7 +427,7 @@ func chatGroupResolveList(data map[string]any) []any { return arr } if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "groups", "conversations", "result", "data"} { + for _, ik := range []string{"list", "items", "groups", "conversations", "roles", "result", "data"} { if arr, ok := inner[ik].([]any); ok { return arr } diff --git a/internal/shortcut/chat/chat_role_list_projection_test.go b/internal/shortcut/chat/chat_role_list_projection_test.go new file mode 100644 index 00000000..630b77e5 --- /dev/null +++ b/internal/shortcut/chat/chat_role_list_projection_test.go @@ -0,0 +1,54 @@ +// 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 ( + "encoding/json" + "testing" +) + +// TestChatRoleListProjectRolesShape guards against projection-data-loss: +// list_custom_group_roles nests the roles under result.roles; the shared +// resolver must probe "roles" or +chat-role-list silently returns empty despite +// the group having custom roles. +func TestChatRoleListProjectRolesShape(t *testing.T) { + const raw = `{"result":{"roles":[ + {"openRoleId":"r1","name":"CTO"}, + {"openRoleId":"r2","name":"CEO"} + ]}}` + var data map[string]any + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + got := chatRoleListProject(data) + if len(got) != 2 { + t.Fatalf("上下层数据不一致: 底层 result.roles 有 2 条,投影返回 %d 条 (%v)", len(got), got) + } + if got[0]["openRoleId"] == nil || got[0]["name"] == nil { + t.Fatalf("role fields missing: %v", got[0]) + } +} + +// TestChatGroupResolveListStillHandlesGroups ensures adding "roles" did not +// disturb the group-listing shape the resolver is shared with. +func TestChatGroupResolveListStillHandlesGroups(t *testing.T) { + const raw = `{"result":{"groups":[{"openConversationId":"c1","title":"项目群"}]}}` + var data map[string]any + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + if got := chatGroupResolveList(data); len(got) != 1 { + t.Fatalf("group shape regressed: want 1, got %d", len(got)) + } +} diff --git a/internal/shortcut/contact/contact.go b/internal/shortcut/contact/contact.go index 4f740ae3..a4498c44 100644 --- a/internal/shortcut/contact/contact.go +++ b/internal/shortcut/contact/contact.go @@ -214,13 +214,13 @@ func listRolesResolveList(data map[string]any) []any { continue } if arr, ok := v.([]any); ok { - return arr + return listRolesFlattenGroups(arr) } // container may itself wrap the list one level deeper if inner, ok := v.(map[string]any); ok { for _, ik := range []string{"list", "items", "labels", "result", "data"} { if arr, ok := inner[ik].([]any); ok { - return arr + return listRolesFlattenGroups(arr) } } } @@ -228,6 +228,42 @@ func listRolesResolveList(data map[string]any) []any { return []any{} } +// listRolesFlattenGroups flattens the grouped get_org_labels response into a +// flat list of label objects. get_org_labels returns result[] as label GROUPS +// ({groupName, labels[]}), nesting the actual roles one level under each group's +// labels[]. A naive top-level scan would hand back the group wrappers — which +// carry no labelId/name — so every row would silently project to empty. When +// the array is already a flat label list (no per-group labels[]), it is returned +// unchanged so the projection tolerates both response shapes. +func listRolesFlattenGroups(arr []any) []any { + flattened := make([]any, 0, len(arr)) + sawGroup := false + for _, item := range arr { + m, ok := item.(map[string]any) + if !ok { + flattened = append(flattened, item) + continue + } + var nested []any + for _, gk := range []string{"labels", "subLabels"} { + if inner, ok := m[gk].([]any); ok { + nested = inner + break + } + } + if nested != nil { + sawGroup = true + flattened = append(flattened, nested...) + continue + } + flattened = append(flattened, item) + } + if sawGroup { + return flattened + } + return arr +} + // listRolesFirst returns the first present candidate key's value. func listRolesFirst(m map[string]any, keys ...string) (any, bool) { for _, k := range keys { @@ -291,6 +327,12 @@ func memberListProject(data map[string]any) []map[string]any { if !ok { continue } + // get_dept_members_by_deptId wraps each member under userInfo + // (deptUserList item = {"userInfo": {userId, name, ...}}); unwrap it or + // every row projects empty and +list-dept-members silently returns none. + if ui, ok := m["userInfo"].(map[string]any); ok { + m = ui + } row := map[string]any{} if v := memberListFirst(m, "userId", "user_id", "userid", "id"); v != nil { row["userId"] = v @@ -306,9 +348,14 @@ func memberListProject(data map[string]any) []map[string]any { } // memberListFindList returns the first slice found under the common list -// container keys, or nil when none is present. +// container keys, or nil when none is present. get_dept_members_by_deptId nests +// its members under deptUserList, so that key must be probed too. func memberListFindList(m map[string]any) []any { - for _, k := range []string{"result", "data", "list", "items", "members", "userList"} { + // get_dept_members_by_deptId nests under deptUserList and + // get_label_members_by_labelId under labelUserList; both must be probed (and + // each item unwrapped from userInfo above) or +list-dept-members / + // +list-role-members silently return empty despite having members. + for _, k := range []string{"result", "data", "list", "items", "members", "userList", "deptUserList", "labelUserList"} { if arr, ok := m[k].([]any); ok { return arr } diff --git a/internal/shortcut/contact/list_roles_projection_test.go b/internal/shortcut/contact/list_roles_projection_test.go new file mode 100644 index 00000000..5df06148 --- /dev/null +++ b/internal/shortcut/contact/list_roles_projection_test.go @@ -0,0 +1,84 @@ +// 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 ( + "encoding/json" + "testing" +) + +// TestListRolesProjectGroupedShape guards against the projection-data-loss class: +// get_org_labels returns result[] as label GROUPS with the real roles nested one +// level under each group's labels[]. The projection MUST descend into those +// nested labels[] rather than treating group wrappers as roles — otherwise it +// silently returns empty (exit 0, no error envelope) while the backend has data. +func TestListRolesProjectGroupedShape(t *testing.T) { + // Faithful get_org_labels shape (as returned by the backend / leaf command). + const raw = `{"result":[ + {"groupName":"默认","labels":[ + {"labelId":101,"name":"角色A"}, + {"labelId":102,"name":"角色B"}, + {"labelId":103,"name":"角色C"}]}, + {"groupName":"职务","labels":[ + {"labelId":104,"name":"角色D"}, + {"labelId":105,"name":"角色E"}]} + ]}` + var data map[string]any + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + + roles := listRolesProject(data) + if len(roles) != 5 { + t.Fatalf("上下层数据不一致: 底层有 5 个角色,投影返回 %d 个 (roles=%v)", len(roles), roles) + } + for _, r := range roles { + if r["labelId"] == nil || r["labelName"] == nil { + t.Fatalf("projected role missing labelId/labelName: %v", r) + } + } +} + +// TestListRolesProjectNestedWrapperAndNonMap covers the nested map-wrapper branch +// ({result:{labels:[...]}}) and a non-map array element, which must be passed +// through rather than dropped. +func TestListRolesProjectNestedWrapperAndNonMap(t *testing.T) { + const raw = `{"result":{"labels":[ + {"groupName":"默认","labels":[{"labelId":1,"name":"角色A"}]}, + "junk" + ]}}` + var data map[string]any + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + if roles := listRolesProject(data); len(roles) != 1 { + t.Fatalf("nested wrapper + non-map: want 1 role, got %d (%v)", len(roles), roles) + } +} + +// TestListRolesProjectFlatShape ensures the flatten step is a no-op when the +// response is already a flat label list (response-shape drift tolerance). +func TestListRolesProjectFlatShape(t *testing.T) { + const raw = `{"result":[ + {"labelId":1,"name":"销售"}, + {"labelId":2,"name":"市场"} + ]}` + var data map[string]any + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + if roles := listRolesProject(data); len(roles) != 2 { + t.Fatalf("flat shape: want 2 roles, got %d (%v)", len(roles), roles) + } +} diff --git a/internal/shortcut/contact/member_list_projection_test.go b/internal/shortcut/contact/member_list_projection_test.go new file mode 100644 index 00000000..f129df25 --- /dev/null +++ b/internal/shortcut/contact/member_list_projection_test.go @@ -0,0 +1,76 @@ +// 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 ( + "encoding/json" + "testing" +) + +// TestMemberListProjectDeptUserListShape guards against projection-data-loss: +// get_dept_members_by_deptId returns members under deptUserList, each wrapped in +// userInfo. The resolver must probe deptUserList AND unwrap userInfo or +// +list-dept-members silently returns empty despite the backend returning members. +func TestMemberListProjectDeptUserListShape(t *testing.T) { + const raw = `{"deptUserList":[ + {"userInfo":{"userId":"u1","name":"张三"}}, + {"userInfo":{"userId":"u2","name":"李四"}} + ]}` + var data map[string]any + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + got := memberListProject(data) + if len(got) != 2 { + t.Fatalf("上下层数据不一致: 底层 deptUserList 有 2 人,投影返回 %d 人 (%v)", len(got), got) + } + if got[0]["userId"] == nil || got[0]["name"] == nil { + t.Fatalf("userInfo fields not unwrapped: %v", got[0]) + } +} + +// TestMemberListProjectLabelUserListShape guards the role-member path: +// get_label_members_by_labelId nests members under labelUserList, each wrapped +// in userInfo. The resolver must probe labelUserList AND unwrap userInfo or +// +list-role-members silently returns empty despite the role having members. +func TestMemberListProjectLabelUserListShape(t *testing.T) { + const raw = `{"labelUserList":[ + {"userInfo":{"userId":"u1","name":"王五"}}, + {"userInfo":{"userId":"u2","name":"赵六"}} + ]}` + var data map[string]any + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + got := memberListProject(data) + if len(got) != 2 { + t.Fatalf("上下层数据不一致: 底层 labelUserList 有 2 人,投影返回 %d 人 (%v)", len(got), got) + } + if got[0]["userId"] == nil || got[0]["name"] == nil { + t.Fatalf("userInfo not unwrapped: %v", got[0]) + } +} + +// TestMemberListProjectFlatRoleShape ensures the shared projection still handles +// a flat member shape unchanged. +func TestMemberListProjectFlatRoleShape(t *testing.T) { + const raw = `{"result":[{"userId":"u9","name":"张三"}]}` + var data map[string]any + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + if got := memberListProject(data); len(got) != 1 { + t.Fatalf("flat role-member shape: want 1, got %d (%v)", len(got), got) + } +} diff --git a/internal/shortcut/drive/drive.go b/internal/shortcut/drive/drive.go index 68c1bb77..fabeef1e 100644 --- a/internal/shortcut/drive/drive.go +++ b/internal/shortcut/drive/drive.go @@ -495,8 +495,16 @@ var Recent = shortcut.Shortcut{ // document list, dropping transport noise (logId) while keeping the pagination // cursor. func recentListProject(data map[string]any) map[string]any { + // get_recent_list nests its payload under result.recentItems; earlier this + // read data["recentItems"] at the top level only, so the whole list silently + // projected to empty. Resolve the payload container (top-level or one level + // under result/data) before reading recentItems / pagination fields. + payload := data + if inner, ok := recentListPayload(data); ok { + payload = inner + } items := []map[string]any{} - raw, _ := data["recentItems"].([]any) + raw, _ := payload["recentItems"].([]any) for _, it := range raw { m, ok := it.(map[string]any) if !ok { @@ -512,15 +520,31 @@ func recentListProject(data map[string]any) map[string]any { }) } out := map[string]any{"count": len(items), "items": items} - if nc, ok := data["nextCursor"]; ok && nc != nil { + if nc, ok := payload["nextCursor"]; ok && nc != nil { out["nextCursor"] = nc } - if hm, ok := data["hasMore"]; ok { + if hm, ok := payload["hasMore"]; ok { out["hasMore"] = hm } return out } +// recentListPayload returns the map that actually holds recentItems, tolerating +// a {result|data:{recentItems:[...]}} envelope as well as a bare top-level shape. +func recentListPayload(data map[string]any) (map[string]any, bool) { + if _, ok := data["recentItems"]; ok { + return data, true + } + for _, k := range []string{"result", "data"} { + if inner, ok := data[k].(map[string]any); ok { + if _, ok := inner["recentItems"]; ok { + return inner, true + } + } + } + return nil, false +} + func init() { shortcut.Register( List, diff --git a/internal/shortcut/drive/recent_list_projection_test.go b/internal/shortcut/drive/recent_list_projection_test.go new file mode 100644 index 00000000..80ee7f79 --- /dev/null +++ b/internal/shortcut/drive/recent_list_projection_test.go @@ -0,0 +1,66 @@ +// 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 ( + "encoding/json" + "testing" +) + +// TestRecentListProjectResultWrapper guards against projection-data-loss: +// get_recent_list nests its payload under result.recentItems; the projection +// must descend into result or +recent silently returns empty despite backend +// records. +func TestRecentListProjectResultWrapper(t *testing.T) { + const raw = `{"result":{"hasMore":true,"nextCursor":"c2","recentItems":[ + {"name":"周报","nodeType":"doc","nodeId":"n1","docUrl":"https://x/1"}, + {"name":"预算表","nodeType":"sheet","nodeId":"n2","docUrl":"https://x/2"} + ]}}` + var data map[string]any + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + out := recentListProject(data) + if got, _ := out["count"].(int); got != 2 { + t.Fatalf("上下层数据不一致: 底层 result.recentItems 有 2 条,投影 count=%v (%v)", out["count"], out) + } + if out["hasMore"] != true || out["nextCursor"] != "c2" { + t.Fatalf("pagination fields lost from result wrapper: %v", out) + } +} + +// TestRecentListProjectNoItems covers the no-recentItems branch (payload not +// found under any wrapper), which must yield an empty list, not a panic. +func TestRecentListProjectNoItems(t *testing.T) { + const raw = `{"result":{"totalCount":0},"success":true}` + var data map[string]any + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + if out := recentListProject(data); out["count"].(int) != 0 { + t.Fatalf("no recentItems: want count 0, got %v", out["count"]) + } +} + +// TestRecentListProjectTopLevel covers the already-unwrapped shape. +func TestRecentListProjectTopLevel(t *testing.T) { + const raw = `{"recentItems":[{"name":"周报","nodeId":"n1"}],"hasMore":false}` + var data map[string]any + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + if out := recentListProject(data); out["count"].(int) != 1 { + t.Fatalf("top-level recentItems: want count 1, got %v", out["count"]) + } +} diff --git a/internal/shortcut/minutes/list_projection_test.go b/internal/shortcut/minutes/list_projection_test.go new file mode 100644 index 00000000..839ac8ce --- /dev/null +++ b/internal/shortcut/minutes/list_projection_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 ( + "encoding/json" + "testing" +) + +// TestCallListProjectItemListShape guards against projection-data-loss end to +// end: list_by_keyword_and_time_range nests the minutes under result.itemList, +// so the resolver must probe "itemList"; and each projected row must carry a +// usable id. The real backend item uses taskUuid, but the API also emits +// minutesId — both must map to the projected taskUuid or callers get rows with +// a title but no id to act on. +func TestCallListProjectItemListShape(t *testing.T) { + const raw = `{"result":{"itemList":[ + {"taskUuid":"7632756964333736","title":"周会纪要"}, + {"minutesId":"m2","title":"评审纪要"} + ]}}` + var data map[string]any + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + got := callListProject(data) + if len(got) != 2 { + t.Fatalf("上下层数据不一致: 底层 result.itemList 有 2 条,投影返回 %d 条", len(got)) + } + if got[0]["taskUuid"] != "7632756964333736" { + t.Fatalf("taskUuid-shaped item lost its id: %v", got[0]) + } + if got[1]["taskUuid"] != "m2" { + t.Fatalf("minutesId-shaped item did not map to taskUuid: %v", got[1]) + } +} diff --git a/internal/shortcut/minutes/minutes.go b/internal/shortcut/minutes/minutes.go index 26cc621b..a17268ea 100644 --- a/internal/shortcut/minutes/minutes.go +++ b/internal/shortcut/minutes/minutes.go @@ -113,7 +113,7 @@ func callListProject(data map[string]any) []map[string]any { continue } row := map[string]any{} - if v, ok := callListFirst(m, "taskUuid", "task_uuid", "uuid", "id"); ok { + if v, ok := callListFirst(m, "taskUuid", "task_uuid", "uuid", "minutesId", "minutes_id", "id"); ok { row["taskUuid"] = v } if v, ok := callListFirst(m, "title", "name"); ok { @@ -144,7 +144,10 @@ func callListProject(data map[string]any) []map[string]any { // callListResolveList locates the list payload inside the response, tolerating a // bare top-level array or nesting under result/data/list/items/records containers. func callListResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "records", "dataList"} { + // list_by_keyword_and_time_range nests the minutes under result.itemList; + // "itemList" MUST be probed or +list-all/+list-mine/+list-shared silently + // return empty despite the backend returning minutes. + for _, key := range []string{"result", "data", "list", "items", "itemList", "records", "dataList"} { v, ok := data[key] if !ok { continue @@ -154,7 +157,7 @@ func callListResolveList(data map[string]any) []any { } // container may itself wrap the list one level deeper if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "records", "dataList", "result", "data"} { + for _, ik := range []string{"list", "items", "itemList", "records", "dataList", "result", "data"} { if arr, ok := inner[ik].([]any); ok { return arr } diff --git a/internal/shortcut/oa/list_forms_projection_test.go b/internal/shortcut/oa/list_forms_projection_test.go new file mode 100644 index 00000000..d1a42db0 --- /dev/null +++ b/internal/shortcut/oa/list_forms_projection_test.go @@ -0,0 +1,82 @@ +// 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 oa + +import ( + "encoding/json" + "testing" +) + +// TestListFormsProjectProcessCodeListShape guards against the projection-data-loss +// class: list_user_visible_process nests the forms under result.processCodeList. +// The resolver MUST probe that exact key — otherwise the whole list silently +// projects to empty (exit 0, no error envelope) while the backend has data. +func TestListFormsProjectProcessCodeListShape(t *testing.T) { + // Faithful list_user_visible_process shape (as returned by the backend). + const raw = `{"result":{"processCodeList":[ + {"processCode":"PROC-1","processName":"请假","dirName":"假勤管理"}, + {"processCode":"PROC-2","processName":"补卡申请","dirName":"假勤管理"}, + {"processCode":"PROC-3","processName":"加班","dirName":"假勤管理"} + ],"totalCount":-1},"success":true}` + var data map[string]any + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + + forms := listFormsProject(data) + if len(forms) != 3 { + t.Fatalf("上下层数据不一致: 底层有 3 张表单,投影返回 %d 张 (forms=%v)", len(forms), forms) + } + for _, f := range forms { + if f["processCode"] == nil || f["name"] == nil { + t.Fatalf("projected form missing processCode/name: %v", f) + } + } +} + +// TestListFormsProjectBareArrayShape ensures a bare top-level array still works. +func TestListFormsProjectBareArrayShape(t *testing.T) { + const raw = `{"result":[ + {"processCode":"PROC-9","processName":"通用审批"} + ]}` + var data map[string]any + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + if forms := listFormsProject(data); len(forms) != 1 { + t.Fatalf("bare array shape: want 1 form, got %d (%v)", len(forms), forms) + } +} + +// TestOAInstanceResolveListValuesShape guards the shared resolver behind +// +list-pending / +list-executed / +list-submitted / +list-cc. Those approval +// instance tools nest the list under result.values; the resolver must probe +// "values" or all four commands silently return empty despite backend records. +func TestOAInstanceResolveListValuesShape(t *testing.T) { + const raw = `{"result":{"hasMore":false,"values":[ + {"processInstanceId":"i-1","title":"测试用户的请假"}, + {"processInstanceId":"i-2","title":"测试用户的报销"} + ]}}` + var data map[string]any + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + if got := oaInstanceResolveList(data); len(got) != 2 { + t.Fatalf("上下层数据不一致: 底层 result.values 有 2 条,resolver 返回 %d 条", len(got)) + } + // End-to-end through a real projection that uses the shared resolver. + if instances := listSubmittedProject(data); len(instances) != 2 { + t.Fatalf("listSubmittedProject: want 2, got %d (%v)", len(instances), instances) + } +} diff --git a/internal/shortcut/oa/oa.go b/internal/shortcut/oa/oa.go index bcb21dd7..2ae954e1 100644 --- a/internal/shortcut/oa/oa.go +++ b/internal/shortcut/oa/oa.go @@ -137,7 +137,11 @@ func oaFormResolveList(data map[string]any) []any { if data == nil { return []any{} } - for _, key := range []string{"result", "data", "list", "items", "processList", "forms"} { + // list_user_visible_process nests the forms under result.processCodeList; + // probing must include that exact key (both at the top level in case the + // envelope is already unwrapped, and one level deeper under result/data) or + // the whole list silently projects to empty. + for _, key := range []string{"result", "data", "list", "items", "processList", "processCodeList", "forms"} { v, ok := data[key] if !ok { continue @@ -146,7 +150,7 @@ func oaFormResolveList(data map[string]any) []any { return arr } if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "processList", "forms", "result", "data"} { + for _, ik := range []string{"list", "items", "processList", "processCodeList", "forms", "result", "data"} { if arr, ok := inner[ik].([]any); ok { return arr } @@ -285,7 +289,12 @@ func oaInstanceResolveList(data map[string]any) []any { if data == nil { return []any{} } - for _, key := range []string{"result", "data", "list", "items", "instances", "tasks"} { + // The approval instance tools (list_pending_approvals, get_done_tasks, + // get_submitted_instances, get_noticed_instances) all nest the instance list + // under result.values; "values" MUST be in the probe set or every one of + // +list-pending / +list-executed / +list-submitted / +list-cc silently + // projects to empty despite the backend returning records. + for _, key := range []string{"result", "data", "list", "items", "values", "instances", "tasks"} { v, ok := data[key] if !ok { continue @@ -294,7 +303,7 @@ func oaInstanceResolveList(data map[string]any) []any { return arr } if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "instances", "tasks", "result", "data"} { + for _, ik := range []string{"list", "items", "values", "instances", "tasks", "result", "data"} { if arr, ok := inner[ik].([]any); ok { return arr } diff --git a/internal/shortcut/report/report.go b/internal/shortcut/report/report.go index 5c486714..1a7234ec 100644 --- a/internal/shortcut/report/report.go +++ b/internal/shortcut/report/report.go @@ -131,7 +131,11 @@ func reportEntryListResolveList(data map[string]any) []any { if data == nil { return []any{} } - for _, key := range []string{"result", "data", "list", "items", "reportList", "records"} { + // get_received_report_list / get_send_report_list nest the list under + // result.report_list (snake_case); "report_list" MUST be probed — the + // camelCase "reportList" alone leaves +inbox-list / +outbox-list silently + // empty despite the backend returning reports. + for _, key := range []string{"result", "data", "list", "items", "report_list", "reportList", "records"} { v, ok := data[key] if !ok { continue @@ -140,7 +144,7 @@ func reportEntryListResolveList(data map[string]any) []any { return arr } if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "reportList", "records", "result", "data"} { + for _, ik := range []string{"list", "items", "report_list", "reportList", "records", "result", "data"} { if arr, ok := inner[ik].([]any); ok { return arr } diff --git a/internal/shortcut/report/report_list_projection_test.go b/internal/shortcut/report/report_list_projection_test.go new file mode 100644 index 00000000..5e9264dc --- /dev/null +++ b/internal/shortcut/report/report_list_projection_test.go @@ -0,0 +1,38 @@ +// 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 report + +import ( + "encoding/json" + "testing" +) + +// TestReportEntryListResolveListSnakeShape guards against projection-data-loss: +// get_received_report_list / get_send_report_list nest the list under +// result.report_list (snake_case); the resolver must probe "report_list" or +// +inbox-list / +outbox-list silently return empty despite the backend having +// reports. +func TestReportEntryListResolveListSnakeShape(t *testing.T) { + const raw = `{"result":{"report_list":[ + {"reportId":"r1","templateName":"日报"}, + {"reportId":"r2","templateName":"周报"} + ]}}` + var data map[string]any + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + if got := reportEntryListResolveList(data); len(got) != 2 { + t.Fatalf("上下层数据不一致: 底层 result.report_list 有 2 条,resolver 返回 %d 条", len(got)) + } +} diff --git a/internal/shortcut/smart/created_todos.go b/internal/shortcut/smart/created_todos.go index 5fdce334..6a488b61 100644 --- a/internal/shortcut/smart/created_todos.go +++ b/internal/shortcut/smart/created_todos.go @@ -55,12 +55,12 @@ var CreatedTodos = shortcut.Shortcut{ `dws todo +created-todos`, }, Execute: func(rt *shortcut.RuntimeContext) error { - // Step 1 — list my todos as creator. pageNum/pageSize are strings and - // roleTypes=["creator"] mirrors helpers.buildListTodoTaskArgs, where - // "creator" is an accepted role-type enum value. - data, err := rt.CallMCPData("todo", "get_user_todos_in_current_org", map[string]any{ - "pageNum": "1", - "pageSize": "50", + // Step 1 — list my todos as creator via the shared pager. This keeps + // pageSize at todoPageSize(=20): the backend silently returns an empty + // page for pageSize>20, so the previous hard-coded pageSize=50 made this + // command always return empty even when the creator had todos. The pager + // also merges every page instead of only the first. + cards, err := shortcutListAllTodoCards(rt, map[string]any{ "roleTypes": []string{"creator"}, }) if err != nil { @@ -69,7 +69,6 @@ var CreatedTodos = shortcut.Shortcut{ // Step 2 — project each card to {subject, taskId, dueTime}, parsing // fields defensively (helpers reused from todo_done.go / overdue.go). - cards := shortcutTodoCards(data) created := make([]map[string]any, 0, len(cards)) for _, m := range cards { subject, _ := m["subject"].(string) diff --git a/internal/shortcut/smart/mail_space_todo_projection_test.go b/internal/shortcut/smart/mail_space_todo_projection_test.go new file mode 100644 index 00000000..29cee492 --- /dev/null +++ b/internal/shortcut/smart/mail_space_todo_projection_test.go @@ -0,0 +1,203 @@ +// 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 ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +var errStubTool = errors.New("stub tool error") + +// TestSearchMailPickMailbox covers every list_user_mailboxes shape: objects with +// an email field and bare string arrays, each at the top level or one level +// under a result/data wrapper. The nested bare-string case regressed silently +// (searchMailToMaps drops strings), making all smart mail shortcuts wrongly +// demand an explicit --email. +func TestSearchMailPickMailbox(t *testing.T) { + cases := []struct { + name, raw, want string + }{ + {"object top-level", `{"emailAccounts":[{"email":"a@x.com"}]}`, "a@x.com"}, + {"object under result", `{"result":{"emailAccounts":[{"email":"b@x.com"}]}}`, "b@x.com"}, + {"bare string top-level", `{"emailAccounts":["c@x.com"]}`, "c@x.com"}, + {"bare string under result", `{"result":{"emailAccounts":["d@x.com"]}}`, "d@x.com"}, + {"empty", `{"result":{"emailAccounts":[]}}`, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var data map[string]any + if err := json.Unmarshal([]byte(tc.raw), &data); err != nil { + t.Fatal(err) + } + if got := searchMailPickMailbox(data); got != tc.want { + t.Fatalf("searchMailPickMailbox(%s) = %q, want %q", tc.raw, got, tc.want) + } + }) + } +} + +// TestResolveSpaceItemsWikiSpaces guards that search_wikiSpaces' result.wikiSpaces +// container is probed — otherwise +resolve-space silently finds no candidates. +func TestResolveSpaceItemsWikiSpaces(t *testing.T) { + const raw = `{"result":{"wikiSpaces":[ + {"spaceId":"s1","name":"研发知识库"}, + {"spaceId":"s2","name":"产品知识库"} + ]}}` + var data map[string]any + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatal(err) + } + if got := resolveSpaceItems(data); len(got) != 2 { + t.Fatalf("上下层数据不一致: 底层 result.wikiSpaces 有 2 条,resolver 返回 %d 条", len(got)) + } +} + +// --- fake MCP caller for end-to-end shortcut projection tests --- + +type stubMailboxCaller struct { + byTool map[string]string + errTool string // tool name that should return a transport error +} + +func (f *stubMailboxCaller) CallTool(_ context.Context, _, tool string, _ map[string]any) (*edition.ToolResult, error) { + if tool == f.errTool { + return nil, errStubTool + } + text := f.byTool[tool] + if text == "" { + text = `{"result":{}}` + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} +func (f *stubMailboxCaller) Format() string { return "json" } +func (f *stubMailboxCaller) DryRun() bool { return false } +func (f *stubMailboxCaller) Fields() string { return "" } +func (f *stubMailboxCaller) JQ() string { return "" } + +func runShortcut(t *testing.T, fake *stubMailboxCaller, argv ...string) string { + t.Helper() + helpers.InitDeps(fake) + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + root.AddCommand(shortcut.Commands()...) + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(io.Discard) + root.SetArgs(argv) + if err := root.Execute(); err != nil { + t.Fatalf("execute %v: %v", argv, err) + } + return buf.String() +} + +func runShortcutErr(t *testing.T, fake *stubMailboxCaller, argv ...string) error { + t.Helper() + helpers.InitDeps(fake) + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + root.AddCommand(shortcut.Commands()...) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs(argv) + return root.Execute() +} + +// TestSmartSearchMailNoMailbox covers the empty-mailbox path: when +// list_user_mailboxes yields no address, +search-mail must surface a clear +// "provide --email" validation error rather than silently proceeding. +func TestSmartSearchMailNoMailbox(t *testing.T) { + fake := &stubMailboxCaller{byTool: map[string]string{ + "list_user_mailboxes": `{"result":{"emailAccounts":[]}}`, + }} + err := runShortcutErr(t, fake, "mail", "+search-mail", "--query", "x", "--format", "json") + if err == nil || !strings.Contains(err.Error(), "未找到可用邮箱") { + t.Fatalf("want no-mailbox validation error, got %v", err) + } +} + +// TestSmartSearchMailBackendError covers the transport-error branch of the +// mailbox resolver. +func TestSmartSearchMailBackendError(t *testing.T) { + fake := &stubMailboxCaller{byTool: map[string]string{}, errTool: "list_user_mailboxes"} + if err := runShortcutErr(t, fake, "mail", "+search-mail", "--query", "x", "--format", "json"); err == nil { + t.Fatal("want backend error propagated, got nil") + } +} + +// TestCreatedTodosBackendError covers the error branch of +created-todos when the +// paged todo fetch fails. +func TestCreatedTodosBackendError(t *testing.T) { + fake := &stubMailboxCaller{byTool: map[string]string{}, errTool: "get_user_todos_in_current_org"} + if err := runShortcutErr(t, fake, "todo", "+created-todos", "--format", "json"); err == nil { + t.Fatal("want backend error propagated, got nil") + } +} + +// TestCreatedTodosProjectsCards runs +created-todos end to end through a fake MCP +// caller. get_user_todos_in_current_org nests cards under result.todoCards; the +// shared pager (pageSize=20, not the old 50 that silently returned empty) must +// surface them into the projected "created" list. +func TestCreatedTodosProjectsCards(t *testing.T) { + fake := &stubMailboxCaller{byTool: map[string]string{ + "get_user_todos_in_current_org": `{"result":{"todoCards":[ + {"subject":"待办A","taskId":"111","dueTime":1784686500000}, + {"subject":"待办B","taskId":"222"} + ]}}`, + }} + out := runShortcut(t, fake, "todo", "+created-todos", "--format", "json") + var d map[string]any + if err := json.Unmarshal([]byte(out), &d); err != nil { + t.Fatalf("output not json: %q", out) + } + created, _ := d["created"].([]any) + if len(created) != 2 { + t.Fatalf("上下层数据不一致: 底层 2 条待办,投影 created=%d (%s)", len(created), out) + } + if !strings.Contains(out, "111") || !strings.Contains(out, "222") { + t.Fatalf("projected rows missing taskId: %s", out) + } +} + +// TestSmartSearchMailAutoResolvesMailbox runs +search-mail without --email so the +// shortcut must auto-resolve the mailbox from list_user_mailboxes +// (result.emailAccounts) before searching. Before the fix this returned +// "未找到可用邮箱" because emailAccounts was not probed. +func TestSmartSearchMailAutoResolvesMailbox(t *testing.T) { + fake := &stubMailboxCaller{byTool: map[string]string{ + "list_user_mailboxes": `{"emailAccounts":[{"email":"me@example.com"}]}`, + "search_emails": `{"result":{"messages":[{"subject":"周报","from":"a@x.com"}]}}`, + }} + out := runShortcut(t, fake, "mail", "+search-mail", "--query", "subject:周报", "--format", "json") + if strings.Contains(out, "未找到可用邮箱") || out == "" { + t.Fatalf("mailbox auto-resolve failed: %s", out) + } + if !strings.Contains(out, "周报") { + t.Fatalf("search result not projected: %s", out) + } +} diff --git a/internal/shortcut/smart/resolve_space.go b/internal/shortcut/smart/resolve_space.go index bc7c050b..496c13c8 100644 --- a/internal/shortcut/smart/resolve_space.go +++ b/internal/shortcut/smart/resolve_space.go @@ -97,7 +97,7 @@ func resolveSpaceItems(data map[string]any) []map[string]any { if data == nil { return nil } - for _, key := range []string{"result", "data", "list", "items", "spaces", "records"} { + for _, key := range []string{"result", "data", "list", "items", "wikiSpaces", "spaces", "records"} { raw, ok := data[key] if !ok { continue diff --git a/internal/shortcut/smart/search_mail.go b/internal/shortcut/smart/search_mail.go index 9721cd54..a94acc7f 100644 --- a/internal/shortcut/smart/search_mail.go +++ b/internal/shortcut/smart/search_mail.go @@ -112,23 +112,49 @@ func searchMailFirstMailbox(rt *shortcut.RuntimeContext) (string, error) { if err != nil { return "", err } - boxes := searchMailUnwrapList(data, "mailboxes", "list", "items", "data", "result", "records") - for _, m := range boxes { + if addr := searchMailPickMailbox(data); addr != "" { + return addr, nil + } + return "", apperrors.NewValidation("未找到可用邮箱,请用 --email 指定要搜索的邮箱地址") +} + +// searchMailPickMailbox resolves the first usable mailbox address from a +// list_user_mailboxes response. It tolerates every observed shape: objects with +// an email field (result.emailAccounts[].email) and bare string arrays +// (result.emailAccounts[]), each at the top level or one level under a +// result/data wrapper. searchMailToMaps drops plain strings, so the bare-string +// case is scanned separately — otherwise all three smart mail shortcuts wrongly +// report that no mailbox exists. +func searchMailPickMailbox(data map[string]any) string { + containers := []string{"emailAccounts", "mailboxes", "list", "items", "data", "result", "records"} + for _, m := range searchMailUnwrapList(data, containers...) { if addr := searchMailFirstString(m, "email", "emailAddress", "mailbox", "address", "account"); addr != "" { - return addr, nil + return addr } } - // Some gateways return the addresses as a bare string list. - for _, key := range []string{"mailboxes", "list", "items", "data", "result"} { - if arr, ok := data[key].([]any); ok { - for _, it := range arr { - if s, ok := it.(string); ok && s != "" { - return s, nil + scanStrings := func(m map[string]any) string { + for _, key := range containers { + if arr, ok := m[key].([]any); ok { + for _, it := range arr { + if s, ok := it.(string); ok && s != "" { + return s + } } } } + return "" } - return "", apperrors.NewValidation("未找到可用邮箱,请用 --email 指定要搜索的邮箱地址") + if s := scanStrings(data); s != "" { + return s + } + for _, wrap := range []string{"result", "data"} { + if inner, ok := data[wrap].(map[string]any); ok { + if s := scanStrings(inner); s != "" { + return s + } + } + } + return "" } // searchMailMessages extracts the message list from a search_emails response. diff --git a/internal/shortcut/wiki/space_list_projection_test.go b/internal/shortcut/wiki/space_list_projection_test.go new file mode 100644 index 00000000..e56e0087 --- /dev/null +++ b/internal/shortcut/wiki/space_list_projection_test.go @@ -0,0 +1,49 @@ +// 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 ( + "encoding/json" + "testing" +) + +// TestSpaceListProjectWikiSpacesShape guards against projection-data-loss: +// list_wikiSpaces / search_wikiSpaces nest the list under result.wikiSpaces; +// the resolver must probe "wikiSpaces" or +space-list / +space-search silently +// return empty despite the backend returning spaces. +func TestSpaceListProjectWikiSpacesShape(t *testing.T) { + const raw = `{"result":{"hasMore":false,"wikiSpaces":[ + {"workspaceId":"w1","name":"研发知识库"}, + {"workspaceId":"w2","name":"产品知识库"} + ]},"success":true}` + var data map[string]any + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + if spaces := spaceListProject(data); len(spaces) != 2 { + t.Fatalf("上下层数据不一致: 底层 result.wikiSpaces 有 2 条,投影返回 %d 条 (%v)", len(spaces), spaces) + } +} + +// TestSpaceListProjectTopLevelWikiSpaces covers the already-unwrapped shape. +func TestSpaceListProjectTopLevelWikiSpaces(t *testing.T) { + const raw = `{"wikiSpaces":[{"workspaceId":"w1","name":"研发知识库"}]}` + var data map[string]any + if err := json.Unmarshal([]byte(raw), &data); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + if spaces := spaceListProject(data); len(spaces) != 1 { + t.Fatalf("top-level wikiSpaces: want 1, got %d (%v)", len(spaces), spaces) + } +} diff --git a/internal/shortcut/wiki/wiki.go b/internal/shortcut/wiki/wiki.go index 6db6a781..c3464f8c 100644 --- a/internal/shortcut/wiki/wiki.go +++ b/internal/shortcut/wiki/wiki.go @@ -94,12 +94,15 @@ func spaceListProject(data map[string]any) []map[string]any { // wikiSpaceRawList locates the space array across candidate container keys, // tolerating a nested {result|data:{list|items|spaces}} wrapper. func wikiSpaceRawList(data map[string]any) []any { - for _, k := range []string{"result", "data", "list", "items", "spaces", "workspaces"} { + // list_wikiSpaces / search_wikiSpaces nest the space list under + // result.wikiSpaces (or a top-level wikiSpaces once unwrapped); "wikiSpaces" + // MUST be probed or +space-list / +space-search silently return empty. + for _, k := range []string{"result", "data", "list", "items", "wikiSpaces", "spaces", "workspaces"} { if arr, ok := data[k].([]any); ok { return arr } if inner, ok := data[k].(map[string]any); ok { - for _, ik := range []string{"list", "items", "spaces", "workspaces", "result", "data"} { + for _, ik := range []string{"list", "items", "wikiSpaces", "spaces", "workspaces", "result", "data"} { if arr, ok := inner[ik].([]any); ok { return arr } diff --git a/scripts/record_real_shortcut_run.py b/scripts/record_real_shortcut_run.py index f8a856e1..27ece9e7 100644 --- a/scripts/record_real_shortcut_run.py +++ b/scripts/record_real_shortcut_run.py @@ -6,16 +6,25 @@ from __future__ import annotations import argparse import datetime as dt import json +import os import shlex import subprocess import time from pathlib import Path -from shortcut_real_result import classify_failure, summarize_failure_categories, classify_real_status, summarize_results +from shortcut_real_result import ( + classify_failure, + summarize_failure_categories, + classify_real_status, + summarize_results, + projection_audit, +) ROOT = Path(__file__).resolve().parents[1] -OUT_PATH = ROOT / "docs" / "shortcut-real-write-results.json" +OUT_PATH = Path( + os.environ.get("SHORTCUT_REAL_RESULTS_PATH", ROOT / "docs" / "shortcut-real-write-results.json") +) def load_report() -> dict: @@ -39,6 +48,12 @@ def main() -> int: p.add_argument("--risk", default="write") p.add_argument("--method", default="real-backend-manual-write; no --mock; no --dry-run") p.add_argument("--timeout", type=int, default=35) + p.add_argument( + "--backend-raw", + help="Path to a JSON file holding the lower-layer MCP tool response for " + "this shortcut (captured separately). Used only in memory to compare the " + "upper projection against the lower layer; never persisted.", + ) p.add_argument("argv", nargs=argparse.REMAINDER) ns = p.parse_args() argv = ns.argv @@ -47,6 +62,12 @@ def main() -> int: if not argv: raise SystemExit("missing command argv") + # Lower layer stays in memory only — we persist derived counts, never the raw + # PII-bearing backend payload. + backend_raw = None + if ns.backend_raw: + backend_raw = json.loads(Path(ns.backend_raw).read_text(encoding="utf-8")) + started = time.time() status = "real-error" stdout = "" @@ -57,7 +78,7 @@ def main() -> int: stdout = proc.stdout.strip() stderr = proc.stderr.strip() exit_code = proc.returncode - status = classify_real_status(exit_code, stdout) + status = classify_real_status(exit_code, stdout, backend_raw=backend_raw) except subprocess.TimeoutExpired as e: status = "timeout" if isinstance(e.stdout, str): @@ -83,7 +104,17 @@ def main() -> int: "duration_ms": duration_ms, "recorded_at": dt.datetime.now().isoformat(), } - category, fixability, note = classify_failure(result) + # Compare upper projection vs lower layer in memory; persist only the derived + # verdict (kind + counts), never the raw backend payload. + if backend_raw is not None: + audit = projection_audit({**result, "backend": backend_raw}) + if audit is not None: + result["projection_audit"] = audit + + # classify_failure inspects the lower layer to flag projection-data-loss, so + # hand it a copy carrying the in-memory backend; the persisted `result` keeps + # only the verdict. + category, fixability, note = classify_failure({**result, "backend": backend_raw}) if category != "passed": result["failure_category"] = category result["fixability"] = fixability diff --git a/scripts/shortcut_real_result.py b/scripts/shortcut_real_result.py index c4ff9288..16256b61 100644 --- a/scripts/shortcut_real_result.py +++ b/scripts/shortcut_real_result.py @@ -80,13 +80,120 @@ def payload_indicates_error(stdout: str) -> bool: return False -def classify_real_status(exit_code: int | None, stdout: str, current_status: str | None = None) -> str: +# --------------------------------------------------------------------------- +# 上层 / 下层数据一致性(投影保真) +# +# 警示案例:contact +list-roles、oa +list-forms 在 exit 0、无 error 信封的情况下 +# 依然静默返空——底层 MCP 明明有 57 个角色 / 93 张表单,投影层因为容器 key 对不上 +# (result[].labels[] 未下钻 / 缺 processCodeList)把数据全吃掉了。只看上层(便捷层 +# 投影输出)永远发现不了这类问题;**shortcut 务必对比上层投影与下层原始后端数据**。 +# +# 判定规则因此新增一条铁律:一个只读/列表类 shortcut,若上层投影为空,必须能拿下层 +# 原始响应来佐证「本就没有数据」。拿不到下层、或下层明明有数据,都不能算 real-ok。 +# --------------------------------------------------------------------------- + +# 便捷层投影输出里承载业务列表的常见容器字段名。 +_PROJECTION_LIST_KEYS = ( + "roles", "forms", "users", "user", "items", "list", "records", "files", + "docs", "nodes", "messages", "conversations", "groups", "events", + "attendees", "rooms", "threads", "folders", "tags", "templates", "contacts", + "spaces", "tasks", "todos", "created", "members", "depts", "departments", + "sheets", "bases", "tables", "views", "apps", "minutes", "reports", + "comments", "attachments", "instances", "robots", "bots", "results", + "workflows", "permissions", "versions", "calendars", "cards", +) + + +def count_projection_items(stdout: str) -> int | None: + """从便捷层投影输出里数出条目数(上层)。 + + 返回 None 表示这段输出不像一个「列表型」投影(例如详情命令、纯文本、写操作 + 回执),此时不参与空投影判定。识别到列表型投影时返回其条目数(可能为 0)。 + """ + best: int | None = None + for value in _json_values(stdout): + if isinstance(value, list): + best = max(best or 0, len(value)) + continue + if not isinstance(value, dict): + continue + count_field = value.get("count") + if isinstance(count_field, bool): + count_field = None + if isinstance(count_field, int): + best = max(best or 0, count_field) + for key in _PROJECTION_LIST_KEYS: + member = value.get(key) + if isinstance(member, list): + best = max(best or 0, len(member)) + return best + + +def backend_record_count(raw: Any, depth: int = 0) -> int: + """递归估算下层原始后端响应里的业务条目数(下层真值)。 + + 取任意深度下「最大的一个对象数组」的长度作为「后端是否有数据」的代理指标—— + 足以判定投影是否把非空的底层数据吃成了空。 + """ + if depth > 6 or raw is None: + return 0 + best = 0 + if isinstance(raw, list): + obj_items = sum(1 for item in raw if isinstance(item, dict)) + best = max(best, obj_items) + for item in raw: + best = max(best, backend_record_count(item, depth + 1)) + elif isinstance(raw, dict): + for value in raw.values(): + best = max(best, backend_record_count(value, depth + 1)) + return best + + +def _backend_payload(result: dict[str, Any]) -> Any: + """取出随 result 一起记录的下层原始后端响应(若采集了的话)。 + + 约定字段:backend_raw / raw_backend / backend / lower_layer / backend_stdout。 + backend_stdout 是字符串时按 JSON 解析。""" + for key in ("backend_raw", "raw_backend", "backend", "lower_layer"): + if key in result and result[key] not in (None, "", {}, []): + return result[key] + text = result.get("backend_stdout") + if isinstance(text, str) and text.strip(): + values = _json_values(text) + if values: + return values[0] if len(values) == 1 else values + return None + + +def compare_layers(shortcut_stdout: str, backend_raw: Any) -> tuple[bool, int | None, int]: + """对比上层投影与下层原始后端数据。 + + 返回 (是否投影吃了数据, 上层条目数, 下层条目数)。当上层是列表型投影且为空、 + 而下层明显有对象数组时,判定为投影数据丢失(True)。""" + upper = count_projection_items(shortcut_stdout) + lower = backend_record_count(backend_raw) + lost = upper == 0 and lower > 0 + return lost, upper, lower + + +def classify_real_status( + exit_code: int | None, + stdout: str, + current_status: str | None = None, + *, + backend_raw: Any = None, +) -> str: if current_status in {"timeout", "held"}: return current_status if exit_code != 0: return "real-error" if payload_indicates_error(stdout): return "real-error" + # 上下层比对:下层有数据但上层投影为空 = 投影吃数据,绝不能算通过。 + if backend_raw is not None: + lost, _upper, _lower = compare_layers(stdout, backend_raw) + if lost: + return "real-error" return "real-ok" @@ -97,6 +204,22 @@ def _haystack(result: dict[str, Any]) -> str: def classify_failure(result: dict[str, Any]) -> tuple[str, str, str]: """Return (category, fixability, note) for a real-test result.""" status = result.get("status") + + # 上下层比对优先:即便进程 exit 0、无 error 信封、status 被记成 real-ok,只要 + # 采集到的下层原始后端有数据而上层投影为空,就是投影把数据吃了——判定为 + # projection-data-loss,属于 shortcut 自身可修的 bug(改投影层的容器 key/下钻)。 + backend_raw = _backend_payload(result) + if backend_raw is not None and result.get("exit_code") in (0, None): + lost, upper, lower = compare_layers(result.get("stdout") or "", backend_raw) + if lost: + return ( + "projection-data-loss", + "cli-projection-fix-needed", + f"上层便捷层投影为空({upper} 条),但下层原始后端有数据(约 {lower} 条);" + "投影层的容器 key/嵌套下钻与真实响应结构不匹配,把非空数据吃成了空。" + "需修投影层解析,并补「喂真实响应结构、断言非空」的单测。", + ) + if status == "real-ok": return ("passed", "fixed", "真实后端执行成功。") if status == "timeout": @@ -263,6 +386,38 @@ def summarize_failure_categories(results: list[dict[str, Any]]) -> dict[str, int return dict(sorted(out.items(), key=lambda kv: (-kv[1], kv[0]))) +def projection_audit(result: dict[str, Any]) -> dict[str, Any] | None: + """投影保真审计:对只读/列表类 shortcut 强制「上下层比对」这条铁律。 + + 返回 None 表示无需关注;否则返回一个 warning dict 供报告展示: + - projection-data-loss:下层有数据、上层空 —— 确定的投影吃数据 bug; + - empty-projection-unverified:上层投影为空但缺下层采集 —— 无法证明「本就为空」, + 必须补采下层原始响应(原始 MCP 响应 / 对应 leaf 命令)再比对,禁止直接判 real-ok。 + """ + stdout = result.get("stdout") or "" + upper = count_projection_items(stdout) + if upper is None or upper > 0: + return None # 非列表型投影,或上层本就有数据 —— 最常见的正常情形。 + backend_raw = _backend_payload(result) + if backend_raw is None: + return { + "kind": "empty-projection-unverified", + "upper_count": upper, + "backend_count": None, + "note": "便捷层投影为空,但未采集下层原始后端响应,无法证明底层确实无数据;" + "按铁律必须补采下层后再比对,不能直接判 real-ok。", + } + lost, _upper, lower = compare_layers(stdout, backend_raw) + if lost: + return { + "kind": "projection-data-loss", + "upper_count": upper, + "backend_count": lower, + "note": "下层原始后端有数据但上层投影为空:投影层解析与真实响应结构不匹配。", + } + return None + + _OSS_URL_RE = re.compile(r"https?://[^\s\"'<>]*oss[^\s\"'<>]*", re.IGNORECASE) _ALIBABA_ACCESS_KEY_ID_RE = re.compile(r"\bLTAI[A-Za-z0-9]{12,}\b") _OSS_QUERY_KEY_RE = re.compile( diff --git a/scripts/shortcut_real_result_test.py b/scripts/shortcut_real_result_test.py new file mode 100644 index 00000000..26287328 --- /dev/null +++ b/scripts/shortcut_real_result_test.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Self-checking tests for scripts/shortcut_real_result.py. + +Run: python3 scripts/shortcut_real_result_test.py + +Focus: the upper/lower layer comparison rules added after the +contact +list-roles / oa +list-forms projection-data-loss case, plus a +regression guard that pre-existing exit-code / error-envelope behaviour is +unchanged. +""" + +from __future__ import annotations + +import json + +from shortcut_real_result import ( + backend_record_count, + classify_failure, + classify_real_status, + compare_layers, + count_projection_items, + payload_indicates_error, + projection_audit, +) + +# Faithful backend shapes from the real case. +GROUPED_ROLES = { + "result": [ + {"groupName": "默认", "labels": [{"labelId": 1, "name": "主管理员"}, {"labelId": 2, "name": "负责人"}]}, + {"groupName": "职务", "labels": [{"labelId": 3, "name": "财务"}]}, + ] +} +PROCESS_CODE_LIST = { + "result": { + "processCodeList": [ + {"processCode": "PROC-1", "processName": "请假"}, + {"processCode": "PROC-2", "processName": "加班"}, + ], + "totalCount": -1, + }, + "success": True, +} + +EMPTY_ROLES_PROJECTION = json.dumps({"count": 0, "roles": []}) +FULL_ROLES_PROJECTION = json.dumps({"count": 2, "roles": [{"labelId": 1}, {"labelId": 2}]}) +DETAIL_PROJECTION = json.dumps({"userId": "u1", "name": "张三"}) + + +def check(name, cond): + if not cond: + raise AssertionError(f"FAIL: {name}") + print(f" ok: {name}") + + +def test_count_projection_items(): + check("empty roles -> 0", count_projection_items(EMPTY_ROLES_PROJECTION) == 0) + check("full roles -> 2", count_projection_items(FULL_ROLES_PROJECTION) == 2) + check("detail (no list) -> None", count_projection_items(DETAIL_PROJECTION) is None) + check("bare array -> len", count_projection_items("[1,2,3]") == 3) + check("empty string -> None", count_projection_items("") is None) + + +def test_backend_record_count(): + # backend_record_count is a "has data" proxy = size of the largest single + # object array found at any depth (not the flattened total). + check("grouped roles has data (>0)", backend_record_count(GROUPED_ROLES) == 2) + check("processCodeList count", backend_record_count(PROCESS_CODE_LIST) == 2) + check("empty backend -> 0", backend_record_count({"result": []}) == 0) + check("error envelope -> 0", backend_record_count({"errorCode": 1, "errorMessage": "x"}) == 0) + + +def test_compare_layers(): + lost, upper, lower = compare_layers(EMPTY_ROLES_PROJECTION, GROUPED_ROLES) + check("roles: data loss detected", lost and upper == 0 and lower == 2) + lost2, _, _ = compare_layers(FULL_ROLES_PROJECTION, GROUPED_ROLES) + check("roles: no loss when upper populated", not lost2) + lost3, _, lower3 = compare_layers(EMPTY_ROLES_PROJECTION, {"result": []}) + check("roles: no loss when backend also empty", not lost3 and lower3 == 0) + + +def test_classify_real_status(): + # Backward-compat: without backend_raw, empty projection is still real-ok. + check( + "legacy empty projection stays real-ok", + classify_real_status(0, EMPTY_ROLES_PROJECTION) == "real-ok", + ) + # With lower layer that has data, empty projection becomes real-error. + check( + "empty projection + backend data -> real-error", + classify_real_status(0, EMPTY_ROLES_PROJECTION, backend_raw=GROUPED_ROLES) == "real-error", + ) + # Populated projection stays ok even with backend. + check( + "full projection + backend -> real-ok", + classify_real_status(0, FULL_ROLES_PROJECTION, backend_raw=GROUPED_ROLES) == "real-ok", + ) + # Regression: non-zero exit and error envelope untouched. + check("nonzero exit -> real-error", classify_real_status(5, "") == "real-error") + check( + "error envelope -> real-error", + classify_real_status(0, json.dumps({"success": False, "error": "boom"})) == "real-error", + ) + check("timeout preserved", classify_real_status(0, "", "timeout") == "timeout") + + +def test_classify_failure_projection_loss(): + result = { + "status": "real-ok", # process exited 0, looked fine + "exit_code": 0, + "service": "contact", + "command": "+list-roles", + "stdout": EMPTY_ROLES_PROJECTION, + "backend_stdout": json.dumps(GROUPED_ROLES), + } + category, fixability, _ = classify_failure(result) + check("projection-data-loss category", category == "projection-data-loss") + check("cli fixable", fixability == "cli-projection-fix-needed") + + # No loss when projection is populated. + ok_result = dict(result, stdout=FULL_ROLES_PROJECTION) + check("populated -> passed", classify_failure(ok_result)[0] == "passed") + + # Regression: a normal real-ok with no backend capture still passes. + check( + "plain real-ok passes", + classify_failure({"status": "real-ok", "exit_code": 0, "stdout": FULL_ROLES_PROJECTION})[0] == "passed", + ) + + +def test_projection_audit(): + # Empty projection, no backend -> must-verify warning. + warn = projection_audit({"stdout": EMPTY_ROLES_PROJECTION}) + check("unverified empty flagged", warn is not None and warn["kind"] == "empty-projection-unverified") + + # Empty projection + backend data -> data loss. + warn2 = projection_audit({"stdout": EMPTY_ROLES_PROJECTION, "backend": GROUPED_ROLES}) + check("data loss flagged", warn2 is not None and warn2["kind"] == "projection-data-loss") + + # Populated projection -> no warning. + check("populated -> no warning", projection_audit({"stdout": FULL_ROLES_PROJECTION}) is None) + + # Detail (non-list) projection -> no warning. + check("detail -> no warning", projection_audit({"stdout": DETAIL_PROJECTION}) is None) + + +def test_payload_indicates_error_regression(): + check("clean payload not error", not payload_indicates_error(FULL_ROLES_PROJECTION)) + check("errorCode!=0 is error", payload_indicates_error(json.dumps({"errorCode": 1}))) + check("errorCode==0 not error", not payload_indicates_error(json.dumps({"errorCode": 0}))) + + +def test_record_run_wires_lower_layer(): + """End-to-end: record_real_shortcut_run.py must capture the lower layer and + flag an exit-0 empty projection as projection-data-loss (not real-ok).""" + import os + import subprocess + import tempfile + + here = os.path.dirname(os.path.abspath(__file__)) + recorder = os.path.join(here, "record_real_shortcut_run.py") + with tempfile.TemporaryDirectory() as d: + backend = os.path.join(d, "backend.json") + out = os.path.join(d, "out.json") + # Lower layer has 3 roles nested under result[].labels[]. + with open(backend, "w") as f: + json.dump(GROUPED_ROLES, f) + env = dict(os.environ, SHORTCUT_REAL_RESULTS_PATH=out) + # Upper command: an empty projection (exit 0, no error envelope). + argv = ["python3", "-c", "print('{\"count\": 0, \"roles\": []}')"] + p = subprocess.run( + ["python3", recorder, "--service", "contact", "--command", "+list-roles", + "--risk", "read", "--backend-raw", backend, "--"] + argv, + capture_output=True, text=True, env=env, + ) + check("recorder exits non-zero on data loss", p.returncode == 2) + rec = json.loads(p.stdout) + check("status flipped to real-error", rec["status"] == "real-error") + check("category is projection-data-loss", rec.get("failure_category") == "projection-data-loss") + check("projection_audit attached", rec.get("projection_audit", {}).get("kind") == "projection-data-loss") + # Raw backend payload must NOT be persisted. + persisted = json.load(open(out)) + blob = json.dumps(persisted, ensure_ascii=False) + check("raw lower-layer not persisted", "主管理员" not in blob and "labels" not in blob) + + +def main(): + tests = [ + test_count_projection_items, + test_backend_record_count, + test_compare_layers, + test_classify_real_status, + test_classify_failure_projection_loss, + test_projection_audit, + test_payload_indicates_error_regression, + test_record_run_wires_lower_layer, + ] + for t in tests: + print(f"{t.__name__}:") + t() + print(f"\nAll {len(tests)} test groups passed.") + + +if __name__ == "__main__": + main() diff --git a/test/scripts/shortcut_real_result_selftest_test.go b/test/scripts/shortcut_real_result_selftest_test.go new file mode 100644 index 00000000..86aec659 --- /dev/null +++ b/test/scripts/shortcut_real_result_selftest_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 scripts_test + +import ( + "os/exec" + "path/filepath" + "testing" +) + +// TestShortcutRealResultSelfTest wires scripts/shortcut_real_result_test.py into +// `go test ./...` so the upper/lower projection-audit rules (and the end-to-end +// record_real_shortcut_run.py integration) are enforced in CI, not manual-only. +func TestShortcutRealResultSelfTest(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("repo root: %v", err) + } + py, err := exec.LookPath("python3") + if err != nil { + t.Skipf("python3 not available: %v", err) + } + cmd := exec.Command(py, filepath.Join("scripts", "shortcut_real_result_test.py")) + cmd.Dir = root + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("shortcut_real_result_test.py failed: %v\n%s", err, out) + } +}