Compare commits

..
Author SHA1 Message Date
Dennis 415ce12905 fix(wiki): close shortcut review gaps 2026-08-14 14:51:26 +08:00
Dennis a1d223015d docs(wiki): keep review product-neutral 2026-08-14 14:33:14 +08:00
Dennis 0283cb4f79 feat(wiki): align and harden shortcut workflows 2026-08-14 14:33:12 +08:00
66 changed files with 3309 additions and 3939 deletions
-7
View File
@@ -1,7 +0,0 @@
---
category: Changed
---
- **Chat IM ID flags** (#954) — standardizes chat command entry points on `--conversation-id` for conversation IDs and `--message-id` for message IDs, so help, Schema, and Agent recommendations use the same canonical flags.
- **Legacy chat flag compatibility** (#954) — keeps older chat IM ID flags such as `--group`, `--id`, `--chat`, `--open-conversation-id`, `--msg-id`, and `--open-message-id` working as compatibility aliases where applicable, while hiding migrated aliases from recommended help and Schema surfaces.
- **Chat group bots target flag** (#954) — keeps `dws chat group bots` on the visible `--group` flag; this command does not register `--group-name`, and `--group` accepts either an openConversationId or a uniquely resolved group name.
@@ -1,20 +0,0 @@
---
category: Fixed
---
- **Drive `--latest` refuses incomplete Top-N** (#899) — `dws drive list --latest` used to
exit 0 with a "Top-N" computed over a partially scanned tree whenever a directory read
failed mid-recursion (permission denied, API error), letting an incomplete set pose as the
globally newest files. Truncation at the 2000-item scan cap and mid-recursion directory
failures now both fail closed (`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`), report
the first failing folder with its depth and reason, and emit a recovery command that
reproduces the original candidate set — query domain, `--folder`, `--pattern`, `--type`,
`--start` and `--end` are all carried over. On POSIX shells each user-supplied value is
quoted so a URL query string or a shell metacharacter cannot change how the copied command
parses. On Windows no quoting form is safe for both `cmd.exe` and PowerShell, so values
containing metacharacters are not inlined at all: the command carries a placeholder and the
original value is shown on a separate line marked as data rather than an executable command.
Unrecoverable errors under `--latest` return the root cause instead of a partial result.
Remote-controlled folder names and server error text are stripped of ANSI escapes and
control characters before they reach the plain-text stderr message. The internal `sortTime`
sort key no longer leaks into `drive list --depth` output on any path.
+9
View File
@@ -0,0 +1,9 @@
---
category: Added
---
- **Wiki Shortcut workflows** — publishes 20 reviewed space, member, node, and
activity shortcuts with strict collection validation, cursor handling,
write-terminal evidence, safe read-backs where the backend supports them,
task-oriented routing, and documented backend
boundaries.
+38 -173
View File
@@ -911,7 +911,7 @@ jobs:
coverage-current:
name: Coverage (current)
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite != 'true' }}
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
@@ -926,6 +926,10 @@ jobs:
with:
go-version-file: go.mod
- name: Install archive tooling
if: needs.lint.outputs.full_suite == 'true'
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Resolve authoritative coverage base
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
@@ -947,33 +951,41 @@ jobs:
- name: Build
run: make build
- name: Run scoped unit tests with coverage
- name: Run current unit tests with coverage
shell: bash
env:
DWS_PACKAGE_VERSION: 0.0.0-test
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
run: |
set -euo pipefail
changed_output="$(
./scripts/ci/changed-test-packages.sh \
changed "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
)"
impacted_output="$(
./scripts/ci/changed-test-packages.sh \
list "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
)"
if [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then
printf 'mode: atomic\n' > coverage.txt
echo "No buildable Go package needs scoped coverage." \
>> "$GITHUB_STEP_SUMMARY"
else
mapfile -t changed_packages <<< "$changed_output"
mapfile -t impacted_packages <<< "$impacted_output"
coverpkg="$(IFS=,; echo "${changed_packages[*]}")"
if [ "$FULL_SUITE" = true ]; then
go test -count=1 -p 1 \
-coverpkg="$coverpkg" \
-coverprofile=coverage.txt \
-covermode=atomic \
"${impacted_packages[@]}"
./ ./cmd/... ./internal/... ./skills/...
else
changed_output="$(
./scripts/ci/changed-test-packages.sh \
changed "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
)"
impacted_output="$(
./scripts/ci/changed-test-packages.sh \
list "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
)"
if [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then
printf 'mode: atomic\n' > coverage.txt
echo "No buildable Go package needs scoped coverage." \
>> "$GITHUB_STEP_SUMMARY"
else
mapfile -t changed_packages <<< "$changed_output"
mapfile -t impacted_packages <<< "$impacted_output"
coverpkg="$(IFS=,; echo "${changed_packages[*]}")"
go test -count=1 -p 1 \
-coverpkg="$coverpkg" \
-coverprofile=coverage.txt \
-covermode=atomic \
"${impacted_packages[@]}"
fi
fi
if [ "$(wc -l < coverage.txt)" -gt 1 ]; then
go tool cover -func=coverage.txt
@@ -986,66 +998,6 @@ jobs:
path: coverage.txt
retention-days: 1
coverage-current-full:
name: "Coverage (current: ${{ matrix.shard }})"
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shard:
- app
- cli
- generators
- helpers
- remaining
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Build
run: make build
# Each shard keeps -p 1 so the authoritative measurement stays serial
# inside one instrumented process group; shards run on isolated runners,
# and scripts/ci/test-packages.sh verify proves the shard union equals
# the previous single full-suite package set exactly once.
- name: Run current shard tests with coverage
shell: bash
env:
DWS_PACKAGE_VERSION: 0.0.0-test
COVERAGE_SHARD: ${{ matrix.shard }}
run: |
set -euo pipefail
package_output="$(./scripts/ci/test-packages.sh list-coverage "$COVERAGE_SHARD")"
test -n "$package_output"
mapfile -t packages <<< "$package_output"
test "${#packages[@]}" -gt 0
go test -count=1 -p 1 \
-coverprofile="coverage-shard-$COVERAGE_SHARD.txt" \
-covermode=atomic \
"${packages[@]}"
go tool cover -func="coverage-shard-$COVERAGE_SHARD.txt" | tail -n 1
- name: Upload current shard coverage profile
uses: actions/upload-artifact@v4
with:
name: coverage-current-shard-${{ matrix.shard }}
path: coverage-shard-${{ matrix.shard }}.txt
retention-days: 1
coverage-supporting:
name: Coverage (supporting)
needs: lint
@@ -1099,11 +1051,14 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
id: setup-go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install archive tooling
if: needs.lint.outputs.full_suite == 'true'
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Resolve authoritative coverage base
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
@@ -1121,34 +1076,7 @@ jobs:
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
# The merge-base full-suite profile is a pure function of the base
# commit. Reuse the profile published by the last green push run of
# exactly that commit instead of re-running the whole suite; any key
# mismatch falls back to authoritative recomputation. Exact key only,
# never prefix fallback: a near-miss profile would compare the
# candidate against the wrong commit.
- name: Restore cached merge-base coverage profile
id: baseline-cache
if: needs.lint.outputs.full_suite == 'true'
uses: actions/cache/restore@v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }}
- name: Materialize cached merge-base coverage profile
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit == 'true'
run: |
set -eu
test -s coverage-cache.txt
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
cp coverage-cache.txt coverage-base.txt
- name: Install archive tooling
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Run baseline unit tests with coverage
if: steps.baseline-cache.outputs.cache-hit != 'true'
shell: bash
env:
DWS_PACKAGE_VERSION: 0.0.0-test
@@ -1197,21 +1125,6 @@ jobs:
fi
)
- name: Prepare merge-base coverage profile cache
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
run: |
set -eu
test -s coverage-base.txt
test "$(head -n 1 coverage-base.txt)" = "mode: atomic"
cp coverage-base.txt coverage-cache.txt
- name: Save merge-base coverage profile cache
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }}
- name: Upload baseline coverage profile
uses: actions/upload-artifact@v4
with:
@@ -1224,7 +1137,6 @@ jobs:
needs:
- lint
- coverage-current
- coverage-current-full
- coverage-supporting
- coverage-baseline
- coverage-darwin
@@ -1240,7 +1152,6 @@ jobs:
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }}
CURRENT_RESULT: ${{ needs.coverage-current.result }}
CURRENT_FULL_RESULT: ${{ needs.coverage-current-full.result }}
SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }}
BASELINE_RESULT: ${{ needs.coverage-baseline.result }}
DARWIN_RESULT: ${{ needs.coverage-darwin.result }}
@@ -1248,7 +1159,6 @@ jobs:
run: |
failed=0
current_expected=success
current_full_expected=skipped
supporting_expected=skipped
baseline_expected=success
native_expected=skipped
@@ -1256,8 +1166,6 @@ jobs:
current_expected=skipped
baseline_expected=skipped
elif [ "$FULL_SUITE" = true ]; then
current_expected=skipped
current_full_expected=success
supporting_expected=success
fi
if [ "$CHANGELOG_ONLY" != true ] &&
@@ -1268,7 +1176,6 @@ jobs:
for profile in \
"current:$CURRENT_RESULT:$current_expected" \
"current shards:$CURRENT_FULL_RESULT:$current_full_expected" \
"supporting:$SUPPORTING_RESULT:$supporting_expected" \
"baseline:$BASELINE_RESULT:$baseline_expected"
do
@@ -1304,7 +1211,6 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
id: setup-go
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
uses: actions/setup-go@v5
with:
@@ -1328,12 +1234,11 @@ jobs:
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
- name: Download current coverage profiles
- name: Download current coverage profile
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
uses: actions/download-artifact@v4
with:
pattern: coverage-current-*
merge-multiple: true
name: coverage-current-profile
path: .
- name: Download supporting coverage profiles
@@ -1350,26 +1255,6 @@ jobs:
name: coverage-baseline-profile
path: .
# Shard profiles cover disjoint package sets, so their block-level
# concatenation is the same candidate profile one serial run produced.
# Every expected shard must be present; a missing shard would silently
# shrink the scope-matched overall comparison.
- name: Assemble full-suite coverage profile
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
shell: bash
run: |
set -euo pipefail
test ! -f coverage.txt
for shard in app cli generators helpers remaining; do
profile="coverage-shard-$shard.txt"
test -f "$profile"
test "$(head -n 1 "$profile")" = "mode: atomic"
done
printf 'mode: atomic\n' > coverage.txt
for shard in app cli generators helpers remaining; do
tail -n +2 "coverage-shard-$shard.txt" >> coverage.txt
done
- name: Enforce coverage gate
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
env:
@@ -1390,26 +1275,6 @@ jobs:
COVERAGE_ADDITIONAL_DIFF_PROFILE="$additional_profile" \
make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
# Publish this push's full-suite profile as the merge-base cache for
# future PRs whose merge-base is exactly this commit. Saved only after
# the gate passed so a broken run never becomes a baseline. Both producer
# and consumer use coverage-cache.txt because the cache version includes
# the configured path as well as the compression tool.
- name: Prepare push coverage profile as merge-base cache
if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
run: |
set -eu
test -s coverage.txt
test "$(head -n 1 coverage.txt)" = "mode: atomic"
cp coverage.txt coverage-cache.txt
- name: Save push coverage profile as merge-base cache
if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
uses: actions/cache/save@v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }}
- name: Generate coverage report
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
run: |
-64
View File
@@ -6,70 +6,6 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.59-beta.1] - 2026-08-14
### Added
- **Drive list type/time filtering** (#942) — `dws drive list` gains `--type
file|folder`, `--start`, and `--end` for client-side filtering by node type
and modification time on both the pan and workspace routes. Filtering runs
a bounded full scan of the target directory (2000-entry cap, reported via
`truncated=true`), composes with `--latest`/`--pattern`/`--depth`, and is
mutually exclusive with `--versions`/`--cursor`/`--order-by`/`--order`/
`--limit`. Time values accept relative forms (`24h`/`7d`/`2w`), RFC 3339,
zone-less ISO 8601 (Asia/Shanghai), or a plain date.
- **Drive folder synchronization** — adds `dws drive status`, `dws drive pull`,
`dws drive push`, and `dws drive sync` for file-level comparison and transfer
between a local folder and a Drive folder. Differences come from exact MD5 by
default or from modification time with `--quick`; `status` is read-only, `pull`
and `push` are one-directional with `--if-exists skip|smart|overwrite`, and
`sync` is bidirectional with `--on-conflict remote-wins|local-wins|keep-both|ask`.
Only regular files are transferred — online documents and shortcuts are skipped,
neither side deletes extra files, downloads are staged through a temporary file
and committed with an atomic rename, and remote names that would escape
`--local-folder` are reported as failures instead of being written. Every command
prints a structured summary on stdout and exits non-zero when any item fails.
- **International DingTalk region support** — adds `.io` login and MCP routing, pre-release endpoint overrides, and profile-aware gateway selection while preserving the existing `.com` flow.
### Changed
- **Chat identity routing** — validates explicit `openDingTalkId` inputs and improves name, `userId`, and `openDingTalkId` routing for message shortcuts.
### Fixed
- **Drive `--latest` refuses incomplete Top-N** (#899) — `dws drive list --latest` used to
exit 0 with a "Top-N" computed over a partially scanned tree whenever a directory read
failed mid-recursion (permission denied, API error), letting an incomplete set pose as the
globally newest files. Truncation at the 2000-item scan cap and mid-recursion directory
failures now both fail closed (`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`), report
the first failing folder with its depth and reason, and emit a recovery command that
reproduces the original candidate set — query domain, `--folder`, `--pattern`, `--type`,
`--start` and `--end` are all carried over. On POSIX shells each user-supplied value is
quoted so a URL query string or a shell metacharacter cannot change how the copied command
parses. On Windows no quoting form is safe for both `cmd.exe` and PowerShell, so values
containing metacharacters are not inlined at all: the command carries a placeholder and the
original value is shown on a separate line marked as data rather than an executable command.
Unrecoverable errors under `--latest` return the root cause instead of a partial result.
Remote-controlled folder names and server error text are stripped of ANSI escapes and
control characters before they reach the plain-text stderr message. The internal `sortTime`
sort key no longer leaks into `drive list --depth` output on any path.
- **Drive list pattern filtering** (#942) — `dws drive list --pattern` on the
single-layer pan route now filters the returned page by name pattern; the
flag was previously accepted but silently ignored.
- **Drive list `--type folder --latest` composition** (#942) — `--latest` now
ranks the filtered entries (folders included when `--type folder` is set)
instead of unconditionally dropping folders, so the documented combination
returns the most recently modified folders rather than an empty list.
- **Chat message time defaults** (#973) — default omitted `chat message list-all` time bounds in `Asia/Shanghai` when emitting timezone-less `yyyy-MM-dd HH:mm:ss` values, matching parsing semantics and rejecting reversed windows.
- **Doc and Drive parameter aliases** — normalizes reviewed identifier, pagination, path, version, and role synonyms while blocking ambiguous values before dispatch.
## [1.0.58] - 2026-08-13
This release promotes the sealed `v1.0.58-beta.6` contents to stable.
+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.59-beta.1"
version "1.0.58-beta.6"
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.59-beta.1/dws-darwin-arm64.tar.gz"
sha256 "36a30f3496e0f759c15c0b09f67dbd23b8ecdfff2eebe572f88125b26485830f"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-darwin-arm64.tar.gz"
sha256 "8f55497b84113f81b318e087c723016a02eded1cb5784cbd0811fe527d5852ca"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-darwin-amd64.tar.gz"
sha256 "e7a04906380efd8da88cd112e6a512bb6470a3956dc370150037ed6e314db445"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-darwin-amd64.tar.gz"
sha256 "b1762f1640310fb4100634fe54d9baace46384bb270c59148fe306275554d491"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-arm64.tar.gz"
sha256 "f59ab055f3e841e4cebc964ae3ef969668475548abaaf8bede44afdca9a3e28d"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-linux-arm64.tar.gz"
sha256 "55393310ef0e1f24ea2c0dc22f00c0eb3b343edc2deb18d0db7838cda65af25d"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-amd64.tar.gz"
sha256 "2c8f919489d958c7d49262615e81faac70a9fbcae2d589ab54a0bb3c5700a057"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-linux-amd64.tar.gz"
sha256 "3830f77d09b4da4aa39f0c08d772ff3fa1ffb837eb15bb417f97edbccb073b7d"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-skills.zip"
sha256 "25f4a7e1d01fa4d771d79201b34b11ee8a24182bdcdc94bfb98d2bd5845bed3b"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-skills.zip"
sha256 "f304a883a4f9e938b26a44692cd5a8d3d8704ba70ee7f33ba7c288434da72b6f"
end
def install
+1 -1
View File
@@ -18,7 +18,7 @@ help:
@printf "Available targets:\n"
@printf " make build - Build the dws CLI binary\n"
@printf " make test - Run the Go test suite\n"
@printf " make test-plan - Verify CI test and full-suite coverage package plans cover their scopes exactly once\n"
@printf " make test-plan - Verify every default Go package belongs to one CI test shard\n"
@printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\n"
@printf " make lint - Run formatting checks, go vet, and staticcheck\n"
@printf " make format-check - Check all repository Go source files with gofmt\n"
+2 -15
View File
@@ -201,21 +201,8 @@ base_ref=$(git merge-base HEAD origin/main)
standard PR, CI derives changed packages and their reverse-dependency test
closure, then generates candidate and merge-base profiles with the same test
scope and `coverpkg`. High-risk and protected-main runs use the complete
profiles. The complete candidate profile is produced by disjoint per-shard
helper jobs (`scripts/ci/test-packages.sh list-coverage`, kept serial with
`-p 1` inside each shard; `verify` proves the shard union equals the
full-suite scope exactly once) and concatenated in the aggregate job before
enforcement. The complete merge-base profile is restored from an exact-key
cache written by the last green `main` push of that same commit (key:
merge-base SHA plus resolved Go version); any miss falls back to recomputing
it in a merge-base worktree. The trusted `main` producer and PR consumer use
the same dedicated cache profile path because GitHub includes that path in the
cache version; the runtime-facing candidate and baseline filenames remain
separate. Near-miss reuse is forbidden — the caches carry no prefix restore
keys, because a neighbouring commit's profile would compare the candidate
against the wrong baseline. Supporting and (when
platform-selected) native profiles are generated before the aggregate
`Coverage` context evaluates them. The
profiles. Supporting and (when platform-selected) native profiles are
generated before the aggregate `Coverage` context evaluates them. The
aggregate and native gates require 100% coverage for changed executable Go
statements. Overall coverage remains an unrounded, zero-tolerance,
scope-matched merge-base non-regression check. Candidate and baseline profiles
+197 -4
View File
@@ -1,6 +1,6 @@
{
"generated_at": "2026-08-12T00:10:44.511794",
"count": 399,
"generated_at": "2026-08-14T13:50:36.324505",
"count": 418,
"results": [
{
"suite": "semantic",
@@ -3659,11 +3659,204 @@
"status": "real-ok"
},
{
"suite": "read",
"suite": "semantic",
"service": "wiki",
"command": "+delete-space",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "删除前读取影响目标,经高风险确认后只接受 success=true 终态。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+feed-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "补充知识库协作动态查询,严格验证 feeds 并保留游标。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+member-add",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "支持 1-30 个 userId 与四类角色;严格要求写接口终态,并明确后端无法提供精确成员读回。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+member-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证成员数组并公开真实单次上限 50;后端无游标时不伪造 page-all。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+member-remove",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "支持批量 userId 移除;严格要求写接口终态,并明确后端无法提供精确成员读回。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+member-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "补充成员角色更新;严格要求写接口终态,并明确后端无法提供精确成员读回。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+move",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "同一入口覆盖 Wiki 内移动和我的文档在线节点入 Wiki,并校验目标 workspace/folder。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+move-to-drive",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "同步移动 Wiki 节点到我的文档并读回验证 workspace 变化,免去异步任务轮询。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+node-copy",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "高风险确认后复制,必须取得新 nodeId 并读回副本,避免空响应被当作成功。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+node-create",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "支持七种 Wiki 节点类型,创建后要求 nodeId 并读取元数据验证。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+node-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "删除前读取并核对 workspace,经高风险确认后要求 success=true 终态证据。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+node-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "统一节点 ID 或在线文档 URL 的元数据读取,补充文档域属性视角。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+node-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格区分显式空目录与假空成功,稳定投影节点并保留 nextCursor/hasMore。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+node-search",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "补充库内关键词和扩展名搜索,并拒绝假空结果。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+resolve-space",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "把关键词搜索收敛为唯一 workspaceId;零命中与多命中显式分流,绝不猜测。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+space-create",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "创建后要求 workspaceId 并通过空间详情读回验证真实落库。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+space-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "补充知识库详情入口,并要求 workspaceId 业务证据。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+space-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格区分显式空知识库列表与缺失、畸形或内部错误响应,并保留真实分页证据。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+space-search",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按关键词搜索知识库并拒绝把缺失业务数组误报为零命中。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
"command": "+wiki-new-doc",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按空间名精确唯一解析、创建在线文档并读回验证;零命中和歧义均显式失败。",
"availability": "available"
}
]
}
+116
View File
@@ -0,0 +1,116 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>DWS Wiki Shortcut 全景评审</title>
<style>
:root{--ink:#14213d;--muted:#5c677d;--line:#dbe4f0;--paper:#fff;--bg:#f3f7fb;--blue:#1769e0;--cyan:#00a6a6;--green:#178746;--amber:#a45b00;--red:#b42318;--shadow:0 14px 34px rgba(20,33,61,.08)}
*{box-sizing:border-box}body{margin:0;overflow-x:hidden;background:linear-gradient(150deg,#edf5ff 0,#f8fbff 45%,#eef8f5 100%);color:var(--ink);font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}
main,.card,.two>*{min-width:0}main{width:min(1180px,calc(100% - 32px));margin:28px auto 72px}.hero,.card{background:rgba(255,255,255,.96);border:1px solid var(--line);border-radius:22px;box-shadow:var(--shadow)}
.hero{padding:38px;background:radial-gradient(circle at 95% 0,#dff8f3,transparent 36%),linear-gradient(135deg,#fff,#f5f9ff)}h1{font-size:34px;line-height:1.2;margin:0 0 10px}.lead{font-size:17px;color:var(--muted);max-width:900px}.meta{display:flex;gap:10px;flex-wrap:wrap;margin-top:20px}.pill{border:1px solid #cbd9ea;border-radius:999px;padding:5px 11px;background:#fff;font-size:13px}
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:18px 0}.metric{padding:20px}.metric b{display:block;font-size:31px;color:var(--blue)}.metric span{color:var(--muted)}
section{margin-top:22px}.card{padding:26px}h2{font-size:23px;margin:0 0 14px}h3{font-size:17px;margin:22px 0 8px}.callout{border-left:4px solid var(--blue);background:#f2f7ff;padding:14px 16px;border-radius:8px}.warn{border-color:var(--amber);background:#fff8eb}.ok{border-color:var(--green);background:#effbf4}
table{width:100%;border-collapse:collapse;font-size:14px}th,td{text-align:left;vertical-align:top;border-bottom:1px solid var(--line);padding:11px 9px}th{color:#41516b;background:#f7f9fc;position:sticky;top:0}code{background:#edf2f8;border-radius:5px;padding:2px 5px;color:#24466e}.tag{display:inline-block;border-radius:999px;padding:2px 8px;font-size:12px;font-weight:650;white-space:nowrap}.full{background:#e6f6ec;color:#116436}.partial{background:#fff0d5;color:#875000}.extra{background:#e8f1ff;color:#1854a5}.fixed{background:#f1eaff;color:#6338a5}
.toolbar{display:flex;flex-wrap:wrap;gap:10px;margin:12px 0}.toolbar input,.toolbar select{border:1px solid #bdcada;border-radius:10px;padding:9px 11px;background:#fff;min-width:min(220px,100%);max-width:100%;flex:1 1 220px}.matrix{max-height:620px;overflow:auto;border:1px solid var(--line);border-radius:12px}.two{display:grid;grid-template-columns:1fr 1fr;gap:18px}.small{color:var(--muted);font-size:13px}ul{padding-left:20px}.footer{color:var(--muted);text-align:center;margin-top:22px}@media(max-width:850px){.grid,.two{grid-template-columns:1fr 1fr}.hero{padding:25px}}@media(max-width:560px){.grid,.two{grid-template-columns:1fr}main{width:min(100% - 18px,1180px)}.card{padding:18px}h1{font-size:28px}}
</style>
</head>
<body><main>
<header class="hero">
<h1>DWS Wiki Shortcut 全景评审</h1>
<p class="lead">以 13 项成熟 Wiki 用户任务为基线,重新审视 DWS 的空间、成员、节点与动态能力。本次不是按命令名凑数:每个入口都要求真实业务证据,缺失数组、畸形响应、空确认或读回不一致一律失败。</p>
<div class="meta"><span class="pill">评审日期 2026-08-14</span><span class="pill">独立 worktree / 独立分支</span><span class="pill">真实组织数据 E2E 28/28</span><span class="pill">报告已去标识化</span></div>
</header>
<div class="grid">
<div class="card metric"><b>20</b><span>公开 Wiki Shortcuts</span></div>
<div class="card metric"><b>13/13</b><span>基线用户任务有对应路径</span></div>
<div class="card metric"><b>7</b><span>DWS 额外场景</span></div>
<div class="card metric"><b>20/20</b><span>真实数据能力已触达</span></div>
</div>
<section class="card">
<h2>结论先行</h2>
<div class="callout ok"><strong>DWS 已形成比“API 快捷别名”更完整的 Wiki 任务层。</strong> 基线中的 13 个用户任务均有对应入口;DWS 还提供空间搜索/详情/唯一解析、成员角色更新、库内节点搜索、协作动态和按空间名新建文档。创建、复制、移动等关键写能力从“请求发出”升级为“终态 + ID + 读回”成功标准。</div>
<div class="callout warn" style="margin-top:12px"><strong>能力边界必须诚实表达。</strong> DingTalk 成员接口不提供游标,单次真实上限是 50,因此不能实现成员 <code>--page-all</code>;成员身份只接受同组织可用的 userId,无法提供 email/open_id 等多种身份模式;节点创建也没有等价的 origin/shortcut 模式。这些差异保留为明确边界,而不是用本地循环或空结果伪装。</div>
</section>
<section class="card">
<h2>13 项基线任务逐条映射</h2>
<div class="matrix"><table><thead><tr><th>基线任务</th><th>DWS 主入口</th><th>结论</th><th>DWS 视角与边界</th></tr></thead><tbody>
<tr><td><code>+space-list</code></td><td><code>wiki +space-list</code></td><td><span class="tag full">完整对齐</span></td><td>严格空集合、游标续传、自动翻页、停滞检测;支持组织/我的知识库。</td></tr>
<tr><td><code>+space-create</code></td><td><code>wiki +space-create</code></td><td><span class="tag full">超过</span></td><td>公开真实 32 字符名称上限;创建后按 workspaceId 读回。</td></tr>
<tr><td><code>+delete-space</code></td><td><code>wiki +delete-space</code></td><td><span class="tag full">超过</span></td><td>预读目标、高风险确认、只接受 <code>success=true</code>;兼容 <code>+space-delete</code>。</td></tr>
<tr><td><code>+member-add</code></td><td><code>wiki +member-add</code></td><td><span class="tag partial">任务对齐</span></td><td>支持 1–30 个 userId 与四种角色;以写接口终态作为成功证据,不把最多 50 条的名单误作精确读回。</td></tr>
<tr><td><code>+member-list</code></td><td><code>wiki +member-list</code></td><td><span class="tag partial">任务对齐</span></td><td>严格成员数组、角色过滤、真实上限 50;后端无游标,不能提供诚实的 page-all。</td></tr>
<tr><td><code>+member-remove</code></td><td><code>wiki +member-remove</code></td><td><span class="tag partial">任务对齐</span></td><td>支持批量 userId;只接受写接口明确终态,并公开无法进行精确成员读回的边界。</td></tr>
<tr><td><code>+node-list</code></td><td><code>wiki +node-list</code></td><td><span class="tag full">完整对齐</span></td><td>正确跨域路由 doc/list_nodes,严格空目录、分页与自动翻页。</td></tr>
<tr><td><code>+node-get</code></td><td><code>wiki +node-get</code></td><td><span class="tag partial">任务对齐</span></td><td>支持 DingTalk 节点 ID/在线文档 URL 并返回文档域元数据;不接受跨平台专用的 token/type 组合。</td></tr>
<tr><td><code>+node-create</code></td><td><code>wiki +node-create</code></td><td><span class="tag partial">任务对齐</span></td><td>支持 adoc/axls/able/appt/adraw/amind/folder 并读回;无 origin/shortcut 等价接口。</td></tr>
<tr><td><code>+node-copy</code></td><td><code>wiki +node-copy</code></td><td><span class="tag full">超过</span></td><td>确认后要求新 nodeId 并读取副本;底层面向在线节点,不把 .dlink 当独立副本。</td></tr>
<tr><td><code>+move</code></td><td><code>wiki +move</code></td><td><span class="tag partial">任务对齐</span></td><td>同一入口支持 Wiki 内移动和“我的文档”在线节点入 Wiki,读回 workspace/folder;底层接口没有 apply 权限迁移开关。</td></tr>
<tr><td><code>+move-to-drive</code></td><td><code>wiki +move-to-drive</code></td><td><span class="tag full">超过</span></td><td>DWS 当前接口同步完成并读回 workspace 变化,无需暴露异步 task 轮询。</td></tr>
<tr><td><code>+node-delete</code></td><td><code>wiki +node-delete</code></td><td><span class="tag full">超过</span></td><td>预读并核对 workspace,高风险确认,要求删除终态。</td></tr>
</tbody></table></div>
</section>
<section class="card">
<h2>DWS 可挖掘的 7 个额外场景</h2>
<div class="two">
<div><h3>定位与创建链</h3><ul><li><code>+space-search</code>:严格关键词搜索。</li><li><code>+space-get</code>:空间详情与 workspaceId 证据。</li><li><code>+resolve-space</code>:唯一命中直出 ID,多命中拒绝猜测。</li><li><code>+wiki-new-doc</code>:空间名解析 → 创建 → 文档读回。</li></ul></div>
<div><h3>治理与巡检链</h3><ul><li><code>+member-update</code>:角色变更终态与不可精确读回声明。</li><li><code>+node-search</code>:库内关键词/扩展名搜索,严格零命中。</li><li><code>+feed-list</code>:知识库动态时间线与服务端 exclude-file 过滤。</li></ul></div>
</div>
</section>
<section class="card">
<h2>隐藏问题与修复</h2>
<table><thead><tr><th>原问题</th><th>错误风险</th><th>本次修复</th></tr></thead><tbody>
<tr><td>5 个旧 Wiki Shortcut 可直接执行,但只有 1 个进入公开目录。</td><td>Help、Schema、Skill 发现链与运行面漂移。</td><td><span class="tag fixed">20 项统一评审</span> 全部具备 Contract/Safety/Result 与语义目录记录。</td></tr>
<tr><td>列表投影找不到数组或遇到坏元素时返回空 slice。</td><td>把内部错误、字段漂移误报为“没有数据”。</td><td><span class="tag fixed">失败关闭</span> 只有响应中真实存在的 <code>[]</code> 才是合法空集合。</td></tr>
<tr><td>节点列表 Shortcut 调错 Wiki MCP 服务。</td><td>真实后端 <code>success=false</code>,Mock/静态检查看不出。</td><td><span class="tag fixed">跨域路由</span> 明确调用 doc/list_nodes,并纳入真实 E2E。</td></tr>
<tr><td>成员帮助宣称最大 200。</td><td>真实接口超过 50 直接参数错误。</td><td><span class="tag fixed">真实上限</span> Shortcut 与原子 Help 均改为 50,并在本地提前拒绝。</td></tr>
<tr><td>成员写操作从最多 50 条、不可分页的名单推断成员存在或缺失。</td><td>目标在截断部分时会误报写失败,或把未验证的移除报告为已读回。</td><td><span class="tag fixed">终态证据</span> 只接受写接口 <code>success=true</code>,并在结果中明确 <code>readbackAvailable=false</code>。</td></tr>
<tr><td>知识库名称帮助宣称最大 100。</td><td>真实接口超过 32 失败。</td><td><span class="tag fixed">真实上限</span> Help 与 Shortcut 校验统一为 32。</td></tr>
<tr><td>复制/移动/创建只把无异常视为成功。</td><td>空确认、未知远端效果或移动未到目标仍可能被接受。</td><td><span class="tag fixed">读回证明</span> 在后端具备精确查询能力时检查 success、业务 ID、workspace/folder 等最终状态。</td></tr>
</tbody></table>
</section>
<section class="card">
<h2>真实数据 E2E 证据矩阵</h2>
<p class="small">28 项业务断言全部通过。测试使用一次性空知识库、临时在线文档与一名同组织内部测试成员;所有对象在 finally 清理。报告不保存对象 ID、成员身份、组织信息、URL、trace 或原始响应。</p>
<div class="toolbar"><input id="q" placeholder="筛选命令或证据"><select id="g"><option value="">全部分组</option><option>空间</option><option>成员</option><option>节点</option><option>动态</option></select></div>
<div class="matrix"><table id="catalog"><thead><tr><th>分组</th><th>Shortcut</th><th>实际业务断言</th><th>状态</th></tr></thead><tbody>
<tr><td>空间</td><td><code>+space-list</code></td><td>真实 count、hasMore、nextCursor;自动翻页返回两页结果。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>空间</td><td><code>+space-search</code></td><td>等待搜索索引后命中一次性 workspaceId。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>空间</td><td><code>+space-get</code></td><td>读回 workspaceId 与创建结果一致。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>空间</td><td><code>+resolve-space</code></td><td>唯一名称解析为同一 workspaceId。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>空间</td><td><code>+space-create</code></td><td>success=true、workspaceId 非空、详情读回一致。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>空间</td><td><code>+delete-space</code></td><td>目标预读、确认、success=true;兼容别名执行 finally 清理。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>成员</td><td><code>+member-list</code></td><td>真实 owner 条目与显式 members 数组,limit=50。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>成员</td><td><code>+member-add</code></td><td>命令只报告写终态;一次性小规模空间另行确认名单完整且角色为 READER。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>成员</td><td><code>+member-update</code></td><td>命令只报告写终态;一次性小规模空间另行确认角色变为 EDITOR。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>成员</td><td><code>+member-remove</code></td><td>命令只报告写终态;一次性小规模空间另行确认完整名单中不存在该 userId。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+node-list</code></td><td>空库返回真实 nodes:[];有数据时验证游标与自动翻页。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+node-get</code></td><td>读回 nodeId 与请求一致。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+node-search</code></td><td>等待索引后按标题命中真实 nodeId。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+node-create</code></td><td>分别创建 folder/adoc,均取得 nodeId 和元数据读回。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+node-copy</code></td><td>取得不同的新 nodeId,副本元数据可读。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+move</code></td><td>读回 workspaceId 与 folderId 均等于目标;兼容 +node-move。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+move-to-drive</code></td><td>移动后读回 workspace 发生变化,再通过 +move 移回。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+node-delete</code></td><td>目标预读与 workspace 核对后收到 success=true。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>节点</td><td><code>+wiki-new-doc</code></td><td>按唯一空间名创建,nodeId 与文档详情读回一致。</td><td><span class="tag full">PASS</span></td></tr>
<tr><td>动态</td><td><code>+feed-list</code></td><td>创建/移动操作后返回真实 feeds 数组,缺字段不会被接受。</td><td><span class="tag full">PASS</span></td></tr>
</tbody></table></div>
<p class="small">可复跑入口:<code>make build</code> 后设置临时 <code>DWS_WIKI_E2E_MEMBER_ID</code>,运行 <code>./scripts/dev/wiki-shortcut-e2e.py</code>。脚本只输出能力标签,不输出业务对象。</p>
</section>
<section class="card">
<h2>成功判定与发布门</h2>
<div class="two"><div><h3>运行时证据层</h3><ol><li>传输/MCP 调用成功。</li><li>响应契约存在且类型正确。</li><li>写操作必须有 <code>success=true</code>;创建类操作还必须有业务 ID。</li><li>后端具备精确查询时必须读回;不具备时明确发布不可读回,而非从截断集合推断。</li><li>集合只有显式数组才允许为空。</li></ol></div><div><h3>交付门</h3><ol><li>20/20 语义目录与注册面精确覆盖。</li><li>Contract、Safety、Result、统一输出完整。</li><li>生成漂移、Schema、确认真值、全量 Go 测试。</li><li>独立真实数据 E2E 与 finally 清理。</li><li>diff PII/密钥/本地绝对路径扫描。</li></ol></div></div>
</section>
<p class="footer">DWS Wiki Shortcut business review · sanitized engineering artifact</p>
</main>
<script>
const q=document.querySelector('#q'),g=document.querySelector('#g'),rows=[...document.querySelectorAll('#catalog tbody tr')];
function filter(){const text=q.value.trim().toLowerCase(),group=g.value;rows.forEach(r=>{const okText=!text||r.textContent.toLowerCase().includes(text),okGroup=!group||r.children[0].textContent===group;r.style.display=okText&&okGroup?'':'none'})}q.addEventListener('input',filter);g.addEventListener('change',filter);
</script></body></html>
+1 -5
View File
@@ -479,11 +479,7 @@ func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPaylo
if err != nil {
t.Fatalf("alias execution failed: %v", err)
}
if alias == "open-conversation-id" {
if ctx == nil || len(ctx.Corrections) != 0 {
t.Fatalf("alias corrections = %#v", ctx)
}
} else if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--"+alias || ctx.Corrections[0].Corrected != "--conversation-id" {
if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--"+alias || ctx.Corrections[0].Corrected != "--conversation-id" {
t.Fatalf("alias corrections = %#v", ctx)
}
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
+2 -2
View File
@@ -94,8 +94,8 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
}{
{args: []string{"chat", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{args: []string{"im", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id <GROUP_OPEN_CONVERSATION_ID>"},
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id <GROUP_OPEN_CONVERSATION_ID>"},
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
} {
command := NewRootCommand()
command.SilenceErrors = true
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 399
publicShortcutCount = 418
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including the hidden historical minutes.shortcut_minutes_search contract.
schemaPublishedShortcutCount = 401
schemaPublishedShortcutCount = 420
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 399
publiclyDeliveredShortcutCount = 418
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -207,11 +207,7 @@ func assertChatCatalogCompleteLeafContracts(t testing.TB) {
})
auditJoin := executeShortcutSchemaQuery(t, "--cli-path", "chat group audit-join-validation")
assertSchemaLeafParameterRequired(t, auditJoin, "chat group audit-join-validation", "conversation-id", true)
assertSchemaLeafParameterEnum(t, auditJoin, "chat group audit-join-validation", "status", []string{"AuditApprove", "AuditDelete"})
if parameters := schemaContractMap(auditJoin["parameters"]); parameters["group"] != nil {
t.Fatalf("chat group audit-join-validation publishes hidden --group alias: %#v", parameters["group"])
}
}
func assertSchemaLeafParameterRequired(t testing.TB, leaf map[string]any, cliPath, name string, want bool) {
+12 -18
View File
@@ -177,7 +177,6 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
aliasMap := make(map[string]string)
blockedSet := make(map[string]bool)
excludedSet := make(map[string]bool)
claimedRealSet := make(map[string]bool)
pendingReview := ov.Confirm || ov.Investigate
for boundFlag, conceptID := range ov.Bind {
@@ -219,14 +218,6 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
if len(candidates) == 0 {
continue
}
for m := range eff {
if _, isReal := realByMorph[m]; isReal {
claimedRealSet[m] = true
}
}
for _, exclude := range concept.Excludes {
excludedSet[cmdutil.Morph(exclude)] = true
}
visible := distinctRealNames(candidates, true)
var canon string
switch len(visible) {
@@ -276,18 +267,21 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
}
aliasMap[m] = canon
}
// Excludes are not passive prose: once this concept is active on a
// reviewed command, a non-real excluded spelling is protected from
// downstream fuzzy correction. A real flag is left alone because it
// already has an independently valid command-local meaning.
for _, exclude := range concept.Excludes {
morphed := cmdutil.Morph(exclude)
if _, isReal := realByMorph[morphed]; !isReal {
excludedSet[morphed] = true
}
}
}
for excluded := range excludedSet {
if _, isAlias := aliasMap[excluded]; isAlias {
continue
if _, isAlias := aliasMap[excluded]; !isAlias {
blockedSet[excluded] = true
}
if claimedRealSet[excluded] {
continue
}
if _, isReal := realByMorph[excluded]; isReal {
continue
}
blockedSet[excluded] = true
}
// (b) Command scoped aliases override concept reductions.
+249 -130
View File
@@ -889,9 +889,13 @@ var generatedParamAliases = []ParamAliasEntry{
Blocked: []string{"cursor"},
},
{
CLIPath: "chat category add-conv",
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Ambiguous: []string{"chat", "chat-id", "open-conversation-id"},
CLIPath: "chat category add-conv",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat category create",
@@ -916,9 +920,13 @@ var generatedParamAliases = []ParamAliasEntry{
Blocked: []string{"category-ids"},
},
{
CLIPath: "chat category remove-conv",
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Ambiguous: []string{"chat", "chat-id", "open-conversation-id"},
CLIPath: "chat category remove-conv",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat category rename",
@@ -962,19 +970,23 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat conversation-info",
Aliases: map[string]string{
"staff-id": "user",
"uid": "user",
"userid": "user",
"chat-id": "group",
"open-conversation-id": "group",
"staff-id": "user",
"uid": "user",
"userid": "user",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
},
{
CLIPath: "chat group audit-join-validation",
Aliases: map[string]string{
"chat-id": "conversation-id",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Ambiguous: []string{"staff-id", "uid", "user", "user-id", "userid"},
},
{
@@ -994,9 +1006,12 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat group dismiss",
Aliases: map[string]string{
"chat-id": "conversation-id",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group get-by-group-id",
@@ -1005,9 +1020,12 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat group invite-url",
Aliases: map[string]string{
"chat-id": "conversation-id",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group members",
@@ -1082,37 +1100,52 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat group notice create",
Aliases: map[string]string{
"chat-id": "conversation-id",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group notice edit",
Aliases: map[string]string{
"chat-id": "conversation-id",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group notice get",
Aliases: map[string]string{
"chat-id": "conversation-id",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group notice list",
Aliases: map[string]string{
"chat-id": "conversation-id",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group quit",
Aliases: map[string]string{
"chat-id": "conversation-id",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group rename",
@@ -1128,17 +1161,23 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat group set-admin",
Aliases: map[string]string{
"chat-id": "conversation-id",
"user-ids": "users",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"user-ids": "users",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
},
{
CLIPath: "chat group set-history",
Aliases: map[string]string{
"chat-id": "conversation-id",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group share-invite",
@@ -1152,111 +1191,151 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat group transfer-owner",
Aliases: map[string]string{
"chat-id": "conversation-id",
"staff-id": "user",
"uid": "user",
"userid": "user",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"staff-id": "user",
"uid": "user",
"userid": "user",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
},
{
CLIPath: "chat group update-alias",
Aliases: map[string]string{
"chat-id": "conversation-id",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group update-icon",
Aliases: map[string]string{
"chat-id": "conversation-id",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group update-nick",
Aliases: map[string]string{
"chat-id": "conversation-id",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group update-settings",
Aliases: map[string]string{
"chat-id": "conversation-id",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group-mute",
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
CLIPath: "chat group-mute",
Aliases: map[string]string{
"chat-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group-mute-member",
Aliases: map[string]string{
"user-ids": "users",
"chat-id": "group",
"open-conversation-id": "group",
"user-ids": "users",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
},
{
CLIPath: "chat group-role add",
Aliases: map[string]string{
"chat-id": "conversation-id",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group-role list",
Aliases: map[string]string{
"chat-id": "conversation-id",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group-role query-user",
Aliases: map[string]string{
"chat-id": "conversation-id",
"staff-id": "user",
"uid": "user",
"userid": "user",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"staff-id": "user",
"uid": "user",
"userid": "user",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
},
{
CLIPath: "chat group-role remove",
Aliases: map[string]string{
"chat-id": "conversation-id",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat group-role remove-user",
Aliases: map[string]string{
"chat-id": "conversation-id",
"staff-id": "user",
"uid": "user",
"userid": "user",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"staff-id": "user",
"uid": "user",
"userid": "user",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
},
{
CLIPath: "chat group-role set-user",
Aliases: map[string]string{
"chat-id": "conversation-id",
"staff-id": "user",
"uid": "user",
"userid": "user",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"staff-id": "user",
"uid": "user",
"userid": "user",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
},
{
CLIPath: "chat group-role update",
Aliases: map[string]string{
"chat-id": "conversation-id",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat hide",
@@ -1290,10 +1369,12 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat message add-emoji",
Aliases: map[string]string{
"chat-id": "conversation-id",
"chat-id": "conversation-id",
"message-id": "msg-id",
"open-conversation-id": "conversation-id",
"open-message-id": "msg-id",
},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
Ambiguous: []string{"open-message-id"},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
},
{
CLIPath: "chat message add-favorite",
@@ -1310,10 +1391,12 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat message add-text-emotion",
Aliases: map[string]string{
"chat-id": "conversation-id",
"chat-id": "conversation-id",
"message-id": "msg-id",
"open-conversation-id": "conversation-id",
"open-message-id": "msg-id",
},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
Ambiguous: []string{"open-message-id"},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
},
{
CLIPath: "chat message combine-forward",
@@ -1344,6 +1427,8 @@ var generatedParamAliases = []ParamAliasEntry{
Aliases: map[string]string{
"dest-open-cid": "dest-conversation-id",
"destination-conversation-id": "dest-conversation-id",
"message-id": "msg-id",
"open-message-id": "msg-id",
"source-conversation-id": "src-conversation-id",
"src-open-cid": "src-conversation-id",
"target-conversation-id": "dest-conversation-id",
@@ -1368,20 +1453,21 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat message list",
Aliases: map[string]string{
"max-result": "limit",
"max-results": "limit",
"page-size": "limit",
"per-page": "limit",
"staff-id": "user",
"start": "time",
"take": "limit",
"top": "limit",
"uid": "user",
"user-id": "user",
"userid": "user",
"chat-id": "group",
"max-result": "limit",
"max-results": "limit",
"open-conversation-id": "group",
"page-size": "limit",
"per-page": "limit",
"staff-id": "user",
"start": "time",
"take": "limit",
"top": "limit",
"uid": "user",
"user-id": "user",
"userid": "user",
},
Blocked: []string{"at-user-ids", "conversation-ids", "count", "cursor", "dest-conversation-id", "end", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "page", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
Blocked: []string{"at-user-ids", "conversation-ids", "count", "cursor", "dest-conversation-id", "end", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "page", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
},
{
CLIPath: "chat message list-all",
@@ -1428,9 +1514,12 @@ var generatedParamAliases = []ParamAliasEntry{
},
},
{
CLIPath: "chat message list-mentions",
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
CLIPath: "chat message list-mentions",
Aliases: map[string]string{
"chat-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat message list-pin-msg",
@@ -1445,7 +1534,8 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat message list-topic-replies",
Aliases: map[string]string{
"chat-id": "conversation-id",
"chat-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
@@ -1459,10 +1549,11 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat message read-status",
Aliases: map[string]string{
"chat-id": "conversation-id",
"msg-id": "message-id",
"open-message-id": "message-id",
"user-ids": "users",
"chat-id": "conversation-id",
"msg-id": "message-id",
"open-conversation-id": "conversation-id",
"open-message-id": "message-id",
"user-ids": "users",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "staff-id", "target", "topic-id", "uid", "userid"},
},
@@ -1470,25 +1561,30 @@ var generatedParamAliases = []ParamAliasEntry{
CLIPath: "chat message recall",
Aliases: map[string]string{
"chat-id": "conversation-id",
"message-id": "msg-id",
"open-conversation-id": "conversation-id",
"open-message-id": "msg-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
},
{
CLIPath: "chat message recall-by-bot",
Aliases: map[string]string{
"robot": "robot-code",
"chat-id": "group",
"open-conversation-id": "group",
"robot": "robot-code",
},
Blocked: []string{"bot-code", "bot-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-bot-id", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "target"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
Blocked: []string{"bot-code", "bot-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-bot-id", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat message remove-emoji",
Aliases: map[string]string{
"chat-id": "conversation-id",
"chat-id": "conversation-id",
"message-id": "msg-id",
"open-conversation-id": "conversation-id",
"open-message-id": "msg-id",
},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
Ambiguous: []string{"open-message-id"},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
},
{
CLIPath: "chat message remove-favorite",
@@ -1505,10 +1601,12 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat message remove-text-emotion",
Aliases: map[string]string{
"chat-id": "conversation-id",
"chat-id": "conversation-id",
"message-id": "msg-id",
"open-conversation-id": "conversation-id",
"open-message-id": "msg-id",
},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
Ambiguous: []string{"open-message-id"},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
},
{
CLIPath: "chat message reply",
@@ -1522,9 +1620,12 @@ var generatedParamAliases = []ParamAliasEntry{
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-id", "msg-id", "msg-ids", "name", "open-conversation-ids", "open-message-id", "source", "src-conversation-id", "src-msg-id", "staff-id", "target", "uid", "user", "user-id", "userid"},
},
{
CLIPath: "chat message search",
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
CLIPath: "chat message search",
Aliases: map[string]string{
"chat-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat message search-advanced",
@@ -1537,25 +1638,28 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat message send",
Aliases: map[string]string{
"file": "file-path",
"staff-id": "user",
"to-user": "user",
"uid": "user",
"user-id": "user",
"userid": "user",
"chat-id": "group",
"file": "file-path",
"open-conversation-id": "group",
"staff-id": "user",
"to-user": "user",
"uid": "user",
"user-id": "user",
"userid": "user",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "user-ids", "users"},
Ambiguous: []string{"chat-id", "open-conversation-id"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "user-ids", "users"},
},
{
CLIPath: "chat message send-by-bot",
Aliases: map[string]string{
"at-users": "at-user-ids",
"robot": "robot-code",
"user-ids": "users",
"at-users": "at-user-ids",
"chat-id": "group",
"open-conversation-id": "group",
"robot": "robot-code",
"user-ids": "users",
},
Blocked: []string{"bot-code", "bot-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-bot-id", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "staff-id", "target", "to-user-id", "uid", "user", "user-id", "userid"},
Ambiguous: []string{"at-ids", "chat-id", "open-conversation-id"},
Ambiguous: []string{"at-ids"},
},
{
CLIPath: "chat message send-by-webhook",
@@ -1564,9 +1668,14 @@ var generatedParamAliases = []ParamAliasEntry{
},
},
{
CLIPath: "chat message send-card",
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "user", "user-id", "userid"},
Ambiguous: []string{"chat", "chat-id", "open-conversation-id"},
CLIPath: "chat message send-card",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "user", "user-id", "userid"},
},
{
CLIPath: "chat message set-pin-msg",
@@ -1575,6 +1684,8 @@ var generatedParamAliases = []ParamAliasEntry{
"chat-id": "open-conversation-id",
"conversation-id": "open-conversation-id",
"group": "open-conversation-id",
"message-id": "msg-id",
"open-message-id": "msg-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
},
@@ -1585,6 +1696,8 @@ var generatedParamAliases = []ParamAliasEntry{
"chat-id": "open-conversation-id",
"conversation-id": "open-conversation-id",
"group": "open-conversation-id",
"message-id": "msg-id",
"open-message-id": "msg-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
},
@@ -1595,6 +1708,8 @@ var generatedParamAliases = []ParamAliasEntry{
"chat-id": "open-conversation-id",
"conversation-id": "open-conversation-id",
"group": "open-conversation-id",
"message-id": "msg-id",
"open-message-id": "msg-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
},
@@ -1605,13 +1720,17 @@ var generatedParamAliases = []ParamAliasEntry{
"chat-id": "open-conversation-id",
"conversation-id": "open-conversation-id",
"group": "open-conversation-id",
"message-id": "msg-id",
"open-message-id": "msg-id",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
},
{
CLIPath: "chat mute",
Aliases: map[string]string{
"chat-id": "conversation-id",
"chat-id": "conversation-id",
"group": "conversation-id",
"open-conversation-id": "conversation-id",
},
Blocked: []string{"conversation-ids", "group-id", "group-ids", "open-conversation-ids"},
},
@@ -2429,7 +2548,7 @@ var generatedParamAliases = []ParamAliasEntry{
"node-id": "node",
"url": "node",
},
Blocked: []string{"block-id", "comment-id", "comment-key", "count", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "offset", "page", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"},
Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"},
Ambiguous: []string{"max-result", "max-results", "next-cursor", "next-page-token", "next-token", "per-page", "size", "take", "top"},
},
{
-104
View File
@@ -237,42 +237,6 @@ func TestReduceLeafParamAliasesRemainingEdges(t *testing.T) {
})
}
func TestCrossPlatformCoverageReduceLeafParamAliasesBindExcludesRealFlags(t *testing.T) {
entry, problems := reduceLeafParamAliases(
"demo cmd",
realMap(realFlag{name: "id"}, realFlag{name: "name"}, realFlag{name: "query"}),
[]Concept{
{ID: "base_id", Members: []string{"base-id", "base-token"}, Excludes: []string{"keyword", "name", "query", "unsafe"}},
{ID: "query", Members: []string{"query", "keyword"}},
},
CommandOverride{Bind: map[string]string{"id": "base_id"}},
)
if len(problems) != 0 {
t.Fatalf("reduceLeafParamAliases() problems = %v", problems)
}
if entry == nil {
t.Fatal("reduceLeafParamAliases() entry = nil")
}
if entry.Aliases["base-id"] != "id" || entry.Aliases["base-token"] != "id" {
t.Fatalf("bound aliases = %#v, want base-id/base-token -> id", entry.Aliases)
}
if entry.Aliases["keyword"] != "query" {
t.Fatalf("query alias = %#v, want keyword -> query", entry.Aliases)
}
if containsParamAlias(entry.Blocked, "keyword") {
t.Fatalf("excluded alias entered blocked list: %#v", entry.Blocked)
}
if containsParamAlias(entry.Blocked, "name") {
t.Fatalf("real excluded flag entered blocked list: %#v", entry.Blocked)
}
if containsParamAlias(entry.Blocked, "query") {
t.Fatalf("claimed real excluded flag entered blocked list: %#v", entry.Blocked)
}
if !containsParamAlias(entry.Blocked, "unsafe") {
t.Fatalf("non-real excluded flag was not blocked: %#v", entry.Blocked)
}
}
func TestParamAliasEntryLookupMethods(t *testing.T) {
entry := ParamAliasEntry{
Aliases: map[string]string{"uid": "user"},
@@ -493,22 +457,6 @@ func TestReduceLeafParamAliasesExcludesProtectFuzzyButDoNotOverrideAnotherConcep
}
}
func TestReduceLeafParamAliasesExcludesDoNotBlockRealFlag(t *testing.T) {
concepts := []Concept{
{ID: "single_id", Members: []string{"id", "item-id"}, Excludes: []string{"item-ids"}},
}
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}, realFlag{name: "item-ids"}), concepts, CommandOverride{})
if len(problems) != 0 {
t.Fatalf("unexpected problems: %v", problems)
}
if entry == nil {
t.Fatal("expected a reduced entry")
}
if containsParamAlias(entry.Blocked, "item-ids") {
t.Fatalf("real exclude was blocked: %#v", entry)
}
}
func TestReduceLeafParamAliasesRejectsProtectionOrScopedAliasOnRealFlag(t *testing.T) {
real := realMap(realFlag{name: "user-id"}, realFlag{name: "user"})
for name, override := range map[string]CommandOverride{
@@ -524,58 +472,6 @@ func TestReduceLeafParamAliasesRejectsProtectionOrScopedAliasOnRealFlag(t *testi
}
}
func TestGeneratedParamAliasesBlockPluralListSpellingsOnSingleIDCommands(t *testing.T) {
entries := make(map[string]ParamAliasEntry, len(generatedParamAliases))
for _, entry := range generatedParamAliases {
entries[entry.CLIPath] = entry
}
assertBlocked := func(path string, names ...string) {
t.Helper()
entry, ok := entries[path]
if !ok {
t.Fatalf("missing generated alias entry for %q", path)
}
for _, name := range names {
if !entry.IsBlocked(cmdutil.Morph(name)) {
t.Fatalf("%s: %q not blocked; entry = %#v", path, name, entry)
}
}
}
for _, path := range []string{
"chat message add-emoji",
"chat message remove-emoji",
"chat message add-text-emotion",
"chat message remove-text-emotion",
} {
assertBlocked(path, "msg-ids", "message-ids")
}
for _, path := range []string{
"chat message send",
"chat conversation-info",
"chat category add-conv",
"chat category remove-conv",
"chat message list",
"chat message list-mentions",
"chat message recall-by-bot",
"chat message search",
} {
assertBlocked(path, "conversation-ids")
}
}
func TestGeneratedParamAliasesKeepAuditJoinUserRoleAmbiguous(t *testing.T) {
entry, ok := LookupParamAlias("chat group audit-join-validation")
if !ok {
t.Fatal("missing generated alias entry for chat group audit-join-validation")
}
for _, name := range []string{"user", "user-id", "userid", "uid", "staff-id"} {
if !entry.IsAmbiguous(cmdutil.Morph(name)) {
t.Fatalf("%q not ambiguous; entry = %#v", name, entry)
}
}
}
// TestGeneratedParamAliasesAreWellFormed guards the committed generated table
// at the Go level, complementing the byte-identity drift gate.
func TestGeneratedParamAliasesAreWellFormed(t *testing.T) {
+12 -19
View File
@@ -60,17 +60,11 @@
"chat group members": {"bind": {"id": "open_conversation_id"}},
"chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."},
"chat group members remove": {"bind": {"id": "open_conversation_id"}},
"chat conversation-info": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"chat group-mute": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"chat group-mute-member": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"chat message add-emoji": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
"chat message add-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
"chat message list-mentions": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"chat message recall-by-bot": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
"chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
"chat message search": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"chat mute": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id and hidden compatibility --group/--id/--chat/--open-conversation-id remain unchanged; reviewed chat-id reduces to --conversation-id."},
"chat message add-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
"chat message add-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
"chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
"chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
"chat mute": {"scoped_aliases": {"group": "conversation-id", "chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id/--id/--chat remain unchanged; other reviewed openConversationId spellings reduce to --conversation-id."},
"drive list": {"ambiguous": ["root-id", "space"], "note": "Numeric --space-id and knowledge-base --workspace are distinct routes; bare --space/--root-id cannot select a domain or folder.", "scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}, "scope_strict": true, "block": ["dentry-id"]},
"drive upload": {"ambiguous": ["destination-id", "space", "target-id"], "note": "Local file, display name, MIME, overwrite node, folder, storage space, and knowledge-base workspace remain distinct roles.", "scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "overwrite-node-id": "node", "target-folder-id": "folder", "target-workspace-id": "workspace", "source-file": "file", "content-type": "mime-type", "filename": "file-name", "name": "file-name", "display-name": "file-name", "upload-name": "file-name"}, "block": ["dentry-id", "document-url", "output-path"], "scope_strict": true},
"ding +receiver-status": {"scoped_aliases": {"id": "ding-id"}, "note": "generic id reduces to ding-id"},
@@ -90,12 +84,12 @@
"chat +unread-chats": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."},
"chat message list-unread-conversations": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."},
"chat +messages-list-direct": {"scoped_aliases": {"start": "time"}, "block": ["end"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented."},
"chat message list": {"scoped_aliases": {"start": "time"}, "block": ["end"], "ambiguous": ["chat-id", "open-conversation-id"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"chat message list": {"scoped_aliases": {"start": "time"}, "block": ["end"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented."},
"chat message list-by-sender": {"scoped_aliases": {"user-id": "sender-user-id", "open-dingtalk-id": "sender-open-dingtalk-id"}, "block": ["time"], "scope_strict": true, "note": "Only same-role sender identifiers are mapped; --time cannot supply the required RFC3339 start/end range."},
"contact +resolve-dept": {"bind": {"name": "search_query"}, "note": "The real --name is a department-name search keyword and carries the search_query concept on this shortcut."},
"contact +list-sub-depts": {"block": ["name", "query"], "note": "--dept is an integer department id; names and search queries require a separate resolution command"},
"contact +dept-members": {"bind": {"dept": "search_query"}, "scoped_aliases": {"name": "dept"}, "note": "The real --dept is a department-name search keyword; search spellings come from search_query, while --name remains command-scoped."},
"chat message send": {"scoped_aliases": {"to-user": "user", "file": "file-path"}, "ambiguous": ["chat-id", "open-conversation-id"], "note": "Recipient and local-file-path aliases are exact to this command; obsolete file metadata flags remain unsupported. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"chat message send": {"scoped_aliases": {"to-user": "user", "file": "file-path"}, "note": "Recipient and local-file-path aliases are exact to this command; obsolete file metadata flags remain unsupported."},
"chat +group-members": {"bind": {"group": "group_name"}, "note": "The real --group is a group-name search keyword on this shortcut, not an identifier."},
"chat +category-create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact shortcut."},
"chat category create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact command."},
@@ -104,8 +98,8 @@
"chat +category-delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
"chat category delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
"chat category list-conversations": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
"chat category add-conv": {"block": ["category-id"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"chat category remove-conv": {"block": ["category-id"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"chat category add-conv": {"block": ["category-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input."},
"chat category remove-conv": {"block": ["category-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input."},
"chat +chat-role-update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
"chat group-role remove": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
"chat group-role update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
@@ -115,7 +109,7 @@
"chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."},
"chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."},
"doc block insert": {"block": ["before-block-id"], "note": "Parent and reference roles remain distinct. --before-block-id needs both --ref-block and --where before, while role-free --block-id cannot choose parent versus reference.", "scoped_aliases": {"parent-block-id": "parent-block", "ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "ambiguous": ["block-id"], "scope_strict": true},
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids", "chat-id", "open-conversation-id"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"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": ["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", "space": "workspace", "space-id": "workspace"}, "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. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."},
@@ -148,7 +142,7 @@
"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"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
"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."},
@@ -334,7 +328,7 @@
{"command": "chat group rename", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
{"command": "chat group rename", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"},
{"command": "chat message send", "emitted": "conversation-id", "expect": "group", "via": "concept:open_conversation_id"},
{"command": "chat message add-emoji", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "concept:open_conversation_id"},
{"command": "chat message add-emoji", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "override:scoped"},
{"command": "chat message add-emoji", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"},
{"command": "chat +group-members", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"},
{"command": "chat +send-to-group", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"},
@@ -472,7 +466,6 @@
{"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 group audit-join-validation", "emitted": "user", "expect": "did-you-mean:ambiguous", "via": "guard:applicant-vs-inviter-role"},
{"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"},
+8 -52
View File
@@ -1354,16 +1354,16 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
interfaceType string
}{
{"chat message edit", "conversation-id", "openConversationId", true, ""},
{"chat message edit", "message-id", "openMessageId", true, ""},
{"chat message edit", "msg-id", "openMessageId", true, ""},
{"chat message edit", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
{"chat message update-text-emotion", "message-id", "openMsgId", true, ""},
{"chat message send", "idempotency-key", "uuid", false, ""},
{"chat message send-card", "at-all", "atAll", false, ""},
{"chat message send-card", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
{"chat message update-text-emotion", "msg-id", "openMsgId", true, ""},
{"chat message update-text-emotion", "old-emotion-id", "oldEmotionId", true, ""},
{"chat category batch-info", "category-ids", "categoryIds", true, "array"},
{"chat category list-by-conv", "conversation-id", "openConversationId", true, ""},
{"chat group update-nick", "conversation-id", "", true, ""},
{"chat category list-by-conv", "group", "openConversationId", true, ""},
{"chat group update-nick", "group", "openConversationId", true, ""},
{"chat group upgrade-to-external", "extension", "extension", false, "object"},
{"chat +messages-send-card", "receiver-open-dingtalk-id", "receiverOpenDingTalkId", false, ""},
{"chat message list-favorites", "size", "", false, "string"},
@@ -1397,8 +1397,7 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
}
}
// Manifest-covered migrations hide legacy aliases; manifest-external
// commands keep their existing visible flags for compatibility.
// Hidden conversation aliases must stay unpublished (merge-base parity).
editLeaf, err := queryDeliverySchemaPayload([]string{"chat message edit"})
if err != nil {
t.Fatal(err)
@@ -1422,52 +1421,9 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
t.Fatal(err)
}
listParams := schemaMap(listByConv["parameters"])
if _, ok := listParams["conversation-id"]; !ok {
t.Fatalf("chat category list-by-conv missing public canonical --conversation-id")
}
if _, ok := listParams["group"]; !ok {
t.Fatalf("chat category list-by-conv unexpectedly hides manifest-external --group")
}
if _, ok := listParams["id"]; ok {
t.Fatalf("chat category list-by-conv unexpectedly publishes hidden alias --id")
}
for _, path := range []string{
"chat message add-emoji",
"chat message remove-emoji",
"chat message add-text-emotion",
"chat message remove-text-emotion",
} {
leaf, err := queryDeliverySchemaPayload([]string{path})
if err != nil {
t.Fatal(err)
}
params := schemaMap(leaf["parameters"])
if _, ok := params["conversation-id"]; !ok {
t.Fatalf("%s missing public canonical --conversation-id", path)
}
for _, visible := range []string{"group", "id", "chat"} {
if _, ok := params[visible]; !ok {
t.Fatalf("%s unexpectedly hides manifest-external --%s", path, visible)
}
}
}
groupBots, err := queryDeliverySchemaPayload([]string{"chat group bots"})
if err != nil {
t.Fatal(err)
}
groupBotsParams := schemaMap(groupBots["parameters"])
group := groupBotsParams["group"]
if group == nil {
t.Fatal("chat group bots missing public legacy --group")
}
if group["property"] != "openConversationId" {
t.Fatalf("chat group bots --group property = %#v, want openConversationId", group["property"])
}
for _, migrated := range []string{"conversation-id", "group-name"} {
if _, ok := groupBotsParams[migrated]; ok {
t.Fatalf("chat group bots unexpectedly publishes migrated --%s", migrated)
for _, hidden := range []string{"conversation-id", "id"} {
if _, ok := listParams[hidden]; ok {
t.Fatalf("chat category list-by-conv unexpectedly publishes hidden alias --%s", hidden)
}
}
}
@@ -43,14 +43,19 @@ func init() {
RequireOneOf: [][]string{{"conversation-id", "open-dingtalk-id", "user", "permParam"}},
})
registerExclusiveOneOf("chat.chat_permission_grant_cross_org_data", "target-org-id", "all")
registerRequireOneOf("chat.add_emoji_reaction", "conversation-id", "group", "id", "chat")
registerRequireOneOf("chat.add_text_emotion", "conversation-id", "group", "id", "chat")
registerExclusiveOneOf("chat.clear_conversation_messages", "conversation-id", "id", "chat")
registerExclusiveOneOf("chat.clear_conversation_red_point", "conversation-id", "id", "chat")
registerRequireOneOf("chat.update_text_emotion", "conversation-id", "group", "id", "chat")
registerExclusiveOneOf("chat.get_conversation_info", "group", "user", "open-dingtalk-id")
registerExclusiveOneOf("chat.hide_conversation", "conversation-id", "id", "chat")
registerExclusiveOneOf("chat.list_conversation_message_v2", "group", "user", "open-dingtalk-id")
registerExclusiveOneOf("chat.list_individual_chat_message", "user", "open-dingtalk-id")
registerExclusiveOneOf("chat.mark_conversation_unread", "conversation-id", "id", "chat")
registerExclusiveOneOf("chat.mark_message_read", "conversation-id", "id", "chat")
registerRequireOneOf("chat.remove_emoji_reaction", "conversation-id", "group", "id", "chat")
registerRequireOneOf("chat.remove_text_emotion", "conversation-id", "group", "id", "chat")
registerRequireOneOf("chat.send_personal_message", "text", "content", "msg-type")
registerExclusiveOneOf("chat.send_robot_message", "group", "users")
registerRequireOneOf("chat.set_group_member_mute_list", "users", "user")
@@ -252,8 +252,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"chat.batch_query_group_chat_settings --groups": "Reviewed unpinned adapter: chat.batch_query_group_chat_settings has no singular pinned interface_ref; --groups is a CLI wrapper input and does not publish a direct interface property.",
"chat.batch_update_group_chat_settings --items": "Reviewed unpinned adapter: chat.batch_update_group_chat_settings has no singular pinned interface_ref; --items is a CLI wrapper input and does not publish a direct interface property.",
"chat.create_text_emotion --background-id": "Runtime extension: the executable helper forwards backgroundId to im/create_text_emotion, but the pinned source-revision metadata does not declare that optional property; preserve the compatibility flag without advertising it as a pinned RPC field.",
"chat.get_group_mute_config --conversation-id": "Reviewed unpinned adapter: chat.get_group_mute_config has no singular pinned interface_ref; --conversation-id is a CLI wrapper input and does not publish a direct interface property.",
"chat.get_group_mute_config --group": "Reviewed legacy Schema compatibility: the historical visible --group wrapper input was required but did not publish a direct interface property.",
"chat.get_group_mute_config --group": "Reviewed unpinned adapter: chat.get_group_mute_config has no singular pinned interface_ref; --group is a CLI wrapper input and does not publish a direct interface property.",
"chat.list_conversation_message_v2 --open-dingtalk-id": "selects the alternate list_individual_chat_message branch",
"chat.list_conversation_message_v2 --user": "selects the alternate list_individual_chat_message branch",
"chat.list_message_favorites --cursor": "Reviewed unpinned adapter: chat.list_message_favorites has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
@@ -266,7 +265,6 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"chat.query_msg_read_status --users": "conditional wrapper/alias of --user: parseCSVValues + appendChatIDArgs routes each supplied identifier to targetUserIds or targetOpenDingTalkIds according to its runtime ID shape; there is no single RPC property for this flag",
"chat.remove_message_favorite --open-conversation-id": "Reviewed unpinned adapter: chat.remove_message_favorite has no singular pinned interface_ref; --open-conversation-id is a CLI wrapper input and does not publish a direct interface property.",
"chat.remove_message_favorite --open-message-id": "Reviewed unpinned adapter: chat.remove_message_favorite has no singular pinned interface_ref; --open-message-id is a CLI wrapper input and does not publish a direct interface property.",
"chat.update_text_emotion --conversation-id": "Reviewed unpinned adapter: chat.update_text_emotion has no singular pinned interface_ref; --conversation-id is a CLI wrapper input and does not publish a direct interface property.",
"chat.reply_personal_message --ref-msg-id": "serialized into the aggregate content JSON string",
"chat.reply_personal_message --ref-sender": "resolved then serialized into the aggregate content JSON string",
"chat.reply_personal_message --text": "serialized into the aggregate content JSON string",
+12 -25
View File
@@ -578,7 +578,18 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
for _, alias := range flag.Aliases {
RegisterFlag(cmd, flag.Kind, alias, "", flag.Usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
AnnotateFlagAlias(cmd, alias, flag.Name)
if registered := cmd.Flags().Lookup(alias); registered != nil {
runtimeannotate.SetFlagAnnotation(
registered,
runtimeannotate.AnnotationFlagAliasOf,
flag.Name,
)
runtimeannotate.SetFlagAnnotation(
registered,
runtimeannotate.AnnotationFlagAliasOrigin,
runtimeannotate.FlagAliasOriginCorecmdV1,
)
}
}
if flag.MarkRequired {
_ = cmd.MarkFlagRequired(flag.Name)
@@ -589,30 +600,6 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
}
}
// AnnotateFlagAlias records framework-owned evidence that aliasName is a hidden
// compatibility alias for canonicalName. It is for commands that already own
// their Cobra flag registration outside FlagSpec but still need the same
// interface-snapshot alias contract as FlagSpec.Aliases.
func AnnotateFlagAlias(cmd *cobra.Command, aliasName, canonicalName string) {
if cmd == nil {
return
}
registered := cmd.Flags().Lookup(aliasName)
if registered == nil {
return
}
runtimeannotate.SetFlagAnnotation(
registered,
runtimeannotate.AnnotationFlagAliasOf,
canonicalName,
)
runtimeannotate.SetFlagAnnotation(
registered,
runtimeannotate.AnnotationFlagAliasOrigin,
runtimeannotate.FlagAliasOriginCorecmdV1,
)
}
// RegisterFlag registers one flag by Kind. Default is applied at registration
// for every kind so --help DefValue matches the declared fallback.
// Malformed KindInt / KindBool Default values panic at registration (fail-closed)
-10
View File
@@ -116,16 +116,6 @@ func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
}
}
func TestCrossPlatformCoverageAnnotateFlagAliasIgnoresMissingInputs(t *testing.T) {
AnnotateFlagAlias(nil, "alias", "canonical")
cmd := newTestCommand()
AnnotateFlagAlias(cmd, "missing", "canonical")
if flag := cmd.Flags().Lookup("missing"); flag != nil {
t.Fatalf("unexpected missing flag registered: %#v", flag)
}
}
// ── effective value fallback chain ─────────────────────────────────
func TestCrossPlatformCoverageEffectiveValueFallbackChain(t *testing.T) {
+446 -808
View File
File diff suppressed because it is too large Load Diff
+8 -260
View File
@@ -3,7 +3,6 @@ package helpers
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
@@ -12,7 +11,6 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
@@ -34,26 +32,14 @@ func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...str
func runChatCoverageDirect(t *testing.T, path []string, flags map[string]string) error {
t.Helper()
InitDeps(&scriptedToolCaller{})
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
root := newChatCommand()
installExampleGlobalFlags(root)
root.PersistentFlags().Bool("debug", false, "")
root.PersistentFlags().Bool("verbose", false, "")
command, _, err := root.Find(path)
command, _, err := newChatCommand().Find(path)
if err != nil {
return err
}
for name, value := range flags {
flag := command.Flag(name)
if flag == nil {
return fmt.Errorf("no such flag -%s", name)
}
if err := flag.Value.Set(value); err != nil {
if err := command.Flags().Set(name, value); err != nil {
return err
}
flag.Changed = true
}
return command.RunE(command, nil)
}
@@ -107,7 +93,7 @@ func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *tes
hint string
}{
{path: "send", args: []string{"send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id <GROUP_OPEN_CONVERSATION_ID>"},
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
} {
command, remaining, err := root.Find([]string{tc.path})
if err != nil {
@@ -127,80 +113,6 @@ func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *tes
}
}
func TestCrossPlatformCoverageChatAliasInstallerRemainingEdges(t *testing.T) {
restoreChatManifestExternalVisibleFlags(nil)
mismatchedRoot := &cobra.Command{Use: "chat"}
mismatchedRoot.AddCommand(&cobra.Command{Use: "other"})
restoreChatManifestExternalVisibleFlags(mismatchedRoot)
missingPrimary := &cobra.Command{Use: "leaf"}
installChatFlagAliases(missingPrimary, "conversation-id", []string{"group"}, requireChatConversationID)
if flag := missingPrimary.Flags().Lookup("group"); flag != nil {
t.Fatalf("alias registered without canonical flag: %#v", flag)
}
skipGroup := &cobra.Command{Use: "leaf", RunE: func(cmd *cobra.Command, args []string) error { return nil }}
skipGroup.Flags().String("conversation-id", "", "")
skipGroup.Flags().String("group-name", "", "")
installChatFlagAliases(skipGroup, "conversation-id", []string{"group", "chat"}, requireChatConversationID)
if flag := skipGroup.Flags().Lookup("group"); flag != nil {
t.Fatalf("group alias registered beside group-name: %#v", flag)
}
if flag := skipGroup.Flags().Lookup("chat"); flag == nil {
t.Fatal("non-group alias was not registered")
}
preRunCalled := false
withPreRun := &cobra.Command{
Use: "leaf",
PreRunE: func(cmd *cobra.Command, args []string) error {
preRunCalled = true
return nil
},
RunE: func(cmd *cobra.Command, args []string) error { return nil },
}
withPreRun.Flags().String("conversation-id", "", "")
installChatFlagAliases(withPreRun, "conversation-id", []string{"group"}, requireChatConversationID)
withPreRun.SetArgs([]string{"--group", "cid"})
if err := withPreRun.ExecuteContext(context.Background()); err != nil {
t.Fatalf("execute with alias and previous PreRunE: %v", err)
}
if !preRunCalled {
t.Fatal("previous PreRunE was not called")
}
}
func TestCrossPlatformCoverageChatMessageForwardRequiresMessageID(t *testing.T) {
caller := &productExampleCaller{}
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
root := newChatCommand()
command, _, err := root.Find([]string{"message", "forward"})
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"message-id", "msg-id"} {
if flag := command.Flags().Lookup(name); flag != nil && flag.Annotations != nil {
delete(flag.Annotations, cobra.BashCompOneRequiredFlag)
}
}
if err := command.Flags().Set("src-conversation-id", "src"); err != nil {
t.Fatal(err)
}
if err := command.Flags().Set("dest-conversation-id", "dest"); err != nil {
t.Fatal(err)
}
err = command.RunE(command, nil)
if err == nil || !strings.Contains(err.Error(), "missing required flag: --message-id") {
t.Fatalf("forward missing message id error = %v", err)
}
if caller.calls != 0 {
t.Fatalf("tool calls = %d, want 0", caller.calls)
}
}
func TestCrossPlatformCoverageChatGroupUpdateIconAcceptsUploadedMediaIDPrefixes(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
@@ -287,10 +199,9 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T)
{"category", "remove-conv", "--group=cid", "--category-ids=1,2"},
{"message", "list-by-ids", "--msg-ids=" + strings.Repeat("id,", 51) + "last"},
{"group", "transfer-owner", "--group=cid", "--new-owner=D-owner"},
{"group", "transfer-owner", "--group=cid", "--new-owner=DAAAAAAAAAAAiE"},
{"group", "update-icon", "--group=cid", "--icon-media-id=@valid"},
{"group", "set-history", "--group=cid", "--option=ALL"},
{"group", "audit-join-validation", "--conversation-id=cid", "--record-id=1", "--applicant=D1", "--inviter=D2", "--status=AuditApprove", "--description=ok"},
{"group", "audit-join-validation", "--group=cid", "--record-id=1", "--applicant=D1", "--inviter=D2", "--status=AuditApprove", "--description=ok"},
{"mark-read", "--conversation-id=cid", "--message-id=mid"},
{"text", "translate", "--query=hello", "--to=zh_CN"},
{"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids=r1"},
@@ -382,7 +293,7 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller,
"message", "send-card",
"--conversation-id=cid",
"--group=cid",
"--at-open-dingtalk-ids=D1,D2,D1",
"--at-all",
)
@@ -403,13 +314,13 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
name string
args []string
}{
{name: "member mention rejects direct message", args: []string{"--open-dingtalk-id=D1", "--at-open-dingtalk-ids=D2"}},
{name: "at all rejects direct message", args: []string{"--open-dingtalk-id=D1", "--at-all"}},
{name: "member mention rejects direct message", args: []string{"--receiver=D1", "--at-open-dingtalk-ids=D2"}},
{name: "at all rejects direct message", args: []string{"--receiver=D1", "--at-all"}},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller, append([]string{"message", "send-card"}, tc.args...)...)
if err == nil || !strings.Contains(err.Error(), "only supported with --conversation-id") {
if err == nil || !strings.Contains(err.Error(), "only supported with --group") {
t.Fatalf("error = %v, want group-only mention validation", err)
}
if caller.calls != 0 {
@@ -419,168 +330,6 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
}
}
func TestCrossPlatformCoverageChatSendCardHiddenAliasesMapToCanonicalPayload(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
for _, tc := range []struct {
name string
args []string
want map[string]any
}{
{
name: "group alias",
args: []string{"--group=cid"},
want: map[string]any{"openConversationId": "cid"},
},
{
name: "receiver alias",
args: []string{"--receiver=DAAAAAAAAAAAiE"},
want: map[string]any{"receiverOpenDingTalkId": "DAAAAAAAAAAAiE"},
},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller, append([]string{"message", "send-card"}, tc.args...)...)
if err != nil {
t.Fatal(err)
}
if caller.calls != 1 || caller.server != "im" || caller.tool != "create_and_send_card" || !reflect.DeepEqual(caller.args, tc.want) {
t.Fatalf("call = count:%d server:%q tool:%q args:%#v, want %#v", caller.calls, caller.server, caller.tool, caller.args, tc.want)
}
})
}
}
func TestCrossPlatformCoverageChatGroupAuditJoinValidationUsesCanonicalAndAliasPayload(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
for _, tc := range []struct {
name string
flag string
}{
{name: "canonical conversation-id", flag: "--conversation-id=cid"},
{name: "hidden group alias", flag: "--group=cid"},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller,
"group", "audit-join-validation",
tc.flag,
"--record-id=123",
"--applicant=D-applicant",
"--inviter=D-inviter",
"--status=AuditDelete",
"--description=deny",
)
if err != nil {
t.Fatal(err)
}
want := map[string]any{
"openConversationId": "cid",
"applyRecordId": int64(123),
"applicantUid": "D-applicant",
"inviterUid": "D-inviter",
"status": "AuditDelete",
"auditDescription": "deny",
}
if caller.calls != 1 || caller.server != "im" || caller.tool != "audit_join_group" || !reflect.DeepEqual(caller.args, want) {
t.Fatalf("call = count:%d server:%q tool:%q args:%#v, want %#v", caller.calls, caller.server, caller.tool, caller.args, want)
}
})
}
}
func TestCrossPlatformCoverageChatIMIDMigrationRequiredFlagErrors(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
tests := []struct {
name string
path []string
flag map[string]string
want string
}{
{name: "message list mutually exclusive targets", path: []string{"message", "list"}, flag: map[string]string{"conversation-id": "cid", "user": "u1", "time": "2026-01-01"}, want: "mutually exclusive"},
{name: "message list missing target", path: []string{"message", "list"}, flag: map[string]string{"time": "2026-01-01"}, want: "--conversation-id, --user or --open-dingtalk-id is required"},
{name: "topic replies missing conversation", path: []string{"message", "list-topic-replies"}, flag: map[string]string{"topic-id": "t1"}, want: "conversation-id"},
{name: "read status missing conversation", path: []string{"message", "read-status"}, flag: map[string]string{"message-id": "m1"}, want: "conversation-id"},
{name: "read status conflicting aliases", path: []string{"message", "read-status"}, flag: map[string]string{"conversation-id": "cid1", "group": "cid2", "message-id": "m1"}, want: "conflicts"},
{name: "read status missing message", path: []string{"message", "read-status"}, flag: map[string]string{"conversation-id": "cid"}, want: "message-id"},
{name: "update text emotion missing message", path: []string{"message", "update-text-emotion"}, flag: map[string]string{"conversation-id": "cid", "old-emotion-id": "e1", "emotion-id": "e2", "emotion-name": "n", "text": "t", "background-id": "b"}, want: "message-id"},
{name: "update text emotion missing detail flag", path: []string{"message", "update-text-emotion"}, flag: map[string]string{"conversation-id": "cid", "message-id": "m1", "old-emotion-id": "e1", "emotion-id": "e2", "emotion-name": "n", "text": "t"}, want: "background-id"},
{name: "transfer owner missing conversation", path: []string{"group", "transfer-owner"}, flag: map[string]string{"new-owner": "D1"}, want: "conversation-id"},
{name: "invite url missing conversation", path: []string{"group", "invite-url"}, want: "conversation-id"},
{name: "quit missing conversation", path: []string{"group", "quit"}, want: "conversation-id"},
{name: "update icon missing conversation", path: []string{"group", "update-icon"}, flag: map[string]string{"icon-media-id": "@media"}, want: "conversation-id"},
{name: "update settings missing conversation", path: []string{"group", "update-settings"}, flag: map[string]string{"setting-key": "searchable"}, want: "conversation-id"},
{name: "set admin missing conversation", path: []string{"group", "set-admin"}, flag: map[string]string{"users": "D1"}, want: "conversation-id"},
{name: "role list missing conversation", path: []string{"group-role", "list"}, want: "group"},
{name: "role add missing conversation", path: []string{"group-role", "add"}, flag: map[string]string{"name": "role"}, want: "conversation-id"},
{name: "role update missing conversation", path: []string{"group-role", "update"}, flag: map[string]string{"role-id": "r1", "name": "role"}, want: "conversation-id"},
{name: "role remove missing conversation", path: []string{"group-role", "remove"}, flag: map[string]string{"role-id": "r1"}, want: "conversation-id"},
{name: "role set user missing conversation", path: []string{"group-role", "set-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"},
{name: "role remove user missing conversation", path: []string{"group-role", "remove-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"},
{name: "role query user missing conversation", path: []string{"group-role", "query-user"}, flag: map[string]string{"user": "D1"}, want: "conversation-id"},
{name: "bots missing legacy group", path: []string{"group", "bots"}, want: "group"},
{name: "bots rejects migrated conversation id", path: []string{"group", "bots"}, flag: map[string]string{"conversation-id": "cid"}, want: "no such flag"},
{name: "dismiss missing conversation", path: []string{"group", "dismiss"}, flag: map[string]string{"yes": "true"}, want: "conversation-id"},
{name: "set history missing conversation", path: []string{"group", "set-history"}, flag: map[string]string{"option": "ALL"}, want: "conversation-id"},
{name: "set pin missing message", path: []string{"message", "set-pin-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
{name: "unset pin missing message", path: []string{"message", "unset-pin-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
{name: "audit join missing conversation", path: []string{"group", "audit-join-validation"}, flag: map[string]string{"record-id": "1", "applicant": "D1", "inviter": "D2", "status": "AuditApprove"}, want: "conversation-id"},
{name: "set top missing message", path: []string{"message", "set-top-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
{name: "unset top missing message", path: []string{"message", "unset-top-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
{name: "update alias missing conversation", path: []string{"group", "update-alias"}, flag: map[string]string{"alias-title": "alias"}, want: "conversation-id"},
{name: "notice create missing conversation", path: []string{"group", "notice", "create"}, flag: map[string]string{"content": "hello"}, want: "conversation-id"},
{name: "notice edit missing conversation", path: []string{"group", "notice", "edit"}, flag: map[string]string{"notice-id": "n1", "content": "hello"}, want: "conversation-id"},
{name: "notice get missing conversation", path: []string{"group", "notice", "get"}, flag: map[string]string{"notice-id": "n1"}, want: "conversation-id"},
{name: "notice list missing conversation", path: []string{"group", "notice", "list"}, want: "conversation-id"},
}
probe := newChatCommand()
messageList, _, err := probe.Find([]string{"message", "list"})
if err != nil {
t.Fatal(err)
}
if got, err := chatConversationID(messageList); err != nil || got != "" {
t.Fatalf("empty chatConversationID = %q, %v", got, err)
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := runChatCoverageDirect(t, tc.path, tc.flag)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want containing %q", err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageChatMessageReadStatusConversationAliasesExecute(t *testing.T) {
for _, alias := range []string{"group", "id", "chat", "open-conversation-id"} {
t.Run(alias, func(t *testing.T) {
caller := &scriptedToolCaller{}
if err := runChatCoverageCommand(t, caller, "message", "read-status", "--"+alias, "cid-1", "--message-id", "msg-1"); err != nil {
t.Fatal(err)
}
if caller.server != "im" || caller.tool != "query_msg_read_status" {
t.Fatalf("call = %s/%s, want im/query_msg_read_status", caller.server, caller.tool)
}
if got := caller.args["openConversationId"]; got != "cid-1" {
t.Fatalf("openConversationId = %#v, want cid-1", got)
}
if got := caller.args["openMessageId"]; got != "msg-1" {
t.Fatalf("openMessageId = %#v, want msg-1", got)
}
})
}
}
func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
@@ -594,7 +343,6 @@ func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *te
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "conversation-info", "--open-dingtalk-id=D1")
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "conversation-info", "--user=D1")
_ = runChatCoverageCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"D1"}]}`}, {text: `{}`}}}, "conversation-info", "--user=u1")
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "message", "send-card", "--open-dingtalk-id=D1")
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "message", "send-card", "--receiver=D1")
oldGet := httpGetFile
+2 -170
View File
@@ -17,88 +17,8 @@ import (
"bytes"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageChatGroupAuditJoinValidationAliasContract(t *testing.T) {
cmd := newChatCommand()
leaf, _, err := cmd.Find([]string{"group", "audit-join-validation"})
if err != nil {
t.Fatal(err)
}
canonical := leaf.Flags().Lookup("conversation-id")
if canonical == nil || canonical.Hidden {
t.Fatalf("conversation-id flag = %#v, want visible canonical", canonical)
}
legacy := leaf.Flags().Lookup("group")
if legacy == nil || !legacy.Hidden {
t.Fatalf("group flag = %#v, want hidden compatibility alias", legacy)
}
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "conversation-id" {
t.Fatalf("group alias_of annotation = %#v", got)
}
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
t.Fatalf("group alias_origin annotation = %#v", got)
}
if got := legacy.Annotations[cobra.BashCompOneRequiredFlag]; len(got) != 0 {
t.Fatalf("hidden group alias kept required annotation: %#v", got)
}
}
func TestCrossPlatformCoverageChatGroupAuditJoinValidationRestoreRequiredNoop(t *testing.T) {
restoreChatGroupBotsLegacyRequired(nil)
restoreChatPendingMigrationCanonicalRequired(nil)
root := &cobra.Command{Use: "chat"}
root.AddCommand(&cobra.Command{Use: "other"})
restoreChatGroupBotsLegacyRequired(root)
restoreChatPendingMigrationCanonicalRequired(root)
}
func TestCrossPlatformCoverageChatGroupBotsKeepsLegacyGroupFlag(t *testing.T) {
cmd := newChatCommand()
leaf, _, err := cmd.Find([]string{"group", "bots"})
if err != nil {
t.Fatal(err)
}
group := leaf.Flags().Lookup("group")
if group == nil || group.Hidden {
t.Fatalf("group flag = %#v, want visible legacy flag", group)
}
if got := group.Annotations[cobra.BashCompOneRequiredFlag]; len(got) == 0 || got[0] != "true" {
t.Fatalf("group required annotation = %#v, want true", got)
}
if leaf.Flags().Lookup("conversation-id") != nil {
t.Fatalf("chat group bots still exposes migrated --conversation-id")
}
if leaf.Flags().Lookup("group-name") != nil {
t.Fatalf("chat group bots still exposes migrated --group-name")
}
}
func TestCrossPlatformCoverageChatPendingMigrationAliasesMatchManifest(t *testing.T) {
cmd := newChatCommand()
leaf, _, err := cmd.Find([]string{"group", "dismiss"})
if err != nil {
t.Fatal(err)
}
canonical := leaf.Flags().Lookup("conversation-id")
if canonical == nil {
t.Fatal("missing conversation-id flag")
}
if got := canonical.Annotations[cobra.BashCompOneRequiredFlag]; len(got) == 0 || got[0] != "true" {
t.Fatalf("conversation-id required annotation = %#v, want true", got)
}
legacy := leaf.Flags().Lookup("group")
if legacy == nil || !legacy.Hidden {
t.Fatalf("group flag = %#v, want hidden legacy alias", legacy)
}
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "conversation-id" {
t.Fatalf("group alias_of annotation = %#v", got)
}
}
func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing.T) {
tests := []struct {
name string
@@ -131,7 +51,7 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing
contains: []string{
"send -> query-send-status -> edit",
"query-send-status --open-task-id <上一步返回的openTaskId>",
"edit --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId>",
"edit --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
},
notContain: "chat message list",
},
@@ -141,7 +61,7 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing
contains: []string{
"send -> query-send-status -> recall",
"query-send-status --open-task-id <上一步返回的openTaskId>",
"recall --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId>",
"recall --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
},
notContain: "chat message list",
},
@@ -263,91 +183,3 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsOptionalTimeDefaults(t *te
})
}
}
func TestCrossPlatformCoverageChatReactionHelpKeepsManifestExternalAliasesVisible(t *testing.T) {
for _, command := range []string{"add-emoji", "remove-emoji", "add-text-emotion", "remove-text-emotion"} {
t.Run(command, func(t *testing.T) {
cmd := newChatCommand()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetErr(&output)
cmd.SetArgs([]string{"message", command, "--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("chat message %s --help: %v\n%s", command, err, output.String())
}
help := output.String()
if !strings.Contains(help, "--conversation-id") {
t.Fatalf("chat message %s help missing --conversation-id:\n%s", command, help)
}
for _, visible := range []string{"--group", "--id", "--chat"} {
if !strings.Contains(help, visible+" string") {
t.Fatalf("chat message %s help hides manifest-external alias %s:\n%s", command, visible, help)
}
}
})
}
}
func TestCrossPlatformCoverageChatGroupBotsHelpKeepsLegacyGroup(t *testing.T) {
cmd := newChatCommand()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetErr(&output)
cmd.SetArgs([]string{"group", "bots", "--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("chat group bots --help: %v\n%s", err, output.String())
}
help := output.String()
if !strings.Contains(help, "--group string") {
t.Fatalf("chat group bots help missing visible --group:\n%s", help)
}
for _, hidden := range []string{"--conversation-id", "--group-name"} {
if strings.Contains(help, hidden) {
t.Fatalf("chat group bots help exposes migrated flag %s:\n%s", hidden, help)
}
}
}
func TestCrossPlatformCoverageChatSendCardHelpUsesCanonicalIDFlags(t *testing.T) {
cmd := newChatCommand()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetErr(&output)
cmd.SetArgs([]string{"message", "send-card", "--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("chat message send-card --help: %v\n%s", err, output.String())
}
help := output.String()
for _, visible := range []string{"--conversation-id", "--open-dingtalk-id"} {
if !strings.Contains(help, visible) {
t.Fatalf("send-card help missing %s:\n%s", visible, help)
}
}
for _, visible := range []string{"--group", "--receiver"} {
if !strings.Contains(help, visible+" string") {
t.Fatalf("send-card help hides manifest-external alias %s:\n%s", visible, help)
}
}
}
func TestCrossPlatformCoverageChatGroupAuditJoinValidationHelpUsesCanonicalConversationID(t *testing.T) {
cmd := newChatCommand()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetErr(&output)
cmd.SetArgs([]string{"group", "audit-join-validation", "--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("chat group audit-join-validation --help: %v\n%s", err, output.String())
}
help := output.String()
if !strings.Contains(help, "--conversation-id") {
t.Fatalf("audit-join-validation help missing --conversation-id:\n%s", help)
}
if strings.Contains(help, "--group string") {
t.Fatalf("audit-join-validation help exposes hidden --group alias:\n%s", help)
}
}
+19 -67
View File
@@ -81,32 +81,6 @@ func TestCrossPlatformCoverageChatUpdateTextEmotion(t *testing.T) {
},
},
},
{
name: "open-conversation-id alias",
args: []string{
"message", "update-text-emotion",
"--open-conversation-id", "conv-3",
"--message-id", "msg-3",
"--old-emotion-id", "old-3",
"--emotion-id", "new-3",
"--emotion-name", "smile",
"--text", "done",
"--background-id", "im_bg_2",
},
want: guardedMutationCall{
productID: "im",
toolName: "update_text_emotion",
args: map[string]any{
"openConversationId": "conv-3",
"openMsgId": "msg-3",
"oldEmotionId": "old-3",
"emotionId": "new-3",
"emotionName": "smile",
"text": "done",
"backgroundId": "im_bg_2",
},
},
},
}
for _, test := range tests {
test := test
@@ -153,7 +127,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
{
name: "missing conversation-id and aliases",
args: dropFlag("--conversation-id"),
wantErr: "missing required flag: --conversation-id (or --group / --id / --chat / --open-conversation-id)",
wantErr: "at least one of the flags in the group [conversation-id group id chat] is required",
},
{
name: "missing old-emotion-id",
@@ -161,7 +135,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
wantErr: `required flag(s) "old-emotion-id" not set`,
},
{
name: "missing message-id and background-id",
name: "missing msg-id and background-id",
args: []string{
"message", "update-text-emotion",
"--conversation-id", "conv-1",
@@ -170,7 +144,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
"--emotion-name", "like",
"--text", "nice",
},
wantErr: "missing required flag: --message-id (or --msg-id / --open-message-id)",
wantErr: `required flag(s) "background-id", "msg-id" not set`,
},
}
for _, test := range tests {
@@ -189,41 +163,19 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
}
func TestCrossPlatformCoverageChatGroupGetMuteConfig(t *testing.T) {
for _, test := range []struct {
name string
args []string
want guardedMutationCall
}{
{
name: "legacy group alias",
args: []string{"group", "get-mute-config", "--group", "conv-1"},
want: guardedMutationCall{
productID: "im",
toolName: "get_group_mute_config",
args: map[string]any{"openConversationId": "conv-1"},
},
},
{
name: "canonical conversation id",
args: []string{"group", "get-mute-config", "--conversation-id", "conv-2"},
want: guardedMutationCall{
productID: "im",
toolName: "get_group_mute_config",
args: map[string]any{"openConversationId": "conv-2"},
},
},
} {
test := test
t.Run(test.name, func(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand, test.args...)
if err != nil {
t.Fatalf("get-mute-config returned error: %v", err)
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], test.want) {
t.Fatalf("tool calls = %#v, want %#v", caller.calls, test.want)
}
})
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "get-mute-config", "--group", "conv-1")
if err != nil {
t.Fatalf("get-mute-config returned error: %v", err)
}
want := guardedMutationCall{
productID: "im",
toolName: "get_group_mute_config",
args: map[string]any{"openConversationId": "conv-1"},
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
t.Fatalf("tool calls = %#v, want %#v", caller.calls, want)
}
}
@@ -244,12 +196,12 @@ func TestCrossPlatformCoverageChatGroupGetMuteConfigRecordsRawArgs(t *testing.T)
}
}
func TestCrossPlatformCoverageChatGroupGetMuteConfigRequiresConversationID(t *testing.T) {
func TestCrossPlatformCoverageChatGroupGetMuteConfigRequiresGroup(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"group", "get-mute-config")
if err == nil || !strings.Contains(err.Error(), "--conversation-id") {
t.Fatalf("err = %v, want message containing --conversation-id", err)
if err == nil || !strings.Contains(err.Error(), "--group") {
t.Fatalf("err = %v, want message containing --group", err)
}
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %#v, want none", caller.calls)
+1 -1
View File
@@ -1226,7 +1226,7 @@ func newDriveCommand() *cobra.Command {
driveListCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (--versions 模式下必填)")
driveListCmd.Flags().String("pattern", "", "按名称通配过滤结果,如 \"*日报*\" (客户端过滤) (可选)")
driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥;与 --workspace 组合时走知识库递归 (可选)")
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥;扫描触发 2000 条上限或途中目录读取失败时报错,不产出不完整的 Top-N (可选)")
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥 (可选)")
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (--depth>1 或 --latest 多页扫描时有效) (可选)")
driveListCmd.Flags().String("type", "", "按节点类型过滤: file|folder(客户端过滤:全量扫描后筛,钉盘/知识库均可用;与 --versions/--cursor/--order-by/--order/--limit 互斥)(可选)")
driveListCmd.Flags().String("start", "", "按修改时间过滤·起始: 相对时间如 24h/7d/2w、RFC3339、YYYY-MM-DD(客户端过滤,互斥同 --type)(可选)")
+6 -211
View File
@@ -10,14 +10,11 @@ import (
"os/signal"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
)
// ──────────────────────────────────────────────────────────
@@ -299,11 +296,6 @@ bfs:
if folderErr != nil {
if driveDepthUnrecoverable(folderErr) {
if latest > 0 {
// 不完整集合上的 Top-N 会被误读为全局最新:latest 下不吐 partial,
// 直接回根因错误(auth 过期 / 网络不可达比通用 token 更可操作)。
return folderErr
}
// partial 照吐 stdout,错误详情走 stderr,非零退出
errs = append(errs, newDriveDepthError(folder, folderErr))
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth, route, filter); emitErr != nil {
@@ -339,206 +331,17 @@ bfs:
}
}
// BFS 序与修改时间无关:截断与递归途中目录失败都让未扫区域可能含更新文件,
// 此时的 Top-N 不是全局最新,两者同属一条防线——拒绝以成功状态产出。
if latest > 0 && (truncated || len(errs) > 0) {
return driveLatestIncompleteError(latest, truncated, errs, driveLatestScopeFromCmd(cmd, maxDepth, rootFolderID))
if truncated && latest > 0 {
return &CLIError{
Code: CodeContentTruncated,
Message: fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d", driveDepthMaxItems, latest),
Suggestion: fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 dws drive list --folder <子目录ID> --latest %d", latest),
}
}
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth, route, filter)
}
// driveLatestScope 是原调用的完整候选集快照,用于生成不改变候选集的恢复命令。
//
// 恢复命令若丢掉任何一项,用户照抄后都会在**另一个集合**上拿到一份「看起来对」的 Top-N ——
// 比直接报错更难发现:丢 --workspace/--space-id 会从知识库切到普通钉盘(或反之);丢 --folder
// 会从子树跳到空间根;丢 --pattern/--type/--start/--end 会把全部条目纳入排序基。
type driveLatestScope struct {
// domain 是查询域 flag 串(如 "--workspace ws-1" / "--space-id sp-1"),无则空串。
domain string
// filters 是决定候选集的过滤 flag 串(--pattern/--type/--start/--end),无则空串。
filters string
// folder 是原调用实际使用的扫描根(已解析的 ID,非用户原始 URL),空则为空间根。
folder string
// depth 是原调用的 --depth 层数,让「去掉 --latest 重跑」的恢复命令给出确切层数。
depth int
// notes 收集无法安全内联进可执行命令的原值展示行。POSIX 构建下恒为空(单引号足够);
// Windows 构建下含元字符的值走这里,命令里只留占位符。
notes []string
// 以上 domain/filters/folder 里的值全部经 driveLatestScopeValue 渲染:恢复命令是给用户
// 直接复制到 shell 执行的,而 workspace 的常见形态就是带 & 查询串的 URL,pattern 又天然
// 含 * 与中文,裸拼接会改变命令解析。
}
// driveLatestValueRenderer 把用户值渲染成可内联的命令片段;ok 为 false 表示该值在目标 shell
// 下无法安全内联。取成参数而非直接调用平台绑定函数,是为了让任一平台的测试都能驱动另一平台
// 的降级分支 —— 否则「Windows 上降级为占位符」这条路在 POSIX 机器上永不可达、无法验证。
type driveLatestValueRenderer func(string) (string, bool)
// value 渲染单个用户值供内联。值在目标 shell 下无法安全内联时,登记一条展示行并返回占位符
// —— 宁可让用户手动粘一次,也不能给出一条粘贴即执行额外命令的「恢复命令」。
// 展示行用 strconv.Quote 包裹并显式声明非可执行,与 internal/auth 侧展示 profile 标识一致。
func (s *driveLatestScope) value(render driveLatestValueRenderer, label, v string) string {
if inline, ok := render(v); ok {
return inline
}
s.notes = append(s.notes, fmt.Sprintf("%s 原值(仅作数据展示,不是可执行命令)%s", label, strconv.Quote(v)))
return driveLatestUnsafeValuePlaceholder
}
// driveLatestUnsafeValuePlaceholder 是不可内联值在命令中的占位符。
const driveLatestUnsafeValuePlaceholder = "<见下方原值>"
// driveLatestScopeFromCmd 从原命令抽完整候选集。--workspace 决定路由(知识库 vs 钉盘),判定与
// drive list 里的路由分支同源(同一个 flagOrFallback(cmd, "workspace", "workspace-id"));
// 钉盘侧的 --space-id 同样必须保留。目录 flag 名无需按路由切换:--folder 两条路由都接受。
//
// rootFolder 取 runDriveListDepth 实际使用的扫描根而非重新读 flag:用户可能传的是 URL,
// 解析后的 ID 才是真正被扫描的目标,也是照抄时更精确的形态。
func driveLatestScopeFromCmd(cmd *cobra.Command, depth int, rootFolder string) driveLatestScope {
return driveLatestScopeFrom(cmd, depth, rootFolder, driveLatestScopeValue)
}
// driveLatestScopeFrom 是 driveLatestScopeFromCmd 的可注入本体:render 决定用户值以何种形态
// 进入恢复命令。生产路径固定传平台绑定的 driveLatestScopeValue;测试可传另一平台的策略,
// 从而在单一平台上覆盖两种形态。
func driveLatestScopeFrom(cmd *cobra.Command, depth int, rootFolder string, render driveLatestValueRenderer) driveLatestScope {
scope := driveLatestScope{depth: depth}
if workspaceID := flagOrFallback(cmd, "workspace", "workspace-id"); workspaceID != "" {
scope.domain = "--workspace " + scope.value(render, "--workspace", workspaceID)
} else if spaceID, _ := cmd.Flags().GetString("space-id"); spaceID != "" {
scope.domain = "--space-id " + scope.value(render, "--space-id", spaceID)
}
if rootFolder != "" {
scope.folder = scope.value(render, "--folder", rootFolder)
}
// 顺序固定为注册顺序,保证同一组入参每次给出同一条恢复命令(便于用户比对与测试断言)。
filters := make([]string, 0, 4)
for _, name := range []string{"pattern", "type", "start", "end"} {
if v, _ := cmd.Flags().GetString(name); v != "" {
filters = append(filters, "--"+name+" "+scope.value(render, "--"+name, v))
}
}
scope.filters = strings.Join(filters, " ")
return scope
}
// command 拼一条保留原候选集的恢复命令:查询域 + 指定的 --folder + 原过滤条件。
// folderArg 传 "" 表示该条命令不带 --folder(原调用就在空间根时不应凭空塞一个)。
func (s driveLatestScope) command(folderArg string) string {
parts := make([]string, 0, 4)
parts = append(parts, "dws drive list")
if s.domain != "" {
parts = append(parts, s.domain)
}
if folderArg != "" {
parts = append(parts, "--folder "+folderArg)
}
if s.filters != "" {
parts = append(parts, s.filters)
}
return strings.Join(parts, " ")
}
// driveLatestIncompleteError 是排序基不完整时的拒绝产出错误。截断与目录失败共用
// CodeContentTruncated(→ ExitAPI),但 token 分开,便于消费方区分「范围太大」与「读不到」;
// 二者同真时两个 token 都带。拒绝产出后 errors[] 不再进 stdout,失败详情必须落在错误消息里,
// 否则用户完全瞎。调用点已保证 truncated 与 len(errs)>0 至少一真。
func driveLatestIncompleteError(latest int, truncated bool, errs []driveDepthError, scope driveLatestScope) error {
// 目录失败详情排在截断之前:BFS 可以先记下可恢复目录错误、再在别的目录撞上 2000 上限,
// 此时 permission_denied 这类 reason 是用户唯一能动手修的线索,不能被截断提示吞掉。
causes := make([]string, 0, 2)
if len(errs) > 0 {
causes = append(causes, driveLatestFolderFailureCause(errs))
}
if truncated {
causes = append(causes, fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断", driveDepthMaxItems))
}
return &CLIError{
Code: CodeContentTruncated,
Message: fmt.Sprintf("%s,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d",
strings.Join(causes, ";同时 "), latest),
Suggestion: driveLatestIncompleteSuggestion(latest, truncated, len(errs) > 0, scope),
}
}
// driveLatestFolderFailureCause 组装目录失败详情,含首个失败的 folder/depth/reason。
// folderName 空回落 folderID,两者都空回落 <root>。
//
// folderName / folderID / message 三项都是远端可控内容(目录名由共享目录的创建者决定,
// message 是服务端错误文本),必须过 driveLatestSafeRemoteText。Reason 不用过:它是
// classifyDriveDepthReason 的固定三值映射,与服务端字符串无关。
func driveLatestFolderFailureCause(errs []driveDepthError) string {
first := errs[0]
folder := first.FolderName
if folder == "" {
folder = first.FolderID
}
if folder == "" {
folder = "<root>"
}
return fmt.Sprintf("LATEST_SCAN_INCOMPLETE: %d 个目录未读全(首个失败 folder=%s depth=%d reason=%s: %s)",
len(errs), driveLatestSafeRemoteText(folder), first.Depth, first.Reason,
driveLatestSafeRemoteText(first.Message))
}
// driveLatestSafeRemoteText 把远端可控文本压成可安全嵌进单行 stderr 错误消息的形式。
//
// 拒绝产出后 errors[] 不再进 stdout,失败详情改走纯文本错误消息 —— 而 JSON 编码会转义的
// 控制字符在纯文本里会被终端直接执行:ANSI/OSC 序列可以伪造提示、清屏、隐藏后续内容、改窗口
// 标题,在 AI Agent 场景还会污染上下文窗口。latest=0 的既有路径仍把原值放进 errors[] JSON,
// 不受影响,也不该受影响(消费方需要原始数据)。
//
// output.SanitizeForTerminal 负责剥 ANSI/OSC、C0 控制字符与危险 Unicode,但按设计保留 \n 与
// \t;本错误消息是单行叙述,故再把这两者折成空格,避免远端换行把一条错误拆成多行伪造输出。
func driveLatestSafeRemoteText(s string) string {
s = output.SanitizeForTerminal(s)
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\t", " ")
return strings.TrimSpace(s)
}
// driveLatestIncompleteSuggestion 按实际触发的成因给恢复指引。约束两条:
// 1. 每条示例命令都带原查询域(scope.base()),照抄不会切换查询域;
// 2. 每个子句的示例命令与该子句正文一致——「去掉 --latest」的子句示例不带 --latest,
// 否则照抄复现同一错误。
func driveLatestIncompleteSuggestion(latest int, truncated, folderFailed bool, scope driveLatestScope) string {
// 「缩小范围」类命令要求用户换一个目录,故 --folder 给占位符;查询域与原过滤条件由
// command 一并带上,否则照抄后候选集就变了(例如丢掉 --pattern 会对全部条目取 Top-N)。
narrowed := scope.command("<可读子目录ID>")
clauses := make([]string, 0, 3)
if folderFailed {
clauses = append(clauses, "确认目录权限后重试")
}
switch {
case folderFailed && truncated:
// 两个成因都要解:既要换到可读目录,也要把范围缩到 2000 条以内。
clauses = append(clauses, fmt.Sprintf("或用 --folder 缩小到可读子目录、并降低 --depth 层数后重取 Top-%d:%s --latest %d", latest, narrowed, latest))
case folderFailed:
clauses = append(clauses, fmt.Sprintf("或用 --folder 缩小到可读子目录后重取 Top-%d:%s --latest %d", latest, narrowed, latest))
default:
clauses = append(clauses, fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 %s --latest %d", narrowed, latest))
}
// partial+errors[] 承诺限定 --depth>1:单层去掉 --latest 会路由回普通单层 list,本就无
// errors[] 契约,故该子句只在多层时给出,并直接带上原层数。这是唯一一条「按原范围」命令,
// 必须原样带回原 --folder(原调用在空间根时则不带),照抄即复现同一候选集、只是不取 Top-N。
if folderFailed && scope.depth > 1 {
clauses = append(clauses, fmt.Sprintf("需要看失败明细请去掉 --latest 按原范围重跑(同时输出已扫到的 partial 与 errors[] 明细):%s --depth %d", scope.command(scope.folder), scope.depth))
}
// Windows 构建下无法安全引用的值不会进入命令(cmd.exe 不把单引号当引号),原值改在此处以
// 数据行给出,由用户手动替换占位符 —— 少一次复制粘贴的便利,换掉一条粘贴即执行的命令。
if len(scope.notes) > 0 {
clauses = append(clauses, fmt.Sprintf("命令中的 %s 请手动替换为 —— %s",
driveLatestUnsafeValuePlaceholder, strings.Join(scope.notes, "、")))
}
return strings.Join(clauses, ";")
}
// emitDriveDepthCancelled 处理 SIGINT 取消:吐已扫到的 partial(truncated=true)后回退出码 130。
//
// 这里刻意**不**套用 latest 的拒绝产出防线(只有 BFS 尾部 guard 与 unrecoverable 分支走):
// 取消是用户主动发起的,退出码 130 本身已明确告知结果不完整,此时 partial 是用户的预期产物而
// 非冒充全局最新的误导。sortTime 仍由 emitDriveDepthResult 统一剥除,取消路径不例外。
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int, route driveDepthRoute, filter driveListFilter) error {
if err := emitDriveDepthResult(items, errs, true, pattern, latest, reqDepth, route, filter); err != nil {
return err
@@ -586,14 +389,6 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
maxDepth = d
}
}
// sortTime 是内部排序字段(applyDriveListLatest 排 Top-N、applyDriveListFilter 筛时间区间
// 时都已用完),任何输出路径都不得泄露进契约。放在这里一处覆盖三条 emit 路径:正常 emit、
// SIGINT 取消、unrecoverable partial。
// depth/parentId/rel_path 不在此处删——它们是 depth>1 的既有输出契约,
// stripDriveDepthDecorations 仅在单层(reqDepth==1)把这套装饰整体剥掉。
for _, item := range items {
delete(item, "sortTime")
}
if (latest > 0 || filter.active()) && reqDepth == 1 {
stripDriveDepthDecorations(items)
}
@@ -1,731 +0,0 @@
package helpers
import (
"errors"
"fmt"
"strconv"
"strings"
"testing"
"github.com/spf13/cobra"
)
// 本文件锁定 drive list --latest 的两个 P1 行为,独立于 pr868_*_test.go / drive_depth_test.go:
//
// P1-a:sortTime 是内部排序字段,任何输出路径都不得泄露进契约;
// P1-b:递归途中目录读取失败时,Top-N 建立在不完整集合上,必须拒绝产出而非吐 partial。
//
// 改代码前这些断言对 origin/main 应为红:main 采集端无条件写 sortTime、emit 仅在单层
// latest/filter 才剥;main 尾部拒绝 guard 只拦截断、不拦目录失败。
//
// 另锁定评审反馈的三个边界:
//
// 截断与目录失败同真时,失败详情(permission_denied 等)不得被截断提示吞掉;
// 拒绝产出时给的恢复命令必须保留原查询域(--workspace / --space-id),照抄不会切换查询域;
// 恢复命令里的用户值必须过 argv 引用,URL 查询串与 shell 元字符都不能改变命令解析。
//
// 关于 TestCrossPlatformCoverage 前缀:这是门禁约定,不是命名风格。平台覆盖率门禁
// scripts/policy/run-platform-coverage-gate.sh 只跑
// -run '^(TestAllShortcuts|TestCrossPlatformCoverage)',本包新增的生产代码若没有带该前缀的
// 测试覆盖,Coverage (macOS) / (Windows) 会以「changed code coverage 低于 100%」失败
// (本 PR 改名前实测 69.0476%,84 条可执行语句)。改名务必保留前缀。
// assertNoSortTime 断言 stdout 每个 item 都不含内部排序字段 sortTime。
func assertNoSortTime(t *testing.T, result map[string]any) {
t.Helper()
items, ok := result["items"].([]any)
if !ok {
t.Fatalf("items missing or wrong type: %#v", result["items"])
}
for i, raw := range items {
item, ok := raw.(map[string]any)
if !ok {
t.Fatalf("item[%d] not an object: %#v", i, raw)
}
if _, leaked := item["sortTime"]; leaked {
t.Fatalf("item[%d] leaked internal sortTime into output contract: %#v", i, item)
}
}
}
// TestCrossPlatformCoverageDriveLatestNoSortTimeLeak 覆盖 main 的覆盖漏洞:main 的
// TestCrossPlatformCoverageDriveDepthLatestTruncatedAndSortTime 名字带 SortTime,
// 却只断言 TRUNCATED、从不检查输出无 sortTime。这里把三条泄露路径都钉住。
func TestCrossPlatformCoverageDriveLatestNoSortTimeLeak(t *testing.T) {
twoFiles := `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE","modifiedTime":1000},{"fileId":"f2","name":"b.txt","type":"FILE","modifiedTime":2000}]}`
// 场景 A:depth>1 --latest —— 走 applyDriveListLatest(只读 sortTime、不剥),reqDepth>1 不触发 strip。
t.Run("depth_latest", func(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: twoFiles}}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{}); err != nil {
t.Fatalf("runDriveListDepth: %v", err)
}
assertNoSortTime(t, decodeDepthResult(t, out))
})
// 场景 B:--depth 2 无 latest 无 filter —— 走 else 分支树序排序,同样不触发 strip。
t.Run("depth_no_latest", func(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: twoFiles}}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0, driveListFilter{}); err != nil {
t.Fatalf("runDriveListDepth: %v", err)
}
assertNoSortTime(t, decodeDepthResult(t, out))
})
// 场景 C:--depth 2 --type file —— #971 引入的 filter 也读 sortTime(applyDriveListFilter),
// strip 条件 (latest>0||filter.active()) && reqDepth==1 在多层下依旧不成立,泄露面因此变大。
t.Run("depth_filter_no_latest", func(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: twoFiles}}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0, driveListFilter{nodeType: "file"}); err != nil {
t.Fatalf("runDriveListDepth: %v", err)
}
result := decodeDepthResult(t, out)
if len(result["items"].([]any)) != 2 {
t.Fatalf("filter 应保留两个 FILE: %#v", result["items"])
}
assertNoSortTime(t, result)
})
}
// TestCrossPlatformCoverageDriveLatestSigintStillEmitsPartialWithoutSortTime 同时承担两件事:
// 1. P1-a 的第四条路径 —— SIGINT 取消也走 emitDriveDepthResult,同样不得泄露 sortTime;
// 2. SIGINT 契约锁 —— 本次 P1-b 只在 BFS 尾部 guard 与 unrecoverable 分支生效,**不改**
// 取消路径:SIGINT 是用户主动中断、退出码 130 已明确告知不完整,partial 是明确预期。
// 这条测试防止后续误把 fail-closed 扩到取消路径,也为外部评测用例
// test_sigint_exits_130_with_partial 提供本地对照。
func TestCrossPlatformCoverageDriveLatestSigintStillEmitsPartialWithoutSortTime(t *testing.T) {
caller := &scriptedToolCaller{}
out := installDepthCaller(t, caller)
items := []map[string]any{
{"fileId": "f1", "name": "a.txt", "type": "FILE", "sortTime": int64(2000), "rel_path": "a.txt", "depth": 2},
}
errs := []driveDepthError{
{Depth: 1, FolderID: "fid-1", FolderName: "半路目录", Reason: "permission_denied", Message: "denied"},
}
// latest>0 且 reqDepth>1:main 在此不 strip,sortTime 泄露。
err := emitDriveDepthCancelled(items, errs, "", 5, 3, newDrivePanDepthRoute(), driveListFilter{})
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
}
if cancelErr.ExitCode() != 130 {
t.Fatalf("exit code = %d, want 130", cancelErr.ExitCode())
}
result := decodeDepthResult(t, out)
// partial 契约不变:items 与 errors 都照吐,truncated 标记为真。
if len(result["items"].([]any)) != 1 {
t.Fatalf("取消路径应保留 partial items: %#v", result["items"])
}
if len(result["errors"].([]any)) != 1 {
t.Fatalf("取消路径应保留 errors[]: %#v", result["errors"])
}
if result["truncated"] != true {
t.Fatalf("取消结果 truncated = %#v, want true", result["truncated"])
}
assertNoSortTime(t, result)
}
// TestCrossPlatformCoverageDriveLatestRefusesOnFolderFailure 覆盖 P1-b:递归途中一个可恢复目录失败(403/business,
// 非 auth 非限流 → 记 errs[] 跳过),Top-N 落在不完整集合上,必须拒绝产出。
// 构造:根目录成功产出 FOLDER+FILE(collected>0 且 dirA 入队),子目录返回 forbidden.* →
// recoverable → errs=[1]。旧代码尾部 `if truncated && latest>0` 不触发 → emit 吐 partial(err=nil);
// 新代码 `len(errs)>0` → LATEST_SCAN_INCOMPLETE 且 stdout 无 items。
func TestCrossPlatformCoverageDriveLatestRefusesOnFolderFailure(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"dirA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE","modifiedTime":1000}]}`},
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`},
}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{})
if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_INCOMPLETE") {
t.Fatalf("err = %v, want LATEST_SCAN_INCOMPLETE", err)
}
// 拒绝产出:stdout 必须没有 items(不是 partial)。旧代码此处会吐 partial,断言随之失败。
if out.Len() != 0 {
t.Fatalf("expected no stdout on refusal, got: %s", out.String())
}
}
// TestCrossPlatformCoverageDriveLatestRefusesOnTruncationWithFolderFailure 端到端证明「截断 + 目录失败」组合确实可达:
// 根目录出 dirA/dirB 两个子目录 → dirA 权限失败记 errs[] → dirB 返回 2000 条触发全局截断,
// 尾部 guard 拿到 truncated=true 且 len(errs)=1。旧实现在此让 truncated 短路,permission_denied
// 详情整块丢失(评审反馈的阻塞点);现在两个 token 与失败详情都必须在。
func TestCrossPlatformCoverageDriveLatestRefusesOnTruncationWithFolderFailure(t *testing.T) {
useDriveDepthArgs(t)
var bulk strings.Builder
bulk.WriteString(`{"items":[`)
for i := 0; i < driveDepthMaxItems; i++ {
if i > 0 {
bulk.WriteString(",")
}
fmt.Fprintf(&bulk, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifiedTime":%d}`, i, i, 1000+i)
}
bulk.WriteString(`]}`)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"dirA","name":"报表","type":"FOLDER"},{"fileId":"dirB","name":"dirB","type":"FOLDER"}]}`},
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`}, // dirA:可恢复 → 记 errs[]
{text: bulk.String()}, // dirB:撞 2000 上限 → truncated
}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{})
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
}
msg := cliErr.Message
if !strings.Contains(msg, "LATEST_SCAN_TRUNCATED") {
t.Fatalf("组合场景缺 TRUNCATED token: %q", msg)
}
// 旧实现在这里丢掉整段目录失败详情。
if !strings.Contains(msg, "LATEST_SCAN_INCOMPLETE") ||
!strings.Contains(msg, "folder=报表") ||
!strings.Contains(msg, "permission_denied") {
t.Fatalf("组合场景丢失目录失败详情: %q", msg)
}
if out.Len() != 0 {
t.Fatalf("expected no stdout on refusal, got: %s", out.String())
}
}
// TestCrossPlatformCoverageDriveLatestScopeWiredFromCommand 端到端验证查询域从原命令一路带到恢复命令:单测构造器
// 拿不到的是 runDriveListDepth 里的接线(driveLatestScopeFromCmd(cmd, maxDepth, rootFolderID)),这里补上。
func TestCrossPlatformCoverageDriveLatestScopeWiredFromCommand(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"dirA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE","modifiedTime":1000}]}`},
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`},
}}
installDepthCaller(t, caller)
cmd := newDriveListScopeCmd(t, map[string]string{"space-id": "sp-7"})
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{"spaceId": "sp-7"}, "", 4, "", true, 5, driveListFilter{})
var cliErr *CLIError
if !errors.As(err, &cliErr) {
t.Fatalf("err = %T %v", err, err)
}
// 恢复命令必须带原 --space-id,且给出原 --depth 层数。
assertDriveLatestSuggestion(t, cliErr.Suggestion, "--space-id sp-7")
if !strings.Contains(cliErr.Suggestion, "--depth 4") {
t.Fatalf("恢复命令应保留原层数: %q", cliErr.Suggestion)
}
}
// TestCrossPlatformCoverageDriveLatestRefusesOnUnrecoverableFailure 覆盖 P1-b 的另一半:递归途中遇不可恢复错误
// (auth 过期 / 网络不可达)且 latest>0 时,不吐 partial,直接回根因错误。
// 与 latest=0 的既有行为(TestCrossPlatformCoverageRunDriveListDepthUnrecoverable:partial
// + errors[] 进 stdout 后非零退出)对照——latest 下 partial 的 Top-N 会被误读为全局最新,
// 故必须拒绝产出;回根因错误而非 INCOMPLETE token,因为 auth/网络比通用截断提示更可操作。
func TestCrossPlatformCoverageDriveLatestRefusesOnUnrecoverableFailure(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"dirA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE","modifiedTime":1000}]}`},
{text: `{"errorCode":"DWS_SERVICE_UNAUTHORIZED"}`},
}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{})
// 回根因错误:Code 仍是 auth 过期,不被包装成 LATEST_SCAN_INCOMPLETE。
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeAuthTokenExpired {
t.Fatalf("err = %T %v, want CodeAuthTokenExpired", err, err)
}
if strings.Contains(cliErr.Message, "LATEST_SCAN_INCOMPLETE") {
t.Fatalf("unrecoverable 应回根因错误而非 INCOMPLETE 包装: %q", cliErr.Message)
}
// 拒绝产出:不吐 partial(对照 latest=0 时会输出 2 条 items + 1 条 errors)。
if out.Len() != 0 {
t.Fatalf("expected no stdout on refusal, got: %s", out.String())
}
}
// TestCrossPlatformCoverageDriveLatestIncompleteErrorBranches 直接单测构造器的各条分支。
func TestCrossPlatformCoverageDriveLatestIncompleteErrorBranches(t *testing.T) {
// 纯截断分支:errs 为空 → 只有 TRUNCATED。
t.Run("truncated_only", func(t *testing.T) {
err := driveLatestIncompleteError(5, true, nil, driveLatestScope{depth: 3})
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
}
if !strings.Contains(cliErr.Message, "LATEST_SCAN_TRUNCATED") {
t.Fatalf("message = %q", cliErr.Message)
}
if strings.Contains(cliErr.Message, "LATEST_SCAN_INCOMPLETE") {
t.Fatalf("无目录失败时不应出现 INCOMPLETE: %q", cliErr.Message)
}
assertDriveLatestSuggestion(t, cliErr.Suggestion, "")
})
// 纯目录失败分支:未截断 → 只有 INCOMPLETE,Message 含首个失败的 folder/depth/reason。
t.Run("folder_failure_only", func(t *testing.T) {
err := driveLatestIncompleteError(3, false, twoDriveDepthErrors(), driveLatestScope{depth: 3})
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
}
msg := cliErr.Message
if !strings.Contains(msg, "LATEST_SCAN_INCOMPLETE") ||
!strings.Contains(msg, "报表") ||
!strings.Contains(msg, "depth=2") ||
!strings.Contains(msg, "permission_denied") ||
!strings.Contains(msg, "2 个目录未读全") {
t.Fatalf("message = %q", msg)
}
if strings.Contains(msg, "LATEST_SCAN_TRUNCATED") {
t.Fatalf("未截断时不应出现 TRUNCATED: %q", msg)
}
assertDriveLatestSuggestion(t, cliErr.Suggestion, "")
})
// 组合分支(评审反馈的阻塞点):截断与目录失败同真时,旧实现让 truncated 短路、
// permission_denied 这类目录失败详情整块丢失。现在两个 token 都必须在,且失败详情不得被吞。
t.Run("truncated_with_folder_failures", func(t *testing.T) {
err := driveLatestIncompleteError(4, true, twoDriveDepthErrors(), driveLatestScope{depth: 3})
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
}
msg := cliErr.Message
// 两个成因都要可被消费方 token 匹配到。
if !strings.Contains(msg, "LATEST_SCAN_INCOMPLETE") || !strings.Contains(msg, "LATEST_SCAN_TRUNCATED") {
t.Fatalf("组合场景需同时带两个 token: %q", msg)
}
// 目录失败详情必须完整保留——这是用户唯一能动手修的线索。
if !strings.Contains(msg, "报表") ||
!strings.Contains(msg, "depth=2") ||
!strings.Contains(msg, "permission_denied") ||
!strings.Contains(msg, "2 个目录未读全") {
t.Fatalf("组合场景丢失目录失败详情: %q", msg)
}
if !strings.Contains(msg, "拒绝输出不完整的 Top-4") {
t.Fatalf("message 缺少拒绝产出结论: %q", msg)
}
assertDriveLatestSuggestion(t, cliErr.Suggestion, "")
// 组合场景的指引要同时覆盖两条补救:换可读目录 + 降层数。
if !strings.Contains(cliErr.Suggestion, "确认目录权限") || !strings.Contains(cliErr.Suggestion, "--depth") {
t.Fatalf("组合场景 suggestion 需同时给出权限与降层数补救: %q", cliErr.Suggestion)
}
})
// folderName 为空回落 folderID;folderID 也空回落 <root>。
t.Run("folder_fallback", func(t *testing.T) {
byID := driveLatestIncompleteError(1, false, []driveDepthError{{FolderID: "fid-x"}}, driveLatestScope{depth: 2})
if !strings.Contains(byID.Error(), "folder=fid-x") {
t.Fatalf("fallback to folderID: %v", byID)
}
byRoot := driveLatestIncompleteError(1, false, []driveDepthError{{}}, driveLatestScope{depth: 2})
if !strings.Contains(byRoot.Error(), "folder=<root>") {
t.Fatalf("fallback to <root>: %v", byRoot)
}
})
}
// twoDriveDepthErrors 是两条目录失败样本,首条用于断言「首个失败」详情。
func twoDriveDepthErrors() []driveDepthError {
return []driveDepthError{
{Depth: 2, FolderID: "fid-9", FolderName: "报表", Reason: "permission_denied", Message: "denied"},
{Depth: 1, FolderID: "fid-3", FolderName: "归档", Reason: "api_error", Message: "boom"},
}
}
// TestCrossPlatformCoverageDriveLatestScopePreservedInSuggestion 覆盖评审反馈的第二个阻塞点:恢复命令此前固定
// 生成 `dws drive list --folder ...`,把原调用的 --workspace / --space-id 丢掉。用户照抄后
// 会从知识库切到普通钉盘(或从指定钉盘空间切到「我的文件」),在另一个查询域里拿到一份
// 「看起来对」的 Top-N —— 比直接报错更难发现。
func TestCrossPlatformCoverageDriveLatestScopePreservedInSuggestion(t *testing.T) {
cases := []struct {
name string
flags map[string]string
want string
}{
// 知识库路由:--workspace 决定路由,丢了就切到普通钉盘。
{name: "workspace", flags: map[string]string{"workspace": "ws-1"}, want: "--workspace ws-1"},
// 别名路径:--workspace-id 与 --workspace 同源(flagOrFallback)。
{name: "workspace_id_alias", flags: map[string]string{"workspace-id": "ws-alias"}, want: "--workspace ws-alias"},
// 钉盘路由:--space-id 丢了就退回「我的文件」。
{name: "space_id", flags: map[string]string{"space-id": "sp-7"}, want: "--space-id sp-7"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, tc.flags), 3, "")
// 三条成因组合下的恢复命令都必须带原查询域。
for _, variant := range []struct {
label string
truncated bool
errs []driveDepthError
}{
{"truncated_only", true, nil},
{"folder_failure_only", false, twoDriveDepthErrors()},
{"both", true, twoDriveDepthErrors()},
} {
err := driveLatestIncompleteError(5, variant.truncated, variant.errs, scope)
var cliErr *CLIError
if !errors.As(err, &cliErr) {
t.Fatalf("%s: err = %T %v", variant.label, err, err)
}
assertDriveLatestSuggestion(t, cliErr.Suggestion, tc.want)
}
})
}
// --workspace 优先于 --space-id:与 drive list 的路由判定同序(先看 workspace 再看 space-id),
// 否则恢复命令会把知识库查询写成钉盘查询。
t.Run("workspace_wins_over_space_id", func(t *testing.T) {
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, map[string]string{
"workspace": "ws-1", "space-id": "sp-7",
}), 3, "")
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), scope)
suggestion := err.(*CLIError).Suggestion
if !strings.Contains(suggestion, "--workspace ws-1") || strings.Contains(suggestion, "--space-id") {
t.Fatalf("workspace 应优先且不混入 space-id: %q", suggestion)
}
})
// 无查询域时不得凭空造 flag(原调用就是「我的文件」根,硬塞 scope 同样是改查询域)。
t.Run("no_scope_adds_nothing", func(t *testing.T) {
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, nil), 3, "")
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), scope)
suggestion := err.(*CLIError).Suggestion
if strings.Contains(suggestion, "--workspace") || strings.Contains(suggestion, "--space-id") {
t.Fatalf("无 scope 时不应凭空造查询域 flag: %q", suggestion)
}
assertDriveLatestSuggestion(t, suggestion, "")
})
// depth==1(知识库 --latest 单层)时不给 --depth 1:partial+errors[] 契约只在多层成立,
// 硬塞 --depth 1 会让「去掉 --latest 看明细」的子句自相矛盾。
t.Run("single_depth_omits_depth_flag", func(t *testing.T) {
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), driveLatestScope{domain: "--workspace ws-1", depth: 1})
suggestion := err.(*CLIError).Suggestion
if strings.Contains(suggestion, "--depth") {
t.Fatalf("单层不应出现 --depth: %q", suggestion)
}
})
// 多层时给出确切层数,用户无需把 <原层数> 换成数字。
t.Run("multi_depth_emits_actual_depth", func(t *testing.T) {
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), driveLatestScope{domain: "--space-id sp-7", depth: 4})
suggestion := err.(*CLIError).Suggestion
if !strings.Contains(suggestion, "--depth 4") {
t.Fatalf("应给出原层数 --depth 4: %q", suggestion)
}
if strings.Contains(suggestion, "<原层数>") {
t.Fatalf("不应残留占位符: %q", suggestion)
}
})
}
// TestCrossPlatformCoverageDriveLatestScopeQuotesHostileValues 覆盖评审的 P1 阻断项:恢复命令是给用户直接复制到
// shell 里执行的,查询域的值来自用户输入(workspace 常见形态就是带查询串的 URL)。未引用时
// 一个 `&` 就把命令拆成后台任务,`;` / `$()` 更能执行额外内容。
// 断言落在「值被完整包在单引号里」而不只是「出现过」——后者对裸拼接也成立,抓不住缺陷。
func TestCrossPlatformCoverageDriveLatestScopeQuotesHostileValues(t *testing.T) {
cases := []struct {
name string
flag string
value string
want string
}{
// 合法 workspace URL:& 在裸拼接下直接改变 shell 解析(前半段被丢进后台)。
{name: "workspace_url", flag: "workspace", value: "https://alidocs.dingtalk.com/i/nodes/abc?spaceId=1&type=doc",
want: `--workspace 'https://alidocs.dingtalk.com/i/nodes/abc?spaceId=1&type=doc'`},
// 命令替换:裸拼接会在用户复制执行时真的跑起来。
{name: "command_substitution", flag: "workspace", value: "$(id)", want: `--workspace '$(id)'`},
// 分号拆语句。
{name: "semicolon", flag: "space-id", value: "sp-7;id", want: `--space-id 'sp-7;id'`},
// 空格拆参:裸拼接会让 --folder 收到错误的值。
{name: "space", flag: "space-id", value: "sp 7", want: `--space-id 'sp 7'`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// 显式注入 POSIX 策略:本测试断言的是「危险值被单引号正确包住」这一 POSIX 形态。
// Windows 策略下这些值根本不内联(见 ...SuggestionNeverInlinesHostileValue),
// 若走平台绑定,这里在 Windows runner 上会因形态不同而误报。
scope := driveLatestScopeFrom(newDriveListScopeCmd(t, map[string]string{tc.flag: tc.value}), 3, "", driveLatestPosixScopeValue)
err := driveLatestIncompleteError(5, true, twoDriveDepthErrors(), scope)
suggestion := err.(*CLIError).Suggestion
if !strings.Contains(suggestion, tc.want) {
t.Fatalf("查询域未安全引用\n want substring: %s\n got suggestion: %s", tc.want, suggestion)
}
})
}
}
// TestCrossPlatformCoverageDriveLatestScopePreservesFiltersAndFolder 覆盖自动 CR 的 P2:
// --pattern/--type/--start/--end 与 --folder 同样决定 --latest 的候选集合。恢复命令丢掉任一项,
// 用户照抄后就是在**另一个集合**上取 Top-N —— 原调用带 --pattern 时,缺了它的重试命令会对全部
// 条目排序,结果「看起来成功」却答非所问,比直接报错更难发现。
func TestCrossPlatformCoverageDriveLatestScopePreservesFiltersAndFolder(t *testing.T) {
cmd := newDriveListScopeCmd(t, map[string]string{
"workspace": "ws-1",
"pattern": "*日报*",
"type": "file",
"start": "7d",
"end": "2026-08-01",
})
// 注入 POSIX 策略:`--pattern '*日报*'` 这种引用形态是 POSIX 专属,Windows 下该值会降级为
// 占位符 + 展示行(见 ...SuggestionNeverInlinesHostileValue)。走平台绑定会在 Windows 误报。
scope := driveLatestScopeFrom(cmd, 4, "folder-root", driveLatestPosixScopeValue)
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), scope)
suggestion := err.(*CLIError).Suggestion
// 每条示例命令都要带齐查询域与全部过滤条件;pattern 含 * 与中文,必须是引用后的形态。
for _, want := range []string{
"--workspace ws-1",
"--pattern '*日报*'",
"--type file",
"--start 7d",
"--end 2026-08-01",
} {
for _, clause := range strings.Split(suggestion, ";") {
cmdText := extractTrailingDwsCommand(clause)
if cmdText == "" {
continue
}
if !strings.Contains(cmdText, want) {
t.Fatalf("恢复命令丢失候选集条件 %q:\n clause: %s", want, cmdText)
}
}
}
// 「去掉 --latest 按原范围重跑」是唯一的原范围命令,必须原样带回原 --folder 而非占位符,
// 否则它就不是「原范围」。
var origin string
for _, clause := range strings.Split(suggestion, ";") {
if strings.Contains(clause, "去掉 --latest") {
origin = extractTrailingDwsCommand(clause)
}
}
if origin == "" {
t.Fatalf("多层场景应给出「去掉 --latest 按原范围重跑」子句: %q", suggestion)
}
if !strings.Contains(origin, "--folder folder-root") {
t.Fatalf("原范围命令应保留原 --folder: %q", origin)
}
if strings.Contains(origin, "<可读子目录ID>") {
t.Fatalf("原范围命令不应把原 --folder 换成占位符: %q", origin)
}
}
// TestCrossPlatformCoverageDriveLatestScopeOmitsFolderAtSpaceRoot 原调用就在空间根时不得凭空
// 造 --folder:硬塞一个目录同样是改候选集。
func TestCrossPlatformCoverageDriveLatestScopeOmitsFolderAtSpaceRoot(t *testing.T) {
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, map[string]string{"space-id": "sp-7"}), 3, "")
err := driveLatestIncompleteError(2, false, twoDriveDepthErrors(), scope)
suggestion := err.(*CLIError).Suggestion
for _, clause := range strings.Split(suggestion, ";") {
if !strings.Contains(clause, "去掉 --latest") {
continue
}
if strings.Contains(extractTrailingDwsCommand(clause), "--folder") {
t.Fatalf("空间根扫描的原范围命令不应带 --folder: %q", clause)
}
}
}
// TestCrossPlatformCoverageDriveLatestErrorStripsRemoteControlChars 覆盖自动 CR 的 P2 安全项:
// 拒绝产出后,目录名与服务端错误文本从 JSON(编码时会被转义)挪进了纯文本 stderr。若原样透传,
// 其中的 ANSI/OSC 序列会被终端直接执行 —— 可清屏、伪造彩色「成功」、隐藏后续输出、改窗口标题,
// 在 AI Agent 场景还会污染上下文窗口。目录名对共享目录而言是他人可控输入。
func TestCrossPlatformCoverageDriveLatestErrorStripsRemoteControlChars(t *testing.T) {
assertNoControlChars := func(t *testing.T, msg string) {
t.Helper()
for name, r := range map[string]rune{"ESC": 0x1b, "BEL": 0x07, "CR": '\r', "LF": '\n', "TAB": '\t'} {
if strings.ContainsRune(msg, r) {
t.Fatalf("错误消息残留 %s 控制字符: %q", name, msg)
}
}
}
// folderName 路径:CSI 清屏 + 变色,外加 OSC 改标题。
t.Run("folder_name", func(t *testing.T) {
err := driveLatestIncompleteError(3, false, []driveDepthError{{
Depth: 2,
FolderName: "报表\x1b[2J\x1b[31m看起来成功\x1b[0m",
Reason: "permission_denied",
Message: "denied\r\n\x1b]0;pwned\x07次行",
}}, driveLatestScope{depth: 2})
msg := err.(*CLIError).Message
assertNoControlChars(t, msg)
// 清理不能把诊断信息一起抹掉:可读部分必须留下。
if !strings.Contains(msg, "报表") || !strings.Contains(msg, "denied") || !strings.Contains(msg, "次行") {
t.Fatalf("清理后应保留可读文本: %q", msg)
}
})
// folderName 为空时回落到 folderID,该字段同样来自服务端。
t.Run("folder_id_fallback", func(t *testing.T) {
err := driveLatestIncompleteError(1, true, []driveDepthError{{
FolderID: "fid\x1b[1m-x",
Reason: "api_error",
Message: "boom",
}}, driveLatestScope{depth: 1})
msg := err.(*CLIError).Message
assertNoControlChars(t, msg)
if !strings.Contains(msg, "fid-x") {
t.Fatalf("剥离控制序列后 folderID 应连成 fid-x: %q", msg)
}
})
}
// TestCrossPlatformCoverageDriveLatestSafeRemoteText 直接钉住清理函数的各条分支:换行/制表符
// 折成空格(SanitizeForTerminal 按设计保留这两者,但单行错误消息里它们会拆出伪造行),
// 首尾空白收掉,可打印内容与中文原样保留。
func TestCrossPlatformCoverageDriveLatestSafeRemoteText(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{name: "plain", in: "denied", want: "denied"},
{name: "cjk", in: "报表目录", want: "报表目录"},
{name: "csi", in: "a\x1b[31mb", want: "ab"},
{name: "osc", in: "a\x1b]0;t\x07b", want: "ab"},
{name: "newline_to_space", in: "a\nb", want: "a b"},
{name: "tab_to_space", in: "a\tb", want: "a b"},
{name: "carriage_return_dropped", in: "a\rb", want: "ab"},
{name: "trim_outer", in: "\n denied \t", want: "denied"},
{name: "empty", in: "", want: ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := driveLatestSafeRemoteText(tc.in); got != tc.want {
t.Fatalf("driveLatestSafeRemoteText(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// TestCrossPlatformCoverageDriveLatestSuggestionNeverInlinesHostileValue 端到端锁定评审提出的
// Windows 注入阻断项:`--space-id sp-7&whoami` 这种值,在 POSIX 下靠单引号关停,但 cmd.exe
// 不把单引号当引号,粘贴后 `&whoami` 仍会执行。因此不变量必须是「恢复命令里不存在能被目标
// shell 解释的裸元字符」,两个平台分别用各自的手段满足:POSIX 引用内联,Windows 不内联。
//
// 两种形态都通过注入渲染策略在本机验证,不依赖当前 GOOS —— 否则「Windows 上降级为占位符」
// 这条路在 POSIX 机器上永不可达,就成了只有 Windows runner 才跑到的盲区(平台覆盖率门禁也会
// 因此报未覆盖)。平台绑定本身由 TestCrossPlatformCoverageDriveLatestScopeValueBinding 覆盖。
func TestCrossPlatformCoverageDriveLatestSuggestionNeverInlinesHostileValue(t *testing.T) {
const hostile = "sp-7&whoami"
newSuggestion := func(t *testing.T, render driveLatestValueRenderer) string {
t.Helper()
scope := driveLatestScopeFrom(newDriveListScopeCmd(t, map[string]string{"space-id": hostile}), 3, "", render)
return driveLatestIncompleteError(5, true, twoDriveDepthErrors(), scope).(*CLIError).Suggestion
}
t.Run("windows_never_inlines", func(t *testing.T) {
suggestion := newSuggestion(t, driveLatestWindowsScopeValue)
for _, clause := range strings.Split(suggestion, ";") {
cmdText := extractTrailingDwsCommand(clause)
if cmdText == "" {
continue
}
// cmd.exe 里 & 分隔命令,且单引号不是引号,故它压根不能进命令。
if strings.Contains(cmdText, "&") {
t.Fatalf("windows 形态的命令不得含 &: %s", cmdText)
}
if strings.Contains(cmdText, hostile) {
t.Fatalf("windows 形态的命令不得内联原值: %s", cmdText)
}
}
if !strings.Contains(suggestion, driveLatestUnsafeValuePlaceholder) {
t.Fatalf("应降级为占位符: %s", suggestion)
}
if !strings.Contains(suggestion, strconv.Quote(hostile)) {
t.Fatalf("应在展示行给出原值: %s", suggestion)
}
if !strings.Contains(suggestion, "不是可执行命令") {
t.Fatalf("展示行须显式声明非可执行: %s", suggestion)
}
})
t.Run("posix_quotes_inline", func(t *testing.T) {
suggestion := newSuggestion(t, driveLatestPosixScopeValue)
if !strings.Contains(suggestion, "'"+hostile+"'") {
t.Fatalf("posix 形态应单引号内联原值: %s", suggestion)
}
// POSIX 下不该无谓降级 —— 那会白白损失可复制体验。
if strings.Contains(suggestion, driveLatestUnsafeValuePlaceholder) {
t.Fatalf("posix 形态不应降级为占位符: %s", suggestion)
}
for _, clause := range strings.Split(suggestion, ";") {
cmdText := extractTrailingDwsCommand(clause)
if cmdText == "" {
continue
}
if strings.Contains(cmdText, "&") && !strings.Contains(cmdText, "'"+hostile+"'") {
t.Fatalf("posix 形态出现未引用的元字符: %s", cmdText)
}
}
})
}
// newDriveListScopeCmd 造一个带 drive list 查询域与过滤 flag 的命令。flag 名与 newDriveCommand
// 里 driveListCmd 的注册保持一致;workspace-id 是 cross-product 别名,此处显式注册以覆盖别名路径。
func newDriveListScopeCmd(t *testing.T, flags map[string]string) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: "list"}
for _, name := range []string{
"workspace", "workspace-id", "space-id", // 查询域
"folder", // 扫描根(scope 从 rootFolder 参数取,注册仅为对齐真实命令)
"pattern", "type", "start", "end", // 决定候选集的过滤条件
} {
cmd.Flags().String(name, "", "")
}
for name, value := range flags {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s=%s: %v", name, value, err)
}
}
return cmd
}
// assertDriveLatestSuggestion 钉住 Suggestion 的三条约束:
// 1. 每条示例命令都带原查询域 wantScope(空串表示原调用无查询域,此时只跳过该项检查);
// 2. 含 --latest 的引导子句存在;
// 3. 「去掉 --latest」子句给出的示例命令本身不带 --latest(否则照抄复现同一错误)。
func assertDriveLatestSuggestion(t *testing.T, suggestion, wantScope string) {
t.Helper()
clauses := strings.Split(suggestion, ";")
sawLatestGuide := false
for _, clause := range clauses {
cmd := extractTrailingDwsCommand(clause)
if cmd == "" {
continue
}
if wantScope != "" && !strings.Contains(cmd, wantScope) {
t.Fatalf("示例命令丢失原查询域 %q(照抄会切换查询域): %q", wantScope, cmd)
}
if strings.Contains(clause, "去掉 --latest") {
if strings.Contains(cmd, "--latest") {
t.Fatalf("「去掉 --latest」子句的示例命令仍含 --latest: %q", cmd)
}
continue
}
if strings.Contains(cmd, "--latest") {
sawLatestGuide = true
}
}
if !sawLatestGuide {
t.Fatalf("no --latest-bearing guidance clause in suggestion: %q", suggestion)
}
}
// extractTrailingDwsCommand 抽子句里以 "dws " 开头的尾部命令片段(到子句末),无则空串。
func extractTrailingDwsCommand(clause string) string {
idx := strings.LastIndex(clause, "dws ")
if idx < 0 {
return ""
}
return clause[idx:]
}
@@ -1,13 +0,0 @@
//go:build !windows
package helpers
// driveLatestScopeValue 按**本次构建的目标 shell** 渲染恢复命令里的用户值:返回可内联的片段,
// 第二个返回值为 false 时表示该值不能安全进入可执行命令,调用方须改用占位符。
//
// 非 Windows 构建面向 POSIX shell,单引号可靠地关闭所有展开,故含元字符的值引用后内联即安全。
// Windows 构建见 drive_latest_scope_windows.go —— 两个平台的策略本体都是
// shell_quote.go 里的纯函数,可在任意平台被测试直接调用;本文件只做编译期绑定。
func driveLatestScopeValue(value string) (string, bool) {
return driveLatestPosixScopeValue(value)
}
@@ -1,13 +0,0 @@
//go:build windows
package helpers
// driveLatestScopeValue 按**本次构建的目标 shell** 渲染恢复命令里的用户值:返回可内联的片段,
// 第二个返回值为 false 时表示该值不能安全进入可执行命令,调用方须改用占位符。
//
// Windows 构建下没有对 cmd.exe 与 PowerShell 同时成立的引用形式(cmd.exe 不认单引号,双引号
// 又挡不住 %VAR% 展开),故只内联本身就安全的值;理由与取舍详见
// driveLatestWindowsScopeValue 的注释。POSIX 构建见 drive_latest_scope_posix.go。
func driveLatestScopeValue(value string) (string, bool) {
return driveLatestWindowsScopeValue(value)
}
-85
View File
@@ -1,85 +0,0 @@
package helpers
import "strings"
// shellQuoteArg 按 POSIX sh 规则把 s 引用成可安全放进可复制命令的单个 argv 元素。
//
// 为什么需要它:错误提示里的恢复命令是给用户直接复制到 shell 执行的,其中的查询域取自用户
// 输入(--workspace 的常见形态就是带查询串的 URL)。裸拼接下,合法 URL 里的 `&` 就会把命令
// 拆成后台任务,空格会拆参,`;` 与 `$()` 还能执行额外内容。
//
// 为什么不用 strconv.Quote(internal/auth 侧展示 profile 标识的既有做法):那是 Go 语法引号,
// 产出双引号串,而 shell 双引号内 `$()`、反引号、`$VAR` 仍会展开——正是要防的场景。auth 那处
// 成立是因为它显式声明「仅作数据展示,不是可执行命令」,本函数的产物恰恰要能执行。
//
// 策略是「必要时才引用」:全由安全字符组成时原样返回,命令保持可读、在 PowerShell/cmd 下同样
// 可复制;只要含一个非安全字符就整体单引号包裹——单引号内 POSIX sh 不做任何展开($ ` \ ! 全部
// 字面化),是唯一无需逐字符转义的形式。单引号自身无法出现在单引号串内,按 POSIX 标准写法
// 先闭合、拼一个反斜杠转义的单引号、再重开——即把每个单引号替换成下面这四个字符:
//
// '\''
//
// 空串必须显式引用成一对空单引号,否则该参数会从 argv 里整个消失,后面的 token 会被前一个
// flag 吞掉。
func shellQuoteArg(s string) string {
if s == "" {
return "''"
}
if shellValueIsBare(s) {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// shellValueIsBare 报告 s 能否原样出现在命令行里而不改变**任何**常见 shell 的解析:非空,
// 且每个字符都落在保守白名单内。这类值在 POSIX sh、PowerShell 与 cmd.exe 下含义一致,
// 无需引用即可内联进可复制命令。
func shellValueIsBare(s string) bool {
return s != "" && !strings.ContainsFunc(s, shellNeedsQuote)
}
// driveLatestPosixScopeValue 是 POSIX shell 下把用户值放进可复制命令的策略:单引号内 sh 不做
// 任何展开,因此含元字符的值引用后即可安全内联。第二个返回值恒为 true。
func driveLatestPosixScopeValue(value string) (string, bool) {
return shellQuoteArg(value), true
}
// driveLatestWindowsScopeValue 是 Windows 构建下的策略:只内联本身就安全的值,其余一律不进
// 命令,由调用方降级为占位符 + 展示行。
//
// Windows 上不存在「一条命令同时对 cmd.exe 与 PowerShell 都安全」的引用形式:
// - cmd.exe 根本不把单引号当引号,`--space-id 'sp-7&whoami'` 里的 & 仍然分隔命令,
// 粘贴即执行 whoami;
// - cmd.exe 的双引号能挡 & | < >,却挡不住 %VAR% 展开;
// - PowerShell 的单引号是引号,但内嵌单引号写作两个连续单引号,与 POSIX 的
// 闭合-反斜杠转义-重开写法不兼容。
//
// 而生成命令时无法知道用户会粘贴进哪个 shell。既然引用不可靠,就不把不受信任的值放进可执行
// 命令——与 internal/auth 侧「标识仅作数据展示,不是可执行命令」的既有做法同一思路。
func driveLatestWindowsScopeValue(value string) (string, bool) {
if shellValueIsBare(value) {
return value, true
}
return "", false
}
// shellNeedsQuote 报告 r 是否落在「无需引用」白名单之外。
//
// 用白名单而非黑名单:漏掉一个元字符就是一个注入口,而白名单漏一个字符只会多加一对无害的
// 引号。非 ASCII 一律视为需引用——中文目录名很常见,保守处理不会错。
//
// 白名单的门槛是「在 POSIX sh、PowerShell、cmd.exe 下含义都一致」,因此 % 被刻意排除:
// cmd.exe 会展开 %VAR%,一个看似普通的值(URL 里的 %20、含 %PATH% 的名字)原样内联后在
// cmd 里就会变形甚至泄露环境变量。@ 保留:PowerShell 的 splatting 只在 @( 、@{ 与
// @变量名 形态下生效,而那些字符本身都不在白名单里,且值总出现在 --flag 之后而非语句开头。
func shellNeedsQuote(r rune) bool {
switch {
case r >= 'a' && r <= 'z':
return false
case r >= 'A' && r <= 'Z':
return false
case r >= '0' && r <= '9':
return false
}
return !strings.ContainsRune("_@+=:,./-", r)
}
@@ -1,76 +0,0 @@
//go:build !windows
package helpers
import (
"os/exec"
"testing"
)
// TestCrossPlatformCoverageShellQuoteArgRoundTrip 把引用后的串交给真 /bin/sh 求值,证明它被解析回
// **恰好一个、且内容完全相同**的参数。上面那条表驱动测试只能证明「我以为的形态」,这条证明
// 「shell 认的形态」——两者不是一回事,恢复命令是给用户复制到 shell 里跑的,后者才是契约。
//
// 它同时是注入的负向对照:一旦引用失效,$(id) / `id` 会真的执行、空格与 & 会拆参,argc 或
// 取回的值必然不等于原值,测试立刻红。用 build tag 排除 Windows(无 POSIX sh),跨平台形态
// 断言留在 shell_quote_test.go。
func TestCrossPlatformCoverageShellQuoteArgRoundTrip(t *testing.T) {
sh, err := exec.LookPath("sh")
if err != nil {
t.Skipf("POSIX sh unavailable: %v", err)
}
values := []string{
"ws-1",
"https://alidocs.dingtalk.com/i/nodes/x?spaceId=1&type=doc",
"sp 7",
"a;id",
"a&b",
"a|b",
"$(id)",
"`id`",
"$HOME",
"*.txt",
"a>b",
`a\b`,
`a"b`,
"a!b",
"{a,b}",
"(a)",
"~/x",
"a#b",
"it's",
"'",
"a'b'c",
"",
"报表",
"a\tb",
"a\nb",
"--not-a-flag",
}
for _, want := range values {
t.Run(want, func(t *testing.T) {
quoted := shellQuoteArg(want)
// 1) 未被拆参也未被吞掉:位置参数个数必须恰好为 1。
// 引用失效时 "sp 7" 会变 2 个、"" 会变 0 个、$(id) 会变 id 的输出词数。
argc, err := exec.Command(sh, "-c", "set -- "+quoted+`; printf %s "$#"`).Output()
if err != nil {
t.Fatalf("argc probe failed for %q (quoted %s): %v", want, quoted, err)
}
if string(argc) != "1" {
t.Fatalf("argc = %s, want 1 — %q quoted as %s split or vanished", argc, want, quoted)
}
// 2) 内容逐字节相同:展开、命令替换、转义都不得改动值。
got, err := exec.Command(sh, "-c", "set -- "+quoted+`; printf %s "$1"`).Output()
if err != nil {
t.Fatalf("value probe failed for %q (quoted %s): %v", want, quoted, err)
}
if string(got) != want {
t.Fatalf("round-trip mismatch\n input: %q\n quoted: %s\n shell: %q", want, quoted, got)
}
})
}
}
-185
View File
@@ -1,185 +0,0 @@
package helpers
import (
"runtime"
"testing"
)
// TestCrossPlatformCoverageShellQuoteArg 表驱动锁定 argv 引用规则。分两类断言:
// - 安全值原样返回(保持恢复命令可读,且在 PowerShell/cmd 下同样可复制);
// - 含任何 shell 元字符的值整体单引号包裹,内嵌单引号按 POSIX 标准写法处理
// (闭合、拼反斜杠转义的单引号、重开)。
//
// 往返正确性另有 Unix 下用真 sh 求值的对照测试(shell_quote_roundtrip_unix_test.go)。
//
// TestCrossPlatformCoverage 前缀是门禁约定:平台覆盖率门禁只跑该前缀(与 TestAllShortcuts)的
// 测试,shell_quote.go 的覆盖全靠这一条,去掉前缀会让 Coverage (macOS)/(Windows) 直接红。
// 详见 drive_latest_incomplete_test.go 头部说明。
func TestCrossPlatformCoverageShellQuoteArg(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
// —— 无需引用:白名单字符 ——
{name: "plain_id", in: "ws-1", want: "ws-1"},
{name: "digits", in: "1234567890", want: "1234567890"},
{name: "upper_lower", in: "abcXYZ", want: "abcXYZ"},
{name: "all_safe_punct", in: "_@+=:,./-", want: "_@+=:,./-"},
{name: "path_like", in: "/tmp/a.b/c-d_e", want: "/tmp/a.b/c-d_e"},
// —— 必须引用:真实业务形态 ——
// workspace 常见形态就是带查询串的 URL:? 与 & 都不在白名单,& 在裸拼接下会拆命令。
{name: "url_with_query", in: "https://alidocs.dingtalk.com/i/nodes/x?spaceId=1&type=doc",
want: `'https://alidocs.dingtalk.com/i/nodes/x?spaceId=1&type=doc'`},
// —— 必须引用:shell 元字符 ——
{name: "space", in: "sp 7", want: `'sp 7'`},
{name: "tab", in: "a\tb", want: "'a\tb'"},
{name: "newline", in: "a\nb", want: "'a\nb'"},
{name: "semicolon", in: "a;id", want: `'a;id'`},
{name: "ampersand", in: "a&b", want: `'a&b'`},
{name: "pipe", in: "a|b", want: `'a|b'`},
{name: "command_substitution", in: "$(id)", want: `'$(id)'`},
{name: "backtick", in: "`id`", want: "'`id`'"},
{name: "variable", in: "$HOME", want: `'$HOME'`},
// % 不在白名单:cmd.exe 会展开 %VAR%,故含 % 的值一律视为需引用。
{name: "windows_variable", in: "%PATH%", want: `'%PATH%'`},
{name: "url_percent_escape", in: "a%20b", want: `'a%20b'`},
{name: "glob", in: "*.txt", want: `'*.txt'`},
{name: "redirect", in: "a>b", want: `'a>b'`},
{name: "backslash", in: `a\b`, want: `'a\b'`},
{name: "double_quote", in: `a"b`, want: `'a"b'`},
{name: "history_expansion", in: "a!b", want: `'a!b'`},
{name: "brace", in: "{a,b}", want: `'{a,b}'`},
{name: "paren", in: "(a)", want: `'(a)'`},
{name: "tilde", in: "~/x", want: `'~/x'`},
{name: "hash", in: "a#b", want: `'a#b'`},
// —— 单引号:唯一无法直接放进单引号串的字符 ——
{name: "single_quote", in: "it's", want: `'it'\''s'`},
{name: "only_single_quote", in: "'", want: `''\'''`},
{name: "two_single_quotes", in: "a'b'c", want: `'a'\''b'\''c'`},
// —— 边界 ——
// 空串必须显式成 '',否则参数会从 argv 里整个消失(--workspace 吞掉下一个 token)。
{name: "empty", in: "", want: `''`},
// 非 ASCII 一律引用:保守优于漏字符,中文目录名很常见。
{name: "cjk", in: "报表", want: `'报表'`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := shellQuoteArg(tc.in); got != tc.want {
t.Fatalf("shellQuoteArg(%q)\n got: %s\nwant: %s", tc.in, got, tc.want)
}
})
}
}
// TestCrossPlatformCoverageDriveLatestScopeValueStrategies 直接测两条平台策略的本体。它们是
// shell_quote.go 里的纯函数、与构建平台无关,因此**在任一平台都能验证 Windows 侧的行为** ——
// 这一点是刻意设计:真 shell 往返测试只能在 Unix 跑,Windows 的安全属性必须由这里钉住,
// 否则「Windows 上不会把不受信任值放进可执行命令」就成了无人复核的断言。
func TestCrossPlatformCoverageDriveLatestScopeValueStrategies(t *testing.T) {
// 这些值在至少一种目标 shell 下会改变命令解析。
hostile := []struct {
name string
value string
}{
{"cmd_command_separator", "sp-7&whoami"}, // cmd.exe:& 分隔命令,单引号不是引号
{"cmd_variable", "%PATH%"}, // cmd.exe:无条件展开
{"workspace_url", "https://x/y?a=1&b=2"}, // 合法 workspace 形态,含 &
{"space", "sp 7"},
{"pwsh_statement_separator", "a;id"}, // PowerShell:; 分隔语句
{"posix_substitution", "$(id)"},
{"backtick", "`id`"},
{"posix_variable", "$HOME"},
{"pipe", "a|b"},
{"redirect", "a>b"},
{"caret", "a^b"}, // cmd.exe 转义符
{"single_quote", "it's"},
{"cjk", "报表"},
{"empty", ""},
}
for _, tc := range hostile {
t.Run("hostile/"+tc.name, func(t *testing.T) {
// POSIX:始终可内联,但必须是引用后的形态(不能等于原值)。
got, ok := driveLatestPosixScopeValue(tc.value)
if !ok {
t.Fatalf("POSIX 策略应始终可内联: %q", tc.value)
}
if got == tc.value {
t.Fatalf("POSIX 策略未引用危险值: %q", tc.value)
}
// Windows:一律拒绝内联 —— 没有对 cmd.exe 与 PowerShell 同时安全的引用形式。
if inline, ok := driveLatestWindowsScopeValue(tc.value); ok {
t.Fatalf("Windows 策略不得内联危险值 %q(得到 %q)", tc.value, inline)
}
})
}
// 安全值:两条策略都原样内联,命令保持可读、跨 shell 可复制。
for _, v := range []string{"ws-1", "sp-7", "folder-root", "modifyTime", "7d", "2026-08-01", "a.b/c-d_e", "a@b"} {
t.Run("bare/"+v, func(t *testing.T) {
if got, ok := driveLatestPosixScopeValue(v); !ok || got != v {
t.Fatalf("POSIX 应原样内联安全值 %q: got %q ok=%v", v, got, ok)
}
if got, ok := driveLatestWindowsScopeValue(v); !ok || got != v {
t.Fatalf("Windows 应原样内联安全值 %q: got %q ok=%v", v, got, ok)
}
})
}
// 空串在 POSIX 下引用成一对空单引号(否则参数会从 argv 消失);Windows 侧归入不可内联。
if got, ok := driveLatestPosixScopeValue(""); !ok || got != "''" {
t.Fatalf("POSIX 空串应引用成一对空单引号: got %q ok=%v", got, ok)
}
}
// TestCrossPlatformCoverageShellBareCharsetHasNoMetacharacters 锁定「安全值可原样内联」的根本
// 前提:白名单里不能含任何一种目标 shell 会特殊解释的字符。
//
// 这比「用某个 shell 跑一遍」更本质 —— 内联路径的安全性不来自引用正确,而来自字符集本身无害;
// 而拒绝内联的路径(Windows 侧)连引用都不需要。三套元字符合并检查:POSIX sh、PowerShell、
// cmd.exe。`%` 曾因 URL 转义(%20)被误列入白名单,而 cmd.exe 会无条件展开 %VAR%,这条测试
// 同时是该缺陷的回归锁。
func TestCrossPlatformCoverageShellBareCharsetHasNoMetacharacters(t *testing.T) {
// 逐字符列出,避免用字符串字面量时漏掉转义细节。
meta := []rune{
'&', '|', '<', '>', ';', '(', ')', '{', '}', '[', ']', // 分隔/分组
'$', '`', '"', '\'', '\\', // 引用与替换
'^', '%', // cmd.exe:转义符与变量展开
'*', '?', '~', '#', '!', // 通配、家目录、注释、历史/取反
' ', '\t', '\n', '\r', // 空白:拆参与换行注入
}
for _, r := range meta {
if !shellNeedsQuote(r) {
t.Fatalf("shell 元字符 %q 落在「无需引用」白名单内,安全值会被原样内联", string(r))
}
}
// 反向哨兵:常规标识符字符必须留在白名单内,否则恢复命令会被无谓地全量引用/降级。
for _, r := range []rune{'a', 'Z', '0', '9', '_', '-', '.', '/', ':', ',', '=', '+', '@'} {
if shellNeedsQuote(r) {
t.Fatalf("常规字符 %q 被判为需引用,会让恢复命令可读性无谓下降", string(r))
}
}
}
// TestCrossPlatformCoverageDriveLatestScopeValueBinding 断言当前构建绑定到了正确的策略。
// 这条测试在每个平台各自成立,把「哪个平台用哪条策略」也纳入 CI(Coverage (Windows) 会跑它)。
func TestCrossPlatformCoverageDriveLatestScopeValueBinding(t *testing.T) {
const hostile = "sp-7&whoami"
inline, ok := driveLatestScopeValue(hostile)
if runtime.GOOS == "windows" {
if ok {
t.Fatalf("windows 构建不得内联 %q(得到 %q)", hostile, inline)
}
return
}
if !ok {
t.Fatalf("posix 构建应可内联 %q", hostile)
}
if inline != `'sp-7&whoami'` {
t.Fatalf("posix 构建应单引号包裹: %s", inline)
}
}
+6 -3
View File
@@ -428,7 +428,7 @@ func newWikiCommand() *cobra.Command {
})
// space create flags
spaceCreateCmd.Flags().String("name", "", "知识库名称 (必填,不超过 100 字符)")
spaceCreateCmd.Flags().String("name", "", "知识库名称 (必填,不超过 32 字符)")
spaceCreateCmd.Flags().String("desc", "", "知识库描述 (选填,不超过 500 字符)")
spaceCreateCmd.Flags().String("icon", "", "知识库图标标识 (选填)")
@@ -696,7 +696,7 @@ func newWikiCommand() *cobra.Command {
Short: "查询知识库成员列表",
Long: `查询指定知识库的成员列表,返回每位成员的 userId、姓名、角色等信息。
注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 200)。
注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 50)。
若结果被截断(出参 truncated=true),可通过 --filter-role 收窄查询范围;
ORG 类型授权不会出现在查询结果中。`,
Example: ` dws wiki member list --workspace <workspaceId>
@@ -717,6 +717,9 @@ ORG 类型授权不会出现在查询结果中。`,
limit, _ = cmd.Flags().GetInt("max-results")
}
if limit > 0 {
if limit > 50 {
return fmt.Errorf("--limit 不能超过 50;底层成员接口不提供游标续页")
}
toolArgs["maxResults"] = limit
}
if v := mustGetFlag(cmd, "filter-role"); v != "" {
@@ -762,7 +765,7 @@ ORG 类型授权不会出现在查询结果中。`,
})
memberListCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
memberListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
memberListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50;底层不支持游标续页")
memberListCmd.Flags().Int("max-results", 0, "")
_ = memberListCmd.Flags().MarkHidden("max-results")
memberListCmd.Flags().String("filter-role", "", "按角色过滤(逗号分隔):OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
@@ -116,8 +116,8 @@ func requireWukongWeeklySyncConfirmation(t *testing.T, err error) {
func TestCrossPlatformCoverageWukongWeeklyChatCategoryQueries(t *testing.T) {
caller := &wukongWeeklySyncCaller{}
_, _, err := executeWukongWeeklySyncCommand(t, "chat", caller, newChatCommand, "category", "list-by-conv")
if err == nil || !strings.Contains(err.Error(), "missing required flag: --conversation-id") {
t.Fatalf("missing conversation ID error = %v", err)
if err == nil || !strings.Contains(err.Error(), "flag --group is required") {
t.Fatalf("missing group error = %v", err)
}
requireWukongWeeklySyncNoCalls(t, caller)
@@ -378,15 +378,15 @@ func TestCrossPlatformCoverageWukongWeeklyChatUpdateNickClearSemantics(t *testin
if findErr != nil {
t.Fatal(findErr)
}
if err := updateNick.RunE(updateNick, nil); err == nil || !strings.Contains(err.Error(), "--conversation-id") {
t.Fatalf("direct missing conversation-id error = %v", err)
if err := updateNick.RunE(updateNick, nil); err == nil || !strings.Contains(err.Error(), "--group") {
t.Fatalf("direct missing group error = %v", err)
}
caller := &wukongWeeklySyncCaller{}
_, _, err := executeWukongWeeklySyncCommand(t, "chat", caller, newChatCommand,
"group", "update-nick")
if err == nil || !strings.Contains(err.Error(), "conversation-id") {
t.Fatalf("missing conversation-id error = %v", err)
if err == nil || !strings.Contains(err.Error(), "group") {
t.Fatalf("missing group error = %v", err)
}
requireWukongWeeklySyncNoCalls(t, caller)
@@ -428,8 +428,8 @@ func TestCrossPlatformCoverageWukongWeeklyChatUpgradeValidationAndSafety(t *test
upgrade.Flags().Bool("yes", false, "")
}
_ = upgrade.Flags().Set("yes", "true")
if err := upgrade.RunE(upgrade, nil); err == nil || !strings.Contains(err.Error(), "--conversation-id") {
t.Fatalf("direct missing conversation-id error = %v", err)
if err := upgrade.RunE(upgrade, nil); err == nil || !strings.Contains(err.Error(), "--group") {
t.Fatalf("direct missing group error = %v", err)
}
tests := []struct {
@@ -0,0 +1,71 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package builtin_test
import (
"encoding/json"
"os"
"sort"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageWikiSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_wiki.json")
if err != nil {
t.Fatal(err)
}
var source chatSemanticCatalogFixture
if err := json.Unmarshal(raw, &source); err != nil {
t.Fatal(err)
}
if source.Service != "wiki" {
t.Fatalf("service = %q", source.Service)
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service == "wiki" {
if _, exists := registered[item.Command]; exists {
t.Fatalf("duplicate %s", item.Command)
}
registered[item.Command] = item
}
}
if len(registered) != 20 || len(source.Shortcuts) != 20 {
t.Fatalf("registered/catalog = %d/%d, want 20/20", len(registered), len(source.Shortcuts))
}
var missing, stale []string
for command, item := range registered {
record, ok := source.Shortcuts[command]
if !ok {
missing = append(missing, command)
continue
}
if !record.Reviewed || !item.SemanticReviewed || item.Hidden || !record.Public {
t.Errorf("%s: not publicly reviewed", command)
}
if strings.TrimSpace(record.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta || item.Disposition != record.Disposition {
t.Errorf("%s: semantic facts drifted", command)
}
if item.Risk != record.Risk {
t.Errorf("%s: risk=%q want=%q", command, item.Risk, record.Risk)
}
if item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive {
t.Errorf("%s: incomplete contract/safety/result/output", command)
}
}
for command := range source.Shortcuts {
if _, ok := registered[command]; !ok {
stale = append(stale, command)
}
}
sort.Strings(missing)
sort.Strings(stale)
if len(missing) > 0 || len(stale) > 0 {
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
}
}
@@ -404,6 +404,25 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"todo\u0000+overdue": {},
"todo\u0000+remind": {},
"todo\u0000+todo-done": {},
"wiki\u0000+delete-space": {},
"wiki\u0000+feed-list": {},
"wiki\u0000+member-add": {},
"wiki\u0000+member-list": {},
"wiki\u0000+member-remove": {},
"wiki\u0000+member-update": {},
"wiki\u0000+move": {},
"wiki\u0000+move-to-drive": {},
"wiki\u0000+node-copy": {},
"wiki\u0000+node-create": {},
"wiki\u0000+node-delete": {},
"wiki\u0000+node-get": {},
"wiki\u0000+node-list": {},
"wiki\u0000+node-search": {},
"wiki\u0000+resolve-space": {},
"wiki\u0000+space-create": {},
"wiki\u0000+space-get": {},
"wiki\u0000+space-list": {},
"wiki\u0000+space-search": {},
"wiki\u0000+wiki-new-doc": {},
}
}
+4
View File
@@ -25,6 +25,9 @@ var minutesSemanticCatalogJSON []byte
//go:embed semantic_catalog_drive.json
var driveSemanticCatalogJSON []byte
//go:embed semantic_catalog_wiki.json
var wikiSemanticCatalogJSON []byte
type semanticCatalogFile struct {
Version int `json:"version"`
Service string `json:"service"`
@@ -48,6 +51,7 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
aitableSemanticCatalogJSON,
minutesSemanticCatalogJSON,
driveSemanticCatalogJSON,
wikiSemanticCatalogJSON,
)
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
@@ -0,0 +1,30 @@
{
"version": 1,
"service": "wiki",
"default_availability": "available",
"shortcuts": {
"+space-list": {"disposition":"semantic_adapter","semantic_delta":"严格区分显式空知识库列表与缺失、畸形或内部错误响应,并保留真实分页证据。","risk":"read","public":true,"reviewed":true},
"+space-search": {"disposition":"semantic_adapter","semantic_delta":"按关键词搜索知识库并拒绝把缺失业务数组误报为零命中。","risk":"read","public":true,"reviewed":true},
"+resolve-space": {"disposition":"primary_smart","semantic_delta":"把关键词搜索收敛为唯一 workspaceId;零命中与多命中显式分流,绝不猜测。","risk":"read","public":true,"reviewed":true},
"+space-get": {"disposition":"schema_leaf","semantic_delta":"补充知识库详情入口,并要求 workspaceId 业务证据。","risk":"read","public":true,"reviewed":true},
"+space-create": {"disposition":"primary_smart","semantic_delta":"创建后要求 workspaceId 并通过空间详情读回验证真实落库。","risk":"write","public":true,"reviewed":true},
"+delete-space": {"disposition":"semantic_adapter","semantic_delta":"删除前读取影响目标,经高风险确认后只接受 success=true 终态。","risk":"high-risk-write","public":true,"reviewed":true},
"+member-add": {"disposition":"semantic_adapter","semantic_delta":"支持 1-30 个 userId 与四类角色;严格要求写接口终态,并明确后端无法提供精确成员读回。","risk":"write","public":true,"reviewed":true},
"+member-update": {"disposition":"semantic_adapter","semantic_delta":"补充成员角色更新;严格要求写接口终态,并明确后端无法提供精确成员读回。","risk":"write","public":true,"reviewed":true},
"+member-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证成员数组并公开真实单次上限 50;后端无游标时不伪造 page-all。","risk":"read","public":true,"reviewed":true},
"+member-remove": {"disposition":"semantic_adapter","semantic_delta":"支持批量 userId 移除;严格要求写接口终态,并明确后端无法提供精确成员读回。","risk":"write","public":true,"reviewed":true},
"+node-list": {"disposition":"semantic_adapter","semantic_delta":"严格区分显式空目录与假空成功,稳定投影节点并保留 nextCursor/hasMore。","risk":"read","public":true,"reviewed":true},
"+node-get": {"disposition":"schema_leaf","semantic_delta":"统一节点 ID 或在线文档 URL 的元数据读取,补充文档域属性视角。","risk":"read","public":true,"reviewed":true},
"+node-search": {"disposition":"semantic_adapter","semantic_delta":"补充库内关键词和扩展名搜索,并拒绝假空结果。","risk":"read","public":true,"reviewed":true},
"+node-create": {"disposition":"primary_smart","semantic_delta":"支持七种 Wiki 节点类型,创建后要求 nodeId 并读取元数据验证。","risk":"write","public":true,"reviewed":true},
"+node-copy": {"disposition":"primary_smart","semantic_delta":"高风险确认后复制,必须取得新 nodeId 并读回副本,避免空响应被当作成功。","risk":"high-risk-write","public":true,"reviewed":true},
"+move": {"disposition":"primary_smart","semantic_delta":"同一入口覆盖 Wiki 内移动和我的文档在线节点入 Wiki,并校验目标 workspace/folder。","risk":"write","public":true,"reviewed":true},
"+move-to-drive": {"disposition":"primary_smart","semantic_delta":"同步移动 Wiki 节点到我的文档并读回验证 workspace 变化,免去异步任务轮询。","risk":"write","public":true,"reviewed":true},
"+node-delete": {"disposition":"semantic_adapter","semantic_delta":"删除前读取并核对 workspace,经高风险确认后要求 success=true 终态证据。","risk":"high-risk-write","public":true,"reviewed":true},
"+feed-list": {"disposition":"semantic_adapter","semantic_delta":"补充知识库协作动态查询,严格验证 feeds 并保留游标。","risk":"read","public":true,"reviewed":true},
"+wiki-new-doc": {"disposition":"primary_smart","semantic_delta":"按空间名精确唯一解析、创建在线文档并读回验证;零命中和歧义均显式失败。","risk":"write","public":true,"reviewed":true}
}
}
@@ -23,6 +23,7 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
@@ -103,6 +104,8 @@ func runShortcut(t *testing.T, fake *stubMailboxCaller, argv ...string) string {
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
ctx, _ := output.WithResultStore(context.Background())
root.SetContext(ctx)
root.AddCommand(shortcut.Commands()...)
var buf bytes.Buffer
root.SetOut(&buf)
@@ -121,6 +124,8 @@ func runShortcutErr(t *testing.T, fake *stubMailboxCaller, argv ...string) error
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
ctx, _ := output.WithResultStore(context.Background())
root.SetContext(ctx)
root.AddCommand(shortcut.Commands()...)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
+73 -7
View File
@@ -14,7 +14,13 @@
package smart
import (
"encoding/json"
"fmt"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
@@ -35,15 +41,25 @@ import (
//
// dws wiki +resolve-space --name 产品文档
var ResolveSpace = shortcut.Shortcut{
Service: "wiki",
Command: "+resolve-space",
Product: "wiki",
Description: "按名称搜索知识空间并解析出唯一 spaceId(只读)",
OutputRollout: output.RolloutUnifiedActive,
Service: "wiki",
Command: "+resolve-space",
Product: "wiki",
Description: "按名称搜索知识空间并解析出唯一 spaceId(只读)",
Intent: "当你只知道某个知识空间(wiki space)的名称(或名称里的关键词)、想把它解析成可直接用于后续工具的 spaceId 时使用;" +
"内部按 --name 关键词调用 search_wikiSpaces 搜索知识空间,再在本地投影出每个候选的 spaceId 和 name。" +
"如果只命中一个知识空间就直接返回它的 spaceId;如果命中多个则列出全部候选让你消歧,绝不替你瞎猜;如果一个都没命中则提示未找到。" +
"这是纯只读操作,只做搜索与本地投影,不会修改任何知识空间。",
Risk: shortcut.RiskRead,
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: corecmd.ContractDecl{
Description: "按名称搜索知识空间并解析出唯一 spaceId(只读)",
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"知识库名称解析结果","properties":{"resolved":{"type":"boolean","description":"是否唯一解析"},"spaceId":{"type":"string","description":"唯一知识库 ID"},"name":{"type":"string","description":"唯一知识库名称"},"count":{"type":"integer","description":"候选数量"},"candidates":{"type":"array","description":"需要消歧的候选知识库","items":{"type":"object","description":"知识库候选","additionalProperties":true}}},"required":["resolved"],"additionalProperties":true}`)},
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: "Reviewed Wiki resolver: the executable CLI strictly validates search results and refuses to guess when multiple spaces match."},
Selection: contract.SelectionSpec{AgentSummary: "按名称搜索知识空间并解析出唯一 spaceId(只读)", UseWhen: []string{"当你只知道某个知识空间(wiki space)的名称(或名称里的关键词)、想把它解析成可直接用于后续工具的 spaceId 时使用;内部按 --name 关键词调用 search_wikiSpaces 搜索知识空间,再在本地投影出每个候选的 spaceId 和 name。如果只命中一个知识空间就直接返回它的 spaceId;如果命中多个则列出全部候选让你消歧,绝不替你瞎猜;如果一个都没命中则提示未找到。这是纯只读操作,只做搜索与本地投影,不会修改任何知识空间。"}, AvoidWhen: []string{"只想浏览所有匹配项用 wiki +space-search;已知 workspaceId 时无需解析"}, Examples: []string{`dws wiki +resolve-space --name "产品文档"`}},
Identity: contract.ToolIdentitySpec{ProductID: "wiki", Name: "shortcut_resolve_space", CanonicalPath: "wiki.shortcut_resolve_space", CLIPath: "wiki +resolve-space", PrimaryCLIPath: "wiki +resolve-space"},
Parameters: []contract.ParamDecl{{Name: "name", Property: "keyword"}},
},
Flags: []shortcut.Flag{
{Name: "name", Type: shortcut.FlagString, Desc: "要搜索的知识空间名称关键词(必填)", Required: true},
},
@@ -62,7 +78,10 @@ var ResolveSpace = shortcut.Shortcut{
}
// Project candidates to {spaceId, name}, defensively unwrapping the list.
items := resolveSpaceItems(data)
items, err := resolveSpaceItemsStrict(data)
if err != nil {
return err
}
candidates := make([]map[string]any, 0, len(items))
for _, s := range items {
candidates = append(candidates, map[string]any{
@@ -123,7 +142,7 @@ func resolveSpaceItems(data map[string]any) []map[string]any {
// resolveSpaceID reads a space's identifier, tolerating the common id keys.
func resolveSpaceID(s map[string]any) string {
for _, key := range []string{"spaceId", "space_id", "id"} {
for _, key := range []string{"workspaceId", "spaceId", "space_id", "id"} {
if v, ok := s[key].(string); ok && v != "" {
return v
}
@@ -131,6 +150,53 @@ func resolveSpaceID(s map[string]any) string {
return ""
}
func resolveSpaceItemsStrict(data map[string]any) ([]map[string]any, error) {
if len(data) == 0 {
return nil, apperrors.NewAPI("search_wikiSpaces 返回空响应,不能当作零命中", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("empty_tool_response"))
}
if success, present := data["success"]; present {
value, ok := success.(bool)
if !ok || !value {
return nil, apperrors.NewAPI("search_wikiSpaces 未成功", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("remote_failure"))
}
}
containers := []map[string]any{data}
for _, wrapper := range []string{"result", "data"} {
if raw, present := data[wrapper]; present {
inner, ok := raw.(map[string]any)
if !ok {
return nil, apperrors.NewAPI("search_wikiSpaces 响应包装不是对象", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_envelope"))
}
containers = append(containers, inner)
}
}
for _, container := range containers {
for _, key := range []string{"wikiSpaces", "spaces", "items", "list", "records"} {
raw, present := container[key]
if !present {
continue
}
list, ok := raw.([]any)
if !ok {
return nil, apperrors.NewAPI("search_wikiSpaces 业务集合不是数组", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection"))
}
out := make([]map[string]any, 0, len(list))
for index, item := range list {
object, ok := item.(map[string]any)
if !ok {
return nil, apperrors.NewAPI(fmt.Sprintf("search_wikiSpaces 第 %d 项不是对象", index), apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection_item"))
}
if resolveSpaceID(object) == "" || resolveSpaceName(object) == "" {
return nil, apperrors.NewAPI(fmt.Sprintf("search_wikiSpaces 第 %d 项缺少名称或 workspaceId", index), apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection_item"))
}
out = append(out, object)
}
return out, nil
}
}
return nil, apperrors.NewAPI("search_wikiSpaces 缺少 wikiSpaces 数组,不能投影为空", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("missing_collection"))
}
// resolveSpaceName reads a space's display name, tolerating the common name keys.
func resolveSpaceName(s map[string]any) string {
for _, key := range []string{"name", "spaceName", "title"} {
+83 -23
View File
@@ -14,10 +14,14 @@
package smart
import (
"encoding/json"
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
@@ -33,15 +37,25 @@ import (
//
// dws wiki +wiki-new-doc --space "产品文档库" --title "需求评审纪要"
var WikiNewDoc = shortcut.Shortcut{
Service: "wiki",
Command: "+wiki-new-doc",
Product: "wiki",
Description: "在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId)",
OutputRollout: output.RolloutUnifiedActive,
Service: "wiki",
Command: "+wiki-new-doc",
Product: "wiki",
Description: "在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId)",
Intent: "当你只知道知识库(知识空间)的名字、想直接在它下面新建一篇文档,却不想先搜索空间、复制 workspaceId 再建节点时使用;" +
"内部先按空间名搜索知识库,若唯一命中则拿到它的 workspaceId,再在该库根目录下创建一个在线文档节点。" +
"如果这个名字没有匹配到任何知识库,或匹配到多个,会报错让你用更精确的名字,绝不乱猜。" +
"这会真实创建一个新的文档节点。",
Risk: shortcut.RiskWrite,
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "non_idempotent"},
Contract: corecmd.ContractDecl{
Description: "在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId)",
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"已验证的新建 Wiki 文档","properties":{"success":{"type":"boolean","description":"是否成功"},"nodeId":{"type":"string","description":"新文档节点 ID"},"space":{"type":"string","description":"请求的知识库名称"},"title":{"type":"string","description":"请求的文档标题"},"document":{"type":"object","description":"读回的文档元数据","additionalProperties":true}},"required":["success","nodeId","space","title","document"],"additionalProperties":true}`)},
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: "Reviewed Wiki smart Shortcut: the executable CLI strictly resolves one exact space, creates a document, and verifies it through a metadata read-back."},
Selection: contract.SelectionSpec{AgentSummary: "在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId)", UseWhen: []string{"当你只知道知识库(知识空间)的名字、想直接在它下面新建一篇文档,却不想先搜索空间、复制 workspaceId 再建节点时使用;内部先按空间名搜索知识库,若唯一命中则拿到它的 workspaceId,再在该库根目录下创建一个在线文档节点。如果这个名字没有匹配到任何知识库,或匹配到多个,会报错让你用更精确的名字,绝不乱猜。这会真实创建一个新的文档节点。"}, AvoidWhen: []string{"已知 workspaceId 时用 wiki +node-create;空间名不唯一时先用 wiki +space-search"}, Examples: []string{`dws wiki +wiki-new-doc --space "产品文档库" --title "需求评审纪要"`}},
Identity: contract.ToolIdentitySpec{ProductID: "wiki", Name: "shortcut_wiki_new_doc", CanonicalPath: "wiki.shortcut_wiki_new_doc", CLIPath: "wiki +wiki-new-doc", PrimaryCLIPath: "wiki +wiki-new-doc"},
Parameters: []contract.ParamDecl{{Name: "space", Property: "keyword"}, {Name: "title", Property: "name"}},
},
Flags: []shortcut.Flag{
{Name: "space", Type: shortcut.FlagString, Desc: "知识库(知识空间)名称", Required: true},
{Name: "title", Type: shortcut.FlagString, Desc: "新建文档的标题", Required: true},
@@ -88,7 +102,7 @@ var WikiNewDoc = shortcut.Shortcut{
"wouldCreateIn": workspaceID,
})
}
created, err := rt.CallMCPWriteData("doc", "create_file", map[string]any{
created, err := rt.CallMCPWriteDataStrict("doc", "create_file", map[string]any{
"workspaceId": workspaceID,
"name": title,
"type": "adoc",
@@ -96,13 +110,28 @@ var WikiNewDoc = shortcut.Shortcut{
if err != nil {
return err
}
// Surface the created doc (id/url in the response) instead of returning
// silently — previously the caller got no confirmation of the new doc.
if success, ok := created["success"].(bool); !ok || !success {
return apperrors.NewAPI("create_file 未返回 success=true,无法证明文档已创建", apperrors.WithOperation("doc/create_file"), apperrors.WithReason("missing_terminal_success"))
}
nodeID := wikiNewDocFirstString(created, "nodeId", "fileId", "id")
if nodeID == "" {
return apperrors.NewAPI("create_file 未返回 nodeId,远端效果未知", apperrors.WithOperation("doc/create_file"), apperrors.WithReason("missing_created_id"))
}
verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": nodeID})
if err != nil {
return err
}
if success, present := verified["success"]; present {
value, ok := success.(bool)
if !ok || !value {
return apperrors.NewAPI("新建文档读回未成功", apperrors.WithOperation("doc/get_document_info"), apperrors.WithReason("readback_failed"))
}
}
if wikiNewDocFirstString(verified, "nodeId", "fileId", "id") != nodeID {
return apperrors.NewAPI("新建文档读回 nodeId 不一致", apperrors.WithOperation("doc/get_document_info"), apperrors.WithReason("readback_id_mismatch"))
}
return rt.Output(map[string]any{
"created": true,
"space": spaceName,
"title": title,
"result": created,
"success": true, "nodeId": nodeID, "space": spaceName, "title": title, "document": verified,
})
},
}
@@ -117,7 +146,10 @@ type wikiSpaceCandidate struct {
// out of a search_wikiSpaces response and returns its workspaceId. It errors
// clearly when nothing matches or when the name is ambiguous, never guessing.
func wikiNewDocResolveSpaceID(data map[string]any, spaceName string) (string, error) {
spaces := wikiNewDocExtractSpaces(data)
spaces, err := wikiNewDocExtractSpaces(data)
if err != nil {
return "", err
}
if len(spaces) == 0 {
return "", apperrors.NewValidation(fmt.Sprintf(
"没找到名为 %q 的知识库;换个更完整/精确的空间名再试。", spaceName))
@@ -156,9 +188,15 @@ func wikiNewDocResolveSpaceID(data map[string]any, spaceName string) (string, er
// wikiNewDocExtractSpaces flattens the several shapes a search_wikiSpaces
// response may take into a list of {id, name} candidates. The gateway wraps the
// list under one of several common container keys, so probe them defensively.
func wikiNewDocExtractSpaces(data map[string]any) []wikiSpaceCandidate {
func wikiNewDocExtractSpaces(data map[string]any) ([]wikiSpaceCandidate, error) {
if data == nil {
return nil
return nil, apperrors.NewAPI("search_wikiSpaces 返回空响应,不能当作零命中", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("empty_tool_response"))
}
if success, present := data["success"]; present {
value, ok := success.(bool)
if !ok || !value {
return nil, apperrors.NewAPI("search_wikiSpaces 未成功", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("remote_failure"))
}
}
for _, key := range []string{"result", "data", "list", "wikiSpaces", "spaces", "items", "records"} {
switch v := data[key].(type) {
@@ -166,21 +204,25 @@ func wikiNewDocExtractSpaces(data map[string]any) []wikiSpaceCandidate {
return wikiNewDocToCandidates(v)
case map[string]any:
for _, k2 := range []string{"list", "wikiSpaces", "spaces", "items", "records", "result"} {
if arr, ok := v[k2].([]any); ok {
if raw, present := v[k2]; present {
arr, ok := raw.([]any)
if !ok {
return nil, apperrors.NewAPI("search_wikiSpaces 业务集合不是数组", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection"))
}
return wikiNewDocToCandidates(arr)
}
}
}
}
return nil
return nil, apperrors.NewAPI("search_wikiSpaces 缺少 wikiSpaces 数组,不能投影为空", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("missing_collection"))
}
func wikiNewDocToCandidates(arr []any) []wikiSpaceCandidate {
func wikiNewDocToCandidates(arr []any) ([]wikiSpaceCandidate, error) {
out := make([]wikiSpaceCandidate, 0, len(arr))
for _, it := range arr {
for index, it := range arr {
m, ok := it.(map[string]any)
if !ok {
continue
return nil, apperrors.NewAPI(fmt.Sprintf("search_wikiSpaces 结果第 %d 项不是对象", index), apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection_item"))
}
id := ""
for _, k := range []string{"workspaceId", "spaceId", "id"} {
@@ -196,12 +238,30 @@ func wikiNewDocToCandidates(arr []any) []wikiSpaceCandidate {
break
}
}
if id == "" && name == "" {
continue
if id == "" || name == "" {
return nil, apperrors.NewAPI(fmt.Sprintf("search_wikiSpaces 结果第 %d 项缺少名称或 workspaceId", index), apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection_item"))
}
out = append(out, wikiSpaceCandidate{id: id, name: name})
}
return out
return out, nil
}
func wikiNewDocFirstString(data map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
for _, wrapper := range []string{"result", "data"} {
if inner, ok := data[wrapper].(map[string]any); ok {
for _, key := range keys {
if value, ok := inner[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
}
}
return ""
}
func wikiNewDocLabels(spaces []wikiSpaceCandidate) []string {
@@ -0,0 +1,187 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package smart
import (
"strings"
"testing"
)
func TestCrossPlatformCoverageResolveSpaceStrictContracts(t *testing.T) {
invalid := []map[string]any{
nil,
{"success": false},
{"success": "yes"},
{"result": "bad"},
{"wikiSpaces": "bad"},
{"wikiSpaces": []any{"bad"}},
{"wikiSpaces": []any{map[string]any{"workspaceId": "w"}}},
{"wikiSpaces": []any{map[string]any{"name": "Docs"}}},
{"success": true},
}
for index, data := range invalid {
if _, err := resolveSpaceItemsStrict(data); err == nil {
t.Fatalf("invalid response %d succeeded: %#v", index, data)
}
}
for _, data := range []map[string]any{
{"wikiSpaces": []any{}},
{"spaces": []any{map[string]any{"spaceId": "s", "spaceName": "Docs"}}},
{"result": map[string]any{"items": []any{map[string]any{"id": "i", "title": "Plan"}}}},
{"data": map[string]any{"records": []any{map[string]any{"workspaceId": "w", "name": "Roadmap"}}}},
} {
if _, err := resolveSpaceItemsStrict(data); err != nil {
t.Fatalf("valid response rejected: %#v: %v", data, err)
}
}
for _, tc := range []struct {
data map[string]any
id string
name string
}{
{map[string]any{"workspaceId": "w", "name": "N"}, "w", "N"},
{map[string]any{"spaceId": "s", "spaceName": "S"}, "s", "S"},
{map[string]any{"space_id": "legacy", "title": "T"}, "legacy", "T"},
{map[string]any{"id": "i"}, "i", ""},
{map[string]any{}, "", ""},
} {
if got := resolveSpaceID(tc.data); got != tc.id {
t.Fatalf("resolveSpaceID(%#v)=%q want %q", tc.data, got, tc.id)
}
if got := resolveSpaceName(tc.data); got != tc.name {
t.Fatalf("resolveSpaceName(%#v)=%q want %q", tc.data, got, tc.name)
}
}
}
func TestCrossPlatformCoverageResolveSpaceExecution(t *testing.T) {
for _, tc := range []struct {
name string
fake *stubMailboxCaller
ok bool
}{
{"transport error", &stubMailboxCaller{errTool: "search_wikiSpaces"}, false},
{"malformed result", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"success":true}`}}, false},
{"zero result", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"wikiSpaces":[]}`}}, false},
{"one result", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"wikiSpaces":[{"workspaceId":"w","name":"Docs"}]}`}}, true},
{"multiple results", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"wikiSpaces":[{"workspaceId":"w1","name":"Docs"},{"workspaceId":"w2","name":"Docs 2"}]}`}}, true},
} {
t.Run(tc.name, func(t *testing.T) {
err := runShortcutErr(t, tc.fake, "wiki", "+resolve-space", "--name", "Docs", "--format", "json")
if (err == nil) != tc.ok {
t.Fatalf("success=%v want %v; err=%v", err == nil, tc.ok, err)
}
})
}
}
func TestCrossPlatformCoverageWikiNewDocParsing(t *testing.T) {
invalid := []map[string]any{
nil,
{"success": false},
{"success": "yes"},
{"result": map[string]any{"wikiSpaces": "bad"}},
{"wikiSpaces": []any{"bad"}},
{"wikiSpaces": []any{map[string]any{"workspaceId": "w"}}},
{"success": true},
}
for index, data := range invalid {
if _, err := wikiNewDocExtractSpaces(data); err == nil {
t.Fatalf("invalid response %d succeeded: %#v", index, data)
}
}
for _, data := range []map[string]any{
{"wikiSpaces": []any{}},
{"list": []any{map[string]any{"workspaceId": "w", "name": "Docs"}}},
{"data": map[string]any{"spaces": []any{map[string]any{"spaceId": "s", "spaceName": "Plan"}}}},
{"result": map[string]any{"result": []any{map[string]any{"id": "i", "title": "Roadmap"}}}},
} {
if _, err := wikiNewDocExtractSpaces(data); err != nil {
t.Fatalf("valid response rejected: %#v: %v", data, err)
}
}
if got := wikiNewDocFirstString(map[string]any{"nodeId": " n "}, "nodeId"); got != "n" {
t.Fatalf("direct node id=%q", got)
}
if got := wikiNewDocFirstString(map[string]any{"result": map[string]any{"fileId": " f "}}, "nodeId", "fileId"); got != "f" {
t.Fatalf("nested node id=%q", got)
}
if got := wikiNewDocFirstString(map[string]any{"data": map[string]any{"id": " i "}}, "id"); got != "i" {
t.Fatalf("data node id=%q", got)
}
if got := wikiNewDocFirstString(map[string]any{"id": 1}, "id"); got != "" {
t.Fatalf("invalid node id=%q", got)
}
labels := wikiNewDocLabels([]wikiSpaceCandidate{{id: "w1", name: "Docs"}, {id: "w2", name: "Plan"}})
if strings.Join(labels, ",") != "Docs(w1),Plan(w2)" {
t.Fatalf("labels=%v", labels)
}
}
func TestCrossPlatformCoverageWikiNewDocResolution(t *testing.T) {
cases := []struct {
name string
data map[string]any
spaceName string
want string
ok bool
}{
{"parser error", nil, "Docs", "", false},
{"zero", map[string]any{"wikiSpaces": []any{}}, "Docs", "", false},
{"unique exact", map[string]any{"wikiSpaces": []any{map[string]any{"workspaceId": "w1", "name": " docs "}, map[string]any{"workspaceId": "w2", "name": "Plan"}}}, "Docs", "w1", true},
{"unique fallback", map[string]any{"wikiSpaces": []any{map[string]any{"workspaceId": "w1", "name": "Docs Team"}}}, "Docs", "w1", true},
{"multiple", map[string]any{"wikiSpaces": []any{map[string]any{"workspaceId": "w1", "name": "Docs 1"}, map[string]any{"workspaceId": "w2", "name": "Docs 2"}}}, "Docs", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := wikiNewDocResolveSpaceID(tc.data, tc.spaceName)
if (err == nil) != tc.ok || got != tc.want {
t.Fatalf("got=%q err=%v want=%q ok=%v", got, err, tc.want, tc.ok)
}
})
}
}
func TestCrossPlatformCoverageWikiNewDocExecution(t *testing.T) {
if err := runShortcutErr(t, &stubMailboxCaller{}, "wiki", "+wiki-new-doc", "--space", " ", "--title", "Doc"); err == nil {
t.Fatal("blank space succeeded")
}
if err := runShortcutErr(t, &stubMailboxCaller{}, "wiki", "+wiki-new-doc", "--space", "Docs", "--title", " "); err == nil {
t.Fatal("blank title succeeded")
}
validSearch := `{"wikiSpaces":[{"workspaceId":"w","name":"Docs"}]}`
cases := []struct {
name string
fake *stubMailboxCaller
args []string
ok bool
}{
{"search error", &stubMailboxCaller{errTool: "search_wikiSpaces"}, nil, false},
{"resolve error", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"wikiSpaces":[]}`}}, nil, false},
{"dry run", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch}}, []string{"--dry-run"}, true},
{"create error", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch}, errTool: "create_file"}, nil, false},
{"missing terminal success", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"result":{"fileId":"n"}}`}}, nil, false},
{"missing created id", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true}`}}, nil, false},
{"readback error", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"fileId":"n"}`}, errTool: "get_document_info"}, nil, false},
{"readback failed", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"fileId":"n"}`, "get_document_info": `{"success":false}`}}, nil, false},
{"readback malformed success", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"fileId":"n"}`, "get_document_info": `{"success":"yes"}`}}, nil, false},
{"readback id mismatch", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"fileId":"n"}`, "get_document_info": `{"success":true,"nodeId":"other"}`}}, nil, false},
{"verified", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"data":{"fileId":"n"}}`, "get_document_info": `{"success":true,"result":{"nodeId":"n"}}`}}, nil, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
args := []string{"wiki", "+wiki-new-doc", "--space", "Docs", "--title", "Doc", "--format", "json"}
args = append(args, tc.args...)
err := runShortcutErr(t, tc.fake, args...)
if (err == nil) != tc.ok {
t.Fatalf("success=%v want %v; err=%v", err == nil, tc.ok, err)
}
})
}
}
+300
View File
@@ -0,0 +1,300 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package wiki
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const wikiCompositeReason = "Reviewed Wiki Shortcut composite: the executable CLI owns strict response validation, pagination projection, optional multi-step orchestration, read-back verification, and confirmation; no single MCP interface represents the complete command contract."
func wikiContract(command, description, useWhen string, avoidWhen, examples []string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params ...contract.ParamDecl) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
cliPath := "wiki " + command
return corecmd.ContractDecl{
Description: description, Parameters: params, Result: result, Pagination: pagination,
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: wikiCompositeReason},
Selection: contract.SelectionSpec{AgentSummary: description, UseWhen: []string{useWhen}, AvoidWhen: avoidWhen, Examples: examples},
Identity: contract.ToolIdentitySpec{ProductID: "wiki", Name: name, CanonicalPath: "wiki." + name, CLIPath: cliPath, PrimaryCLIPath: cliPath},
}
}
func wikiObjectResult(description string) *contract.ResultSpec {
return &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"additionalProperties":true}`, description))}
}
func wikiCollectionResult(collection, description string) *contract.ResultSpec {
return &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(fmt.Sprintf(
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"有效结果数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"Wiki 业务条目","additionalProperties":true}},"nextCursor":{"type":"string","description":"下一页游标"},"hasMore":{"type":"boolean","description":"服务端是否仍有下一页"}},"required":["count",%q],"additionalProperties":true}`,
description, collection, description, collection))}
}
func wikiCursorPagination() *contract.PaginationSpec {
return &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor", MetaPath: contract.PaginationMetaPath, EndpointExhaustedPath: contract.PaginationExhaustedPath, NextTokenPath: contract.PaginationNextTokenPath}
}
func requireWikiResponse(data map[string]any, operation string) (map[string]any, error) {
if len(data) == 0 {
return nil, wikiResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明操作成功")
}
if value, present := data["success"]; present {
success, ok := value.(bool)
if !ok {
return nil, wikiResponseError(operation, "malformed_success", "响应 success 字段不是布尔值")
}
if !success {
message := firstWikiString(data, "errorMsg", "message", "error")
if message == "" {
message = "服务明确返回 success=false"
}
return nil, wikiResponseError(operation, "remote_failure", message)
}
}
return data, nil
}
func requireWikiWrite(data map[string]any, operation string) (map[string]any, error) {
data, err := requireWikiResponse(data, operation)
if err != nil {
return nil, err
}
if success, ok := data["success"].(bool); !ok || !success {
return nil, wikiResponseError(operation, "missing_terminal_success", "写操作响应没有 success=true 终态证据")
}
return data, nil
}
func requireWikiObject(data map[string]any, operation string) (map[string]any, error) {
data, err := requireWikiResponse(data, operation)
if err != nil {
return nil, err
}
for _, wrapper := range []string{"result", "data"} {
if value, present := data[wrapper]; present {
object, ok := value.(map[string]any)
if !ok || len(object) == 0 {
return nil, wikiResponseError(operation, "malformed_object", "响应业务对象缺失或畸形")
}
return object, nil
}
}
if len(data) == 1 {
return nil, wikiResponseError(operation, "missing_business_result", "响应没有可验证的业务对象")
}
return data, nil
}
func requireWikiCollection(data map[string]any, operation string, keys ...string) ([]any, map[string]any, error) {
data, err := requireWikiResponse(data, operation)
if err != nil {
return nil, nil, err
}
containers := []map[string]any{data}
for _, wrapper := range []string{"result", "data"} {
if value, present := data[wrapper]; present {
inner, ok := value.(map[string]any)
if !ok {
return nil, nil, wikiResponseError(operation, "malformed_envelope", fmt.Sprintf("响应 %s 字段不是对象", wrapper))
}
containers = append(containers, inner)
}
}
for _, container := range containers {
for _, key := range keys {
value, present := container[key]
if !present {
continue
}
items, ok := value.([]any)
if !ok {
return nil, nil, wikiResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key))
}
for index, item := range items {
if _, ok := item.(map[string]any); !ok {
return nil, nil, wikiResponseError(operation, "malformed_collection_item", fmt.Sprintf("响应 %s[%d] 不是对象", key, index))
}
}
return items, container, nil
}
}
return nil, nil, wikiResponseError(operation, "missing_collection", "响应缺少声明的业务数组;不能把缺字段或内部错误投影成空结果")
}
func projectWikiRows(items []any, aliases map[string][]string) []map[string]any {
rows := make([]map[string]any, 0, len(items))
for _, item := range items {
source := item.(map[string]any)
row := make(map[string]any)
for canonical, candidates := range aliases {
for _, candidate := range candidates {
if value, ok := source[candidate]; ok && value != nil {
row[canonical] = value
break
}
}
}
rows = append(rows, row)
}
return rows
}
func addWikiPagination(out, page map[string]any) {
for _, pair := range [][2]string{{"nextCursor", "nextCursor"}, {"nextToken", "nextCursor"}, {"nextPageToken", "nextCursor"}, {"pageToken", "nextCursor"}, {"hasMore", "hasMore"}, {"truncated", "truncated"}, {"totalCount", "totalCount"}, {"autoPageComplete", "autoPageComplete"}, {"autoPageStopReason", "autoPageStopReason"}, {"pagesFetched", "pagesFetched"}} {
if value, ok := page[pair[0]]; ok && value != nil {
out[pair[1]] = value
}
}
}
func firstWikiString(data map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func nestedWikiString(data map[string]any, keys ...string) string {
if value := firstWikiString(data, keys...); value != "" {
return value
}
for _, wrapper := range []string{"result", "data"} {
if inner, ok := data[wrapper].(map[string]any); ok {
if value := firstWikiString(inner, keys...); value != "" {
return value
}
}
}
return ""
}
func wikiStringInt(rt *shortcut.RuntimeContext, name string, fallback, min, max int) (int, error) {
raw := rt.Str(name)
if raw == "" {
return fallback, nil
}
value, err := strconv.Atoi(raw)
if err != nil || value < min || value > max {
return 0, fmt.Errorf("--%s 必须是 %d-%d 之间的整数", name, min, max)
}
return value, nil
}
func wikiStringSliceFirst(rt *shortcut.RuntimeContext, primary string, aliases ...string) []string {
if rt.Changed(primary) {
return rt.StrSlice(primary)
}
for _, alias := range aliases {
if rt.Changed(alias) {
values, _ := rt.Command().Flags().GetStringSlice(alias)
return values
}
}
return rt.StrSlice(primary)
}
func wikiResponseError(operation, reason, message string) error {
return apperrors.NewAPI(message, apperrors.WithOperation(operation), apperrors.WithOrigin("mcp"), apperrors.WithFailureStage("response_validation"), apperrors.WithRetryable(false), apperrors.WithReason(reason))
}
func wikiReadSafety() contract.SafetySpec {
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
}
func wikiWriteSafety(confirm bool) contract.SafetySpec {
confirmation := "not_required"
if confirm {
confirmation = "user_required"
}
return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: confirmation, Idempotency: "unknown"}
}
func wikiDeleteSafety() contract.SafetySpec {
return contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"}
}
func wikiAutoPageFlags() []shortcut.Flag {
const evidence = "--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"
return append([]shortcut.Flag{{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动沿游标取完所有页。" + evidence}, {Name: "page-limit", Type: shortcut.FlagInt, Default: "20", Desc: "自动翻页最多请求页数。" + evidence}}, shortcut.AutoPageControlFlags()...)
}
func enableWikiAutoPage(item *shortcut.Shortcut) {
item.Flags = append(item.Flags, wikiAutoPageFlags()...)
item.Constraints = append(item.Constraints, shortcut.AutoPageControlConstraints()...)
item.Validate = func(rt *shortcut.RuntimeContext) error {
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
return err
}
if rt.Bool("page-all") && rt.Int("page-limit") < 1 {
return fmt.Errorf("--page-limit 必须大于 0")
}
return nil
}
}
type wikiPageFetcher func(cursor string, pageSize int) (map[string]any, error)
func collectWikiPages(rt *shortcut.RuntimeContext, operation string, pageSize int, keys []string, fetch wikiPageFetcher) ([]any, map[string]any, error) {
if !rt.Bool("page-all") {
data, err := fetch(rt.StrFirst("cursor", "page-token"), pageSize)
if err != nil {
return nil, nil, err
}
return requireWikiCollection(data, operation, keys...)
}
all := make([]any, 0)
cursor := rt.StrFirst("cursor", "page-token")
lastPage := map[string]any{}
for pageNumber := 1; pageNumber <= rt.Int("page-limit"); pageNumber++ {
requestSize := shortcut.AutoPageRequestSize(rt, pageSize, len(all))
data, err := fetch(cursor, requestSize)
if err != nil {
return nil, nil, err
}
items, page, err := requireWikiCollection(data, operation, keys...)
if err != nil {
return nil, nil, err
}
all = append(all, items...)
lastPage = page
if maxItems := rt.Int("max-items"); maxItems > 0 && len(all) >= maxItems {
lastPage["autoPageComplete"] = false
lastPage["autoPageStopReason"] = "max_items"
lastPage["pagesFetched"] = pageNumber
return all, lastPage, nil
}
hasMore, present := page["hasMore"]
if !present {
return nil, nil, wikiResponseError(operation, "missing_has_more", "--page-all 要求每页响应提供 hasMore 布尔值")
}
more, ok := hasMore.(bool)
if !ok {
return nil, nil, wikiResponseError(operation, "malformed_has_more", "分页响应 hasMore 不是布尔值")
}
if !more {
lastPage["autoPageComplete"] = true
lastPage["pagesFetched"] = pageNumber
return all, lastPage, nil
}
next := firstWikiString(page, "nextCursor", "nextToken", "nextPageToken", "pageToken")
if next == "" {
return nil, nil, wikiResponseError(operation, "missing_next_cursor", "hasMore=true 但响应缺少下一页游标")
}
if next == cursor {
return nil, nil, wikiResponseError(operation, "stalled_cursor", "下一页游标未变化,已停止以避免死循环")
}
cursor = next
if err := shortcut.WaitAutoPageDelay(rt); err != nil {
return nil, nil, err
}
}
return nil, nil, wikiResponseError(operation, "page_limit_reached", "达到 --page-limit 时服务端仍有下一页;提高页数上限或使用返回游标续传")
}
+254
View File
@@ -0,0 +1,254 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package wiki
import (
"fmt"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var NodeList = readShortcut("+node-list", "严格分页列出知识库节点", "浏览知识库根目录或指定文件夹;只有显式 nodes:[] 才表示空目录,并完整保留 nextCursor/hasMore。", "nodes", []shortcut.Flag{
{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "父节点 ID"}, {Name: "limit", Type: shortcut.FlagInt, Default: "50", Desc: "每页数量 1-50"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标", Aliases: []string{"page-token"}, AliasesVisible: true},
}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "folder", Property: "folderId"}, {Name: "limit", Property: "pageSize"}, {Name: "cursor", Property: "pageToken"}}, func(rt *shortcut.RuntimeContext) error {
items, page, err := collectWikiPages(rt, "wiki/list_nodes", rt.Int("limit"), []string{"nodes", "items", "list"}, func(cursor string, size int) (map[string]any, error) {
params := map[string]any{"workspaceId": rt.Str("workspace"), "pageSize": size}
if rt.Changed("folder") {
params["folderId"] = rt.Str("folder")
}
if cursor != "" {
params["pageToken"] = cursor
}
return rt.CallMCPData("doc", "list_nodes", params)
})
if err != nil {
return err
}
nodes := projectWikiRows(items, nodeAliases())
out := map[string]any{"count": len(nodes), "nodes": nodes}
addWikiPagination(out, page)
return rt.Output(out)
})
func nodeAliases() map[string][]string {
return map[string][]string{"nodeId": {"nodeId", "id", "dentryUuid", "fileId"}, "name": {"name", "title", "nodeName", "fileName"}, "type": {"type", "nodeType", "docType", "fileType"}, "contentType": {"contentType"}, "folderId": {"folderId", "parentId"}, "workspaceId": {"workspaceId", "spaceId"}, "url": {"docUrl", "url", "webUrl"}}
}
var NodeGet = readShortcut("+node-get", "获取知识库节点详情", "已知节点 ID 或在线文档 URL 时读取元数据,并在节点信息之外统一返回文档/文件属性。", "", []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Required: true, Desc: "节点 ID 或 URL"}}, []contract.ParamDecl{{Name: "node", Property: "nodeId"}}, func(rt *shortcut.RuntimeContext) error {
data, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")})
if err != nil {
return err
}
object, err := requireWikiObject(data, "doc/get_document_info")
if err != nil {
return err
}
if firstWikiString(object, "nodeId", "id", "fileId") == "" {
return wikiResponseError("doc/get_document_info", "missing_node_id", "节点详情缺少 nodeId")
}
return rt.Output(object)
})
var NodeSearch = readShortcut("+node-search", "严格搜索知识库节点", "在指定知识库内按关键词和扩展名搜索节点;零命中必须来自显式 documents:[]。", "nodes", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID"}, {Name: "query", Type: shortcut.FlagString, Required: true, Desc: "关键词"}, {Name: "extensions", Type: shortcut.FlagStringSlice, Desc: "扩展名过滤"}, {Name: "limit", Type: shortcut.FlagInt, Default: "10", Desc: "每页数量 1-30"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceIds"}, {Name: "query", Property: "keyword"}, {Name: "extensions", Property: "extensions"}, {Name: "limit", Property: "pageSize"}, {Name: "cursor", Property: "pageToken"}}, func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"workspaceIds": []string{rt.Str("workspace")}, "keyword": rt.Str("query"), "pageSize": rt.Int("limit")}
if rt.Changed("extensions") {
params["extensions"] = rt.StrSlice("extensions")
}
if rt.Changed("cursor") {
params["pageToken"] = rt.Str("cursor")
}
data, err := rt.CallMCPData("doc", "search_documents", params)
if err != nil {
return err
}
items, page, err := requireWikiCollection(data, "doc/search_documents", "documents", "docs", "nodes", "items", "list")
if err != nil {
return err
}
nodes := projectWikiRows(items, nodeAliases())
out := map[string]any{"count": len(nodes), "nodes": nodes}
addWikiPagination(out, page)
return rt.Output(out)
})
var NodeCreate = writeShortcut("+node-create", "创建知识库节点并读回验证", "在知识库根目录或文件夹中创建文档、表格、白板、脑图或文件夹;取得 nodeId 并读回后才成功。", shortcut.RiskWrite, wikiWriteSafety(false), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID"}, {Name: "name", Type: shortcut.FlagString, Required: true, Desc: "节点名称"}, {Name: "type", Type: shortcut.FlagString, Default: "adoc", Desc: "节点类型", Enum: []string{"adoc", "axls", "able", "appt", "adraw", "amind", "folder"}}, {Name: "folder", Type: shortcut.FlagString, Desc: "父文件夹 ID"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "name", Property: "name"}, {Name: "type", Property: "type"}, {Name: "folder", Property: "folderId"}}, func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"workspaceId": rt.Str("workspace"), "name": rt.Str("name"), "type": rt.Str("type")}
if rt.Changed("folder") {
params["folderId"] = rt.Str("folder")
}
if rt.DryRun() {
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "doc/create_file", "arguments": params})
}
written, err := rt.CallMCPWriteDataStrict("doc", "create_file", params)
if err != nil {
return err
}
written, err = requireWikiWrite(written, "doc/create_file")
if err != nil {
return err
}
id := nestedWikiString(written, "nodeId", "fileId", "id")
if id == "" {
return wikiResponseError("doc/create_file", "missing_created_id", "创建响应没有 nodeId;远端效果未知")
}
verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": id})
if err != nil {
return err
}
verified, err = requireWikiObject(verified, "doc/get_document_info")
if err != nil {
return err
}
if firstWikiString(verified, "nodeId", "id", "fileId") != id {
return wikiResponseError("doc/create_file", "readback_id_mismatch", "创建后读回节点 ID 不一致")
}
return rt.Output(map[string]any{"success": true, "nodeId": id, "node": verified})
})
var NodeCopy = writeShortcut("+node-copy", "复制知识库节点并读回验证", "复制现有在线节点到目标知识库/文件夹;高风险确认后要求新 nodeId 并读取副本元数据。", shortcut.RiskHighWrite, contract.SafetySpec{Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "non_idempotent"}, []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "目标知识库 ID"}, {Name: "node", Type: shortcut.FlagString, Required: true, Desc: "源节点 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "node", Property: "nodeId"}, {Name: "folder", Property: "targetFolderId"}}, func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"workspaceId": rt.Str("workspace"), "nodeId": rt.Str("node")}
if rt.Changed("folder") {
params["targetFolderId"] = rt.Str("folder")
}
if rt.DryRun() {
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "doc/copy_document", "arguments": params})
}
written, err := rt.CallMCPWriteDataStrict("doc", "copy_document", params)
if err != nil {
return err
}
written, err = requireWikiWrite(written, "doc/copy_document")
if err != nil {
return err
}
id := nestedWikiString(written, "nodeId", "fileId", "id")
if id == "" {
return wikiResponseError("doc/copy_document", "missing_created_id", "复制响应没有新 nodeId;远端效果未知")
}
verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": id})
if err != nil {
return err
}
verified, err = requireWikiObject(verified, "doc/get_document_info")
if err != nil {
return err
}
return rt.Output(map[string]any{"success": true, "sourceNodeId": rt.Str("node"), "nodeId": id, "copy": verified})
})
func executeMove(rt *shortcut.RuntimeContext, toDrive bool) error {
preflight, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")})
if err != nil {
return err
}
preflight, err = requireWikiObject(preflight, "doc/get_document_info")
if err != nil {
return err
}
beforeWorkspace := firstWikiString(preflight, "workspaceId", "spaceId")
params := map[string]any{"nodeId": rt.Str("node")}
if !toDrive {
params["workspaceId"] = rt.Str("workspace")
}
if rt.Changed("folder") {
params["targetFolderId"] = rt.Str("folder")
}
if rt.DryRun() {
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "doc/move_document", "arguments": params, "target": preflight})
}
written, err := rt.CallMCPWriteDataStrict("doc", "move_document", params)
if err != nil {
return err
}
if _, err = requireWikiWrite(written, "doc/move_document"); err != nil {
return err
}
verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")})
if err != nil {
return err
}
verified, err = requireWikiObject(verified, "doc/get_document_info")
if err != nil {
return err
}
if firstWikiString(verified, "nodeId", "id", "fileId") != rt.Str("node") {
return wikiResponseError("doc/move_document", "readback_id_mismatch", "移动后读回节点 ID 不一致")
}
afterWorkspace := firstWikiString(verified, "workspaceId", "spaceId")
if toDrive {
if beforeWorkspace == "" || afterWorkspace == "" || beforeWorkspace == afterWorkspace {
return wikiResponseError("doc/move_document", "drive_move_not_verified", "移动到我的文档后 workspace 未发生可验证变化")
}
} else if afterWorkspace != rt.Str("workspace") {
return wikiResponseError("doc/move_document", "workspace_readback_mismatch", "移动后读回的目标知识库不一致")
}
if rt.Changed("folder") && firstWikiString(verified, "folderId", "parentId") != rt.Str("folder") {
return wikiResponseError("doc/move_document", "folder_readback_mismatch", "移动后读回的目标文件夹不一致")
}
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "node": verified})
}
var Move = writeShortcut("+move", "移动节点到知识库并读回验证", "将 Wiki 节点或我的文档在线节点移动到目标知识库/文件夹;同一入口覆盖库内移动与在线文档入库场景。", shortcut.RiskWrite, wikiWriteSafety(true), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "目标知识库 ID"}, {Name: "node", Type: shortcut.FlagString, Required: true, Desc: "节点 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "node", Property: "nodeId"}, {Name: "folder", Property: "targetFolderId"}}, func(rt *shortcut.RuntimeContext) error { return executeMove(rt, false) })
var MoveToDrive = writeShortcut("+move-to-drive", "移动 Wiki 节点到我的文档", "将 Wiki 在线节点同步移动到我的文档或指定文件夹,并以元数据读回证明任务完成。", shortcut.RiskWrite, wikiWriteSafety(true), []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Required: true, Desc: "Wiki 节点 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "我的文档目标文件夹 ID"}}, []contract.ParamDecl{{Name: "node", Property: "nodeId"}, {Name: "folder", Property: "targetFolderId"}}, func(rt *shortcut.RuntimeContext) error { return executeMove(rt, true) })
var NodeDelete = writeShortcut("+node-delete", "删除知识库节点", "明确确认后将节点移入回收站;先读取目标,且删除响应必须提供 success=true 终态证据。", shortcut.RiskHighWrite, wikiDeleteSafety(), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID,用于确认影响范围"}, {Name: "node", Type: shortcut.FlagString, Required: true, Desc: "节点 ID"}}, []contract.ParamDecl{{Name: "node", Property: "nodeId"}}, func(rt *shortcut.RuntimeContext) error {
preflight, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")})
if err != nil {
return err
}
preflight, err = requireWikiObject(preflight, "doc/get_document_info")
if err != nil {
return err
}
if workspace := firstWikiString(preflight, "workspaceId", "spaceId"); workspace != "" && workspace != rt.Str("workspace") {
return wikiResponseError("doc/delete_document", "workspace_preflight_mismatch", "节点不属于请求确认的知识库")
}
if rt.DryRun() {
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "doc/delete_document", "target": preflight})
}
written, err := rt.CallMCPWriteDataStrict("doc", "delete_document", map[string]any{"nodeId": rt.Str("node")})
if err != nil {
return err
}
if _, err = requireWikiWrite(written, "doc/delete_document"); err != nil {
return err
}
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "deleted": true})
})
var FeedList = readShortcut("+feed-list", "严格分页列出知识库动态", "查看谁在何时创建、更新或评论了知识库内容;严格验证 feeds 数组并保留游标。", "feeds", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}, {Name: "limit", Type: shortcut.FlagInt, Default: "10", Desc: "每页数量 1-20"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}, {Name: "exclude-file", Type: shortcut.FlagBool, Desc: "排除普通文件动态"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "limit", Property: "maxResults"}, {Name: "cursor", Property: "nextToken"}, {Name: "exclude-file", Property: "excludeFile"}}, func(rt *shortcut.RuntimeContext) error {
items, page, err := collectWikiPages(rt, "wiki/list_workspace_feeds", rt.Int("limit"), []string{"feeds", "items", "list"}, func(cursor string, size int) (map[string]any, error) {
params := map[string]any{"workspaceId": rt.Str("workspace"), "maxResults": size}
if cursor != "" {
params["nextToken"] = cursor
}
if rt.Changed("exclude-file") {
params["excludeFile"] = rt.Bool("exclude-file")
}
return rt.CallMCPData("wiki", "list_workspace_feeds", params)
})
if err != nil {
return err
}
feeds := projectWikiRows(items, map[string][]string{"id": {"id", "feedId"}, "type": {"type", "feedType", "action"}, "time": {"time", "createTime"}, "name": {"name", "title", "fileName"}, "nodeId": {"nodeId", "fileId"}})
out := map[string]any{"count": len(feeds), "feeds": feeds}
addWikiPagination(out, page)
return rt.Output(out)
})
func init() {
Move.Aliases = []string{"+node-move"}
for _, item := range []*shortcut.Shortcut{&NodeList, &FeedList} {
enableWikiAutoPage(item)
}
for _, item := range []*shortcut.Shortcut{&NodeList, &NodeSearch, &FeedList} {
item.Contract.Pagination = wikiCursorPagination()
}
shortcut.Register(NodeList, NodeGet, NodeSearch, NodeCreate, NodeCopy, Move, MoveToDrive, NodeDelete, FeedList)
_ = fmt.Sprintf
_ = output.RolloutUnifiedActive
}
@@ -18,11 +18,11 @@ import (
"testing"
)
// TestSpaceListProjectWikiSpacesShape guards against projection-data-loss:
// TestCrossPlatformCoverageWikiSpaceListShape guards against projection-data-loss:
// list_wikiSpaces / search_wikiSpaces nest the list under result.wikiSpaces;
// the resolver must probe "wikiSpaces" or +space-list / +space-search silently
// return empty despite the backend returning spaces.
func TestSpaceListProjectWikiSpacesShape(t *testing.T) {
func TestCrossPlatformCoverageWikiSpaceListShape(t *testing.T) {
const raw = `{"result":{"hasMore":false,"wikiSpaces":[
{"workspaceId":"w1","name":"R&D wiki"},
{"workspaceId":"w2","name":"product wiki"}
@@ -31,19 +31,47 @@ func TestSpaceListProjectWikiSpacesShape(t *testing.T) {
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if spaces := spaceListProject(data); len(spaces) != 2 {
items, _, err := requireWikiCollection(data, "wiki/list_wikiSpaces", "wikiSpaces")
if err != nil {
t.Fatal(err)
}
spaces := projectWikiRows(items, map[string][]string{"workspaceId": {"workspaceId"}, "name": {"name"}})
if len(spaces) != 2 {
t.Fatalf("lower/upper mismatch: result.wikiSpaces has 2 entries, projection returned %d (%v)", len(spaces), spaces)
}
}
// TestSpaceListProjectTopLevelWikiSpaces covers the already-unwrapped shape.
func TestSpaceListProjectTopLevelWikiSpaces(t *testing.T) {
func TestCrossPlatformCoverageWikiSpaceListTopLevelShape(t *testing.T) {
const raw = `{"wikiSpaces":[{"workspaceId":"w1","name":"R&D wiki"}]}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if spaces := spaceListProject(data); len(spaces) != 1 {
items, _, err := requireWikiCollection(data, "wiki/list_wikiSpaces", "wikiSpaces")
if err != nil {
t.Fatal(err)
}
spaces := projectWikiRows(items, map[string][]string{"workspaceId": {"workspaceId"}, "name": {"name"}})
if len(spaces) != 1 {
t.Fatalf("top-level wikiSpaces: want 1, got %d (%v)", len(spaces), spaces)
}
}
func TestCrossPlatformCoverageWikiCollectionsRejectFalseEmptySuccess(t *testing.T) {
for name, data := range map[string]map[string]any{
"missing": {"success": true, "hasMore": false},
"malformed": {"success": true, "wikiSpaces": map[string]any{}},
"bad item": {"success": true, "wikiSpaces": []any{"not-an-object"}},
} {
t.Run(name, func(t *testing.T) {
if _, _, err := requireWikiCollection(data, "wiki/list_wikiSpaces", "wikiSpaces"); err == nil {
t.Fatal("malformed response was accepted as an empty success")
}
})
}
items, _, err := requireWikiCollection(map[string]any{"success": true, "wikiSpaces": []any{}}, "wiki/list_wikiSpaces", "wikiSpaces")
if err != nil || len(items) != 0 {
t.Fatalf("explicit empty array must remain valid: items=%v err=%v", items, err)
}
}
+209 -316
View File
@@ -1,344 +1,237 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Licensed under the Apache License, Version 2.0
// Package wiki declares high-fidelity shortcuts for the DingTalk wiki
// (knowledge base) service: space management, member management and node
// management. Tool names and parameters mirror internal/helpers/wiki.go.
// Package wiki declares reviewed, truth-preserving shortcuts for Wiki spaces,
// members, nodes, and activity feeds.
package wiki
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
// ── space (知识库) ────────────────────────────────────────────
var collectionAvoid = []string{"需要原始 MCP 响应或未公开底层参数时改用对应原子命令;缺失业务数组不是合法空结果"}
// SpaceCreate → create_wikiSpace
// SpaceGet → get_wikiSpace
// SpaceList → list_wikiSpaces
var SpaceList = shortcut.Shortcut{
Service: "wiki",
Command: "+space-list",
Product: "wiki",
Description: "列出组织 / 个人知识库",
Intent: "当你想浏览自己有权限访问的知识库、拿到目标知识库的 workspaceId 却不确定具体名称时使用;可按类型(组织知识库或我的知识库)分页列出,返回知识库列表,是定位知识库的常用入口。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "type", Type: shortcut.FlagString, Default: "orgWikiSpace", Desc: "知识库类型: orgWikiSpace(默认) / myWikiSpace", Enum: []string{"orgWikiSpace", "myWikiSpace"}},
{Name: "limit", Type: shortcut.FlagString, Desc: "每页数量 1-50 (默认 20)"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (首页留空)"},
},
Tips: []string{
`dws wiki +space-list`,
`dws wiki +space-list --type myWikiSpace`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{}
if rt.Changed("type") {
params["wikiSpaceType"] = rt.Str("type")
func readShortcut(command, description, intent, collection string, flags []shortcut.Flag, params []contract.ParamDecl, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut {
result := wikiObjectResult(description)
if collection != "" {
result = wikiCollectionResult(collection, description)
}
return shortcut.Shortcut{OutputRollout: output.RolloutUnifiedActive, Service: "wiki", Command: command, Product: "wiki", Description: description, Intent: intent, Risk: shortcut.RiskRead, Safety: wikiReadSafety(), Contract: wikiContract(command, description, intent, collectionAvoid, []string{"dws wiki " + command + " --format json"}, result, nil, params...), Flags: flags, Execute: execute}
}
func writeShortcut(command, description, intent string, risk shortcut.Risk, safety contract.SafetySpec, flags []shortcut.Flag, params []contract.ParamDecl, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut {
return shortcut.Shortcut{OutputRollout: output.RolloutUnifiedActive, Service: "wiki", Command: command, Product: "wiki", Description: description, Intent: intent, Risk: risk, Safety: safety, Contract: wikiContract(command, description, intent, []string{"只需读取或影响范围未确认时不要执行写操作"}, []string{"dws wiki " + command + " --format json"}, wikiObjectResult(description), nil, params...), Flags: flags, Execute: execute}
}
var SpaceList = readShortcut("+space-list", "严格分页列出知识库", "浏览有权访问的组织或个人知识库,并保留服务端分页证据;只有显式 wikiSpaces:[] 才是空结果。", "spaces", []shortcut.Flag{
{Name: "type", Type: shortcut.FlagString, Default: "orgWikiSpace", Desc: "知识库类型", Enum: []string{"orgWikiSpace", "myWikiSpace"}},
{Name: "limit", Type: shortcut.FlagString, Desc: "每页数量 1-50(默认 20)"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标", Aliases: []string{"page-token"}, AliasesVisible: true},
}, []contract.ParamDecl{{Name: "type", Property: "wikiSpaceType"}, {Name: "limit", Property: "pageSize"}, {Name: "cursor", Property: "pageToken"}}, func(rt *shortcut.RuntimeContext) error {
pageSize, err := wikiStringInt(rt, "limit", 20, 1, 50)
if err != nil {
return err
}
items, page, err := collectWikiPages(rt, "wiki/list_wikiSpaces", pageSize, []string{"wikiSpaces", "spaces"}, func(cursor string, size int) (map[string]any, error) {
params := map[string]any{"wikiSpaceType": rt.Str("type"), "pageSize": size}
if cursor != "" {
params["pageToken"] = cursor
}
if rt.Changed("limit") {
params["pageSize"] = rt.Str("limit")
return rt.CallMCPData("wiki", "list_wikiSpaces", params)
})
if err != nil {
return err
}
spaces := projectWikiRows(items, map[string][]string{"workspaceId": {"workspaceId", "spaceId", "id"}, "name": {"name", "spaceName", "title"}, "description": {"description", "desc"}, "createTime": {"createTime", "createdAt"}, "url": {"spaceUrl", "url"}})
out := map[string]any{"count": len(spaces), "spaces": spaces}
addWikiPagination(out, page)
return rt.Output(out)
})
var SpaceSearch = readShortcut("+space-search", "严格搜索知识库", "按名称关键词定位知识库;严格验证搜索数组,避免内部异常被误报为零命中。", "spaces", []shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Required: true, Desc: "搜索关键词"}, {Name: "limit", Type: shortcut.FlagString, Desc: "返回数量 1-20(默认 10)"},
}, []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "limit", Property: "pageSize"}}, func(rt *shortcut.RuntimeContext) error {
pageSize, err := wikiStringInt(rt, "limit", 10, 1, 20)
if err != nil {
return err
}
data, err := rt.CallMCPData("wiki", "search_wikiSpaces", map[string]any{"keyword": rt.Str("query"), "pageSize": pageSize})
if err != nil {
return err
}
items, page, err := requireWikiCollection(data, "wiki/search_wikiSpaces", "wikiSpaces", "spaces")
if err != nil {
return err
}
spaces := projectWikiRows(items, map[string][]string{"workspaceId": {"workspaceId", "spaceId", "id"}, "name": {"name", "spaceName", "title"}, "description": {"description", "desc"}, "url": {"spaceUrl", "url"}})
out := map[string]any{"count": len(spaces), "spaces": spaces}
addWikiPagination(out, page)
return rt.Output(out)
})
var SpaceGet = readShortcut("+space-get", "获取知识库详情", "已知 workspace ID 或知识库 URL 时读取并验证空间详情。", "", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}}, func(rt *shortcut.RuntimeContext) error {
data, err := rt.CallMCPData("wiki", "get_wikiSpace", map[string]any{"workspaceId": rt.Str("workspace")})
if err != nil {
return err
}
object, err := requireWikiObject(data, "wiki/get_wikiSpace")
if err != nil {
return err
}
if firstWikiString(object, "workspaceId", "spaceId", "id") == "" {
return wikiResponseError("wiki/get_wikiSpace", "missing_workspace_id", "空间详情缺少 workspaceId")
}
return rt.Output(object)
})
var SpaceCreate = writeShortcut("+space-create", "创建知识库并读回验证", "创建新的知识库容器;必须取得 workspaceId 并通过详情读回后才报告成功。", shortcut.RiskWrite, wikiWriteSafety(false), []shortcut.Flag{{Name: "name", Type: shortcut.FlagString, Required: true, Desc: "知识库名称,不超过 32 个字符。--name 不超过 32 个字符;--desc 不超过 500 个字符"}, {Name: "desc", Type: shortcut.FlagString, Desc: "知识库描述,不超过 500 个字符。--name 不超过 32 个字符;--desc 不超过 500 个字符"}, {Name: "icon", Type: shortcut.FlagString, Desc: "图标标识"}}, []contract.ParamDecl{{Name: "name", Property: "name"}, {Name: "desc", Property: "description"}, {Name: "icon", Property: "icon"}}, func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"name": rt.Str("name")}
if rt.Changed("desc") {
params["description"] = rt.Str("desc")
}
if rt.Changed("icon") {
params["icon"] = rt.Str("icon")
}
if rt.DryRun() {
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "wiki/create_wikiSpace", "arguments": params})
}
written, err := rt.CallMCPWriteDataStrict("wiki", "create_wikiSpace", params)
if err != nil {
return err
}
written, err = requireWikiWrite(written, "wiki/create_wikiSpace")
if err != nil {
return err
}
id := nestedWikiString(written, "workspaceId", "spaceId", "id")
if id == "" {
return wikiResponseError("wiki/create_wikiSpace", "missing_created_id", "创建响应没有 workspaceId;远端效果未知")
}
verified, err := rt.CallMCPData("wiki", "get_wikiSpace", map[string]any{"workspaceId": id})
if err != nil {
return err
}
verified, err = requireWikiObject(verified, "wiki/get_wikiSpace")
if err != nil {
return err
}
if firstWikiString(verified, "workspaceId", "spaceId", "id") != id {
return wikiResponseError("wiki/create_wikiSpace", "readback_id_mismatch", "创建后读回的 workspaceId 不一致")
}
return rt.Output(map[string]any{"success": true, "workspaceId": id, "space": verified})
})
var DeleteSpace = writeShortcut("+delete-space", "删除知识库", "用户明确确认后将整个知识库移入回收站;删除前读取目标,删除响应必须有 success=true。", shortcut.RiskHighWrite, wikiDeleteSafety(), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}}, func(rt *shortcut.RuntimeContext) error {
preflight, err := rt.CallMCPData("wiki", "get_wikiSpace", map[string]any{"workspaceId": rt.Str("workspace")})
if err != nil {
return err
}
preflight, err = requireWikiObject(preflight, "wiki/get_wikiSpace")
if err != nil {
return err
}
if rt.DryRun() {
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "wiki/delete_wikiSpace", "target": preflight})
}
written, err := rt.CallMCPWriteDataStrict("wiki", "delete_wikiSpace", map[string]any{"workspaceId": rt.Str("workspace")})
if err != nil {
return err
}
written, err = requireWikiWrite(written, "wiki/delete_wikiSpace")
if err != nil {
return err
}
return rt.Output(map[string]any{"success": true, "workspaceId": rt.Str("workspace"), "deleted": true})
})
func memberFlags(withRole bool) []shortcut.Flag {
flags := []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}, {Name: "users", Type: shortcut.FlagStringSlice, Required: true, Desc: "用户 userId,最多 30 个", Aliases: []string{"user"}, AliasesVisible: true}}
if withRole {
flags = append(flags, shortcut.Flag{Name: "role", Type: shortcut.FlagString, Required: true, Desc: "角色", Enum: []string{"MANAGER", "EDITOR", "DOWNLOADER", "READER"}})
}
return flags
}
func memberWrite(command, tool, description, intent string, withRole bool) shortcut.Shortcut {
params := []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "users", Property: "userIds"}}
if withRole {
params = append(params, contract.ParamDecl{Name: "role", Property: "roleId"})
}
return writeShortcut(command, description, intent, shortcut.RiskWrite, wikiWriteSafety(false), memberFlags(withRole), params, func(rt *shortcut.RuntimeContext) error {
users := wikiStringSliceFirst(rt, "users", "user")
if len(users) == 0 || len(users) > 30 {
return fmt.Errorf("--users 必须包含 1-30 个 userId")
}
if rt.Changed("cursor") {
params["pageToken"] = rt.Str("cursor")
args := map[string]any{"workspaceId": rt.Str("workspace"), "userIds": users}
if withRole {
args["roleId"] = strings.ToUpper(rt.Str("role"))
}
data, err := rt.CallMCPData("wiki", "list_wikiSpaces", params)
if rt.DryRun() {
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "wiki/" + tool, "arguments": args})
}
written, err := rt.CallMCPWriteDataStrict("wiki", tool, args)
if err != nil {
return err
}
spaces := spaceListProject(data)
return rt.Output(map[string]any{"count": len(spaces), "spaces": spaces})
},
}
// spaceListProject reshapes list_wikiSpaces into a clean space list
// ({workspaceId, name, description, createTime}) — output-projection fidelity
// for clean output. The list container and per-item field names are probed defensively
// across candidate keys, so an unrecognized shape yields an empty list.
func spaceListProject(data map[string]any) []map[string]any {
raw := wikiSpaceRawList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
if v := wikiSpaceFirst(m, "workspaceId", "workspace_id", "spaceId", "space_id", "id"); v != nil {
row["workspaceId"] = v
}
if v := wikiSpaceFirst(m, "name", "title", "spaceName"); v != nil {
row["name"] = v
}
if v := wikiSpaceFirst(m, "description", "desc"); v != nil {
row["description"] = v
}
if v := wikiSpaceFirst(m, "createTime", "create_time", "gmtCreate", "createdAt"); v != nil {
row["createTime"] = v
}
if len(row) > 0 {
out = append(out, row)
}
}
return out
}
// wikiSpaceRawList locates the space array across candidate container keys,
// tolerating a nested {result|data:{list|items|spaces}} wrapper.
func wikiSpaceRawList(data map[string]any) []any {
// list_wikiSpaces / search_wikiSpaces nest the space list under
// result.wikiSpaces (or a top-level wikiSpaces once unwrapped); "wikiSpaces"
// MUST be probed or +space-list / +space-search silently return empty.
for _, k := range []string{"result", "data", "list", "items", "wikiSpaces", "spaces", "workspaces"} {
if arr, ok := data[k].([]any); ok {
return arr
}
if inner, ok := data[k].(map[string]any); ok {
for _, ik := range []string{"list", "items", "wikiSpaces", "spaces", "workspaces", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
}
return nil
}
// wikiSpaceFirst returns the first present value among candidate keys.
func wikiSpaceFirst(m map[string]any, keys ...string) any {
for _, k := range keys {
if v, ok := m[k]; ok {
return v
}
}
return nil
}
// SpaceSearch → search_wikiSpaces
var SpaceSearch = shortcut.Shortcut{
Service: "wiki",
Command: "+space-search",
Product: "wiki",
Description: "搜索知识库",
Intent: "当你只记得知识库名称的部分关键词、想快速按名称定位某个知识库时使用;输入关键词返回匹配的知识库列表,比逐页 +space-list 更快找到目标 workspaceId。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "wiki",
Name: "shortcut_space_search",
CanonicalPath: "wiki.shortcut_space_search",
CLIPath: "wiki +space-search",
PrimaryCLIPath: "wiki +space-search",
},
Description: "搜索知识库",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "搜索知识库",
UseWhen: []string{"当你只记得知识库名称的部分关键词、想快速按名称定位某个知识库时使用;输入关键词返回匹配的知识库列表,比逐页 +space-list 更快找到目标 workspaceId。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws wiki +space-search --query \"产品文档\""},
},
},
Flags: []shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词", Required: true},
{Name: "limit", Type: shortcut.FlagString, Desc: "返回数量 1-20 (默认 10)"},
},
Tips: []string{`dws wiki +space-search --query "产品文档"`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"keyword": rt.Str("query")}
if rt.Changed("limit") {
params["pageSize"] = rt.Str("limit")
}
data, err := rt.CallMCPData("wiki", "search_wikiSpaces", params)
written, err = requireWikiWrite(written, "wiki/"+tool)
if err != nil {
return err
}
spaces := spaceListProject(data)
return rt.Output(map[string]any{"count": len(spaces), "spaces": spaces})
},
return rt.Output(map[string]any{
"success": true, "workspaceId": rt.Str("workspace"), "userCount": len(users), "operation": tool,
"verifiedBy": "write_terminal_success",
"verification": map[string]any{
"status": "terminal_response_only",
"readbackAvailable": false,
"reason": "member_list_is_capped_and_has_no_cursor",
},
})
})
}
// SpaceDelete → delete_wikiSpace
// ── member (知识库成员) ───────────────────────────────────────
var MemberAdd = memberWrite("+member-add", "add_member", "添加知识库成员", "向知识库授予一个或多个用户容器级角色;仅以写接口 success=true 作为终态证据,并明确成员列表无法完成精确读回。", true)
var MemberUpdate = memberWrite("+member-update", "update_member", "更新知识库成员角色", "调整已有成员的知识库容器级角色;仅以写接口 success=true 作为终态证据,并明确成员列表无法完成精确读回。", true)
var MemberRemove = memberWrite("+member-remove", "remove_member", "移除知识库成员", "移除一个或多个用户的知识库容器级访问;仅以写接口 success=true 作为终态证据,并明确成员列表无法完成精确读回。", false)
// MemberAdd → add_member
// MemberUpdate → update_member
// MemberList → list_member
// MemberRemove → remove_member
// ── node (知识库节点,路由到 doc MCP server) ──────────────────
// NodeList → list_nodes (doc)
var NodeList = shortcut.Shortcut{
Service: "wiki",
Command: "+node-list",
Product: "doc",
Description: "列出知识库节点",
Intent: "当你要浏览某个知识库的目录结构、查看某文件夹下有哪些文档/子文件夹并拿到它们的 nodeId 时使用;输入 workspace(可选父节点 folder),分页返回该层级的节点列表,是逐层进入知识库定位文档的常用方式。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID", Required: true},
{Name: "folder", Type: shortcut.FlagString, Desc: "父节点 nodeId (不传则列出根目录)"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"},
},
Tips: []string{`dws wiki +node-list --workspace <workspaceId> --folder <parentNodeId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"workspaceId": rt.Str("workspace")}
if rt.Changed("folder") {
params["folderId"] = rt.Str("folder")
}
if rt.Changed("limit") {
params["pageSize"] = rt.Int("limit")
}
if rt.Changed("cursor") {
params["pageToken"] = rt.Str("cursor")
}
data, err := rt.CallMCPData("wiki", "list_nodes", params)
if err != nil {
return err
}
nodes := nodeListProject(data)
return rt.Output(map[string]any{"count": len(nodes), "nodes": nodes})
},
}
// nodeListProject reshapes list_nodes into a clean node list (name/nodeId/type)
// — clean output projection. Container and field keys are probed
// defensively across candidate aliases; an unrecognized shape yields an empty list.
func nodeListProject(data map[string]any) []map[string]any {
raw := nodeListRawList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
if v := nodeListFirst(m, "name", "title", "nodeName"); v != nil {
row["name"] = v
}
if v := nodeListFirst(m, "nodeId", "node_id", "id", "uuid", "dentryUuid"); v != nil {
row["nodeId"] = v
}
if v := nodeListFirst(m, "type", "nodeType", "docType", "fileType"); v != nil {
row["type"] = v
}
if len(row) > 0 {
out = append(out, row)
}
var MemberList = readShortcut("+member-list", "严格列出知识库成员", "列出知识库成员及角色;后端不提供可续游标且单次真实上限为 50,不伪造 page-all。", "members", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}, {Name: "limit", Type: shortcut.FlagInt, Default: "30", Desc: "返回上限 1-50"}, {Name: "filter-role", Type: shortcut.FlagStringSlice, Desc: "角色过滤"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "limit", Property: "maxResults"}, {Name: "filter-role", Property: "filterRoleIds"}}, func(rt *shortcut.RuntimeContext) error {
if rt.Int("limit") < 1 || rt.Int("limit") > 50 {
return fmt.Errorf("--limit 必须在 1-50 之间;服务端不支持超过 50 或游标续页")
}
return out
}
// nodeListRawList locates the node array across candidate container keys,
// tolerating a nested {result|data:{list|items|nodes}} wrapper.
func nodeListRawList(data map[string]any) []any {
for _, k := range []string{"result", "data", "list", "items", "nodes"} {
if arr, ok := data[k].([]any); ok {
return arr
}
if inner, ok := data[k].(map[string]any); ok {
for _, ik := range []string{"list", "items", "nodes", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
params := map[string]any{"workspaceId": rt.Str("workspace"), "maxResults": rt.Int("limit")}
if rt.Changed("filter-role") {
params["filterRoleIds"] = rt.StrSlice("filter-role")
}
return nil
}
// nodeListFirst returns the first present value among candidate keys.
func nodeListFirst(m map[string]any, keys ...string) any {
for _, k := range keys {
if v, ok := m[k]; ok {
return v
}
data, err := rt.CallMCPData("wiki", "list_member", params)
if err != nil {
return err
}
return nil
}
items, page, err := requireWikiCollection(data, "wiki/list_member", "members")
if err != nil {
return err
}
members := projectWikiRows(items, map[string][]string{"id": {"id", "userId"}, "name": {"name", "nick"}, "role": {"role", "roleId"}, "type": {"type"}, "outer": {"outer"}})
out := map[string]any{"count": len(members), "members": members}
addWikiPagination(out, page)
return rt.Output(out)
})
// NodeCreate → create_file (doc)
// NodeCopy → copy_document (doc)
var NodeCopy = shortcut.Shortcut{
Service: "wiki",
Command: "+node-copy",
Product: "doc",
Description: "复制知识库节点",
Intent: "当你想基于已有文档/文件夹快速生成一份副本(如用模板起草新文档、留档备份)时使用;指定源 node 和目标 folder,会实际在知识库中复制出一个新节点,原节点保持不变。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID", Required: true},
{Name: "node", Type: shortcut.FlagString, Desc: "源节点 ID", Required: true},
{Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 nodeId (不传则复制到根目录)"},
},
Tips: []string{`dws wiki +node-copy --workspace <workspaceId> --node <nodeId> --folder <targetFolderId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"nodeId": rt.Str("node"),
"workspaceId": rt.Str("workspace"),
}
if rt.Changed("folder") {
params["targetFolderId"] = rt.Str("folder")
}
return rt.CallMCP("copy_document", params)
},
}
// NodeMove → move_document (doc)
var NodeMove = shortcut.Shortcut{
Service: "wiki",
Command: "+node-move",
Product: "doc",
Description: "移动知识库节点",
Intent: "当你要重新整理知识库目录、把某个文档或文件夹从当前位置挪到另一个文件夹(或根目录)下时使用;指定源 node 和目标 folder,会实际改变该节点在知识库中的所属位置。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID", Required: true},
{Name: "node", Type: shortcut.FlagString, Desc: "源节点 ID", Required: true},
{Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 nodeId (不传则移动到根目录)"},
},
Tips: []string{`dws wiki +node-move --workspace <workspaceId> --node <nodeId> --folder <targetFolderId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"nodeId": rt.Str("node"),
"workspaceId": rt.Str("workspace"),
}
if rt.Changed("folder") {
params["targetFolderId"] = rt.Str("folder")
}
return rt.CallMCP("move_document", params)
},
}
// NodeDelete → delete_document (doc)
// NodeSearch → search_documents (doc)
func init() {
shortcut.Register(
SpaceList,
SpaceSearch,
NodeList,
NodeCopy,
NodeMove,
)
DeleteSpace.Aliases = []string{"+space-delete"}
SpaceCreate.Validate = func(rt *shortcut.RuntimeContext) error {
if len([]rune(rt.Str("name"))) > 32 {
return fmt.Errorf("--name 不能超过 32 个字符")
}
if len([]rune(rt.Str("desc"))) > 500 {
return fmt.Errorf("--desc 不能超过 500 个字符")
}
return nil
}
SpaceCreate.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"name", "desc"}, Description: "--name 不超过 32 个字符;--desc 不超过 500 个字符"}}
enableWikiAutoPage(&SpaceList)
SpaceList.Contract.Pagination = wikiCursorPagination()
shortcut.Register(SpaceList, SpaceSearch, SpaceGet, SpaceCreate, DeleteSpace, MemberAdd, MemberUpdate, MemberList, MemberRemove)
}
@@ -0,0 +1,638 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package wiki
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type wikiCoverageCall struct {
product string
tool string
args map[string]any
}
type wikiCoverageCaller struct {
responses map[string][]string
errors map[string][]error
indexes map[string]int
calls []wikiCoverageCall
dryRun bool
}
func (c *wikiCoverageCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
key := product + "/" + tool
c.calls = append(c.calls, wikiCoverageCall{product: product, tool: tool, args: args})
if c.indexes == nil {
c.indexes = map[string]int{}
}
index := c.indexes[key]
c.indexes[key]++
if sequence := c.errors[key]; index < len(sequence) && sequence[index] != nil {
return nil, sequence[index]
}
sequence := c.responses[key]
if len(sequence) == 0 {
return nil, fmt.Errorf("unexpected MCP call %s", key)
}
if index >= len(sequence) {
index = len(sequence) - 1
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: sequence[index]}}}, nil
}
func (c *wikiCoverageCaller) Format() string { return "json" }
func (c *wikiCoverageCaller) DryRun() bool { return c.dryRun }
func (c *wikiCoverageCaller) Fields() string { return "" }
func (c *wikiCoverageCaller) JQ() string { return "" }
func runWikiCoverageCLI(t *testing.T, caller *wikiCoverageCaller, args ...string) (map[string]any, error) {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
root.PersistentFlags().String("jq", "", "")
root.PersistentFlags().String("fields", "", "")
ctx, _ := output.WithResultStore(context.Background())
root.SetContext(ctx)
root.AddCommand(shortcut.Commands()...)
stdout := &bytes.Buffer{}
root.SetOut(stdout)
root.SetErr(&bytes.Buffer{})
root.SetArgs(append([]string{"wiki"}, args...))
executed, err := root.ExecuteC()
if err == nil {
if _, _, emitErr := output.EmitStoredResult(executed); emitErr != nil {
err = emitErr
}
}
if stdout.Len() == 0 {
return nil, err
}
var payload map[string]any
if decodeErr := json.Unmarshal(stdout.Bytes(), &payload); decodeErr != nil {
t.Fatalf("decode output %q: %v", stdout.String(), decodeErr)
}
if data, ok := payload["data"].(map[string]any); ok {
return data, err
}
return payload, err
}
func TestCrossPlatformCoverageWikiSpaceWorkflows(t *testing.T) {
t.Run("list default string limit", func(t *testing.T) {
caller := &wikiCoverageCaller{responses: map[string][]string{
"wiki/list_wikiSpaces": {`{"success":true,"result":{"wikiSpaces":[{"workspaceId":"w1","name":"Docs"}],"hasMore":false}}`},
}}
out, err := runWikiCoverageCLI(t, caller, "+space-list")
if err != nil || out["count"] != float64(1) || caller.calls[0].args["pageSize"] != 20 {
t.Fatalf("list output=%#v err=%v calls=%#v", out, err, caller.calls)
}
})
t.Run("list explicit legacy string limit and cursor", func(t *testing.T) {
caller := &wikiCoverageCaller{responses: map[string][]string{
"wiki/list_wikiSpaces": {`{"wikiSpaces":[],"hasMore":false}`},
}}
out, err := runWikiCoverageCLI(t, caller, "+space-list", "--type", "myWikiSpace", "--limit", "7", "--page-token", "next")
if err != nil || out["count"] != float64(0) || caller.calls[0].args["pageSize"] != 7 || caller.calls[0].args["pageToken"] != "next" {
t.Fatalf("list output=%#v err=%v calls=%#v", out, err, caller.calls)
}
})
for _, value := range []string{"bad", "0", "51"} {
t.Run("list rejects "+value, func(t *testing.T) {
caller := &wikiCoverageCaller{}
if _, err := runWikiCoverageCLI(t, caller, "+space-list", "--limit", value); err == nil || len(caller.calls) != 0 {
t.Fatalf("limit %q err=%v calls=%#v", value, err, caller.calls)
}
})
}
t.Run("search parses string limit", func(t *testing.T) {
caller := &wikiCoverageCaller{responses: map[string][]string{
"wiki/search_wikiSpaces": {`{"success":true,"wikiSpaces":[{"spaceId":"w2","spaceName":"Plan"}]}`},
}}
out, err := runWikiCoverageCLI(t, caller, "+space-search", "--query", "Plan", "--limit", "12")
if err != nil || out["count"] != float64(1) || caller.calls[0].args["pageSize"] != 12 {
t.Fatalf("search output=%#v err=%v calls=%#v", out, err, caller.calls)
}
})
t.Run("get requires business id", func(t *testing.T) {
caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true,"result":{"name":"missing id"}}`}}}
if _, err := runWikiCoverageCLI(t, caller, "+space-get", "--workspace", "w"); err == nil {
t.Fatal("space without id succeeded")
}
})
t.Run("create dry-run", func(t *testing.T) {
caller := &wikiCoverageCaller{}
out, err := runWikiCoverageCLI(t, caller, "+space-create", "--name", "Docs", "--desc", "D", "--icon", "I", "--dry-run")
if err != nil || out["executed"] != false || len(caller.calls) != 0 {
t.Fatalf("dry-run output=%#v err=%v calls=%#v", out, err, caller.calls)
}
})
t.Run("create and exact readback", func(t *testing.T) {
caller := &wikiCoverageCaller{responses: map[string][]string{
"wiki/create_wikiSpace": {`{"success":true,"result":{"workspaceId":"w3"}}`},
"wiki/get_wikiSpace": {`{"success":true,"data":{"workspaceId":"w3","name":"Docs"}}`},
}}
out, err := runWikiCoverageCLI(t, caller, "+space-create", "--name", "Docs")
if err != nil || out["workspaceId"] != "w3" || len(caller.calls) != 2 {
t.Fatalf("create output=%#v err=%v calls=%#v", out, err, caller.calls)
}
})
t.Run("delete preflight dry-run and terminal", func(t *testing.T) {
dry := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"workspaceId":"w4","name":"Docs"}`}}}
out, err := runWikiCoverageCLI(t, dry, "+delete-space", "--workspace", "w4", "--dry-run", "--yes")
if err != nil || out["executed"] != false || len(dry.calls) != 1 {
t.Fatalf("delete dry-run output=%#v err=%v calls=%#v", out, err, dry.calls)
}
caller := &wikiCoverageCaller{responses: map[string][]string{
"wiki/get_wikiSpace": {`{"workspaceId":"w4","name":"Docs"}`},
"wiki/delete_wikiSpace": {`{"success":true}`},
}}
out, err = runWikiCoverageCLI(t, caller, "+space-delete", "--workspace", "w4", "--yes")
if err != nil || out["deleted"] != true || len(caller.calls) != 2 {
t.Fatalf("delete output=%#v err=%v calls=%#v", out, err, caller.calls)
}
})
for _, args := range [][]string{{"--name", strings.Repeat("界", 33)}, {"--name", "x", "--desc", strings.Repeat("界", 501)}} {
caller := &wikiCoverageCaller{}
if _, err := runWikiCoverageCLI(t, caller, append([]string{"+space-create"}, args...)...); err == nil || len(caller.calls) != 0 {
t.Fatalf("invalid create args succeeded: %v", args)
}
}
}
func TestCrossPlatformCoverageWikiMemberWritesUseTerminalEvidenceOnly(t *testing.T) {
for _, tc := range []struct {
command string
tool string
extra []string
}{
{command: "+member-add", tool: "add_member", extra: []string{"--role", "READER"}},
{command: "+member-update", tool: "update_member", extra: []string{"--role", "EDITOR"}},
{command: "+member-remove", tool: "remove_member"},
} {
t.Run(tc.command, func(t *testing.T) {
caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/" + tc.tool: {`{"success":true}`}}}
args := append([]string{tc.command, "--workspace", "w", "--users", "u1,u2"}, tc.extra...)
out, err := runWikiCoverageCLI(t, caller, args...)
verification, _ := out["verification"].(map[string]any)
if err != nil || out["verifiedBy"] != "write_terminal_success" || verification["readbackAvailable"] != false || len(caller.calls) != 1 {
t.Fatalf("member output=%#v err=%v calls=%#v", out, err, caller.calls)
}
if caller.calls[0].tool == "list_member" {
t.Fatal("member write used capped list as readback")
}
if tc.tool != "remove_member" && caller.calls[0].args["roleId"] != strings.ToUpper(tc.extra[1]) {
t.Fatalf("role was not normalized: %#v", caller.calls[0].args)
}
})
}
t.Run("dry-run has no write", func(t *testing.T) {
caller := &wikiCoverageCaller{}
out, err := runWikiCoverageCLI(t, caller, "+member-add", "--workspace", "w", "--users", "u", "--role", "READER", "--dry-run")
if err != nil || out["executed"] != false || len(caller.calls) != 0 {
t.Fatalf("dry-run output=%#v err=%v calls=%#v", out, err, caller.calls)
}
})
t.Run("rejects missing terminal evidence", func(t *testing.T) {
caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/add_member": {`{"result":{"accepted":true}}`}}}
if _, err := runWikiCoverageCLI(t, caller, "+member-add", "--workspace", "w", "--users", "u", "--role", "READER"); err == nil {
t.Fatal("member write without success=true succeeded")
}
})
many := make([]string, 31)
for index := range many {
many[index] = fmt.Sprintf("u%d", index)
}
caller := &wikiCoverageCaller{}
if _, err := runWikiCoverageCLI(t, caller, "+member-remove", "--workspace", "w", "--users", strings.Join(many, ",")); err == nil || len(caller.calls) != 0 {
t.Fatal("more than 30 members reached MCP")
}
}
func TestCrossPlatformCoverageWikiMemberListContracts(t *testing.T) {
caller := &wikiCoverageCaller{responses: map[string][]string{
"wiki/list_member": {`{"success":true,"members":[{"userId":"u1","nick":"A","roleId":"READER","type":"USER","outer":false}],"truncated":false}`},
}}
out, err := runWikiCoverageCLI(t, caller, "+member-list", "--workspace", "w", "--limit", "50", "--filter-role", "READER,EDITOR")
if err != nil || out["count"] != float64(1) || caller.calls[0].args["maxResults"] != 50 {
t.Fatalf("member list output=%#v err=%v calls=%#v", out, err, caller.calls)
}
for _, value := range []string{"0", "51"} {
caller = &wikiCoverageCaller{}
if _, err := runWikiCoverageCLI(t, caller, "+member-list", "--workspace", "w", "--limit", value); err == nil || len(caller.calls) != 0 {
t.Fatalf("invalid member limit %s reached MCP", value)
}
}
}
func TestCrossPlatformCoverageWikiReadWorkflows(t *testing.T) {
cases := []struct {
name string
args []string
responses map[string][]string
wantTool string
wantCount float64
}{
{name: "node list", args: []string{"+node-list", "--workspace", "w", "--folder", "f", "--limit", "2", "--cursor", "c"}, responses: map[string][]string{"doc/list_nodes": {`{"success":true,"nodes":[{"id":"n","title":"Doc","parentId":"f","spaceId":"w"}],"hasMore":false}`}}, wantTool: "list_nodes", wantCount: 1},
{name: "node search", args: []string{"+node-search", "--workspace", "w", "--query", "Doc", "--extensions", "adoc", "--limit", "2", "--cursor", "c"}, responses: map[string][]string{"doc/search_documents": {`{"success":true,"documents":[],"hasMore":false}`}}, wantTool: "search_documents", wantCount: 0},
{name: "feed list", args: []string{"+feed-list", "--workspace", "w", "--limit", "2", "--cursor", "c", "--exclude-file"}, responses: map[string][]string{"wiki/list_workspace_feeds": {`{"success":true,"feeds":[{"feedId":"x","feedType":"update","fileId":"n"}],"hasMore":false}`}}, wantTool: "list_workspace_feeds", wantCount: 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caller := &wikiCoverageCaller{responses: tc.responses}
out, err := runWikiCoverageCLI(t, caller, tc.args...)
if err != nil || out["count"] != tc.wantCount || caller.calls[0].tool != tc.wantTool {
t.Fatalf("output=%#v err=%v calls=%#v", out, err, caller.calls)
}
})
}
t.Run("node get", func(t *testing.T) {
caller := &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true,"result":{"nodeId":"n","title":"Doc"}}`}}}
out, err := runWikiCoverageCLI(t, caller, "+node-get", "--node", "n")
if err != nil || out["nodeId"] != "n" {
t.Fatalf("node get output=%#v err=%v", out, err)
}
})
}
func TestCrossPlatformCoverageWikiWriteWorkflows(t *testing.T) {
t.Run("node create dry and verified", func(t *testing.T) {
dry := &wikiCoverageCaller{}
out, err := runWikiCoverageCLI(t, dry, "+node-create", "--workspace", "w", "--folder", "f", "--name", "Doc", "--type", "adoc", "--dry-run")
if err != nil || out["executed"] != false || len(dry.calls) != 0 {
t.Fatalf("create dry output=%#v err=%v calls=%#v", out, err, dry.calls)
}
caller := &wikiCoverageCaller{responses: map[string][]string{
"doc/create_file": {`{"success":true,"data":{"fileId":"n"}}`},
"doc/get_document_info": {`{"success":true,"nodeId":"n","workspaceId":"w","folderId":"f"}`},
}}
out, err = runWikiCoverageCLI(t, caller, "+node-create", "--workspace", "w", "--folder", "f", "--name", "Doc")
if err != nil || out["nodeId"] != "n" || len(caller.calls) != 2 {
t.Fatalf("create output=%#v err=%v calls=%#v", out, err, caller.calls)
}
})
t.Run("node copy dry and verified", func(t *testing.T) {
dry := &wikiCoverageCaller{}
out, err := runWikiCoverageCLI(t, dry, "+node-copy", "--workspace", "w", "--folder", "f", "--node", "source", "--dry-run", "--yes")
if err != nil || out["executed"] != false || len(dry.calls) != 0 {
t.Fatalf("copy dry output=%#v err=%v calls=%#v", out, err, dry.calls)
}
caller := &wikiCoverageCaller{responses: map[string][]string{
"doc/copy_document": {`{"success":true,"nodeId":"copy"}`},
"doc/get_document_info": {`{"success":true,"nodeId":"copy","workspaceId":"w"}`},
}}
out, err = runWikiCoverageCLI(t, caller, "+node-copy", "--workspace", "w", "--node", "source", "--yes")
if err != nil || out["nodeId"] != "copy" || len(caller.calls) != 2 {
t.Fatalf("copy output=%#v err=%v calls=%#v", out, err, caller.calls)
}
})
t.Run("move within wiki and to drive", func(t *testing.T) {
caller := &wikiCoverageCaller{responses: map[string][]string{
"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old","folderId":"old-f"}`, `{"nodeId":"n","workspaceId":"target","folderId":"f"}`},
"doc/move_document": {`{"success":true}`},
}}
out, err := runWikiCoverageCLI(t, caller, "+move", "--workspace", "target", "--folder", "f", "--node", "n", "--yes")
if err != nil || out["nodeId"] != "n" || len(caller.calls) != 3 {
t.Fatalf("move output=%#v err=%v calls=%#v", out, err, caller.calls)
}
caller = &wikiCoverageCaller{responses: map[string][]string{
"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"nodeId":"n","workspaceId":"drive"}`},
"doc/move_document": {`{"success":true}`},
}}
out, err = runWikiCoverageCLI(t, caller, "+move-to-drive", "--node", "n", "--yes")
if err != nil || out["nodeId"] != "n" {
t.Fatalf("move-to-drive output=%#v err=%v calls=%#v", out, err, caller.calls)
}
})
t.Run("move dry-run only preflights", func(t *testing.T) {
caller := &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`}}}
out, err := runWikiCoverageCLI(t, caller, "+move", "--workspace", "target", "--node", "n", "--dry-run", "--yes")
if err != nil || out["executed"] != false || len(caller.calls) != 1 {
t.Fatalf("move dry output=%#v err=%v calls=%#v", out, err, caller.calls)
}
})
t.Run("node delete dry and terminal", func(t *testing.T) {
dry := &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"w"}`}}}
out, err := runWikiCoverageCLI(t, dry, "+node-delete", "--workspace", "w", "--node", "n", "--dry-run", "--yes")
if err != nil || out["executed"] != false || len(dry.calls) != 1 {
t.Fatalf("delete dry output=%#v err=%v calls=%#v", out, err, dry.calls)
}
caller := &wikiCoverageCaller{responses: map[string][]string{
"doc/get_document_info": {`{"nodeId":"n","workspaceId":"w"}`},
"doc/delete_document": {`{"success":true}`},
}}
out, err = runWikiCoverageCLI(t, caller, "+node-delete", "--workspace", "w", "--node", "n", "--yes")
if err != nil || out["deleted"] != true || len(caller.calls) != 2 {
t.Fatalf("delete output=%#v err=%v calls=%#v", out, err, caller.calls)
}
})
}
func TestCrossPlatformCoverageWikiResponseValidationBranches(t *testing.T) {
if _, err := requireWikiResponse(nil, "op"); err == nil {
t.Fatal("empty response accepted")
}
for _, data := range []map[string]any{{"success": "yes"}, {"success": false}, {"success": false, "message": "denied"}} {
if _, err := requireWikiResponse(data, "op"); err == nil {
t.Fatalf("invalid response accepted: %#v", data)
}
}
if _, err := requireWikiWrite(map[string]any{"result": map[string]any{"id": "x"}}, "op"); err == nil {
t.Fatal("write without terminal success accepted")
}
if _, err := requireWikiWrite(map[string]any{"success": false}, "op"); err == nil {
t.Fatal("failed write response accepted")
}
for _, data := range []map[string]any{
{"success": false}, {"result": "bad"}, {"result": map[string]any{}}, {"only": true},
} {
if _, err := requireWikiObject(data, "op"); err == nil {
t.Fatalf("invalid object accepted: %#v", data)
}
}
if object, err := requireWikiObject(map[string]any{"success": true, "id": "x"}, "op"); err != nil || object["id"] != "x" {
t.Fatalf("direct object=%#v err=%v", object, err)
}
for _, data := range []map[string]any{
{"success": false}, {"result": "bad"}, {"result": map[string]any{"items": "bad"}}, {"items": []any{1}}, {"success": true},
} {
if _, _, err := requireWikiCollection(data, "op", "items"); err == nil {
t.Fatalf("invalid collection accepted: %#v", data)
}
}
if value := nestedWikiString(map[string]any{"data": map[string]any{"id": " nested "}}, "id"); value != "nested" {
t.Fatalf("nested string=%q", value)
}
if value := nestedWikiString(map[string]any{"id": " direct "}, "id"); value != "direct" {
t.Fatalf("direct string=%q", value)
}
if value := nestedWikiString(map[string]any{"id": 1}, "id"); value != "" {
t.Fatalf("non-string=%q", value)
}
page := map[string]any{"nextToken": "n", "hasMore": true, "truncated": false, "totalCount": 1, "autoPageComplete": true, "autoPageStopReason": "done", "pagesFetched": 2}
out := map[string]any{}
addWikiPagination(out, page)
for _, key := range []string{"nextCursor", "hasMore", "truncated", "totalCount", "autoPageComplete", "autoPageStopReason", "pagesFetched"} {
if _, ok := out[key]; !ok {
t.Fatalf("pagination output missing %s: %#v", key, out)
}
}
rows := projectWikiRows([]any{map[string]any{"legacy": "x", "empty": nil}}, map[string][]string{"value": {"empty", "legacy"}})
if len(rows) != 1 || rows[0]["value"] != "x" {
t.Fatalf("projected rows=%#v", rows)
}
}
func TestCrossPlatformCoverageWikiAliasAndCancellationBranches(t *testing.T) {
caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/remove_member": {`{"success":true}`}}}
out, err := runWikiCoverageCLI(t, caller, "+member-remove", "--workspace", "w", "--user", "u1")
if err != nil || out["userCount"] != float64(1) {
t.Fatalf("visible member alias output=%#v err=%v calls=%#v", out, err, caller.calls)
}
cmd := &cobra.Command{Use: "probe"}
cmd.Flags().StringSlice("users", nil, "")
cmd.Flags().StringSlice("user", nil, "")
rt := shortcut.RuntimeContextForTest(cmd, MemberRemove)
if got := wikiStringSliceFirst(rt, "users", "user"); len(got) != 0 {
t.Fatalf("unset string slice=%v", got)
}
pageCmd := &cobra.Command{Use: "page"}
pageCmd.Flags().Bool("page-all", true, "")
pageCmd.Flags().Int("page-limit", 2, "")
pageCmd.Flags().Int("max-items", 0, "")
pageCmd.Flags().Int("page-delay", 1, "")
pageCmd.Flags().String("cursor", "", "")
pageCmd.Flags().String("page-token", "", "")
cancelled, cancel := context.WithCancel(context.Background())
cancel()
pageCmd.SetContext(cancelled)
pageRT := shortcut.RuntimeContextForTest(pageCmd, SpaceList)
_, _, err = collectWikiPages(pageRT, "probe", 1, []string{"items"}, func(string, int) (map[string]any, error) {
return map[string]any{"items": []any{}, "hasMore": true, "nextCursor": "next"}, nil
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled page delay err=%v", err)
}
}
func TestCrossPlatformCoverageWikiPaginationFailureModes(t *testing.T) {
t.Run("two pages", func(t *testing.T) {
caller := &wikiCoverageCaller{responses: map[string][]string{
"wiki/list_wikiSpaces": {
`{"wikiSpaces":[{"workspaceId":"w1"}],"hasMore":true,"nextCursor":"next"}`,
`{"wikiSpaces":[{"workspaceId":"w2"}],"hasMore":false}`,
},
}}
out, err := runWikiCoverageCLI(t, caller, "+space-list", "--limit", "1", "--page-all", "--page-limit", "2")
if err != nil || out["count"] != float64(2) || out["autoPageComplete"] != true {
t.Fatalf("pagination output=%#v err=%v calls=%#v", out, err, caller.calls)
}
})
t.Run("max items", func(t *testing.T) {
caller := &wikiCoverageCaller{responses: map[string][]string{
"wiki/list_wikiSpaces": {`{"wikiSpaces":[{"workspaceId":"w1"}],"hasMore":true,"nextCursor":"next"}`},
}}
out, err := runWikiCoverageCLI(t, caller, "+space-list", "--limit", "2", "--page-all", "--max-items", "1")
if err != nil || out["autoPageStopReason"] != "max_items" {
t.Fatalf("max-items output=%#v err=%v", out, err)
}
})
cases := []struct {
name string
response string
args []string
}{
{name: "missing has more", response: `{"wikiSpaces":[]}`, args: []string{"--page-all"}},
{name: "malformed has more", response: `{"wikiSpaces":[],"hasMore":"yes"}`, args: []string{"--page-all"}},
{name: "missing cursor", response: `{"wikiSpaces":[],"hasMore":true}`, args: []string{"--page-all"}},
{name: "stalled cursor", response: `{"wikiSpaces":[],"hasMore":true,"nextCursor":"same"}`, args: []string{"--cursor", "same", "--page-all"}},
{name: "page limit", response: `{"wikiSpaces":[],"hasMore":true,"nextCursor":"next"}`, args: []string{"--page-all", "--page-limit", "1"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/list_wikiSpaces": {tc.response}}}
if _, err := runWikiCoverageCLI(t, caller, append([]string{"+space-list"}, tc.args...)...); err == nil {
t.Fatalf("pagination failure %s succeeded", tc.name)
}
})
}
for _, args := range [][]string{{"--page-limit", "0", "--page-all"}, {"--max-items", "1"}, {"--page-delay", "1"}} {
caller := &wikiCoverageCaller{}
if _, err := runWikiCoverageCLI(t, caller, append([]string{"+space-list"}, args...)...); err == nil || len(caller.calls) != 0 {
t.Fatalf("invalid auto-page controls succeeded: %v", args)
}
}
}
func TestCrossPlatformCoverageWikiMCPFailuresPropagate(t *testing.T) {
caller := &wikiCoverageCaller{
responses: map[string][]string{"wiki/list_wikiSpaces": {`{"wikiSpaces":[]}`}},
errors: map[string][]error{"wiki/list_wikiSpaces": {errors.New("read failed")}},
}
if _, err := runWikiCoverageCLI(t, caller, "+space-list"); err == nil {
t.Fatal("MCP failure was swallowed")
}
}
func TestCrossPlatformCoverageWikiSpaceFailureBranches(t *testing.T) {
assertErr := func(name string, caller *wikiCoverageCaller, args ...string) {
t.Helper()
t.Run(name, func(t *testing.T) {
if _, err := runWikiCoverageCLI(t, caller, args...); err == nil {
t.Fatalf("%s unexpectedly succeeded; calls=%#v", name, caller.calls)
}
})
}
backend := errors.New("backend failed")
assertErr("search invalid limit", &wikiCoverageCaller{}, "+space-search", "--query", "x", "--limit", "bad")
assertErr("search call", &wikiCoverageCaller{errors: map[string][]error{"wiki/search_wikiSpaces": {backend}}}, "+space-search", "--query", "x")
assertErr("search collection", &wikiCoverageCaller{responses: map[string][]string{"wiki/search_wikiSpaces": {`{"success":true}`}}}, "+space-search", "--query", "x")
assertErr("get call", &wikiCoverageCaller{errors: map[string][]error{"wiki/get_wikiSpace": {backend}}}, "+space-get", "--workspace", "w")
assertErr("get object", &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true}`}}}, "+space-get", "--workspace", "w")
caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"workspaceId":"w","name":"Docs"}`}}}
if out, err := runWikiCoverageCLI(t, caller, "+space-get", "--workspace", "w"); err != nil || out["workspaceId"] != "w" {
t.Fatalf("space get output=%#v err=%v", out, err)
}
assertErr("create write call", &wikiCoverageCaller{errors: map[string][]error{"wiki/create_wikiSpace": {backend}}}, "+space-create", "--name", "Docs")
assertErr("create terminal", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"result":{"workspaceId":"w"}}`}}}, "+space-create", "--name", "Docs")
assertErr("create id", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true}`}}}, "+space-create", "--name", "Docs")
assertErr("create readback call", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true,"workspaceId":"w"}`}}, errors: map[string][]error{"wiki/get_wikiSpace": {backend}}}, "+space-create", "--name", "Docs")
assertErr("create readback object", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true,"workspaceId":"w"}`}, "wiki/get_wikiSpace": {`{"success":true}`}}}, "+space-create", "--name", "Docs")
assertErr("create readback mismatch", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true,"workspaceId":"w"}`}, "wiki/get_wikiSpace": {`{"workspaceId":"other"}`}}}, "+space-create", "--name", "Docs")
spaceMismatch := &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true,"result":{"workspaceId":"w"}}`}, "wiki/get_wikiSpace": {`{"success":true,"data":{"workspaceId":"other"}}`}}}
if _, err := runWikiCoverageCLI(t, spaceMismatch, "+space-create", "--name", "Docs"); err == nil || !strings.Contains(err.Error(), "不一致") {
t.Fatalf("space create mismatch error=%v calls=%#v", err, spaceMismatch.calls)
}
assertErr("delete preflight call", &wikiCoverageCaller{errors: map[string][]error{"wiki/get_wikiSpace": {backend}}}, "+delete-space", "--workspace", "w", "--yes")
assertErr("delete preflight object", &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true}`}}}, "+delete-space", "--workspace", "w", "--yes")
assertErr("delete write call", &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"workspaceId":"w"}`}}, errors: map[string][]error{"wiki/delete_wikiSpace": {backend}}}, "+delete-space", "--workspace", "w", "--yes")
assertErr("delete terminal", &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"workspaceId":"w"}`}, "wiki/delete_wikiSpace": {`{"result":{}}`}}}, "+delete-space", "--workspace", "w", "--yes")
delWrite := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true,"result":{"workspaceId":"w"}}`}}, errors: map[string][]error{"wiki/delete_wikiSpace": {backend}}}
if _, err := runWikiCoverageCLI(t, delWrite, "+delete-space", "--workspace", "w", "--yes"); err == nil || !strings.Contains(err.Error(), "backend failed") {
t.Fatalf("delete write error=%v calls=%#v", err, delWrite.calls)
}
delTerminal := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true,"result":{"workspaceId":"w"}}`}, "wiki/delete_wikiSpace": {`{"result":{}}`}}}
if _, err := runWikiCoverageCLI(t, delTerminal, "+delete-space", "--workspace", "w", "--yes"); err == nil || !strings.Contains(err.Error(), "success=true") {
t.Fatalf("delete terminal error=%v calls=%#v", err, delTerminal.calls)
}
assertErr("member list call", &wikiCoverageCaller{errors: map[string][]error{"wiki/list_member": {backend}}}, "+member-list", "--workspace", "w")
assertErr("member list collection", &wikiCoverageCaller{responses: map[string][]string{"wiki/list_member": {`{"success":true}`}}}, "+member-list", "--workspace", "w")
assertErr("member write call", &wikiCoverageCaller{errors: map[string][]error{"wiki/add_member": {backend}}}, "+member-add", "--workspace", "w", "--users", "u", "--role", "READER")
}
func TestCrossPlatformCoverageWikiNodeFailureBranches(t *testing.T) {
assertErr := func(name string, caller *wikiCoverageCaller, args ...string) {
t.Helper()
t.Run(name, func(t *testing.T) {
if _, err := runWikiCoverageCLI(t, caller, args...); err == nil {
t.Fatalf("%s unexpectedly succeeded; calls=%#v", name, caller.calls)
}
})
}
backend := errors.New("backend failed")
assertErr("list call", &wikiCoverageCaller{errors: map[string][]error{"doc/list_nodes": {backend}}}, "+node-list", "--workspace", "w")
assertErr("list collection", &wikiCoverageCaller{responses: map[string][]string{"doc/list_nodes": {`{"success":true}`}}}, "+node-list", "--workspace", "w")
assertErr("get call", &wikiCoverageCaller{errors: map[string][]error{"doc/get_document_info": {backend}}}, "+node-get", "--node", "n")
assertErr("get object", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true}`}}}, "+node-get", "--node", "n")
assertErr("get id", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"name":"Doc"}`}}}, "+node-get", "--node", "n")
missingID := &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true,"result":{"name":"Doc"}}`}}}
if _, err := runWikiCoverageCLI(t, missingID, "+node-get", "--node", "n"); err == nil || !strings.Contains(err.Error(), "nodeId") {
t.Fatalf("missing node id error=%v calls=%#v", err, missingID.calls)
}
assertErr("search call", &wikiCoverageCaller{errors: map[string][]error{"doc/search_documents": {backend}}}, "+node-search", "--workspace", "w", "--query", "x")
assertErr("search collection", &wikiCoverageCaller{responses: map[string][]string{"doc/search_documents": {`{"success":true}`}}}, "+node-search", "--workspace", "w", "--query", "x")
createArgs := []string{"+node-create", "--workspace", "w", "--name", "Doc"}
assertErr("create write", &wikiCoverageCaller{errors: map[string][]error{"doc/create_file": {backend}}}, createArgs...)
assertErr("create terminal", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"result":{"fileId":"n"}}`}}}, createArgs...)
assertErr("create id", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true}`}}}, createArgs...)
assertErr("create readback call", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true,"fileId":"n"}`}}, errors: map[string][]error{"doc/get_document_info": {backend}}}, createArgs...)
assertErr("create readback object", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true,"fileId":"n"}`}, "doc/get_document_info": {`{"success":true}`}}}, createArgs...)
assertErr("create mismatch", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true,"fileId":"n"}`}, "doc/get_document_info": {`{"nodeId":"other"}`}}}, createArgs...)
createMismatch := &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true,"result":{"fileId":"n"}}`}, "doc/get_document_info": {`{"success":true,"result":{"nodeId":"other"}}`}}}
if _, err := runWikiCoverageCLI(t, createMismatch, createArgs...); err == nil || !strings.Contains(err.Error(), "不一致") {
t.Fatalf("create mismatch error=%v calls=%#v", err, createMismatch.calls)
}
copyArgs := []string{"+node-copy", "--workspace", "w", "--node", "source", "--yes"}
assertErr("copy write", &wikiCoverageCaller{errors: map[string][]error{"doc/copy_document": {backend}}}, copyArgs...)
assertErr("copy terminal", &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"result":{"fileId":"n"}}`}}}, copyArgs...)
assertErr("copy id", &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"success":true}`}}}, copyArgs...)
assertErr("copy readback call", &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"success":true,"fileId":"n"}`}}, errors: map[string][]error{"doc/get_document_info": {backend}}}, copyArgs...)
assertErr("copy readback object", &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"success":true,"fileId":"n"}`}, "doc/get_document_info": {`{"success":true}`}}}, copyArgs...)
moveArgs := []string{"+move", "--workspace", "target", "--node", "n", "--yes"}
assertErr("move preflight call", &wikiCoverageCaller{errors: map[string][]error{"doc/get_document_info": {backend}}}, moveArgs...)
assertErr("move preflight object", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true}`}}}, moveArgs...)
assertErr("move write", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`}}, errors: map[string][]error{"doc/move_document": {backend}}}, moveArgs...)
assertErr("move terminal", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`}, "doc/move_document": {`{"result":{}}`}}}, moveArgs...)
assertErr("move readback call", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`}, "doc/move_document": {`{"success":true}`}}, errors: map[string][]error{"doc/get_document_info": {nil, backend}}}, moveArgs...)
assertErr("move readback object", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"success":true}`}, "doc/move_document": {`{"success":true}`}}}, moveArgs...)
assertErr("move id mismatch", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"nodeId":"other","workspaceId":"target"}`}, "doc/move_document": {`{"success":true}`}}}, moveArgs...)
assertErr("move workspace mismatch", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"nodeId":"n","workspaceId":"other"}`}, "doc/move_document": {`{"success":true}`}}}, moveArgs...)
assertErr("move folder mismatch", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"nodeId":"n","workspaceId":"target","folderId":"other"}`}, "doc/move_document": {`{"success":true}`}}}, append(moveArgs, "--folder", "f")...)
assertErr("drive move unchanged", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"same"}`, `{"nodeId":"n","workspaceId":"same"}`}, "doc/move_document": {`{"success":true}`}}}, "+move-to-drive", "--node", "n", "--yes")
deleteArgs := []string{"+node-delete", "--workspace", "w", "--node", "n", "--yes"}
assertErr("delete preflight call", &wikiCoverageCaller{errors: map[string][]error{"doc/get_document_info": {backend}}}, deleteArgs...)
assertErr("delete preflight object", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true}`}}}, deleteArgs...)
assertErr("delete workspace", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"other"}`}}}, deleteArgs...)
assertErr("delete write", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"w"}`}}, errors: map[string][]error{"doc/delete_document": {backend}}}, deleteArgs...)
assertErr("delete terminal", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"w"}`}, "doc/delete_document": {`{"result":{}}`}}}, deleteArgs...)
assertErr("feed collection", &wikiCoverageCaller{responses: map[string][]string{"wiki/list_workspace_feeds": {`{"success":true}`}}}, "+feed-list", "--workspace", "w")
}
func TestCrossPlatformCoverageWikiSecondPageFailures(t *testing.T) {
caller := &wikiCoverageCaller{
responses: map[string][]string{"wiki/list_wikiSpaces": {`{"wikiSpaces":[],"hasMore":true,"nextCursor":"next"}`}},
errors: map[string][]error{"wiki/list_wikiSpaces": {nil, errors.New("second page failed")}},
}
if _, err := runWikiCoverageCLI(t, caller, "+space-list", "--page-all"); err == nil {
t.Fatal("second-page transport error was swallowed")
}
caller = &wikiCoverageCaller{responses: map[string][]string{"wiki/list_wikiSpaces": {`{"wikiSpaces":[],"hasMore":true,"nextCursor":"next"}`, `{"success":true}`}}}
if _, err := runWikiCoverageCLI(t, caller, "+space-list", "--page-all"); err == nil {
t.Fatal("second-page malformed collection was swallowed")
}
}
-103
View File
@@ -7,7 +7,6 @@ MODULE="$(cd "$ROOT" && go list -m)"
usage() {
printf '%s\n' \
"usage: $0 list <app|generators|helpers|cli|smoke|remaining|release-scripts>" \
" $0 list-coverage <app|cli|generators|helpers|remaining>" \
" $0 verify" >&2
exit 2
}
@@ -58,52 +57,6 @@ list_shard() {
esac
}
# Full-suite coverage measurement shards the same authoritative package set the
# single serial run used: ./ ./cmd/... ./internal/... ./skills/... (pkg/ and
# scripts/ belong to the supporting profile; test/ suites carry no production
# statements for the candidate profile). Shards stay disjoint so their merged
# profile is block-for-block equivalent to one serial invocation.
list_coverage_scope() {
cd "$ROOT"
go list ./ ./cmd/... ./internal/... ./skills/...
}
list_coverage_shard() {
shard="$1"
cd "$ROOT"
case "$shard" in
app)
go list ./internal/app/...
;;
cli)
go list ./internal/cli/...
;;
generators)
go list ./internal/generator/...
;;
helpers)
go list ./internal/helpers/...
;;
remaining)
scope_packages="$(list_coverage_scope)"
printf '%s\n' "$scope_packages" | while IFS= read -r package; do
case "$package" in
"$MODULE/internal/app"|"$MODULE/internal/app/"*) ;;
"$MODULE/internal/cli"|"$MODULE/internal/cli/"*) ;;
"$MODULE/internal/generator"|"$MODULE/internal/generator/"*) ;;
"$MODULE/internal/helpers"|"$MODULE/internal/helpers/"*) ;;
*) printf '%s\n' "$package" ;;
esac
done
;;
*)
printf 'unknown coverage package shard: %s\n' "$shard" >&2
exit 2
;;
esac
}
verify_plan() {
workdir="$(mktemp -d "${TMPDIR:-/tmp}/dws-test-packages.XXXXXX")"
trap 'rm -rf "$workdir"' EXIT HUP INT TERM
@@ -161,58 +114,6 @@ verify_plan() {
package_count="$(wc -l < "$expected" | tr -d ' ')"
printf 'test package plan covers %s default packages exactly once\n' "$package_count"
coverage_expected="$workdir/coverage-expected"
coverage_assigned="$workdir/coverage-assigned"
coverage_unique="$workdir/coverage-unique"
coverage_duplicates="$workdir/coverage-duplicates"
coverage_missing="$workdir/coverage-missing"
coverage_unexpected="$workdir/coverage-unexpected"
list_coverage_scope > "$workdir/coverage-scope"
LC_ALL=C sort -u "$workdir/coverage-scope" > "$coverage_expected"
: > "$coverage_assigned"
for shard in app cli generators helpers remaining; do
shard_packages="$workdir/coverage-$shard"
unsorted_shard_packages="$workdir/coverage-$shard.unsorted"
list_coverage_shard "$shard" > "$unsorted_shard_packages"
LC_ALL=C sort "$unsorted_shard_packages" > "$shard_packages"
if [ ! -s "$shard_packages" ]; then
printf 'coverage package shard is empty: %s\n' "$shard" >&2
exit 1
fi
cat "$shard_packages" >> "$coverage_assigned"
done
LC_ALL=C sort "$coverage_assigned" -o "$coverage_assigned"
uniq -d "$coverage_assigned" > "$coverage_duplicates"
LC_ALL=C sort -u "$coverage_assigned" > "$coverage_unique"
comm -23 "$coverage_expected" "$coverage_unique" > "$coverage_missing"
comm -13 "$coverage_expected" "$coverage_unique" > "$coverage_unexpected"
coverage_failed=0
if [ -s "$coverage_duplicates" ]; then
printf '%s\n' 'coverage packages assigned to more than one shard:' >&2
sed 's/^/ /' "$coverage_duplicates" >&2
coverage_failed=1
fi
if [ -s "$coverage_missing" ]; then
printf '%s\n' 'full-suite coverage packages missing from the CI coverage plan:' >&2
sed 's/^/ /' "$coverage_missing" >&2
coverage_failed=1
fi
if [ -s "$coverage_unexpected" ]; then
printf '%s\n' 'CI coverage plan contains packages outside its scope:' >&2
sed 's/^/ /' "$coverage_unexpected" >&2
coverage_failed=1
fi
if [ "$coverage_failed" -ne 0 ]; then
exit 1
fi
coverage_count="$(wc -l < "$coverage_expected" | tr -d ' ')"
printf 'coverage package plan covers %s full-suite packages exactly once\n' "$coverage_count"
}
case "${1:-}" in
@@ -220,10 +121,6 @@ case "${1:-}" in
[ "$#" -eq 2 ] || usage
list_shard "$2"
;;
list-coverage)
[ "$#" -eq 2 ] || usage
list_coverage_shard "$2"
;;
verify)
[ "$#" -eq 1 ] || usage
verify_plan
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Run destructive, real-data Wiki Shortcut verification on a disposable space.
Requires an authenticated `dws` session. Set DWS_WIKI_E2E_MEMBER_ID to a real
internal user ID that may be granted temporary access to the empty fixture.
The script prints capability labels only; business IDs, names, URLs, and raw
responses remain in memory. The disposable Wiki space is deleted in `finally`.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
DWS = ROOT / "dws"
MEMBER_ID = os.environ.get("DWS_WIKI_E2E_MEMBER_ID", "").strip()
class E2EFailure(RuntimeError):
pass
def invoke(args: list[str]) -> dict:
process = subprocess.run(
[str(DWS), "wiki", *args, "--format", "json"],
cwd=ROOT,
capture_output=True,
text=True,
check=False,
)
try:
envelope = json.loads(process.stdout)
except json.JSONDecodeError as exc:
raise E2EFailure(f"non-JSON response (exit {process.returncode})") from exc
if process.returncode != 0 or envelope.get("ok") is not True:
error = envelope.get("error") or {}
reason = error.get("reason") or error.get("category") or "command_failed"
raise E2EFailure(f"{reason} (exit {process.returncode})")
data = envelope.get("data")
if not isinstance(data, dict):
raise E2EFailure("success envelope lacks object data")
return data
def check(label: str, condition: bool) -> None:
if not condition:
raise E2EFailure(f"{label}: business assertion failed")
print(f"PASS {label}")
def member_role(data: dict, user_id: str) -> str:
for member in data.get("members", []):
if member.get("id") == user_id:
return str(member.get("role") or "").upper()
return ""
def main() -> int:
if not DWS.exists():
raise E2EFailure("build ./dws first with make build")
if not MEMBER_ID:
raise E2EFailure("set DWS_WIKI_E2E_MEMBER_ID to a temporary internal test member")
stamp = time.strftime("%m%d%H%M%S")
space_name = f"DWS Wiki E2E {stamp}" # <= 32 characters
workspace = ""
disposable_nodes: list[str] = []
member_added = False
try:
created = invoke(["+space-create", "--name", space_name, "--desc", "Disposable E2E fixture"])
workspace = str(created.get("workspaceId") or "")
check("space-create-readback", bool(workspace) and created.get("space", {}).get("workspaceId") == workspace)
page = invoke(["+space-list", "--limit", "1"])
check("space-list-cursor", page.get("count") == 1 and isinstance(page.get("hasMore"), bool))
all_spaces = invoke(["+space-list", "--limit", "1", "--page-all", "--max-items", "2"])
check("space-list-auto-page", all_spaces.get("count") == 2 and len(all_spaces.get("spaces", [])) == 2)
search_ok = False
for _ in range(8):
searched = invoke(["+space-search", "--query", space_name])
if any(row.get("workspaceId") == workspace for row in searched.get("spaces", [])):
search_ok = True
break
time.sleep(2)
check("space-search", search_ok)
detail = invoke(["+space-get", "--workspace", workspace])
check("space-get", detail.get("workspaceId") == workspace)
resolved = invoke(["+resolve-space", "--name", space_name])
check("resolve-space", resolved.get("resolved") is True and resolved.get("spaceId") == workspace)
empty_nodes = invoke(["+node-list", "--workspace", workspace])
check("node-list-explicit-empty", empty_nodes.get("count") == 0 and empty_nodes.get("nodes") == [])
folder = invoke(["+node-create", "--workspace", workspace, "--name", "E2E Folder", "--type", "folder"])
folder_id = str(folder.get("nodeId") or "")
disposable_nodes.append(folder_id)
check("node-create-folder-readback", bool(folder_id) and folder.get("node", {}).get("nodeId") == folder_id)
document = invoke(["+node-create", "--workspace", workspace, "--folder", folder_id, "--name", "E2E Document"])
node_id = str(document.get("nodeId") or "")
disposable_nodes.append(node_id)
check("node-create-document-readback", bool(node_id) and document.get("node", {}).get("nodeId") == node_id)
listed = invoke(["+node-list", "--workspace", workspace, "--limit", "1"])
check("node-list-cursor", listed.get("count") == 1 and listed.get("hasMore") is True and bool(listed.get("nextCursor")))
all_nodes = invoke(["+node-list", "--workspace", workspace, "--limit", "1", "--page-all"])
check(
"node-list-auto-page",
all_nodes.get("count", 0) >= 1
and (all_nodes.get("autoPageComplete") is True or all_nodes.get("hasMore") is False),
)
info = invoke(["+node-get", "--node", node_id])
check("node-get", info.get("nodeId") == node_id)
# Search indexing can lag after a create; retry without accepting a
# malformed/missing collection as an empty success.
search_ok = False
for _ in range(6):
found = invoke(["+node-search", "--workspace", workspace, "--query", "E2E Document"])
if any(row.get("nodeId") == node_id for row in found.get("nodes", [])):
search_ok = True
break
time.sleep(2)
check("node-search", search_ok)
copied = invoke(["+node-copy", "--workspace", workspace, "--folder", folder_id, "--node", node_id, "--yes"])
copy_id = str(copied.get("nodeId") or "")
disposable_nodes.append(copy_id)
check("node-copy-readback", bool(copy_id) and bool(copied.get("copy")))
moved = invoke(["+move", "--workspace", workspace, "--folder", folder_id, "--node", node_id, "--yes"])
check("move-readback", moved.get("node", {}).get("workspaceId") == workspace and moved.get("node", {}).get("folderId") == folder_id)
moved_out = invoke(["+move-to-drive", "--node", node_id, "--yes"])
check("move-to-drive-readback", moved_out.get("node", {}).get("workspaceId") != workspace)
moved_back = invoke(["+node-move", "--workspace", workspace, "--folder", folder_id, "--node", node_id, "--yes"])
check("node-move-alias-readback", moved_back.get("node", {}).get("workspaceId") == workspace)
named = invoke(["+wiki-new-doc", "--space", space_name, "--title", "E2E Name Resolved"])
named_id = str(named.get("nodeId") or "")
disposable_nodes.append(named_id)
check("wiki-new-doc-readback", bool(named_id) and named.get("document", {}).get("nodeId") == named_id)
members = invoke(["+member-list", "--workspace", workspace, "--limit", "50"])
check("member-list", members.get("count", 0) >= 1 and isinstance(members.get("members"), list))
added = invoke(["+member-add", "--workspace", workspace, "--users", MEMBER_ID, "--role", "READER"])
member_added = True
check("member-add-terminal", added.get("success") is True and added.get("verifiedBy") == "write_terminal_success")
after_add = invoke(["+member-list", "--workspace", workspace, "--limit", "50"])
check("member-add-fixture-readback", after_add.get("truncated") is not True and member_role(after_add, MEMBER_ID) == "READER")
updated = invoke(["+member-update", "--workspace", workspace, "--users", MEMBER_ID, "--role", "EDITOR"])
check("member-update-terminal", updated.get("success") is True and updated.get("verifiedBy") == "write_terminal_success")
after_update = invoke(["+member-list", "--workspace", workspace, "--limit", "50"])
check("member-update-fixture-readback", after_update.get("truncated") is not True and member_role(after_update, MEMBER_ID) == "EDITOR")
removed = invoke(["+member-remove", "--workspace", workspace, "--users", MEMBER_ID])
member_added = False
check("member-remove-terminal", removed.get("success") is True and removed.get("verifiedBy") == "write_terminal_success")
after_remove = invoke(["+member-list", "--workspace", workspace, "--limit", "50"])
check("member-remove-fixture-readback", after_remove.get("truncated") is not True and member_role(after_remove, MEMBER_ID) == "")
feeds = invoke(["+feed-list", "--workspace", workspace, "--limit", "10"])
check("feed-list", isinstance(feeds.get("feeds"), list))
# Exercise high-risk delete before final whole-space cleanup.
delete_target = disposable_nodes.pop()
deleted = invoke(["+node-delete", "--workspace", workspace, "--node", delete_target, "--yes"])
check("node-delete-terminal", deleted.get("success") is True and deleted.get("deleted") is True)
return 0
finally:
if workspace:
if member_added:
try:
invoke(["+member-remove", "--workspace", workspace, "--users", MEMBER_ID])
except Exception:
pass
try:
deleted = invoke(["+space-delete", "--workspace", workspace, "--yes"])
check("space-delete-alias-cleanup", deleted.get("success") is True and deleted.get("deleted") is True)
except Exception as exc:
print(f"CLEANUP FAILED: {exc}", file=sys.stderr)
if __name__ == "__main__":
try:
raise SystemExit(main())
except E2EFailure as exc:
print(f"FAIL {exc}", file=sys.stderr)
raise SystemExit(1)
+1
View File
@@ -24,6 +24,7 @@ SEMANTIC_PATHS = [
ROOT / "internal" / "shortcut" / "semantic_catalog_aitable.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_minutes.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_drive.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_wiki.json",
]
@@ -31,7 +31,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -97,7 +97,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -130,7 +130,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -163,7 +163,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -196,7 +196,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -229,7 +229,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -262,7 +262,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -295,7 +295,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -328,7 +328,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -361,7 +361,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -394,7 +394,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -427,7 +427,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -460,7 +460,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -493,7 +493,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -526,7 +526,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -559,7 +559,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -592,7 +592,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -625,7 +625,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -658,7 +658,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -691,7 +691,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -724,7 +724,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -757,7 +757,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -790,7 +790,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -823,7 +823,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -856,7 +856,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -892,7 +892,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -925,7 +925,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -991,7 +991,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -1024,7 +1024,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -1057,7 +1057,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -1090,7 +1090,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
@@ -1123,7 +1123,7 @@
"required": true
}
},
"state": "consumed",
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
{
+1 -1
View File
@@ -58,7 +58,7 @@ cli_version: ">=1.0.15"
| `report` | 2 | `dingtalk-misc` |
| `sheet` | 2 | `dingtalk-misc` |
| `todo` | 11 | `dingtalk-todo` |
| `wiki` | 1 | `dingtalk-wiki` |
| `wiki` | 20 | `dingtalk-wiki` |
<!-- VISIBLE_SHORTCUTS_OVERVIEW_END -->
## 多组织 / 多账号
-6
View File
@@ -67,12 +67,6 @@ Flags:
- 输出形态:带过滤时输出从单页透传变为聚合形态 `{items, maxDepth, truncated, errors}`。
- 已知代价:大目录(>2000 条)触顶截断时 `truncated=true`(退出码 0,结果每条都正确但没扫完);
建议用 `--folder` 指定子目录缩小扫描范围;带关键词的过滤场景改用 `dws drive search`。
- 与 `--latest` 组合时上一条不适用:排序基不完整的 Top-N 不是全局最新,故触顶截断**或**递归途中
目录读取失败都拒绝产出并报错(`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`),不会以
退出码 0 交出结果;错误消息里带首个失败目录的 folder/depth/reason,以及一条复现原候选集
(查询域 + `--folder` + `--pattern`/`--type`/`--start`/`--end`)的恢复命令。Windows 构建下,
若原值含 shell 元字符则命令里只给占位符、原值另起一行以数据形式列出(cmd.exe 与 PowerShell
没有共同安全的引用形式),照抄时需手动替换。
### 获取钉盘空间列表
@@ -61,12 +61,6 @@ Flags:
- 输出形态:带过滤时输出从单页透传变为聚合形态 `{items, maxDepth, truncated, errors}`。
- 已知代价:大目录(>2000 条)触顶截断时 `truncated=true`(退出码 0,结果每条都正确但没扫完);
建议用 `--folder` 指定子目录缩小扫描范围;带关键词的过滤场景改用 `dws drive search`。
- 与 `--latest` 组合时上一条不适用:排序基不完整的 Top-N 不是全局最新,故触顶截断**或**递归途中
目录读取失败都拒绝产出并报错(`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`),不会以
退出码 0 交出结果;错误消息里带首个失败目录的 folder/depth/reason,以及一条复现原候选集
(查询域 + `--folder` + `--pattern`/`--type`/`--start`/`--end`)的恢复命令。Windows 构建下,
若原值含 shell 元字符则命令里只给占位符、原值另起一行以数据形式列出(cmd.exe 与 PowerShell
没有共同安全的引用形式),照抄时需手动替换。
### 获取钉盘空间列表
+2 -1
View File
@@ -24,7 +24,8 @@ metadata:
| Shortcut | 风险 | 适用场景 |
|---|---|---|
| `dws wiki +space-search` | read | 搜索知识库 |
| `dws wiki +resolve-space` | read | 按名称搜索知识空间并解析出唯一 spaceId(只读) |
| `dws wiki +wiki-new-doc` | write | 在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId) |
<!-- VISIBLE_SHORTCUTS_END -->
## 意图表
-33
View File
@@ -14,39 +14,6 @@ func TestCITestPackagePlanCoversDefaultPackagesExactlyOnce(t *testing.T) {
if !strings.Contains(output, "default packages exactly once") {
t.Fatalf("verify output = %q, want coverage summary", output)
}
if !strings.Contains(output, "full-suite packages exactly once") {
t.Fatalf("verify output = %q, want coverage shard plan summary", output)
}
}
func TestCICoveragePackagePlanRoutesFullSuiteScope(t *testing.T) {
root := testPackagePlanRoot(t)
remaining := strings.Fields(runTestPackagePlan(t, root, "list-coverage", "remaining"))
for _, suffix := range []string{"/cmd", "/internal/output", "/skills"} {
if !containsPackageSuffix(remaining, suffix) {
t.Errorf("coverage remaining shard does not contain package ending in %q", suffix)
}
}
for _, suffix := range []string{
"/internal/app",
"/internal/cli",
"/internal/generator",
"/internal/helpers",
"/test/smoke",
"/test/scripts",
"/pkg/cmdutil",
"/scripts/policy/coverage-gate",
} {
if containsPackageSuffix(remaining, suffix) {
t.Errorf("coverage remaining shard unexpectedly contains package ending in %q", suffix)
}
}
app := strings.Fields(runTestPackagePlan(t, root, "list-coverage", "app"))
if !containsPackageSuffix(app, "/internal/app") {
t.Error("coverage app shard does not contain /internal/app")
}
}
func TestCITestPackagePlanRoutesPublicTestSuites(t *testing.T) {
@@ -118,108 +118,3 @@ esac
assertDiffProfiles(t, runGate(t, &empty), "coverage.txt")
})
}
// TestCoverageWorkflowShardsAndBaselineCache pins the full-suite coverage
// architecture: the candidate profile is produced by disjoint per-shard
// helper jobs and reassembled before enforcement, and the merge-base profile
// is reused only through an exact-key cache written by a green main push of
// that same commit. Near-miss reuse (restore-keys) would compare the
// candidate against the wrong commit and must never appear.
func TestCoverageWorkflowShardsAndBaselineCache(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)
}
admission := string(data)
currentStart := strings.Index(admission, "\n coverage-current:\n")
fullStart := strings.Index(admission, "\n coverage-current-full:\n")
supportingStart := strings.Index(admission, "\n coverage-supporting:\n")
baselineStart := strings.Index(admission, "\n coverage-baseline:\n")
gateStart := strings.Index(admission, "\n coverage:\n")
policyStart := strings.Index(admission, "\n policy:\n")
if currentStart < 0 || fullStart <= currentStart || supportingStart <= fullStart ||
baselineStart <= supportingStart || gateStart <= baselineStart || policyStart <= gateStart {
t.Fatal("CI workflow missing ordered coverage job boundaries")
}
currentJob := admission[currentStart:fullStart]
if !strings.Contains(currentJob, "needs.lint.outputs.full_suite != 'true'") {
t.Error("coverage-current must be scoped-tier only; the full suite belongs to the shard matrix")
}
if strings.Contains(currentJob, "./ ./cmd/... ./internal/... ./skills/...") {
t.Error("coverage-current must not retain the retired single serial full-suite run")
}
fullJob := admission[fullStart:supportingStart]
for _, want := range []string{
"needs.lint.outputs.full_suite == 'true'",
"fail-fast: false",
" - app",
" - cli",
" - generators",
" - helpers",
" - remaining",
`./scripts/ci/test-packages.sh list-coverage "$COVERAGE_SHARD"`,
"go test -count=1 -p 1",
`-coverprofile="coverage-shard-$COVERAGE_SHARD.txt"`,
"-covermode=atomic",
"name: coverage-current-shard-${{ matrix.shard }}",
} {
if !strings.Contains(fullJob, want) {
t.Errorf("coverage-current-full missing shard contract %q", want)
}
}
baselineJob := admission[baselineStart:gateStart]
cachePath := "coverage-cache.txt"
baselineKey := "dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }}"
for _, want := range []string{
"uses: actions/cache/restore@v4",
"uses: actions/cache/save@v4",
"path: " + cachePath,
"key: " + baselineKey,
"if: steps.baseline-cache.outputs.cache-hit != 'true'",
"cp coverage-cache.txt coverage-base.txt",
"cp coverage-base.txt coverage-cache.txt",
} {
if !strings.Contains(baselineJob, want) {
t.Errorf("coverage-baseline missing cache contract %q", want)
}
}
if strings.Count(baselineJob, "key: "+baselineKey) != 2 {
t.Error("coverage-baseline restore and save must use the identical exact cache key")
}
if strings.Count(baselineJob, "path: "+cachePath) != 2 {
t.Error("coverage-baseline restore and save must use the identical cache path/version")
}
if strings.Contains(baselineJob, "restore-keys") {
t.Error("coverage baseline cache must stay exact-key; prefix restore-keys can resurrect a wrong-commit baseline")
}
gateJob := admission[gateStart:policyStart]
for _, want := range []string{
"pattern: coverage-current-*",
"merge-multiple: true",
"for shard in app cli generators helpers remaining; do",
"test ! -f coverage.txt",
`test "$(head -n 1 "$profile")" = "mode: atomic"`,
"github.event_name == 'push'",
"cp coverage.txt coverage-cache.txt",
"path: " + cachePath,
"key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }}",
`"current shards:$CURRENT_FULL_RESULT:$current_full_expected"`,
} {
if !strings.Contains(gateJob, want) {
t.Errorf("coverage gate missing shard assembly contract %q", want)
}
}
if strings.Count(gateJob, "path: "+cachePath) != 1 {
t.Error("green main push must save the candidate profile through the same cache path/version as baseline restore")
}
}