Compare commits

...
Author SHA1 Message Date
chichuan 262248d08d Merge branch 'main' into codex/ci-pr-release-compatibility 2026-08-06 21:38:02 +08:00
github-actions[bot] e7955b5891 Merge pull request #864 from DingTalk-Real-AI/fix/param-hallucination
fix(cli): harden command and parameter hallucination recovery
2026-08-06 21:30:44 +08:00
chichuan efb172dcd6 Merge branch 'main' into codex/ci-pr-release-compatibility 2026-08-06 21:18:39 +08:00
克谨 0cbb1b8d30 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-06 20:16:11 +08:00
github-actions[bot] db6addfc0e Merge pull request #873 from maoqxxmm/codex/csv-put-formula-pr
feat(sheet): expose csv-put formula semantics
2026-08-06 12:05:46 +00:00
chichuan 17f153a070 Merge branch 'main' into codex/csv-put-formula-pr 2026-08-06 19:50:54 +08:00
克谨 959bc4c1a8 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-06 19:46:24 +08:00
github-actions[bot] 9f80006b3b chore: update formula for v1.0.57 [skip ci] 2026-08-06 11:25:13 +00:00
克谨 e530aea14d Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-06 19:23:34 +08:00
chichuan 32515729af Merge pull request #898 from DingTalk-Real-AI/codex/recover-v1.0.57-formula-parent
chore: prepare safe v1.0.57 Formula recovery
2026-08-06 19:08:28 +08:00
chichuan ef71673c69 chore: prepare v1.0.57 Formula recovery 2026-08-06 18:13:16 +08:00
克谨 af0086c9a8 test: cover command fallback platform gates 2026-08-06 17:53:57 +08:00
克谨 28f75dec0a Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-06 17:42:40 +08:00
github-actions[bot] 15d5be6000 chore: update formula for v1.0.57 [skip ci] 2026-08-06 09:38:41 +00:00
github-actions[bot] ebfba82ebc Merge pull request #867 from Anonymity-0/feat/robot-message-image-file
feat(chat): support robot image and file messages
2026-08-06 17:35:32 +08:00
克谨 ec9897678f Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-06 17:20:38 +08:00
克谨 af6e566abd fix(cli): preserve native doc export task alias 2026-08-06 17:19:21 +08:00
前津 4ea298ec61 Merge remote-tracking branch 'upstream/main' into feat/robot-message-image-file 2026-08-06 17:12:30 +08:00
前津 7aba24b690 fix(chat): preserve schema compatibility 2026-08-06 17:11:19 +08:00
克谨 60e278f32f Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-06 17:11:17 +08:00
克谨 7acbec2615 fix(cli): extend doc hallucination recovery 2026-08-06 17:11:09 +08:00
chichuan 6811a12330 Merge pull request #896 from DingTalk-Real-AI/codex/accept-successful-release-rerun
fix(release): accept successful sealed run reruns
2026-08-06 17:08:02 +08:00
前津 c54a9e1e5e Merge remote-tracking branch 'upstream/main' into feat/robot-message-image-file 2026-08-06 16:56:58 +08:00
前津 a15fb22671 Merge remote-tracking branch 'origin/feat/robot-message-image-file' into feat/robot-message-image-file 2026-08-06 16:48:39 +08:00
chichuan a1712337a8 fix(release): accept successful sealed run reruns 2026-08-06 16:48:29 +08:00
前津 d65ad9aa2e fix(chat): preserve robot markdown requirements 2026-08-06 16:48:27 +08:00
前津 7688f95d2b Merge remote-tracking branch 'upstream/main' into feat/robot-message-image-file 2026-08-06 16:37:08 +08:00
github-actions[bot] c2ee691db7 chore: update beta formula for v1.0.57-beta.4 [skip ci] 2026-08-06 08:36:13 +00:00
xiatian 368c879f45 Merge remote-tracking branch 'upstream/main' into codex/csv-put-formula-pr
# Conflicts:
#	CHANGELOG.md
2026-08-06 16:24:03 +08:00
john 1920552ab0 Merge pull request #895 from DingTalk-Real-AI/codex/changelog-v1.0.57-beta.4
docs: seal v1.0.57-beta.4 changelog
2026-08-06 16:21:44 +08:00
chichuan f1b5330a4e docs: seal v1.0.57-beta.4 changelog 2026-08-06 16:18:22 +08:00
github-actions[bot] f9ccff963f Merge pull request #887 from DingTalk-Real-AI/feat/multi-skill-framework-align
feat(skills+schema): multi-skill fold, ding ParamDecl, retire discovery cache
2026-08-06 16:06:54 +08:00
xiatian 49a17b4375 Merge remote-tracking branch 'upstream/main' into codex/csv-put-formula-pr
# Conflicts:
#	CHANGELOG.md
2026-08-06 15:46:53 +08:00
xiatian b9e7ff8c55 Merge remote-tracking branch 'upstream/main' into codex/csv-put-formula-pr
# Conflicts:
#	CHANGELOG.md
2026-08-06 15:12:29 +08:00
克谨 b94e21331d chore(param): refresh aliases after doc shortcuts 2026-08-06 14:46:11 +08:00
xiatian 637a6f2f68 Merge remote-tracking branch 'upstream/main' into codex/csv-put-formula-pr 2026-08-06 14:45:05 +08:00
chichuan ae9ba333a0 Merge branch 'main' into codex/ci-pr-release-compatibility 2026-08-06 14:44:32 +08:00
克谨 6dbc8399df Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-06 14:42:33 +08:00
xiatian 24cdb85d71 docs(changelog): record formula verify fix 2026-08-06 14:22:42 +08:00
chichuan d75b744a35 ci: classify core command changes as interface-sensitive 2026-08-06 11:58:49 +08:00
xiatian 7134f33e1d fix(sheet): route formula verify to registered tool 2026-08-06 11:53:06 +08:00
chichuan 37fbb110e3 ci: run release compatibility in PR admission 2026-08-06 11:16:19 +08:00
xiatian 7564496ea0 fix(sheet): clarify csv-put literal text semantics 2026-08-06 11:09:02 +08:00
Anonymity-0 665b79d8da Merge branch 'main' into feat/robot-message-image-file 2026-08-06 09:54:07 +08:00
克谨 32e7a80889 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-06 09:52:52 +08:00
xiatian 5e6b588b5e Merge upstream/main into codex/csv-put-formula-pr 2026-08-06 09:52:28 +08:00
克谨 fae21ec9f2 fix(chat): extend parameter and shortcut fallbacks 2026-08-05 20:17:49 +08:00
克谨 779fd82a88 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-05 20:14:10 +08:00
前津 3e60b83881 Merge remote-tracking branch 'upstream/main' into feat/robot-message-image-file 2026-08-05 20:04:29 +08:00
Anonymity-0 66a676b6aa Merge branch 'main' into feat/robot-message-image-file 2026-08-05 20:02:09 +08:00
前津 23bbbccb7e fix: use DX markdown type for robot direct messages 2026-08-05 19:58:17 +08:00
xiatian fd61868393 test(sheet): use managed seams in csv-put test 2026-08-05 18:02:01 +08:00
Anonymity-0 f1e95d763d Merge branch 'main' into feat/robot-message-image-file 2026-08-05 16:59:20 +08:00
前津 9140015c42 test(chat): cover exclusive robot targets 2026-08-05 16:43:51 +08:00
前津 de418c5696 test: cover robot rich media branches 2026-08-05 16:43:51 +08:00
前津 5ec5b9811c chore(chat): omit rich media tests 2026-08-05 16:43:51 +08:00
前津 378eaa377e test(chat): use managed rich media seams 2026-08-05 16:43:50 +08:00
前津 d4368be1c3 fix(chat): preserve text schema compatibility 2026-08-05 16:43:50 +08:00
前津 9733acfb5a feat(chat): support robot image and file messages 2026-08-05 16:43:50 +08:00
克谨 b1d422dcfd Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-05 16:23:12 +08:00
克谨 1231e500a3 Merge remote-tracking branch 'origin/main' into fix/param-hallucination
# Conflicts:
#	internal/cli/param_concepts.json
2026-08-05 16:23:01 +08:00
xiatian c508968814 feat(sheet): expose csv-put formula semantics 2026-08-05 15:51:43 +08:00
克谨 10943629d6 Merge remote-tracking branch 'origin/main' into fix/param-hallucination
# Conflicts:
#	Makefile
#	internal/cli/gen.go
#	internal/helpers/dev.go
#	scripts/policy/check-generated-drift.sh
2026-08-05 12:03:39 +08:00
克谨 9fd9ae7a95 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-04 20:14:15 +08:00
克谨 5b0198b2ec Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-04 17:12:53 +08:00
克谨 dc854acb9b test: cover command fallback edge cases 2026-08-04 14:23:53 +08:00
克谨 d054e3dc01 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-04 13:52:34 +08:00
克谨 95536c3e97 fix(cli): complete shortcut hallucination fallbacks 2026-08-04 13:52:26 +08:00
克谨 1bfedbd4d2 fix(cli): expand hallucination recovery coverage 2026-08-04 11:59:32 +08:00
克谨 cfaf161fc7 fix(cli): add reviewed command path fallbacks 2026-08-04 09:35:48 +08:00
57 changed files with 6827 additions and 252 deletions
+8
View File
@@ -111,6 +111,7 @@ jobs:
filename.startsWith('internal/app/') ||
filename.startsWith('internal/cli/') ||
filename.startsWith('internal/cobracmd/') ||
filename.startsWith('internal/corecmd/') ||
filename.startsWith('internal/helpers/') ||
filename.startsWith('internal/i18n/') ||
filename.startsWith('internal/interfacesnapshot/') ||
@@ -1385,6 +1386,13 @@ jobs:
BASE_REF="$COMPATIBILITY_STABLE_REF"
fi
- name: Check complete CLI command compatibility
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
run: |
./scripts/policy/check-command-compatibility.sh \
--base-ref "$COMPATIBILITY_BASE_REF" \
--stable-ref "$COMPATIBILITY_STABLE_REF"
- name: Check complete Schema compatibility
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
+48 -11
View File
@@ -6,23 +6,59 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### Changed
- **Sheet CSV formula writes** — `dws sheet csv-put` and batch `csv-put` now expose the service contract that CSV fields beginning with `=` are written as formulas. Prefix the field with an apostrophe to write literal text beginning with `=`; CSV content continues to pass through unchanged.
### Fixed
- **Sheet formula verification** (#873) — `dws sheet formula-verify` now calls
the registered remote tool name `verify_formula`; the previous
`formula_verify` name failed at gateway dispatch.
## [1.0.57-beta.4] - 2026-08-06
### Added
- **Expanded open CLI workflows** (#887) — adds calendar event-instance
queries, Drive latest-file selection, Markdown diff, Mail calendar/export/
share-to-chat workflows, and Minutes hot-word, permission, and audio-memo
operations, with matching Schema and cross-platform coverage.
### Changed
- **Multi-skill framework alignment** (#887) — folds long-tail skills into
`dingtalk-misc`, renames the shared package to `dingtalk-shared`, removes
stale Preview guidance, and reorganizes shared recipes and routing for more
predictable Agent selection.
- **Bounded Agent Schema delivery** (#887) — keeps compact and wire projections
focused on executable contract facts, retires stale MCP metadata candidates,
and teaches Agents to prefer `dws schema --compact` for bounded context.
### Deprecated
- **`dws recovery` CLI** — keeps a visible Deprecated compatibility stub for
plan/execute/finalize that returns an explicit “不再支持” notice. The
`internal/recovery` package is removed; Skills must not teach this path.
Transport errors keep generic retry/auth guidance without pointing callers
at recovery.
- **Recovery and discovery-cache compatibility surfaces** (#887) — keeps
visible Deprecated `dws recovery` and `dws cache` compatibility stubs while
retiring their former recovery engine and dynamic discovery-cache behavior.
Recovery plan/execute/finalize now return an explicit “不再支持” notice, and
Skills no longer teach either retired workflow.
### Fixed
- **Mail share-to-chat confirmation** (#887) — requires explicit confirmation
before the first remote write, while preserving the confirmed sign-retry
flow and covering both direct-success and retry responses.
## [1.0.57] - 2026-08-06
This stable release promotes the fully delivered `v1.0.57-beta.3` baseline.
It includes reviewed document shortcuts and chat reply mentions, together with
the v1.0.57 beta-line command-contract, document, chat, OA, Wiki, compatibility,
and CI reliability improvements validated through the prerelease channel.
This stable release promotes the fully delivered `v1.0.57-beta.4` baseline.
It includes the v1.0.57 beta-line command-contract, document, chat, OA, Wiki,
and compatibility improvements, plus the multi-skill framework alignment and
expanded calendar, Drive, Markdown, Mail, and Minutes workflows validated in
the final prerelease.
- **Promote v1.0.57-beta.3** — publishes the validated prerelease baseline as
the stable `v1.0.57` release without adding post-beta product changes.
- **Promote v1.0.57-beta.4** — publishes the final validated prerelease
baseline as stable `v1.0.57` without adding post-beta product changes.
## [1.0.57-beta.3] - 2026-08-06
@@ -100,6 +136,7 @@ and compatibility and CI reliability fixes.
### Added
- **Document-embedded whiteboard workflows** — adds `doc whiteboard insert` for confirmed creation and part-ID verification, `whiteboard query/update` for structured OpenNodes reads and confirmed writes, and `doc media upload` for preparing node-bound Vector/SVG resources. The public adapter uses an explicit helper-only whiteboard endpoint, validates update envelopes locally, decodes `resultJson`, and publishes the full command, Schema, Skill, and safety contract migrated from `dws-wukong@e2da8ab947c6`.
- **Robot image and file messages** — extends `chat message send-by-bot` with public image URL delivery through `--msg-type image --image-url` and local-file upload/send through `--msg-type file --file-path`, while preserving Markdown as the default message type.
### Changed
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.57-beta.3"
version "1.0.57-beta.4"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-darwin-arm64.tar.gz"
sha256 "b1ea300a76654751ea33540d8a244b0c81b5df3947786980f95a5a19362a097a"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-darwin-arm64.tar.gz"
sha256 "ff363e258d463732e4dc02aa71ac1b5b1f05c25107c32ddcc5ae11d14931a782"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-darwin-amd64.tar.gz"
sha256 "68b5f6e38bec994fa4db4bef5db1629c7bce799bb9837c447008c3325fc886e3"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-darwin-amd64.tar.gz"
sha256 "3eafcc4c27931b8457f3611a12ce4ae727f7bd65356fbef80958699defa09acf"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-linux-arm64.tar.gz"
sha256 "350e74f1a2611975e476e113e50264975a98185c11ee889a83d9480c0f10181b"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-linux-arm64.tar.gz"
sha256 "49eabb4d2c419d8d1fb0fcf71d9c318ef49cf3d198431df1bcbaa71589e11383"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-linux-amd64.tar.gz"
sha256 "2c27a9a884650a7a60545d9447f1b966667216e94c333ebf9d69f9b2ead96e04"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-linux-amd64.tar.gz"
sha256 "fb3903485fe494e1fadb67ec87b2fef19cf575c72b7df25f0b93f80f8b36273f"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-skills.zip"
sha256 "abaa8feaa3c61fff048cfd1139e1fc5b91c329eb666d2eb852797a3f5c4c0cac"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-skills.zip"
sha256 "6bd11363dbd2ce79627f49bc80b8a92f9c79fca083a6bd3ff9bc5c4833594fb9"
end
def install
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCli < Formula
desc "Automate DingTalk workspace tasks from the terminal"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.56"
version "1.0.57"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-darwin-arm64.tar.gz"
sha256 "5c6003fe484aa36cc00820a574186652467b9d075f19c159cf807e57590256ba"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-arm64.tar.gz"
sha256 "c01c28dc13948a70fca905207073dc8dbd22f7ba7fc90e68b3316eb9a9c98e88"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-darwin-amd64.tar.gz"
sha256 "969b005a10682c2a1a828fa112165b5b0cd8ceeed8d22110ef7f39402cc36804"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-amd64.tar.gz"
sha256 "d7baa218beefc851c6a933b456055195f8272984ce008d7e0122bdfc5dad94ea"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-arm64.tar.gz"
sha256 "530c5ea7ddc7de320d9c2471fbd33752a723d00c9665f49321c7580e8392c756"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-arm64.tar.gz"
sha256 "0bbe9c233a3ff585077bae1ac5000937c32d967846d14cc44c46f98d49b95ae2"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-amd64.tar.gz"
sha256 "675fa42727ac9a549c6710b82e1980cd0f795363d71d5116a4e69771b7c5470e"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-amd64.tar.gz"
sha256 "f113ce3654f21d1f9ecc7c196f815aeafbca54d377a347b244a15116c5cba698"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-skills.zip"
sha256 "3d57794e4660a089209ce3962571d16ca0d46141e973c9993257a301cce0e097"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-skills.zip"
sha256 "0c9667209cf30761427a8f9348149cbbf1e397aa3c25587e99f205bc7525e101"
end
def install
+13 -1
View File
@@ -151,9 +151,13 @@ generate-schema:
@set -e; \
concepts_guard=$$(mktemp); \
concepts_schema_guard=$$(mktemp); \
trap 'rm -rf "$$concepts_guard" "$$concepts_schema_guard"' EXIT HUP INT TERM; \
command_fallbacks_guard=$$(mktemp); \
command_fallbacks_schema_guard=$$(mktemp); \
trap 'rm -f "$$concepts_guard" "$$concepts_schema_guard" "$$command_fallbacks_guard" "$$command_fallbacks_schema_guard"' EXIT HUP INT TERM; \
cp internal/cli/param_concepts.json "$$concepts_guard"; \
cp internal/cli/param_concepts.schema.json "$$concepts_schema_guard"; \
cp internal/cli/command_path_fallbacks.json "$$command_fallbacks_guard"; \
cp internal/cli/command_path_fallbacks.schema.json "$$command_fallbacks_schema_guard"; \
$(GO) generate ./internal/cli; \
rm -rf internal/cli/schema_agent_metadata internal/cli/schema_agent_metadata_audit.json; \
rm -f internal/cli/schema_meta_index.json; \
@@ -169,6 +173,14 @@ generate-schema:
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \
exit 1; \
}; \
cmp -s internal/cli/command_path_fallbacks.json "$$command_fallbacks_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.json' >&2; \
exit 1; \
}; \
cmp -s internal/cli/command_path_fallbacks.schema.json "$$command_fallbacks_schema_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.schema.json' >&2; \
exit 1; \
}; \
if [ -e internal/cli/schema_hints ]; then \
printf '%s\n' 'retired schema_hints/ must not reappear after generation' >&2; \
exit 1; \
@@ -0,0 +1,301 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
_ "embed"
"encoding/json"
stderrors "errors"
"fmt"
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
//go:embed testdata/shortcut_hallucination_badcases_20260728.json
var shortcutHallucinationBadcases20260728JSON []byte
type shortcutHallucinationReplayCorpus struct {
SourceReport string `json:"source_report"`
SourceFile string `json:"source_file"`
SourceDWSCommit string `json:"source_dws_commit"`
Model string `json:"model"`
ExpectedCount int `json:"expected_count"`
Badcases []shortcutHallucinationReplayBadcase `json:"badcases"`
}
type shortcutHallucinationReplayBadcase struct {
ID string `json:"id"`
CaseID string `json:"case_id"`
Run int `json:"run"`
CommandIndex int `json:"command_index"`
Turn int `json:"turn"`
SourcePath string `json:"source_path"`
ExpectedOutcome string `json:"expected_outcome"`
Raw string `json:"raw"`
OriginalExitCode int `json:"original_exit_code"`
IsHelp bool `json:"is_help"`
}
func TestCrossPlatformCoverageShortcutHallucinationBadcases20260728Replay(t *testing.T) {
var corpus shortcutHallucinationReplayCorpus
if err := json.Unmarshal(shortcutHallucinationBadcases20260728JSON, &corpus); err != nil {
t.Fatalf("decode historical shortcut badcase corpus: %v", err)
}
if corpus.SourceReport != "param_hallucination_20260728_120943" ||
corpus.SourceFile != "raw_dashscope_qwen3_7-max_20260728_120943.json" ||
corpus.SourceDWSCommit != "a8e83e5" || corpus.Model != "dashscope/qwen3.7-max" {
t.Fatalf("unexpected historical corpus provenance: %#v", corpus)
}
if corpus.ExpectedCount != 52 || len(corpus.Badcases) != corpus.ExpectedCount {
t.Fatalf("historical shortcut badcases = %d, expected_count=%d; want 52", len(corpus.Badcases), corpus.ExpectedCount)
}
wantOutcomeCounts := map[string]int{
"rewrite": 8,
"ambiguous": 44,
}
wantSourceCounts := map[string]int{
"chat +group-member-list": 1,
"chat +group-send-text": 1,
"chat +list-group-bots": 1,
"chat +list-robot": 1,
"chat +list-robots": 1,
"chat +members": 3,
"chat +message-list": 1,
"chat +read-single": 6,
"chat +rename-group": 1,
"chat +send": 4,
"chat +send-by-bot": 3,
"chat +send-dm": 2,
"chat +send-file": 15,
"chat +send-image": 3,
"chat +send-media": 2,
"chat +send-message": 3,
"chat +send-single": 1,
"chat +send-text": 2,
"chat +send-to": 1,
}
rewriteTargets := map[string]string{
"chat +members": "chat +group-members",
"chat +group-member-list": "chat +group-members",
"chat +list-group-bots": "chat +chat-bots",
"chat +list-robot": "chat +chat-bots",
"chat +list-robots": "chat +chat-bots",
"chat +rename-group": "chat +chat-update",
}
seenIDs := make(map[string]bool, len(corpus.Badcases))
gotOutcomeCounts := make(map[string]int)
gotSourceCounts := make(map[string]int)
for _, badcase := range corpus.Badcases {
badcase := badcase
t.Run(badcase.ID, func(t *testing.T) {
if badcase.ID == "" || seenIDs[badcase.ID] {
t.Fatalf("missing or duplicate historical badcase id %q", badcase.ID)
}
seenIDs[badcase.ID] = true
gotOutcomeCounts[badcase.ExpectedOutcome]++
gotSourceCounts[badcase.SourcePath]++
args := historicalShortcutBadcaseArgv(t, badcase.Raw)
if len(args) < 2 || strings.Join(args[:2], " ") != badcase.SourcePath {
t.Fatalf("raw argv source = %v, fixture source_path=%q", args, badcase.SourcePath)
}
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
switch badcase.ExpectedOutcome {
case "rewrite":
assertHistoricalShortcutRewrite(t, badcase, rewriteTargets[badcase.SourcePath], ctx, err)
case "ambiguous":
assertHistoricalShortcutReason(t, badcase, ctx, err, "ambiguous_command_fallback")
entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath)
if !ok || entry.Mode != cli.CommandPathFallbackAmbiguous || len(entry.Candidates) < 2 {
t.Fatalf("ambiguous fallback table entry = %#v, %v", entry, ok)
}
case "unknown_shortcut":
assertHistoricalShortcutReason(t, badcase, ctx, err, "unknown_shortcut")
if entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath); ok {
t.Fatalf("contract-incomplete path unexpectedly entered fallback table: %#v", entry)
}
default:
t.Fatalf("unsupported expected_outcome %q", badcase.ExpectedOutcome)
}
})
}
if !reflect.DeepEqual(gotOutcomeCounts, wantOutcomeCounts) {
t.Errorf("historical outcome counts = %v, want %v", gotOutcomeCounts, wantOutcomeCounts)
}
if !reflect.DeepEqual(gotSourceCounts, wantSourceCounts) {
t.Errorf("historical source counts = %v, want %v", gotSourceCounts, wantSourceCounts)
}
}
func TestCrossPlatformCoverageShortcutHallucinationMerged20260720Replay(t *testing.T) {
tests := []struct {
id string
raw string
occurrences int
outcome string
target string
}{
{
id: "dws_im_v2_0013-search-group",
raw: `dws chat +search-group --name "dws测试群02" --format json`,
occurrences: 1,
outcome: "official_alias",
target: "chat +chat-search",
},
{
id: "dws_oa_0017-list-processes",
raw: `dws oa +list-processes --format json --limit 50`,
occurrences: 2,
outcome: "ambiguous",
},
}
totalOccurrences := 0
for _, test := range tests {
test := test
t.Run(test.id, func(t *testing.T) {
totalOccurrences += test.occurrences
args := historicalShortcutBadcaseArgv(t, test.raw)
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
badcase := shortcutHallucinationReplayBadcase{
ID: test.id,
SourcePath: strings.Join(args[:2], " "),
ExpectedOutcome: test.outcome,
Raw: test.raw,
}
switch test.outcome {
case "rewrite":
assertHistoricalShortcutRewrite(t, badcase, test.target, ctx, err)
case "official_alias":
assertHistoricalOfficialAlias(t, badcase, test.target, ctx, err)
case "ambiguous":
assertHistoricalShortcutReason(t, badcase, ctx, err, "ambiguous_command_fallback")
default:
t.Fatalf("unsupported merged expected outcome %q", test.outcome)
}
})
}
if totalOccurrences != 3 {
t.Fatalf("merged shortcut hallucination occurrences = %d, want 3", totalOccurrences)
}
}
func assertHistoricalOfficialAlias(
t *testing.T,
badcase shortcutHallucinationReplayBadcase,
wantTarget string,
ctx *pipeline.Context,
err error,
) {
t.Helper()
if err != nil {
t.Fatalf("historical official alias %q returned error: %v", badcase.SourcePath, err)
}
if wantTarget == "" || ctx == nil || ctx.Command != "dws "+wantTarget {
t.Fatalf("historical official alias context = %#v; want command dws %s", ctx, wantTarget)
}
for _, correction := range ctx.Corrections {
if correction.Handler == "command-path-fallback" {
t.Fatalf("historical official alias received fallback correction: %#v", ctx.Corrections)
}
}
if entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath); ok {
t.Fatalf("official alias unexpectedly remains in fallback table: %#v", entry)
}
}
func historicalShortcutBadcaseArgv(t *testing.T, raw string) []string {
t.Helper()
command := strings.TrimSpace(raw)
for _, suffix := range []string{" 2>&1 | head -50", " 2>&1"} {
command = strings.TrimSuffix(command, suffix)
}
argv, err := cli.ParseAgentExampleArgv(command)
if err != nil {
t.Fatalf("parse historical raw command %q without a shell: %v", raw, err)
}
if len(argv) < 3 || argv[0] != "dws" {
t.Fatalf("historical raw command did not produce dws argv: %q => %v", raw, argv)
}
return append([]string(nil), argv[1:]...)
}
func assertHistoricalShortcutRewrite(
t *testing.T,
badcase shortcutHallucinationReplayBadcase,
wantTarget string,
ctx *pipeline.Context,
err error,
) {
t.Helper()
if wantTarget == "" {
t.Fatalf("historical rewrite %q has no reviewed target", badcase.SourcePath)
}
if ctx == nil || ctx.Command != "dws "+wantTarget {
t.Fatalf("historical rewrite context = %#v, error=%v; want command dws %s", ctx, err, wantTarget)
}
found := false
for _, correction := range ctx.Corrections {
if correction.Handler == "command-path-fallback" && correction.Original == badcase.SourcePath && correction.Corrected == wantTarget {
found = true
break
}
}
if !found {
t.Fatalf("historical rewrite corrections = %#v; want %q -> %q", ctx.Corrections, badcase.SourcePath, wantTarget)
}
wantPrefix := strings.Fields(wantTarget)
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
t.Fatalf("historical rewrite argv = %v, want prefix %v", ctx.Args, wantPrefix)
}
if reason := structuredShortcutReplayReason(err); reason == "unknown_shortcut" || reason == "ambiguous_command_fallback" {
t.Fatalf("historical rewrite returned command-resolution reason %q: %v", reason, err)
}
}
func assertHistoricalShortcutReason(
t *testing.T,
badcase shortcutHallucinationReplayBadcase,
ctx *pipeline.Context,
err error,
wantReason string,
) {
t.Helper()
if ctx == nil {
t.Fatalf("historical %s badcase returned nil context: %v", wantReason, err)
}
if got := structuredShortcutReplayReason(err); got != wantReason {
t.Fatalf("historical badcase %q reason = %q, error=%v; want %q (original exit=%d help=%v)", badcase.Raw, got, err, wantReason, badcase.OriginalExitCode, badcase.IsHelp)
}
if strings.Contains(strings.ToLower(fmt.Sprint(err)), "unknown flag") {
t.Fatalf("historical badcase %q regressed to unknown flag: %v", badcase.Raw, err)
}
}
func structuredShortcutReplayReason(err error) string {
if err == nil {
return ""
}
var structured *apperrors.Error
if stderrors.As(err, &structured) {
return structured.Reason
}
return ""
}
@@ -0,0 +1,603 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"bytes"
"context"
stderrors "errors"
"io"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
const (
runtimeShortcutFallbackChildEnv = "DWS_RUNTIME_SHORTCUT_FALLBACK_CHILD"
runtimeShortcutFallbackConfigEnv = "DWS_RUNTIME_SHORTCUT_FALLBACK_CONFIG_DIR"
)
func TestCrossPlatformCoverageRuntimeUserShortcutTakesPrecedenceOverReviewedFallback(t *testing.T) {
if os.Getenv(runtimeShortcutFallbackChildEnv) == "1" {
t.Setenv("DWS_CONFIG_DIR", os.Getenv(runtimeShortcutFallbackConfigEnv))
engine := newPipelineEngine()
root := NewRootCommandWithEngine(context.Background(), engine)
runtimeCommand := exactAppCommand(root, "chat +members")
if runtimeCommand == nil || !runtimeCommand.Runnable() || cmdutil.IsHintOnlyCommand(runtimeCommand) {
var userDefined []string
for _, candidate := range shortcut.All() {
if candidate.UserDefined {
userDefined = append(userDefined, candidate.Service+" "+candidate.Command)
}
}
t.Fatalf("runtime shortcut was not mounted as an executable command: command=%#v user_defined=%v config=%q", runtimeCommand, userDefined, os.Getenv("DWS_CONFIG_DIR"))
}
root.SetOut(io.Discard)
root.SetErr(io.Discard)
args := []string{"chat", "+members", "--sentinel", "fixture", "--mock", "--format", "json"}
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, engine, args)
if err != nil {
t.Fatal(err)
}
if ctx == nil || ctx.Command != "dws chat +members" {
t.Fatalf("runtime shortcut context = %#v", ctx)
}
assertNoCommandPathFallbackCorrection(t, ctx)
if err := root.Execute(); err != nil {
t.Fatalf("runtime shortcut execute: %v", err)
}
return
}
configDir := t.TempDir()
shortcutDir := filepath.Join(configDir, "shortcuts")
if err := os.MkdirAll(shortcutDir, 0o700); err != nil {
t.Fatal(err)
}
definition := `version: 1
service: chat
command: "+members"
product: chat
description: runtime members fixture
execute:
tool: fixture_user_members
bind:
sentinel: "${sentinel}"
flags:
- name: sentinel
type: string
required: true
desc: fixture sentinel
`
if err := os.WriteFile(filepath.Join(shortcutDir, "chat.members.yaml"), []byte(definition), 0o600); err != nil {
t.Fatal(err)
}
command := exec.Command(os.Args[0], "-test.run=^TestCrossPlatformCoverageRuntimeUserShortcutTakesPrecedenceOverReviewedFallback$", "-test.count=1")
command.Env = append(os.Environ(), runtimeShortcutFallbackConfigEnv+"="+configDir, runtimeShortcutFallbackChildEnv+"=1")
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("runtime shortcut child failed: %v\n%s", err, strings.TrimSpace(string(output)))
}
if !strings.Contains(string(output), `"_tool": "fixture_user_members"`) {
t.Fatalf("runtime shortcut child used the wrong command:\n%s", strings.TrimSpace(string(output)))
}
}
func TestCrossPlatformCoverageReviewedCommandFallbacksReachCanonicalDryRunPayload(t *testing.T) {
_, canonicalQuery, canonicalQueryAttempts, err := executeParamAliasDryRunE2E(t,
"chat", "+chat-search", "--query", "project", "--dry-run",
)
if err != nil || len(canonicalQueryAttempts) != 0 {
t.Fatalf("canonical query preview = %#v, attempts=%v, error=%v", canonicalQuery, canonicalQueryAttempts, err)
}
tests := []struct {
name string
args []string
wantCommand string
wantPreview paramAliasDryRunPreview
}{
{
name: "group search query",
args: []string{"chat", "+group-search", "--query", "project", "--dry-run"},
wantCommand: "dws chat +chat-search",
wantPreview: canonicalQuery,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctx, preview, attempts, executeErr := executeParamAliasDryRunE2E(t, test.args...)
if executeErr != nil {
t.Fatalf("fallback execute error = %v", executeErr)
}
if len(attempts) != 0 {
t.Fatalf("fallback crossed dry-run dispatch boundary: %#v", attempts)
}
if !reflect.DeepEqual(preview, test.wantPreview) {
t.Fatalf("fallback preview = %#v, want canonical %#v", preview, test.wantPreview)
}
if ctx == nil || ctx.Command != test.wantCommand || len(ctx.Corrections) == 0 {
t.Fatalf("fallback context = %#v, want command %q", ctx, test.wantCommand)
}
correction := ctx.Corrections[0]
if correction.Handler != "command-path-fallback" || correction.Kind != "reviewed-fallback" {
t.Fatalf("fallback correction = %#v", correction)
}
})
}
}
func TestCrossPlatformCoverageOfficialCommandAliasesBypassFallbackAndReachEquivalentPayload(t *testing.T) {
_, canonicalQuery, canonicalQueryAttempts, err := executeParamAliasDryRunE2E(t,
"chat", "+chat-search", "--query", "project", "--dry-run",
)
if err != nil || len(canonicalQueryAttempts) != 0 {
t.Fatalf("canonical query preview = %#v, attempts=%v, error=%v", canonicalQuery, canonicalQueryAttempts, err)
}
_, canonicalKeyword, canonicalKeywordAttempts, err := executeParamAliasDryRunE2E(t,
"chat", "+chat-search", "--keyword", "project", "--dry-run",
)
if err != nil || len(canonicalKeywordAttempts) != 0 {
t.Fatalf("canonical keyword preview = %#v, attempts=%v, error=%v", canonicalKeyword, canonicalKeywordAttempts, err)
}
_, nativeCanonical, nativeCanonicalAttempts, err := executeParamAliasDryRunE2E(t,
"chat", "search", "--query", "project", "--dry-run",
)
if err != nil || len(nativeCanonicalAttempts) != 0 {
t.Fatalf("native canonical preview = %#v, attempts=%v, error=%v", nativeCanonical, nativeCanonicalAttempts, err)
}
tests := []struct {
name string
args []string
wantCommand string
wantPreview paramAliasDryRunPreview
}{
{
name: "search group shortcut alias",
args: []string{"chat", "+search-group", "--keyword", "project", "--dry-run"},
wantCommand: "dws chat +chat-search",
wantPreview: canonicalKeyword,
},
{
name: "chat group search shortcut alias",
args: []string{"chat", "+chat-group-search", "--query", "project", "--dry-run"},
wantCommand: "dws chat +chat-search",
wantPreview: canonicalQuery,
},
{
name: "hidden native compatibility leaf",
args: []string{"chat", "group", "search", "--query", "project", "--dry-run"},
wantCommand: "dws chat group search",
wantPreview: nativeCanonical,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctx, preview, attempts, executeErr := executeParamAliasDryRunE2E(t, test.args...)
if executeErr != nil {
t.Fatalf("official alias execute error = %v", executeErr)
}
if len(attempts) != 0 {
t.Fatalf("official alias crossed dry-run dispatch boundary: %#v", attempts)
}
if !reflect.DeepEqual(preview, test.wantPreview) {
t.Fatalf("official alias preview = %#v, want canonical %#v", preview, test.wantPreview)
}
if ctx == nil || ctx.Command != test.wantCommand {
t.Fatalf("official alias context = %#v, want command %q", ctx, test.wantCommand)
}
assertNoCommandPathFallbackCorrection(t, ctx)
})
}
}
func TestCrossPlatformCoverageReviewedReadFallbacksResolveCanonicalLeafBeforeParameterValidation(t *testing.T) {
tests := []struct {
name string
args []string
target string
}{
{
name: "members",
args: []string{"chat", "+members", "--group", "Fixture Group"},
target: "chat +group-members",
},
{
name: "group member list",
args: []string{"chat", "+group-member-list", "--group-name", "Fixture Group"},
target: "chat +group-members",
},
{
name: "list group bots",
args: []string{"chat", "+list-group-bots", "--group", "cid-fixture"},
target: "chat +chat-bots",
},
{
name: "list robot",
args: []string{"chat", "+list-robot", "--group", "cid-fixture"},
target: "chat +chat-bots",
},
{
name: "list robots",
args: []string{"chat", "+list-robots", "--group", "cid-fixture"},
target: "chat +chat-bots",
},
{
name: "bot list",
args: []string{"chat", "+bot-list", "--group", "cid-fixture"},
target: "chat +chat-bots",
},
{
name: "conversation detail",
args: []string{"chat", "+conversation-detail", "--group", "cid-fixture"},
target: "chat +conversation-info",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(test.args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
if err != nil {
t.Fatal(err)
}
if ctx == nil || ctx.Command != "dws "+test.target || len(ctx.Corrections) == 0 {
t.Fatalf("read fallback context = %#v", ctx)
}
wantPrefix := strings.Fields(test.target)
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
t.Fatalf("read fallback args = %v, want prefix %v", ctx.Args, wantPrefix)
}
})
}
}
func TestCrossPlatformCoverageOfficialGroupMembersAliasBypassesCommandFallback(t *testing.T) {
args := []string{"chat", "+chat-group-members", "--conversation-id", "cid-fixture", "--member-types", "user"}
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
t.Fatal(err)
}
if ctx == nil || ctx.Command != "dws chat +chat-members-list" {
t.Fatalf("official group-members alias context = %#v", ctx)
}
assertNoCommandPathFallbackCorrection(t, ctx)
}
func TestCrossPlatformCoverageReviewedRenameFallbackResolvesCanonicalLeafBeforeParameterValidation(t *testing.T) {
args := []string{"chat", "+rename-group", "--id", "cid-fixture", "--name", "Fixture Group"}
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
t.Fatal(err)
}
if ctx == nil || ctx.Command != "dws chat +chat-update" || len(ctx.Corrections) == 0 {
t.Fatalf("rename fallback context = %#v", ctx)
}
wantPrefix := []string{"chat", "+chat-update"}
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
t.Fatalf("rename fallback args = %v, want prefix %v", ctx.Args, wantPrefix)
}
}
func TestCrossPlatformCoverageReviewedDocHistorySaveFallbacksPreserveArguments(t *testing.T) {
sources := []string{"+create-version", "+save-version", "+snapshot", "+version-create"}
for _, source := range sources {
t.Run(source, func(t *testing.T) {
args := []string{"doc", source, "--node", "node-fixture", "--mock", "--format", "json"}
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
t.Fatal(err)
}
if ctx == nil || ctx.Command != "dws doc +history-save" || len(ctx.Corrections) == 0 {
t.Fatalf("history-save fallback context = %#v", ctx)
}
wantArgs := []string{"doc", "+history-save", "--node", "node-fixture", "--mock", "--format", "json"}
if !reflect.DeepEqual(ctx.Args, wantArgs) {
t.Fatalf("history-save fallback args = %v, want %v", ctx.Args, wantArgs)
}
correction := ctx.Corrections[0]
if correction.Handler != "command-path-fallback" || correction.Kind != "reviewed-fallback" {
t.Fatalf("history-save correction = %#v", correction)
}
})
}
}
func TestCrossPlatformCoverageDocExportPDFRemainsUnknownShortcut(t *testing.T) {
if entry, ok := cli.LookupCommandPathFallback("doc +export-pdf"); ok {
t.Fatalf("+export-pdf must not receive a path-only fallback: %#v", entry)
}
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
args := []string{"doc", "+export-pdf", "--node", "node-fixture", "--mock", "--format", "json"}
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if ctx == nil {
t.Fatal("+export-pdf returned nil context")
}
var structured *apperrors.Error
if !stderrors.As(err, &structured) || structured.Reason != "unknown_shortcut" {
t.Fatalf("+export-pdf preparse error = %T %#v", err, err)
}
if len(ctx.Corrections) != 0 {
t.Fatalf("+export-pdf unexpectedly received corrections: %#v", ctx.Corrections)
}
}
func TestCrossPlatformCoverageReviewedAmbiguousCommandFallbackNeverDispatches(t *testing.T) {
tests := []struct {
path string
candidates []string
}{
{path: "chat +group-send-text", candidates: []string{"chat +send-to-group", "chat +messages-send"}},
{path: "chat +message-list", candidates: []string{"chat +chat-messages", "chat +messages-list-direct", "chat +search-msg", "chat +unread-chats"}},
{path: "chat +read-single", candidates: []string{"chat +messages-list-direct", "chat +chat-messages"}},
{path: "chat +send", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
{path: "chat +send-by-bot", candidates: []string{"chat +messages-send", "chat message send-by-bot"}},
{path: "chat +send-dm", candidates: []string{"chat +dm", "chat +messages-send"}},
{path: "chat +send-message", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
{path: "chat +send-single", candidates: []string{"chat +dm", "chat +messages-send"}},
{path: "chat +send-text", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
{path: "chat +send-to", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
{path: "chat +send-file", candidates: []string{"chat +messages-send", "chat message send"}},
{path: "chat +send-image", candidates: []string{"chat +messages-send", "chat message send"}},
{path: "chat +send-media", candidates: []string{"chat +messages-send", "chat message send"}},
{path: "chat +conversation-category-list", candidates: []string{"chat +category-list", "chat +category-list-conversations"}},
{path: "chat +conversation-group-list", candidates: []string{"chat +category-list-conversations", "chat +conversation-list"}},
{path: "chat +list-my-groups", candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"}},
{path: "oa +list-processes", candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}},
}
for _, test := range tests {
t.Run(test.path, func(t *testing.T) {
caller := &paramAliasCaptureCaller{}
args := append(strings.Fields(test.path), "--format", "json")
ctx, err := executeParamAliasE2E(t, caller, args...)
if ctx == nil {
t.Fatal("ambiguous command fallback returned nil context")
}
var structured *apperrors.Error
if !stderrors.As(err, &structured) || structured.Reason != "ambiguous_command_fallback" || structured.ExitCode() != 3 {
t.Fatalf("ambiguous error = %T %#v", err, err)
}
if len(structured.Actions) != len(test.candidates) || len(caller.calls) != 0 {
t.Fatalf("ambiguous actions=%v calls=%#v", structured.Actions, caller.calls)
}
for _, candidate := range test.candidates {
if !strings.Contains(structured.Hint, "dws "+candidate) {
t.Errorf("ambiguous hint %q missing candidate %q", structured.Hint, candidate)
}
}
})
}
}
func TestCrossPlatformCoverageCanonicalShortcutBadFlagDoesNotEnterCommandFallback(t *testing.T) {
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
args := []string{"chat", "+chat-search", "--definitely-not-a-real-flag", "project"}
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
t.Fatalf("preparse error = %v", err)
}
if ctx == nil || ctx.Command != "dws chat +chat-search" {
t.Fatalf("canonical context = %#v", ctx)
}
for _, correction := range ctx.Corrections {
if correction.Handler == "command-path-fallback" {
t.Fatalf("canonical command received fallback correction: %#v", ctx.Corrections)
}
}
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "unknown flag") {
t.Fatalf("canonical bad flag error = %v", err)
}
}
func TestCrossPlatformCoverageRewrittenShortcutStillUsesCanonicalParameterErrors(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{
name: "missing required query",
args: []string{"chat", "+group-search", "--dry-run"},
want: "请至少指定 --query、--keyword 之一",
},
{
name: "unknown canonical flag",
args: []string{"chat", "+group-search", "--definitely-not-a-real-flag", "project"},
want: "unknown flag",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(test.args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
if err != nil {
t.Fatalf("preparse error = %v", err)
}
if ctx == nil || ctx.Command != "dws chat +chat-search" || len(ctx.Corrections) == 0 ||
ctx.Corrections[0].Handler != "command-path-fallback" {
t.Fatalf("rewritten context = %#v", ctx)
}
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("canonical parameter error = %v, want containing %q", err, test.want)
}
})
}
}
func TestCrossPlatformCoverageCommandFallbackNamesStayOutOfHelpSchemaAndShortcutCatalog(t *testing.T) {
invalidShortcuts := map[string]bool{
"+bot-list": true,
"+conversation-detail": true,
"+conversation-category-list": true,
"+conversation-group-list": true,
"+list-my-groups": true,
"+group-search": true,
"+members": true,
"+group-member-list": true,
"+list-group-bots": true,
"+list-robot": true,
"+list-robots": true,
"+message-list": true,
"+read-single": true,
"+rename-group": true,
"+send": true,
"+send-by-bot": true,
"+send-dm": true,
"+send-message": true,
"+send-single": true,
"+send-text": true,
"+send-to": true,
"+send-file": true,
"+send-image": true,
"+send-media": true,
"+group-send-text": true,
}
root := NewSchemaSourceRootCommand()
chat := exactAppCommand(root, "chat")
if chat == nil {
t.Fatal("chat command missing")
}
for _, child := range chat.Commands() {
if invalidShortcuts[child.Name()] {
t.Fatalf("fallback name %q became a real command", child.Name())
}
for _, alias := range child.Aliases {
if invalidShortcuts[alias] {
t.Fatalf("fallback name %q became a Cobra alias", alias)
}
}
}
for path := range invalidShortcuts {
if _, ok := cli.ResolveMeta("chat " + path); ok {
t.Fatalf("fallback path %q leaked into embedded Schema", path)
}
}
for _, declared := range shortcut.All() {
if declared.Service == "chat" && invalidShortcuts[declared.Command] {
t.Fatalf("fallback name %q leaked into shortcut catalog", declared.Command)
}
}
if _, ok := cli.ResolveMeta("oa +list-processes"); ok {
t.Fatal("fallback path oa +list-processes leaked into embedded Schema")
}
for _, declared := range shortcut.All() {
if declared.Service == "oa" && declared.Command == "+list-processes" {
t.Fatal("fallback name +list-processes leaked into shortcut catalog")
}
}
group := exactAppCommand(root, "chat group")
searchCompatibility := exactAppCommand(root, "chat group search")
if group == nil || searchCompatibility == nil || !searchCompatibility.Hidden || cmdutil.IsHintOnlyCommand(searchCompatibility) {
t.Fatalf("chat group search must be a hidden executable compatibility leaf: group=%v search=%v", group, searchCompatibility)
}
var help bytes.Buffer
group.SetOut(&help)
if err := group.Help(); err != nil {
t.Fatal(err)
}
if strings.Contains(help.String(), "\n search ") {
t.Fatalf("hidden fallback source leaked into group help:\n%s", help.String())
}
}
func TestCrossPlatformCoverageDocCommandFallbackNamesStayOutOfHelpSchemaAndShortcutCatalog(t *testing.T) {
invalidShortcuts := map[string]bool{
"+create-version": true,
"+save-version": true,
"+snapshot": true,
"+version-create": true,
"+export-pdf": true,
}
root := NewSchemaSourceRootCommand()
doc := exactAppCommand(root, "doc")
if doc == nil {
t.Fatal("doc command missing")
}
for _, child := range doc.Commands() {
if invalidShortcuts[child.Name()] {
t.Fatalf("invalid shortcut %q became a real command", child.Name())
}
for _, alias := range child.Aliases {
if invalidShortcuts[alias] {
t.Fatalf("invalid shortcut %q became a Cobra alias", alias)
}
}
}
for path := range invalidShortcuts {
if _, ok := cli.ResolveMeta("doc " + path); ok {
t.Fatalf("invalid path %q leaked into embedded Schema", path)
}
}
for _, declared := range shortcut.All() {
if declared.Service == "doc" && invalidShortcuts[declared.Command] {
t.Fatalf("invalid shortcut %q leaked into shortcut catalog", declared.Command)
}
}
}
func assertNoCommandPathFallbackCorrection(t *testing.T, ctx *pipeline.Context) {
t.Helper()
for _, correction := range ctx.Corrections {
if correction.Handler == "command-path-fallback" {
t.Fatalf("official command unexpectedly received fallback correction: %#v", ctx.Corrections)
}
}
}
func exactAppCommand(root *cobra.Command, path string) *cobra.Command {
current := root
parts := strings.Fields(path)
if len(parts) > 0 && parts[0] == root.Name() {
parts = parts[1:]
}
for _, part := range parts {
var next *cobra.Command
for _, child := range current.Commands() {
if child.Name() == part {
next = child
break
}
}
if next == nil {
return nil
}
current = next
}
return current
}
+20 -12
View File
@@ -58,6 +58,14 @@ func paramAliasResponseForTool(tool string) string {
return `{"deptList":[{"deptId":1,"name":"Fixture Dept"}]}`
case "search_groups":
return `{"result":{"items":[{"openConversationId":"fixture-conversation","title":"Fixture Group"}]}}`
case "search_contact_by_key_word":
return `{"result":[{"name":"Fixture User","userId":"fixture-user","openDingTalkId":"D-fixture-user"}]}`
case "list_doc_versions":
return `{"result":{"items":[{"version":3}]}}`
case "search_doc_templates":
return `{"result":[{"templateId":"fixture-template-id"}]}`
case "get_document_content":
return `{"revision":1}`
default:
return `{}`
}
@@ -191,7 +199,7 @@ func executeParamAliasE2E(t *testing.T, caller *paramAliasCaptureCaller, args ..
return ctx, root.Execute()
}
func TestBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
func TestCrossPlatformCoverageBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
tests := []struct {
name string
confirmation []string
@@ -244,7 +252,7 @@ func TestBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
}
}
func TestParamAliasReadCommandFinalPayload(t *testing.T) {
func TestCrossPlatformCoverageParamAliasReadCommandFinalPayload(t *testing.T) {
caller := &paramAliasCaptureCaller{}
start := "2026-03-10T14:00:00+08:00"
end := "2026-03-10T18:00:00+08:00"
@@ -279,7 +287,7 @@ func TestParamAliasReadCommandFinalPayload(t *testing.T) {
}
}
func TestParamAliasWriteCommandFinalPayload(t *testing.T) {
func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
caller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasE2E(t, caller,
"chat", "message", "send",
@@ -311,7 +319,7 @@ func TestParamAliasWriteCommandFinalPayload(t *testing.T) {
}
}
func TestChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
tests := []struct {
name string
command []string
@@ -390,7 +398,7 @@ func TestChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
}
}
func TestAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
func TestCrossPlatformCoverageAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
root := NewRootCommand()
engine := newPipelineEngine()
entries, err := cli.ReduceParamAliases(root)
@@ -478,7 +486,7 @@ func TestAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
t.Logf("verified generated chat parameter routes: entries=%d aliases=%d blocked=%d ambiguous=%d", chatEntries, aliasCases, guardCases[pipeline.FlagProtectionBlocked], guardCases[pipeline.FlagProtectionAmbiguous])
}
func TestIMUserIDHallucinationRoutes(t *testing.T) {
func TestCrossPlatformCoverageIMUserIDHallucinationRoutes(t *testing.T) {
tests := []struct {
command string
want string
@@ -531,7 +539,7 @@ func TestIMUserIDHallucinationRoutes(t *testing.T) {
}
}
func TestHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
func TestCrossPlatformCoverageHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
const command = "chat message list-direct"
if _, ok := cli.LookupParamAlias(command); ok {
t.Fatalf("hidden command %q unexpectedly entered the public generated alias table", command)
@@ -556,7 +564,7 @@ func TestHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
}
}
func TestSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing.T) {
func TestCrossPlatformCoverageSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing.T) {
tests := []struct {
name string
tool string
@@ -653,7 +661,7 @@ func TestSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing
}
}
func TestParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
func TestCrossPlatformCoverageParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
caller := &paramAliasCaptureCaller{}
for _, args := range [][]string{
{"calendar", "event", "list", "--date", "2026-03-10", "--start", "2026-03-11"},
@@ -678,7 +686,7 @@ func TestParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
}
}
func TestAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
func TestCrossPlatformCoverageAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
@@ -770,7 +778,7 @@ func TestAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
}
}
func TestRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testing.T) {
func TestCrossPlatformCoverageRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testing.T) {
for _, test := range []struct {
path string
emitted string
@@ -817,7 +825,7 @@ func TestRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testin
}
}
func TestFlagConflictErrorFormattingIsDeterministic(t *testing.T) {
func TestCrossPlatformCoverageFlagConflictErrorFormattingIsDeterministic(t *testing.T) {
err := (&pipeline.FlagConflictError{Command: "dws demo", Canonical: "start", Spellings: []string{"start", "date"}}).Error()
want := `conflicting parameter spellings for --start on "dws demo": --date, --start; pass exactly one spelling`
if err != want {
+11 -5
View File
@@ -44,7 +44,7 @@ import (
// - expect=<realFlag> : emitted must reduce to that canonical flag.
// - expect=did-you-mean:blocked : block guard hit; never auto-rewritten.
// - expect=did-you-mean:ambiguous: co-occurrence guard hit; never rewritten.
func TestParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
func TestCrossPlatformCoverageParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
@@ -113,13 +113,19 @@ func TestParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
t.Fatalf("reviewed canonical --%s on %q is not a real Cobra flag", c.Expect, c.Command)
}
flagArgs := ctx.Args[len(strings.Fields(c.Command)):]
if len(flagArgs) < 2 || flagArgs[1] != fixtureValue {
t.Fatalf("%q on %q lost its value: args=%v", c.Emitted, c.Command, ctx.Args)
if len(flagArgs) == 0 {
t.Fatalf("%q on %q lost its flag and value: args=%v", c.Emitted, c.Command, ctx.Args)
}
got := flagArgs[0]
gotBare := strings.SplitN(strings.TrimPrefix(got, "--"), "=", 2)[0]
gotBare, inlineValue, hasInlineValue := strings.Cut(strings.TrimPrefix(got, "--"), "=")
switch {
case got == "--"+c.Expect:
case hasInlineValue && inlineValue != fixtureValue:
t.Fatalf("%q on %q changed its inline value: got %q, want %q (args=%v)", c.Emitted, c.Command, inlineValue, fixtureValue, ctx.Args)
case !hasInlineValue && (len(flagArgs) < 2 || flagArgs[1] != fixtureValue):
t.Fatalf("%q on %q lost its value: args=%v", c.Emitted, c.Command, ctx.Args)
}
switch {
case gotBare == c.Expect:
// (1) rewritten; the embedded table must agree.
if !hasEntry {
t.Fatalf("%q on %q was rewritten without an embedded alias entry", c.Emitted, c.Command)
@@ -30,13 +30,34 @@ var paramAliasCompleteCommands = map[string][]string{
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
"chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
"chat +chat-audit-join": {"chat", "+chat-audit-join", "--group", "fixture-conversation", "--record-id", "7", "--applicant", "user-1", "--inviter", "user-2", "--status", "AuditApprove", "--yes"},
"chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
"chat +chat-members-list": {"chat", "+chat-members-list", "--conversation-id", "fixture-conversation", "--member-types", "user,bot"},
"chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", "D-user-1,D-user-2", "--mute-time", "3600000", "--yes"},
"chat +chat-remove-bot": {"chat", "+chat-remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
"chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", "D-user-1", "--role-ids", "role-1", "--yes"},
"chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", "D-user-1", "--yes"},
"chat +chat-update": {"chat", "+chat-update", "--group", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
"chat +category-create": {"chat", "+category-create", "--title", "Fixture Cat", "--yes"},
"chat +category-rename": {"chat", "+category-rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
"chat +group-members": {"chat", "+group-members", "--group", "Fixture Group"},
"chat +conversation-set-top": {"chat", "+conversation-set-top", "--conversation-id", "fixture-conversation", "--yes"},
"chat +feed-group-query-item": {"chat", "+feed-group-query-item", "--category-id", "7", "--conversation-ids", "fixture-conversation"},
"chat +flag-cancel": {"chat", "+flag-cancel", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
"chat +flag-create": {"chat", "+flag-create", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
"chat +flag-list": {"chat", "+flag-list", "--cursor", "0", "--size", "7"},
"chat +messages-combine-forward": {"chat", "+messages-combine-forward", "--src-conversation-id", "fixture-source", "--msg-ids", "message-1,message-2", "--dest-conversation-id", "fixture-destination", "--yes"},
"chat +messages-forward": {"chat", "+messages-forward", "--src-conversation-id", "fixture-source", "--msg-id", "message-1", "--dest-conversation-id", "fixture-destination", "--yes"},
"chat +messages-forward-topic": {"chat", "+messages-forward-topic", "--src-msg-id", "message-1", "--src-conversation-id", "fixture-source", "--src-thread-id", "convThread-fixture", "--dest-conversation-id", "fixture-destination", "--yes"},
"chat +messages-list": {"chat", "+messages-list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
"chat +messages-resource-download": {"chat", "+messages-resource-download", "--resource-id", "resource-1", "--message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--output", "downloads/fixture.bin"},
"chat +messages-set-pin": {"chat", "+messages-set-pin", "--open-conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
"chat +search-msg": {"chat", "+search-msg", "--group", "fixture-conversation", "--query", "fixture", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--no-enrich"},
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"},
@@ -81,9 +102,36 @@ var paramAliasCompleteCommands = map[string][]string{
"ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"},
"ding message receiver-status": {"ding", "message", "receiver-status", "--ding-id", "ding-1"},
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1", "--yes"},
"doc +comment-create": {"doc", "+comment-create", "--node", "node-1", "--content", "fixture comment", "--yes"},
"doc +comment-list": {"doc", "+comment-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
"doc +comment-reply": {"doc", "+comment-reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--yes"},
"doc +access-grant": {"doc", "+access-grant", "--node", "node-1", "--to", "Fixture User", "--role", "READER", "--workspace", "workspace-1", "--yes"},
"doc +copy": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
"doc +create": {"doc", "+create", "--name", "Fixture Document", "--content", "fixture body", "--doc-format", "markdown"},
"doc +create-from-template": {"doc", "+create-from-template", "--query", "fixture template", "--name", "Fixture From Template", "--folder", "folder-1", "--workspace", "workspace-1"},
"doc +doc-append": {"doc", "+doc-append", "--doc", "node-1", "--text", "fixture appendix", "--yes"},
"doc +export-submit": {"doc", "+export-submit", "--node", "node-1", "--export-format", "docx"},
"doc +fetch": {"doc", "+fetch", "--node", "node-1", "--scope", "section", "--start-block-id", "block-1"},
"doc +find-doc": {"doc", "+find-doc", "--query", "fixture", "--limit", "7"},
"doc +history-revert": {"doc", "+history-revert", "--node", "node-1", "--version", "3", "--yes"},
"doc +inspect": {"doc", "+inspect", "--node", "node-1", "--include-history"},
"doc +list": {"doc", "+list", "--folder", "folder-1", "--cursor", "cursor-1"},
"doc +move": {"doc", "+move", "--node", "node-1", "--folder", "folder-1", "--yes"},
"doc +search": {"doc", "+search", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
"doc +template-list": {"doc", "+template-list", "--source", "MY", "--limit", "7", "--cursor", "cursor-1"},
"doc +template-search": {"doc", "+template-search", "--query", "fixture", "--source", "MY", "--limit", "7"},
"doc +version-list": {"doc", "+version-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
"doc +version-revert": {"doc", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "append", "--content", "fixture body", "--expected-revision", "1", "--yes"},
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
"doc comment create-inline": {"doc", "comment", "create-inline", "--node", "node-1", "--block-id", "block-1", "--start", "0", "--end", "7", "--content", "fixture comment", "--yes"},
"doc comment delete": {"doc", "comment", "delete", "--node", "node-1", "--comment-key", "comment-1", "--yes"},
"doc comment reply": {"doc", "comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--mentioned-open-conversation-id", "cid-1,cid-2", "--yes"},
"doc comment update": {"doc", "comment", "update", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture update", "--yes"},
"doc version revert": {"doc", "version", "revert", "--node", "node-1", "--version", "3", "--yes"},
"drive info": {"drive", "info", "--node", "node-1", "--space-id", "space-1"},
"drive list": {"drive", "list", "--folder", "folder-1", "--limit", "7"},
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"},
@@ -101,6 +149,13 @@ var paramAliasCompleteCommands = map[string][]string{
// that case the shared command template above cannot contain every canonical
// flag at once, so select a fixture-specific complete invocation here.
var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"doc +copy": {
"folder": {"doc", "+copy", "--node", "node-1", "--folder", "folder-1", "--yes"},
"workspace": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
},
"doc block insert": {
"parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--text", "fixture paragraph", "--yes"},
},
"chat message list": {
"user": {"chat", "message", "list", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
},
@@ -111,6 +166,9 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--uuid", "param-alias-equivalence-group", "--yes"},
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--uuid", "param-alias-equivalence-file", "--yes"},
},
"chat +conversation-set-top": {
"conversation-ids": {"chat", "+conversation-set-top", "--conversation-ids", "fixture-conversation-1,fixture-conversation-2", "--yes"},
},
}
// paramAliasNewIMCases is the exact set of aliases added by the reviewed IM
@@ -149,6 +207,38 @@ var paramAliasNewIMCases = []struct {
{command: "chat message send", emitted: "file", canonical: "file-path"},
{command: "chat message send-by-bot", emitted: "at-users", canonical: "at-user-ids"},
{command: "chat message send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
{command: "chat +chat-update", emitted: "chat-id", canonical: "group"},
{command: "chat +chat-update", emitted: "conversation-id", canonical: "group"},
{command: "chat +chat-update", emitted: "open-conversation-id", canonical: "group"},
{command: "chat +chat-update", emitted: "title", canonical: "name"},
{command: "chat +chat-update", emitted: "new-title", canonical: "name"},
{command: "chat +flag-list", emitted: "limit", canonical: "size"},
{command: "chat +chat-members-list", emitted: "chat-id", canonical: "conversation-id"},
{command: "chat +chat-members-list", emitted: "id", canonical: "conversation-id"},
{command: "chat +conversation-set-top", emitted: "open-conversation-id", canonical: "conversation-id"},
{command: "chat +conversation-set-top", emitted: "chat-ids", canonical: "conversation-ids"},
{command: "chat +chat-members-get", emitted: "conversation-id", canonical: "id"},
{command: "chat +chat-members-get", emitted: "open-dingtalk-ids", canonical: "users"},
{command: "chat +chat-members-get", emitted: "chat", canonical: "id"},
{command: "chat +messages-list", emitted: "start", canonical: "time"},
{command: "chat +messages-reply", emitted: "msg-id", canonical: "ref-msg-id"},
{command: "chat +messages-reply", emitted: "chat", canonical: "conversation-id"},
{command: "chat +flag-cancel", emitted: "group", canonical: "conversation-id"},
{command: "chat +flag-cancel", emitted: "chat", canonical: "conversation-id"},
{command: "chat +flag-create", emitted: "group", canonical: "conversation-id"},
{command: "chat +chat-add-bot", emitted: "conversation-id", canonical: "id"},
{command: "chat +chat-add-bot", emitted: "robot", canonical: "robot-code"},
{command: "chat +chat-audit-join", emitted: "applicant-user-id", canonical: "applicant"},
{command: "chat +chat-mute-member", emitted: "user-ids", canonical: "users"},
{command: "chat +chat-remove-bot", emitted: "open-bot-id", canonical: "bot-id"},
{command: "chat +chat-role-remove-user", emitted: "open-dingtalk-id", canonical: "user"},
{command: "chat +chat-transfer-owner", emitted: "user-id", canonical: "new-owner"},
{command: "chat +feed-group-query-item", emitted: "chat-ids", canonical: "conversation-ids"},
{command: "chat +messages-combine-forward", emitted: "src-open-cid", canonical: "src-conversation-id"},
{command: "chat +messages-forward", emitted: "source-message-id", canonical: "msg-id"},
{command: "chat +messages-forward-topic", emitted: "src-open-message-id", canonical: "src-msg-id"},
{command: "chat +messages-resource-download", emitted: "conversation-id", canonical: "open-conversation-id"},
{command: "chat +messages-set-pin", emitted: "conversation-id", canonical: "open-conversation-id"},
}
// paramAliasRepresentativePayloadCases keeps final transport coverage across
@@ -163,14 +253,33 @@ var paramAliasNewIMCases = []struct {
// That duplicated command construction was enough to push the pre-existing
// macOS app suite beyond its package-level 10-minute timeout.
var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
paramAliasPayloadCaseKey("contact user profile get", "user-id"): true, // native compatibility flag
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
paramAliasPayloadCaseKey("contact user profile get", "user-id"): true, // native compatibility flag
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
paramAliasPayloadCaseKey("doc +comment-create", "body"): true, // write shortcut content alias
paramAliasPayloadCaseKey("doc +copy", "parent-folder-id"): true, // Doc folder role on a write shortcut
paramAliasPayloadCaseKey("doc +create", "content-format"): true, // shortcut format alias preserves markdown/jsonml enum
paramAliasPayloadCaseKey("doc +create-from-template", "keyword"): true, // template search alias composes with a write workflow
paramAliasPayloadCaseKey("doc +create-from-template", "workspace-id"): true, // template target workspace identifier
paramAliasPayloadCaseKey("doc +create-from-template", "parent-folder-id"): true, // template target folder identifier
paramAliasPayloadCaseKey("doc +export-submit", "doc-id"): true, // Doc node identifier alias
paramAliasPayloadCaseKey("doc +fetch", "start-block"): true, // section boundary role remains exact
paramAliasPayloadCaseKey("doc +history-revert", "version-number"): true, // destructive history version alias keeps confirmation
paramAliasPayloadCaseKey("doc +inspect", "include-versions"): true, // boolean section alias preserves value
paramAliasPayloadCaseKey("doc +template-list", "next-token"): true, // Doc cursor alias on a read shortcut
paramAliasPayloadCaseKey("doc +update", "mode"): true, // write operation selector alias
paramAliasPayloadCaseKey("doc +update", "revision"): true, // optimistic edit revision alias
paramAliasPayloadCaseKey("doc +access-grant", "doc-id"): true, // permission write keeps document identity
paramAliasPayloadCaseKey("doc +version-revert", "version-number"): true, // high-write version role with canonical confirmation
paramAliasPayloadCaseKey("doc block insert", "content"): true, // block write content alias
paramAliasPayloadCaseKey("doc block insert", "parent-block-id"): true, // scoped block-role alias
paramAliasPayloadCaseKey("doc comment delete", "comment-id"): true, // destructive comment-key alias
paramAliasPayloadCaseKey("doc comment reply", "mentioned-open-conversation-ids"): true, // list-valued group mention role
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
}
func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
@@ -273,8 +382,9 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
}
canonicalCaller := &paramAliasCaptureCaller{}
if _, err := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...); err != nil {
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", err, canonicalArgs, canonicalCaller.calls)
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
if canonicalErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, canonicalErr) {
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
}
if len(canonicalCaller.calls) == 0 {
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
@@ -290,13 +400,16 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
}
activeAliases++
aliasCaller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
if err != nil {
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", err, aliasArgs, aliasCaller.calls)
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
if aliasErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, aliasErr) {
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
}
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
if (canonicalErr == nil) != (aliasErr == nil) || (canonicalErr != nil && canonicalErr.Error() != aliasErr.Error()) {
t.Fatalf("canonical and alias completion errors differ: canonical=%v alias=%v", canonicalErr, aliasErr)
}
normalizeParamAliasVolatileDefaults(test.command, canonicalCaller, aliasCaller)
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
@@ -308,6 +421,16 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
}
}
// Resource download deliberately continues from the transport call into a
// local HTTPS download. The generic capture caller returns an empty object, so
// this command's stable post-transport validation error is the expected test
// boundary; canonical and alias calls must still produce the same request and
// the same error.
func paramAliasExpectedCaptureBoundaryError(command string, err error) bool {
return command == "chat +messages-resource-download" && err != nil &&
strings.Contains(err.Error(), "资源下载接口未返回合法的 HTTPS 下载地址")
}
// +chat-messages supplies the current wall-clock time when callers omit
// --time. Alias equivalence concerns the resolved target and transport shape;
// a suite crossing a second boundary must not make that default appear
+64 -2
View File
@@ -24,7 +24,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
func TestLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
func TestCrossPlatformCoverageLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
tests := []struct {
name string
args []string
@@ -55,7 +55,7 @@ func TestLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
}
}
func TestPreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
func TestCrossPlatformCoveragePreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
root := NewSchemaSourceRootCommand()
args := []string{
"chat", "message", "send",
@@ -98,3 +98,65 @@ func TestPreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
t.Fatalf("--debug details missing from early error:\n%s", rendered)
}
}
func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTree(t *testing.T) {
tests := []struct {
name string
args []string
wantReason string
wantCommand string
}{
{
name: "unknown shortcut",
args: []string{"chat", "+chat-mesages", "--keyword", "x", "--format", "json"},
wantReason: "unknown_shortcut",
wantCommand: "dws chat",
},
{
name: "unknown dev app subcommand",
args: []string{"dev", "app", "search", "--keyword", "x", "--format", "json"},
wantReason: "unknown_subcommand",
wantCommand: "dws dev app",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := NewSchemaSourceRootCommand()
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
if ctx == nil || ctx.Command != test.wantCommand {
t.Fatalf("Context = %#v, want command %q", ctx, test.wantCommand)
}
err = newPreParseValidationError(err)
var structured *apperrors.Error
if !stderrors.As(err, &structured) {
t.Fatalf("error = %T %v", err, err)
}
if structured.Reason != test.wantReason || structured.ExitCode() != 3 {
t.Fatalf("structured error = %#v", structured)
}
if len(structured.AvailableFlags) != 0 || strings.Contains(structured.Message, "unknown flag") {
t.Fatalf("command error leaked flag classification: %#v", structured)
}
})
}
}
func TestCrossPlatformCoverageResolvedShortcutStillUsesExactLeafFlagError(t *testing.T) {
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
args := []string{"chat", "+chat-messages", "--keyword", "x", "--format", "json"}
if ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args); err != nil {
t.Fatalf("valid shortcut path failed PreParse: %#v, %v", ctx, err)
}
root.SetArgs(args)
err := root.Execute()
var structured *apperrors.Error
if !stderrors.As(err, &structured) {
t.Fatalf("error = %T %v", err, err)
}
if structured.Reason != "unknown_flag" || !strings.Contains(structured.Message, "dws chat +chat-messages --help") {
t.Fatalf("resolved shortcut flag error = %#v", structured)
}
}
+15
View File
@@ -135,6 +135,9 @@ func Execute() (exitCode int) {
// newPreParseValidationError keeps pipeline handler identity in internal logs
// while exposing only the underlying parameter-domain error to CLI users.
func newPreParseValidationError(err error) error {
if structured, ok := err.(*apperrors.Error); ok {
return structured
}
userErr := err
var handlerErr *pipeline.HandlerError
if stderrors.As(err, &handlerErr) && handlerErr.Unwrap() != nil {
@@ -1324,6 +1327,18 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
// - PostResponse: after transport returns, before stdout (canonical RunE)
func newPipelineEngine() *pipeline.Engine {
engine := pipeline.NewEngine()
engine.SetCommandPathFallbackLookup(func(path string) (pipeline.CommandPathFallback, bool) {
entry, ok := cli.LookupCommandPathFallback(path)
if !ok {
return pipeline.CommandPathFallback{}, false
}
return pipeline.CommandPathFallback{
From: entry.From,
Mode: string(entry.Mode),
To: entry.To,
Candidates: append([]string(nil), entry.Candidates...),
}, true
})
engine.RegisterAll(
// Register handler runs during command tree building.
handlers.RegisterHandler{},
@@ -0,0 +1,633 @@
{
"source_report": "param_hallucination_20260728_120943",
"source_file": "raw_dashscope_qwen3_7-max_20260728_120943.json",
"source_dws_commit": "a8e83e5",
"model": "dashscope/qwen3.7-max",
"expected_count": 52,
"badcases": [
{
"id": "dws_im_v3_0003-run10-command0",
"case_id": "dws_im_v3_0003",
"run": 10,
"command_index": 0,
"turn": 1,
"source_path": "chat +members",
"expected_outcome": "rewrite",
"raw": "dws chat +members --group \"dws测试群01\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0003-run10-command1",
"case_id": "dws_im_v3_0003",
"run": 10,
"command_index": 1,
"turn": 1,
"source_path": "chat +members",
"expected_outcome": "rewrite",
"raw": "dws chat +members --group-name \"dws测试群01\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0003-run10-command2",
"case_id": "dws_im_v3_0003",
"run": 10,
"command_index": 2,
"turn": 1,
"source_path": "chat +members",
"expected_outcome": "rewrite",
"raw": "dws chat +members \"dws测试群01\" --format json",
"original_exit_code": 5,
"is_help": false
},
{
"id": "dws_im_v3_0010-run1-command1",
"case_id": "dws_im_v3_0010",
"run": 1,
"command_index": 1,
"turn": 1,
"source_path": "chat +send-dm",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-dm --open-dingtalk-id DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS --content \"评测任务更新了,稍后同步\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0010-run1-command2",
"case_id": "dws_im_v3_0010",
"run": 1,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-dm",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-dm --dingtalk-id DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS --content \"评测任务更新了,稍后同步\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0010-run8-command4",
"case_id": "dws_im_v3_0010",
"run": 8,
"command_index": 4,
"turn": 1,
"source_path": "chat +send-single",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-single --user-id \"DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS\" --content \"评测任务更新了,稍后同步\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0010-run10-command0",
"case_id": "dws_im_v3_0010",
"run": 10,
"command_index": 0,
"turn": 1,
"source_path": "chat +send-by-bot",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-by-bot --open-dingtalk-id \"段旭彬\" --message \"评测任务更新了,稍后同步\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0010-run10-command1",
"case_id": "dws_im_v3_0010",
"run": 10,
"command_index": 1,
"turn": 1,
"source_path": "chat +send-by-bot",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-by-bot --open-id \"段旭彬\" --msg \"评测任务更新了,稍后同步\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0010-run10-command2",
"case_id": "dws_im_v3_0010",
"run": 10,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-by-bot",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-by-bot \"段旭彬\" \"评测任务更新了,稍后同步\" --format json",
"original_exit_code": 5,
"is_help": false
},
{
"id": "dws_im_v3_0011-run1-command3",
"case_id": "dws_im_v3_0011",
"run": 1,
"command_index": 3,
"turn": 1,
"source_path": "chat +send-media",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-media --user-id \"489149\" --file-path \"建筑.jpeg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0011-run1-command4",
"case_id": "dws_im_v3_0011",
"run": 1,
"command_index": 4,
"turn": 1,
"source_path": "chat +send-media",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-media --receiver \"489149\" --file-path \"建筑.jpeg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0011-run9-command1",
"case_id": "dws_im_v3_0011",
"run": 9,
"command_index": 1,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --user-id 489149 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case__ysfhvr3/建筑.jpeg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0011-run9-command2",
"case_id": "dws_im_v3_0011",
"run": 9,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --dry-run --format json 2>&1 | head -50",
"original_exit_code": 0,
"is_help": false
},
{
"id": "dws_im_v3_0012-run1-command2",
"case_id": "dws_im_v3_0012",
"run": 1,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_bc6azkyi/streamlit.docx\" --to \"符咏畅\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run1-command3",
"case_id": "dws_im_v3_0012",
"run": 1,
"command_index": 3,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_bc6azkyi/streamlit.docx\" --user \"符咏畅\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run2-command3",
"case_id": "dws_im_v3_0012",
"run": 2,
"command_index": 3,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --receiver 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_3er98f5m/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run2-command8",
"case_id": "dws_im_v3_0012",
"run": 2,
"command_index": 8,
"turn": 1,
"source_path": "chat +send",
"expected_outcome": "ambiguous",
"raw": "dws chat +send --user 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_3er98f5m/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run3-command1",
"case_id": "dws_im_v3_0012",
"run": 3,
"command_index": 1,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --user-id 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_cljycf8p/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run3-command2",
"case_id": "dws_im_v3_0012",
"run": 3,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --receiver 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_cljycf8p/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run4-command3",
"case_id": "dws_im_v3_0012",
"run": 4,
"command_index": 3,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --help",
"original_exit_code": 64,
"is_help": true
},
{
"id": "dws_im_v3_0012-run6-command4",
"case_id": "dws_im_v3_0012",
"run": 6,
"command_index": 4,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --user 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_8ef9dsey/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run6-command5",
"case_id": "dws_im_v3_0012",
"run": 6,
"command_index": 5,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --to \"519483\" --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_8ef9dsey/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run6-command6",
"case_id": "dws_im_v3_0012",
"run": 6,
"command_index": 6,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --name \"符咏畅\" --path \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_8ef9dsey/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run7-command1",
"case_id": "dws_im_v3_0012",
"run": 7,
"command_index": 1,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --user-id \"519483\" --file-path \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_fjcfinmi/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run7-command2",
"case_id": "dws_im_v3_0012",
"run": 7,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --format json --userid \"519483\" --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_fjcfinmi/streamlit.docx\"",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0016-run1-command7",
"case_id": "dws_im_v3_0016",
"run": 1,
"command_index": 7,
"turn": 3,
"source_path": "chat +rename-group",
"expected_outcome": "rewrite",
"raw": "dws chat +rename-group --id \"cideWy3UCPNZ4k8Mr4BL/8KRQ==\" --name \"测试废物群-20260713\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0017-run2-command6",
"case_id": "dws_im_v3_0017",
"run": 2,
"command_index": 6,
"turn": 2,
"source_path": "chat +send-message",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-message --group \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --content \"@陈邦杰 请确认下评测环境\" --mention \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0017-run2-command7",
"case_id": "dws_im_v3_0017",
"run": 2,
"command_index": 7,
"turn": 2,
"source_path": "chat +send-message",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-message --open-conversation-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --content \"@陈邦杰 请确认下评测环境\" --mention \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0017-run2-command9",
"case_id": "dws_im_v3_0017",
"run": 2,
"command_index": 9,
"turn": 2,
"source_path": "chat +send-message",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-message --format json 2>&1",
"original_exit_code": 5,
"is_help": false
},
{
"id": "dws_im_v3_0017-run5-command0",
"case_id": "dws_im_v3_0017",
"run": 5,
"command_index": 0,
"turn": 1,
"source_path": "chat +group-member-list",
"expected_outcome": "rewrite",
"raw": "dws chat +group-member-list --group-name \"dws测试群01\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0017-run6-command6",
"case_id": "dws_im_v3_0017",
"run": 6,
"command_index": 6,
"turn": 2,
"source_path": "chat +send-text",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-text --open-conversation-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --text \"@陈邦杰 请确认下评测环境\" --at-open-dingtalk-ids \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0017-run6-command7",
"case_id": "dws_im_v3_0017",
"run": 6,
"command_index": 7,
"turn": 2,
"source_path": "chat +send-text",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-text --conversation-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --content \"@陈邦杰 请确认下评测环境\" --at-user-ids \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0017-run6-command8",
"case_id": "dws_im_v3_0017",
"run": 6,
"command_index": 8,
"turn": 2,
"source_path": "chat +group-send-text",
"expected_outcome": "ambiguous",
"raw": "dws chat +group-send-text --group-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --text \"@陈邦杰 请确认下评测环境\" --at-open-dingtalk-ids \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0018-run10-command5",
"case_id": "dws_im_v3_0018",
"run": 10,
"command_index": 5,
"turn": 1,
"source_path": "chat +send-image",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-image --group \"cidyfbDkUpmy4ReyI7vfZy+zw==\" --file-path \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_z7y6p6_0/dog.png\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run4-command1",
"case_id": "dws_im_v3_0019",
"run": 4,
"command_index": 1,
"turn": 1,
"source_path": "chat +send-image",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-image --user-id 489149 --image /private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_wsppmjwr/landscape.jpg --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run4-command2",
"case_id": "dws_im_v3_0019",
"run": 4,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-image",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-image --to 489149 --file /private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_wsppmjwr/landscape.jpg --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run6-command3",
"case_id": "dws_im_v3_0019",
"run": 6,
"command_index": 3,
"turn": 1,
"source_path": "chat +send",
"expected_outcome": "ambiguous",
"raw": "dws chat +send --receiver-id 489149 --image \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_09930iej/landscape.jpg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run8-command2",
"case_id": "dws_im_v3_0019",
"run": 8,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --user-id 489149 --file \"landscape.jpg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run8-command3",
"case_id": "dws_im_v3_0019",
"run": 8,
"command_index": 3,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --receiver-id 489149 --file \"landscape.jpg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run8-command6",
"case_id": "dws_im_v3_0019",
"run": 8,
"command_index": 6,
"turn": 1,
"source_path": "chat +send",
"expected_outcome": "ambiguous",
"raw": "dws chat +send --to 489149 --file \"landscape.jpg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run8-command9",
"case_id": "dws_im_v3_0019",
"run": 8,
"command_index": 9,
"turn": 1,
"source_path": "chat +send-to",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-to --uid 489149 --file \"landscape.jpg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run9-command0",
"case_id": "dws_im_v3_0019",
"run": 9,
"command_index": 0,
"turn": 1,
"source_path": "chat +send",
"expected_outcome": "ambiguous",
"raw": "dws chat +send --receiver 489149 --file-path \"./landscape.jpg\" --format json 2>&1",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0021-run4-command3",
"case_id": "dws_im_v3_0021",
"run": 4,
"command_index": 3,
"turn": 1,
"source_path": "chat +read-single",
"expected_outcome": "ambiguous",
"raw": "dws chat +read-single --user-id 550582 --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0021-run4-command4",
"case_id": "dws_im_v3_0021",
"run": 4,
"command_index": 4,
"turn": 1,
"source_path": "chat +read-single",
"expected_outcome": "ambiguous",
"raw": "dws chat +read-single --uid 550582 --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0021-run4-command5",
"case_id": "dws_im_v3_0021",
"run": 4,
"command_index": 5,
"turn": 1,
"source_path": "chat +read-single",
"expected_outcome": "ambiguous",
"raw": "dws chat +read-single --staff-id 550582 --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0021-run4-command6",
"case_id": "dws_im_v3_0021",
"run": 4,
"command_index": 6,
"turn": 1,
"source_path": "chat +read-single",
"expected_outcome": "ambiguous",
"raw": "dws chat +read-single --open-dingtalk-id \"DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0021-run4-command7",
"case_id": "dws_im_v3_0021",
"run": 4,
"command_index": 7,
"turn": 1,
"source_path": "chat +read-single",
"expected_outcome": "ambiguous",
"raw": "dws chat +read-single --target 550582 --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0021-run4-command8",
"case_id": "dws_im_v3_0021",
"run": 4,
"command_index": 8,
"turn": 1,
"source_path": "chat +read-single",
"expected_outcome": "ambiguous",
"raw": "dws chat +read-single 550582 --format json",
"original_exit_code": 5,
"is_help": false
},
{
"id": "dws_im_v3_0021-run5-command1",
"case_id": "dws_im_v3_0021",
"run": 5,
"command_index": 1,
"turn": 1,
"source_path": "chat +message-list",
"expected_outcome": "ambiguous",
"raw": "dws chat +message-list --format json 2>&1",
"original_exit_code": 5,
"is_help": false
},
{
"id": "dws_im_v3_0028-run4-command5",
"case_id": "dws_im_v3_0028",
"run": 4,
"command_index": 5,
"turn": 1,
"source_path": "chat +list-robot",
"expected_outcome": "rewrite",
"raw": "dws chat +list-robot \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --format json",
"original_exit_code": 5,
"is_help": false
},
{
"id": "dws_im_v3_0028-run5-command15",
"case_id": "dws_im_v3_0028",
"run": 5,
"command_index": 15,
"turn": 1,
"source_path": "chat +list-group-bots",
"expected_outcome": "rewrite",
"raw": "dws chat +list-group-bots --id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0028-run9-command9",
"case_id": "dws_im_v3_0028",
"run": 9,
"command_index": 9,
"turn": 1,
"source_path": "chat +list-robots",
"expected_outcome": "rewrite",
"raw": "dws chat +list-robots --cid \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --format json",
"original_exit_code": 3,
"is_help": false
}
]
}
+289
View File
@@ -0,0 +1,289 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package cli
import (
"bytes"
_ "embed"
"encoding/json"
"fmt"
"io"
"regexp"
"sort"
"strings"
"sync"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
const commandPathFallbacksSchemaRef = "./command_path_fallbacks.schema.json"
// command_path_fallbacks.json is a reviewed recovery policy for invalid paths
// observed in evaluation. It is deliberately not a command identity source:
// Help, Skill, Schema, and normal navigation continue to publish canonical
// paths from the CommandRegistry and the real Cobra tree only.
//go:embed command_path_fallbacks.json
var embeddedCommandPathFallbacksJSON []byte
//go:embed command_path_fallbacks.schema.json
var embeddedCommandPathFallbacksSchemaJSON []byte
var commandFallbackPathPattern = regexp.MustCompile(`^[A-Za-z0-9+][A-Za-z0-9._:+-]*(?: [A-Za-z0-9+][A-Za-z0-9._:+-]*)*$`)
// CommandPathFallbackMode determines whether an invalid reviewed path can be
// normalized exactly or must stop and surface reviewed candidates.
type CommandPathFallbackMode string
const (
CommandPathFallbackRewrite CommandPathFallbackMode = "rewrite"
CommandPathFallbackAmbiguous CommandPathFallbackMode = "ambiguous"
)
// CommandPathFallback is one validated, generated runtime recovery record.
// From is never advertised as a stable alias. To and Candidates are canonical
// real Cobra paths validated at generation time.
type CommandPathFallback struct {
From string `json:"from"`
Mode CommandPathFallbackMode `json:"mode"`
To string `json:"to,omitempty"`
Candidates []string `json:"candidates,omitempty"`
Reviewed bool `json:"reviewed"`
ReviewReason string `json:"review_reason"`
}
type commandPathFallbackSnapshot struct {
Schema string `json:"$schema"`
Version int `json:"version"`
Entries []commandPathFallbackEntrySpec `json:"entries"`
}
type commandPathFallbackEntrySpec struct {
From string `json:"from"`
Mode CommandPathFallbackMode `json:"mode"`
To string `json:"to,omitempty"`
Candidates []string `json:"candidates,omitempty"`
Reviewed bool `json:"reviewed"`
ReviewReason string `json:"review_reason"`
}
var (
embeddedCommandPathFallbacksOnce sync.Once
embeddedCommandPathFallbacksData []CommandPathFallback
embeddedCommandPathFallbacksErr error
loadReviewedCommandPathFallbacks = loadEmbeddedCommandPathFallbacks
)
// LoadCommandPathFallbacks decodes and validates the authored recovery table.
// Callers receive a clone so generation and tests cannot mutate shared data.
func LoadCommandPathFallbacks() ([]CommandPathFallback, error) {
return loadReviewedCommandPathFallbacks()
}
func loadEmbeddedCommandPathFallbacks() ([]CommandPathFallback, error) {
embeddedCommandPathFallbacksOnce.Do(func() {
embeddedCommandPathFallbacksData, embeddedCommandPathFallbacksErr = decodeCommandPathFallbacks(embeddedCommandPathFallbacksJSON)
})
return cloneCommandPathFallbacks(embeddedCommandPathFallbacksData), embeddedCommandPathFallbacksErr
}
func decodeCommandPathFallbacks(data []byte) ([]CommandPathFallback, error) {
var snapshot commandPathFallbackSnapshot
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&snapshot); err != nil {
return nil, fmt.Errorf("decode reviewed command path fallbacks: %w", err)
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
err = fmt.Errorf("multiple JSON values")
}
return nil, fmt.Errorf("decode reviewed command path fallbacks: %w", err)
}
if snapshot.Version != 1 {
return nil, fmt.Errorf("unsupported command path fallbacks version %d", snapshot.Version)
}
if strings.TrimSpace(snapshot.Schema) != commandPathFallbacksSchemaRef {
return nil, fmt.Errorf("command path fallbacks must declare $schema=%q", commandPathFallbacksSchemaRef)
}
if len(snapshot.Entries) == 0 {
return nil, fmt.Errorf("command path fallbacks declares no entries")
}
entries := make([]CommandPathFallback, 0, len(snapshot.Entries))
seenFrom := make(map[string]bool, len(snapshot.Entries))
for index, spec := range snapshot.Entries {
from, err := validateAuthoredCommandFallbackPath(spec.From)
if err != nil {
return nil, fmt.Errorf("command path fallback entry %d from: %w", index, err)
}
if seenFrom[from] {
return nil, fmt.Errorf("command path fallbacks contains duplicate from path %q", from)
}
seenFrom[from] = true
reason := strings.TrimSpace(spec.ReviewReason)
if !spec.Reviewed || reason == "" {
return nil, fmt.Errorf("command path fallback %q requires reviewed=true and non-empty review_reason", from)
}
entry := CommandPathFallback{
From: from,
Mode: spec.Mode,
Reviewed: true,
ReviewReason: reason,
}
switch spec.Mode {
case CommandPathFallbackRewrite:
if len(spec.Candidates) > 0 {
return nil, fmt.Errorf("rewrite command path fallback %q must not declare candidates", from)
}
to, pathErr := validateAuthoredCommandFallbackPath(spec.To)
if pathErr != nil {
return nil, fmt.Errorf("rewrite command path fallback %q to: %w", from, pathErr)
}
entry.To = to
case CommandPathFallbackAmbiguous:
if strings.TrimSpace(spec.To) != "" {
return nil, fmt.Errorf("ambiguous command path fallback %q must not declare to", from)
}
if len(spec.Candidates) < 2 {
return nil, fmt.Errorf("ambiguous command path fallback %q requires at least two candidates", from)
}
seenCandidates := make(map[string]bool, len(spec.Candidates))
for _, rawCandidate := range spec.Candidates {
candidate, pathErr := validateAuthoredCommandFallbackPath(rawCandidate)
if pathErr != nil {
return nil, fmt.Errorf("ambiguous command path fallback %q candidate: %w", from, pathErr)
}
if seenCandidates[candidate] {
return nil, fmt.Errorf("ambiguous command path fallback %q repeats candidate %q", from, candidate)
}
seenCandidates[candidate] = true
entry.Candidates = append(entry.Candidates, candidate)
}
default:
return nil, fmt.Errorf("command path fallback %q has invalid mode %q", from, spec.Mode)
}
entries = append(entries, entry)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].From < entries[j].From })
return entries, nil
}
func validateAuthoredCommandFallbackPath(raw string) (string, error) {
path := strings.TrimSpace(raw)
if path == "" {
return "", fmt.Errorf("path is empty")
}
if path != raw || normalizeSchemaCLIPath(path) != path || !commandFallbackPathPattern.MatchString(path) {
return "", fmt.Errorf("path %q is not a normalized command path without leading dws or flags", raw)
}
return path, nil
}
// ReduceCommandPathFallbacks validates the reviewed recovery table against the
// live distribution-owned Cobra tree. It refuses to turn an existing command
// or stable alias into a hidden rewrite. The sole exception is an explicitly
// annotated hint-only compatibility node, which contains no business action.
func ReduceCommandPathFallbacks(root *cobra.Command) ([]CommandPathFallback, error) {
if root == nil {
return nil, fmt.Errorf("command path fallback source root is nil")
}
entries, err := LoadCommandPathFallbacks()
if err != nil {
return nil, err
}
byFrom := make(map[string]CommandPathFallback, len(entries))
for _, entry := range entries {
byFrom[entry.From] = entry
}
var problems []string
for _, entry := range entries {
match, resolveErr := resolveExactCobraPath(root, entry.From)
if resolveErr != nil {
problems = append(problems, fmt.Sprintf("from %q cannot be resolved safely: %v", entry.From, resolveErr))
} else if match.Command != nil && !cmdutil.IsHintOnlyCommand(match.Command) {
problems = append(problems, fmt.Sprintf("from %q collides with a real Cobra command or alias", entry.From))
}
switch entry.Mode {
case CommandPathFallbackRewrite:
if _, chained := byFrom[entry.To]; chained {
problems = append(problems, fmt.Sprintf("rewrite %q targets fallback source %q; chained fallbacks are forbidden", entry.From, entry.To))
}
problems = append(problems, validateCommandFallbackTarget(root, entry.From, entry.To, true)...)
case CommandPathFallbackAmbiguous:
for _, candidate := range entry.Candidates {
if _, chained := byFrom[candidate]; chained {
problems = append(problems, fmt.Sprintf("ambiguous fallback %q candidate %q is another fallback source", entry.From, candidate))
}
// Ambiguous recovery never dispatches. It may therefore point the
// caller at both canonical shortcuts and native leaves, while exact
// rewrites keep the stricter +shortcut identity boundary.
problems = append(problems, validateCommandFallbackTarget(root, entry.From, candidate, false)...)
}
default:
problems = append(problems, fmt.Sprintf("fallback %q has unsupported mode %q", entry.From, entry.Mode))
}
}
if len(problems) > 0 {
sort.Strings(problems)
return nil, fmt.Errorf("command path fallback reduction failed:\n - %s", strings.Join(problems, "\n - "))
}
return cloneCommandPathFallbacks(entries), nil
}
func validateCommandFallbackTarget(root *cobra.Command, from, target string, requireShortcutParity bool) []string {
var problems []string
if commandFallbackService(from) != commandFallbackService(target) {
problems = append(problems, fmt.Sprintf("fallback %q crosses service boundary to %q", from, target))
}
if requireShortcutParity && commandFallbackHasShortcut(from) != commandFallbackHasShortcut(target) {
problems = append(problems, fmt.Sprintf("fallback %q and target %q disagree on +shortcut identity", from, target))
}
match, err := resolveExactCobraPath(root, target)
if err != nil {
return append(problems, fmt.Sprintf("fallback %q target %q cannot be resolved safely: %v", from, target, err))
}
if match.Command == nil {
return append(problems, fmt.Sprintf("fallback %q target %q does not exist", from, target))
}
if match.UsedAlias {
problems = append(problems, fmt.Sprintf("fallback %q target %q must use canonical Cobra names, not an alias", from, target))
}
if !runnableSchemaLeaf(match.Command) || !match.Command.IsAvailableCommand() {
problems = append(problems, fmt.Sprintf("fallback %q target %q is not a public runnable Cobra leaf", from, target))
}
return problems
}
func commandFallbackService(path string) string {
parts := strings.Fields(path)
if len(parts) == 0 {
return ""
}
return parts[0]
}
func commandFallbackHasShortcut(path string) bool {
for _, part := range strings.Fields(path) {
if strings.HasPrefix(part, "+") {
return true
}
}
return false
}
func cloneCommandPathFallbacks(entries []CommandPathFallback) []CommandPathFallback {
cloned := make([]CommandPathFallback, len(entries))
for index, entry := range entries {
cloned[index] = entry
cloned[index].Candidates = append([]string(nil), entry.Candidates...)
}
return cloned
}
+311
View File
@@ -0,0 +1,311 @@
{
"$schema": "./command_path_fallbacks.schema.json",
"version": 1,
"entries": [
{
"from": "chat +group-search",
"mode": "rewrite",
"to": "chat +chat-search",
"reviewed": true,
"review_reason": "0803 evaluation badcase: the model emitted +group-search with --query; +chat-search provides the same group-name search operation."
},
{
"from": "chat +members",
"mode": "rewrite",
"to": "chat +group-members",
"reviewed": true,
"review_reason": "20260728 evaluation badcases emitted +members three times for listing members of a group selected by name; +group-members is the unique reviewed read-only shortcut for that intent."
},
{
"from": "chat +group-member-list",
"mode": "rewrite",
"to": "chat +group-members",
"reviewed": true,
"review_reason": "20260728 evaluation emitted +group-member-list for a group-name member lookup; +group-members is the unique reviewed read-only shortcut and canonical parameter validation remains authoritative."
},
{
"from": "chat +list-group-bots",
"mode": "rewrite",
"to": "chat +chat-bots",
"reviewed": true,
"review_reason": "20260728 evaluation emitted +list-group-bots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs."
},
{
"from": "chat +list-robot",
"mode": "rewrite",
"to": "chat +chat-bots",
"reviewed": true,
"review_reason": "20260728 evaluation emitted singular +list-robot for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs."
},
{
"from": "chat +list-robots",
"mode": "rewrite",
"to": "chat +chat-bots",
"reviewed": true,
"review_reason": "20260728 evaluation emitted +list-robots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs."
},
{
"from": "chat +message-list",
"mode": "ambiguous",
"candidates": [
"chat +chat-messages",
"chat +messages-list-direct",
"chat +search-msg",
"chat +unread-chats"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +message-list without identifying group versus direct history, conversation history versus cross-chat search, or ordinary versus unread conversations; no candidate may be selected automatically."
},
{
"from": "chat +read-single",
"mode": "ambiguous",
"candidates": [
"chat +messages-list-direct",
"chat +chat-messages"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +read-single six times for direct-message history, but the invented name does not choose between the focused direct-history shortcut and the broader group/direct history workflow; command recovery must stop before parameter validation or dispatch."
},
{
"from": "chat +rename-group",
"mode": "rewrite",
"to": "chat +chat-update",
"reviewed": true,
"review_reason": "20260728 evaluation emitted +rename-group for the unique group-name update intent; +chat-update is the reviewed shortcut with that exact command-level operation. Parameter compatibility remains the canonical target's responsibility."
},
{
"from": "chat +send",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat +dm",
"chat +send-to-group"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow."
},
{
"from": "chat +send-message",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat +dm",
"chat +send-to-group"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-message without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow."
},
{
"from": "chat +send-text",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat +dm",
"chat +send-to-group"
],
"reviewed": true,
"review_reason": "20260728 evaluation identified text content but not a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow."
},
{
"from": "chat +send-to",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat +dm",
"chat +send-to-group"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-to without proving whether the recipient denotes a user, group, or low-level identifier; the write operation must not choose a target workflow automatically."
},
{
"from": "chat +send-dm",
"mode": "ambiguous",
"candidates": [
"chat +dm",
"chat +messages-send"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-dm twice, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically."
},
{
"from": "chat +send-single",
"mode": "ambiguous",
"candidates": [
"chat +dm",
"chat +messages-send"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-single for a direct message, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically."
},
{
"from": "chat +send-by-bot",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat message send-by-bot"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-by-bot three times without a complete sending-identity contract; stop and present the unified identity-aware shortcut and the exact native bot sender instead of selecting a write path."
},
{
"from": "chat +group-send-text",
"mode": "ambiguous",
"candidates": [
"chat +send-to-group",
"chat +messages-send"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +group-send-text for a group text operation, but the invented name does not choose between name-resolved group text and the unified identifier-aware workflow; the write operation must not dispatch automatically."
},
{
"from": "chat +send-file",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat message send"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-file fifteen times with incompatible target and file parameter spellings; stop before dispatch and let the caller choose the unified shortcut or native current-user file workflow."
},
{
"from": "chat +send-image",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat message send"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-image without proving whether the input is an existing mediaId or a local file; stop before dispatch and let the caller choose the reviewed unified or native workflow."
},
{
"from": "chat +send-media",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat message send"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-media without a concrete media type, sending identity, or compatible parameter contract; stop before dispatch and let the caller choose the reviewed unified or native workflow."
},
{
"from": "oa +list-processes",
"mode": "ambiguous",
"candidates": [
"oa +list-forms",
"oa +my-initiated",
"oa approval list-initiated"
],
"reviewed": true,
"review_reason": "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf."
},
{
"from": "chat +conversation-detail",
"mode": "rewrite",
"to": "chat +conversation-info",
"reviewed": true,
"review_reason": "20260804 multi-im badcase requested one conversation's details. +conversation-info is the unique current read-only shortcut for that operation. The rewrite changes only the command path and preserves every flag/value for target validation."
},
{
"from": "chat +bot-list",
"mode": "rewrite",
"to": "chat +chat-bots",
"reviewed": true,
"review_reason": "20260804 multi-im badcase requested the robot list for one group. +chat-bots is the unique current read-only shortcut. The fallback must not reinterpret the accompanying group flag."
},
{
"from": "chat +conversation-category-list",
"mode": "ambiguous",
"candidates": [
"chat +category-list",
"chat +category-list-conversations"
],
"reviewed": true,
"review_reason": "The invented name can mean listing the user's categories or listing conversations inside one category. No candidate may execute before the caller chooses the intended object level."
},
{
"from": "chat +conversation-group-list",
"mode": "ambiguous",
"candidates": [
"chat +category-list-conversations",
"chat +conversation-list"
],
"reviewed": true,
"review_reason": "The invented name can mean conversations in a custom category or the general conversation list. The command name alone does not identify the requested collection."
},
{
"from": "chat +list-my-groups",
"mode": "ambiguous",
"candidates": [
"chat +my-groups",
"chat +chat-list-mine",
"chat +chat-list"
],
"reviewed": true,
"review_reason": "The invented name does not choose between the established resolver shortcut, the legacy personal-group list and the current Schema-complete chat list. Recovery must stop instead of silently changing pagination or output semantics."
},
{
"from": "doc +list-templates",
"mode": "rewrite",
"to": "doc +template-list",
"reviewed": true,
"review_reason": "202608 Doc experiments emitted +list-templates while discovering available templates. +template-list is the unique current public read-only shortcut for that operation; the rewrite changes only the command path and preserves flags for target validation."
},
{
"from": "doc +search-template",
"mode": "rewrite",
"to": "doc +template-search",
"reviewed": true,
"review_reason": "202608 Doc experiments emitted the verb/object inversion +search-template. +template-search is the unique current public read-only template-name search shortcut; the rewrite preserves every flag and value."
},
{
"from": "doc +template",
"mode": "ambiguous",
"candidates": [
"doc +template-list",
"doc +template-search",
"doc +create-from-template"
],
"reviewed": true,
"review_reason": "202608 Doc experiments emitted the family-like +template without selecting browse, search, or create-from-template. Command recovery must stop before parameters or execution and present the three public workflows."
},
{
"from": "doc +version",
"mode": "ambiguous",
"candidates": [
"doc +history-list",
"doc +history-save",
"doc +history-revert"
],
"reviewed": true,
"review_reason": "202608 Doc experiments emitted the family-like +version without choosing list, save, or revert. The write and high-risk revert operations cannot be selected from an umbrella name; present the canonical history shortcuts and stop."
},
{
"from": "doc +create-version",
"mode": "rewrite",
"to": "doc +history-save",
"reviewed": true,
"review_reason": "202608 supplemental Doc badcase emitted +create-version while explicitly trying to save the current document as a recoverable history snapshot. +history-save is the unique current canonical shortcut for that operation and accepts the same --node value without parameter transformation."
},
{
"from": "doc +save-version",
"mode": "rewrite",
"to": "doc +history-save",
"reviewed": true,
"review_reason": "202608 supplemental Doc badcase emitted the verb/object inversion +save-version for a current-version snapshot. +history-save is the unique current canonical shortcut with the same document-node contract; the legacy +version-save path is not selected as the fallback target."
},
{
"from": "doc +snapshot",
"mode": "rewrite",
"to": "doc +history-save",
"reviewed": true,
"review_reason": "202608 supplemental Doc badcase emitted +snapshot during an explicit save-current-version workflow. The current +history-save selection contract names this exact recoverable history-snapshot operation, so the reviewed rewrite preserves the --node value and does not infer an update or export workflow."
},
{
"from": "doc +version-create",
"mode": "rewrite",
"to": "doc +history-save",
"reviewed": true,
"review_reason": "202608 supplemental Doc badcase emitted +version-create while saving the current document version. +history-save is the unique current canonical snapshot shortcut and the path-only rewrite leaves its real execution and safety contract authoritative."
}
]
}
@@ -0,0 +1,122 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/internal/cli/command_path_fallbacks.schema.json",
"title": "DWS Reviewed Command Path Fallbacks",
"description": "Human-reviewed recovery-only normalization for invalid model-emitted command paths. These entries are not stable command identity, navigation, Help, Skill, or Schema aliases.",
"type": "object",
"additionalProperties": false,
"required": [
"$schema",
"version",
"entries"
],
"properties": {
"$schema": {
"const": "./command_path_fallbacks.schema.json"
},
"version": {
"const": 1
},
"entries": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/entry"
}
}
},
"$defs": {
"commandPath": {
"type": "string",
"pattern": "^[A-Za-z0-9+][A-Za-z0-9._:+-]*(?: [A-Za-z0-9+][A-Za-z0-9._:+-]*)*$",
"description": "Exact CLI path without a leading dws or flags."
},
"entry": {
"type": "object",
"additionalProperties": false,
"required": [
"from",
"mode",
"reviewed",
"review_reason"
],
"properties": {
"from": {
"$ref": "#/$defs/commandPath"
},
"mode": {
"type": "string",
"enum": [
"rewrite",
"ambiguous"
]
},
"to": {
"$ref": "#/$defs/commandPath"
},
"candidates": {
"type": "array",
"minItems": 2,
"uniqueItems": true,
"description": "Canonical public runnable paths offered for an ambiguous source. Because ambiguous recovery never dispatches, candidates may mix reviewed shortcuts and native leaves within the same service.",
"items": {
"$ref": "#/$defs/commandPath"
}
},
"reviewed": {
"const": true
},
"review_reason": {
"type": "string",
"minLength": 1
}
},
"allOf": [
{
"if": {
"properties": {
"mode": {
"const": "rewrite"
}
},
"required": [
"mode"
]
},
"then": {
"required": [
"to"
],
"not": {
"required": [
"candidates"
]
}
}
},
{
"if": {
"properties": {
"mode": {
"const": "ambiguous"
}
},
"required": [
"mode"
]
},
"then": {
"required": [
"candidates"
],
"not": {
"required": [
"to"
]
}
}
}
]
}
}
}
@@ -0,0 +1,253 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Code generated by cmd_command_path_fallbacks; DO NOT EDIT.
// Source: internal/cli/command_path_fallbacks.json validated against the live Cobra tree.
// Regenerate with `make generate-schema` (or `go generate ./internal/cli`).
package cli
var generatedCommandPathFallbacks = []CommandPathFallback{
{
From: "chat +bot-list",
Mode: "rewrite",
To: "chat +chat-bots",
Reviewed: true,
ReviewReason: "20260804 multi-im badcase requested the robot list for one group. +chat-bots is the unique current read-only shortcut. The fallback must not reinterpret the accompanying group flag.",
},
{
From: "chat +conversation-category-list",
Mode: "ambiguous",
Candidates: []string{"chat +category-list", "chat +category-list-conversations"},
Reviewed: true,
ReviewReason: "The invented name can mean listing the user's categories or listing conversations inside one category. No candidate may execute before the caller chooses the intended object level.",
},
{
From: "chat +conversation-detail",
Mode: "rewrite",
To: "chat +conversation-info",
Reviewed: true,
ReviewReason: "20260804 multi-im badcase requested one conversation's details. +conversation-info is the unique current read-only shortcut for that operation. The rewrite changes only the command path and preserves every flag/value for target validation.",
},
{
From: "chat +conversation-group-list",
Mode: "ambiguous",
Candidates: []string{"chat +category-list-conversations", "chat +conversation-list"},
Reviewed: true,
ReviewReason: "The invented name can mean conversations in a custom category or the general conversation list. The command name alone does not identify the requested collection.",
},
{
From: "chat +group-member-list",
Mode: "rewrite",
To: "chat +group-members",
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +group-member-list for a group-name member lookup; +group-members is the unique reviewed read-only shortcut and canonical parameter validation remains authoritative.",
},
{
From: "chat +group-search",
Mode: "rewrite",
To: "chat +chat-search",
Reviewed: true,
ReviewReason: "0803 evaluation badcase: the model emitted +group-search with --query; +chat-search provides the same group-name search operation.",
},
{
From: "chat +group-send-text",
Mode: "ambiguous",
Candidates: []string{"chat +send-to-group", "chat +messages-send"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +group-send-text for a group text operation, but the invented name does not choose between name-resolved group text and the unified identifier-aware workflow; the write operation must not dispatch automatically.",
},
{
From: "chat +list-group-bots",
Mode: "rewrite",
To: "chat +chat-bots",
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +list-group-bots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs.",
},
{
From: "chat +list-my-groups",
Mode: "ambiguous",
Candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"},
Reviewed: true,
ReviewReason: "The invented name does not choose between the established resolver shortcut, the legacy personal-group list and the current Schema-complete chat list. Recovery must stop instead of silently changing pagination or output semantics.",
},
{
From: "chat +list-robot",
Mode: "rewrite",
To: "chat +chat-bots",
Reviewed: true,
ReviewReason: "20260728 evaluation emitted singular +list-robot for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs.",
},
{
From: "chat +list-robots",
Mode: "rewrite",
To: "chat +chat-bots",
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +list-robots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs.",
},
{
From: "chat +members",
Mode: "rewrite",
To: "chat +group-members",
Reviewed: true,
ReviewReason: "20260728 evaluation badcases emitted +members three times for listing members of a group selected by name; +group-members is the unique reviewed read-only shortcut for that intent.",
},
{
From: "chat +message-list",
Mode: "ambiguous",
Candidates: []string{"chat +chat-messages", "chat +messages-list-direct", "chat +search-msg", "chat +unread-chats"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +message-list without identifying group versus direct history, conversation history versus cross-chat search, or ordinary versus unread conversations; no candidate may be selected automatically.",
},
{
From: "chat +read-single",
Mode: "ambiguous",
Candidates: []string{"chat +messages-list-direct", "chat +chat-messages"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +read-single six times for direct-message history, but the invented name does not choose between the focused direct-history shortcut and the broader group/direct history workflow; command recovery must stop before parameter validation or dispatch.",
},
{
From: "chat +rename-group",
Mode: "rewrite",
To: "chat +chat-update",
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +rename-group for the unique group-name update intent; +chat-update is the reviewed shortcut with that exact command-level operation. Parameter compatibility remains the canonical target's responsibility.",
},
{
From: "chat +send",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow.",
},
{
From: "chat +send-by-bot",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat message send-by-bot"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-by-bot three times without a complete sending-identity contract; stop and present the unified identity-aware shortcut and the exact native bot sender instead of selecting a write path.",
},
{
From: "chat +send-dm",
Mode: "ambiguous",
Candidates: []string{"chat +dm", "chat +messages-send"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-dm twice, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically.",
},
{
From: "chat +send-file",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat message send"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-file fifteen times with incompatible target and file parameter spellings; stop before dispatch and let the caller choose the unified shortcut or native current-user file workflow.",
},
{
From: "chat +send-image",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat message send"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-image without proving whether the input is an existing mediaId or a local file; stop before dispatch and let the caller choose the reviewed unified or native workflow.",
},
{
From: "chat +send-media",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat message send"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-media without a concrete media type, sending identity, or compatible parameter contract; stop before dispatch and let the caller choose the reviewed unified or native workflow.",
},
{
From: "chat +send-message",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-message without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow.",
},
{
From: "chat +send-single",
Mode: "ambiguous",
Candidates: []string{"chat +dm", "chat +messages-send"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-single for a direct message, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically.",
},
{
From: "chat +send-text",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
Reviewed: true,
ReviewReason: "20260728 evaluation identified text content but not a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow.",
},
{
From: "chat +send-to",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-to without proving whether the recipient denotes a user, group, or low-level identifier; the write operation must not choose a target workflow automatically.",
},
{
From: "doc +create-version",
Mode: "rewrite",
To: "doc +history-save",
Reviewed: true,
ReviewReason: "202608 supplemental Doc badcase emitted +create-version while explicitly trying to save the current document as a recoverable history snapshot. +history-save is the unique current canonical shortcut for that operation and accepts the same --node value without parameter transformation.",
},
{
From: "doc +list-templates",
Mode: "rewrite",
To: "doc +template-list",
Reviewed: true,
ReviewReason: "202608 Doc experiments emitted +list-templates while discovering available templates. +template-list is the unique current public read-only shortcut for that operation; the rewrite changes only the command path and preserves flags for target validation.",
},
{
From: "doc +save-version",
Mode: "rewrite",
To: "doc +history-save",
Reviewed: true,
ReviewReason: "202608 supplemental Doc badcase emitted the verb/object inversion +save-version for a current-version snapshot. +history-save is the unique current canonical shortcut with the same document-node contract; the legacy +version-save path is not selected as the fallback target.",
},
{
From: "doc +search-template",
Mode: "rewrite",
To: "doc +template-search",
Reviewed: true,
ReviewReason: "202608 Doc experiments emitted the verb/object inversion +search-template. +template-search is the unique current public read-only template-name search shortcut; the rewrite preserves every flag and value.",
},
{
From: "doc +snapshot",
Mode: "rewrite",
To: "doc +history-save",
Reviewed: true,
ReviewReason: "202608 supplemental Doc badcase emitted +snapshot during an explicit save-current-version workflow. The current +history-save selection contract names this exact recoverable history-snapshot operation, so the reviewed rewrite preserves the --node value and does not infer an update or export workflow.",
},
{
From: "doc +template",
Mode: "ambiguous",
Candidates: []string{"doc +template-list", "doc +template-search", "doc +create-from-template"},
Reviewed: true,
ReviewReason: "202608 Doc experiments emitted the family-like +template without selecting browse, search, or create-from-template. Command recovery must stop before parameters or execution and present the three public workflows.",
},
{
From: "doc +version",
Mode: "ambiguous",
Candidates: []string{"doc +history-list", "doc +history-save", "doc +history-revert"},
Reviewed: true,
ReviewReason: "202608 Doc experiments emitted the family-like +version without choosing list, save, or revert. The write and high-risk revert operations cannot be selected from an umbrella name; present the canonical history shortcuts and stop.",
},
{
From: "doc +version-create",
Mode: "rewrite",
To: "doc +history-save",
Reviewed: true,
ReviewReason: "202608 supplemental Doc badcase emitted +version-create while saving the current document version. +history-save is the unique current canonical snapshot shortcut and the path-only rewrite leaves its real execution and safety contract authoritative.",
},
{
From: "oa +list-processes",
Mode: "ambiguous",
Candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
Reviewed: true,
ReviewReason: "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf.",
},
}
func loadGeneratedCommandPathFallbacks() []CommandPathFallback {
return generatedCommandPathFallbacks
}
@@ -0,0 +1,32 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package cli
import "sync"
var (
commandPathFallbackIndexOnce sync.Once
commandPathFallbackIndex map[string]CommandPathFallback
)
func buildCommandPathFallbackIndex() {
entries := loadGeneratedCommandPathFallbacks()
commandPathFallbackIndex = make(map[string]CommandPathFallback, len(entries))
for _, entry := range entries {
commandPathFallbackIndex[entry.From] = entry
}
}
// LookupCommandPathFallback performs an exact O(1) lookup of a reviewed
// recovery-only path. Normalization only removes a leading dws and folds
// whitespace; it does not apply prefix, typo, or semantic matching.
func LookupCommandPathFallback(rawPath string) (CommandPathFallback, bool) {
commandPathFallbackIndexOnce.Do(buildCommandPathFallbackIndex)
entry, ok := commandPathFallbackIndex[normalizeSchemaCLIPath(rawPath)]
if !ok {
return CommandPathFallback{}, false
}
entry.Candidates = append([]string(nil), entry.Candidates...)
return entry, true
}
+372
View File
@@ -0,0 +1,372 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package cli
import (
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
func commandFallbackJSON(entries string) []byte {
return []byte(`{"$schema":"./command_path_fallbacks.schema.json","version":1,"entries":` + entries + `}`)
}
func validCommandFallbackEntry(from, to string) string {
return `{"from":"` + from + `","mode":"rewrite","to":"` + to + `","reviewed":true,"review_reason":"reviewed fixture"}`
}
func TestCrossPlatformCoverageDecodeCommandPathFallbacksStrictContract(t *testing.T) {
valid := commandFallbackJSON(`[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]`)
entries, err := decodeCommandPathFallbacks(valid)
if err != nil || len(entries) != 1 || entries[0].From != "chat +bad" || entries[0].To != "chat +good" {
t.Fatalf("decode valid = %#v, %v", entries, err)
}
entries[0].Candidates = append(entries[0].Candidates, "mutated")
cloned := cloneCommandPathFallbacks(entries)
cloned[0].Candidates[0] = "clone"
if entries[0].Candidates[0] != "mutated" {
t.Fatal("cloneCommandPathFallbacks aliases candidate storage")
}
tests := map[string]struct {
data []byte
want string
}{
"unknown field": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":true,"review_reason":"x","extra":true}]`),
want: "unknown field",
},
"wrong schema": {
data: []byte(`{"$schema":"wrong","version":1,"entries":[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]}`),
want: "$schema",
},
"wrong version": {
data: []byte(`{"$schema":"./command_path_fallbacks.schema.json","version":2,"entries":[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]}`),
want: "unsupported",
},
"empty entries": {data: commandFallbackJSON(`[]`), want: "no entries"},
"unnormalized path": {
data: commandFallbackJSON(`[` + validCommandFallbackEntry(` chat +bad `, `chat +good`) + `]`),
want: "not a normalized",
},
"unreviewed": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":false,"review_reason":"x"}]`),
want: "reviewed=true",
},
"empty reason": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":true,"review_reason":" "}]`),
want: "reviewed=true",
},
"invalid mode": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"guess","reviewed":true,"review_reason":"x"}]`),
want: "invalid mode",
},
"rewrite candidates": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","candidates":["chat +one","chat +two"],"reviewed":true,"review_reason":"x"}]`),
want: "must not declare candidates",
},
"empty rewrite target": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","reviewed":true,"review_reason":"x"}]`),
want: "path is empty",
},
"ambiguous target": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","to":"chat +good","candidates":["chat +one","chat +two"],"reviewed":true,"review_reason":"x"}]`),
want: "must not declare to",
},
"ambiguous too small": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one"],"reviewed":true,"review_reason":"x"}]`),
want: "at least two",
},
"empty ambiguous candidate": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one",""],"reviewed":true,"review_reason":"x"}]`),
want: "path is empty",
},
"duplicate candidate": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one","chat +one"],"reviewed":true,"review_reason":"x"}]`),
want: "repeats candidate",
},
"duplicate from": {
data: commandFallbackJSON(`[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `,` + validCommandFallbackEntry(`chat +bad`, `chat +other`) + `]`),
want: "duplicate from",
},
"multiple documents": {
data: append(valid, []byte(` {}`)...),
want: "multiple JSON values",
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
if _, err := decodeCommandPathFallbacks(test.data); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("decode error = %v, want containing %q", err, test.want)
}
})
}
}
func TestCrossPlatformCoverageCommandPathFallbackSchemaAndEmbeddedSource(t *testing.T) {
var schema map[string]any
if err := json.Unmarshal(embeddedCommandPathFallbacksSchemaJSON, &schema); err != nil {
t.Fatalf("decode embedded schema: %v", err)
}
if schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" || schema["additionalProperties"] != false {
t.Fatalf("schema header = %#v", schema)
}
entries, err := LoadCommandPathFallbacks()
if err != nil || len(entries) != 34 {
t.Fatalf("LoadCommandPathFallbacks() = %#v, %v", entries, err)
}
if got, ok := LookupCommandPathFallback("dws chat +group-search"); !ok || got.To != "chat +chat-search" {
t.Fatalf("generated lookup = %#v, %v", got, ok)
}
if _, ok := LookupCommandPathFallback("chat +missing"); ok {
t.Fatal("generated lookup accepted unknown path")
}
}
func TestCrossPlatformCoverageCommandPathFallbackAuditCoverage(t *testing.T) {
rewrites := map[string]string{
"chat +bot-list": "chat +chat-bots",
"chat +conversation-detail": "chat +conversation-info",
"chat +group-search": "chat +chat-search",
"chat +members": "chat +group-members",
"chat +group-member-list": "chat +group-members",
"chat +list-group-bots": "chat +chat-bots",
"chat +list-robot": "chat +chat-bots",
"chat +list-robots": "chat +chat-bots",
"chat +rename-group": "chat +chat-update",
"doc +create-version": "doc +history-save",
"doc +list-templates": "doc +template-list",
"doc +save-version": "doc +history-save",
"doc +search-template": "doc +template-search",
"doc +snapshot": "doc +history-save",
"doc +version-create": "doc +history-save",
}
for from, to := range rewrites {
entry, ok := LookupCommandPathFallback(from)
if !ok || entry.Mode != CommandPathFallbackRewrite || entry.To != to {
t.Errorf("rewrite %q = %#v, %v; want %q", from, entry, ok, to)
}
}
ambiguous := map[string][]string{
"chat +conversation-category-list": {"chat +category-list", "chat +category-list-conversations"},
"chat +conversation-group-list": {"chat +category-list-conversations", "chat +conversation-list"},
"chat +group-send-text": {"chat +send-to-group", "chat +messages-send"},
"chat +list-my-groups": {"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"},
"chat +message-list": {"chat +chat-messages", "chat +messages-list-direct", "chat +search-msg", "chat +unread-chats"},
"chat +read-single": {"chat +messages-list-direct", "chat +chat-messages"},
"chat +send": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
"chat +send-by-bot": {"chat +messages-send", "chat message send-by-bot"},
"chat +send-dm": {"chat +dm", "chat +messages-send"},
"chat +send-message": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
"chat +send-single": {"chat +dm", "chat +messages-send"},
"chat +send-text": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
"chat +send-to": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
"chat +send-file": {"chat +messages-send", "chat message send"},
"chat +send-image": {"chat +messages-send", "chat message send"},
"chat +send-media": {"chat +messages-send", "chat message send"},
"oa +list-processes": {"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
"doc +template": {"doc +template-list", "doc +template-search", "doc +create-from-template"},
"doc +version": {"doc +history-list", "doc +history-save", "doc +history-revert"},
}
for from, candidates := range ambiguous {
entry, ok := LookupCommandPathFallback(from)
if !ok || entry.Mode != CommandPathFallbackAmbiguous || !reflect.DeepEqual(entry.Candidates, candidates) {
t.Errorf("ambiguous %q = %#v, %v; want %v", from, entry, ok, candidates)
}
}
if entry, ok := LookupCommandPathFallback("chat +definitely-unknown"); ok {
t.Errorf("unreviewed path unexpectedly falls back: %#v", entry)
}
}
func TestCrossPlatformCoverageReduceCommandPathFallbacksValidatesLiveTree(t *testing.T) {
root := commandFallbackTestRoot()
entries := []CommandPathFallback{
{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good", Reviewed: true, ReviewReason: "fixture"},
{From: "chat group search", Mode: CommandPathFallbackRewrite, To: "chat search", Reviewed: true, ReviewReason: "fixture"},
{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +other"}, Reviewed: true, ReviewReason: "fixture"},
{From: "chat +mixed", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat search"}, Reviewed: true, ReviewReason: "fixture"},
}
withCommandFallbackSource(t, entries)
got, err := ReduceCommandPathFallbacks(root)
if err != nil || len(got) != len(entries) {
t.Fatalf("ReduceCommandPathFallbacks() = %#v, %v", got, err)
}
got[2].Candidates[0] = "mutated"
again, err := ReduceCommandPathFallbacks(root)
if err != nil || again[2].Candidates[0] == "mutated" {
t.Fatalf("reduction did not clone candidates: %#v, %v", again, err)
}
}
func TestCrossPlatformCoverageReduceCommandPathFallbacksRejectsUnsafeMappings(t *testing.T) {
baseRoot := commandFallbackTestRoot
tests := map[string]struct {
entries []CommandPathFallback
mutate func(*cobra.Command)
want string
}{
"missing target": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +missing"}},
want: "does not exist",
},
"real source collision": {
entries: []CommandPathFallback{{From: "chat +other", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
want: "collides with a real Cobra command or alias",
},
"cobra alias collision": {
entries: []CommandPathFallback{{From: "chat +official-alias", Mode: CommandPathFallbackRewrite, To: "chat +other"}},
want: "collides with a real Cobra command or alias",
},
"cross service": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "mail +good"}},
want: "crosses service boundary",
},
"shortcut mismatch": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat search"}},
want: "disagree on +shortcut identity",
},
"chained rewrite": {
entries: []CommandPathFallback{
{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +next"},
{From: "chat +next", Mode: CommandPathFallbackRewrite, To: "chat +good"},
},
want: "chained fallbacks are forbidden",
},
"chained ambiguous candidate": {
entries: []CommandPathFallback{
{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +next"}},
{From: "chat +next", Mode: CommandPathFallbackRewrite, To: "chat +other"},
},
want: "is another fallback source",
},
"missing ambiguous candidate": {
entries: []CommandPathFallback{{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +missing"}}},
want: "does not exist",
},
"hidden target": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
mutate: func(root *cobra.Command) {
exactSchemaCommand(root, "chat +good").Hidden = true
},
want: "not a public runnable",
},
"target is alias": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +official-alias"}},
want: "must use canonical Cobra names",
},
"ambiguous source command": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
mutate: func(root *cobra.Command) {
chat := exactSchemaCommand(root, "chat")
chat.AddCommand(
&cobra.Command{Use: "+bad", Run: func(*cobra.Command, []string) {}},
&cobra.Command{Use: "+bad", Run: func(*cobra.Command, []string) {}},
)
},
want: "cannot be resolved safely",
},
"ambiguous target command": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +duplicate"}},
mutate: func(root *cobra.Command) {
chat := exactSchemaCommand(root, "chat")
chat.AddCommand(
&cobra.Command{Use: "+duplicate", Run: func(*cobra.Command, []string) {}},
&cobra.Command{Use: "+duplicate", Run: func(*cobra.Command, []string) {}},
)
},
want: "target \"chat +duplicate\" cannot be resolved safely",
},
"unsupported reduced mode": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackMode("invalid")}},
want: "unsupported mode",
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
root := baseRoot()
if test.mutate != nil {
test.mutate(root)
}
for index := range test.entries {
test.entries[index].Reviewed = true
test.entries[index].ReviewReason = "fixture"
}
withCommandFallbackSource(t, test.entries)
if _, err := ReduceCommandPathFallbacks(root); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("reduction error = %v, want containing %q", err, test.want)
}
})
}
withCommandFallbackLoadError(t, errors.New("fixture load"))
if _, err := ReduceCommandPathFallbacks(baseRoot()); err == nil || !strings.Contains(err.Error(), "fixture load") {
t.Fatalf("load error = %v", err)
}
if _, err := ReduceCommandPathFallbacks(nil); err == nil || !strings.Contains(err.Error(), "root is nil") {
t.Fatalf("nil root error = %v", err)
}
if got := commandFallbackService(""); got != "" {
t.Fatalf("commandFallbackService(empty) = %q", got)
}
}
func commandFallbackTestRoot() *cobra.Command {
root := &cobra.Command{Use: "dws"}
chat := &cobra.Command{Use: "chat"}
good := &cobra.Command{Use: "+good", Aliases: []string{"+official-alias"}, Run: func(*cobra.Command, []string) {}}
chat.AddCommand(
good,
&cobra.Command{Use: "+other", Run: func(*cobra.Command, []string) {}},
&cobra.Command{Use: "search", Run: func(*cobra.Command, []string) {}},
)
group := &cobra.Command{Use: "group"}
group.AddCommand(cmdutil.HintSubCmd("search", "use chat search"))
chat.AddCommand(group)
mail := &cobra.Command{Use: "mail"}
mail.AddCommand(&cobra.Command{Use: "+good", Run: func(*cobra.Command, []string) {}})
root.AddCommand(chat, mail)
return root
}
func withCommandFallbackSource(t *testing.T, entries []CommandPathFallback) {
t.Helper()
old := loadReviewedCommandPathFallbacks
loadReviewedCommandPathFallbacks = func() ([]CommandPathFallback, error) {
return cloneCommandPathFallbacks(entries), nil
}
t.Cleanup(func() { loadReviewedCommandPathFallbacks = old })
}
func withCommandFallbackLoadError(t *testing.T, want error) {
t.Helper()
old := loadReviewedCommandPathFallbacks
loadReviewedCommandPathFallbacks = func() ([]CommandPathFallback, error) { return nil, want }
t.Cleanup(func() { loadReviewedCommandPathFallbacks = old })
}
func TestCrossPlatformCoverageCommandPathFallbackGeneratedTableWellFormed(t *testing.T) {
entries := loadGeneratedCommandPathFallbacks()
if len(entries) == 0 {
t.Fatal("generated command path fallback table is empty")
}
seen := map[string]bool{}
for _, entry := range entries {
if seen[entry.From] || !entry.Reviewed || strings.TrimSpace(entry.ReviewReason) == "" {
t.Fatalf("invalid generated entry: %#v", entry)
}
seen[entry.From] = true
}
if !reflect.DeepEqual(entries, cloneCommandPathFallbacks(entries)) {
t.Fatal("generated table clone changed values")
}
}
+5 -1
View File
@@ -27,7 +27,9 @@
// 3. schema_parameter_mapping_ledger.go mapping exclusions / removals (Go)
// (active bindings retired; ParamDecl.Property owns delivery)
// 4. param_concepts.json + schema reviewed parameter synonym policy
// 5. cobra command tree (Go runtime) flags/usage/required (reflected)
// 5. command_path_fallbacks.json + schema
// reviewed recovery-only invalid paths
// 6. cobra command tree (Go runtime) flags/usage/required (reflected)
//
// schema_hints/, schema_agent_metadata/, schema_command_registry/,
// schema_mcp_metadata.json, and schema_mcp_service_review(.json|/ledger) are
@@ -35,7 +37,9 @@
//
// Remaining generated output from this file:
// - param_aliases_generated.go per-command parameter normalization
// - command_path_fallbacks_generated.go invalid-path recovery normalization
package cli
//go:generate go run ../generator/cmd_param_aliases -root ../.. -output param_aliases_generated.go
//go:generate go run ../generator/cmd_command_path_fallbacks -root ../.. -output command_path_fallbacks_generated.go
@@ -21,7 +21,7 @@ import (
"testing"
)
func TestGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
func TestCrossPlatformCoverageGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
entries, err := os.ReadDir(".")
if err != nil {
t.Fatalf("read internal/cli: %v", err)
@@ -50,6 +50,7 @@ func TestGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
for _, generator := range []string{
"cmd_schema_catalog",
"cmd_param_aliases",
"cmd_command_path_fallbacks",
} {
if !bytes.Contains(content, []byte("//go:generate go run")) || !bytes.Contains(content, []byte(generator)) {
t.Errorf("gen.go does not register %s", generator)
File diff suppressed because it is too large Load Diff
+187 -18
View File
@@ -10,32 +10,37 @@
},
"concepts": {
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +template-search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "mail thread list", "oa +list-executed"], "risk": "green"},
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +create-from-template", "doc +find-doc", "doc +search", "doc +template-search", "doc template search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "doc +comment-list", "doc +find-doc", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "mail thread list", "oa +list-executed"], "risk": "green"},
"page_number": {"denotes": "one-based page number", "canonical_hint": "page", "members": ["page", "page-no", "current-page", "page-num"], "excludes": ["cursor", "page-index", "page-size", "page-token"], "commands": ["devdoc article search"], "risk": "green"},
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list"], "risk": "green"},
"content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc block insert", "doc block update"], "risk": "green"},
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list", "doc +comment-list", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list"], "risk": "green"},
"content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc +checkpoint-update", "doc +comment-create", "doc +comment-reply", "doc +comment-update", "doc +create", "doc +doc-append", "doc block insert", "doc block update", "doc comment create", "doc comment create-inline", "doc comment reply", "doc comment update", "doc create"], "risk": "green"},
"time_start": {"denotes": "start time point with unchanged value format and unit", "canonical_hint": "start", "members": ["start", "start-time", "start-date", "from", "from-date", "begin", "since", "time-min", "min-time"], "excludes": ["date", "time", "end"], "commands": ["calendar event list", "chat message list-all", "report list"], "risk": "yellow"},
"time_end": {"denotes": "end time point with unchanged value format and unit", "canonical_hint": "end", "members": ["end", "end-time", "end-date", "time-max", "max-time"], "excludes": ["date", "time", "start"], "commands": ["calendar event list"], "risk": "yellow"},
"base_id": {"denotes": "multi-dimensional table Base id", "canonical_hint": "base-id", "members": ["base", "base-id", "base-token"], "excludes": [], "commands": ["aitable +field-get", "aitable +list-tables", "aitable +record-query", "aitable +record-share-url", "aitable +table-get"], "risk": "green"},
"dept_id": {"denotes": "single department id", "canonical_hint": "dept", "members": ["dept", "dept-id", "department", "department-id", "parent", "parent-id"], "excludes": ["depts", "dept-ids", "department-ids", "name", "query"], "commands": ["contact +list-sub-depts", "contact dept list-children"], "risk": "yellow"},
"dept_ids": {"denotes": "department id list", "canonical_hint": "dept-ids", "members": ["depts", "dept-ids", "department-ids"], "excludes": ["dept", "dept-id", "department-id", "name", "query"], "commands": ["contact +list-dept-members"], "risk": "yellow"},
"group_id": {"denotes": "single DingTalk numeric groupId", "canonical_hint": "group-id", "members": ["group-id"], "excludes": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "commands": ["chat group get-by-group-id"], "risk": "yellow"},
"open_conversation_id": {"denotes": "single DingTalk openConversationId with unchanged value", "canonical_hint": "conversation-id", "members": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id"], "excludes": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids", "group-name", "name", "id", "source", "target", "src-conversation-id", "dest-conversation-id"], "commands": ["chat +chat-bots", "chat +chat-dismiss", "chat +chat-invite-url", "chat +chat-mute", "chat +chat-role-add", "chat +chat-role-list", "chat +chat-role-query-user", "chat +chat-role-set-user", "chat +chat-role-update", "chat +chat-set-admin", "chat +chat-set-history", "chat +chat-update-alias", "chat +chat-update-nick", "chat +conversation-info", "chat +messages-list-pin", "chat +messages-read-status", "chat category add-conv", "chat category remove-conv", "chat chmod", "chat clear-messages", "chat clear-red-point", "chat conversation-info", "chat group audit-join-validation", "chat group bots", "chat group dismiss", "chat group invite-url", "chat group members", "chat group members add", "chat group members add-bot", "chat group members list-by-ids", "chat group members remove", "chat group members remove-bot", "chat group notice create", "chat group notice edit", "chat group notice get", "chat group notice list", "chat group quit", "chat group rename", "chat group set-admin", "chat group set-history", "chat group transfer-owner", "chat group update-alias", "chat group update-icon", "chat group update-nick", "chat group update-settings", "chat group-mute", "chat group-mute-member", "chat group-role add", "chat group-role list", "chat group-role query-user", "chat group-role remove", "chat group-role remove-user", "chat group-role set-user", "chat group-role update", "chat hide", "chat mark-read", "chat mark-unread", "chat message add-favorite", "chat message download-media", "chat message list", "chat message list-mentions", "chat message list-pin-msg", "chat message list-topic-replies", "chat message read-status", "chat message recall", "chat message recall-by-bot", "chat message remove-favorite", "chat message reply", "chat message search", "chat message send", "chat message send-by-bot", "chat message send-card", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg", "chat mute-at-all", "chat mute-red-envelope", "chat set-top"], "risk": "yellow"},
"group_id": {"denotes": "single DingTalk numeric groupId", "canonical_hint": "group-id", "members": ["group-id"], "excludes": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "commands": ["chat +chat-get-by-id", "chat group get-by-group-id"], "risk": "yellow"},
"open_conversation_id": {"denotes": "single DingTalk openConversationId with unchanged value", "canonical_hint": "conversation-id", "members": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id"], "excludes": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids", "group-name", "name", "id", "source", "target", "src-conversation-id", "dest-conversation-id"], "commands": ["chat +category-add-conversation", "chat +category-remove-conversation", "chat +chat-add-bot", "chat +chat-audit-join", "chat +chat-bots", "chat +chat-dismiss", "chat +chat-invite-url", "chat +chat-members-get", "chat +chat-mute", "chat +chat-mute-member", "chat +chat-quit", "chat +chat-remove-bot", "chat +chat-role-add", "chat +chat-role-list", "chat +chat-role-query-user", "chat +chat-role-remove", "chat +chat-role-remove-user", "chat +chat-role-set-user", "chat +chat-role-update", "chat +chat-set-admin", "chat +chat-set-history", "chat +chat-transfer-owner", "chat +chat-update-alias", "chat +chat-update-icon", "chat +chat-update-nick", "chat +chat-update-settings", "chat +conversation-clear-messages", "chat +conversation-clear-red-point", "chat +conversation-hide", "chat +conversation-info", "chat +conversation-mark-read", "chat +conversation-mark-unread", "chat +conversation-mute", "chat +flag-cancel", "chat +flag-create", "chat +messages-add-emoji", "chat +messages-add-text-emotion", "chat +messages-list-pin", "chat +messages-read-status", "chat +messages-recall-by-bot", "chat +messages-remove-emoji", "chat +messages-remove-text-emotion", "chat +messages-reply", "chat +messages-resource-download", "chat +messages-resource-url", "chat +messages-send-by-bot", "chat +messages-set-pin", "chat +messages-set-top", "chat +messages-unset-pin", "chat +messages-unset-top", "chat category add-conv", "chat category remove-conv", "chat chmod", "chat clear-messages", "chat clear-red-point", "chat conversation-info", "chat group audit-join-validation", "chat group bots", "chat group dismiss", "chat group invite-url", "chat group members", "chat group members add", "chat group members add-bot", "chat group members list-by-ids", "chat group members remove", "chat group members remove-bot", "chat group notice create", "chat group notice edit", "chat group notice get", "chat group notice list", "chat group quit", "chat group rename", "chat group set-admin", "chat group set-history", "chat group transfer-owner", "chat group update-alias", "chat group update-icon", "chat group update-nick", "chat group update-settings", "chat group-mute", "chat group-mute-member", "chat group-role add", "chat group-role list", "chat group-role query-user", "chat group-role remove", "chat group-role remove-user", "chat group-role set-user", "chat group-role update", "chat hide", "chat mark-read", "chat mark-unread", "chat message add-favorite", "chat message download-media", "chat message list", "chat message list-mentions", "chat message list-pin-msg", "chat message list-topic-replies", "chat message read-status", "chat message recall", "chat message recall-by-bot", "chat message remove-favorite", "chat message reply", "chat message search", "chat message send", "chat message send-by-bot", "chat message send-card", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg", "chat mute-at-all", "chat mute-red-envelope", "chat set-top"], "risk": "yellow"},
"open_conversation_ids": {"denotes": "DingTalk openConversationId list with unchanged element values", "canonical_hint": "conversation-ids", "members": ["conversation-ids", "open-conversation-ids", "groups"], "excludes": ["group-id", "group-ids", "conversation-id", "open-conversation-id", "chat-id"], "commands": ["chat message search-advanced"], "risk": "yellow"},
"group_name": {"denotes": "group-name search keyword, not a group identifier", "canonical_hint": "group-name", "members": ["group-name"], "excludes": ["group-id", "conversation-id", "open-conversation-id", "chat-id", "id"], "commands": ["chat +group-members", "chat +send-to-group"], "risk": "yellow"},
"open_message_id": {"denotes": "single DingTalk openMessageId with unchanged value", "canonical_hint": "open-message-id", "members": ["msg-id", "message-id", "open-message-id"], "excludes": ["msg-ids", "message-ids", "open-message-ids", "ref-msg-id", "src-msg-id", "open-task-id", "topic-id", "resource-id"], "commands": ["chat +messages-read-status", "chat mark-read", "chat message add-emoji", "chat message add-favorite", "chat message add-text-emotion", "chat message download-media", "chat message forward", "chat message read-status", "chat message recall", "chat message remove-emoji", "chat message remove-favorite", "chat message remove-text-emotion", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg"], "risk": "yellow"},
"open_message_ids": {"denotes": "DingTalk openMessageId list with unchanged element values", "canonical_hint": "msg-ids", "members": ["msg-ids", "message-ids", "open-message-ids"], "excludes": ["msg-id", "message-id", "open-message-id", "ref-msg-id", "src-msg-id"], "commands": ["chat +messages-mget", "chat message combine-forward", "chat message list-by-ids", "chat message list-emotion-replies"], "risk": "yellow"},
"referenced_open_message_id": {"denotes": "referenced DingTalk openMessageId in a reply", "canonical_hint": "ref-msg-id", "members": ["ref-msg-id", "ref-message-id"], "excludes": ["msg-id", "message-id", "open-message-id", "msg-ids", "src-msg-id"], "commands": ["chat message reply"], "risk": "yellow"},
"open_message_id": {"denotes": "single DingTalk openMessageId with unchanged value", "canonical_hint": "open-message-id", "members": ["msg-id", "message-id", "open-message-id"], "excludes": ["msg-ids", "message-ids", "open-message-ids", "ref-msg-id", "src-msg-id", "open-task-id", "topic-id", "resource-id"], "commands": ["chat +conversation-mark-read", "chat +flag-cancel", "chat +flag-create", "chat +messages-add-emoji", "chat +messages-add-text-emotion", "chat +messages-forward", "chat +messages-read-status", "chat +messages-remove-emoji", "chat +messages-remove-text-emotion", "chat +messages-resource-download", "chat +messages-set-pin", "chat +messages-set-top", "chat +messages-unset-pin", "chat +messages-unset-top", "chat mark-read", "chat message add-emoji", "chat message add-favorite", "chat message add-text-emotion", "chat message download-media", "chat message forward", "chat message read-status", "chat message recall", "chat message remove-emoji", "chat message remove-favorite", "chat message remove-text-emotion", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg"], "risk": "yellow"},
"open_message_ids": {"denotes": "DingTalk openMessageId list with unchanged element values", "canonical_hint": "msg-ids", "members": ["msg-ids", "message-ids", "open-message-ids"], "excludes": ["msg-id", "message-id", "open-message-id", "ref-msg-id", "src-msg-id"], "commands": ["chat +flag-cancel", "chat +flag-create", "chat +messages-combine-forward", "chat +messages-mget", "chat message combine-forward", "chat message list-by-ids", "chat message list-emotion-replies"], "risk": "yellow"},
"referenced_open_message_id": {"denotes": "referenced DingTalk openMessageId in a reply", "canonical_hint": "ref-msg-id", "members": ["ref-msg-id", "ref-message-id"], "excludes": ["msg-id", "message-id", "open-message-id", "msg-ids", "src-msg-id"], "commands": ["chat +messages-reply", "chat message reply"], "risk": "yellow"},
"user_id": {"denotes": "single user id", "canonical_hint": "user-id", "members": ["user", "user-id", "userid", "uid", "staff-id"], "excludes": ["at-user-ids", "to-user", "users", "user-ids", "name"], "commands": ["chat +chat-role-query-user", "chat +chat-role-set-user", "chat +messages-list-direct", "chat chmod", "chat conversation-info", "chat group transfer-owner", "chat group-role query-user", "chat group-role remove-user", "chat group-role set-user", "chat message list", "chat message send", "contact user profile get"], "risk": "yellow"},
"user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"},
"open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"},
"user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat +messages-batch-send-by-bot", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"},
"open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat +chat-members-get", "chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"},
"ding_id": {"denotes": "DING id", "canonical_hint": "ding-id", "members": ["ding-id", "open-ding-id"], "excludes": ["id"], "commands": ["ding message receiver-status"], "risk": "yellow"},
"folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive list", "mail folder update"], "risk": "green"},
"space_id": {"denotes": "drive/wiki space id", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["drive info"], "risk": "yellow"},
"space_id": {"denotes": "drive/wiki/Doc workspace id with unchanged value", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +copy", "doc +create", "doc +create-from-template", "doc +grant-and-share", "doc +import", "doc +list", "doc +move", "doc create", "doc file create", "doc import", "doc template apply", "drive info"], "risk": "yellow"},
"app_id": {"denotes": "application id", "canonical_hint": "unified-app-id", "members": ["app-id", "unified-app-id", "application-id"], "excludes": ["app-key", "app-secret", "agent-id"], "commands": ["dev app get"], "risk": "yellow"},
"robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id"], "commands": ["chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"},
"open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat group members remove-bot"], "risk": "yellow"}
"robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id", "bot-id", "open-bot-id", "bot-code"], "commands": ["chat +chat-add-bot", "chat +messages-batch-recall-by-bot", "chat +messages-batch-send-by-bot", "chat +messages-recall-by-bot", "chat +messages-send-by-bot", "chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"},
"open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat +chat-remove-bot", "chat group members remove-bot"], "risk": "yellow"},
"doc_node_id": {"denotes": "single DingTalk document nodeId or accepted document URL/token with unchanged value", "canonical_hint": "node", "members": ["node", "node-id", "doc", "doc-id", "file-id", "document-id", "url"], "excludes": ["id", "folder", "folder-id", "parent-id", "workspace", "workspace-id", "block-id", "comment-id", "comment-key", "job-id", "task-id", "template-id", "version", "revision"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +background-delete", "doc +background-update", "doc +checkpoint-update", "doc +comment-create", "doc +comment-delete", "doc +comment-list", "doc +comment-reply", "doc +comment-update", "doc +copy", "doc +doc-append", "doc +export", "doc +export-submit", "doc +fetch", "doc +history-list", "doc +history-revert", "doc +history-save", "doc +inspect", "doc +move", "doc +review", "doc +version-list", "doc +version-revert", "doc +version-save", "doc block delete", "doc block insert", "doc block list", "doc block update", "doc comment create", "doc comment create-inline", "doc comment delete", "doc comment list", "doc comment reply", "doc comment update", "doc export", "doc info", "doc media download", "doc media insert", "doc media upload", "doc read", "doc style background clear", "doc style background set", "doc style cover clear", "doc style cover set", "doc style get", "doc update", "doc version list", "doc version revert", "doc version save", "doc whiteboard insert"], "risk": "yellow"},
"doc_comment_key": {"denotes": "single DingTalk document commentKey with unchanged value", "canonical_hint": "comment-key", "members": ["comment-key", "comment-id"], "excludes": ["id", "node", "node-id", "doc-id", "block-id"], "commands": ["doc +comment-delete", "doc +comment-reply", "doc +comment-update", "doc comment delete", "doc comment reply", "doc comment update"], "risk": "yellow"},
"doc_version_number": {"denotes": "single DingTalk document historical version number with unchanged integer value", "canonical_hint": "version", "members": ["version", "version-number", "version-no"], "excludes": ["revision", "id", "node", "node-id", "doc-id"], "commands": ["doc +history-revert", "doc +version-revert", "doc version revert"], "risk": "yellow"},
"doc_content_format": {"denotes": "DingTalk document body format with unchanged markdown/jsonml value", "canonical_hint": "content-format", "members": ["content-format", "doc-format"], "excludes": ["format", "export-format", "mime-type"], "commands": ["doc +create", "doc +update", "doc create", "doc update"], "risk": "green"},
"doc_edit_revision": {"denotes": "single optimistic-concurrency revision for a document edit", "canonical_hint": "revision", "members": ["revision", "expected-revision"], "excludes": ["version", "version-number", "version-no"], "commands": ["doc +update", "doc update"], "risk": "yellow"}
},
"command_overrides": {
@@ -91,10 +96,24 @@
"chat group-role set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
"chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."},
"chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."},
"doc block insert": {"block": ["before-block-id"], "note": "requires a two-parameter conversion to --ref-block plus --where before; name-only normalization would silently default to after"},
"doc block insert": {"block": ["before-block-id"], "note": "Parent and reference roles remain distinct. --before-block-id needs both --ref-block and --where before, while role-free --block-id cannot choose parent versus reference.", "scoped_aliases": {"parent-block-id": "parent-block", "ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "ambiguous": ["block-id"], "scope_strict": true},
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."},
"doc +export-get": {"block": ["node"], "note": "node is a document node id, a different entity from job-id"},
"doc +export-get": {"block": ["doc-id", "document-id", "file-id", "node", "node-id", "task-id", "url"], "note": "This command queries one export jobId. Document node identifiers and import taskId spellings are different entities and are rejected.", "scoped_aliases": {"export-job-id": "job-id"}, "scope_strict": true},
"doc block delete": {"block": ["index"], "note": "index (position) vs node (node id) are different"},
"doc +copy": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc +list": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc +move": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc comment create": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
"doc comment reply": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
"doc comment update": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
"doc +comment-create": {"block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids", "mentioned-open-conversation-id", "mentioned-open-conversation-ids", "open-conversation-id", "open-conversation-ids"], "note": "This shortcut has no group-mention flag or payload field. Group-conversation spellings are blocked instead of inventing unsupported capability; route to the stable doc comment command when group mentions are required."},
"doc +comment-reply": {"block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids", "mentioned-open-conversation-id", "mentioned-open-conversation-ids", "open-conversation-id", "open-conversation-ids"], "note": "This shortcut has no group-mention flag or payload field. Group-conversation spellings are blocked instead of inventing unsupported capability; route to the stable doc comment command when group mentions are required."},
"doc media insert": {"scoped_aliases": {"ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "block": ["before-block-id", "parent-block", "parent-block-id"], "ambiguous": ["block-id"], "scope_strict": true, "note": "Media insertion supports a reference block but no parent-block role. --before-block-id additionally needs --where before; role-free --block-id is left ambiguous."},
"doc read": {"block": ["before-block-id", "parent-block-id", "ref-block-id", "reference-block-id"], "ambiguous": ["block-id"], "note": "A section read requires --scope section plus a start/end boundary. A role-free --block-id cannot be reduced to one flag without inventing the missing scope/boundary role."},
"doc export get": {"scoped_aliases": {"export-job-id": "job-id"}, "block": ["doc-id", "document-id", "file-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one export jobId. Document node identifiers are rejected; native hidden --task-id remains the command's reviewed add-only compatibility alias for --job-id."},
"doc import get": {"scoped_aliases": {"import-task-id": "task-id"}, "block": ["doc-id", "document-id", "file-id", "job-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one import taskId. Document node identifiers and export jobId spellings are different entities and are rejected."},
"doc +share-doc": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a document nodeId into a URL, so identifier spellings are rejected with guidance to provide --url."},
"doc update": {"block": ["version", "version-no", "version-number"], "note": "--revision is an optimistic-concurrency revision, not a historical document version number. Version spellings must not reduce to --revision."},
"report outbox list": {"block": ["template-type"], "note": "type vs name are different fields"},
"chat group members add-bot": {"bind": {"id": "open_conversation_id"}},
"chat group members list-by-ids": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["user-id", "user-ids"], "note": "This command's --id carries openConversationId, while --users carries an openDingTalkId list."},
@@ -111,7 +130,48 @@
"chat category create-smart": {"bind": {"members": "open_dingtalk_ids"}, "scoped_aliases": {"title": "name"}, "note": "The real --members is an openDingTalkId list; the reviewed title/name alias is exact to the category display name."},
"chat group audit-join-validation": {"ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "note": "A role-free user identifier cannot choose between the required --applicant and --inviter roles."},
"chat message reply": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The required --ref-sender is a role-specific openDingTalkId and must not accept generic userId spellings."},
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."}
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."},
"chat +category-add-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
"chat +category-list-conversations": {"block": ["category-ids"], "note": "The real --category-id is singular; list cardinality is not reduced automatically."},
"chat +category-remove-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
"chat +chat-add-bot": {"bind": {"id": "open_conversation_id"}, "note": "The real --id is the group's openConversationId; robotCode and openBotId remain different domains."},
"chat +chat-audit-join": {"scoped_aliases": {"applicant-user-id": "applicant", "inviter-user-id": "inviter"}, "ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "scope_strict": true, "note": "A role-free user identifier cannot choose between applicant and inviter."},
"chat +chat-create": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users field is a list and may contain mixed identifier domains; a singular value is not promoted."},
"chat +chat-get-by-id": {"block": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "note": "The real --group-id is numeric groupId; no CID or group-name spelling can be value-preservingly converted."},
"chat +chat-members-get": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["group", "group-name", "user-id", "user-ids"], "note": "The real --id is openConversationId and --users is an openDingTalkId list. The observed --group spelling carried a natural group name and is blocked; explicit CID spellings and --chat remain value-preserving aliases."},
"chat +chat-members-list": {"scoped_aliases": {"chat-id": "conversation-id", "id": "conversation-id"}, "block": ["query", "keyword", "group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "scope_strict": true, "note": "Native --chat/--open-conversation-id stay native; member filtering by query is unsupported and group-name resolution stays on --group/--chat-query."},
"chat +chat-mute-member": {"scoped_aliases": {"user-ids": "users", "open-dingtalk-ids": "users"}, "block": ["user", "user-id", "open-dingtalk-id"], "scope_strict": true, "note": "The target accepts a mixed identifier list; list spellings preserve values, but singular inputs are not promoted."},
"chat +chat-remove-bot": {"bind": {"id": "open_conversation_id"}, "note": "The real --id is openConversationId; --bot-id is separately governed by open_bot_id."},
"chat +chat-role-remove": {"block": ["role-ids"], "note": "The command removes one role ID; list cardinality is not reduced."},
"chat +chat-role-remove-user": {"scoped_aliases": {"user-id": "user", "open-dingtalk-id": "user"}, "block": ["role-id"], "scope_strict": true, "note": "The single --user accepts either identifier domain; --role-ids remains a list."},
"chat +chat-transfer-owner": {"scoped_aliases": {"user-id": "new-owner", "open-dingtalk-id": "new-owner"}, "scope_strict": true, "note": "The only user role is the new owner, and the target accepts either userId or openDingTalkId without changing the value."},
"chat +chat-update": {"scoped_aliases": {"conversation-id": "group", "open-conversation-id": "group", "chat-id": "group", "title": "name", "new-title": "name"}, "block": ["id", "group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "scope_strict": true, "note": "--group accepts a name or CID, so only explicit CID spellings are mapped; generic --id is blocked."},
"chat +conversation-set-top": {"scoped_aliases": {"open-conversation-id": "conversation-id", "chat-id": "conversation-id", "open-conversation-ids": "conversation-ids", "chat-ids": "conversation-ids"}, "block": ["group", "groups", "group-id", "group-ids", "top", "set-top"], "scope_strict": true, "note": "Singular/list cardinality stays explicit; top/set-top cannot be rewritten to the inverse --off switch."},
"chat +feed-group-query-item": {"scoped_aliases": {"open-conversation-ids": "conversation-ids", "chat-ids": "conversation-ids"}, "block": ["group", "groups", "group-id", "group-ids", "conversation-id", "open-conversation-id"], "scope_strict": true, "note": "The real field is an openConversationId list; group names and singular IDs are not converted."},
"chat +flag-list": {"scoped_aliases": {"limit": "size"}, "block": ["max", "max-results", "max-size", "count", "page", "page-size", "per-page"], "scope_strict": true, "note": "Only limit and size are reviewed as the same page bound; total-count and page-number spellings are not equivalent."},
"chat +messages-batch-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values returned by bot sending; it is not an openMessageId field."},
"chat +messages-combine-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "Source and destination conversation roles remain explicit; role-free CID spellings cannot choose a side."},
"chat +messages-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id", "src-open-message-id": "msg-id", "source-message-id": "msg-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "The message role is uniquely the source message, but source/destination conversation roles cannot be inferred from a generic CID."},
"chat +messages-forward-topic": {"scoped_aliases": {"src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id", "src-open-message-id": "src-msg-id", "source-message-id": "src-msg-id"}, "block": ["group-id", "group-ids", "msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "Source and destination conversation roles and the source-message role remain explicit; role-free message IDs and list cardinality are not inferred."},
"chat +messages-list": {"scoped_aliases": {"start": "time"}, "block": ["before", "before-time", "end", "direction", "page-all", "count", "max", "max-results", "max-size", "page-size"], "scope_strict": true, "note": "start preserves the same boundary value; before/direction require multi-parameter or value transforms and page-all requires iteration. Native --conversation-id/--id/--size remain native."},
"chat +messages-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values, not openMessageId values."},
"chat +messages-reply": {"scoped_aliases": {"msg-id": "ref-msg-id", "open-message-id": "ref-msg-id"}, "block": ["group", "msg-ids", "message-ids", "open-message-ids"], "scope_strict": true, "note": "The observed --group spelling carried a natural group name and is blocked. The only message role is the referenced message; plural IDs are not accepted, while --chat remains a CID alias and native --message-id stays native."},
"chat +messages-resource-download": {"block": ["download-dir"], "note": "--output may be a file or directory under workspace safety rules; a download directory cannot be assumed equivalent."},
"doc +create": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["content-file", "parent-id"], "scope_strict": true, "note": "--content-format is value-preservingly normalized to --doc-format. A raw --content-file path cannot become --content without adding the required @file transform, so it is blocked with guidance to use @relative-path or stable doc create."},
"doc +create-from-template": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc +import": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc +update": {"scoped_aliases": {"mode": "command", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "url": "node"}, "block": ["content-file"], "scope_strict": true, "note": "--mode append/overwrite is the same operation selector subset as --command and preserves its value. --content-file is blocked because +update requires @relative-path or stdin and central aliases cannot read/transform a file value."},
"doc +inspect": {"scoped_aliases": {"include-versions": "include-history"}, "block": ["include", "include-info"], "scope_strict": true, "note": "Historical versions and history are the same optional section on this exact shortcut. Generic --include needs value-dependent flag expansion, while base document info is always returned, so those spellings are rejected with precise guidance."},
"doc +fetch": {"scoped_aliases": {"start-block": "start-block-id", "end-block": "end-block-id"}, "ambiguous": ["block-id"], "scope_strict": true, "note": "Start/end block roles are preserved. A role-free --block-id cannot choose a range/section boundary and must stop before execution."},
"doc +media-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose between document and media identities."},
"doc +media-insert": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and local-media roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
"doc +media-list": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "The command lists media inside one document, so only strong document spellings map to --node. File and URL spellings remain role-ambiguous."},
"doc +media-preview": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
"doc +resource-delete": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command removes a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
"doc +resource-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command downloads a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
"doc +resource-update": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has document-node, local-file, and HTTPS image URL roles. Only strong document spellings map to --node; --file-id and --url must stop as ambiguous."},
"doc +share": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a node identifier into a URL, so identifier spellings are rejected with guidance to provide --url."},
"doc +grant-and-share": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node"}, "scope_strict": true, "note": "This workflow has two different real URL roles: --node selects the document for access control and --url is the shareable link sent to recipients. Explicit document-ID spellings map only to --node; --url remains native."}
},
"validation_fixture": {
@@ -258,8 +318,117 @@
{"command": "chat +unread-chats", "emitted": "size", "expect": "count", "via": "override:scoped"},
{"command": "chat category rename", "emitted": "name", "expect": "title", "via": "override:scoped"},
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"},
{"command": "doc +comment-create", "emitted": "node-id", "expect": "node", "via": "concept:doc_node_id"},
{"command": "doc +doc-append", "emitted": "node", "expect": "doc", "via": "concept:doc_node_id"},
{"command": "doc +find-doc", "emitted": "keyword", "expect": "query", "via": "concept:search_query"},
{"command": "doc +search", "emitted": "q", "expect": "query", "via": "concept:search_query"},
{"command": "doc +comment-list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size"},
{"command": "doc +list", "emitted": "page-token", "expect": "cursor", "via": "concept:page_cursor"},
{"command": "doc +copy", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
{"command": "doc +copy", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
{"command": "doc +copy", "emitted": "parent-id", "expect": "did-you-mean:blocked", "via": "guard:doc-folder-value-domain"},
{"command": "doc +comment-reply", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
{"command": "doc comment reply", "emitted": "mentioned-open-conversation-ids", "expect": "mentioned-open-conversation-id", "via": "override:scoped-role-list"},
{"command": "doc comment reply", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:numeric-group-id-vs-open-conversation-id"},
{"command": "doc +comment-create", "emitted": "mentioned-open-conversation-id", "expect": "did-you-mean:blocked", "via": "guard:shortcut-missing-capability"},
{"command": "doc block insert", "emitted": "parent-block-id", "expect": "parent-block", "via": "override:scoped-block-role"},
{"command": "doc block insert", "emitted": "block-id", "expect": "did-you-mean:ambiguous", "via": "guard:parent-vs-reference-block-role"},
{"command": "doc media insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-ref-block-plus-where"},
{"command": "doc read", "emitted": "block-id", "expect": "did-you-mean:ambiguous", "via": "guard:requires-scope-and-boundary-role"},
{"command": "doc export get", "emitted": "node", "expect": "did-you-mean:blocked", "via": "guard:document-node-vs-export-job"},
{"command": "doc import get", "emitted": "job-id", "expect": "did-you-mean:blocked", "via": "guard:export-job-vs-import-task"},
{"command": "doc +version-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
{"command": "doc +version-revert", "emitted": "revision", "expect": "did-you-mean:blocked", "via": "concept:doc_version_number+exclude"},
{"command": "doc update", "emitted": "version", "expect": "did-you-mean:blocked", "via": "guard:historical-version-vs-edit-revision"},
{"command": "doc +share-doc", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"},
{"command": "doc +comment-create", "emitted": "body", "expect": "content", "via": "concept:content_text"},
{"command": "doc +doc-append", "emitted": "content", "expect": "text", "via": "concept:content_text"},
{"command": "doc +export-submit", "emitted": "doc-id", "expect": "node", "via": "concept:doc_node_id"},
{"command": "doc +move", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
{"command": "doc +template-list", "emitted": "next-token", "expect": "cursor", "via": "concept:page_cursor"},
{"command": "doc +version-list", "emitted": "page-size", "expect": "limit", "via": "concept:pagination_size"},
{"command": "doc +version-save", "emitted": "file-id", "expect": "node", "via": "concept:doc_node_id"},
{"command": "doc comment create", "emitted": "text", "expect": "content", "via": "concept:content_text"},
{"command": "doc comment create-inline", "emitted": "body", "expect": "content", "via": "concept:content_text"},
{"command": "doc comment delete", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
{"command": "doc comment update", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
{"command": "doc version revert", "emitted": "version-no", "expect": "version", "via": "concept:doc_version_number"},
{"command": "chat +chat-messages", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"},
{"command": "chat +search-msg", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"}
{"command": "chat +search-msg", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"},
{"command": "chat +chat-update", "emitted": "chat-id", "expect": "group", "via": "override:scoped-explicit-cid"},
{"command": "chat +chat-update", "emitted": "conversation-id", "expect": "group", "via": "override:scoped-explicit-cid"},
{"command": "chat +chat-update", "emitted": "open-conversation-id", "expect": "group", "via": "override:scoped-explicit-cid"},
{"command": "chat +chat-update", "emitted": "id", "expect": "did-you-mean:blocked", "via": "guard:generic-id-value-domain"},
{"command": "chat +chat-update", "emitted": "title", "expect": "name", "via": "override:scoped-group-title"},
{"command": "chat +chat-update", "emitted": "new-title", "expect": "name", "via": "override:scoped-group-title"},
{"command": "chat +flag-list", "emitted": "limit", "expect": "size", "via": "override:scoped-page-bound"},
{"command": "chat +flag-list", "emitted": "max", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
{"command": "chat +flag-list", "emitted": "max-results", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
{"command": "chat +flag-list", "emitted": "max-size", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
{"command": "chat +chat-members-list", "emitted": "chat-id", "expect": "conversation-id", "via": "override:scoped-explicit-cid"},
{"command": "chat +chat-members-list", "emitted": "id", "expect": "conversation-id", "via": "override:scoped-explicit-cid"},
{"command": "chat +chat-members-list", "emitted": "query", "expect": "did-you-mean:blocked", "via": "guard:unsupported-member-filter"},
{"command": "chat +conversation-set-top", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "override:scoped-explicit-cid"},
{"command": "chat +conversation-set-top", "emitted": "chat-ids", "expect": "conversation-ids", "via": "override:scoped-explicit-cid-list"},
{"command": "chat +conversation-set-top", "emitted": "groups", "expect": "did-you-mean:blocked", "via": "guard:group-name-or-list-ambiguity"},
{"command": "chat +conversation-set-top", "emitted": "top", "expect": "did-you-mean:blocked", "via": "guard:inverse-boolean-semantics"},
{"command": "chat +chat-members-get", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
{"command": "chat +chat-members-get", "emitted": "open-dingtalk-ids", "expect": "users", "via": "concept:open_dingtalk_ids+bind"},
{"command": "chat +chat-members-get", "emitted": "group", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"},
{"command": "chat +chat-members-get", "emitted": "chat", "expect": "id", "via": "concept:open_conversation_id+bind"},
{"command": "chat +chat-get-by-id", "emitted": "group", "expect": "did-you-mean:blocked", "via": "guard:open-conversation-id-vs-numeric-group-id"},
{"command": "chat +messages-list", "emitted": "start", "expect": "time", "via": "override:scoped-time-boundary"},
{"command": "chat +messages-list", "emitted": "count", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
{"command": "chat +messages-list", "emitted": "max-results", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
{"command": "chat +messages-list", "emitted": "page-all", "expect": "did-you-mean:blocked", "via": "guard:requires-pagination-loop"},
{"command": "chat +messages-reply", "emitted": "msg-id", "expect": "ref-msg-id", "via": "override:scoped-reference-message"},
{"command": "chat +messages-reply", "emitted": "group", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"},
{"command": "chat +messages-reply", "emitted": "chat", "expect": "conversation-id", "via": "concept:open_conversation_id"},
{"command": "chat +flag-cancel", "emitted": "group", "expect": "conversation-id", "via": "concept:open_conversation_id"},
{"command": "chat +flag-cancel", "emitted": "chat", "expect": "conversation-id", "via": "concept:open_conversation_id"},
{"command": "chat +flag-create", "emitted": "group", "expect": "conversation-id", "via": "concept:open_conversation_id"},
{"command": "chat +chat-add-bot", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
{"command": "chat +chat-add-bot", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code"},
{"command": "chat +chat-audit-join", "emitted": "applicant-user-id", "expect": "applicant", "via": "override:scoped-user-role"},
{"command": "chat +chat-audit-join", "emitted": "user-id", "expect": "did-you-mean:ambiguous", "via": "guard:applicant-vs-inviter-role"},
{"command": "chat +chat-create", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
{"command": "chat +chat-mute-member", "emitted": "user-ids", "expect": "users", "via": "override:scoped-mixed-id-list"},
{"command": "chat +chat-mute-member", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
{"command": "chat +chat-remove-bot", "emitted": "open-bot-id", "expect": "bot-id", "via": "concept:open_bot_id"},
{"command": "chat +chat-role-remove", "emitted": "role-ids", "expect": "did-you-mean:blocked", "via": "guard:list-vs-single"},
{"command": "chat +chat-role-remove-user", "emitted": "open-dingtalk-id", "expect": "user", "via": "override:scoped-mixed-id"},
{"command": "chat +chat-transfer-owner", "emitted": "user-id", "expect": "new-owner", "via": "override:scoped-owner-role"},
{"command": "chat +feed-group-query-item", "emitted": "chat-ids", "expect": "conversation-ids", "via": "override:scoped-explicit-cid-list"},
{"command": "chat +feed-group-query-item", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
{"command": "chat +messages-batch-recall-by-bot", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:process-query-key-vs-open-message-id"},
{"command": "chat +messages-combine-forward", "emitted": "src-open-cid", "expect": "src-conversation-id", "via": "override:scoped-source-role"},
{"command": "chat +messages-combine-forward", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-destination-role"},
{"command": "chat +messages-forward", "emitted": "source-message-id", "expect": "msg-id", "via": "override:scoped-source-role"},
{"command": "chat +messages-forward", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-destination-role"},
{"command": "chat +messages-forward-topic", "emitted": "src-open-message-id", "expect": "src-msg-id", "via": "override:scoped-source-role"},
{"command": "chat +messages-forward-topic", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:message-source-role"},
{"command": "chat +messages-recall-by-bot", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:process-query-key-vs-open-message-id"},
{"command": "chat +messages-resource-download", "emitted": "conversation-id", "expect": "open-conversation-id", "via": "concept:open_conversation_id"},
{"command": "chat +messages-resource-download", "emitted": "download-dir", "expect": "did-you-mean:blocked", "via": "guard:output-file-or-directory-contract"},
{"command": "chat +messages-set-pin", "emitted": "conversation-id", "expect": "open-conversation-id", "via": "concept:open_conversation_id"},
{"command": "doc +create", "emitted": "content-format", "expect": "doc-format", "via": "concept:doc_content_format"},
{"command": "doc +create", "emitted": "content-file", "expect": "did-you-mean:blocked", "via": "guard:requires-file-read-transform"},
{"command": "doc +inspect", "emitted": "include-versions", "expect": "include-history", "via": "override:scoped-section"},
{"command": "doc +inspect", "emitted": "include", "expect": "did-you-mean:blocked", "via": "guard:requires-value-dependent-flag-expansion"},
{"command": "doc +inspect", "emitted": "include-info", "expect": "did-you-mean:blocked", "via": "guard:base-info-always-returned"},
{"command": "doc +update", "emitted": "mode", "expect": "command", "via": "override:scoped-operation"},
{"command": "doc +update", "emitted": "revision", "expect": "expected-revision", "via": "concept:doc_edit_revision"},
{"command": "doc +update", "emitted": "version", "expect": "did-you-mean:blocked", "via": "guard:historical-version-vs-edit-revision"},
{"command": "doc +fetch", "emitted": "start-block", "expect": "start-block-id", "via": "override:scoped-boundary-role"},
{"command": "doc +fetch", "emitted": "block-id", "expect": "did-you-mean:ambiguous", "via": "guard:start-vs-end-boundary-role"},
{"command": "doc +access-grant", "emitted": "doc-id", "expect": "node", "via": "concept:doc_node_id"},
{"command": "doc +history-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
{"command": "doc +create-from-template", "emitted": "keyword", "expect": "query", "via": "concept:search_query"},
{"command": "doc +create-from-template", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
{"command": "doc +create-from-template", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
{"command": "doc +media-download", "emitted": "file-id", "expect": "did-you-mean:ambiguous", "via": "guard:document-node-vs-attachment-resource-role"},
{"command": "doc +resource-update", "emitted": "url", "expect": "did-you-mean:ambiguous", "via": "guard:document-url-vs-image-url-role"},
{"command": "doc +share", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"}
]
}
}
@@ -0,0 +1,122 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Command cmd_command_path_fallbacks validates the reviewed recovery-only
// command path table against the live distribution Cobra tree and writes the
// deterministic runtime lookup source. It does not add paths to Cobra, Help,
// Skill, Schema, or the stable CommandRegistry identity surface.
package main
import (
"bytes"
"flag"
"fmt"
"go/format"
"os"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/generator/outputguard"
)
const defaultOutput = "internal/cli/command_path_fallbacks_generated.go"
var (
newCommandFallbackRoot = app.NewSchemaSourceRootCommand
reduceCommandFallbackEntries = cli.ReduceCommandPathFallbacks
formatCommandFallbackSource = format.Source
writeCommandFallbackFile = os.WriteFile
exitCommandFallbackProcess = os.Exit
)
func main() {
var rootPath string
var outputPath string
flag.StringVar(&rootPath, "root", ".", "Repository root used to protect generator inputs")
flag.StringVar(&outputPath, "output", defaultOutput, "Output generated command-path fallback table")
flag.Parse()
if err := generateCommandPathFallbacks(rootPath, outputPath); err != nil {
fail(err)
}
}
func generateCommandPathFallbacks(rootPath, outputPath string) error {
if err := validateOutputIsolation(rootPath, outputPath); err != nil {
return err
}
entries, err := reduceCommandFallbackEntries(newCommandFallbackRoot())
if err != nil {
return err
}
source, err := renderCommandPathFallbacks(entries)
if err != nil {
return err
}
if err := writeCommandFallbackFile(outputPath, source, 0o644); err != nil {
return fmt.Errorf("write generated command path fallbacks: %w", err)
}
_, _ = fmt.Fprintf(os.Stderr, "generated command path fallbacks: output=%s entries=%d\n", outputPath, len(entries))
return nil
}
func validateOutputIsolation(rootPath, outputPath string) error {
inputs := []outputguard.Input{
{Name: "reviewed command path fallbacks", Path: "internal/cli/command_path_fallbacks.json"},
{Name: "reviewed command path fallback schema", Path: "internal/cli/command_path_fallbacks.schema.json"},
}
target := outputguard.Target{Name: "--output", Path: outputPath}
if err := outputguard.Validate(rootPath, inputs, []outputguard.Target{target}); err != nil {
return err
}
return outputguard.ValidateRepoTargetAllowlist(rootPath, target, defaultOutput)
}
func renderCommandPathFallbacks(entries []cli.CommandPathFallback) ([]byte, error) {
var b bytes.Buffer
b.WriteString("// Copyright 2026 Alibaba Group\n")
b.WriteString("// Licensed under the Apache License, Version 2.0\n\n")
b.WriteString("// Code generated by cmd_command_path_fallbacks; DO NOT EDIT.\n")
b.WriteString("// Source: internal/cli/command_path_fallbacks.json validated against the live Cobra tree.\n")
b.WriteString("// Regenerate with `make generate-schema` (or `go generate ./internal/cli`).\n\n")
b.WriteString("package cli\n\n")
b.WriteString("var generatedCommandPathFallbacks = []CommandPathFallback{\n")
for _, entry := range entries {
b.WriteString("\t{\n")
fmt.Fprintf(&b, "\t\tFrom: %q,\n", entry.From)
fmt.Fprintf(&b, "\t\tMode: %q,\n", entry.Mode)
if entry.To != "" {
fmt.Fprintf(&b, "\t\tTo: %q,\n", entry.To)
}
if len(entry.Candidates) > 0 {
fmt.Fprintf(&b, "\t\tCandidates: %s,\n", renderStringSlice(entry.Candidates))
}
b.WriteString("\t\tReviewed: true,\n")
fmt.Fprintf(&b, "\t\tReviewReason: %q,\n", entry.ReviewReason)
b.WriteString("\t},\n")
}
b.WriteString("}\n\n")
b.WriteString("func loadGeneratedCommandPathFallbacks() []CommandPathFallback {\n")
b.WriteString("\treturn generatedCommandPathFallbacks\n")
b.WriteString("}\n")
formatted, err := formatCommandFallbackSource(b.Bytes())
if err != nil {
return nil, fmt.Errorf("format generated command path fallbacks: %w", err)
}
return formatted, nil
}
func renderStringSlice(values []string) string {
quoted := make([]string, len(values))
for index, value := range values {
quoted[index] = fmt.Sprintf("%q", value)
}
return "[]string{" + strings.Join(quoted, ", ") + "}"
}
func fail(err error) {
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
exitCommandFallbackProcess(1)
}
@@ -0,0 +1,184 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package main
import (
"context"
"errors"
"flag"
"go/format"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageCommandFallbackGeneratorUsesDistributionOwnedCommandTree(t *testing.T) {
got := reflect.ValueOf(newCommandFallbackRoot).Pointer()
want := reflect.ValueOf(app.NewSchemaSourceRootCommand).Pointer()
if got != want {
t.Fatal("command fallback generator must use the distribution-owned Schema source command tree")
}
}
func preserveCommandFallbackGeneratorGlobals(t *testing.T) {
t.Helper()
oldArgs, oldFlags := os.Args, flag.CommandLine
oldRoot := newCommandFallbackRoot
oldReduce := reduceCommandFallbackEntries
oldFormat := formatCommandFallbackSource
oldWrite := writeCommandFallbackFile
oldExit := exitCommandFallbackProcess
t.Cleanup(func() {
os.Args, flag.CommandLine = oldArgs, oldFlags
newCommandFallbackRoot = oldRoot
reduceCommandFallbackEntries = oldReduce
formatCommandFallbackSource = oldFormat
writeCommandFallbackFile = oldWrite
exitCommandFallbackProcess = oldExit
})
}
func commandFallbackRepositoryRoot(t *testing.T) string {
t.Helper()
root, err := filepath.Abs(filepath.Join("..", "..", ".."))
if err != nil {
t.Fatal(err)
}
return root
}
func TestCrossPlatformCoverageCommandFallbackMainWritesGeneratedFile(t *testing.T) {
preserveCommandFallbackGeneratorGlobals(t)
output := filepath.Join(t.TempDir(), "command_path_fallbacks_generated.go")
flag.CommandLine = flag.NewFlagSet("command-fallback-success", flag.ContinueOnError)
os.Args = []string{"cmd_command_path_fallbacks", "-root", commandFallbackRepositoryRoot(t), "-output", output}
newCommandFallbackRoot = func(...context.Context) *cobra.Command { return &cobra.Command{Use: "dws"} }
reduceCommandFallbackEntries = func(*cobra.Command) ([]cli.CommandPathFallback, error) {
return []cli.CommandPathFallback{{
From: "demo +bad",
Mode: cli.CommandPathFallbackRewrite,
To: "demo +good",
Reviewed: true,
ReviewReason: "fixture",
}}, nil
}
main()
data, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
text := string(data)
for _, want := range []string{
"func loadGeneratedCommandPathFallbacks() []CommandPathFallback",
`From: "demo +bad"`,
`To: "demo +good"`,
} {
if !strings.Contains(text, want) {
t.Fatalf("generated source missing %q:\n%s", want, text)
}
}
}
func TestCrossPlatformCoverageGenerateCommandPathFallbackFailurePaths(t *testing.T) {
preserveCommandFallbackGeneratorGlobals(t)
root := commandFallbackRepositoryRoot(t)
output := filepath.Join(t.TempDir(), "command_path_fallbacks_generated.go")
newCommandFallbackRoot = func(...context.Context) *cobra.Command { return &cobra.Command{Use: "dws"} }
if err := generateCommandPathFallbacks(root, filepath.Join(root, "internal", "cli", "command_path_fallbacks.json")); err == nil {
t.Fatal("generator accepted output overlapping reviewed input")
}
reduceCommandFallbackEntries = func(*cobra.Command) ([]cli.CommandPathFallback, error) {
return nil, errors.New("reduce")
}
if err := generateCommandPathFallbacks(root, output); err == nil || !strings.Contains(err.Error(), "reduce") {
t.Fatalf("reduction error = %v", err)
}
reduceCommandFallbackEntries = func(*cobra.Command) ([]cli.CommandPathFallback, error) { return nil, nil }
formatCommandFallbackSource = func([]byte) ([]byte, error) { return nil, errors.New("format") }
if err := generateCommandPathFallbacks(root, output); err == nil || !strings.Contains(err.Error(), "format generated") {
t.Fatalf("format error = %v", err)
}
formatCommandFallbackSource = format.Source
writeCommandFallbackFile = func(string, []byte, os.FileMode) error { return errors.New("write") }
if err := generateCommandPathFallbacks(root, output); err == nil || !strings.Contains(err.Error(), "write generated") {
t.Fatalf("write error = %v", err)
}
}
func TestCrossPlatformCoverageValidateCommandFallbackOutputIsolation(t *testing.T) {
root := commandFallbackRepositoryRoot(t)
if err := validateOutputIsolation(root, filepath.Join(t.TempDir(), "fallbacks.go")); err != nil {
t.Fatalf("temporary output rejected: %v", err)
}
if err := validateOutputIsolation(root, filepath.Join(root, "internal", "cli", "not_a_delivery_target.go")); err == nil ||
!strings.Contains(err.Error(), "not a canonical generated delivery target") {
t.Fatalf("non-canonical repository output error = %v", err)
}
}
func TestCrossPlatformCoverageRenderCommandPathFallbacksShape(t *testing.T) {
preserveCommandFallbackGeneratorGlobals(t)
entries := []cli.CommandPathFallback{
{
From: "demo +bad",
Mode: cli.CommandPathFallbackRewrite,
To: "demo +good",
Reviewed: true,
ReviewReason: "rewrite fixture",
},
{
From: "demo +choose",
Mode: cli.CommandPathFallbackAmbiguous,
Candidates: []string{"demo +one", "demo +two"},
Reviewed: true,
ReviewReason: "ambiguous fixture",
},
}
data, err := renderCommandPathFallbacks(entries)
if err != nil {
t.Fatal(err)
}
text := string(data)
for _, want := range []string{
`Mode: "rewrite"`,
`Candidates: []string{"demo +one", "demo +two"}`,
`ReviewReason: "ambiguous fixture"`,
} {
if !strings.Contains(text, want) {
t.Fatalf("render missing %q:\n%s", want, text)
}
}
if got := renderStringSlice(nil); got != "[]string{}" {
t.Fatalf("renderStringSlice(nil) = %q", got)
}
formatCommandFallbackSource = func([]byte) ([]byte, error) { return nil, errors.New("broken formatter") }
if _, err := renderCommandPathFallbacks(entries); err == nil || !strings.Contains(err.Error(), "broken formatter") {
t.Fatalf("formatter error = %v", err)
}
}
func TestCrossPlatformCoverageCommandFallbackMainReportsFailure(t *testing.T) {
preserveCommandFallbackGeneratorGlobals(t)
flag.CommandLine = flag.NewFlagSet("command-fallback-failure", flag.ContinueOnError)
os.Args = []string{
"cmd_command_path_fallbacks",
"-root", commandFallbackRepositoryRoot(t),
"-output", filepath.Join(commandFallbackRepositoryRoot(t), "internal", "cli", "command_path_fallbacks.json"),
}
exitCommandFallbackProcess = func(code int) { panic(code) }
defer func() {
if recovered := recover(); recovered != 1 {
t.Fatalf("main exit = %#v, want 1", recovered)
}
}()
main()
}
+174 -40
View File
@@ -1086,6 +1086,63 @@ func uploadConversationLocalFile(ctx context.Context, targetArgs map[string]any,
return callMCPToolReturnTextOnServer(ctx, "im", "commit_conversation_file_upload", commitArgs)
}
func parseConversationFileDownloadURL(text string) (string, error) {
var data map[string]any
if err := unmarshalJSONUseNumber(text, &data); err != nil {
return "", fmt.Errorf("failed to parse uploaded file response JSON: %w", err)
}
if result, ok := data["result"].(map[string]any); ok {
data = result
}
downloadURL := firstStringField(data, "downloadUrl")
if downloadURL == "" {
return "", fmt.Errorf("uploaded file response missing downloadUrl")
}
return downloadURL, nil
}
func uploadRobotMessageLocalFile(
cmd *cobra.Command,
filePath string,
groupID string,
userIDs []string,
openDingTalkIDs []string,
) (string, error) {
uploadTarget := map[string]any{}
if groupID != "" {
uploadTarget["openConversationId"] = groupID
} else {
if len(userIDs)+len(openDingTalkIDs) != 1 {
return "", fmt.Errorf("--file-path requires exactly one --users or --open-dingtalk-ids recipient")
}
if len(userIDs) == 1 {
uploadTarget["userId"] = userIDs[0]
} else {
uploadTarget["openDingTalkId"] = openDingTalkIDs[0]
}
}
meta, err := buildConversationLocalFileMeta(filePath, "", "")
if err != nil {
return "", err
}
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "上传本地文件并由机器人发送")
deps.Out.PrintKeyValue("文件", meta.LocalPath)
deps.Out.PrintKeyValue("名称", meta.FileName)
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", meta.FileSize))
return "", nil
}
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
defer cancel()
commitText, err := uploadConversationLocalFile(ctx, uploadTarget, meta, "")
if err != nil {
return "", err
}
return parseConversationFileDownloadURL(commitText)
}
func buildConversationFileContent(dentryID, spaceID int64, meta conversationLocalFileMeta) (string, error) {
content := struct {
DentryID int64 `json:"dentryId"`
@@ -2143,14 +2200,18 @@ func newChatCommand() *cobra.Command {
},
})
// send-by-bot: 群聊传 --group,单聊传 --users/--open-dingtalk-ids;--text 支持 Markdown
// send-by-bot: 群聊传 --group,单聊传 --users/--open-dingtalk-ids。
// Markdown 使用 --title/--text,图片使用 --msg-type image/--image-url,
// 文件使用 --msg-type file/--file-path,CLI 上传后发送。
chatMessageSendByBotCmd := &cobra.Command{
Use: "send-by-bot",
Short: "机器人发送消息(--group 群聊 / --users 单聊)",
Long: `群聊:传 --group 指定群;单聊:传 --users 或 --open-dingtalk-ids 指定用户列表,与 --group 只能选其一,不能同时指定。--text 支持 Markdown。
Long: `群聊:传 --group 指定群;单聊:传 --users 或 --open-dingtalk-ids 指定用户列表,与 --group 只能选其一,不能同时指定。省略 --msg-type 时发送 Markdown;图片使用 --msg-type image --image-url;本地文件使用 --msg-type file --file-path,CLI 完成上传后发送。
⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。`,
Example: ` dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --title "日报" --text "## 今日完成..."
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --msg-type image --image-url "https://example.com/image.png"
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --msg-type file --file-path ./report.pdf
dws chat message send-by-bot --robot-code <robot-code> --users userId1,userId2 --title "提醒" --text "请提交周报"
dws chat message send-by-bot --robot-code <robot-code> --open-dingtalk-ids openDingtalkId1,openDingtalkId2 --title "提醒" --text "请提交周报"
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --at-user-ids userId1,userId2 --title "提醒" --text "@userId1 @userId2 请查收本周报告"
@@ -2160,9 +2221,42 @@ func newChatCommand() *cobra.Command {
# 查询 userId: dws contact user search --query "姓名"
# robot-code: $DINGTALK_CHAT_ROBOT_CODE`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "robot-code", "title", "text"); err != nil {
msgType := strings.TrimSpace(mustGetFlag(cmd, "msg-type"))
title := mustGetFlag(cmd, "title")
text := mustGetFlag(cmd, "text")
imageURL := strings.TrimSpace(mustGetFlag(cmd, "image-url"))
filePath := mustGetFlag(cmd, "file-path")
if err := validateRequiredFlags(cmd, "robot-code"); err != nil {
return err
}
if msgType == "" {
if imageURL != "" {
return fmt.Errorf("--msg-type image is required when using --image-url")
}
if filePath != "" {
return fmt.Errorf("--msg-type file is required when using --file-path")
}
msgType = "markdown"
}
switch msgType {
case "markdown":
if err := validateRequiredFlags(cmd, "title", "text"); err != nil {
return err
}
case "image":
if imageURL == "" {
return fmt.Errorf("--image-url is required for --msg-type image")
}
case "file":
if filePath == "" {
return fmt.Errorf("--file-path is required for --msg-type file")
}
default:
return fmt.Errorf("unsupported --msg-type %q, must be one of: markdown, image, file", msgType)
}
isMarkdownMessage := msgType == "markdown"
chatID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat")
usersStr, _ := cmd.Flags().GetString("users")
openDingtalkIdsStr, _ := cmd.Flags().GetString("open-dingtalk-ids")
@@ -2173,6 +2267,38 @@ func newChatCommand() *cobra.Command {
if chatID == "" && !hasDirectTarget {
return fmt.Errorf("--group or --users/--open-dingtalk-ids is required")
}
userIDs := splitCommaList(usersStr)
openDingTalkIDs := splitCommaList(openDingtalkIdsStr)
fileURL := ""
if msgType == "file" {
var err error
fileURL, err = uploadRobotMessageLocalFile(
cmd, filePath, chatID, userIDs, openDingTalkIDs)
if err != nil {
return err
}
if deps.Caller.DryRun() {
return nil
}
}
buildRobotMessageArgs := func(markdown string) map[string]any {
toolArgs := map[string]any{
"robotCode": mustGetFlag(cmd, "robot-code"),
}
switch msgType {
case "image":
toolArgs["photoURL"] = imageURL
case "file":
toolArgs["fileUrl"] = fileURL
default:
toolArgs["title"] = title
toolArgs["markdown"] = markdown
}
return toolArgs
}
if chatID != "" {
var atUserIds []string
if atUserIdsStr, _ := cmd.Flags().GetString("at-user-ids"); atUserIdsStr != "" {
@@ -2190,17 +2316,23 @@ func newChatCommand() *cobra.Command {
}
}
}
// 机器人发消息要求 @ 占位符为裸 @id;模型若写成 <@id> 会导致 @ 不生效,主动剥离尖括号
markdown := mustGetFlag(cmd, "text")
markdown = normalizeAtPlaceholders(markdown, atUserIds, false)
markdown = normalizeAtPlaceholders(markdown, atOpenDingtalkIds, false)
markdown = strings.ReplaceAll(markdown, "<@all>", "@all")
toolArgs := map[string]any{
"robotCode": mustGetFlag(cmd, "robot-code"),
"openConversationId": chatID,
"title": mustGetFlag(cmd, "title"),
"markdown": markdown,
markdown := text
if isMarkdownMessage {
// 机器人发消息要求 @ 占位符为裸 @id;模型若写成 <@id> 会导致 @ 不生效,主动剥离尖括号
markdown = normalizeAtPlaceholders(markdown, atUserIds, false)
markdown = normalizeAtPlaceholders(markdown, atOpenDingtalkIds, false)
markdown = strings.ReplaceAll(markdown, "<@all>", "@all")
}
toolArgs := buildRobotMessageArgs(markdown)
switch msgType {
case "image":
toolArgs["msgKey"] = "sampleImageMsg"
case "file":
toolArgs["msgKey"] = "sampleDingtalkDriveFile"
default:
toolArgs["msgKey"] = "sampleMarkdownDX"
}
toolArgs["openConversationId"] = chatID
if len(atUserIds) > 0 {
toolArgs["atUserIds"] = atUserIds
}
@@ -2212,28 +2344,21 @@ func newChatCommand() *cobra.Command {
}
return callMCPToolOnServer("bot", "send_robot_group_message", toolArgs)
}
toolArgs := map[string]any{
"robotCode": mustGetFlag(cmd, "robot-code"),
"title": mustGetFlag(cmd, "title"),
"markdown": mustGetFlag(cmd, "text"),
toolArgs := buildRobotMessageArgs(text)
switch msgType {
case "image":
toolArgs["msgType"] = "sampleImageMsg"
case "file":
toolArgs["msgType"] = "sampleDingtalkDriveFile"
default:
toolArgs["msgType"] = "sampleMarkdownDX"
}
if usersStr != "" {
var userIds []string
for _, u := range strings.Split(usersStr, ",") {
if s := strings.TrimSpace(u); s != "" {
userIds = append(userIds, s)
}
}
toolArgs["userIds"] = userIds
if len(userIDs) > 0 {
toolArgs["userIds"] = userIDs
}
if openDingtalkIdsStr != "" {
var openDingtalkIds []string
for _, id := range strings.Split(openDingtalkIdsStr, ",") {
if s := strings.TrimSpace(id); s != "" {
openDingtalkIds = append(openDingtalkIds, s)
}
}
toolArgs["openDingtalkIds"] = openDingtalkIds
if len(openDingTalkIDs) > 0 {
toolArgs["openDingtalkIds"] = openDingTalkIDs
}
if isAtAll, _ := cmd.Flags().GetBool("at-all"); isAtAll {
toolArgs["isAtAll"] = "true"
@@ -2254,18 +2379,25 @@ func newChatCommand() *cobra.Command {
CLIPath: "chat message send-by-bot",
PrimaryCLIPath: "chat message send-by-bot",
},
Description: "以应用机器人身份发送群消息或批量单聊",
Description: "以应用机器人身份发送 Markdown、图片或文件群消息或批量单聊",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令包含多个 RPC、条件分派或本地 HTTP/文件步骤,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "以应用机器人身份发送群消息或批量单聊",
UseWhen: []string{"已有 robotCode 且需要机器人身份投递消息时"},
AgentSummary: "以应用机器人身份发送 Markdown、图片或文件群消息或批量单聊",
UseWhen: []string{"已有 robotCode 且需要机器人身份投递 Markdown、图片或文件时"},
AvoidWhen: []string{"个人身份发送或自定义 Webhook 告警不要使用"},
Examples: []string{"dws chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title \"日报\" --text \"今日进展\""},
},
// Keep title/text required_when out of Schema: the runtime switch above
// enforces Markdown inputs, while adding it breaks merge-base compatibility.
Parameters: []contract.ParamDecl{
{Name: "msg-type", RequiredWhen: "image-url or file-path is provided", Enum: []string{"markdown", "image", "file"}},
{Name: "image-url", RequiredWhen: "msg-type is image"},
{Name: "file-path", RequiredWhen: "msg-type is file"},
},
},
})
@@ -3570,14 +3702,16 @@ func newChatCommand() *cobra.Command {
_ = chatMessageSendByBotCmd.MarkFlagRequired("robot-code")
chatMessageSendByBotCmd.Flags().String("group", "", "群聊 openConversationId(群聊时必填)")
chatMessageSendByBotCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔,最多20个(单聊时必填)")
chatMessageSendByBotCmd.Flags().String("title", "", "消息标题 (必填)")
_ = chatMessageSendByBotCmd.MarkFlagRequired("title")
chatMessageSendByBotCmd.Flags().String("text", "", "消息内容 Markdown (必填)")
_ = chatMessageSendByBotCmd.MarkFlagRequired("text")
chatMessageSendByBotCmd.Flags().String("msg-type", "", "消息类型: markdown/image/file(省略时为 markdown;图片使用 image --image-url;本地文件使用 file --file-path)")
chatMessageSendByBotCmd.Flags().String("title", "", "Markdown 消息标题(发送 Markdown 时必填)")
chatMessageSendByBotCmd.Flags().String("text", "", "Markdown 消息内容(发送 Markdown 时必填;稳定换行用空行,转义形式写 \\n\\n,不要只写 \\n)")
chatMessageSendByBotCmd.Flags().String("image-url", "", "公网图片 URL(msgType=image 时必填)")
chatMessageSendByBotCmd.Flags().String("file-path", "", "本地文件路径(msgType=file 时直接上传并按 file 消息发送)")
chatMessageSendByBotCmd.Flags().String("at-user-ids", "", "@指定成员的 userId 列表,逗号分隔(仅群聊时生效,可选),--text 中需包含 @userId 对应文本")
chatMessageSendByBotCmd.Flags().String("open-dingtalk-ids", "", "用户 openDingtalkId 列表,逗号分隔(单聊时可替代 --users,可选)")
chatMessageSendByBotCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingtalkId 列表,逗号分隔(仅群聊时生效,可选)")
chatMessageSendByBotCmd.Flags().Bool("at-all", false, "@所有人(可选),服务端接收字符串 true/false")
cli.AnnotateRuntimeFlagFormat(chatMessageSendByBotCmd, "file-path", "file-path")
chatMessageRecallByBotCmd.Flags().String("robot-code", "", "机器人 Code (必填)")
_ = chatMessageRecallByBotCmd.MarkFlagRequired("robot-code")
+125
View File
@@ -370,6 +370,131 @@ func TestCrossPlatformCoverageUploadConversationLocalFileCoverage(t *testing.T)
}
}
func TestCrossPlatformCoverageChatBotRichMediaCoverage(t *testing.T) {
testseam.Protect(t, &deps)
testseam.Swap(t, &httpPutFile, func(context.Context, string, map[string]string, string, int64) error {
return nil
})
filePath := filepath.Join(t.TempDir(), "report.pdf")
if err := os.WriteFile(filePath, []byte("report"), 0o600); err != nil {
t.Fatal(err)
}
for _, response := range []string{`{`, `{"result":{}}`} {
if _, err := parseConversationFileDownloadURL(response); err == nil {
t.Fatalf("parseConversationFileDownloadURL(%q) returned nil", response)
}
}
for _, tc := range []struct {
name string
args []string
}{
{name: "markdown requires title", args: []string{"--text", "message"}},
{name: "markdown requires text", args: []string{"--title", "title"}},
{name: "image requires URL", args: []string{"--msg-type", "image"}},
{name: "file requires path", args: []string{"--msg-type", "file"}},
} {
t.Run(tc.name, func(t *testing.T) {
args := []string{"message", "send-by-bot", "--robot-code", "robot", "--group", "group"}
if err := runChatCoverageCommand(t, &scriptedToolCaller{}, append(args, tc.args...)...); err == nil {
t.Fatal("missing rich-media input returned nil")
}
})
}
t.Run("group and direct targets are mutually exclusive", func(t *testing.T) {
err := runChatCoverageCommand(t, &scriptedToolCaller{},
"message", "send-by-bot", "--robot-code", "robot",
"--group", "group", "--users", "user", "--title", "title", "--text", "message",
)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("mutually exclusive target error = %v", err)
}
})
t.Run("direct markdown defaults to DX message type", func(t *testing.T) {
caller := &scriptedToolCaller{}
if err := runChatCoverageCommand(t, caller,
"message", "send-by-bot", "--robot-code", "robot", "--users", "user",
"--title", "title", "--text", "message",
); err != nil {
t.Fatal(err)
}
if caller.tool != "batch_send_robot_msg_to_users" || caller.args["msgType"] != "sampleMarkdownDX" {
t.Fatalf("direct markdown call = %s %#v", caller.tool, caller.args)
}
})
t.Run("direct image", func(t *testing.T) {
caller := &scriptedToolCaller{}
if err := runChatCoverageCommand(t, caller,
"message", "send-by-bot", "--robot-code", "robot", "--users", "user",
"--msg-type", "image", "--image-url", "https://example.test/image.png", "--at-all",
); err != nil {
t.Fatal(err)
}
if caller.tool != "batch_send_robot_msg_to_users" || caller.args["msgType"] != "sampleImageMsg" || caller.args["isAtAll"] != "true" {
t.Fatalf("direct image call = %s %#v", caller.tool, caller.args)
}
})
for _, tc := range []struct {
name string
target []string
}{
{name: "user file", target: []string{"--users", "user"}},
{name: "open DingTalk ID file", target: []string{"--open-dingtalk-ids", "D-user"}},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://upload.example/file","uploadKey":"key"}`},
{text: `{"result":{"downloadUrl":"https://download.example/report.pdf"}}`},
{text: `{"success":true}`},
}}
args := []string{"message", "send-by-bot", "--robot-code", "robot", "--msg-type", "file", "--file-path", filePath}
if err := runChatCoverageCommand(t, caller, append(args, tc.target...)...); err != nil {
t.Fatal(err)
}
if caller.calls != 3 || caller.tool != "batch_send_robot_msg_to_users" || caller.args["msgType"] != "sampleDingtalkDriveFile" || caller.args["fileUrl"] != "https://download.example/report.pdf" {
t.Fatalf("direct file call = %d %s %#v", caller.calls, caller.tool, caller.args)
}
})
}
t.Run("file dry run", func(t *testing.T) {
caller := &scriptedToolCaller{dry: true}
if err := runChatCoverageCommand(t, caller,
"message", "send-by-bot", "--robot-code", "robot", "--group", "group",
"--msg-type", "file", "--file-path", filePath,
); err != nil || caller.calls != 0 {
t.Fatalf("dry run error = %v, calls = %d", err, caller.calls)
}
})
t.Run("file rejects multiple recipients", func(t *testing.T) {
err := runChatCoverageCommand(t, &scriptedToolCaller{},
"message", "send-by-bot", "--robot-code", "robot", "--users", "one,two",
"--msg-type", "file", "--file-path", filePath,
)
if err == nil || !strings.Contains(err.Error(), "requires exactly one") {
t.Fatalf("multiple-recipient error = %v", err)
}
})
t.Run("file upload failure", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("upload failed")}}}
err := runChatCoverageCommand(t, caller,
"message", "send-by-bot", "--robot-code", "robot", "--group", "group",
"--msg-type", "file", "--file-path", filePath,
)
if err == nil || !strings.Contains(err.Error(), "upload failed") {
t.Fatalf("upload error = %v", err)
}
})
}
func TestCrossPlatformCoverageGuardGroupOwnerRemovalCoverage(t *testing.T) {
oldArgs := os.Args
os.Args = []string{"dws", "chat"}
+3
View File
@@ -16,6 +16,7 @@ package helpers
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
@@ -70,6 +71,7 @@ func (devHandler) Command(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(root)
doc := &cobra.Command{
Use: "doc",
@@ -81,6 +83,7 @@ func (devHandler) Command(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(doc)
doc.AddCommand(newDevDocSearchCommand())
root.AddCommand(
+10
View File
@@ -22,6 +22,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
@@ -108,6 +109,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(root)
webapp := &cobra.Command{
Use: "webapp",
@@ -119,6 +121,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(webapp)
webapp.AddCommand(
newDevAppWebappGetCommand(runner),
newDevAppWebappConfigCommand(runner),
@@ -134,6 +137,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(permission)
permission.AddCommand(
newDevAppPermissionListCommand(runner),
newDevAppPermissionAddCommand(runner),
@@ -150,6 +154,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(credentials)
credentials.AddCommand(newDevAppCredentialsGetCommand(runner))
member := &cobra.Command{
@@ -162,6 +167,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(member)
member.AddCommand(
newDevAppMemberListCommand(runner),
newDevAppMemberAddCommand(runner),
@@ -178,6 +184,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(security)
security.AddCommand(newDevAppSecurityConfigCommand(runner))
robot := &cobra.Command{
@@ -190,6 +197,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(robot)
robot.AddCommand(
newDevAppRobotSubmitCommand(runner),
newDevAppRobotResultCommand(runner),
@@ -209,6 +217,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(version)
version.AddCommand(
newDevAppVersionCreateCommand(runner),
newDevAppVersionListCommand(runner),
@@ -228,6 +237,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(event)
event.AddCommand(
newDevAppEventListCommand(runner),
newDevAppEventSubscribeCommand(runner),
+4 -1
View File
@@ -423,6 +423,8 @@ CLI 层自动翻译为 MCP toolName + 参数名,无需记忆 MCP 参数名。
range fill / range copy-to / add-dimension / delete-dimension / move-dimension
group-dimension / ungroup-dimension
set-dropdown / delete-dropdown / csv-put / delete-float-image
其中 csv-put 与独立命令语义一致:CSV 字段值以 = 开头时按公式解析,
前加单引号(例如 "'=1+1")时写入以 = 开头的字面文本。
注意:batch-update 中 group-dimension 适合默认展开分组;需要 --group-state fold 时请使用独立
dws sheet group-dimension 命令。
@@ -437,7 +439,8 @@ dws sheet group-dimension 命令。
dws sheet batch-update --node NODE_ID --operations '[
{"toolName":"range clear","input":{"sheet-id":"Sheet1","range":"A1:B3","type":"content"}},
{"toolName":"range update","input":{"sheet-id":"Sheet1","range":"A1","values":[[{"type":"text","text":"hello"}]]}},
{"toolName":"merge-cells","input":{"sheet-id":"Sheet1","range":"A1:B1","merge-type":"mergeAll"}}
{"toolName":"merge-cells","input":{"sheet-id":"Sheet1","range":"A1:B1","merge-type":"mergeAll"}},
{"toolName":"csv-put","input":{"sheet-id":"Sheet1","start-cell":"C1","csv":"=1+1,\u0027=1+1"}}
]' --yes
# 宽松模式
+100
View File
@@ -0,0 +1,100 @@
package helpers
import (
"context"
"io"
"os"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type csvPutRecordingCaller struct {
tool string
args map[string]any
}
func (c *csvPutRecordingCaller) CallTool(_ context.Context, _ string, tool string, args map[string]any) (*edition.ToolResult, error) {
c.tool = tool
c.args = args
return &edition.ToolResult{}, nil
}
func (*csvPutRecordingCaller) Format() string { return "json" }
func (*csvPutRecordingCaller) DryRun() bool { return false }
func (*csvPutRecordingCaller) Fields() string { return "" }
func (*csvPutRecordingCaller) JQ() string { return "" }
func TestCSVPutFormulaContractAndPassThrough(t *testing.T) {
const formulaCSV = "=1+1,'=1+1"
root := &cobra.Command{Use: "sheet"}
root.AddCommand(newDataCmds()...)
csvPut := findCoverageSubcommand(t, root, "csv-put")
for _, want := range []string{"支持公式", "以 = 开头时默认按公式解析", "以 = 开头的字面文本", "'=1+1"} {
if !strings.Contains(csvPut.Short+"\n"+csvPut.Long+"\n"+csvPut.Example, want) {
t.Fatalf("csv-put help does not contain %q", want)
}
}
if strings.Contains(csvPut.Long, "不支持公式") || strings.Contains(csvPut.Long, "=开头当文本") {
t.Fatalf("csv-put help still advertises the old formula contract: %s", csvPut.Long)
}
if strings.Contains(csvPut.Long, "写入公式文本") {
t.Fatalf("csv-put help incorrectly describes apostrophe escaping as formula text: %s", csvPut.Long)
}
caller := &csvPutRecordingCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
testseam.Swap(t, &os.Args, []string{"dws", "sheet", "csv-put"})
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{
"csv-put",
"--node", "node",
"--sheet-id", "Sheet1",
"--start-cell", "A1",
"--csv", formulaCSV,
})
if err := root.Execute(); err != nil {
t.Fatalf("execute csv-put: %v", err)
}
if caller.tool != "set_range_from_csv" {
t.Fatalf("tool = %q, want set_range_from_csv", caller.tool)
}
if got := caller.args["csv"]; got != formulaCSV {
t.Fatalf("csv argument = %#v, want exact pass-through %q", got, formulaCSV)
}
if _, ok := caller.args["interpretFormulas"]; ok {
t.Fatalf("csv-put unexpectedly added an interpretFormulas argument: %#v", caller.args)
}
batchArgs := BuildCsvPutArgs(map[string]any{
"sheet-id": "Sheet1",
"start-cell": "A1",
"csv": formulaCSV,
})
if got := batchArgs["csv"]; got != formulaCSV {
t.Fatalf("batch csv argument = %#v, want exact pass-through %q", got, formulaCSV)
}
if _, ok := batchArgs["interpretFormulas"]; ok {
t.Fatalf("batch csv-put unexpectedly added an interpretFormulas argument: %#v", batchArgs)
}
batchHelp := newBatchUpdateCmd().Long
for _, want := range []string{"csv-put", "以 = 开头时按公式解析", "以 = 开头的字面文本", "'=1+1"} {
if !strings.Contains(batchHelp, want) {
t.Fatalf("batch-update help does not contain %q", want)
}
}
if strings.Contains(batchHelp, "写入公式文本") {
t.Fatalf("batch-update help incorrectly describes apostrophe escaping as formula text: %s", batchHelp)
}
}
+12 -8
View File
@@ -281,16 +281,17 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
csvPutCmd := &cobra.Command{
Use: "csv-put",
Short: "将 CSV 数据写入表格指定位置(纯值,自动扩容)",
Short: "将 CSV 数据写入表格指定位置(支持公式,自动扩容)",
Long: `将 RFC 4180 格式的 CSV 文本写入指定工作表的指定单元格位置。
只写纯值,不支持公式/样式/批注。=开头当文本处理。自动扩容行列。
写入值和公式,不支持样式/批注。字段值以 = 开头时默认按公式解析;
如需写入以 = 开头的字面文本,在字段值前加单引号(例如 "'=1+1")。自动扩容行列。
目标区域如含合并单元格,csv-put 会打散合并并覆盖所有单元格;这不同于
range update 与合并区域冲突时返回 MERGED_CELLS_CONFLICT 的行为。
--csv 支持三种输入:直接传文本、@filepath 从文件读取、- 从 stdin 读取。
--allow-overwrite 默认 false,目标区域有数据时需显式传 --allow-overwrite 才能覆盖。`,
Example: ` dws sheet csv-put --node NODE_ID --sheet-id SHEET_ID --start-cell A1 \
--csv 'name,score\nalice,95\nbob,87'
--csv "=1+1,'=1+1"
dws sheet csv-put --node NODE_ID --sheet-id SHEET_ID --start-cell B2 \
--csv @data.csv --allow-overwrite
@@ -347,17 +348,20 @@ range update 与合并区域冲突时返回 MERGED_CELLS_CONFLICT 的行为。
CLIPath: "sheet csv-put",
PrimaryCLIPath: "sheet csv-put",
},
Description: "将 CSV 纯值写入起始单元格(可自动扩容;大批量纯值首选)。",
Description: "将 CSV 值或公式写入起始单元格(= 开头按公式;可自动扩容)。",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "set_range_from_csv"},
},
Selection: contract.SelectionSpec{
AgentSummary: "将 CSV 纯值写入起始单元格(可自动扩容;大批量纯值首选)。",
UseWhen: []string{"写入纯值且超过约 5 行/20 格,或数据来自 CSV/表格文本时优先使用"},
AvoidWhen: []string{"需要公式/超链接/富文本用 range update;在末尾追加数据行用 append;覆盖已有数据需 --allow-overwrite"},
Examples: []string{"dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell A1 --csv @data.csv --allow-overwrite"},
AgentSummary: "将 CSV 值或公式写入起始单元格(= 开头按公式;可自动扩容)。",
UseWhen: []string{"需要从 CSV/表格文本批量写入值或以 = 开头的公式时优先使用"},
AvoidWhen: []string{"需要超链接/富文本/样式/数据验证用 range update;在末尾追加数据行用 append;覆盖已有数据需 --allow-overwrite;要把 = 开头内容写成字面文本需前加单引号"},
Examples: []string{
"dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell A1 --csv @data.csv --allow-overwrite",
"dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell A1 --csv \"=1+1,'=1+1\"",
},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId"},
+5 -3
View File
@@ -12,6 +12,8 @@ import (
"github.com/spf13/cobra"
)
const sheetFormulaVerifyRemoteTool = "verify_formula"
func newSheetFormulaVerifyCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "formula-verify",
@@ -95,9 +97,9 @@ func newSheetFormulaVerifyCmd() *cobra.Command {
// 返回 payload 判定是否存在公式错误,故走 ReturnText 再自行 PrintJSON。
func callMCPToolFormulaVerify(toolArgs map[string]any, exitOnError bool) error {
if deps.Caller.DryRun() {
return callMCPToolOnServer("sheet", "formula_verify", toolArgs)
return callMCPToolOnServer("sheet", sheetFormulaVerifyRemoteTool, toolArgs)
}
text, err := callMCPToolReturnTextOnServer(context.Background(), "sheet", "formula_verify", toolArgs)
text, err := callMCPToolReturnTextOnServer(context.Background(), "sheet", sheetFormulaVerifyRemoteTool, toolArgs)
if err != nil {
return err
}
@@ -110,7 +112,7 @@ func callMCPToolFormulaVerify(toolArgs map[string]any, exitOnError bool) error {
return nil
}
if parsed == nil {
return fmt.Errorf("formula_verify returned empty result")
return fmt.Errorf("%s returned empty result", sheetFormulaVerifyRemoteTool)
}
if err := deps.Out.PrintJSON(parsed); err != nil {
return err
@@ -42,6 +42,12 @@ func TestCrossPlatformCoverageSheetFormulaVerifyLimitsAndInlineTargets(t *testin
if caller.calls != 1 {
t.Fatalf("calls = %d, want 1", caller.calls)
}
if caller.server != "sheet" {
t.Fatalf("server = %q, want sheet", caller.server)
}
if caller.tool != "verify_formula" {
t.Fatalf("tool = %q, want verify_formula", caller.tool)
}
}
func TestCrossPlatformCoverageSheetFormulaVerifyTargetsFromFile(t *testing.T) {
+5 -5
View File
@@ -168,7 +168,7 @@ dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 merg
- 写图片到单元格建议直接用 dws sheet write-image(更简洁)
- 只设样式或批量刷整片区域样式请用 dws sheet range set-style;写值同时设置少量 cell 样式可用 cellStyles
- 目标范围与已有合并区域冲突时,range update 会返回 MERGED_CELLS_CONFLICT;先用 sheet info 查看 mergedRanges,取消合并后写入,必要时再重新合并
- csv-put 的合并处理不同:目标区域含合并单元格时会打散合并并写入纯值
- csv-put 的合并处理不同:目标区域含合并单元格时会打散合并并写入 CSV 值或公式
- 清空整片区域请用 dws sheet range clear`,
Example: ` # 写入文本
dws sheet range update --node NODE_ID --sheet-id SHEET_ID --range "A1:B2" \
@@ -252,16 +252,16 @@ dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 merg
CLIPath: "sheet range update",
PrimaryCLIPath: "sheet range update",
},
Description: "更新指定区域单元格(值/公式/超链接等 object 协议);少量或含公式时使用。",
Description: "更新指定区域单元格(值/公式/超链接等 object 协议);少量或需富格式时使用。",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "update_range"},
},
Selection: contract.SelectionSpec{
AgentSummary: "更新指定区域单元格(值/公式/超链接等 object 协议);少量或含公式时使用。",
UseWhen: []string{"需要写入少量单元格、公式或超链接,且能提供 sheetId 与精确 range 时"},
AvoidWhen: []string{"大批量纯值写入优先 sheet csv-put;末尾追加行用 append;全局替换文本用 replace;只改样式用 range set-style;清整片区域用 range clear"},
AgentSummary: "更新指定区域单元格(值/公式/超链接等 object 协议);少量或需富格式时使用。",
UseWhen: []string{"需要写入少量单元格、公式对象、超链接或富格式,且能提供 sheetId 与精确 range 时"},
AvoidWhen: []string{"CSV 形式的大批量值或公式写入优先 sheet csv-put;末尾追加行用 append;全局替换文本用 replace;只改样式用 range set-style;清整片区域用 range clear"},
Examples: []string{"dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range \"A1\" --values '[[{\"type\":\"text\",\"text\":\"张三\"}]]'"},
},
Parameters: []contract.ParamDecl{
+61 -23
View File
@@ -32,9 +32,10 @@ import (
// SetArgs so that Cobra's subsequent ExecuteC uses the corrected
// values.
//
// If the target command cannot be resolved (e.g. the user typed a
// non-existent command), PreParse is skipped silently and Cobra will
// handle the error.
// Explicit +shortcut tokens and annotated group subcommands are validated
// before flag parsing so an unknown command cannot be misreported as an
// unknown flag on its nearest parent. Other unresolved paths remain Cobra's
// responsibility.
func RunPreParse(root *cobra.Command, engine *Engine) error {
_, err := RunPreParseArgs(root, engine, os.Args[1:])
return err
@@ -45,13 +46,17 @@ func RunPreParse(root *cobra.Command, engine *Engine) error {
// exact same command traversal, FlagInfo extraction, handler chain, and
// root.SetArgs delivery path.
func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Context, error) {
if engine == nil || !engine.HasHandlers(PreParse) {
return nil, nil
}
if len(rawArgs) == 0 {
return nil, nil
}
normalizedArgs, commandCorrection, fallbackErr := normalizeCommandPathFallback(root, engine, rawArgs)
if fallbackErr != nil {
ctx := &Context{Args: append([]string{}, rawArgs...)}
if presentationErr := primeEarlyErrorPresentation(root, root, rawArgs); presentationErr != nil {
slog.Debug("pipeline command-fallback presentation flags", "error", presentationErr)
}
return ctx, fallbackErr
}
// Cobra's Traverse does not merge root persistent flags before deciding
// whether a leading flag consumes the next token. For example, it can treat
@@ -59,37 +64,52 @@ func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Co
// the unrelated root command `event list`. Remove only known root-persistent
// flags from the traversal copy; the original argv remains intact for the
// handlers and Cobra's real parse.
target, _, err := root.Traverse(argsForCommandTraversal(root, rawArgs))
target, remaining, err := root.Traverse(argsForCommandTraversal(root, normalizedArgs))
if err != nil {
return nil, nil
}
if err := validateUnresolvedCommand(target, remaining); err != nil {
ctx := &Context{
Args: append([]string{}, normalizedArgs...),
Command: target.CommandPath(),
}
if presentationErr := primeEarlyErrorPresentation(root, target, ctx.Args); presentationErr != nil {
slog.Debug("pipeline command-resolution presentation flags", "error", presentationErr)
}
return ctx, err
}
// Build FlagInfo from the target command's registered flags.
flagInfos := FlagInfoFromCommand(target)
if len(flagInfos) == 0 {
if (engine == nil || !engine.HasHandlers(PreParse) || len(flagInfos) == 0) && commandCorrection == nil {
return nil, nil
}
ctx := &Context{
Args: append([]string{}, rawArgs...),
Args: append([]string{}, normalizedArgs...),
// target.CommandPath() still carries the "dws" prefix; PreParse
// handlers that key off a command (e.g. the semantic-alias table)
// normalize it themselves, so the runtime key matches the build key.
Command: target.CommandPath(),
FlagSpecs: flagInfos,
}
if commandCorrection != nil {
ctx.Corrections = append(ctx.Corrections, *commandCorrection)
}
if err := engine.RunPhase(PreParse, ctx); err != nil {
// Cobra has not parsed persistent flags yet, but this error is rendered
// immediately by app.Execute. Prime only the presentation controls so
// --format/--debug/--verbose affect this early error exactly as they do
// errors returned after Cobra parsing. No credentials, profiles, output
// paths, or execution controls are applied here.
if presentationErr := primeEarlyErrorPresentation(root, target, ctx.Args); presentationErr != nil {
slog.Debug("pipeline pre-parse presentation flags", "error", presentationErr)
if engine != nil && engine.HasHandlers(PreParse) && len(flagInfos) > 0 {
if err := engine.RunPhase(PreParse, ctx); err != nil {
// Cobra has not parsed persistent flags yet, but this error is rendered
// immediately by app.Execute. Prime only the presentation controls so
// --format/--debug/--verbose affect this early error exactly as they do
// errors returned after Cobra parsing. No credentials, profiles, output
// paths, or execution controls are applied here.
if presentationErr := primeEarlyErrorPresentation(root, target, ctx.Args); presentationErr != nil {
slog.Debug("pipeline pre-parse presentation flags", "error", presentationErr)
}
slog.Debug("pipeline pre-parse", "error", err)
return ctx, err
}
slog.Debug("pipeline pre-parse", "error", err)
return ctx, err
}
// Only set corrected args if PreParse actually changed something.
@@ -109,25 +129,43 @@ func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Co
}
func argsForCommandTraversal(root *cobra.Command, rawArgs []string) []string {
args, _ := argsForCommandTraversalWithPositions(root, rawArgs)
return args
}
func argsForCommandTraversalWithPositions(root *cobra.Command, rawArgs []string) ([]string, []int) {
if root == nil || len(rawArgs) == 0 {
return rawArgs
positions := make([]int, len(rawArgs))
for index := range positions {
positions[index] = index
}
return rawArgs, positions
}
flags := root.PersistentFlags()
if flags == nil || !flags.HasFlags() {
return rawArgs
positions := make([]int, len(rawArgs))
for index := range positions {
positions[index] = index
}
return rawArgs, positions
}
matcher := newFlagTokenMatcher(flags)
out := make([]string, 0, len(rawArgs))
positions := make([]int, 0, len(rawArgs))
for index := 0; index < len(rawArgs); index++ {
argument := rawArgs[index]
if argument == "--" {
out = append(out, rawArgs[index:]...)
for position := index; position < len(rawArgs); position++ {
positions = append(positions, position)
}
break
}
flag, inlineValue, matched := matcher.matchTraversalToken(argument)
if !matched {
out = append(out, argument)
positions = append(positions, index)
continue
}
if index+1 < len(rawArgs) {
@@ -140,7 +178,7 @@ func argsForCommandTraversal(root *cobra.Command, rawArgs []string) []string {
index++
}
}
return out
return out, positions
}
// separatedBoolValue recognises model-friendly `--flag false` and exact
+158
View File
@@ -0,0 +1,158 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package pipeline
import (
"fmt"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
const (
commandPathFallbackModeRewrite = "rewrite"
commandPathFallbackModeAmbiguous = "ambiguous"
)
// normalizeCommandPathFallback applies only an exact reviewed path match. It
// runs before Cobra resolves a leaf, then the canonical argv continues through
// the ordinary parameter alias, validation, safety, and execution pipeline.
func normalizeCommandPathFallback(root *cobra.Command, engine *Engine, rawArgs []string) ([]string, *Correction, error) {
if root == nil || engine == nil || engine.commandPathFallbackLookup == nil || len(rawArgs) == 0 {
return rawArgs, nil, nil
}
traversalArgs, positions := argsForCommandTraversalWithPositions(root, rawArgs)
entry, tokenCount, ok := longestCommandPathFallback(engine, traversalArgs)
if !ok {
return rawArgs, nil, nil
}
// Runtime extensions are mounted on the root before PreParse runs. An exact
// executable command (or exact Cobra alias) is user/runtime authority and
// must win over the reviewed recovery dictionary. Hint-only compatibility
// nodes are not executable business commands, so fallback may supersede
// them as it does for the distribution-owned declaration tree.
if exactRunnableCommandPath(root, traversalArgs[:tokenCount]) {
return rawArgs, nil, nil
}
switch entry.Mode {
case commandPathFallbackModeRewrite:
if strings.TrimSpace(entry.To) == "" {
return rawArgs, nil, apperrors.NewInternal(fmt.Sprintf("reviewed command path fallback %q has no rewrite target", entry.From))
}
rewritten := rewriteCommandPathTokens(rawArgs, positions[:tokenCount], strings.Fields(entry.To))
correction := &Correction{
Handler: "command-path-fallback",
Phase: PreParse,
Field: "command_path",
Original: entry.From,
Corrected: entry.To,
Kind: "reviewed-fallback",
}
return rewritten, correction, nil
case commandPathFallbackModeAmbiguous:
return rawArgs, nil, ambiguousCommandPathFallbackError(root, entry)
default:
return rawArgs, nil, apperrors.NewInternal(fmt.Sprintf("reviewed command path fallback %q has invalid mode %q", entry.From, entry.Mode))
}
}
func exactRunnableCommandPath(root *cobra.Command, path []string) bool {
if root == nil || len(path) == 0 {
return false
}
current := root
if path[0] == root.Name() {
path = path[1:]
}
if len(path) == 0 {
return false
}
for _, token := range path {
var next *cobra.Command
for _, child := range current.Commands() {
if child.Name() == token || containsExactCommandAlias(child.Aliases, token) {
next = child
break
}
}
if next == nil {
return false
}
current = next
}
return current.Runnable() && !cmdutil.IsHintOnlyCommand(current)
}
func containsExactCommandAlias(aliases []string, token string) bool {
for _, alias := range aliases {
if alias == token {
return true
}
}
return false
}
func longestCommandPathFallback(engine *Engine, traversalArgs []string) (CommandPathFallback, int, bool) {
var selected CommandPathFallback
selectedCount := 0
parts := make([]string, 0, len(traversalArgs))
for index, argument := range traversalArgs {
argument = strings.TrimSpace(argument)
if argument == "" || argument == "--" || strings.HasPrefix(argument, "-") {
break
}
parts = append(parts, argument)
if candidate, ok := engine.lookupCommandPathFallback(strings.Join(parts, " ")); ok {
selected = candidate
selectedCount = index + 1
}
}
return selected, selectedCount, selectedCount > 0
}
func rewriteCommandPathTokens(rawArgs []string, commandPositions []int, canonicalTokens []string) []string {
if len(commandPositions) == 0 || len(canonicalTokens) == 0 {
return append([]string(nil), rawArgs...)
}
remove := make(map[int]bool, len(commandPositions))
for _, position := range commandPositions {
remove[position] = true
}
insertAt := commandPositions[0]
out := make([]string, 0, len(rawArgs)-len(commandPositions)+len(canonicalTokens))
for index, argument := range rawArgs {
if index == insertAt {
out = append(out, canonicalTokens...)
}
if remove[index] {
continue
}
out = append(out, argument)
}
return out
}
func ambiguousCommandPathFallbackError(root *cobra.Command, entry CommandPathFallback) error {
rootName := "dws"
if root != nil && strings.TrimSpace(root.Name()) != "" {
rootName = root.Name()
}
paths := make([]string, 0, len(entry.Candidates))
actions := make([]string, 0, len(entry.Candidates))
for _, candidate := range entry.Candidates {
path := strings.TrimSpace(rootName + " " + candidate)
paths = append(paths, fmt.Sprintf("%q", path))
actions = append(actions, fmt.Sprintf("Run '%s --help' to inspect this candidate", path))
}
hint := "Inspect the reviewed candidates and choose by the original user intent: " + strings.Join(paths, ", ")
return apperrors.NewValidation(
fmt.Sprintf("command path %q is ambiguous", strings.TrimSpace(rootName+" "+entry.From)),
apperrors.WithReason("ambiguous_command_fallback"),
apperrors.WithHint(hint),
apperrors.WithActions(actions...),
)
}
+351
View File
@@ -0,0 +1,351 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package pipeline
import (
"errors"
"reflect"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageRunPreParseArgsRewritesReviewedCommandBeforeFlags(t *testing.T) {
root, executed := commandFallbackPipelineRoot()
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
"chat +bad": {From: "chat +bad", Mode: "rewrite", To: "chat +good"},
})
raw := []string{"--format", "json", "chat", "+bad", "--query", "project"}
ctx, err := RunPreParseArgs(root, engine, raw)
if err != nil {
t.Fatal(err)
}
wantArgs := []string{"--format", "json", "chat", "+good", "--query", "project"}
if ctx == nil || !reflect.DeepEqual(ctx.Args, wantArgs) || ctx.Command != "dws chat +good" {
t.Fatalf("context = %#v, want args=%v command=dws chat +good", ctx, wantArgs)
}
if len(ctx.Corrections) != 1 || ctx.Corrections[0].Handler != "command-path-fallback" ||
ctx.Corrections[0].Original != "chat +bad" || ctx.Corrections[0].Corrected != "chat +good" {
t.Fatalf("corrections = %#v", ctx.Corrections)
}
if _, err := root.ExecuteC(); err != nil {
t.Fatal(err)
}
if *executed != "dws chat +good:project" {
t.Fatalf("executed = %q", *executed)
}
}
func TestCrossPlatformCoverageRunPreParseArgsRewritesMultiTokenPathAroundPersistentFlags(t *testing.T) {
root, executed := commandFallbackPipelineRoot()
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
"chat group search": {From: "chat group search", Mode: "rewrite", To: "chat search"},
})
raw := []string{"chat", "--format", "json", "group", "search", "--query", "project"}
ctx, err := RunPreParseArgs(root, engine, raw)
if err != nil {
t.Fatal(err)
}
wantArgs := []string{"chat", "search", "--format", "json", "--query", "project"}
if ctx == nil || !reflect.DeepEqual(ctx.Args, wantArgs) {
t.Fatalf("rewritten args = %#v, want %v", ctx, wantArgs)
}
if _, err := root.ExecuteC(); err != nil {
t.Fatal(err)
}
if *executed != "dws chat search:project" {
t.Fatalf("executed = %q", *executed)
}
}
func TestCrossPlatformCoverageCommandPathFallbackDefersToExactRunnableCommandAndAlias(t *testing.T) {
if exactRunnableCommandPath(nil, []string{"dws"}) {
t.Fatal("nil root unexpectedly resolved an executable path")
}
emptyRoot := &cobra.Command{Use: "dws"}
if exactRunnableCommandPath(emptyRoot, []string{"dws"}) {
t.Fatal("root-only path unexpectedly resolved as an executable leaf")
}
for _, test := range []struct {
name string
path string
}{
{name: "primary", path: "+members"},
{name: "alias", path: "+runtime-members"},
} {
t.Run(test.name, func(t *testing.T) {
executed := ""
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
chat := &cobra.Command{Use: "chat"}
members := &cobra.Command{
Use: "+members",
Aliases: []string{"+runtime-members"},
RunE: func(cmd *cobra.Command, _ []string) error {
sentinel, _ := cmd.Flags().GetString("sentinel")
executed = cmd.CommandPath() + ":" + sentinel
return nil
},
}
members.Flags().String("sentinel", "", "")
chat.AddCommand(members)
root.AddCommand(chat)
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
"chat " + test.path: {From: "chat " + test.path, Mode: "rewrite", To: "chat +good"},
})
args := []string{"chat", test.path, "--sentinel", "fixture"}
root.SetArgs(args)
ctx, err := RunPreParseArgs(root, engine, args)
if err != nil {
t.Fatal(err)
}
if ctx != nil {
t.Fatalf("runtime command unexpectedly entered correction pipeline: %#v", ctx)
}
if _, err := root.ExecuteC(); err != nil {
t.Fatal(err)
}
if executed != "dws chat +members:fixture" {
t.Fatalf("runtime command execution = %q", executed)
}
})
}
}
func TestCrossPlatformCoverageCommandPathFallbackMaySupersedeHintOnlyCommand(t *testing.T) {
root, executed := commandFallbackPipelineRoot()
chat, _, err := root.Find([]string{"chat"})
if err != nil {
t.Fatal(err)
}
chat.AddCommand(cmdutil.HintSubCmd("+bad", "use the reviewed recovery"))
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
"chat +bad": {From: "chat +bad", Mode: "rewrite", To: "chat +good"},
})
args := []string{"chat", "+bad", "--query", "project"}
root.SetArgs(args)
ctx, err := RunPreParseArgs(root, engine, args)
if err != nil {
t.Fatal(err)
}
if ctx == nil || ctx.Command != "dws chat +good" || len(ctx.Corrections) != 1 {
t.Fatalf("hint-only fallback context = %#v", ctx)
}
if _, err := root.ExecuteC(); err != nil {
t.Fatal(err)
}
if *executed != "dws chat +good:project" {
t.Fatalf("hint-only fallback execution = %q", *executed)
}
}
func TestCrossPlatformCoverageRunPreParseArgsRejectsAmbiguousCommandFallbackWithoutDispatch(t *testing.T) {
root, executed := commandFallbackPipelineRoot()
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
"chat +choose": {
From: "chat +choose",
Mode: "ambiguous",
Candidates: []string{"chat +good", "chat +other"},
},
})
ctx, err := RunPreParseArgs(root, engine, []string{"--format", "json", "chat", "+choose", "--query", "project"})
if ctx == nil {
t.Fatal("ambiguous fallback returned nil context")
}
structured := requireCommandResolutionError(t, err, "ambiguous_command_fallback")
if len(structured.Actions) != 2 || !strings.Contains(structured.Hint, "dws chat +good") || !strings.Contains(structured.Hint, "dws chat +other") {
t.Fatalf("ambiguous recovery = %#v", structured)
}
if *executed != "" {
t.Fatalf("ambiguous fallback dispatched %q", *executed)
}
if format, getErr := root.PersistentFlags().GetString("format"); getErr != nil || format != "json" {
t.Fatalf("format = %q, %v; want primed json", format, getErr)
}
}
func TestCrossPlatformCoverageCommandPathFallbackLeavesUnknownAndCanonicalFlagErrorsDistinct(t *testing.T) {
root, _ := commandFallbackPipelineRoot()
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
"chat +bad": {From: "chat +bad", Mode: "rewrite", To: "chat +good"},
})
_, err := RunPreParseArgs(root, engine, []string{"chat", "+missing", "--query", "project"})
requireCommandResolutionError(t, err, "unknown_shortcut")
validRoot, _ := commandFallbackPipelineRoot()
validRaw := []string{"chat", "+good", "--not-a-flag", "value"}
if ctx, err := RunPreParseArgs(validRoot, engine, validRaw); ctx != nil || err != nil {
t.Fatalf("valid command preparse = %#v, %v", ctx, err)
}
validRoot.SetArgs(validRaw)
_, executeErr := validRoot.ExecuteC()
if executeErr == nil || !strings.Contains(executeErr.Error(), "unknown flag") {
t.Fatalf("canonical bad flag error = %v", executeErr)
}
}
func TestCrossPlatformCoverageCommandPathFallbackCannotBypassCanonicalConfirmation(t *testing.T) {
for _, test := range []struct {
name string
args []string
wantDispatch int
wantErr bool
}{
{name: "confirmation missing", args: []string{"chat", "+danger"}, wantErr: true},
{name: "confirmed", args: []string{"chat", "+danger", "--yes"}, wantDispatch: 1},
} {
t.Run(test.name, func(t *testing.T) {
dispatches := 0
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
chat := &cobra.Command{Use: "chat"}
write := &cobra.Command{
Use: "+write",
RunE: func(cmd *cobra.Command, _ []string) error {
confirmed, _ := cmd.Flags().GetBool("yes")
if !confirmed {
return apperrors.NewValidation("confirmation required", apperrors.WithReason("confirmation_required"))
}
dispatches++
return nil
},
}
write.Flags().Bool("yes", false, "")
chat.AddCommand(write)
root.AddCommand(chat)
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
"chat +danger": {From: "chat +danger", Mode: "rewrite", To: "chat +write"},
})
if _, err := RunPreParseArgs(root, engine, test.args); err != nil {
t.Fatal(err)
}
_, err := root.ExecuteC()
if (err != nil) != test.wantErr || dispatches != test.wantDispatch {
t.Fatalf("execute error=%v dispatches=%d, want error=%v dispatches=%d", err, dispatches, test.wantErr, test.wantDispatch)
}
})
}
}
func TestCrossPlatformCoverageCommandPathFallbackRejectsInvalidGeneratedMode(t *testing.T) {
root, _ := commandFallbackPipelineRoot()
for _, entry := range []CommandPathFallback{
{From: "chat +bad", Mode: "rewrite"},
{From: "chat +bad", Mode: "invalid", To: "chat +good"},
} {
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{"chat +bad": entry})
_, err := RunPreParseArgs(root, engine, []string{"chat", "+bad"})
var structured *apperrors.Error
if !errors.As(err, &structured) || structured.Category != apperrors.CategoryInternal {
t.Fatalf("invalid entry error = %T %v", err, err)
}
}
}
func TestCrossPlatformCoverageRunPreParseArgsKeepsCommandErrorWhenPresentationPrimingFails(t *testing.T) {
fallbackRoot, _ := commandFallbackPipelineRoot()
fallbackEngine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
"chat +choose": {
From: "chat +choose",
Mode: "ambiguous",
Candidates: []string{"chat +good", "chat +other"},
},
})
_, err := RunPreParseArgs(fallbackRoot, fallbackEngine, []string{"chat", "+choose", "--format"})
requireCommandResolutionError(t, err, "ambiguous_command_fallback")
resolutionRoot, _ := commandFallbackPipelineRoot()
_, err = RunPreParseArgs(resolutionRoot, nil, []string{"chat", "+missing", "--format"})
requireCommandResolutionError(t, err, "unknown_shortcut")
handlerRoot, _ := commandFallbackPipelineRoot()
handlerEngine := NewEngine()
handlerEngine.Register(newStub("fail-before-presentation", PreParse, func(*Context) error {
return errors.New("handler failure")
}))
ctx, err := RunPreParseArgs(handlerRoot, handlerEngine, []string{"chat", "+good", "--query", "project", "--format"})
if ctx == nil || err == nil || !strings.Contains(err.Error(), "handler failure") {
t.Fatalf("handler error after presentation failure = context %#v, error %v", ctx, err)
}
}
func TestCrossPlatformCoverageCommandFallbackTraversalRemainingBranches(t *testing.T) {
args, positions := argsForCommandTraversalWithPositions(nil, []string{"chat", "+good"})
if !reflect.DeepEqual(args, []string{"chat", "+good"}) || !reflect.DeepEqual(positions, []int{0, 1}) {
t.Fatalf("nil-root traversal = args %v, positions %v", args, positions)
}
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().String("format", "table", "")
raw := []string{"chat", "+good", "--", "--format", "json"}
args, positions = argsForCommandTraversalWithPositions(root, raw)
if !reflect.DeepEqual(args, raw) || !reflect.DeepEqual(positions, []int{0, 1, 2, 3, 4}) {
t.Fatalf("terminator traversal = args %v, positions %v", args, positions)
}
}
func TestCrossPlatformCoverageRewriteCommandPathTokensLeavesArgsWhenRewriteIsEmpty(t *testing.T) {
raw := []string{"chat", "+bad", "--query", "project"}
for name, test := range map[string]struct {
positions []int
tokens []string
}{
"no command positions": {tokens: []string{"chat", "+good"}},
"no canonical tokens": {positions: []int{0, 1}},
} {
t.Run(name, func(t *testing.T) {
got := rewriteCommandPathTokens(raw, test.positions, test.tokens)
if !reflect.DeepEqual(got, raw) {
t.Fatalf("rewriteCommandPathTokens() = %v, want %v", got, raw)
}
got[0] = "mutated"
if raw[0] != "chat" {
t.Fatal("empty rewrite returned aliased argv")
}
})
}
}
func commandFallbackPipelineEngine(entries map[string]CommandPathFallback) *Engine {
engine := NewEngine()
engine.SetCommandPathFallbackLookup(func(path string) (CommandPathFallback, bool) {
entry, ok := entries[path]
return entry, ok
})
return engine
}
func commandFallbackPipelineRoot() (*cobra.Command, *string) {
executed := new(string)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().String("format", "table", "")
chat := &cobra.Command{Use: "chat"}
good := &cobra.Command{
Use: "+good",
RunE: func(cmd *cobra.Command, _ []string) error {
query, _ := cmd.Flags().GetString("query")
*executed = cmd.CommandPath() + ":" + query
return nil
},
}
good.Flags().String("query", "", "")
good.Flags().String("keyword", "", "")
other := &cobra.Command{Use: "+other", Run: func(cmd *cobra.Command, _ []string) { *executed = cmd.CommandPath() }}
search := &cobra.Command{
Use: "search",
RunE: func(cmd *cobra.Command, _ []string) error {
query, _ := cmd.Flags().GetString("query")
*executed = cmd.CommandPath() + ":" + query
return nil
},
}
search.Flags().String("query", "", "")
group := &cobra.Command{Use: "group"}
group.AddCommand(cmdutil.HintSubCmd("search", "use dws chat search"))
chat.AddCommand(good, other, search, group)
root.AddCommand(chat)
return root, executed
}
+143
View File
@@ -0,0 +1,143 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package pipeline
import (
"fmt"
"sort"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
const maxCommandSuggestions = 3
// validateUnresolvedCommand gives command identity precedence over flag
// parsing. Cobra otherwise falls back to the nearest parent when a child is
// unknown, so a later flag can incorrectly turn an invented command into an
// "unknown flag" error on that parent.
//
// Only two unambiguous command containers are handled here:
// - a top-level service followed by an explicit +shortcut token; and
// - a command explicitly annotated as a group container.
//
// Other unresolved positionals remain Cobra's responsibility so commands that
// legitimately accept positional arguments are not reclassified.
func validateUnresolvedCommand(target *cobra.Command, remaining []string) error {
if target == nil || len(remaining) == 0 {
return nil
}
candidate := strings.TrimSpace(remaining[0])
if candidate == "" || candidate == "--" || strings.HasPrefix(candidate, "-") {
return nil
}
if isExplicitShortcutCandidate(target, candidate) {
return unknownShortcutError(target, candidate)
}
if cmdutil.IsGroup(target) {
return unknownSubcommandError(target, candidate)
}
return nil
}
func isExplicitShortcutCandidate(target *cobra.Command, candidate string) bool {
if target == nil || !strings.HasPrefix(candidate, "+") {
return false
}
root := target.Root()
if root == nil || target == root || target.Parent() != root {
return false
}
for _, child := range target.Commands() {
if strings.HasPrefix(child.Name(), "+") {
return true
}
}
return false
}
func unknownShortcutError(parent *cobra.Command, candidate string) error {
action := fmt.Sprintf("Run '%s shortcut list --service %s --format json'", parent.Root().Name(), parent.Name())
return apperrors.NewValidation(
fmt.Sprintf("unknown shortcut %q for %q", candidate, parent.CommandPath()),
apperrors.WithReason("unknown_shortcut"),
apperrors.WithHint(commandSuggestionHint(parent, candidate, action)),
apperrors.WithActions(action),
)
}
func unknownSubcommandError(parent *cobra.Command, candidate string) error {
action := fmt.Sprintf("Run '%s --help' to see available subcommands", parent.CommandPath())
return apperrors.NewValidation(
fmt.Sprintf("unknown subcommand %q for %q", candidate, parent.CommandPath()),
apperrors.WithReason("unknown_subcommand"),
apperrors.WithHint(commandSuggestionHint(parent, candidate, action)),
apperrors.WithActions(action),
)
}
func commandSuggestionHint(parent *cobra.Command, candidate, fallback string) string {
suggestions := commandSuggestions(parent, candidate)
if len(suggestions) > maxCommandSuggestions {
suggestions = suggestions[:maxCommandSuggestions]
}
paths := make([]string, 0, len(suggestions))
for _, suggestion := range suggestions {
paths = append(paths, fmt.Sprintf("%q", parent.CommandPath()+" "+suggestion))
}
switch len(paths) {
case 0:
return fallback
case 1:
return "Did you mean " + paths[0] + "?"
default:
return "Did you mean one of: " + strings.Join(paths, ", ") + "?"
}
}
// commandSuggestions mirrors Cobra's suggestion rules without mutating
// SuggestionsMinimumDistance. Cobra only installs its default distance inside
// the final error renderer; this guard runs earlier and must remain safe for
// command trees shared by concurrent tests and callers.
func commandSuggestions(parent *cobra.Command, candidate string) []string {
if parent == nil {
return nil
}
minimumDistance := parent.SuggestionsMinimumDistance
if minimumDistance <= 0 {
minimumDistance = 2
}
lowerCandidate := strings.ToLower(candidate)
shortcutOnly := strings.HasPrefix(candidate, "+")
seen := make(map[string]bool)
var suggestions []string
for _, child := range parent.Commands() {
if !child.IsAvailableCommand() {
continue
}
name := child.Name()
if shortcutOnly && !strings.HasPrefix(name, "+") {
continue
}
matched := cmdutil.LevenshteinDist(lowerCandidate, strings.ToLower(name)) <= minimumDistance ||
strings.HasPrefix(strings.ToLower(name), lowerCandidate)
if !matched {
for _, explicit := range child.SuggestFor {
if strings.EqualFold(candidate, explicit) {
matched = true
break
}
}
}
if matched && !seen[name] {
seen[name] = true
suggestions = append(suggestions, name)
}
}
sort.Strings(suggestions)
return suggestions
}
@@ -0,0 +1,164 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package pipeline
import (
stderrors "errors"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesShortcutBeforeFlags(t *testing.T) {
root := &cobra.Command{Use: "dws"}
chat := &cobra.Command{Use: "chat"}
chat.AddCommand(
&cobra.Command{Use: "+chat-messages", Run: func(*cobra.Command, []string) {}},
&cobra.Command{Use: "+messages-send", Run: func(*cobra.Command, []string) {}},
&cobra.Command{Use: "chat-mesages", Run: func(*cobra.Command, []string) {}},
)
root.AddCommand(chat)
err := validateUnresolvedCommand(chat, []string{"+chat-mesages", "--keyword", "x"})
structured := requireCommandResolutionError(t, err, "unknown_shortcut")
if structured.Message != `unknown shortcut "+chat-mesages" for "dws chat"` {
t.Fatalf("Message = %q", structured.Message)
}
if structured.Hint != `Did you mean "dws chat +chat-messages"?` {
t.Fatalf("Hint = %q", structured.Hint)
}
if len(structured.Actions) != 1 || structured.Actions[0] != "Run 'dws shortcut list --service chat --format json'" {
t.Fatalf("Actions = %#v", structured.Actions)
}
if len(structured.AvailableFlags) != 0 {
t.Fatalf("AvailableFlags = %#v, want none for command error", structured.AvailableFlags)
}
}
func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesOnlyExplicitContainers(t *testing.T) {
root := &cobra.Command{Use: "dws"}
dev := &cobra.Command{Use: "dev"}
app := &cobra.Command{Use: "app"}
cmdutil.MarkGroup(app)
app.AddCommand(&cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}})
dev.AddCommand(app)
root.AddCommand(dev)
err := validateUnresolvedCommand(app, []string{"search", "--keyword", "x"})
structured := requireCommandResolutionError(t, err, "unknown_subcommand")
if structured.Message != `unknown subcommand "search" for "dws dev app"` {
t.Fatalf("Message = %q", structured.Message)
}
if len(structured.Actions) != 1 || structured.Actions[0] != "Run 'dws dev app --help' to see available subcommands" {
t.Fatalf("Actions = %#v", structured.Actions)
}
positional := &cobra.Command{Use: "schema [path]", Run: func(*cobra.Command, []string) {}}
root.AddCommand(positional)
for name, test := range map[string]struct {
target *cobra.Command
remaining []string
}{
"legitimate positional": {target: positional, remaining: []string{"+chat-messages"}},
"flag remains a flag": {target: app, remaining: []string{"--keyword", "x"}},
"dash terminator": {target: app, remaining: []string{"--", "search"}},
"nil target": {target: nil, remaining: []string{"search"}},
"empty remaining": {target: app},
} {
t.Run(name, func(t *testing.T) {
if err := validateUnresolvedCommand(test.target, test.remaining); err != nil {
t.Fatalf("validateUnresolvedCommand() error = %v", err)
}
})
}
}
func TestCrossPlatformCoverageRunPreParseArgsValidatesCommandsWithoutHandlersAndPrimesPresentation(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().String("format", "table", "")
chat := &cobra.Command{Use: "chat"}
chat.AddCommand(&cobra.Command{Use: "+chat-messages", Run: func(*cobra.Command, []string) {}})
root.AddCommand(chat)
ctx, err := RunPreParseArgs(root, nil, []string{
"--format", "json", "chat", "+chat-mesages", "--keyword", "x",
})
if ctx == nil || ctx.Command != "dws chat" {
t.Fatalf("Context = %#v", ctx)
}
requireCommandResolutionError(t, err, "unknown_shortcut")
if format, getErr := root.PersistentFlags().GetString("format"); getErr != nil || format != "json" {
t.Fatalf("format = %q, %v; want primed json presentation", format, getErr)
}
validRoot := &cobra.Command{Use: "dws"}
validChat := &cobra.Command{Use: "chat"}
validChat.AddCommand(&cobra.Command{Use: "+chat-messages", Run: func(*cobra.Command, []string) {}})
validRoot.AddCommand(validChat)
if ctx, err := RunPreParseArgs(validRoot, nil, []string{"chat", "+chat-messages"}); ctx != nil || err != nil {
t.Fatalf("valid shortcut = %#v, %v", ctx, err)
}
}
func TestCrossPlatformCoverageCommandSuggestionHintIsBoundedAndFallsBack(t *testing.T) {
root := &cobra.Command{Use: "dws"}
group := &cobra.Command{Use: "demo", SuggestionsMinimumDistance: 100}
root.AddCommand(group)
for _, name := range []string{"alpha", "beta", "delta", "gamma"} {
group.AddCommand(&cobra.Command{Use: name, Run: func(*cobra.Command, []string) {}})
}
hint := commandSuggestionHint(group, "missing", "fallback")
if strings.Count(hint, `"dws demo `) != maxCommandSuggestions || strings.Contains(hint, "gamma") {
t.Fatalf("bounded hint = %q", hint)
}
empty := &cobra.Command{Use: "empty"}
root.AddCommand(empty)
if got := commandSuggestionHint(empty, "missing", "fallback"); got != "fallback" {
t.Fatalf("fallback hint = %q", got)
}
}
func TestCrossPlatformCoverageCommandResolutionDefensiveAndExplicitSuggestionPaths(t *testing.T) {
root := &cobra.Command{Use: "dws"}
service := &cobra.Command{Use: "demo"}
group := &cobra.Command{Use: "nested"}
service.AddCommand(group)
root.AddCommand(service)
if isExplicitShortcutCandidate(root, "+missing") {
t.Fatal("root command was treated as a shortcut service")
}
if isExplicitShortcutCandidate(group, "+missing") {
t.Fatal("nested command was treated as a top-level shortcut service")
}
if got := commandSuggestions(nil, "missing"); got != nil {
t.Fatalf("commandSuggestions(nil) = %#v", got)
}
service.SuggestionsMinimumDistance = 1
service.AddCommand(&cobra.Command{
Use: "canonical",
SuggestFor: []string{"invented"},
Run: func(*cobra.Command, []string) {},
})
if got := commandSuggestions(service, "invented"); len(got) != 1 || got[0] != "canonical" {
t.Fatalf("explicit SuggestFor suggestions = %#v", got)
}
}
func requireCommandResolutionError(t *testing.T, err error, reason string) *apperrors.Error {
t.Helper()
var structured *apperrors.Error
if !stderrors.As(err, &structured) {
t.Fatalf("error = %T %v, want *errors.Error", err, err)
}
if structured.Category != apperrors.CategoryValidation || structured.Reason != reason || structured.ExitCode() != 3 {
t.Fatalf("structured error = %#v", structured)
}
return structured
}
+33 -1
View File
@@ -21,9 +21,23 @@ import "fmt"
// for reads after all handlers have been registered; registration
// itself is not concurrent-safe and should be done at startup.
type Engine struct {
handlers map[Phase][]Handler
handlers map[Phase][]Handler
commandPathFallbackLookup CommandPathFallbackLookup
}
// CommandPathFallback is the pipeline-local projection of one generated
// recovery record. The app injects the cli lookup so pipeline stays independent
// from the authored/generator package and can be tested with small fixtures.
type CommandPathFallback struct {
From string
Mode string
To string
Candidates []string
}
// CommandPathFallbackLookup resolves an exact normalized path.
type CommandPathFallbackLookup func(path string) (CommandPathFallback, bool)
// HandlerError preserves the pipeline location of a handler failure for logs
// and diagnostics while keeping the underlying domain error available to
// user-facing adapters through Unwrap.
@@ -62,6 +76,24 @@ func (e *Engine) RegisterAll(handlers ...Handler) {
}
}
// SetCommandPathFallbackLookup installs the build-time generated command-path
// recovery lookup. It must be configured before the engine is used.
func (e *Engine) SetCommandPathFallbackLookup(lookup CommandPathFallbackLookup) {
if e == nil {
return
}
e.commandPathFallbackLookup = lookup
}
func (e *Engine) lookupCommandPathFallback(path string) (CommandPathFallback, bool) {
if e == nil || e.commandPathFallbackLookup == nil {
return CommandPathFallback{}, false
}
entry, ok := e.commandPathFallbackLookup(path)
entry.Candidates = append([]string(nil), entry.Candidates...)
return entry, ok
}
// Handlers returns the registered handlers for a given phase, in
// registration order. The returned slice must not be modified.
func (e *Engine) Handlers(phase Phase) []Handler {
+17
View File
@@ -15,6 +15,7 @@ package pipeline
import (
"errors"
"reflect"
"strings"
"testing"
)
@@ -62,6 +63,22 @@ func TestNewEngine(t *testing.T) {
}
}
func TestCrossPlatformCoverageCommandPathFallbackLookupHandlesNilEngine(t *testing.T) {
var engine *Engine
engine.SetCommandPathFallbackLookup(func(string) (CommandPathFallback, bool) {
t.Fatal("nil engine installed or called fallback lookup")
return CommandPathFallback{}, false
})
if entry, ok := engine.lookupCommandPathFallback("chat +bad"); ok || !reflect.DeepEqual(entry, CommandPathFallback{}) {
t.Fatalf("nil engine lookup = %#v, %v", entry, ok)
}
engine = NewEngine()
if entry, ok := engine.lookupCommandPathFallback("chat +bad"); ok || !reflect.DeepEqual(entry, CommandPathFallback{}) {
t.Fatalf("unset engine lookup = %#v, %v", entry, ok)
}
}
func TestRegisterAndHandlerCount(t *testing.T) {
e := NewEngine()
e.Register(newStub("a", PreParse, nil))
+12 -2
View File
@@ -25,6 +25,8 @@ import (
"github.com/spf13/cobra"
)
const hintOnlyCommandAnnotation = "dws.command.hint_only"
// GroupRunE is a reusable RunE for parent (group) commands that have no
// business logic of their own. With args it returns an error listing available
// subcommands; without args it shows help.
@@ -45,8 +47,9 @@ func GroupRunE(cmd *cobra.Command, args []string) error {
// HintSubCmd creates a hidden subcommand that only prints a disambiguation hint.
func HintSubCmd(use, hint string) *cobra.Command {
return &cobra.Command{
Use: use,
Hidden: true,
Use: use,
Hidden: true,
Annotations: map[string]string{hintOnlyCommandAnnotation: "true"},
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("ambiguous command %q for %q\n hint: %s",
use, cmd.Parent().CommandPath(), hint)
@@ -54,6 +57,13 @@ func HintSubCmd(use, hint string) *cobra.Command {
}
}
// IsHintOnlyCommand reports whether cmd is a hidden compatibility prompt that
// has no business execution of its own. Reviewed command-path fallbacks may
// supersede these nodes, but must still reject collisions with real commands.
func IsHintOnlyCommand(cmd *cobra.Command) bool {
return cmd != nil && cmd.Annotations[hintOnlyCommandAnnotation] == "true"
}
// MustGetFlag retrieves a string flag value, checking both local and inherited flags.
func MustGetFlag(cmd *cobra.Command, name string) string {
val, _ := cmd.Flags().GetString(name)
+27
View File
@@ -0,0 +1,27 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package cmdutil
import (
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageHintSubCmdCarriesHintOnlyIdentity(t *testing.T) {
parent := &cobra.Command{Use: "parent"}
hint := HintSubCmd("search", "use parent find")
parent.AddCommand(hint)
if !hint.Hidden || !IsHintOnlyCommand(hint) {
t.Fatalf("hint command = %#v", hint)
}
if IsHintOnlyCommand(nil) || IsHintOnlyCommand(&cobra.Command{Use: "real"}) {
t.Fatal("ordinary command was classified as hint-only")
}
if err := hint.RunE(hint, nil); err == nil || !strings.Contains(err.Error(), "use parent find") {
t.Fatalf("hint error = %v", err)
}
}
+36 -1
View File
@@ -2,7 +2,8 @@
set -eu
# Drift policy after Catalog generator retirement as a committed delivery step:
# 1. param_aliases_generated.go matches a fresh generation (still committed)
# 1. committed parameter-alias and command-path-fallback Go tables match
# fresh generations
# 2. Schema assembly is deterministic (check-schema-assembly.sh)
# 3. Reviewed inputs are not mutated; retired delivery artifacts stay absent
#
@@ -18,16 +19,25 @@ policy_prepare_runtime "$ROOT"
tmp="$(mktemp -d)"
exec_tmp="$(policy_runtime_mktemp_dir dws-generated-drift)"
param_aliases_generator="$exec_tmp/param-aliases"
command_fallbacks_generator="$exec_tmp/command-path-fallbacks"
trap 'rm -rf "$tmp" "$exec_tmp"' EXIT HUP INT TERM
go build -o "$param_aliases_generator" ./internal/generator/cmd_param_aliases
go build -o "$command_fallbacks_generator" ./internal/generator/cmd_command_path_fallbacks
concepts_guard="$tmp/param_concepts.json"
concepts_schema_guard="$tmp/param_concepts.schema.json"
cp internal/cli/param_concepts.json "$concepts_guard"
cp internal/cli/param_concepts.schema.json "$concepts_schema_guard"
# Command path fallbacks and their editor schema are reviewed inputs too.
command_fallbacks_guard="$tmp/command_path_fallbacks.json"
command_fallbacks_schema_guard="$tmp/command_path_fallbacks.schema.json"
cp internal/cli/command_path_fallbacks.json "$command_fallbacks_guard"
cp internal/cli/command_path_fallbacks.schema.json "$command_fallbacks_schema_guard"
param_aliases_tmp="$tmp/param_aliases_generated.go"
param_aliases_tmp_second="$tmp/param_aliases_generated-second.go"
command_fallbacks_tmp="$tmp/command_path_fallbacks_generated.go"
command_fallbacks_tmp_second="$tmp/command_path_fallbacks_generated-second.go"
if [ -e internal/cli/schema_agent_metadata ] || [ -e internal/cli/schema_agent_metadata_audit.json ]; then
printf '%s\n' 'generated drift: retired schema_agent_metadata delivery artifact is present' >&2
@@ -37,6 +47,8 @@ fi
"$param_aliases_generator" -root . -output "$param_aliases_tmp"
"$param_aliases_generator" -root . -output "$param_aliases_tmp_second"
"$command_fallbacks_generator" -root . -output "$command_fallbacks_tmp"
"$command_fallbacks_generator" -root . -output "$command_fallbacks_tmp_second"
if [ -e internal/cli/schema_command_registry ]; then
printf '%s\n' 'generated drift: retired schema_command_registry/ must not be present' >&2
@@ -53,6 +65,16 @@ if ! cmp -s internal/cli/param_concepts.schema.json "$concepts_schema_guard"; th
exit 1
fi
if ! cmp -s internal/cli/command_path_fallbacks.json "$command_fallbacks_guard"; then
printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.json' >&2
exit 1
fi
if ! cmp -s internal/cli/command_path_fallbacks.schema.json "$command_fallbacks_schema_guard"; then
printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.schema.json' >&2
exit 1
fi
if [ -e internal/cli/schema_hints ]; then
printf '%s\n' 'generated drift: retired schema_hints/ must not be present' >&2
exit 1
@@ -79,6 +101,19 @@ if ! cmp -s internal/cli/param_aliases_generated.go "$param_aliases_tmp"; then
exit 1
fi
if ! cmp -s "$command_fallbacks_tmp" "$command_fallbacks_tmp_second"; then
printf '%s\n' 'generated drift: consecutive command-path fallback generations are not byte-identical' >&2
diff -u "$command_fallbacks_tmp" "$command_fallbacks_tmp_second" || true
exit 1
fi
if ! cmp -s internal/cli/command_path_fallbacks_generated.go "$command_fallbacks_tmp"; then
printf '%s\n' 'generated drift: internal/cli/command_path_fallbacks_generated.go is stale' >&2
printf '%s\n' 'run: make generate-schema' >&2
diff -u internal/cli/command_path_fallbacks_generated.go "$command_fallbacks_tmp" || true
exit 1
fi
# Assembly determinism validates fresh CI/local Catalog dumps.
"$ROOT/scripts/policy/check-schema-assembly.sh"
@@ -150,16 +150,16 @@ print("\t".join([
verify_cloud_delivery() {
cloud_identity="$1"
cloud_run_id="$(printf '%s\n' "$cloud_identity" | cut -f1)"
cloud_run_attempt="$(printf '%s\n' "$cloud_identity" | cut -f2)"
sealed_run_attempt="$(printf '%s\n' "$cloud_identity" | cut -f2)"
cloud_actor="$(printf '%s\n' "$cloud_identity" | cut -f3)"
cloud_actor_id="$(printf '%s\n' "$cloud_identity" | cut -f4)"
cloud_workflow_sha="$(printf '%s\n' "$cloud_identity" | cut -f5)"
[ -n "$cloud_run_id" ] && [ -n "$cloud_run_attempt" ] &&
[ -n "$cloud_run_id" ] && [ -n "$sealed_run_attempt" ] &&
[ -n "$cloud_actor" ] && [ -n "$cloud_actor_id" ] &&
[ -n "$cloud_workflow_sha" ] || return 1
cloud_run_state="$(
github_get "repos/$REPOSITORY/actions/runs/$cloud_run_id/attempts/$cloud_run_attempt" \
github_get "repos/$REPOSITORY/actions/runs/$cloud_run_id" \
| python3 -c 'import json,sys
r=json.load(sys.stdin)
print("\t".join(str(value) for value in (
@@ -176,6 +176,9 @@ print("\t".join(str(value) for value in (
r.get("actor", {}).get("id", ""),
)))'
)" || return 1
cloud_run_attempt="$(printf '%s\n' "$cloud_run_state" | cut -f2)"
printf '%s\n' "$cloud_run_attempt" | grep -Eq '^[1-9][0-9]*$' || return 1
[ "$cloud_run_attempt" -ge "$sealed_run_attempt" ] || return 1
expected_cloud_core="$(printf '%s\t%s\t%s\t.github/workflows/release.yml\tworkflow_dispatch\tcompleted\tsuccess\tmain\t%s' \
"$cloud_run_id" "$cloud_run_attempt" "$REPOSITORY" "$cloud_workflow_sha")"
[ "$(printf '%s\n' "$cloud_run_state" | cut -f1-9)" = "$expected_cloud_core" ] ||
+37 -19
View File
@@ -16,7 +16,8 @@
| 读取已知 thread/topic 的全部回复 | `dws chat +thread-replies ...` |
| 下载单个 mediaId/fileId | `dws chat +messages-resource-download ...` |
- `+messages-send` 只暴露下层真实支持的身份能力:user 支持文本/Markdown、已有 mediaId 图片、本地文件和幂等键;bot 支持群聊、最多 100 个稳定群 ID 的逐项 ledger 或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。Bot/Webhook 不支持富媒体。
- `+messages-send` 只暴露下层真实支持的身份能力:user 支持文本/Markdown、已有 mediaId 图片、本地文件和幂等键;bot 支持群聊、最多 100 个稳定群 ID 的逐项 ledger 或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。该 Shortcut 的 Bot/Webhook 路由不支持富媒体。
- 需要机器人发送公网图片 URL 或本地文件时,直接使用 `chat message send-by-bot`;它分别支持 `--msg-type image --image-url` 和 `--msg-type file --file-path`。
- `+chat-messages --page-all` 连续读取 typed `nextPage.time`,按消息 ID 去重并受 `--page-limit/--max-results` 约束;`--output` 将同一完整性 ledger 原子写入工作目录内 JSON。
- `+messages-send` 会自动规范化并补齐 @ 占位符。user 使用 `<@id>` / `<@all>`;bot/webhook 使用 `@id` / `@手机号` / `@all`。声明 `--at-*` / `--at-all` 即可,不要为统一 Shortcut 手工拼 `@10`。
- `+search-msg --page-all` 连续翻页并默认按消息 ID 批量富化;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger。
@@ -716,8 +717,7 @@ Flags:
**重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。**
群聊:传 --group 指定群;单聊:传 --users 指定用户列表,二者只能选其一,不能同时指定。--text 支持 Markdown。群聊时可选 --at-user-ids @指定成员。
群聊传 --group;单聊可传 --users、--open-dingtalk-ids 或两者组合。--group 不能与单聊目标同时指定。默认发送 Markdown,必须同时使用 --title 和 --text;公网图片 URL 使用 --msg-type image --image-url <图片 URL>;本地图片和其他本地文件一样使用 --msg-type file --file-path <本地路径>,CLI 会完成上传并按文件附件发送。群聊时可选 --at-user-ids 或 --at-open-dingtalk-ids @指定成员。
如果用户明确要求"用机器人/机器人身份/robot"发送,必须使用本命令,严禁改用 `chat message send` 以当前用户身份发送。
**重要**:机器人发群消息前,必须确认该机器人已在目标群中。若机器人不在群内会报错"机器人不存在",需先执行 `dws chat group members add-bot --id <openConversationId> --robot-code <robot-code>` 将机器人加入群聊后再发送。
@@ -726,6 +726,8 @@ Usage:
dws chat message send-by-bot [flags]
Example:
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --title "日报" --text "## 今日完成..."
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --msg-type image --image-url "https://example.com/image.png"
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --msg-type file --file-path ./report.pdf
dws chat message send-by-bot --robot-code <robot-code> --users userId1,userId2 --title "提醒" --text "请提交周报"
dws chat message send-by-bot --robot-code <robot-code> --open-dingtalk-ids openDingtalkId1,openDingtalkId2 --title "提醒" --text "请提交周报"
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --at-user-ids userId1,userId2 --title "提醒" --text "@userId1 @userId2 请查收本周报告"
@@ -734,8 +736,11 @@ Example:
Flags:
--group string 群聊 openConversationId(群聊时必填)
--robot-code string 机器人 Code (必填)
--text string 消息内容 Markdown (必填)
--title string 消息标题 (必填)
--msg-type string 消息类型:markdown、image 或 file;省略时为 markdown;公网图片使用 image --image-url;本地图片和文件使用 file --file-path
--title string Markdown 消息标题(Markdown 时必填)
--text string Markdown 消息内容(Markdown 时必填)
--image-url string 公网图片 URL(msgType=image 时必填)
--file-path string 本地图片或文件路径(msgType=file 时上传并按文件附件发送)
--users string 用户 userId 列表,逗号分隔,最多20个(单聊时必填)
--open-dingtalk-ids string 用户 openDingtalkId 列表,逗号分隔(单聊时可替代 --users,可选)
--at-user-ids string @指定成员的 userId 列表,逗号分隔(仅群聊时生效,可选)
@@ -744,7 +749,8 @@ Flags:
注意:
- 用户明确要求机器人发送时,必须使用 `chat message send-by-bot`;严禁使用 `chat message send` 以用户身份代发
- --group 与 --users/--open-dingtalk-ids 互斥,必须且只能指定其一
- --group 与任一单聊目标互斥;单聊可同时提供 --users 和 --open-dingtalk-ids,但发送文件时只能指定一个收件人
- --msg-type 决定发送类型:Markdown 必须同时指定 --title 和 --text;公网图片传 --image-url,本地图片和文件传 --file-path
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- --at-user-ids 仅在 --group 群聊时生效,单聊时无效;设置时 --text 中需包含 @userId 对应文本
- --at-open-dingtalk-ids 仅在 --group 群聊时生效,单聊时无效;设置时 --text 中需包含 @openDingtalkId 对应文本
@@ -1979,6 +1985,8 @@ Flags:
用户说"置顶消息/把消息置顶" → `chat message set-top-msg`
用户说"取消置顶消息/撤销消息置顶" → `chat message unset-top-msg`
用户说"发送/上传本地图片或媒体到聊天" → `chat message send --msg-type file --file-path <本地路径>`
用户明确要求机器人发送公网图片 URL → `chat message send-by-bot --msg-type image --image-url <图片 URL>`
用户明确要求机器人发送本地图片或文件 → `chat message send-by-bot --msg-type file --file-path <本地路径>`,按文件附件发送
用户明确只要 mediaId → DWS CLI 当前不提供本地上传入口;仅在上游已有有效 mediaId 时使用 `chat message send --msg-type image --media-id`
用户说"群机器人列表/群里有哪些机器人/查看群机器人" → `chat group bots`
用户说"从群里移除机器人/踢出机器人" → `chat group members remove-bot`
@@ -2008,7 +2016,7 @@ Flags:
- `chat message send` — 以当前用户身份发消息(群聊或单聊),text 为位置参数;本地图片/文件/音视频统一用 `--msg-type file --file-path`,其中图片显示为可下载附件而非内联图片;`--msg-type image --media-id` 只用于上游已经提供有效 mediaId 的场景,DWS CLI 不能从本地文件生成 mediaId
- `chat message search` — 按关键词搜索消息内容(跨所有会话,可选指定群)
- `chat search-common` — 搜索共同群,查询指定人共同所在的群聊(AND=所有人都在,OR=任一人在)
- `chat message send-by-bot` — 以**机器人**身份发消息(群聊或单聊),text 为 --text flag
- `chat message send-by-bot` — 以**机器人**身份发消息(群聊或单聊);Markdown 使用 `--text`,公网图片使用 `--msg-type image --image-url`,本地图片和文件使用 `--msg-type file --file-path`
- `chat message send-by-webhook` — 通过**自定义机器人 Webhook** 发群消息
- `chat message recall-by-bot` — 通过**机器人接口**撤回机器人发出的消息,需要 `--robot-code` + `--keys`(发送时返回的 processQueryKey);传 `--group` 为群聊撤回,不传为单聊撤回
- `chat message recall` — 通过 **IM 接口**撤回当前用户自己发出的消息,需要 `--conversation-id`(openConversationId)+ `--msg-id`(openMessageId,可通过 `chat message list` 获取);群聊单聊均通过 `--conversation-id` 区分
@@ -2166,34 +2174,44 @@ dws chat message send-by-bot --robot-code <robot-code> --group <openconversation
```
### 发送图片/文件 + 文字说明(两条消息)
### 发送文件 + 文字说明(两条消息)
本地图片和文件先用 `--msg-type file --file-path` 发送,再补一条文本消息说明;这是两条独立消息,不需要媒体上传或钉盘前置步骤。图片会显示为可下载的文件附件,不会内联渲染。
如果用户明确要求机器人身份,两条消息都必须改用 `chat message send-by-bot`,不得降级为个人身份发送。
```bash
dws chat message send --group <openconversation_id> --msg-type file --file-path ./screenshot.png --format json
dws chat message send --group <openconversation_id> --text "这是本周的数据汇总" --format json
```
如果调用方已经从上游取得有效 mediaId,可以先用 `--msg-type image --media-id` 发送内联图片,再补一条文本消息;DWS CLI 本身不能把本地图片转换成 mediaId。
### 发送图片 / 文件(统一一条命令)
**`dws chat message send --msg-type file --file-path <本地路径>`** 适用于所有发图片/文件场景,任意扩展名。CLI 内部完成上传与发送,无需任何前置工具调用。
机器人身份发送文件及说明:
```bash
# 群聊
dws chat message send --group <openConversationId> --msg-type file --file-path ./screenshot.png --format json
dws chat message send --group <openConversationId> --msg-type file --file-path ./report.pdf --format json
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --msg-type file --file-path ./report.pdf --format json
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --text "这是本周的数据汇总" --format json
```
# 单聊(推荐 --open-dingtalk-id)
dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file-path ./screenshot.png --format json
如果调用方已经从上游取得有效 mediaId,可以先用 `--msg-type image --media-id` 发送内联图片,再补一条文本消息;DWS CLI 本身不能把本地图片转换成 mediaId。
### 发送图片或文件
公网图片 URL 使用 `--msg-type image --image-url`,按图片消息发送。本地图片和其他本地文件一样使用 `--msg-type file --file-path`,由 CLI 上传并按文件附件发送。明确要求机器人身份时使用 `dws chat message send-by-bot`,不得降级为个人身份。
```bash
# 机器人发送图片
dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --msg-type image --image-url "https://example.com/image.png" --format json
# 当前用户发送文件
dws chat message send --group <openConversationId> --msg-type file --file-path ./report.pdf --format json
# 机器人发送文件
dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --msg-type file --file-path ./report.pdf --format json
```
**带文字说明**:在上一步发完文件后,再补一条文本消息即可。不要尝试把文字塞进 `--msg-type file` 命令(该命令不读 `--text`)。
```bash
dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file-path ./screenshot.png --format json
dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file-path ./report.pdf --format json
dws chat message send --open-dingtalk-id <openDingTalkId> --text "这是本周数据汇总" --format json
```
+9 -6
View File
@@ -99,7 +99,7 @@ dws sheet filter-view --help
| `sheet range batch-set-style` | 按配置文件批量设置样式 |
| `sheet find` | 搜索单元格内容 |
| `sheet append` | 在末尾追加数据行 |
| `sheet csv-put` | 将 CSV 数据写入指定位置(纯值,自动扩容) |
| `sheet csv-put` | 将 CSV 数据写入指定位置(支持公式,自动扩容) |
| `sheet table-put` | 写入一个或多个结构化 table(别名: table-write) |
| `sheet pivot-table list` | 列出透视表或获取指定透视表详情 |
| `sheet pivot-table create` | 创建原生透视表 |
@@ -220,14 +220,14 @@ dws sheet filter-view --help
- 更新数据 → `range update`
- 【强制】`--sheet-id` 必填:即使是单工作表也不能省略,不要参照 `range read` 的默认行为;未知时先执行 `dws sheet list --node <NODE_ID> --format json` 获取 `sheetId`,禁止凭空臆测为 `Sheet1`、`sheet1`、`0`、`default` 等
- 注意:如果用户的目的是替换文本、移动行列或追加空行空列,请勿使用 `range update`,必须使用对应的专用命令(`replace`/`move-dimension`/`add-dimension`)
- **批量纯值写入优先用 `csv-put`**:当写入场景同时满足以下条件时,必须优先使用 `csv-put` 而非 `range update`:(1) 写入的是纯值(不含公式、超链接);(2) 数据量较大(超过 5 行或超过 20 个单元格);(3) 数据来源为表格/CSV 文本/结构化文本。`csv-put` 无需手动构造二维 JSON 数组,直接传 CSV 文本即可,更简洁高效且支持自动扩容
- **批量 CSV 值/公式写入优先用 `csv-put`**:当数据可表达为 CSV(可含 `=` 开头的公式)、不需要超链接或富格式且数据量较大(超过 5 行或超过 20 个单元格)时,必须优先使用 `csv-put`。它无需手动构造二维 JSON 数组并支持自动扩容;要写以 `=` 开头的字面文本时在字段值前加单引号
用户说"追加数据/添加行/在末尾加数据/新增记录":
- 追加数据 → `append`
用户说"批量写入CSV/导入CSV/CSV写入表格/把CSV贴到表格里":
- 写入 CSV → `csv-put`
- 与 `range update` 的区别:`csv-put` 接受 CSV 文本直接写入,无需手动构造二维 JSON 数组;适合大批量纯值写入
- 与 `range update` 的区别:`csv-put` 接受 CSV 文本直接写入,无需手动构造二维 JSON 数组;适合大批量值或公式写入,但不支持超链接和富格式对象
- 与 `append` 的区别:`csv-put` 写入指定位置(--start-cell),`append` 在末尾追加
用户说"搜索/查找/找单元格/搜内容/精确搜索/精确匹配/完全匹配/全字匹配":
@@ -416,7 +416,7 @@ dws sheet filter-view --help
- ★ **`range update` 维度校验(强制)**:`--values` 的行列数必须与 `--range` 完全一致。例如 `--range "A1:C3"` → `--values` 必须是 3×3 的 object 数组
- ★ **`range update` 清空规范(强制)**:清空单个单元格用 `{"type":"text","text":""}`;清空整片区域用 `range clear`。跳过某格保留原值用 `{}` 空对象
- ★ **单次调用上限(强制)**:`range update` / `set-style` 行数 ≤ 1000,单元格总数建议 ≤ 5000(硬限 30000)
- ★ **大批量纯值写入用 `csv-put` 不用 `range update`**:当写入纯值(无公式/超链接)且数据量较大时(>5 行或 >20 单元格),必须使用 `csv-put`。`csv-put` 接受 CSV 文本直接写入,无需构造二维 JSON 数组,支持自动扩容,更简洁高效。仅在需要写入公式、超链接、或仅更新少量单元格时才使用 `range update`
- ★ **大批量 CSV 值/公式写入用 `csv-put` 不用 `range update`**:当数据可表达为 CSV(可含 `=` 开头的公式)、不需要超链接或富格式且数据量较大时(>5 行或 >20 单元格),必须使用 `csv-put`。需要超链接、富格式对象、`{}` 跳过,或仅更新少量单元格时使用 `range update`
- ★ **搜索用 `find` 不用 `range read`**:`find` 是服务端搜索,禁止用 `range read` 全量读取后客户端过滤
- ★ **替换用 `replace` 不用 `range update`**:`replace` 是服务端原子操作,返回替换计数
- ★ **移动用 `move-dimension` 不用 `range update`**:原子操作,保留格式和合并状态
@@ -620,7 +620,7 @@ Flags:
**单次调用建议**:行数 ≤ 1000,单元格总数(行×列)≤ 5000;超过时请拆分多次调用。
**何时该用 `csv-put` 替代**:如果你准备用 `range update` 写入纯值(不含公式和超链接),且数据量超过 5 行或 20 个单元格,应改用 `csv-put`——它接受 CSV 文本直接写入,无需手动拼装 object 数组,且支持自动扩容行列。仅在需要写入公式(`=SUM(...)`)、超链接、富文本、或修改少量单元格时才使用 `range update`。
**何时该用 `csv-put` 替代**:如果你准备用 `range update` 写入大批量值或公式,数据可以表达为 CSV,且不需要超链接或富文本,应改用 `csv-put`——它无需手动拼装 object 数组,且支持自动扩容行列。需要富格式对象、`{}` 跳过或修改少量单元格时使用 `range update`。
**范围职责**:`range update` 负责写入单元格的值、超链接、per-cell 样式与数据验证。批量刷整片区域的统一样式或数字格式(百分比 / 货币 / 日期 / 文本等)请使用 `dws sheet range set-style --number-format <格式代码>`。
@@ -762,6 +762,9 @@ Example:
dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell A1 \
--csv $'name,score\nAlice,95\nBob,87'
dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell A1 \
--csv "=1+1,'=1+1"
# 多行数据推荐用 @文件,最稳妥
dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell B2 \
--csv @data.csv --allow-overwrite
@@ -780,7 +783,7 @@ Flags:
```
将 RFC 4180 格式的 CSV 文本写入指定工作表的指定单元格位置。
- 只写纯值,不支持公式/样式/批注。`=` 开头的内容当文本处理,不会被解析为公式
- 写入值和公式,不支持样式/批注。字段值以 `=` 开头时默认按公式解析;如需写入以 `=` 开头的字面文本,在字段值前加单引号(例如 `'=1+1`)
- 数字/日期/百分数由表格引擎自动识别类型(如 `95` 存为数字,`2025-03-01` 存为日期)
- 自动扩容行列:CSV 数据超出当前工作表维度时自动追加行/列
- 目标区域如含合并单元格,合并将被打散,值正常写入
@@ -89,6 +89,7 @@ Notes:
- --continue-on-error: 宽松模式,遇失败继续执行后续操作(已执行的子操作不回滚)
- operations 最多 20 条
- 当需要对多个区域执行相同清除时,优先使用 `range batch-clear`(更简洁)
- `csv-put` 子操作与独立命令语义一致:CSV 字段值以 `=` 开头时按公式解析;前加单引号时写入以 `=` 开头的字面文本
- 典型场景:先插入行列再写入数据、先清除再写入、批量合并+调整行高列宽
- 真实执行必须获得用户确认后加 --yes;--dry-run 不需要 --yes
```
@@ -139,7 +140,7 @@ DryRun 输出内容:
DryRun 适用场景:
- 确认 CLI 命令名 → MCP toolName 的翻译是否正确
- 确认参数名转换(如 `--range` → `rangeAddress`)是否符合预期
- 检查 `csv-put` 的 `@filepath` 是否被正确解析为文件内容
- 检查 `csv-put` 的 `@filepath` 是否被正确解析为文件内容,并确认 `=...` / `'=...` 字段原样进入子操作
### --continue-on-error 行为
@@ -7,14 +7,14 @@
- 更新数据 → `range update`
- 【强制】`--sheet-id` 必填:即使是单工作表也不能省略,不要参照 `range read` 的默认行为;未知时先执行 `dws sheet list --node <NODE_ID> --format json` 获取 `sheetId`,禁止凭空臆测为 `Sheet1`、`sheet1`、`0`、`default` 等
- 注意:如果用户的目的是替换文本、移动行列、追加空行空列、清空区域、排序、填充、复制区域或移动区域,请勿使用 `range update`,必须使用对应的专用命令(`replace`/`move-dimension`/`add-dimension`/`range clear`/`range sort`/`range fill`/`range copy-to`/`range move-to`)
- **批量纯值写入优先用 `csv-put`**:当写入场景同时满足以下条件时,必须优先使用 `csv-put` 而非 `range update`:(1) 写入的是纯值(不含公式、超链接、dataValidation、cellStyles、richText);(2) 数据量较大(超过 5 行或超过 20 个单元格);(3) 数据来源为表格/CSV 文本/结构化文本。`csv-put` 无需手动构造二维 JSON 数组,直接传 CSV 文本即可,更简洁高效且支持自动扩容
- **批量 CSV 值/公式写入优先用 `csv-put`**:当写入场景同时满足以下条件时,必须优先使用 `csv-put` 而非 `range update`:(1) 不需要超链接、dataValidation、cellStyles 或 richText;(2) 数据量较大(超过 5 行或超过 20 个单元格);(3) 数据来源为表格/CSV 文本/结构化文本。`csv-put` 无需手动构造二维 JSON 数组,字段值以 `=` 开头时按公式解析,并支持自动扩容
用户说"追加数据/添加行/在末尾加数据/新增记录":
- 追加数据 → `append`
用户说"批量写入CSV/导入CSV/CSV写入表格/把CSV贴到表格里":
- 写入 CSV → `csv-put`
- 与 `range update` 的区别:`csv-put` 接受 CSV 文本直接写入,无需手动构造二维 JSON 数组;适合大批量纯值写入
- 与 `range update` 的区别:`csv-put` 接受 CSV 文本直接写入,无需手动构造二维 JSON 数组;适合大批量值或公式写入,但不支持富格式对象
- 与 `append` 的区别:`csv-put` 写入指定位置(--start-cell),`append` 在末尾追加
**四种写入命令能力对比**:
@@ -23,7 +23,7 @@
|------|-------------|----------------|----------|-----------|
| 多工作表结构化写入 | 支持 | 不支持 | 不支持 | 不支持 |
| columns / dtypes / formats | 支持 | 不支持 | 不支持 | 不支持 |
| 公式(`=` 开头) | 按 dtype/底层能力 | 支持 | 不支持 | 不支持(当文本) |
| 公式(`=` 开头) | 按 dtype/底层能力 | 支持 | 不支持 | 支持;前导单引号写入以 `=` 开头的字面文本 |
| 单元格级超链接(`hyperlink`) | 不使用此命令 | 支持 | 不支持 | 不支持 |
| 富文本(片段链接/附件/图片) | 不使用此命令 | 支持 | 不支持 | 不支持 |
| 原始值(纯数字/字符串) | 支持 | 支持 | 支持 | 支持 |
@@ -90,7 +90,7 @@ Flags:
**单次调用建议**:行数 ≤ 1000,单元格总数(行×列)≤ 5000;超过时请拆分多次调用。
**何时该用 `csv-put` 替代**:如果你准备用 `range update` 写入纯值(不含公式、超链接、富文本对象),且数据量超过 5 行或 20 个单元格,应改用 `csv-put`——它接受 CSV 文本直接写入,无需手动拼装二维 JSON 数组,且支持自动扩容行列。仅在需要写入公式(`=SUM(...)`)、单元格级超链接、富文本对象或修改少量单元格时才使用 `range update`。
**何时该用 `csv-put` 替代**:如果你准备用 `range update` 写入大批量值或公式,数据可以表达为 CSV,且不需要单元格级超链接、富文本对象、样式或数据验证,应改用 `csv-put`——它无需手动拼装二维 JSON 数组,且支持自动扩容行列。需要富格式对象、`{}` 跳过或仅修改少量单元格时使用 `range update`。
**范围职责**:`range update` 负责写入单元格内容(原始值/公式/富文本对象),并支持通过 `cellStyles` 附带 per-cell 样式。如需批量设置整片区域的样式(不写值),请使用 `dws sheet range set-style`。
@@ -120,6 +120,9 @@ Example:
dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell A1 \
--csv $'name,score\nAlice,95\nBob,87'
dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell A1 \
--csv "=1+1,'=1+1"
# 多行数据推荐用 @文件,最稳妥
dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell B2 \
--csv @data.csv --allow-overwrite
@@ -139,10 +142,10 @@ Flags:
将 RFC 4180 格式的 CSV 文本写入指定工作表的指定单元格位置。
- **分隔符必须是英文逗号 `,`**(ASCII 0x2C),禁止使用中文逗号 `,`(U+FF0C)。中文逗号不会被识别为分隔符,会导致整行被写入同一个单元格。生成 CSV 内容时务必检查分隔符
- 只写纯值,不支持公式/样式/批注。`=` 开头的内容当文本处理,不会被解析为公式
- 写入值和公式,不支持样式/批注。字段值以 `=` 开头时默认按公式解析;如需写入以 `=` 开头的字面文本,在字段值前加单引号(例如 `'=1+1`)
- 数字/日期/百分数由表格引擎自动识别类型(如 `95` 存为数字,`2025-03-01` 存为日期)
- 自动扩容行列:CSV 数据超出当前工作表维度时自动追加行/列
- 与 `range update` 不同,目标区域如含合并单元格,`csv-put` 会打散合并并写入纯值
- 与 `range update` 不同,目标区域如含合并单元格,`csv-put` 会打散合并并写入 CSV 数据
- 若需要保留原有合并结构,写入前先用 `sheet info` 记录 `mergedRanges`,写入后用 `merge-cells` 恢复对应区域
- `--allow-overwrite` 默认 false,目标区域有数据时需显式传 `--allow-overwrite` 才能覆盖
- `--csv` 支持三种输入:直接传文本、`@filepath` 从本地文件读取、`-` 从 stdin 管道读取
@@ -424,10 +427,10 @@ dws sheet append --node <NODE_ID> --sheet-id <SHEET_ID> \
- **dataValidation 三语义**:不传字段=保留;`{type:"none"}`=清除;`{type:"dropdown"/"checkbox",...}`=覆盖。无需先 read 再回传,引擎自动保留原 DV
- **hyperlink 三语义**:不传字段=保留;`{type:"none"}`=清除;`{type:"path"/"sheet"/"range",...}`=覆盖。Agent 调用不要使用 `hyperlink:null`
- ★ **单次调用上限(强制)**:`range update` / `set-style` 行数 ≤ 1000,单元格总数建议 ≤ 5000(硬限 30000)
- ★ **大批量纯值写入用 `csv-put` 不用 `range update`**:当写入纯值(无公式、无超链接、无富文本对象)且数据量较大时(>5 行或 >20 单元格),必须使用 `csv-put`。`csv-put` 接受 CSV 文本直接写入,无需构造二维 JSON 数组,支持自动扩容,更简洁高效。仅在需要写入公式、单元格级超链接、富文本对象,或仅更新少量单元格时才使用 `range update`
- ★ **大批量 CSV 值/公式写入用 `csv-put` 不用 `range update`**:当数据可表达为 CSV(可含 `=` 开头的公式)、不需要超链接或富文本对象且数据量较大时(>5 行或 >20 单元格),必须使用 `csv-put`。它无需构造二维 JSON 数组并支持自动扩容。需要单元格级超链接、富文本对象、样式、数据验证、`{}` 跳过,或仅更新少量单元格时使用 `range update`
- `range update` 必填 `--values`;单元格级超链接通过 cell 的 `hyperlink` 字段表达,附件 / 图片 / 带样式片段通过 `--values` 内的 richText 富格式表达,CLI 不再有 `--hyperlinks` 参数
- `range update` 职责边界:`range update` 写入单元格内容(文本 / 公式 / 富文本对象),支持通过 `cellStyles` 附带 per-cell 样式(背景色 / 字号 / 对齐等)。但批量刷整片区域的统一样式时,应使用 `dws sheet range set-style`(如 "给表头加粗居中")或 `dws sheet range batch-set-style --batch <config.json>`。两种方式各有适用场景:少量 cell 写值 + 样式一步到位用 `cellStyles`;大面积统一样式用 `set-style`
- `append` 自动定位到最后一行有数据的位置下方插入,无需手动计算行号
- `append` 的 `--values` 二维数组中每行的列数必须一致,否则会报错。如果用户提供的数据中各行长度不同,必须先将短行用空字符串 `""` 补齐到与最长行相同的列数后再调用。追加的数据列数也应与工作表已有数据列数保持一致
- `append` vs `range update`:追加新行用 `append`,修改已有单元格用 `range update`
- ★ **`append` / `csv-put` 不支持 `{}` skip、`dataValidation`、富文本、公式**:这些能力仅限 `range update`。`append` 和 `csv-put` 只接受原始值(字符串/数字/布尔),走的是不同的 MCP tool(`append_rows` / `set_range_from_csv`)。需要写入公式、超链接、下拉列表或跳过部分单元格时,必须使用 `range update`
- ★ **`append` / `csv-put` 不支持 `{}` skip、`dataValidation`、富文本或超链接**:这些能力仅限 `range update`。`append` 只接受原始值;`csv-put` 接受 CSV 值和公式,字段值以 `=` 开头时按公式解析,前加单引号时写入以 `=` 开头的字面文本。需要下拉列表、富格式或跳过部分单元格时,必须使用 `range update`
@@ -15,13 +15,17 @@
`dws chat +messages-send --as bot --robot-code <robotCode>`;不得改成当前用户身份。
- `chat bot search` 只返回我创建的机器人,没有 `openDingTalkId`;给机器人发单聊必须用 `chat bot find`。
- 机器人发群消息前需确认机器人已在群中;报“机器人不存在”时先 `group members add-bot`。
- `send-by-bot --text` 支持 Markdown;需要稳定换行时用空行分隔段落。若以转义形式组织文本,写 `\n\n`,不要只写 `\n`。
- `send-by-bot` 支持 Markdown、图片 URL 和文件,具体参数见下方消息类型路由。
- 公网图片 URL 使用 `--msg-type image --image-url`,按图片消息发送。
- 本地图片和其他本地文件一样使用 `--msg-type file --file-path`,由 CLI 上传并按文件附件发送。
- 群聊传 `--group`;单聊可传 `--users`、`--open-dingtalk-ids` 或两者组合。
- Markdown 必须同时传 `--title` 和 `--text`;需要稳定换行时用空行分隔段落。若以转义形式组织文本,写 `\n\n`,不要只写 `\n`。
- `recall-by-bot` 使用 `processQueryKey`,不是 `openMessageId`。
- Bot 多群文本/Markdown 直接使用 `+messages-send --groups <cid...>` 或
`--groups-file <工作目录内相对文件>`;最多 100 个稳定 ID,Runtime 去重并返回
`im.batch-write.v1` 逐目标 ledger。
- Bot/Webhook 没有与 current-user 等价的文件、图片、音视频发送接口;不得把富媒体转成文本
或换身份静默发送。
- `+messages-send` 的 Bot 路由和 Webhook 没有与 current-user 等价的文件、图片、音视频发送接口;
机器人富媒体必须使用原子 `chat message send-by-bot`,不得转成文本或换身份静默发送。
## 命令明细
@@ -60,6 +64,8 @@ dws chat +messages-send --as bot --robot-code <robot-code> \
```bash
# 群聊
dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --title "日报" --text "## 今日完成\n\n- 事项 A\n\n- 事项 B"
dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --msg-type image --image-url "https://example.com/image.png"
dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --msg-type file --file-path ./report.pdf
# 单聊 userId
dws chat message send-by-bot --robot-code <robot-code> --users userId1,userId2 --title "提醒" --text "请提交周报"
@@ -81,7 +87,11 @@ dws chat message send-by-bot --robot-code <robot-code> --group <openConversation
| `--group` | 群聊 openConversationId |
| `--users` | 单聊 userId 列表,逗号分隔,最多 20 个 |
| `--open-dingtalk-ids` | 单聊 openDingTalkId 列表 |
| `--title` / `--text` | 消息标题与 Markdown 内容,必填;Markdown 换行用空行,转义表示为 `\n\n` |
| `--msg-type` | `markdown`、`image` 或 `file`;省略时为 Markdown;公网图片使用 `image --image-url`,本地图片和文件使用 `file --file-path` |
| `--text` | Markdown 消息内容,Markdown 模式必填;换行用空行,转义表示为 `\n\n` |
| `--title` | Markdown 消息标题,Markdown 模式必填 |
| `--image-url` | 公网图片 URL,`--msg-type image` 时必填 |
| `--file-path` | 本地图片或文件路径,`--msg-type file` 时由 CLI 上传并按文件附件发送 |
| `--at-user-ids` / `--at-open-dingtalk-ids` | 群聊 @ 指定成员,正文需含对应 `@id` 文本 |
| `--at-all` | 群聊 @所有人 |
@@ -32,7 +32,7 @@
2. **先读结构再动结构**:合并、冻结、行列分组、行高列宽、隐藏行列、最后非空边界都属于工作表结构信息,先读 `dws sheet info --node <ID> --sheet-id <SHEET_ID> --format json`。
3. **按目的选择读取方式**:快速看值和大表分批用 `csv-get`;需要 `columns` / `data` / `dtypes` / `formats` 用 `table-get`;需要公式、样式、数据验证、超链接、富文本等 per-cell 元数据用 `range read`。
4. **写返回不等于完成**:任何写操作完成后都要用独立读命令回读确认。值写入用 `csv-get` / `range read` / `table-get`,结构变更用 `sheet info`,对象类操作用对应 `list` / `get`。
5. **大批量纯值不要拼大 JSON**:超过 5 行或 20 个单元格的纯值写入优先 `csv-put`;需要 table/dataframe 语义时用 `table-put`。
5. **大批量 CSV 值/公式不要拼大 JSON**:超过 5 行或 20 个单元格、且不需要富格式对象时优先 `csv-put`;字段值以 `=` 开头按公式解析,前加单引号写入以 `=` 开头的字面文本;需要 table/dataframe 语义时用 `table-put`。
6. **公式写入后分层校验**:写公式前读 [sheet-formula](./sheet/sheet-formula.md)。写后先用 `range read --value-render-option formula` 确认公式文本,再用 `formula-verify` 聚合扫描计算错误;需要确认业务数值时再用 `raw_value` 抽样对账。
7. **专用操作用专用命令**:搜索用 `find`、替换用 `replace`、清空用 `range clear`、排序用 `range sort`、复制/移动区域用 `range copy-to` / `range move-to`,不要用 `range read` + `range update` 客户端模拟。
8. **大整数按文本写**:超过 `9007199254740991` 的整数、长数字 ID、订单号、手机号等需要逐位精确的值,不要按 JSON number 或 `int64` / `uint64` 写入;用字符串值 + `object` dtype + 文本格式 `@`。
@@ -43,9 +43,9 @@
|---------|---------|----------|----------|
| 快速查看数据 / 大表分批读取 | `csv-get` | [sheet-read-data](sheet/sheet-read-data.md) | 不传 `--range` 全量 `range read` 大表 |
| 按表格结构读写列名、类型、格式 | `table-get` / `table-put` | [sheet-read-data](sheet/sheet-read-data.md)、[sheet-write-data](sheet/sheet-write-data.md) | 把 table spec 塞进 `batch-update` |
| 少量精确写入、公式、超链接、富文本、数据验证 | `range update` | [sheet-write-data](sheet/sheet-write-data.md)、[sheet-formula](sheet/sheet-formula.md) | 用 `csv-put` / `append` 写公式或富格式 |
| 少量精确写入、公式对象、超链接、富文本、数据验证 | `range update` | [sheet-write-data](sheet/sheet-write-data.md)、[sheet-formula](sheet/sheet-formula.md) | 用 `append` 写公式,或用 `csv-put` 写富格式 |
| 校验公式错误 / 全表公式检查 | `formula-verify` | [sheet-formula](sheet/sheet-formula.md) | 全表 `range read` 后由 Agent 自行枚举错误值 |
| 批量纯值写入 / CSV 粘贴 | `csv-put` | [sheet-write-data](sheet/sheet-write-data.md) | 为大块纯值手写巨大 `--values` JSON |
| 批量 CSV 值/公式写入 / CSV 粘贴 | `csv-put` | [sheet-write-data](sheet/sheet-write-data.md)、[sheet-formula](sheet/sheet-formula.md) | 为大块 CSV 数据手写巨大 `--values` JSON |
| 追加记录到末尾 | `append` | [sheet-write-data](sheet/sheet-write-data.md) | 手算最后一行后 `range update` |
| 查找 / 替换 | `find` / `replace` | [sheet-search-replace](sheet/sheet-search-replace.md) | 读全表后本地过滤或手写替换 |
| 清空 / 排序 / 填充 / 复制移动区域 | `range clear` / `range sort` / `range fill` / `range copy-to` / `range move-to` | [sheet-range-operations](sheet/sheet-range-operations.md) | 用读写组合模拟服务端原子操作 |
@@ -70,8 +70,8 @@
|-----------|------|
| [sheet-workbook](sheet/sheet-workbook.md) | 管理表格文档与工作表。当用户说"创建表格"、"有哪些工作表"、"新建/重命名/隐藏/冻结/复制/删除工作表"、"显示/隐藏网格线"时使用;`info` 可回读冻结行列等工作表结构信息。命令:`create`/`list`/`info`/`new`/`update`/`copy`/`delete-sheet`/`show-gridline`/`hide-gridline` |
| [sheet-read-data](sheet/sheet-read-data.md) | 读取工作表数据。当用户说"读数据"、"看表格内容"、"查看数据"时使用。推荐 `csv-get`(CSV 格式、token 低、防爆保护);需按 table/dataframe 结构读取列名、data、dtypes、formats 时用 `table-get`;需 value + dataValidation / hyperlink / richText / cellStyles 等 per-cell 元数据时用 `range read`。大范围数据建议分页读取(单次 ≤5000 单元格)。命令:`csv-get`/`table-get`/`range read` |
| [sheet-write-data](sheet/sheet-write-data.md) | 写入数据到工作表。当用户说"写数据"、"填表"、"更新单元格"、"写公式"、"超链接"、"写值同时设样式/数据验证"、"追加数据"、"导入CSV"、"写入结构化 table"时使用。大批量纯值(>5行或>20单元格)必须用 `csv-put` 而非 `range update`;结构化 table/dataframe 输入用 `table-put`。命令:`range update`/`append`/`csv-put`/`table-put` |
| [sheet-formula](sheet/sheet-formula.md) | 公式写入、文本回读、错误聚合与结果抽样。当用户说"写公式"、"计算列"、"辅助列"、"总计/占比/增长率/查找计算"、"校验公式"、"检查公式错误"时使用。命令:`range update` / `range read --value-render-option formula/raw_value` / `formula-verify` |
| [sheet-write-data](sheet/sheet-write-data.md) | 写入数据到工作表。当用户说"写数据"、"填表"、"更新单元格"、"写公式"、"超链接"、"写值同时设样式/数据验证"、"追加数据"、"导入CSV"、"写入结构化 table"时使用。大批量 CSV 值/公式(>5行或>20单元格,且无需富格式)必须用 `csv-put` 而非 `range update`;结构化 table/dataframe 输入用 `table-put`。命令:`range update`/`append`/`csv-put`/`table-put` |
| [sheet-formula](sheet/sheet-formula.md) | 公式写入、文本回读、错误聚合与结果抽样。当用户说"写公式"、"计算列"、"辅助列"、"总计/占比/增长率/查找计算"、"校验公式"、"检查公式错误"时使用。命令:`range update` / `csv-put` / `range read --value-render-option formula/raw_value` / `formula-verify` |
| [sheet-search-replace](sheet/sheet-search-replace.md) | 搜索和替换文本。当用户说"搜索"、"查找"、"替换"、"把A改成B"时使用。禁止用 `range read` 全量读取后客户端过滤代替 `find`,禁止用 `range update` 模拟 `replace`。命令:`find`/`replace` |
| [sheet-range-operations](sheet/sheet-range-operations.md) | 区域结构性操作。当用户说"清空"、"排序"、"自动填充"、"复制区域到"、"移动数据到"时使用。均为服务端原子操作,禁止 `range read`+`range update` 组合模拟。排序前必须先读前几行判断表头。命令:`range clear`/`range sort`/`range fill`/`range copy-to`/`range move-to` |
| [sheet-batch-operations](sheet/sheet-batch-operations.md) | 批量操作。当用户说"批量清除多个区域"、"组合多个写操作"、"先清除再写入"、"插行列再写数据"、"批量创建/取消分组"时使用。`batch-update` 只支持已列明的原子写操作;`table-put` / `table-get` 不放进 batch,结构化 table 请用独立 `table-put`。命令:`range batch-clear`/`batch-update` |
@@ -102,7 +102,7 @@
8. **hyperlink 三语义**:不传 `hyperlink` 字段=保留原整格超链接;`hyperlink:{type:"none"}`=显式清除;`hyperlink:{type:"path"/"sheet"/"range",link,...}`=覆盖。Agent 调用不要用 `hyperlink:null`,避免网关/Schema 过滤 null 字段
9. **样式写法**:cell-level 样式用 `cellStyles` 或 `range set-style`;richText 片段级样式才用子项 `style`。不要在 `type:"text"` 顶层使用旧 `style` 字段
10. **用专用命令不用组合模拟**:搜索→`find`、替换→`replace`、清空→`range clear`、排序→`range sort`、填充→`range fill`、复制区域→`range copy-to`、移动区域→`range move-to`、移动行列→`move-dimension`
11. **大批量纯值用 `csv-put`**(>5 行或 >20 单元格),不用 `range update`;需要 dataframe/table 语义(列名、dtypes、formats、跨 sheet specs)时用 `table-get` / `table-put`
11. **大批量 CSV 值/公式用 `csv-put`**(>5 行或 >20 单元格,且无需富格式),不用 `range update`;需要 dataframe/table 语义(列名、dtypes、formats、跨 sheet specs)时用 `table-get` / `table-put`
12. **单元格图片用 `write-image`**(`range update` 不支持图片参数)
13. **`export` / `import` 禁止自行轮询**(CLI 内部已完成渐进式退避,最多 30 次约 5 分钟);`export get` / `import get`为单次查询,非终态时可手动再次调用
14. **单次调用上限**:`range update` / `set-style` 行数 ≤ 1000,单元格总数建议 ≤ 5000(硬限 30000)
@@ -100,6 +100,7 @@ Notes:
- --continue-on-error: 宽松模式,遇失败继续执行后续操作(已执行的子操作不回滚)
- operations 最多 20 条
- 当需要对多个区域执行相同清除时,优先使用 `range batch-clear`(更简洁)
- `csv-put` 子操作与独立命令语义一致:CSV 字段值以 `=` 开头时按公式解析;前加单引号时写入以 `=` 开头的字面文本
- 典型场景:先插入行列再写入数据、先清除再写入、批量合并+调整行高列宽
- `group-dimension` 在 batch 中只适合默认展开分组;需要 `--group-state fold` 时请使用独立 `dws sheet group-dimension`
- `table-put` 不支持放进 batch-update;结构化 table 请用独立 `dws sheet table-put`
@@ -10,19 +10,21 @@
## 当前能力边界
- 写公式:使用 `dws sheet range update`。
- 写少量或需要单元格对象的公式:使用 `dws sheet range update`。
- 从 CSV/表格文本批量写公式:使用 `dws sheet csv-put`,字段值以 `=` 开头时默认按公式解析;如需写入以 `=` 开头的字面文本,在字段值前加单引号。
- 公式载体:公式写在 cell object 的 `text` 字段中,例如 `{"type":"text","text":"=SUM(B2:B10)"}`。
- 读取公式文本:使用 `dws sheet range read --value-render-option formula`。
- 读取计算结果:使用 `dws sheet range read --value-render-option raw_value` 或默认 `formatted_value`。
- 聚合错误校验:使用 `dws sheet formula-verify`,支持整本表格、单个目标和多个目标。
- `formula-verify` 扫描已经落表的公式计算结果,按 `#ERROR!` / `#NAME?` / `#DIV/0!` 等错误类型汇总;它不判断一个正常数值是否符合业务预期。
- `csv-put` / `append` / `table-put` 不作为公式写入协议;`=` 开头内容可能按普通值或 table 数据处理。需要公式时用 `range update`。
- `append` / `table-put` 不作为公式写入协议;需要公式时用 `range update` 或 `csv-put`。
## 命令选择
| 目的 | 命令 | 说明 |
|------|------|------|
| 写入少量或中等范围公式 | `range update` | `--values` 必须是二维 cell object,维度与 `--range` 完全一致 |
| 从 CSV/表格文本批量写公式 | `csv-put` | `=` 开头按公式;前导单引号写入以 `=` 开头的字面文本;不支持富格式对象 |
| 查看已写入的公式文本 | `range read --value-render-option formula` | 确认公式本身是否落表、范围和引用是否正确 |
| 查看公式计算结果 | `range read --value-render-option raw_value` | 用于数值对账、错误值检查 |
| 查看格式化展示结果 | `range read` 或 `csv-get` 默认模式 | 用于用户肉眼看到的展示值检查 |
@@ -35,7 +37,7 @@
1. 用 `dws sheet list --node <NODE_ID> --format json` 获取真实 `sheetId`。
2. 用 `range read` 或 `csv-get` 读取表头和样本数据,确认目标列与行号。
3. 明确相对引用和绝对引用:向下填充时检查固定汇率、税率、查找表、标题行是否需要 `$` 锁定。
4. 用 `range update` 写入公式矩阵;矩阵行列数必须与 `--range` 完全一致。
4. 按数据形态写入公式:精确 cell object 用 `range update`,CSV/表格文本用 `csv-put`。`range update` 的矩阵行列数必须与 `--range` 完全一致。
5. 用 `range read --value-render-option formula` 回读公式文本,确认实际公式、范围和引用。
6. 对本次写入目标运行 `formula-verify`;若返回 `partial` / `hasMore=true`,缩小目标或提高 `--max-cells` 后继续扫描,直到结果完整。
7. 用 `range read --value-render-option raw_value` 抽样对账业务数值;正常数值不会被 `formula-verify` 判定为业务计算错误。
@@ -172,7 +174,7 @@ dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --range "D2:D5" \
## 常见错误
- 用 `csv-put` 写 `=SUM(...)`,导致公式没有按公式协议落表。
- 想用 `csv-put` 写入 `=SUM(...)` 文本却忘记加前导单引号,导致内容被解析为公式。
- 用原始二维数组 `--values '[["=B2*C2"]]'`,而不是 cell object。
- 写整列公式时只写第一行,忘记把 `--range` 和 `--values` 扩成同样行数。
- 复制公式时没有锁定固定引用,例如税率、汇率、查找表范围。
@@ -7,34 +7,34 @@
- 写入公式前先读 [sheet-formula](./sheet-formula.md);写完必须回读公式文本并运行 `formula-verify`,关键业务数值还要抽样对账,不能只看写入返回成功
- 【强制】`--sheet-id` 必填:即使是单工作表也不能省略,不要参照 `range read` 的默认行为;未知时先执行 `dws sheet list --node <NODE_ID> --format json` 获取 `sheetId`,禁止凭空臆测为 `Sheet1`、`sheet1`、`0`、`default` 等
- 注意:如果用户的目的是替换文本、移动行列、追加空行空列、清空区域、排序、填充、复制区域或移动区域,请勿使用 `range update`,必须使用对应的专用命令(`replace`/`move-dimension`/`add-dimension`/`range clear`/`range sort`/`range fill`/`range copy-to`/`range move-to`)
- **批量纯值写入优先用 `csv-put`**:当写入场景同时满足以下条件时,必须优先使用 `csv-put` 而非 `range update`:(1) 写入的是纯值(不含公式、超链接、dataValidation、cellStyles、richText);(2) 数据量较大(超过 5 行或超过 20 个单元格);(3) 数据来源为表格/CSV 文本/结构化文本。`csv-put` 无需手动构造二维 JSON 数组,直接传 CSV 文本即可,更简洁高效且支持自动扩容
- **批量 CSV 值/公式写入优先用 `csv-put`**:当写入场景同时满足以下条件时,必须优先使用 `csv-put` 而非 `range update`:(1) 不需要超链接、dataValidation、cellStyles 或 richText;(2) 数据量较大(超过 5 行或超过 20 个单元格);(3) 数据来源为表格/CSV 文本/结构化文本。`csv-put` 无需手动构造二维 JSON 数组,字段值以 `=` 开头时按公式解析,并支持自动扩容
用户说"写入结构化 table/dataframe/带列类型和格式的数据/跨 sheet 写入表格":
- 写入结构化 table → `table-put`
- `table-put` 的 `--sheets` 是 table spec JSON,支持一次写入一个或多个工作表;适合有明确 `columns` / `data` / `dtypes` / `formats` 的数据
- table spec 较长或来自文件时,优先用 `--sheets @table.json`,避免 shell 转义长 JSON 出错
- 如果只是一段 CSV 文本且没有 dtype/format 需求,用 `csv-put` 更简单;如果需要公式、超链接、dataValidation、richText,用 `range update`
- 如果只是一段 CSV 文本且没有 dtype/format 需求,用 `csv-put` 更简单(可含 `=` 开头的公式);如果需要超链接、dataValidation、richText 或 cellStyles,用 `range update`
用户说"追加数据/添加行/在末尾加数据/新增记录":
- 追加数据 → `append`
用户说"批量写入CSV/导入CSV/CSV写入表格/把CSV贴到表格里":
- 写入 CSV → `csv-put`
- 与 `range update` 的区别:`csv-put` 接受 CSV 文本直接写入,无需手动构造二维 JSON 数组;适合大批量纯值写入
- 与 `range update` 的区别:`csv-put` 接受 CSV 文本直接写入,无需手动构造二维 JSON 数组;适合大批量值或公式写入,但不支持富格式对象
- 与 `append` 的区别:`csv-put` 写入指定位置(--start-cell),`append` 在末尾追加
**与现有写入能力的区别**:
- `range update` 面向精确单元格写入:适合公式、超链接、dataValidation、richText、少量 cellStyles 和 `{}` 跳过;必须自己提供 `--sheet-id`、`--range` 和维度完全匹配的二维 cell 对象
- `range update` 面向精确单元格对象写入:适合少量公式、超链接、dataValidation、richText、少量 cellStyles 和 `{}` 跳过;必须自己提供 `--sheet-id`、`--range` 和维度完全匹配的二维 cell 对象
- `append` 面向简单追加行:自动定位到末尾,只写原始值,不支持样式、公式、超链接或数据验证
- `csv-put` 面向大批量纯值导入:输入 CSV 文本,写入指定起点,适合数据来源本来就是 CSV/表格文本;不保留 dtype/format 协议,也不支持富格式
- `csv-put` 面向大批量 CSV 值/公式导入:输入 CSV 文本,写入指定起点;字段值以 `=` 开头时按公式解析,前加单引号时写入以 `=` 开头的字面文本;不保留 dtype/format 协议,也不支持富格式
- `table-put` 面向 dataframe/table 数据交换:输入 `columns` / `data` / `dtypes` / `formats`,可一次写多个 sheet,适合和 `table-get` 往返;不支持 dataValidation、hyperlink、richText、附件或图片
**四种写入命令能力对比**:
| 能力 | `range update` | `append` | `csv-put` | `table-put` |
|------|---------------|----------|-----------|-------------|
| 公式(`=` 开头) | 支持 | 不支持 | 不支持(当文本) | 不作为公式协议;公式请用 `range update` |
| 公式(`=` 开头) | 支持 | 不支持 | 支持;前导单引号写入以 `=` 开头的字面文本 | 不作为公式协议;公式请用 `range update` 或 `csv-put` |
| 单元格级超链接(`hyperlink`) | 支持 | 不支持 | 不支持 | 不支持 |
| 富文本(片段链接/附件/图片) | 支持 | 不支持 | 不支持 | 不支持 |
| richText 片段样式(bold/color) | 支持 | 不支持 | 不支持 | 不支持 |
@@ -92,7 +92,7 @@ Flags:
**单次调用建议**:行数 ≤ 1000,单元格总数(行×列)≤ 5000;超过时请拆分多次调用。
**何时该用 `csv-put` 替代**:如果你准备用 `range update` 写入纯值(不含公式、超链接、富文本对象),且数据量超过 5 行或 20 个单元格,应改用 `csv-put`——它接受 CSV 文本直接写入,无需手动拼装二维 JSON 数组,且支持自动扩容行列。仅在需要写入公式(`=SUM(...)`)、单元格级超链接、富文本对象或修改少量单元格时才使用 `range update`。
**何时该用 `csv-put` 替代**:如果你准备用 `range update` 写入大批量值或公式,数据可以表达为 CSV,且不需要单元格级超链接、富文本对象、样式或数据验证,应改用 `csv-put`——它无需手动拼装二维 JSON 数组,且支持自动扩容行列。需要富格式对象、`{}` 跳过或仅修改少量单元格时使用 `range update`。
**范围职责**:`range update` 负责写入单元格内容(原始值/公式/富文本对象),并支持通过 `cellStyles` 附带 per-cell 样式。如需批量设置整片区域的样式(不写值),请使用 `dws sheet range set-style`。
@@ -123,6 +123,9 @@ Example:
dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell A1 \
--csv 'name,score\nAlice,95\nBob,87'
dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell A1 \
--csv "=1+1,'=1+1"
dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell B2 \
--csv @data.csv --allow-overwrite
@@ -141,10 +144,10 @@ Flags:
将 RFC 4180 格式的 CSV 文本写入指定工作表的指定单元格位置。
- **分隔符必须是英文逗号 `,`**(ASCII 0x2C),禁止使用中文逗号 `,`(U+FF0C)。中文逗号不会被识别为分隔符,会导致整行被写入同一个单元格。生成 CSV 内容时务必检查分隔符
- 只写纯值,不支持公式/样式/批注。`=` 开头的内容当文本处理,不会被解析为公式
- 写入值和公式,不支持样式/批注。字段值以 `=` 开头时默认按公式解析;如需写入以 `=` 开头的字面文本,在字段值前加单引号(例如 `'=1+1`)
- 数字/日期/百分数由表格系统自动识别类型(如 `95` 存为数字,`2025-03-01` 存为日期)
- 自动扩容行列:CSV 数据超出当前工作表维度时自动追加行/列
- 与 `range update` 不同,目标区域如含合并单元格,`csv-put` 会打散合并并写入纯值
- 与 `range update` 不同,目标区域如含合并单元格,`csv-put` 会打散合并并写入 CSV 数据
- 若需要保留原有合并结构,写入前先用 `sheet info` 记录 `mergedRanges`,写入后用 `merge-cells` 恢复对应区域
- `--allow-overwrite` 默认 false,目标区域有数据时需显式传 `--allow-overwrite` 才能覆盖
- `--csv` 支持三种输入:直接传文本、`@filepath` 从本地文件读取、`-` 从 stdin 管道读取
@@ -545,7 +548,7 @@ dws sheet append --node <NODE_ID> --sheet-id <SHEET_ID> \
- **dataValidation 三语义**:不传字段=保留;`{type:"none"}`=清除;`{type:"dropdown"/"checkbox",...}`=覆盖。无需先 read 再回传,系统会保留原 DV
- **hyperlink 三语义**:不传字段=保留;`{type:"none"}`=清除;`{type:"path"/"sheet"/"range",...}`=覆盖。Agent 调用不要使用 `hyperlink:null`
- ★ **单次调用上限(强制)**:`range update` / `set-style` 行数 ≤ 1000,单元格总数建议 ≤ 5000(硬限 30000)
- ★ **大批量纯值写入用 `csv-put` 不用 `range update`**:当写入纯值(无公式、无超链接、无富文本对象)且数据量较大时(>5 行或 >20 单元格),必须使用 `csv-put`。`csv-put` 接受 CSV 文本直接写入,无需构造二维 JSON 数组,支持自动扩容,更简洁高效。仅在需要写入公式、单元格级超链接、富文本对象,或仅更新少量单元格时才使用 `range update`
- ★ **大批量 CSV 值/公式写入用 `csv-put` 不用 `range update`**:当数据可表达为 CSV(可含 `=` 开头的公式)、不需要超链接或富文本对象且数据量较大时(>5 行或 >20 单元格),必须使用 `csv-put`。它无需构造二维 JSON 数组并支持自动扩容。需要单元格级超链接、富文本对象、样式、数据验证、`{}` 跳过,或仅更新少量单元格时使用 `range update`
- ★ **公式写完必须分层校验**:先用 `range read --value-render-option formula` 确认公式文本,再用 `formula-verify` 聚合扫描错误;若返回 `partial` / `hasMore=true` 必须继续校验,关键业务数值还要用 `raw_value` 抽样对账
- ★ **结构化 table 写入用 `table-put`**:需要列名、dtype、number format、跨 sheet specs 或 `table-get` 回写形态时使用 `table-put`;单次写入单元格数硬限 30000,包含表头
- ★ **大整数/长数字 ID 必须按文本写(强制)**:超过 `9007199254740991` 的整数、订单号、手机号、SKU 等需要逐位精确的值,不要用 JSON number,也不要声明 `int64` / `uint64`。用字符串值 + `object` dtype + 文本格式 `@`;写后用 `table-get` 或 `range read --value-render-option raw_value` 回读确认
@@ -554,4 +557,4 @@ dws sheet append --node <NODE_ID> --sheet-id <SHEET_ID> \
- `append` 自动定位到最后一行有数据的位置下方插入,无需手动计算行号
- `append` 的 `--values` 二维数组中每行的列数必须一致,否则会报错。如果用户提供的数据中各行长度不同,必须先将短行用空字符串 `""` 补齐到与最长行相同的列数后再调用。追加的数据列数也应与工作表已有数据列数保持一致
- `append` vs `range update`:追加新行用 `append`,修改已有单元格用 `range update`
- ★ **`append` / `csv-put` / `table-put` 不支持 `{}` skip、dataValidation、富文本、超链接**:这些能力仅限 `range update`。`append` 和 `csv-put` 只接受原始值;`table-put` 接受 table 矩阵、dtypes、formats 和基础 `cellStyles`。需要写入公式、超链接、下拉列表或跳过部分单元格时,必须使用 `range update`
- ★ **`append` / `csv-put` / `table-put` 不支持 `{}` skip、dataValidation、富文本、超链接**:这些能力仅限 `range update`。`append` 只接受原始值;`csv-put` 接受 CSV 值和公式;`table-put` 接受 table 矩阵、dtypes、formats 和基础 `cellStyles`。需要超链接、下拉列表、富文本或跳过部分单元格时,必须使用 `range update`
+55
View File
@@ -384,6 +384,61 @@ func TestReleaseChangelogExtractionAllowsLowercaseTodoProductName(t *testing.T)
}
}
func TestInterfaceIntegrityWorkflowContract(t *testing.T) {
root, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "ci.yml"))
if err != nil {
t.Fatalf("ReadFile(ci.yml) error = %v", err)
}
workflow := string(data)
interfaceStart := strings.Index(workflow, "\n interface-integrity:\n")
interfaceEnd := strings.Index(workflow, "\n cli-smoke:\n")
if interfaceStart < 0 || interfaceEnd <= interfaceStart {
t.Fatal("Code Admission workflow missing Interface Integrity job boundaries")
}
interfaceJob := workflow[interfaceStart:interfaceEnd]
for _, want := range []string{
"name: Check complete CLI command compatibility",
"./scripts/policy/check-command-compatibility.sh",
`--base-ref "$COMPATIBILITY_BASE_REF"`,
`--stable-ref "$COMPATIBILITY_STABLE_REF"`,
} {
if !strings.Contains(interfaceJob, want) {
t.Errorf("Interface Integrity job missing release-equivalent compatibility contract %q", want)
}
}
}
func TestInterfaceSensitiveClassificationCoversCoreCommandFramework(t *testing.T) {
root, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "ci.yml"))
if err != nil {
t.Fatalf("ReadFile(ci.yml) error = %v", err)
}
workflow := string(data)
start := strings.Index(workflow, " const isInterfaceSensitive =")
end := strings.Index(workflow, " const isMCPSensitive =")
if start < 0 || end <= start {
t.Fatal("Code Admission workflow missing interface-sensitive classifier boundaries")
}
classifier := strings.TrimSpace(workflow[start:end])
probe := classifier + `
if (!isInterfaceSensitive("internal/corecmd/corecmd.go")) {
throw new Error("existing corecmd files must trigger Interface Integrity");
}
`
cmd := exec.Command("node", "-e", probe)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("interface-sensitive classifier rejected corecmd change: %v\n%s", err, output)
}
}
func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
root, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
+7 -3
View File
@@ -845,7 +845,7 @@ print(json.dumps({
}))
PY
;;
*/actions/runs/42/attempts/1/jobs*)
*/actions/runs/42/attempts/*/jobs*)
python3 - <<'PY'
import json
import os
@@ -891,13 +891,13 @@ for name in required:
print(json.dumps({"jobs": jobs}))
PY
;;
*/actions/runs/42/attempts/1)
*/actions/runs/42|*/actions/runs/42/attempts/1)
python3 - <<'PY'
import json
import os
print(json.dumps({
"id": 42,
"run_attempt": 1,
"run_attempt": int(os.environ.get("RUN_ATTEMPT", "1")),
"repository": {"full_name": "owner/repo"},
"path": ".github/workflows/release.yml",
"event": "workflow_dispatch",
@@ -943,6 +943,10 @@ esac
if output, err := run(); err != nil || !strings.Contains(output, "cloud release run 42") {
t.Fatalf("tag-bound cloud delivery was rejected: err=%v\noutput:\n%s", err, output)
}
if output, err := run("RUN_ATTEMPT=2"); err != nil ||
!strings.Contains(output, "cloud release run 42") {
t.Fatalf("successful rerun of tag-bound cloud delivery was rejected: err=%v\noutput:\n%s", err, output)
}
if output, err := runWithArgs(
[]string{"--channel-repair", "oss", tag, commit},
); err != nil || !strings.Contains(output, "cloud release run 42") {