Compare commits
189
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7521c1ca3 | ||
|
|
aa0bc0c2d4 | ||
|
|
9e3a5d6fbd | ||
|
|
f2a3dd2344 | ||
|
|
fc05976d33 | ||
|
|
021da02474 | ||
|
|
a5b9e5a13f | ||
|
|
b8c9ff2619 | ||
|
|
225a073d89 | ||
|
|
45a52dee85 | ||
|
|
9f8c525008 | ||
|
|
207d4dd7e5 | ||
|
|
9a6b7d4d41 | ||
|
|
5947016cc1 | ||
|
|
386426bb92 | ||
|
|
3cea671a54 | ||
|
|
235e881ebe | ||
|
|
f06ea4d9e2 | ||
|
|
a82d945f54 | ||
|
|
6846326445 | ||
|
|
54c2054a5c | ||
|
|
e5bf332b05 | ||
|
|
9ed55978d9 | ||
|
|
7ffbbc4a51 | ||
|
|
2db73a8185 | ||
|
|
a62332be93 | ||
|
|
1083093cbc | ||
|
|
c6ebe307cd | ||
|
|
3ee66d4373 | ||
|
|
a0be395ccc | ||
|
|
93dbd768f7 | ||
|
|
c1a549cd64 | ||
|
|
5a414999ef | ||
|
|
7aa8240629 | ||
|
|
37b9a1dc31 | ||
|
|
2ab8748c4d | ||
|
|
f041275811 | ||
|
|
f486105836 | ||
|
|
e14de2b4c2 | ||
|
|
fe2f3ca92f | ||
|
|
8e4519cacd | ||
|
|
16abb481e8 | ||
|
|
7ad82bbf0a | ||
|
|
104eb715c4 | ||
|
|
97ea887ea5 | ||
|
|
03838a3430 | ||
|
|
bfeb9f6af0 | ||
|
|
e26f278112 | ||
|
|
0d34150333 | ||
|
|
e6b5938bd8 | ||
|
|
4f95373420 | ||
|
|
afb90009f6 | ||
|
|
9e8b58cbb6 | ||
|
|
6411d26a95 | ||
|
|
31117d1b89 | ||
|
|
e3553fe7a5 | ||
|
|
fe724e96e8 | ||
|
|
067aff179f | ||
|
|
353454abb2 | ||
|
|
96b9cbce02 | ||
|
|
36877d00dc | ||
|
|
a9a97c2746 | ||
|
|
f72979f4a9 | ||
|
|
55d94d3b58 | ||
|
|
bfd0976b31 | ||
|
|
4a33e7e893 | ||
|
|
ee74765383 | ||
|
|
35239259fb | ||
|
|
85bf2dfc8a | ||
|
|
c4f2ab631b | ||
|
|
308e71c783 | ||
|
|
ecce09b355 | ||
|
|
2855f1a84b | ||
|
|
1d02ff805d | ||
|
|
4d843cf7a4 | ||
|
|
8560830d3e | ||
|
|
9fbd8addbe | ||
|
|
92195a58a3 | ||
|
|
fb9ff7de73 | ||
|
|
5ee80cdb97 | ||
|
|
bf2c0653ed | ||
|
|
e4daddf9cf | ||
|
|
e92309f7c4 | ||
|
|
7a58b0d19a | ||
|
|
78e6f11d72 | ||
|
|
9a8a41a318 | ||
|
|
6406c93eaa | ||
|
|
af8e6a9ccc | ||
|
|
547020f47e | ||
|
|
d5eee82816 | ||
|
|
0d8763b917 | ||
|
|
600404abd0 | ||
|
|
247926d0fa | ||
|
|
9ef2a4e652 | ||
|
|
d4daf9525c | ||
|
|
63a89e68fa | ||
|
|
29b73a7d5e | ||
|
|
3488e11129 | ||
|
|
596bdce3a1 | ||
|
|
0b012788c7 | ||
|
|
747a17f305 | ||
|
|
d578458315 | ||
|
|
58eea98f6c | ||
|
|
e742a6c269 | ||
|
|
b53b84616e | ||
|
|
15a2fea0dc | ||
|
|
05868610f0 | ||
|
|
d8da9a2e9f | ||
|
|
1a6ae856ec | ||
|
|
22649e96ef | ||
|
|
9c6407ae74 | ||
|
|
f68a11f11d | ||
|
|
abe5129306 | ||
|
|
b9b8cc2c77 | ||
|
|
7b7bd556e9 | ||
|
|
d534ee242c | ||
|
|
e02fdbdc8f | ||
|
|
ce529c9337 | ||
|
|
6952b22f45 | ||
|
|
3aa06e32fa | ||
|
|
97fc783cc0 | ||
|
|
a18b1e5fe4 | ||
|
|
90473284b8 | ||
|
|
b17e030d1f | ||
|
|
575b8f32c4 | ||
|
|
03258ca045 | ||
|
|
afd8422580 | ||
|
|
07aa2c883a | ||
|
|
4b3e0e5046 | ||
|
|
a6f69a06ce | ||
|
|
a7a0a97115 | ||
|
|
cbaa8c9bf5 | ||
|
|
0f5ecb609b | ||
|
|
70e46cbd29 | ||
|
|
d0e6aba319 | ||
|
|
b0b18986b1 | ||
|
|
abecb0dee1 | ||
|
|
d17f50b9de | ||
|
|
10417396f1 | ||
|
|
410a63ea9a | ||
|
|
58c382efb7 | ||
|
|
3598586bc0 | ||
|
|
b066a14f0c | ||
|
|
94d4b5dcc9 | ||
|
|
5cbf18713a | ||
|
|
78d94380e7 | ||
|
|
8cd2b0259d | ||
|
|
4b8d94c24e | ||
|
|
e9efd3e909 | ||
|
|
76e5a8c4d9 | ||
|
|
c718b051c2 | ||
|
|
9bc803fde0 | ||
|
|
6a93f14e0a | ||
|
|
913b7cf9a9 | ||
|
|
6abffce4e5 | ||
|
|
2dc3147cda | ||
|
|
86b78e45d7 | ||
|
|
e91b30c769 | ||
|
|
a354144412 | ||
|
|
d525648b45 | ||
|
|
0df293186a | ||
|
|
003bbe87ba | ||
|
|
5065e4bfb6 | ||
|
|
f3f1174407 | ||
|
|
fc47e05cd7 | ||
|
|
ce6d5fb538 | ||
|
|
43882bf959 | ||
|
|
55c6a09bbc | ||
|
|
86ebdd8f25 | ||
|
|
2778bef5bd | ||
|
|
20b8120f59 | ||
|
|
a08a1f54fd | ||
|
|
286376df93 | ||
|
|
93da35413b | ||
|
|
b8deec9087 | ||
|
|
dbee2de1d5 | ||
|
|
8aee08268d | ||
|
|
6a4744073c | ||
|
|
a55bd9bff8 | ||
|
|
516bd5d99c | ||
|
|
65a00b497b | ||
|
|
3e4a3fb9d9 | ||
|
|
1f1c27d68f | ||
|
|
1b8ca149cb | ||
|
|
b6c508acdf | ||
|
|
9472f4a1d9 | ||
|
|
90e27c4b86 | ||
|
|
132dea9aaa | ||
|
|
5034c332fe |
No files matched your search
@@ -0,0 +1,11 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Aitable pagination and Minutes unshare verification** (#1006) — keeps
|
||||
record queries on the service's 20-record page boundary so multi-page reads
|
||||
and mutation readbacks no longer report false retryable failures, preserves
|
||||
`totalCount` when supplied, validates `--dry-run` plans before transport,
|
||||
follows active deletion readback continuations before proving absence, and
|
||||
rejects Minutes unshare success until the listening note exists and the
|
||||
service acknowledges the exact task and member targets.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Robot group reference replies** (#928) — `chat message send-by-bot` supports paired `--reply` and `--ref-sender` flags for Markdown replies that quote an existing group message.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Document write verification** (#960) — avoids false partial-success results when normalized Markdown, paginated blocks, inline images, or version reverts are confirmed by server readback. Document reverts and media inserts now require explicit readback evidence and report partial success when the server cannot prove the requested result.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Calendar event share-info** (#980) — adds `dws calendar event share-info` to fetch a calendar event's share info (title, organizer, location, join info) for sharing with others; supports `--calendar-id` and `--language`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Chat sender identity guards** — preserves unverified mixed sender inputs after exact message `senderId` matches and aligns `--sender-query` Skill guidance with fail-closed Runtime behavior.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Doc/drive description scope** — restates the `dingtalk-doc` description as document-entity-and-content operations with an explicit exclusion list, and narrows `dingtalk-drive` to file-level management of DingTalk documents, so first-round Agent selection separates content work from file management without changing CLI behavior.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
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.
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Privacy-safe CLI telemetry** (#1009) — reports reviewed command outcomes and profile identity dimensions while excluding command arguments, output, paths, device fingerprints, and automatic system dimensions; `DO_NOT_TRACK=1` disables reporting.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Feedback survey entry in root help** (#1019) — `dws --help` now closes with a Feedback section linking the user-experience survey form.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Chat card update evidence** — distinguishes an accepted update request from an independently verified visible update, preserving the real `bizId` and warning callers not to repeat an unverified write.
|
||||
- **Chat command guidance** — splits message and group references by task and explains that `--from` is ambiguous between sender and time-range intent.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Faster Schema Catalog assembly** — projects typed values into payload JSON
|
||||
without re-running a validation scan over documents `json.Marshal` has just
|
||||
produced, cutting roughly a third of the projection work across the full tool
|
||||
set. Untrusted JSON input keeps its existing validation.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Sheet SourceRange dropdowns** — supports range-backed dropdowns across direct, cell, and batch write paths, with structured readback for valid and invalid references. Batch `set-dropdown` now rejects unsupported top-level `colors` / `source-colors`; Inline colors belong in `options[].color`, while SourceRange color writes remain unsupported.
|
||||
- **Sheet read completion metadata** — documents and preserves returned ranges, truncation reasons, and partial-read status for large range and CSV reads.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Windows event bus lifecycle** — start event consumers without unsupported inherited file descriptors, stop buses through local IPC with a termination fallback, and preserve subscription cleanup when startup fails.
|
||||
+314
-63
@@ -151,6 +151,10 @@ jobs:
|
||||
filename.startsWith('scripts/') ||
|
||||
filename.startsWith('verify/') ||
|
||||
filename.startsWith('internal/helpers/') ||
|
||||
// Shortcut declarations feed the live command tree and Schema
|
||||
// assembly. Their reverse dependencies include the expensive
|
||||
// app and generator packages, which must run in separate shards.
|
||||
filename.startsWith('internal/shortcut/') ||
|
||||
filename.startsWith('internal/generator/') ||
|
||||
filename.startsWith('internal/cli/schema') ||
|
||||
// Parameter aliases are reduced against the live command tree.
|
||||
@@ -509,11 +513,38 @@ jobs:
|
||||
run: node .github/reviewer-routing.test.js
|
||||
|
||||
test-focused:
|
||||
name: Test (changed packages)
|
||||
name: "Test (focused: ${{ 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
|
||||
# Each shard owns one bounded slice of the impacted set, so no single job
|
||||
# carries internal/app together with every reverse dependency. The shard
|
||||
# list and per-shard execution below mirror test-race, which runs the same
|
||||
# shards at full-suite scope; release-scripts is included because its
|
||||
# dedicated job only runs at full-suite or release-sensitive scope, and
|
||||
# dropping it here would stop testing test/scripts changes entirely.
|
||||
# internal/app is carried by one shard per bounded partition rather than a
|
||||
# single app shard: the partitions used to run end to end inside one job,
|
||||
# where the Schema partition alone owned most of the wall clock. The
|
||||
# app-<partition> names are pinned to the helper's partition set by
|
||||
# TestCIAppRacePartitionMatrixMatchesHelper, so a partition can never lose
|
||||
# its job silently.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard:
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c
|
||||
- app-d-r
|
||||
- app-s-z-example-fuzz
|
||||
- generators
|
||||
- helpers
|
||||
- cli
|
||||
- smoke
|
||||
- remaining
|
||||
- release-scripts
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -542,37 +573,110 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Test changed packages and reverse dependencies
|
||||
- name: Select impacted packages for shard
|
||||
id: select
|
||||
shell: bash
|
||||
env:
|
||||
TEST_SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Every app partition shard tests the same single internal/app
|
||||
# package, so the impacted-package query uses the base shard name and
|
||||
# the partition only selects which tests run.
|
||||
package_shard="$TEST_SHARD"
|
||||
case "$TEST_SHARD" in
|
||||
app-*) package_shard=app ;;
|
||||
esac
|
||||
package_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
list-shard "$package_shard" "$TEST_BASE_REF" "$TEST_HEAD_REF"
|
||||
)"
|
||||
if [ -z "$package_output" ]; then
|
||||
echo "No buildable Go package in shard $TEST_SHARD is affected by this revision." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
echo "affected=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# The package list travels through a file rather than a step output:
|
||||
# reading it with `mapfile < file` has unambiguous line semantics,
|
||||
# whereas a here-string over a multi-line output would append an extra
|
||||
# empty element if the value ever carried a trailing newline, and an
|
||||
# empty element would reach go test as an empty package argument.
|
||||
printf '%s\n' "$package_output" > "$RUNNER_TEMP/focused-shard-packages.txt"
|
||||
echo "affected=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build
|
||||
if: ${{ matrix.shard == 'remaining' && steps.select.outputs.affected == 'true' }}
|
||||
run: make build
|
||||
|
||||
- name: Install archive tooling
|
||||
if: ${{ matrix.shard == 'release-scripts' && steps.select.outputs.affected == 'true' }}
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Test shard with Race Detection
|
||||
if: ${{ steps.select.outputs.affected == 'true' }}
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
TEST_SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
package_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
list "$TEST_BASE_REF" "$TEST_HEAD_REF"
|
||||
)"
|
||||
if [ -z "$package_output" ]; then
|
||||
echo "No buildable Go package is affected by this revision." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
mapfile -t packages < "$RUNNER_TEMP/focused-shard-packages.txt"
|
||||
test "${#packages[@]}" -gt 0
|
||||
for package in "${packages[@]}"; do
|
||||
test -n "$package" || {
|
||||
echo "shard package list contains an empty entry" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
case "$TEST_SHARD" in
|
||||
app-*)
|
||||
# A single long-lived app test process retains every constructed
|
||||
# command tree in framework registries. Each partition is its own
|
||||
# job, so that state is released when the process exits and the
|
||||
# partitions run concurrently instead of end to end. The helper
|
||||
# still verifies that the partition patterns cover every top-level
|
||||
# test exactly once before running the one it was asked for.
|
||||
test "${#packages[@]}" -eq 1
|
||||
./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
if [ "$TEST_SHARD" = "release-scripts" ]; then
|
||||
# Mirror the dedicated release-contract job: these suites shell out
|
||||
# to archive tooling and are not race-instrumented there.
|
||||
go test -v -count=1 -timeout=10m "${packages[@]}"
|
||||
exit 0
|
||||
fi
|
||||
mapfile -t packages <<< "$package_output"
|
||||
go test -v -race -count=1 -timeout=15m "${packages[@]}"
|
||||
# cli/smoke own heavy NewRootCommand / Schema assembly under -race;
|
||||
# give them a dedicated package timeout on slower hosted runners.
|
||||
timeout_budget=12m
|
||||
if [ "$TEST_SHARD" = "cli" ] ||
|
||||
[ "$TEST_SHARD" = "smoke" ]; then
|
||||
timeout_budget=15m
|
||||
fi
|
||||
go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}"
|
||||
|
||||
test-race:
|
||||
name: "Test (race: ${{ 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
|
||||
# app runs several independently bounded processes; cli/smoke need headroom
|
||||
# beyond go test -timeout for setup + assembly.
|
||||
# internal/app is split across one shard per bounded partition so the
|
||||
# partitions run concurrently and each releases its framework registries
|
||||
# when the process exits; cli/smoke need headroom beyond go test -timeout for
|
||||
# setup + assembly. The app-<partition> names are pinned to the helper's
|
||||
# partition set by TestCIAppRacePartitionMatrixMatchesHelper.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard:
|
||||
- app
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c
|
||||
- app-d-r
|
||||
- app-s-z-example-fuzz
|
||||
- generators
|
||||
- helpers
|
||||
- cli
|
||||
@@ -598,18 +702,30 @@ jobs:
|
||||
TEST_SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
package_output="$(./scripts/ci/test-packages.sh list "$TEST_SHARD")"
|
||||
# Every app partition shard tests the same single internal/app
|
||||
# package, so the package query uses the base shard name and the
|
||||
# partition only selects which tests run.
|
||||
package_shard="$TEST_SHARD"
|
||||
case "$TEST_SHARD" in
|
||||
app-*) package_shard=app ;;
|
||||
esac
|
||||
package_output="$(./scripts/ci/test-packages.sh list "$package_shard")"
|
||||
test -n "$package_output"
|
||||
mapfile -t packages <<< "$package_output"
|
||||
test "${#packages[@]}" -gt 0
|
||||
if [ "$TEST_SHARD" = "app" ]; then
|
||||
# A single long-lived app test process retains every constructed
|
||||
# command tree in framework registries. Isolate Schema assembly and
|
||||
# bounded name ranges so each process releases that state on exit.
|
||||
test "${#packages[@]}" -eq 1
|
||||
./scripts/ci/run-app-race-tests.sh run "${packages[0]}"
|
||||
exit 0
|
||||
fi
|
||||
case "$TEST_SHARD" in
|
||||
app-*)
|
||||
# A single long-lived app test process retains every constructed
|
||||
# command tree in framework registries. Each partition is its own
|
||||
# job, so that state is released when the process exits and the
|
||||
# partitions run concurrently instead of end to end. The helper
|
||||
# still verifies that the partition patterns cover every top-level
|
||||
# test exactly once before running the one it was asked for.
|
||||
test "${#packages[@]}" -eq 1
|
||||
./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
# cli/smoke own heavy NewRootCommand / Schema assembly under -race;
|
||||
# give them a dedicated package timeout on slower hosted runners.
|
||||
timeout_budget=12m
|
||||
@@ -711,7 +827,7 @@ jobs:
|
||||
failed=0
|
||||
if [ "$CHANGELOG_ONLY" = true ] || [ "$DOCS_ONLY" = true ]; then
|
||||
for shard in \
|
||||
"changed packages:$FOCUSED_RESULT" \
|
||||
"focused shards:$FOCUSED_RESULT" \
|
||||
"race shards:$RACE_RESULT" \
|
||||
"release scripts:$RELEASE_SCRIPTS_RESULT" \
|
||||
"cross-platform compile:$CROSS_PLATFORM_RESULT" \
|
||||
@@ -740,7 +856,7 @@ jobs:
|
||||
release_expected=success
|
||||
fi
|
||||
for shard in \
|
||||
"changed packages:$FOCUSED_RESULT:$focused_expected" \
|
||||
"focused shards:$FOCUSED_RESULT:$focused_expected" \
|
||||
"race shards:$RACE_RESULT:$race_expected" \
|
||||
"release scripts:$RELEASE_SCRIPTS_RESULT:$release_expected" \
|
||||
"cross-platform compile:$CROSS_PLATFORM_RESULT:success"
|
||||
@@ -911,7 +1027,7 @@ jobs:
|
||||
coverage-current:
|
||||
name: Coverage (current)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }}
|
||||
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
|
||||
steps:
|
||||
@@ -926,10 +1042,6 @@ 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 }}
|
||||
@@ -951,41 +1063,33 @@ jobs:
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Run current unit tests with coverage
|
||||
- name: Run scoped 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
|
||||
if [ "$FULL_SUITE" = true ]; then
|
||||
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 \
|
||||
./ ./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
|
||||
"${impacted_packages[@]}"
|
||||
fi
|
||||
if [ "$(wc -l < coverage.txt)" -gt 1 ]; then
|
||||
go tool cover -func=coverage.txt
|
||||
@@ -998,6 +1102,66 @@ 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
|
||||
@@ -1051,14 +1215,11 @@ 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 }}
|
||||
@@ -1076,7 +1237,34 @@ 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
|
||||
@@ -1125,6 +1313,21 @@ 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:
|
||||
@@ -1137,6 +1340,7 @@ jobs:
|
||||
needs:
|
||||
- lint
|
||||
- coverage-current
|
||||
- coverage-current-full
|
||||
- coverage-supporting
|
||||
- coverage-baseline
|
||||
- coverage-darwin
|
||||
@@ -1152,6 +1356,7 @@ 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 }}
|
||||
@@ -1159,6 +1364,7 @@ jobs:
|
||||
run: |
|
||||
failed=0
|
||||
current_expected=success
|
||||
current_full_expected=skipped
|
||||
supporting_expected=skipped
|
||||
baseline_expected=success
|
||||
native_expected=skipped
|
||||
@@ -1166,6 +1372,8 @@ 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 ] &&
|
||||
@@ -1176,6 +1384,7 @@ 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
|
||||
@@ -1211,6 +1420,7 @@ 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:
|
||||
@@ -1234,11 +1444,12 @@ jobs:
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download current coverage profile
|
||||
- name: Download current coverage profiles
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-current-profile
|
||||
pattern: coverage-current-*
|
||||
merge-multiple: true
|
||||
path: .
|
||||
|
||||
- name: Download supporting coverage profiles
|
||||
@@ -1255,6 +1466,26 @@ 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:
|
||||
@@ -1275,6 +1506,26 @@ 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: |
|
||||
|
||||
@@ -20,6 +20,10 @@ test/cli_compat/testdata/
|
||||
.gitignore
|
||||
.worktrees/
|
||||
.qoder/
|
||||
_logs/
|
||||
_docs/
|
||||
_output/
|
||||
vendor/
|
||||
|
||||
# Secrets & credentials
|
||||
.env
|
||||
|
||||
@@ -6,6 +6,101 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.59-beta.2] - 2026-08-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Privacy-safe CLI telemetry** (#1009) — reports reviewed command outcomes and profile identity dimensions while excluding command arguments, output, paths, device fingerprints, and automatic system dimensions; `DO_NOT_TRACK=1` disables reporting.
|
||||
|
||||
- **Feedback survey entry in root help** (#1019) — `dws --help` now closes with a Feedback section linking the user-experience survey form.
|
||||
|
||||
- **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.
|
||||
|
||||
### 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.
|
||||
|
||||
- **Faster Schema Catalog assembly** — projects typed values into payload JSON
|
||||
without re-running a validation scan over documents `json.Marshal` has just
|
||||
produced, cutting roughly a third of the projection work across the full tool
|
||||
set. Untrusted JSON input keeps its existing validation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Chat card update evidence** — distinguishes an accepted update request from an independently verified visible update, preserving the real `bizId` and warning callers not to repeat an unverified write.
|
||||
- **Chat command guidance** — splits message and group references by task and explains that `--from` is ambiguous between sender and time-range intent.
|
||||
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -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.58-beta.6"
|
||||
version "1.0.59-beta.2"
|
||||
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.58-beta.6/dws-darwin-arm64.tar.gz"
|
||||
sha256 "8f55497b84113f81b318e087c723016a02eded1cb5784cbd0811fe527d5852ca"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "7f11218d3222f3e93c3b1e94b3a004c061eb0a297b206447ea95fe6b2b1ec674"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-darwin-amd64.tar.gz"
|
||||
sha256 "b1762f1640310fb4100634fe54d9baace46384bb270c59148fe306275554d491"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "72f06a334cf29d23123639fabe13bf2951765066136e47ccc6453667c382f8c2"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-linux-arm64.tar.gz"
|
||||
sha256 "55393310ef0e1f24ea2c0dc22f00c0eb3b343edc2deb18d0db7838cda65af25d"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "3edbfabb7718b53914a2d9efe7b1152e9ebd70765ff0b6f19ad3125e4a2458ed"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-linux-amd64.tar.gz"
|
||||
sha256 "3830f77d09b4da4aa39f0c08d772ff3fa1ffb837eb15bb417f97edbccb073b7d"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "e1c610070a9c1b3763656cbd53c818290f199097adc07cb9fe629ed765c5e1e0"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-skills.zip"
|
||||
sha256 "f304a883a4f9e938b26a44692cd5a8d3d8704ba70ee7f33ba7c288434da72b6f"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-skills.zip"
|
||||
sha256 "aa2854651eaa2c857b526aaec73fd1358d31b11999fe7fd3e99e5060b2522a1f"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -10,7 +10,7 @@ SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob
|
||||
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
|
||||
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
|
||||
|
||||
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples tool-search-evaluation-harness generate-tool-search-comparison generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -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 every default Go package belongs to one CI test shard\n"
|
||||
@printf " make test-plan - Verify CI test and full-suite coverage package plans cover their scopes exactly once\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"
|
||||
@@ -37,6 +37,8 @@ help:
|
||||
@printf " make cli-smoke - Verify help for every public top-level command\n"
|
||||
@printf " make mock-mcp-smoke - Verify HTTP and stdio MCP request/response transport\n"
|
||||
@printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n"
|
||||
@printf " make tool-search-evaluation-harness - Verify Go retrieval, trust metrics, frozen fixtures, and build-time report generation\n"
|
||||
@printf " make generate-tool-search-comparison - Generate the declaration-assembled comparison under .worktrees/policy-tmp\n"
|
||||
@printf " make generate-schema - Refresh param_aliases + verify Schema assembly determinism\n"
|
||||
@printf " make generate-schema-catalog - Optional assembled Catalog dump under artifacts/ (not a delivery step)\n"
|
||||
@printf " make package - Build all release artifacts locally\n"
|
||||
@@ -84,7 +86,7 @@ fmt:
|
||||
$(GO_SOURCE_LIST) > "$$go_files"; \
|
||||
xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files"
|
||||
|
||||
policy: test-auth-legacy-compat
|
||||
policy: test-auth-legacy-compat tool-search-evaluation-harness skill-mono-multi-content
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
|
||||
@@ -161,6 +163,25 @@ mock-mcp-smoke:
|
||||
test-schema-agent-examples:
|
||||
DWS_AGENT_EXAMPLES_DRY_RUN=1 $(GO) test -v -count=1 ./internal/app -run '^TestAgentExamplesDryRun$$'
|
||||
|
||||
tool-search-evaluation-harness:
|
||||
python3 -m unittest \
|
||||
scripts/dev/eval_tool_search_ranking_test.py \
|
||||
scripts/dev/verify_tool_search_eval_manifest_test.py
|
||||
@mkdir -p "$(DWS_POLICY_TMPDIR)"
|
||||
$(GO) run ./internal/generator/cmd_schema_catalog \
|
||||
-root . \
|
||||
-output "$(DWS_POLICY_TMPDIR)/tool-search-schema-catalog" \
|
||||
-meta-index "$(DWS_POLICY_TMPDIR)/tool-search-schema-meta-index.gob"
|
||||
python3 scripts/dev/verify_tool_search_eval_manifest.py \
|
||||
--catalog-dir "$(DWS_POLICY_TMPDIR)/tool-search-schema-catalog"
|
||||
$(GO) test ./internal/cli -run '^TestToolSearch|^TestSchemaSearch|^TestDefaultToolSearch|^TestAggregateToolSearch|^TestScoreToolSearch' -count=1
|
||||
$(GO) test ./internal/app -run '^TestToolSearchDelivery|^TestSchemaSearchInspectCatalogVersionContract$$' -count=1
|
||||
$(GO) run ./internal/generator/cmd_tool_search_comparison -output "$(DWS_POLICY_TMPDIR)/tool-search-comparison.json"
|
||||
|
||||
generate-tool-search-comparison:
|
||||
@mkdir -p "$(DWS_POLICY_TMPDIR)"
|
||||
$(GO) run ./internal/generator/cmd_tool_search_comparison -output "$(DWS_POLICY_TMPDIR)/tool-search-comparison.json"
|
||||
|
||||
# generate-schema refreshes param_aliases_generated.go and verifies that
|
||||
# ResolveSchemaBuild assembly is deterministic. Catalog is runtime-assembled
|
||||
# (声明即 Catalog); cmd_schema_catalog is not a committed delivery step.
|
||||
|
||||
+66
-2
@@ -15,12 +15,76 @@ package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/clitrack"
|
||||
)
|
||||
|
||||
var exit = os.Exit
|
||||
var (
|
||||
appExecute = app.ExecuteWithTelemetry
|
||||
resolveTelemetryIdentity = app.ResolveTelemetryIdentity
|
||||
trackRun = func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
|
||||
clitrack.New(cfg).Run(execute, exitCode)
|
||||
}
|
||||
)
|
||||
|
||||
// trackedExitError tells clitrack that the command failed without asking it to
|
||||
// print the error a second time. The already-rendered message is published via
|
||||
// ExtraFields c5, while app.Execute remains the sole owner of presentation.
|
||||
type trackedExitError struct{}
|
||||
|
||||
func (trackedExitError) Error() string { return "" }
|
||||
|
||||
func trackerConfig(identity app.TelemetryIdentity, commandPath, errorMessage *string) clitrack.Config {
|
||||
return clitrack.Config{
|
||||
PID: "wcCRwZ",
|
||||
App: "dws",
|
||||
Version: app.RawVersion(),
|
||||
UID: identity.UserID,
|
||||
Username: identity.UserName,
|
||||
NoCommandLine: true,
|
||||
NoCwd: true,
|
||||
NoAutomaticDimensions: true,
|
||||
ExtraFields: func() map[string]string {
|
||||
fields := map[string]string{"c9": *commandPath}
|
||||
if identity.CorpID != "" {
|
||||
fields["c10"] = identity.CorpID
|
||||
}
|
||||
if *errorMessage != "" {
|
||||
fields["c5"] = *errorMessage
|
||||
}
|
||||
return fields
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func telemetryOptedOut() bool {
|
||||
return strings.TrimSpace(os.Getenv("DO_NOT_TRACK")) != ""
|
||||
}
|
||||
|
||||
func main() {
|
||||
exit(app.Execute())
|
||||
optedOut := telemetryOptedOut()
|
||||
identity := app.TelemetryIdentity{}
|
||||
if !optedOut {
|
||||
identity = resolveTelemetryIdentity(os.Args[1:])
|
||||
}
|
||||
exitCode := 0
|
||||
commandPath := "dws"
|
||||
errorMessage := ""
|
||||
cfg := trackerConfig(identity, &commandPath, &errorMessage)
|
||||
if optedOut {
|
||||
cfg.PID = ""
|
||||
}
|
||||
trackRun(
|
||||
cfg,
|
||||
func() error {
|
||||
exitCode, commandPath, errorMessage = appExecute()
|
||||
if exitCode != 0 {
|
||||
return trackedExitError{}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
func(error) int { return exitCode },
|
||||
)
|
||||
}
|
||||
+206
-13
@@ -1,27 +1,220 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/clitrack"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageMainExitsWithSuccessfulVersionCommand(t *testing.T) {
|
||||
previousExit := exit
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
exit = previousExit
|
||||
os.Args = previousArgs
|
||||
})
|
||||
func TestCrossPlatformCoverageMainRunsThroughCLITracker(t *testing.T) {
|
||||
for _, wantCode := range []int{0, 1, 3, 5} {
|
||||
t.Run(fmt.Sprintf("exit_%d", wantCode), func(t *testing.T) {
|
||||
t.Setenv("DO_NOT_TRACK", "")
|
||||
wantError := ""
|
||||
if wantCode != 0 {
|
||||
wantError = "synthetic failure"
|
||||
}
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "sheet", "read", "--profile", "corp-a"})
|
||||
testseam.Swap(t, &resolveTelemetryIdentity, func(args []string) app.TelemetryIdentity {
|
||||
if strings.Join(args, " ") != "sheet read --profile corp-a" {
|
||||
t.Fatalf("telemetry identity args = %#v", args)
|
||||
}
|
||||
return app.TelemetryIdentity{UserID: "user-1", UserName: "Alice", CorpID: "corp-1"}
|
||||
})
|
||||
testseam.Swap(t, &appExecute, func() (int, string, string) { return wantCode, "sheet read", wantError })
|
||||
called := false
|
||||
testseam.Swap(t, &trackRun, func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
|
||||
called = true
|
||||
if cfg.PID != "wcCRwZ" || cfg.App != "dws" {
|
||||
t.Fatalf("tracker identity = PID %q App %q", cfg.PID, cfg.App)
|
||||
}
|
||||
if cfg.Version != app.RawVersion() {
|
||||
t.Fatalf("tracker Version = %q, want %q", cfg.Version, app.RawVersion())
|
||||
}
|
||||
if !cfg.NoCommandLine || !cfg.NoCwd || !cfg.NoAutomaticDimensions || cfg.CaptureOutput {
|
||||
t.Fatalf("tracker privacy config = NoCommandLine %v NoCwd %v NoAutomaticDimensions %v CaptureOutput %v", cfg.NoCommandLine, cfg.NoCwd, cfg.NoAutomaticDimensions, cfg.CaptureOutput)
|
||||
}
|
||||
if cfg.Env != "" || cfg.EventID != "" || cfg.Endpoint != "" || cfg.FlushTimeout != 0 || cfg.OutputMaxLen != 0 {
|
||||
t.Fatalf("tracker SDK defaults were overridden: %#v", cfg)
|
||||
}
|
||||
if cfg.UID != "user-1" || cfg.Username != "Alice" || cfg.UserType != "" {
|
||||
t.Fatalf("tracker user identity = UID %q Username %q UserType %q", cfg.UID, cfg.Username, cfg.UserType)
|
||||
}
|
||||
|
||||
err := execute()
|
||||
if wantCode == 0 && err != nil {
|
||||
t.Fatalf("successful tracked execute error = %v", err)
|
||||
}
|
||||
if wantCode != 0 && (err == nil || err.Error() != "") {
|
||||
t.Fatalf("failed tracked execute error = %#v, want empty sentinel", err)
|
||||
}
|
||||
if gotCode := exitCode(err); gotCode != wantCode {
|
||||
t.Fatalf("tracked exit code = %d, want %d", gotCode, wantCode)
|
||||
}
|
||||
fields := cfg.ExtraFields()
|
||||
if fields["c9"] != "sheet read" || fields["c10"] != "corp-1" || fields["c5"] != wantError {
|
||||
t.Fatalf("tracker extra fields = %#v, want command path, corp ID, and error %q", fields, wantError)
|
||||
}
|
||||
if (wantError == "" && len(fields) != 2) || (wantError != "" && len(fields) != 3) {
|
||||
t.Fatalf("tracker extra field count = %d for error %q", len(fields), wantError)
|
||||
}
|
||||
})
|
||||
|
||||
main()
|
||||
if !called {
|
||||
t.Fatalf("trackRun was not called for exit code %d", wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTrackerConfigOmitsEmptyOrganization(t *testing.T) {
|
||||
commandPath := "version"
|
||||
errorMessage := ""
|
||||
cfg := trackerConfig(app.TelemetryIdentity{}, &commandPath, &errorMessage)
|
||||
if cfg.UID != "" {
|
||||
t.Fatalf("empty identity UID = %q", cfg.UID)
|
||||
}
|
||||
if cfg.Username != "" {
|
||||
t.Fatalf("empty identity Username = %q", cfg.Username)
|
||||
}
|
||||
if fields := cfg.ExtraFields(); len(fields) != 1 || fields["c9"] != "version" {
|
||||
t.Fatalf("empty organization fields = %#v", fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDefaultTrackRunNoopTracker(t *testing.T) {
|
||||
called := false
|
||||
code := -1
|
||||
exit = func(value int) {
|
||||
trackRun(clitrack.Config{}, func() error {
|
||||
called = true
|
||||
code = value
|
||||
return nil
|
||||
}, nil)
|
||||
if !called {
|
||||
t.Fatal("default tracker did not execute callback")
|
||||
}
|
||||
os.Args = []string{"dws", "version"}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMainRespectsDoNotTrack(t *testing.T) {
|
||||
t.Setenv("DO_NOT_TRACK", "1")
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "version"})
|
||||
testseam.Swap(t, &resolveTelemetryIdentity, func([]string) app.TelemetryIdentity {
|
||||
t.Fatal("DO_NOT_TRACK must skip telemetry identity reads")
|
||||
return app.TelemetryIdentity{}
|
||||
})
|
||||
testseam.Swap(t, &appExecute, func() (int, string, string) { return 0, "version", "" })
|
||||
testseam.Swap(t, &trackRun, func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
|
||||
if cfg.PID != "" || cfg.UID != "" || cfg.Username != "" {
|
||||
t.Fatalf("opted-out tracker config = %#v", cfg)
|
||||
}
|
||||
if err := execute(); err != nil {
|
||||
t.Fatalf("opted-out execution failed: %v", err)
|
||||
}
|
||||
if code := exitCode(nil); code != 0 {
|
||||
t.Fatalf("opted-out exit code = %d, want 0", code)
|
||||
}
|
||||
})
|
||||
|
||||
main()
|
||||
if !called || code != 0 {
|
||||
t.Fatalf("main exit = called %v, code %d", called, code)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTrackerPayloadUsesReviewedFieldWhitelist(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "sheet", "read", "--access-token", "must-not-leak"}
|
||||
t.Setenv("SHELL", "/bin/zsh")
|
||||
t.Setenv("TERM_SESSION_ID", "stable-session")
|
||||
t.Setenv("TMUX_PANE", "%42")
|
||||
t.Setenv("LANG", "zh_CN.UTF-8")
|
||||
t.Setenv("LC_ALL", "zh_CN.UTF-8")
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
requestBody := make(chan []byte, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
requestBody <- body
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
commandPath := "sheet read"
|
||||
errorMessage := ""
|
||||
cfg := trackerConfig(app.TelemetryIdentity{UserID: "user-1", UserName: "Alice", CorpID: "corp-1"}, &commandPath, &errorMessage)
|
||||
cfg.Endpoint = server.URL
|
||||
cfg.FlushTimeout = time.Second
|
||||
clitrack.New(cfg).Run(func() error { return nil }, nil)
|
||||
|
||||
var body []byte
|
||||
select {
|
||||
case body = <-requestBody:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for telemetry request")
|
||||
}
|
||||
var envelope map[string]string
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
t.Fatalf("decode telemetry request %q: %v", body, err)
|
||||
}
|
||||
decoded, err := url.QueryUnescape(envelope["gokey"])
|
||||
if err != nil {
|
||||
t.Fatalf("decode gokey: %v", err)
|
||||
}
|
||||
globalFields, err := url.ParseQuery(decoded)
|
||||
if err != nil {
|
||||
t.Fatalf("parse global telemetry fields: %v", err)
|
||||
}
|
||||
eventFields, err := url.ParseQuery(globalFields.Get("msg"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse event telemetry fields: %v", err)
|
||||
}
|
||||
|
||||
assertTelemetryKeys(t, globalFields, []string{"app_name", "app_version", "env", "msg", "pid", "platform", "uid", "username", "version"})
|
||||
assertTelemetryKeys(t, eventFields, []string{"c1", "c10", "c3", "c4", "c9", "p1", "p4", "ts", "type"})
|
||||
for key, want := range map[string]string{
|
||||
"app_name": "dws", "app_version": app.RawVersion(), "env": "prod", "pid": "wcCRwZ",
|
||||
"platform": "cli", "uid": "user-1", "username": "Alice", "version": app.RawVersion(),
|
||||
} {
|
||||
if got := globalFields.Get(key); got != want {
|
||||
t.Fatalf("global telemetry field %s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"type": "event", "p1": "cli.exec", "p4": "SYS", "c1": "dws", "c3": "0", "c9": "sheet read", "c10": "corp-1",
|
||||
} {
|
||||
if got := eventFields.Get(key); got != want {
|
||||
t.Fatalf("event telemetry field %s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"device_id", "ext", "os", "os_version", "pv_id", "sdk_version", "sid", "timezone_offset"} {
|
||||
if globalFields.Has(key) {
|
||||
t.Fatalf("global telemetry leaked %s: %q", key, decoded)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"c2", "c5", "c6", "c7", "c8"} {
|
||||
if eventFields.Has(key) {
|
||||
t.Fatalf("event telemetry leaked %s: %q", key, globalFields.Get("msg"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertTelemetryKeys(t *testing.T, fields url.Values, want []string) {
|
||||
t.Helper()
|
||||
got := make([]string, 0, len(fields))
|
||||
for key := range fields {
|
||||
got = append(got, key)
|
||||
}
|
||||
sort.Strings(got)
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("telemetry keys = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -201,8 +201,21 @@ 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. Supporting and (when platform-selected) native profiles are
|
||||
generated before the aggregate `Coverage` context evaluates them. The
|
||||
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
|
||||
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
|
||||
|
||||
@@ -92,6 +92,7 @@ command/Leaf 不再写 `dws.schema.risk`;SafetySpec 走类型化 Final 载荷
|
||||
| `Required` / `MarkRequired` | 非空校验 / cobra 硬必填 | 是(`required`) |
|
||||
| `RequiredHint`, `Aliases`, `EnvVar` | 校验提示、隐藏别名、环境回退 | 否(执行细节;别名不上主 parameter 表) |
|
||||
| `ArgDefault`, `Bind`, `OmitEmpty`, `Trim`, `Transform` | toolArgs 装配语义 | 否(载荷细节;`Bind` 可进 property 映射,但不另造 flag) |
|
||||
| `Input` | 额外取值来源:`@path` 读文件 / `-` 读 stdin,在 required/enum/约束/`Validate` 之前原地解析 | 否(今日:能力由作者写进 `Usage` / `SchemaDescription` 文案,是已声明事实而非推断;不另造 flag。目标形态收敛为类型化投影字段,见 RFC §5.3) |
|
||||
|
||||
#### 1.2.2 编排 / 执行字段(不算声明)
|
||||
|
||||
|
||||
@@ -292,7 +292,7 @@ Definition(仅声明;不可编译)
|
||||
|
||||
下列字段**是**框架声明面(经 `corecmd.New` 生效并嵌入 `dws.schema.*`):
|
||||
|
||||
- `Flags`(含 Name/Kind/Default/Required/MarkRequired/Usage 等注册面)
|
||||
- `Flags`(含 Name/Kind/Default/Required/MarkRequired/Usage 等注册面;`Input` 是取值来源声明,经 `corecmd.New` 生效但**不**嵌入 `dws.schema.*`,能力靠 `Usage` 文案声明,见 §5.3)
|
||||
- `Constraints`
|
||||
- **非空** `Risk`(空值 = 运行时当只读确认,且**不**嵌入 `dws.schema.risk`)
|
||||
- `ConstParams`(载荷声明;不上用户 flag 表)
|
||||
@@ -673,6 +673,52 @@ func (k Key[T]) Declare(opts ...FlagOption[T]) FlagSpec
|
||||
- 构造时拒绝 `InputSourceInvalid`。
|
||||
- 当前没有任何 Shortcut 或 Leaf 声明 `Input`,因此 M1 增加能力且零上线表面变化。让现有命令采用它属于 §9 下的用户可见变更。
|
||||
|
||||
`Input` 的框架能力今日已在 `corecmd` 落地(声明即执行的过渡形态,语义与上文目标一致),使用指南:
|
||||
|
||||
**今日声明形态**:`FlagSpec.Input []string`,源常量 `corecmd.InputFile`(`"file"`)/ `corecmd.InputStdin`(`"stdin"`)。`helpers.LeafFlag` 是 `corecmd.FlagSpec` 别名,直接可用;`shortcut.Flag.Input` 同形声明,经 `FromShortcut` 映射到 `FlagSpec`。
|
||||
|
||||
```go
|
||||
// LeafSpec / helpers
|
||||
Flags: []helpers.LeafFlag{
|
||||
{
|
||||
Name: "content",
|
||||
Usage: "文档内容(支持 @文件路径 或 - 读 stdin)",
|
||||
Bind: "content",
|
||||
Input: []string{corecmd.InputFile, corecmd.InputStdin},
|
||||
},
|
||||
}
|
||||
|
||||
// shortcut
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "markdown", Desc: "Markdown 内容(支持 @文件路径 或 -)",
|
||||
Input: []string{"file", "stdin"}},
|
||||
}
|
||||
```
|
||||
|
||||
**运行时语义**(`resolveInputFlags`,在 `runDeclaredPreflight` 内、required/enum/约束/Validate 之前执行,原地改写 cobra flag 值):
|
||||
|
||||
- `--flag @path`:文件内容替换取值;`--flag -`:stdin 内容替换取值。
|
||||
- `--flag @@value`:转义为字面 `@value`,不做来源解析。
|
||||
- 只解析显式 CLI token(主名或别名);EnvVar 回落与注册默认值透传不解析。
|
||||
- 内容前置剥离 UTF-8 BOM;`Trim` 等既有语义照常作用于解析后的值。
|
||||
- 读取失败、源不支持、`@` 后空路径都是类型化校验错误(退出码 3);同时声明两种源而文件读取失败时附 stdin 引导 hint。
|
||||
|
||||
**作者守则**:
|
||||
|
||||
- 声明即全部能力:required/enum/约束/Validate 校验的已是解析后的真实内容,`Execute`/`Invoke` 无需任何额外代码。
|
||||
- `Usage`/`Desc` 必须写明支持 `@路径`/`-`;框架不自动改写 help 文案,今日也不向 Schema 投影(新增投影字段须先过 homology 评审,避免 catalog drift)。
|
||||
- `user_required` 确认的写命令若声明 `InputStdin`:stdin 在校验阶段被消费,交互确认将 fail-closed 为 `confirmation_required`,此类调用必须显式 `--yes`(或 `--dry-run`)。
|
||||
- **声明前先确认取值空间不会被前缀吃掉**:声明 `InputFile` 后,任何以 `@` 开头的合法值都会被当成文件路径(本产品尤其常见的是 at 提及类取值,如 `--at-user @zhangsan` 会报读取文件失败),用户只能改用 `@@` 转义;声明 `InputStdin` 后字面值 `-` 不可达(与 curl 等约定一致)。若该 flag 的正常取值可能命中这两种形态,就不要声明对应来源。
|
||||
- 声明在构造期校验(fail-closed panic):仅限 `KindString`;源值必须是 `file`/`stdin` 且不重复。
|
||||
|
||||
**今日实现与目标形态的差异**(迁移到本节目标 `FlagSpec` 时收敛):
|
||||
|
||||
| 维度 | 今日 | 目标 |
|
||||
|---|---|---|
|
||||
| 源类型 | `[]string` 常量 | 类型化 `InputSource` |
|
||||
| 路径边界 | 直接本地文件 IO | 复用 §5.5.2 本地文件 effect 边界 |
|
||||
| Schema 投影 | 无(靠作者在 Usage 声明) | 声明即最终源,随 Catalog 透传 |
|
||||
|
||||
核心 FlagSpec 故意没有:
|
||||
|
||||
- `Bind`;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,117 @@
|
||||
<!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>空间搜索的稳定工作流属性名与实际请求属性名不同。</td><td>直接改写已发布的 <code>query/limit</code> 会造成无版本 Schema 破坏;继续隐式转换又会让审计者误以为请求同名透传。</td><td><span class="tag fixed">显式复合适配</span> 最终 Schema 保留兼容属性并明确声明转换为 <code>keyword/pageSize</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>
|
||||
@@ -2,6 +2,8 @@ module github.com/DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
|
||||
go 1.25.9
|
||||
|
||||
replace gitlab.alibaba-inc.com/aes/aem-go-sdk => ./third_party/aem-go-sdk
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15
|
||||
@@ -16,7 +18,9 @@ require (
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/yuin/goldmark v1.8.5
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
gitlab.alibaba-inc.com/aes/aem-go-sdk v0.3.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/text v0.35.0
|
||||
|
||||
@@ -105,6 +105,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
||||
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
|
||||
@@ -1220,7 +1220,8 @@ func newEventStopCommandWithFlags(globalFlags ...*GlobalFlags) *cobra.Command {
|
||||
editionName := editionNameOrDefault()
|
||||
clientIDHash := dwsevent.ClientIDHash(clientID)
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
|
||||
if err := eventStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil {
|
||||
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
|
||||
if err := eventStopBus(busctl.StopConfig{WorkDir: workDir, IPCEndpoint: ipcEndpoint}); err != nil {
|
||||
if errors.Is(err, busctl.ErrNotRunning) {
|
||||
fmt.Fprintln(c.OutOrStdout(), "bus is not running")
|
||||
return nil
|
||||
|
||||
@@ -1220,7 +1220,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
|
||||
}
|
||||
|
||||
busState := "personal bus stopped"
|
||||
if err := personalStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil {
|
||||
if err := personalStopBus(busctl.StopConfig{WorkDir: workDir, IPCEndpoint: ipcEndpoint}); err != nil {
|
||||
if errors.Is(err, busctl.ErrNotRunning) {
|
||||
busState = "personal bus is not running"
|
||||
} else {
|
||||
|
||||
@@ -82,6 +82,46 @@ func TestFlagErrorWithSuggestions_unknownFlagHintAndFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagErrorWithSuggestionsChatFromExplainsBothMeanings(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
search := &cobra.Command{Use: "+search-msg", Run: func(*cobra.Command, []string) {}}
|
||||
search.Flags().String("sender", "", "sender target")
|
||||
search.Flags().String("start", "", "start time")
|
||||
root.AddCommand(chat)
|
||||
chat.AddCommand(search)
|
||||
|
||||
orig := fmt.Errorf("unknown flag: --from")
|
||||
err := flagErrorWithSuggestions(search, orig)
|
||||
var ae *apperrors.Error
|
||||
if !stderrors.As(err, &ae) {
|
||||
t.Fatalf("want *apperrors.Error, got %T", err)
|
||||
}
|
||||
if ae.Reason != "ambiguous_flag" || !strings.Contains(ae.Hint, "--sender") || !strings.Contains(ae.Hint, "--start") {
|
||||
t.Fatalf("structured error = reason %q hint %q", ae.Reason, ae.Hint)
|
||||
}
|
||||
if !strings.HasSuffix(ae.Message, "See 'dws chat +search-msg --help' for usage.") {
|
||||
t.Fatalf("Message = %q", ae.Message)
|
||||
}
|
||||
|
||||
for _, flag := range []string{"from-file", "from-user"} {
|
||||
t.Run(flag, func(t *testing.T) {
|
||||
err := flagErrorWithSuggestions(search, fmt.Errorf("unknown flag: --%s", flag))
|
||||
var structured *apperrors.Error
|
||||
if stderrors.As(err, &structured) && structured.Reason == "ambiguous_flag" {
|
||||
t.Fatalf("--%s incorrectly used --from ambiguity handling: %#v", flag, structured)
|
||||
}
|
||||
if strings.Contains(err.Error(), "--from 在消息查询中含义不明确") {
|
||||
t.Fatalf("--%s incorrectly received --from ambiguity hint: %v", flag, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown flag: --"+flag) {
|
||||
t.Fatalf("error = %q, want original flag --%s", err, flag)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlagErrorWithSuggestions_fallbackTailHint 验证 fallback 路径(非 unknown flag 类错误,
|
||||
// 如 missing required flag / ambiguous shorthand)也带尾部 See '<cmd> --help' for usage.
|
||||
// 这是 wukong / docker / kubectl 的通用 UX——任何 flag 解析错误都给用户一条 help 入口。
|
||||
|
||||
@@ -268,7 +268,7 @@ type frameworkFailWriter struct{}
|
||||
|
||||
func (frameworkFailWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
|
||||
|
||||
func TestFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) {
|
||||
for _, failWriter := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "emits", true: "fallback"}[failWriter], func(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
@@ -470,6 +470,118 @@ func TestCrossPlatformCoverageFrameworkExecuteRareOutcomeBranches(t *testing.T)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExecuteDeterministicInterruptionBranches(t *testing.T) {
|
||||
install := func(t *testing.T, state *processSignalState, stdout, stderr io.Writer) {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws"}
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||
testseam.Swap(t, &rootInstallProcessSignalContext, func(ctx context.Context, _ *output.ResultStore) (context.Context, *processSignalState, func()) {
|
||||
return ctx, state, func() {}
|
||||
})
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
cmd.SetContext(ctx)
|
||||
cmd.SetOut(stdout)
|
||||
cmd.SetErr(stderr)
|
||||
return cmd
|
||||
})
|
||||
}
|
||||
interrupted := func(primaryCompleted bool) *processSignalState {
|
||||
return &processSignalState{
|
||||
interruption: &processInterruption{signal: os.Interrupt},
|
||||
primaryCompletedAtSignal: primaryCompleted,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("preparse interruption emits unified failure", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
install(t, interrupted(false), &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return errors.New("preparse failed") })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(*cobra.Command) (*cobra.Command, error) {
|
||||
t.Fatal("preparse failure reached command execution")
|
||||
return nil, nil
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 130 || summary == "" || !strings.Contains(stdout.String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("preparse interruption = code %d summary %q stdout %q", code, summary, stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("interruption before emission becomes primary error", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
install(t, interrupted(false), &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil })
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 130 || summary == "" || !strings.Contains(stdout.String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("pre-emission interruption = code %d summary %q stdout %q", code, summary, stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("late hook error preserves emitted result", func(t *testing.T) {
|
||||
install(t, interrupted(true), io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cmd, errors.New("late hook failed")
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 0 || summary != "late hook failed" {
|
||||
t.Fatalf("late hook result = code %d summary %q", code, summary)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("interruption after emission preserves emitted result", func(t *testing.T) {
|
||||
install(t, interrupted(false), io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cmd, nil
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 0 || summary == "" {
|
||||
t.Fatalf("post-emission interruption = code %d summary %q", code, summary)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("publication failure replaces unobservable result", func(t *testing.T) {
|
||||
var original bytes.Buffer
|
||||
install(t, interrupted(false), io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, err := os.CreateTemp(t.TempDir(), "finished-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{file: file, original: &original, finished: true}))
|
||||
publicationErr := newOutputPublicationError("publish", errors.New("rename failed"))
|
||||
if _, handled, emitErr := emitOutputPublicationFailure(cmd, publicationErr); !handled || emitErr != nil {
|
||||
t.Fatalf("precondition publication failure = handled %v error %v unified %v state %v", handled, emitErr, output.UsesUnifiedResult(cmd), outputSinkForCommand(cmd) != nil)
|
||||
}
|
||||
return cmd, publicationErr
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 5 || summary == "" {
|
||||
t.Fatalf("publication failure = code %d summary %q output %q", code, summary, original.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type frameworkPanicWriter struct{}
|
||||
|
||||
func (frameworkPanicWriter) Write([]byte) (int, error) { panic("writer panic") }
|
||||
|
||||
@@ -52,6 +52,8 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
|
||||
switch tool {
|
||||
case "list_calendar_events":
|
||||
return `{"result":{"events":[]}}`
|
||||
case "query_records":
|
||||
return `{"success":true,"status":"success","error":{},"data":{}}`
|
||||
case "search_mail_users":
|
||||
return `{"users":[{"name":"Fixture User","email":"fixture@example.com","id":"fixture-user"}]}`
|
||||
case "search_dept_by_keyword":
|
||||
@@ -63,7 +65,7 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
|
||||
case "list_doc_versions":
|
||||
return `{"result":{"items":[{"version":3}]}}`
|
||||
case "revert_doc_version":
|
||||
return `{"version":3}`
|
||||
return `{"revertedToVersion":3}`
|
||||
case "search_doc_templates":
|
||||
return `{"result":[{"templateId":"fixture-template-id"}]}`
|
||||
case "create_document":
|
||||
@@ -479,7 +481,11 @@ func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPaylo
|
||||
if err != nil {
|
||||
t.Fatalf("alias execution failed: %v", err)
|
||||
}
|
||||
if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--"+alias || ctx.Corrections[0].Corrected != "--conversation-id" {
|
||||
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" {
|
||||
t.Fatalf("alias corrections = %#v", ctx)
|
||||
}
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
|
||||
@@ -66,6 +66,32 @@ func TestPreparseProfileFlagUsesNormalizedProfileArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePreparseProfileFlagUsesLastOccurrence(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
valid bool
|
||||
}{
|
||||
{name: "space then equals", args: []string{"--profile", "corp-a", "version", "--profile=corp-b"}, want: "corp-b", valid: true},
|
||||
{name: "equals then space", args: []string{"--profile=corp-a", "version", "--profile", "corp-b"}, want: "corp-b", valid: true},
|
||||
{name: "last multi", args: []string{"--profile=corp-a", "--profile", "corp-b,", "corp-c", "version"}, want: "corp-b,corp-c", valid: true},
|
||||
{name: "empty equals clears earlier", args: []string{"--profile=corp-a", "version", "--profile="}},
|
||||
{name: "missing value clears earlier", args: []string{"--profile=corp-a", "version", "--profile"}},
|
||||
{name: "next flag is not profile value", args: []string{"--profile=corp-a", "--profile", "--debug", "version"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := preparseProfileFlag(tc.args); got != tc.want {
|
||||
t.Fatalf("preparseProfileFlag(%#v) = %q, want %q", tc.args, got, tc.want)
|
||||
}
|
||||
_, specified, valid := preparseProfileSelection(tc.args)
|
||||
if !specified || valid != tc.valid {
|
||||
t.Fatalf("preparseProfileSelection(%#v) = specified %v valid %v, want true/%v", tc.args, specified, valid, tc.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProcessProfileArgsRestoresOriginalArgv(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
|
||||
+132
-24
@@ -80,10 +80,19 @@ var (
|
||||
rootAuthLoadTokenData = authpkg.LoadTokenData
|
||||
rootNewCommandRunnerWithFlags = newCommandRunnerWithFlags
|
||||
rootEmitResult = output.EmitResult
|
||||
rootInstallProcessSignalContext = installProcessSignalContext
|
||||
)
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
func Execute() (exitCode int) {
|
||||
func Execute() int {
|
||||
exitCode, _, _ := ExecuteWithTelemetry()
|
||||
return exitCode
|
||||
}
|
||||
|
||||
// ExecuteWithTelemetry runs the root command and additionally returns a
|
||||
// privacy-safe command path and error summary for the official CLI entrypoint.
|
||||
func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage string) {
|
||||
commandPath = "dws"
|
||||
var (
|
||||
root *cobra.Command
|
||||
executed *cobra.Command
|
||||
@@ -91,12 +100,16 @@ func Execute() (exitCode int) {
|
||||
)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errorMessage = "internal panic"
|
||||
target := executed
|
||||
if target == nil && root != nil {
|
||||
if found, _, err := root.Find(os.Args[1:]); err == nil {
|
||||
target = found
|
||||
}
|
||||
}
|
||||
if target != nil {
|
||||
commandPath = telemetryCommandPath(target)
|
||||
}
|
||||
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||
exitCode = code
|
||||
if target != nil {
|
||||
@@ -121,6 +134,7 @@ func Execute() (exitCode int) {
|
||||
CloseFileLogger()
|
||||
if executed != nil {
|
||||
if err := closeOutputSink(executed); err != nil {
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
if code, handled, emitErr := emitOutputPublicationFailure(executed, err); handled && emitErr == nil {
|
||||
exitCode = code
|
||||
} else {
|
||||
@@ -144,7 +158,9 @@ func Execute() (exitCode int) {
|
||||
agentMetadata := readAgentMetadataSnapshot()
|
||||
if err := agentMetadata.validationError(); err != nil {
|
||||
emitEarlyAgentMetadataValidationError(err, os.Args[1:])
|
||||
return apperrors.ExitCode(err)
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = apperrors.ExitCode(err)
|
||||
return
|
||||
}
|
||||
|
||||
timing := NewTimingCollector()
|
||||
@@ -162,12 +178,13 @@ func Execute() (exitCode int) {
|
||||
ctx, resultStore = output.WithResultStore(ctx)
|
||||
var signalState *processSignalState
|
||||
var stopSignals func()
|
||||
ctx, signalState, stopSignals = installProcessSignalContext(ctx, resultStore)
|
||||
ctx, signalState, stopSignals = rootInstallProcessSignalContext(ctx, resultStore)
|
||||
defer stopSignals()
|
||||
|
||||
initStart := time.Now()
|
||||
engine := newPipelineEngine()
|
||||
root = rootNewRootCommandWithEngine(ctx, engine)
|
||||
commandPath = telemetryCommandPath(root)
|
||||
timing.Record("cmd_init", time.Since(initStart))
|
||||
|
||||
// Run PreParse handlers on raw argv before Cobra parses flags.
|
||||
@@ -182,15 +199,23 @@ func Execute() (exitCode int) {
|
||||
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
|
||||
code, emitErr := output.EmitResult(target, result)
|
||||
if emitErr == nil {
|
||||
return code
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = printExecutionError(root, os.Stdout, os.Stderr, err)
|
||||
return apperrors.ExitCode(err)
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = apperrors.ExitCode(err)
|
||||
return
|
||||
}
|
||||
commandPath = telemetryCommandPathForArgs(root, os.Args[1:])
|
||||
|
||||
var err error
|
||||
executed, err = rootExecuteCommand(root)
|
||||
if executed != nil {
|
||||
commandPath = telemetryCommandPath(executed)
|
||||
}
|
||||
// PersistentPostRunE normally commits or aborts the transactional output
|
||||
// sink. Finalize once more at the process boundary so custom execution
|
||||
// seams, embedding callers, or future hook changes cannot leave publication
|
||||
@@ -222,7 +247,9 @@ func Execute() (exitCode int) {
|
||||
// successfully emitted result into a contradictory 130/143 process
|
||||
// status; likewise, a failed publication must retain its internal
|
||||
// error code instead of being relabelled as cancellation.
|
||||
return code
|
||||
errorMessage = telemetryErrorSummary(interrupted)
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
}
|
||||
var publicationErr *outputPublicationError
|
||||
@@ -233,20 +260,26 @@ func Execute() (exitCode int) {
|
||||
if err != nil {
|
||||
if executed == nil {
|
||||
executed = root
|
||||
commandPath = telemetryCommandPath(root)
|
||||
}
|
||||
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||
var publicationErr *outputPublicationError
|
||||
if stderrors.As(err, &publicationErr) {
|
||||
errorMessage = telemetryErrorSummary(publicationErr)
|
||||
if failureCode, handled, emitErr := emitOutputPublicationFailure(executed, publicationErr); handled {
|
||||
if emitErr == nil {
|
||||
return failureCode
|
||||
exitCode = failureCode
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: emit output publication failure: %v\n", emitErr)
|
||||
}
|
||||
return apperrors.ExitCode(publicationErr)
|
||||
exitCode = apperrors.ExitCode(publicationErr)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: command hook failed after result emission: %v\n", err)
|
||||
return code
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
err = rewordRequiredFlagError(err)
|
||||
var raw apperrors.RawStderrError
|
||||
@@ -254,7 +287,9 @@ func Execute() (exitCode int) {
|
||||
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
|
||||
code, emitErr := output.EmitResult(executed, result)
|
||||
if emitErr == nil {
|
||||
return code
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
err = apperrors.NewInternal("emit failure result: "+emitErr.Error(), apperrors.WithCause(emitErr))
|
||||
}
|
||||
@@ -264,12 +299,42 @@ func Execute() (exitCode int) {
|
||||
_, _ = fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
|
||||
return apperrors.ExitCode(err)
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = apperrors.ExitCode(err)
|
||||
return
|
||||
}
|
||||
if code, emitted := output.StoredExitCode(resultStore); emitted {
|
||||
return code
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
return 0
|
||||
return
|
||||
}
|
||||
|
||||
func telemetryCommandPath(command *cobra.Command) string {
|
||||
if command == nil {
|
||||
return "dws"
|
||||
}
|
||||
path := strings.TrimSpace(command.CommandPath())
|
||||
root := command.Root()
|
||||
rootName := strings.TrimSpace(root.Name())
|
||||
if path == rootName {
|
||||
return rootName
|
||||
}
|
||||
if rootName != "" {
|
||||
path = strings.TrimSpace(strings.TrimPrefix(path, rootName+" "))
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func telemetryCommandPathForArgs(root *cobra.Command, args []string) string {
|
||||
if root == nil {
|
||||
return "dws"
|
||||
}
|
||||
command, _, err := root.Find(args)
|
||||
if err != nil || command == nil {
|
||||
return telemetryCommandPath(root)
|
||||
}
|
||||
return telemetryCommandPath(command)
|
||||
}
|
||||
|
||||
// emitEarlyAgentMetadataValidationError preserves each built-in command's
|
||||
@@ -511,6 +576,22 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
|
||||
// 无论哪种格式,子串 "--help' for usage." 都可被检索到。
|
||||
tail := fmt.Sprintf("\nSee '%s --help' for usage.", cmd.CommandPath())
|
||||
msgWithTail := errMsg + tail
|
||||
if flag, ok := unknownFlagName(errMsg); ok && flag == "from" {
|
||||
switch cmd.CommandPath() {
|
||||
case "dws chat +search-msg", "dws chat +chat-messages":
|
||||
return apperrors.NewValidation(
|
||||
msgWithTail,
|
||||
apperrors.WithHint("--from 在消息查询中含义不明确:按发送者过滤请使用 --sender <姓名|userId|openDingTalkId>;指定时间起点请使用 --start <RFC3339>"),
|
||||
apperrors.WithReason("ambiguous_flag"),
|
||||
apperrors.WithCause(err),
|
||||
apperrors.WithActions(
|
||||
"Use --sender <姓名|userId|openDingTalkId> to filter by sender",
|
||||
"Use --start <RFC3339> together with --end <RFC3339> to set a time range",
|
||||
),
|
||||
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
|
||||
)
|
||||
}
|
||||
}
|
||||
if flag, protection, ok := reviewedFlagProtection(cmd, errMsg); ok {
|
||||
hint := fmt.Sprintf("Parameter --%s is blocked from automatic normalization on %q; choose an explicit flag from --help.", flag, cmd.CommandPath())
|
||||
reason := "blocked_flag"
|
||||
@@ -579,15 +660,10 @@ func reviewedFlagProtection(cmd *cobra.Command, errMsg string) (string, pipeline
|
||||
if cmd == nil {
|
||||
return "", "", false
|
||||
}
|
||||
const prefix = "unknown flag: --"
|
||||
idx := strings.Index(errMsg, prefix)
|
||||
if idx < 0 {
|
||||
flag, ok := unknownFlagName(errMsg)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
flag := strings.TrimSpace(errMsg[idx+len(prefix):])
|
||||
if i := strings.IndexAny(flag, " =\n\t"); i >= 0 {
|
||||
flag = flag[:i]
|
||||
}
|
||||
entry, ok := cli.LookupParamAlias(cmd.CommandPath())
|
||||
if !ok {
|
||||
return "", "", false
|
||||
@@ -602,6 +678,19 @@ func reviewedFlagProtection(cmd *cobra.Command, errMsg string) (string, pipeline
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func unknownFlagName(errMsg string) (string, bool) {
|
||||
const prefix = "unknown flag: --"
|
||||
idx := strings.Index(errMsg, prefix)
|
||||
if idx < 0 {
|
||||
return "", false
|
||||
}
|
||||
flag := strings.TrimSpace(errMsg[idx+len(prefix):])
|
||||
if i := strings.IndexAny(flag, " =\n\t"); i >= 0 {
|
||||
flag = flag[:i]
|
||||
}
|
||||
return flag, flag != ""
|
||||
}
|
||||
|
||||
func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err error) error {
|
||||
var raw apperrors.RawStderrError
|
||||
if stderrors.As(err, &raw) {
|
||||
@@ -935,17 +1024,36 @@ func installReviewedFlagProtectionHandlers(root *cobra.Command) {
|
||||
}
|
||||
|
||||
func preparseProfileFlag(args []string) string {
|
||||
profile, _, valid := preparseProfileSelection(args)
|
||||
if !valid {
|
||||
return ""
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
func preparseProfileSelection(args []string) (profile string, specified, valid bool) {
|
||||
args, _ = normalizeProfileFlagArgs(args)
|
||||
valid = true
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := strings.TrimSpace(args[i])
|
||||
switch {
|
||||
case arg == "--profile" && i+1 < len(args):
|
||||
return strings.TrimSpace(args[i+1])
|
||||
case arg == "--profile":
|
||||
specified = true
|
||||
if i+1 >= len(args) || strings.HasPrefix(strings.TrimSpace(args[i+1]), "-") {
|
||||
profile = ""
|
||||
valid = false
|
||||
continue
|
||||
}
|
||||
profile = strings.TrimSpace(args[i+1])
|
||||
valid = profile != ""
|
||||
i++
|
||||
case strings.HasPrefix(arg, "--profile="):
|
||||
return strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
|
||||
specified = true
|
||||
profile = strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
|
||||
valid = profile != ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return profile, specified, valid
|
||||
}
|
||||
|
||||
func normalizeProcessProfileArgs() func() {
|
||||
|
||||
@@ -37,29 +37,64 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
rootStopAllStdioClients = func() {}
|
||||
var executedLeaf *cobra.Command
|
||||
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
sheet := &cobra.Command{Use: "sheet"}
|
||||
executedLeaf = &cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}}
|
||||
sheet.AddCommand(executedLeaf)
|
||||
root.AddCommand(sheet)
|
||||
return root
|
||||
}
|
||||
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil }
|
||||
if code := Execute(); code != 0 {
|
||||
t.Fatalf("successful Execute code = %d", code)
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return executedLeaf, nil }
|
||||
if code, commandPath, errorMessage := ExecuteWithTelemetry(); code != 0 || commandPath != "sheet read" || errorMessage != "" {
|
||||
t.Fatalf("successful ExecuteWithTelemetry = code %d path %q error %q", code, commandPath, errorMessage)
|
||||
}
|
||||
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return errors.New("alias/canonical conflict") }
|
||||
if code := Execute(); code == 0 {
|
||||
t.Fatal("pre-parse conflict returned zero")
|
||||
if code, _, errorMessage := ExecuteWithTelemetry(); code == 0 || errorMessage != "alias/canonical conflict" {
|
||||
t.Fatalf("pre-parse conflict = code %d error %q", code, errorMessage)
|
||||
}
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
|
||||
wantErr := errors.New("unknown command missing")
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
|
||||
if code := Execute(); code == 0 {
|
||||
t.Fatal("failed Execute returned zero")
|
||||
if code, _, errorMessage := ExecuteWithTelemetry(); code == 0 || errorMessage != "unknown command" {
|
||||
t.Fatalf("failed ExecuteWithTelemetry = code %d error %q", code, errorMessage)
|
||||
}
|
||||
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { panic("boom") }
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("panic Execute code = %d", code)
|
||||
os.Args = []string{"dws", "sheet", "read"}
|
||||
if code, commandPath, errorMessage := ExecuteWithTelemetry(); code != 5 || commandPath != "sheet read" || errorMessage != "internal panic" {
|
||||
t.Fatalf("panic ExecuteWithTelemetry = code %d path %q error %q", code, commandPath, errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryCommandPath(t *testing.T) {
|
||||
if got := telemetryCommandPath(nil); got != "dws" {
|
||||
t.Fatalf("nil command path = %q, want dws", got)
|
||||
}
|
||||
if got := telemetryCommandPathForArgs(nil, nil); got != "dws" {
|
||||
t.Fatalf("nil root command path = %q, want dws", got)
|
||||
}
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
sheet := &cobra.Command{Use: "sheet"}
|
||||
read := &cobra.Command{Use: "read <range>"}
|
||||
sheet.AddCommand(read)
|
||||
root.AddCommand(sheet)
|
||||
if got := telemetryCommandPath(root); got != "dws" {
|
||||
t.Fatalf("root command path = %q, want dws", got)
|
||||
}
|
||||
if got := telemetryCommandPath(read); got != "sheet read" {
|
||||
t.Fatalf("leaf command path = %q, want sheet read", got)
|
||||
}
|
||||
root.PersistentFlags().String("profile", "", "")
|
||||
read.Aliases = []string{"get"}
|
||||
if got := telemetryCommandPathForArgs(root, []string{"--profile", "corp-a", "sheet", "get", "A1:B2"}); got != "sheet read" {
|
||||
t.Fatalf("pre-execution command path = %q, want sheet read", got)
|
||||
}
|
||||
if got := telemetryCommandPathForArgs(root, []string{"missing"}); got != "dws" {
|
||||
t.Fatalf("unknown pre-execution command path = %q, want dws", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
@@ -13,6 +14,12 @@ import (
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// feedbackFormURL points at the DingTalk Notable form collecting dws CLI
|
||||
// user-experience feedback. The source parameter tags submissions that
|
||||
// originated from the CLI help output so they can be told apart from
|
||||
// responses arriving through other channels.
|
||||
const feedbackFormURL = "https://alidocs.dingtalk.com/notable/share/form/v01eLbnj1bw1ELb0laN_dv19yqvsgs3oebp3pcjys_1qX0QQ0?source=dws-cli"
|
||||
|
||||
func configureRootHelp(root *cobra.Command) {
|
||||
if root == nil {
|
||||
return
|
||||
@@ -101,6 +108,29 @@ func renderRootHelp(root *cobra.Command) {
|
||||
_, _ = fmt.Fprintln(w)
|
||||
_, _ = fmt.Fprintln(w, tui.Dim(long))
|
||||
}
|
||||
|
||||
// Keep the feedback entry last: everything above it is operational guidance
|
||||
// an agent acts on, while the survey is addressed to human readers who
|
||||
// scroll to the end.
|
||||
_, _ = fmt.Fprintln(w)
|
||||
renderRootFeedback(w)
|
||||
}
|
||||
|
||||
// renderRootFeedback prints the user-experience survey entry. The URL occupies
|
||||
// its own line and is never wrapped or padded through a tabwriter: it is longer
|
||||
// than the help rule width, and breaking it would stop terminals from
|
||||
// recognizing it as a clickable hyperlink. Soft wrapping performed by the
|
||||
// terminal itself keeps the link intact.
|
||||
//
|
||||
// The label is intentionally not routed through i18n. Everything surrounding it
|
||||
// in this listing — service descriptions, utility descriptions, global flag
|
||||
// usage — is hardcoded Chinese, so translating this one line would render it in
|
||||
// English on any host whose LANG is not zh_*, leaving a single English line
|
||||
// inside an otherwise Chinese screen.
|
||||
func renderRootFeedback(w io.Writer) {
|
||||
_, _ = fmt.Fprintln(w, tui.Section("Feedback:"))
|
||||
_, _ = fmt.Fprintf(w, " %s %s\n", tui.Bullet(), tui.Dim("使用体验反馈问卷(1 分钟)"))
|
||||
_, _ = fmt.Fprintf(w, " %s\n", tui.Cyan(feedbackFormURL))
|
||||
}
|
||||
|
||||
func renderRootGlobalFlags(root *cobra.Command) {
|
||||
|
||||
@@ -53,6 +53,49 @@ func TestRootHelpHidesCompatibilityOnlyCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootHelpShowsFeedbackEntry(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("root help: %v\n%s", err, out.String())
|
||||
}
|
||||
help := out.String()
|
||||
// The label stays Chinese regardless of the host locale: the rest of this
|
||||
// listing is hardcoded Chinese, so a translated label would show up as a
|
||||
// lone English line on any host whose LANG is not zh_*.
|
||||
for _, want := range []string{"Feedback:", "使用体验反馈问卷", feedbackFormURL} {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("root help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
// The form URL is longer than the help rule width; it must stay on a
|
||||
// single unbroken line so terminals keep recognizing it as a hyperlink.
|
||||
if !strings.Contains(help, "\n "+feedbackFormURL+"\n") {
|
||||
t.Fatalf("feedback URL must occupy one unwrapped line:\n%s", help)
|
||||
}
|
||||
}
|
||||
|
||||
// The feedback entry is deliberately root-only: this CLI is driven mostly by
|
||||
// AI agents, and repeating a survey link in every subcommand help would be
|
||||
// pure context noise. Guard the boundary so a future refactor cannot move the
|
||||
// rendering into the shared subcommand help path unnoticed.
|
||||
func TestSubcommandHelpOmitsFeedbackEntry(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"chat", "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat help: %v\n%s", err, out.String())
|
||||
}
|
||||
if help := out.String(); strings.Contains(help, feedbackFormURL) {
|
||||
t.Fatalf("subcommand help must not carry the feedback URL:\n%s", help)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
@@ -94,8 +137,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 --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{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>"},
|
||||
} {
|
||||
command := NewRootCommand()
|
||||
command.SilenceErrors = true
|
||||
@@ -419,6 +462,53 @@ func TestCalendarEventListDryRunPreviewsOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventShareInfoDryRunPreviewsOnly(t *testing.T) {
|
||||
got, err := executeRootCaptureStdout(t, []string{
|
||||
"--dry-run", "calendar", "event", "share-info",
|
||||
"--id", "EVT_001",
|
||||
"--language", "zh-CN",
|
||||
"--calendar-id", "primary",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("calendar event share-info --dry-run error = %v\n%s", err, got)
|
||||
}
|
||||
for _, want := range []string{"get_event_share_info", "eventId", "EVT_001", "zh-CN", "primary"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("calendar event share-info dry-run output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventShareInfoRequiresEventID(t *testing.T) {
|
||||
got, err := executeRootCaptureStdout(t, []string{
|
||||
"--dry-run", "calendar", "event", "share-info",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("calendar event share-info without --id: expected error, got nil\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "\"executed\": true") {
|
||||
t.Fatalf("share-info without --id must not execute:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventShareInfoOmitsOptionalArgs(t *testing.T) {
|
||||
got, err := executeRootCaptureStdout(t, []string{
|
||||
"--dry-run", "calendar", "event", "share-info",
|
||||
"--id", "EVT_001",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("calendar event share-info --dry-run with only --id error = %v\n%s", err, got)
|
||||
}
|
||||
if !strings.Contains(got, "\"eventId\"") {
|
||||
t.Fatalf("calendar event share-info dry-run output missing eventId:\n%s", got)
|
||||
}
|
||||
for _, unwanted := range []string{"\"calendarId\"", "\"language\""} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Fatalf("calendar event share-info dry-run with only --id should not contain %q:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ func TestPublicRootDirectExecuteClosesSinkOnHandlerError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
|
||||
@@ -230,13 +230,20 @@ func TestOutputSinkUnifiedPublicationFailureFailsAndLeavesNoFinalFile(t *testing
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.json")
|
||||
t.Chdir(dir)
|
||||
// Keep argv portable: an absolute Windows path contains a volume colon,
|
||||
// which the CLI intentionally rejects as unsafe user-supplied output.
|
||||
target := "result.json"
|
||||
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originalInfo, err := os.Stat(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Args = []string{"dws", "atomic-output-unified-publication", "--output", target, "--format", "json"}
|
||||
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
@@ -277,7 +284,7 @@ func TestExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing
|
||||
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 0 {
|
||||
t.Fatalf("rolled-back success leaked to stdout: %s", stdout.String())
|
||||
}
|
||||
assertOutputFile(t, target, "original", 0o640)
|
||||
assertOutputFile(t, target, "original", originalInfo.Mode().Perm())
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
|
||||
@@ -400,6 +400,7 @@ func schemaContractPayloadForBoundCanonicals(t *testing.T, root *cobra.Command,
|
||||
func TestChatSchemaSeparatesSendAndReply(t *testing.T) {
|
||||
snapshot := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(),
|
||||
"chat.send_personal_message",
|
||||
"chat.send_robot_message",
|
||||
"chat.reply_personal_message",
|
||||
)
|
||||
|
||||
@@ -418,6 +419,19 @@ func TestChatSchemaSeparatesSendAndReply(t *testing.T) {
|
||||
if _, exists := snapshot.Tools["chat.upload_conversation_file"]; exists {
|
||||
t.Fatal("downlined chat file upload must not be advertised in Schema")
|
||||
}
|
||||
|
||||
botReply := snapshot.Tools["chat.send_robot_message"]
|
||||
botParams := schemaContractMap(botReply["parameters"])
|
||||
if got := schemaContractString(botParams["reply"]["property"]); got != "referenceOpenMessageId" {
|
||||
t.Fatalf("bot --reply property = %q", got)
|
||||
}
|
||||
if got := schemaContractString(botParams["ref-sender"]["property"]); got != "srcMsgSendOpenDingTalkId" {
|
||||
t.Fatalf("bot --ref-sender property = %q", got)
|
||||
}
|
||||
if got, ok := botParams["title"]["required"].(bool); !ok || got {
|
||||
t.Fatalf("bot --title required = %#v, want false for conditional Markdown input", botParams["title"]["required"])
|
||||
}
|
||||
assertSchemaContractConstraintGroup(t, botReply, "require_together", []string{"reply", "ref-sender"})
|
||||
}
|
||||
|
||||
func TestCalendarAttendeeDeleteSchemaMatchesRuntimeGate(t *testing.T) {
|
||||
|
||||
@@ -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) {
|
||||
@@ -140,6 +140,78 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
assertChatCatalogCompleteLeafContracts(t)
|
||||
}
|
||||
|
||||
func TestDeliveryWikiSpaceSearchDeclaresCompatibilityAdapter(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "wiki +space-search")
|
||||
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
|
||||
t.Fatalf("wiki +space-search interface_mode = %q, want composite", got)
|
||||
}
|
||||
reason := schemaContractString(leaf["interface_reason"])
|
||||
for _, fragment := range []string{"query/limit", "search_wikiSpaces.keyword/pageSize", "versioned Schema migration"} {
|
||||
if !strings.Contains(reason, fragment) {
|
||||
t.Fatalf("wiki +space-search interface_reason = %q, want fragment %q", reason, fragment)
|
||||
}
|
||||
}
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
for name, want := range map[string]string{"query": "query", "limit": "limit"} {
|
||||
parameter := parameters[name]
|
||||
if parameter == nil {
|
||||
t.Fatalf("wiki +space-search missing --%s parameter: %#v", name, parameters)
|
||||
}
|
||||
if got := schemaContractString(parameter["property"]); got != want {
|
||||
t.Fatalf("wiki +space-search --%s property = %q, want compatibility value %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllShortcutsWikiSchemaExamplesIncludeRequiredParameters(t *testing.T) {
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
|
||||
checked := 0
|
||||
for _, declared := range shortcut.All() {
|
||||
if declared.Service != "wiki" || declared.UserDefined || !shortcut.InPublicCatalog(declared.Service, declared.Command) {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
canonical := shortcutSchemaCanonical(declared)
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
t.Fatalf("delivery schema --all is missing %s", canonical)
|
||||
}
|
||||
examples := schemaContractStringSlice(tool["examples"])
|
||||
if len(examples) == 0 {
|
||||
t.Fatalf("%s has no delivered examples", canonical)
|
||||
}
|
||||
for _, example := range examples {
|
||||
argv, err := cli.ParseAgentExampleArgv(example)
|
||||
if err != nil {
|
||||
t.Fatalf("%s example %q is not valid argv: %v", canonical, example, err)
|
||||
}
|
||||
for _, flag := range declared.Flags {
|
||||
if !flag.Required {
|
||||
continue
|
||||
}
|
||||
names := append([]string{flag.Name}, flag.Aliases...)
|
||||
if !schemaExampleHasLongFlag(argv, names...) {
|
||||
t.Errorf("%s example %q is missing required --%s", canonical, example, flag.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked != 20 {
|
||||
t.Fatalf("checked Wiki shortcut examples = %d, want 20", checked)
|
||||
}
|
||||
}
|
||||
|
||||
func schemaExampleHasLongFlag(argv []string, names ...string) bool {
|
||||
for _, argument := range argv {
|
||||
for _, name := range names {
|
||||
if argument == "--"+name || strings.HasPrefix(argument, "--"+name+"=") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func assertSchemaSummarySafety(
|
||||
t testing.TB,
|
||||
summaries map[string]map[string]any,
|
||||
@@ -207,7 +279,11 @@ 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) {
|
||||
|
||||
@@ -103,7 +103,7 @@ func installSignalExecuteSeams(t *testing.T, unified bool, stdout, stderr io.Wri
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
signal syscall.Signal
|
||||
@@ -197,7 +197,7 @@ func TestSignalAfterFailedEmissionAttemptPreservesPublicationExitCode(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
@@ -229,7 +229,7 @@ func TestSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
const maxTelemetryErrorRunes = 200
|
||||
|
||||
var (
|
||||
telemetryUnknownFlagPattern = regexp.MustCompile(`(?i)unknown flag:\s*(--[a-z0-9][a-z0-9-]*)`)
|
||||
telemetryAuthPattern = regexp.MustCompile(`(?i)\b(?:bearer|basic)\s+[a-z0-9._~+/=-]+`)
|
||||
telemetrySensitiveFlag = regexp.MustCompile(`(?i)(--(?:access-token|refresh-token|token|client-secret|client-id|password|api-key|authorization|cookie|credential|secret))(?:=|\s+)\S+`)
|
||||
telemetrySensitiveValue = regexp.MustCompile(`(?i)\b(authorization|client[-_]?secret|client[-_]?id|access[-_]?token|refresh[-_]?token|api[-_]?key|password|cookie|credential|secret|token)\b\s*[:=]\s*[^\s,;]+`)
|
||||
telemetryURLPattern = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s]+`)
|
||||
telemetryJSONPattern = regexp.MustCompile(`(?s)[\[{].*[\]}]`)
|
||||
telemetryUnixPathPattern = regexp.MustCompile(`(^|[\s=:])(?:~/|/)[^\s]+`)
|
||||
telemetryWindowsPathPattern = regexp.MustCompile(`(?i)(^|[\s=])[a-z]:[\\/][^\s]+`)
|
||||
telemetryRelativePathPattern = regexp.MustCompile(`(^|[\s=:])\.\.?/[^\s]+`)
|
||||
telemetryEmailPattern = regexp.MustCompile(`(?i)\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b`)
|
||||
telemetryPhonePattern = regexp.MustCompile(`\b\+?\d[\d -]{7,}\d\b`)
|
||||
telemetryOpaqueTokenPattern = regexp.MustCompile(`\b[a-zA-Z0-9_-]{16,}\b`)
|
||||
)
|
||||
|
||||
func telemetryErrorSummary(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
var patError *apperrors.PATError
|
||||
if stderrors.As(err, &patError) {
|
||||
return "permission error"
|
||||
}
|
||||
var rawError apperrors.RawStderrError
|
||||
if stderrors.As(err, &rawError) {
|
||||
return "raw stderr error"
|
||||
}
|
||||
if isUnknownCommandError(err) {
|
||||
return "unknown command"
|
||||
}
|
||||
if match := telemetryUnknownFlagPattern.FindStringSubmatch(err.Error()); len(match) == 2 {
|
||||
return "unknown flag: " + match[1]
|
||||
}
|
||||
return sanitizeTelemetryErrorText(err.Error())
|
||||
}
|
||||
|
||||
func telemetryPanicMessages(value any) (display, summary string) {
|
||||
return fmt.Sprintf("internal panic: %v", value), "internal panic"
|
||||
}
|
||||
|
||||
func sanitizeTelemetryErrorText(message string) string {
|
||||
message = output.SanitizeForTerminal(message)
|
||||
message = telemetryAuthPattern.ReplaceAllString(message, "<credential>")
|
||||
message = telemetrySensitiveFlag.ReplaceAllString(message, "$1=<redacted>")
|
||||
message = telemetrySensitiveValue.ReplaceAllString(message, "$1=<redacted>")
|
||||
message = telemetryURLPattern.ReplaceAllString(message, "<url>")
|
||||
message = telemetryJSONPattern.ReplaceAllString(message, "<payload>")
|
||||
message = redactTelemetryQuotedText(message)
|
||||
message = telemetryUnixPathPattern.ReplaceAllString(message, "$1<path>")
|
||||
message = telemetryWindowsPathPattern.ReplaceAllString(message, "$1<path>")
|
||||
message = telemetryRelativePathPattern.ReplaceAllString(message, "$1<path>")
|
||||
message = telemetryEmailPattern.ReplaceAllString(message, "<email>")
|
||||
message = telemetryPhonePattern.ReplaceAllString(message, "<phone>")
|
||||
message = telemetryOpaqueTokenPattern.ReplaceAllStringFunc(message, func(value string) string {
|
||||
var hasLetter, hasDigit bool
|
||||
for _, r := range value {
|
||||
hasLetter = hasLetter || unicode.IsLetter(r)
|
||||
hasDigit = hasDigit || unicode.IsDigit(r)
|
||||
}
|
||||
if hasLetter && hasDigit {
|
||||
return "<id>"
|
||||
}
|
||||
return value
|
||||
})
|
||||
message = strings.Join(strings.Fields(message), " ")
|
||||
return truncateTelemetryText(message, maxTelemetryErrorRunes)
|
||||
}
|
||||
|
||||
func redactTelemetryQuotedText(message string) string {
|
||||
var result strings.Builder
|
||||
runes := []rune(message)
|
||||
for index := 0; index < len(runes); {
|
||||
quote := runes[index]
|
||||
if quote != '\'' && quote != '"' && quote != '`' {
|
||||
result.WriteRune(quote)
|
||||
index++
|
||||
continue
|
||||
}
|
||||
result.WriteRune(quote)
|
||||
result.WriteString("<redacted>")
|
||||
index++
|
||||
escaped := false
|
||||
for index < len(runes) {
|
||||
current := runes[index]
|
||||
index++
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if current == '\\' && quote != '`' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if current == quote {
|
||||
result.WriteRune(quote)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func truncateTelemetryText(message string, maxRunes int) string {
|
||||
if maxRunes <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(message)
|
||||
if len(runes) <= maxRunes {
|
||||
return message
|
||||
}
|
||||
if maxRunes <= 3 {
|
||||
return string(runes[:maxRunes])
|
||||
}
|
||||
return string(runes[:maxRunes-3]) + "..."
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
type telemetryRawError string
|
||||
|
||||
func (e telemetryRawError) Error() string { return string(e) }
|
||||
func (e telemetryRawError) RawStderr() string { return string(e) }
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryErrorSummaryFixedFamilies(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{name: "nil", want: ""},
|
||||
{name: "PAT", err: &apperrors.PATError{RawJSON: `{"token":"secret"}`}, want: "permission error"},
|
||||
{name: "raw stderr", err: telemetryRawError("raw secret"), want: "raw stderr error"},
|
||||
{name: "unknown command", err: errors.New(`unknown command "secret-value" for "dws"`), want: "unknown command"},
|
||||
{name: "unknown flag", err: errors.New("unknown flag: --token=secret-value"), want: "unknown flag: --token"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := telemetryErrorSummary(tc.err); got != tc.want {
|
||||
t.Fatalf("telemetryErrorSummary() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSanitizeTelemetryErrorText(t *testing.T) {
|
||||
message := "\x1b[31mfailed\x1b[0m " +
|
||||
"--client-secret very-secret " +
|
||||
"--access-token access-secret " +
|
||||
"Authorization: Bearer abcdefghijklmnop1234 " +
|
||||
"url=https://example.test/path?token=secret " +
|
||||
`body={"access_token":"secret"} ` +
|
||||
`user="Alice" email=alice@example.test phone=13800138000 ` +
|
||||
"path=/Users/alice/private.txt relative=./private/secrets.txt id=abcDEF1234567890XYZ"
|
||||
got := sanitizeTelemetryErrorText(message)
|
||||
for _, secret := range []string{
|
||||
"very-secret", "access-secret", "abcdefghijklmnop1234", "example.test", "access_token",
|
||||
"Alice", "alice@example.test", "13800138000", "/Users/alice", "abcDEF1234567890XYZ", "\x1b",
|
||||
"./private/secrets.txt",
|
||||
} {
|
||||
if strings.Contains(got, secret) {
|
||||
t.Fatalf("sanitized telemetry error leaked %q: %q", secret, got)
|
||||
}
|
||||
}
|
||||
for _, marker := range []string{"failed", "<redacted>", "<url>", "<payload>", "<path>", "<id>"} {
|
||||
if !strings.Contains(got, marker) {
|
||||
t.Fatalf("sanitized telemetry error missing %q: %q", marker, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryErrorTruncationAndPanic(t *testing.T) {
|
||||
message := strings.Repeat("错", maxTelemetryErrorRunes+1)
|
||||
got := sanitizeTelemetryErrorText(message)
|
||||
if len([]rune(got)) != maxTelemetryErrorRunes || !strings.HasSuffix(got, "...") {
|
||||
t.Fatalf("truncated telemetry error rune length = %d suffix = %q", len([]rune(got)), got[len(got)-3:])
|
||||
}
|
||||
display, summary := telemetryPanicMessages("token-secret")
|
||||
if display != "internal panic: token-secret" || summary != "internal panic" || strings.Contains(summary, "token-secret") {
|
||||
t.Fatalf("panic messages = display %q summary %q", display, summary)
|
||||
}
|
||||
if got := truncateTelemetryText("value", 0); got != "" {
|
||||
t.Fatalf("zero-limit truncation = %q", got)
|
||||
}
|
||||
if got := truncateTelemetryText("value", 3); got != "val" {
|
||||
t.Fatalf("short-limit truncation = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryErrorEscapesAndOpaqueWords(t *testing.T) {
|
||||
const opaqueWord = "abcdefghijklmnop"
|
||||
got := sanitizeTelemetryErrorText(`failed "quoted \"value" ` + opaqueWord)
|
||||
if strings.Contains(got, "value") || !strings.Contains(got, opaqueWord) {
|
||||
t.Fatalf("escaped quote sanitization = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
)
|
||||
|
||||
// TelemetryIdentity is the privacy-reviewed subset of the local authentication
|
||||
// record that may be attached to a CLI execution event.
|
||||
type TelemetryIdentity struct {
|
||||
UserID string
|
||||
UserName string
|
||||
CorpID string
|
||||
}
|
||||
|
||||
var telemetryResolveProfileMetadata = authpkg.ResolveProfileMetadataReadOnly
|
||||
|
||||
// ResolveTelemetryIdentity returns a pre-execution snapshot of the identity
|
||||
// selected by args. Multi-profile executions are attributed to the current
|
||||
// default profile. Resolution is deliberately best-effort: telemetry must not
|
||||
// refresh credentials or change command behavior when local auth data is
|
||||
// missing, invalid, or unreadable.
|
||||
func ResolveTelemetryIdentity(args []string) (identity TelemetryIdentity) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
identity = TelemetryIdentity{}
|
||||
}
|
||||
}()
|
||||
|
||||
selector, specified, valid := preparseProfileSelection(args)
|
||||
if specified && !valid {
|
||||
return TelemetryIdentity{}
|
||||
}
|
||||
profile, err := resolveTelemetryProfileMetadata(defaultConfigDir(), selector)
|
||||
if err != nil || profile == nil {
|
||||
return TelemetryIdentity{}
|
||||
}
|
||||
return TelemetryIdentity{
|
||||
UserID: strings.TrimSpace(profile.UserID),
|
||||
UserName: strings.TrimSpace(profile.UserName),
|
||||
CorpID: strings.TrimSpace(profile.CorpID),
|
||||
}
|
||||
}
|
||||
|
||||
func resolveTelemetryProfileMetadata(configDir, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" || !strings.Contains(selector, ",") {
|
||||
return telemetryResolveProfileMetadata(configDir, selector)
|
||||
}
|
||||
|
||||
// A local profile name may itself contain a comma. Match the runtime
|
||||
// resolver by trying the full selector before interpreting it as CSV.
|
||||
if profile, err := telemetryResolveProfileMetadata(configDir, selector); err == nil && profile != nil {
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
for _, part := range strings.Split(selector, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
return nil, fmt.Errorf("--profile contains an empty profile selector: %q", selector)
|
||||
}
|
||||
profile, err := telemetryResolveProfileMetadata(configDir, part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if profile == nil {
|
||||
return nil, fmt.Errorf("profile %q not found", part)
|
||||
}
|
||||
}
|
||||
return telemetryResolveProfileMetadata(configDir, "")
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityProfileSelection(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", "/telemetry-config")
|
||||
profiles := map[string]*authpkg.ProfileMetadata{
|
||||
"": {UserID: " default-user ", UserName: " Default User ", CorpID: " default-corp "},
|
||||
"corp-a": {UserID: "user-a", UserName: "Alice", CorpID: "corp-a"},
|
||||
"corp-b": {UserID: "user-b", UserName: "Bob", CorpID: "corp-b"},
|
||||
"alpha,beta": {UserID: "comma-user", UserName: "Comma User", CorpID: "comma-corp"},
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
profiles map[string]*authpkg.ProfileMetadata
|
||||
wantCalls []string
|
||||
want TelemetryIdentity
|
||||
}{
|
||||
{name: "default profile", args: []string{"version"}, profiles: profiles, wantCalls: []string{""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}},
|
||||
{name: "single profile", args: []string{"--profile", "corp-a", "version"}, profiles: profiles, wantCalls: []string{"corp-a"}, want: TelemetryIdentity{UserID: "user-a", UserName: "Alice", CorpID: "corp-a"}},
|
||||
{name: "equals form after command", args: []string{"version", "--profile=corp-b"}, profiles: profiles, wantCalls: []string{"corp-b"}, want: TelemetryIdentity{UserID: "user-b", UserName: "Bob", CorpID: "corp-b"}},
|
||||
{name: "last repeated profile", args: []string{"--profile", "corp-a", "version", "--profile=corp-b"}, profiles: profiles, wantCalls: []string{"corp-b"}, want: TelemetryIdentity{UserID: "user-b", UserName: "Bob", CorpID: "corp-b"}},
|
||||
{name: "comma profile name", args: []string{"--profile", "alpha,beta", "version"}, profiles: profiles, wantCalls: []string{"alpha,beta"}, want: TelemetryIdentity{UserID: "comma-user", UserName: "Comma User", CorpID: "comma-corp"}},
|
||||
{name: "multi profile uses default", args: []string{"--profile", "corp-a,corp-b", "version"}, profiles: profiles, wantCalls: []string{"corp-a,corp-b", "corp-a", "corp-b", ""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}},
|
||||
{name: "unquoted multi profile", args: []string{"--profile", "corp-a,", "corp-b", "version"}, profiles: profiles, wantCalls: []string{"corp-a,corp-b", "corp-a", "corp-b", ""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var calls []string
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(configDir, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
if configDir != "/telemetry-config" {
|
||||
t.Fatalf("resolver config dir = %q", configDir)
|
||||
}
|
||||
calls = append(calls, selector)
|
||||
profile := tc.profiles[selector]
|
||||
if profile == nil {
|
||||
return nil, errors.New("profile not found")
|
||||
}
|
||||
clone := *profile
|
||||
return &clone, nil
|
||||
})
|
||||
|
||||
if got := ResolveTelemetryIdentity(tc.args); got != tc.want {
|
||||
t.Fatalf("ResolveTelemetryIdentity() = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
if !reflect.DeepEqual(calls, tc.wantCalls) {
|
||||
t.Fatalf("metadata selectors = %#v, want %#v", calls, tc.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityRejectsMissingProfileValue(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"version", "--profile"},
|
||||
{"--profile=corp-a", "version", "--profile="},
|
||||
{"--profile", "--debug", "version"},
|
||||
} {
|
||||
t.Run(strings.Join(args, "_"), func(t *testing.T) {
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(string, string) (*authpkg.ProfileMetadata, error) {
|
||||
t.Fatal("invalid profile syntax attempted metadata resolution")
|
||||
return nil, nil
|
||||
})
|
||||
if got := ResolveTelemetryIdentity(args); got != (TelemetryIdentity{}) {
|
||||
t.Fatalf("invalid profile identity = %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityFailsClosed(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", "/telemetry-config")
|
||||
fail := errors.New("metadata unavailable")
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
resolve func(string, string) (*authpkg.ProfileMetadata, error)
|
||||
}{
|
||||
{name: "read error", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return nil, fail }},
|
||||
{name: "missing profile", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return nil, nil }},
|
||||
{name: "empty fields", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return &authpkg.ProfileMetadata{}, nil }},
|
||||
{name: "resolver panic", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { panic("metadata failure") }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, tc.resolve)
|
||||
if got := ResolveTelemetryIdentity(nil); got != (TelemetryIdentity{}) {
|
||||
t.Fatalf("failed-closed identity = %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityRejectsInvalidMultiProfile(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", "/telemetry-config")
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(_ string, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
if selector == "corp-a" {
|
||||
return &authpkg.ProfileMetadata{UserID: "user-a", CorpID: "corp-a"}, nil
|
||||
}
|
||||
return nil, errors.New("profile not found")
|
||||
})
|
||||
if got := ResolveTelemetryIdentity([]string{"--profile", "corp-a,missing", "version"}); got != (TelemetryIdentity{}) {
|
||||
t.Fatalf("invalid multi-profile identity = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryProfileMetadataRejectsMalformedMulti(t *testing.T) {
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(_ string, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
switch selector {
|
||||
case "corp-a,,corp-b":
|
||||
return nil, errors.New("not a literal profile")
|
||||
case "corp-a":
|
||||
return &authpkg.ProfileMetadata{UserID: "user-a"}, nil
|
||||
case "missing":
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, errors.New("unexpected selector")
|
||||
}
|
||||
})
|
||||
if _, err := resolveTelemetryProfileMetadata("/config", "corp-a,,corp-b"); err == nil || !strings.Contains(err.Error(), "empty profile selector") {
|
||||
t.Fatalf("empty multi-profile selector error = %v", err)
|
||||
}
|
||||
if _, err := resolveTelemetryProfileMetadata("/config", "corp-a,missing"); err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("missing multi-profile selector error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
func TestToolSearchDeliveryActionSiblingRouting(t *testing.T) {
|
||||
registerSchemaRuntimeDelivery()
|
||||
// This test pins the action_v1 structured sibling routing itself, decoupled
|
||||
// from the production default (now the fielded BM25 ensemble, which has no
|
||||
// structured multiplier and cannot right-size these sibling queries).
|
||||
config := cli.DefaultToolSearchConfig()
|
||||
config.LexicalAlgorithm = cli.ToolSearchLexicalBM25Action
|
||||
engine, err := cli.NewDeliveryToolSearchEngineWithConfig(config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDeliveryToolSearchEngineWithConfig() error = %v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
query string
|
||||
gold string
|
||||
}{
|
||||
{query: "创建一场新的会议日程", gold: "calendar.create_calendar_event"},
|
||||
{query: "给钉盘文件添加同事阅读权限", gold: "drive.add_permission"},
|
||||
// The new main Catalog declares shortcut_fetch as a reviewed equivalent
|
||||
// read surface, so this query has graded-equivalent Top-1 answers.
|
||||
{query: "读取钉钉在线文字文档正文", gold: "doc.shortcut_fetch|doc.get_document_content"},
|
||||
{query: "查询当前用户待审批任务 ID", gold: "oa.list_pending_tasks"},
|
||||
{query: "同意指定审批任务", gold: "oa.approve_processInstance"},
|
||||
{query: "按群名关键词搜索群聊", gold: "chat.search_groups"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.gold, func(t *testing.T) {
|
||||
response, searchErr := engine.Search(context.Background(), cli.ToolSearchRequest{Query: test.query, Limit: 5})
|
||||
if searchErr != nil {
|
||||
t.Fatalf("Search() error = %v", searchErr)
|
||||
}
|
||||
if len(response.Candidates) == 0 || !containsCanonicalAlternative(test.gold, response.Candidates[0].CanonicalPath) {
|
||||
t.Fatalf("Search(%q) candidates = %#v, want %s first", test.query, response.Candidates, test.gold)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func containsCanonicalAlternative(alternatives, candidate string) bool {
|
||||
for _, alternative := range strings.Split(alternatives, "|") {
|
||||
if candidate == alternative {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestToolSearchDeliveryDiagnosticTrustAndChineseSlices(t *testing.T) {
|
||||
registerSchemaRuntimeDelivery()
|
||||
workflows := []cli.ToolSearchWorkflowEvaluationCase{{
|
||||
ID: "chat-send-file-and-check-read",
|
||||
Query: "给群里发文件,并确认群成员是否已读",
|
||||
Subqueries: []string{"以当前用户身份给群聊发送本地文件", "查询指定消息的已读状态和人员"},
|
||||
Required: []string{"chat.send_personal_message", "chat.query_msg_read_status"},
|
||||
}}
|
||||
report, err := cli.BuildDeliveryToolSearchDiagnosticComparison(context.Background(), workflows)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDeliveryToolSearchDiagnosticComparison() error = %v", err)
|
||||
}
|
||||
// Do not pin absolute Catalog-size counts here: they go stale when main
|
||||
// adds tools. Enforce the size-independent trust contract below; observed
|
||||
// counts are logged for CI forensics and live in the generated comparison
|
||||
// JSON (make generate-tool-search-comparison).
|
||||
t.Logf("intent proxy = %+v excluded_over_budget=%d", report.IntentProxy, report.IntentExcludedOverBudget)
|
||||
t.Logf("language slices = %+v", report.IntentLanguageSlices)
|
||||
t.Logf("context comparison = %+v", report.Context)
|
||||
if report.IntentProxy.Cases == 0 {
|
||||
t.Fatal("intent proxy population is empty; the diagnostic set did not build")
|
||||
}
|
||||
if report.IntentProxy.ZeroResultRate != 0 {
|
||||
t.Fatalf("intent proxy zero-result rate = %v, want every reviewed intent to retrieve something", report.IntentProxy.ZeroResultRate)
|
||||
}
|
||||
for _, slice := range []string{"chinese_only", "mixed_chinese_ascii"} {
|
||||
if report.IntentLanguageSlices[slice].Cases == 0 {
|
||||
t.Fatalf("language slice %q is empty: %+v", slice, report.IntentLanguageSlices)
|
||||
}
|
||||
}
|
||||
identity := report.Trust.Identity
|
||||
// Identity coverage must stay exhaustive over the whole Catalog. Deriving the
|
||||
// expected count from the same report keeps that contract enforced without
|
||||
// hardcoding a tool count.
|
||||
if identity.CanonicalCases != report.Context.ToolCount || identity.NFKCCases != report.Context.ToolCount {
|
||||
t.Fatalf("identity coverage = %+v, want one case per %d Catalog tools", identity, report.Context.ToolCount)
|
||||
}
|
||||
if identity.CanonicalPassRate != 1 || identity.PrimaryCLIPassRate != 1 ||
|
||||
identity.AliasCases == 0 || identity.AliasPassRate != 1 || identity.NFKCPassRate != 1 ||
|
||||
identity.ExactFilteredPassRate != 1 {
|
||||
t.Fatalf("identity trust = %+v", identity)
|
||||
}
|
||||
integrity := report.Trust.Integrity
|
||||
if integrity.CatalogBindingFailures != 0 || integrity.UnknownCandidateCount != 0 || integrity.IneligibleCandidateCount != 0 || integrity.ResponseBudgetViolations != 0 {
|
||||
t.Fatalf("integrity violations = %+v", integrity)
|
||||
}
|
||||
if report.Context.ToolCount == 0 || report.Context.ReductionVsFullSchema < 0.99 {
|
||||
t.Fatalf("context comparison = %+v", report.Context)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaSearchInspectCatalogVersionContract(t *testing.T) {
|
||||
registerSchemaRuntimeDelivery()
|
||||
search := cli.NewSchemaCommand()
|
||||
var searchOutput bytes.Buffer
|
||||
search.SetOut(&searchOutput)
|
||||
search.SetArgs([]string{"search", "--query", "chat.query_msg_read_status"})
|
||||
if err := search.Execute(); err != nil {
|
||||
t.Fatalf("schema search execute: %v", err)
|
||||
}
|
||||
var response cli.ToolSearchResponse
|
||||
if err := json.Unmarshal(searchOutput.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode search response: %v", err)
|
||||
}
|
||||
if response.Strategy != "exact_guard" || len(response.Candidates) != 1 || response.Candidates[0].CanonicalPath != "chat.query_msg_read_status" {
|
||||
t.Fatalf("search response = %#v", response)
|
||||
}
|
||||
|
||||
inspect := cli.NewSchemaCommand()
|
||||
var inspectOutput bytes.Buffer
|
||||
inspect.SetOut(&inspectOutput)
|
||||
inspect.SetArgs([]string{
|
||||
"chat.query_msg_read_status", "--compact",
|
||||
"--expected-source-hash", response.Catalog.SourceHash,
|
||||
"--expected-surface-hash", response.Catalog.SurfaceHash,
|
||||
})
|
||||
if err := inspect.Execute(); err != nil {
|
||||
t.Fatalf("schema inspect execute: %v", err)
|
||||
}
|
||||
var payload cli.SchemaInspectV1Response
|
||||
if err := json.Unmarshal(inspectOutput.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode inspect response: %v", err)
|
||||
}
|
||||
if payload.Version != "schema-inspect.v1" || payload.Catalog != response.Catalog || payload.ToolSpec["canonical_path"] != "chat.query_msg_read_status" {
|
||||
t.Fatalf("inspect envelope = %#v", payload)
|
||||
}
|
||||
|
||||
stale := cli.NewSchemaCommand()
|
||||
stale.SilenceErrors = true
|
||||
stale.SilenceUsage = true
|
||||
stale.SetArgs([]string{
|
||||
"chat.query_msg_read_status", "--compact",
|
||||
"--expected-source-hash", "sha256:stale",
|
||||
"--expected-surface-hash", response.Catalog.SurfaceHash,
|
||||
})
|
||||
err := stale.Execute()
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "catalog_changed" || typed.Category != apperrors.CategoryDiscovery {
|
||||
t.Fatalf("stale inspect error = %#v", err)
|
||||
}
|
||||
|
||||
incomplete := cli.NewSchemaCommand()
|
||||
incomplete.SilenceErrors = true
|
||||
incomplete.SilenceUsage = true
|
||||
incomplete.SetArgs([]string{
|
||||
"chat.query_msg_read_status",
|
||||
"--expected-source-hash", response.Catalog.SourceHash,
|
||||
})
|
||||
err = incomplete.Execute()
|
||||
if !errors.As(err, &typed) || typed.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("incomplete inspect version error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaSearchRejectsIgnoredGlobalOutputFlags(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"schema", "search", "--query", "查询群消息已读状态", "--fields", "canonical_path"},
|
||||
{"schema", "search", "--query", "查询群消息已读状态", "--jq", ".candidates"},
|
||||
{"schema", "search", "--query", "查询群消息已读状态", "--format", "pretty"},
|
||||
} {
|
||||
root := NewRootCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("args %v error = %#v, want validation", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkToolSearchDeliveryChineseQuery measures the shipped search path
|
||||
// against the complete declaration-assembled Catalog. The smaller benchmark
|
||||
// in internal/cli intentionally uses a four-tool fixture and must not be used
|
||||
// as the release-scale latency or allocation number.
|
||||
func BenchmarkToolSearchDeliveryChineseQuery(b *testing.B) {
|
||||
registerSchemaRuntimeDelivery()
|
||||
engine, err := cli.NewDeliveryToolSearchEngineWithConfig(cli.DefaultToolSearchConfig())
|
||||
if err != nil {
|
||||
b.Fatalf("NewDeliveryToolSearchEngineWithConfig() error = %v", err)
|
||||
}
|
||||
request := cli.ToolSearchRequest{Query: "给群里发文件并确认消息已读", Limit: 5, CandidateLimit: 20}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for index := 0; index < b.N; index++ {
|
||||
if _, searchErr := engine.Search(context.Background(), request); searchErr != nil {
|
||||
b.Fatalf("Search() error = %v", searchErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkToolSearchDeliveryDecomposedWorkflow(b *testing.B) {
|
||||
registerSchemaRuntimeDelivery()
|
||||
engine, err := cli.NewDeliveryToolSearchEngineWithConfig(cli.DefaultToolSearchConfig())
|
||||
if err != nil {
|
||||
b.Fatalf("NewDeliveryToolSearchEngineWithConfig() error = %v", err)
|
||||
}
|
||||
request := cli.ToolSearchRequest{Query: "给群里发文件,并确认群成员是否已读", Limit: 5, CandidateLimit: 20}
|
||||
subqueries := []string{"以当前用户身份给群聊发送本地文件", "查询指定消息的已读状态和人员"}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for index := 0; index < b.N; index++ {
|
||||
if _, searchErr := engine.SearchSubqueries(context.Background(), subqueries, request); searchErr != nil {
|
||||
b.Fatalf("SearchSubqueries() error = %v", searchErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkToolSearchDeliveryIndexBuild(b *testing.B) {
|
||||
registerSchemaRuntimeDelivery()
|
||||
config := cli.DefaultToolSearchConfig()
|
||||
// Prime and cache declaration assembly so this benchmark isolates building
|
||||
// the Tool Search projection/index. Cold process startup is measured at the
|
||||
// release-binary boundary and reported separately.
|
||||
if _, err := cli.NewDeliveryToolSearchEngineWithConfig(config); err != nil {
|
||||
b.Fatalf("prime delivery Catalog: %v", err)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for index := 0; index < b.N; index++ {
|
||||
if _, err := cli.NewDeliveryToolSearchEngineWithConfig(config); err != nil {
|
||||
b.Fatalf("NewDeliveryToolSearchEngineWithConfig() error = %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProfileMetadata is the minimal, non-sensitive identity projection exposed to
|
||||
// telemetry callers. It intentionally excludes profile names, client IDs,
|
||||
// organization names, token material, and credential status.
|
||||
type ProfileMetadata struct {
|
||||
UserID string
|
||||
UserName string
|
||||
CorpID string
|
||||
}
|
||||
|
||||
// ResolveProfileMetadataReadOnly resolves one identity exclusively from the
|
||||
// non-sensitive profiles.json metadata. It deliberately avoids auth locks,
|
||||
// token stores, Keychain access, migrations, quarantine renames, and writes.
|
||||
// A missing metadata file or an empty current profile returns (nil, nil).
|
||||
func ResolveProfileMetadataReadOnly(configDir, selector string) (*ProfileMetadata, error) {
|
||||
data, err := profilesReadFile(ProfilesPath(configDir))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read profile metadata: %w", err)
|
||||
}
|
||||
|
||||
var cfg ProfilesConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse profile metadata: %w", err)
|
||||
}
|
||||
if cfg.Version > profilesMaxVersion {
|
||||
return nil, fmt.Errorf("profile metadata version %d is newer than supported version %d", cfg.Version, profilesMaxVersion)
|
||||
}
|
||||
normalizeProfilesConfig(&cfg)
|
||||
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" {
|
||||
selector = strings.TrimSpace(cfg.CurrentProfile)
|
||||
if selector == "" {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
profile, _, err := resolveProfileSelection("", &cfg, selector)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ProfileMetadata{
|
||||
UserID: profile.UserID,
|
||||
UserName: profile.UserName,
|
||||
CorpID: profile.CorpID,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageResolveProfileMetadataReadOnly(t *testing.T) {
|
||||
const metadata = `{
|
||||
"version": 3,
|
||||
"currentProfile": "corp-a:user-a",
|
||||
"profiles": [
|
||||
{"name":"alpha","corpId":"corp-a","userId":"user-a","userName":"Alice"},
|
||||
{"name":"beta","corpId":"corp-b","userId":"user-b","userName":"Bob"}
|
||||
]
|
||||
}`
|
||||
reads := 0
|
||||
testseam.Swap(t, &profilesReadFile, func(path string) ([]byte, error) {
|
||||
reads++
|
||||
if !strings.HasSuffix(path, profilesJSONFile) {
|
||||
t.Fatalf("metadata path = %q", path)
|
||||
}
|
||||
return []byte(metadata), nil
|
||||
})
|
||||
|
||||
current, err := ResolveProfileMetadataReadOnly("/config", "")
|
||||
if err != nil || current == nil || current.UserID != "user-a" || current.UserName != "Alice" || current.CorpID != "corp-a" {
|
||||
t.Fatalf("current metadata profile = %#v, %v", current, err)
|
||||
}
|
||||
explicit, err := ResolveProfileMetadataReadOnly("/config", "beta")
|
||||
if err != nil || explicit == nil || explicit.UserID != "user-b" || explicit.CorpID != "corp-b" {
|
||||
t.Fatalf("explicit metadata profile = %#v, %v", explicit, err)
|
||||
}
|
||||
if reads != 2 {
|
||||
t.Fatalf("profile metadata reads = %d, want 2", reads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveProfileMetadataReadOnlyFailsClosed(t *testing.T) {
|
||||
fail := errors.New("read failed")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
read func(string) ([]byte, error)
|
||||
wantErr string
|
||||
}{
|
||||
{name: "missing", read: func(string) ([]byte, error) { return nil, os.ErrNotExist }},
|
||||
{name: "read error", read: func(string) ([]byte, error) { return nil, fail }, wantErr: "read profile metadata"},
|
||||
{name: "corrupt", read: func(string) ([]byte, error) { return []byte("{"), nil }, wantErr: "parse profile metadata"},
|
||||
{name: "forward version", read: func(string) ([]byte, error) { return []byte(`{"version":999}`), nil }, wantErr: "newer than supported"},
|
||||
{name: "no current", read: func(string) ([]byte, error) { return []byte(`{"version":3,"profiles":[]}`), nil }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testseam.Swap(t, &profilesReadFile, tc.read)
|
||||
testseam.Swap(t, &profilesRename, func(string, string) error {
|
||||
t.Fatal("read-only metadata resolution attempted a quarantine rename")
|
||||
return nil
|
||||
})
|
||||
got, err := ResolveProfileMetadataReadOnly("/config", "")
|
||||
if tc.wantErr == "" {
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("read-only metadata = %#v, %v", got, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) || got != nil {
|
||||
t.Fatalf("read-only metadata = %#v, %v; want %q", got, err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveProfileMetadataReadOnlyRejectsUnknownSelector(t *testing.T) {
|
||||
testseam.Swap(t, &profilesReadFile, func(string) ([]byte, error) {
|
||||
return []byte(`{"version":3,"currentProfile":"corp-a:user-a","profiles":[{"name":"alpha","corpId":"corp-a","userId":"user-a"}]}`), nil
|
||||
})
|
||||
profile, err := ResolveProfileMetadataReadOnly("/config", "missing")
|
||||
if err == nil || profile != nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("unknown read-only profile = %#v, %v", profile, err)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,15 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// SchemaInspectV1Response is the versioned Search -> Inspect wire contract.
|
||||
// Legacy schema queries without expected hashes keep their historical flat
|
||||
// payload for human and script compatibility.
|
||||
type SchemaInspectV1Response struct {
|
||||
Version string `json:"version"`
|
||||
Catalog CatalogVersionRef `json:"catalog"`
|
||||
ToolSpec map[string]any `json:"tool_spec"`
|
||||
}
|
||||
|
||||
// schemaCommandCatalogError / payloads use deliverySchemaCatalog
|
||||
// (RegisterSchemaSourceRoot → ResolveSchemaBuild). There is no committed
|
||||
// Schema Catalog embed fallback.
|
||||
@@ -71,12 +80,20 @@ func NewSchemaCommand() *cobra.Command {
|
||||
Short: "渐进查看命令 Schema (产品 / 分组 / 工具参数)",
|
||||
Long: `查看当前可运行命令的 Schema 元数据。
|
||||
|
||||
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。普通 Agent 查询应使用 --compact:它按稳定字段白名单输出选参、约束、安全语义和已评审的返回契约。省略 --compact 的 full leaf 保留参数映射、接口绑定和 provenance,仅用于定向审计;--all 输出全部工具的完整 leaf Schema,用于审计/CI。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。普通 Agent 查询应使用 --compact:它按稳定字段白名单输出选参、约束、安全语义和已评审的返回契约。省略 --compact 的 full leaf 保留参数映射、接口绑定和 provenance,仅用于定向审计;--all 输出全部工具的完整 leaf Schema,用于审计/CI。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。
|
||||
|
||||
路径形态:多个位置参数会按空格拼接并作为 CLI path 解析,等价于加引号的形式和 --cli-path,例如 dws schema chat message read-status 与 dws schema "chat message read-status" 相同。canonical path 含点号,必须作为单个参数整体传入(dws schema chat.query_msg_read_status);把 canonical 拆成多个 token 会被当作 CLI path 而解析失败。`,
|
||||
Args: cobra.MaximumNArgs(16),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
all, _ := cmd.Flags().GetBool("all")
|
||||
compact, _ := cmd.Flags().GetBool("compact")
|
||||
expectedSourceHash, _ := cmd.Flags().GetString("expected-source-hash")
|
||||
expectedSurfaceHash, _ := cmd.Flags().GetString("expected-surface-hash")
|
||||
if (expectedSourceHash == "") != (expectedSurfaceHash == "") {
|
||||
return apperrors.NewValidation("--expected-source-hash and --expected-surface-hash must be provided together", apperrors.WithReason("incomplete_catalog_version"))
|
||||
}
|
||||
versionedInspect := expectedSourceHash != ""
|
||||
cliPath, _ := cmd.Flags().GetString("cli-path")
|
||||
cliPath = strings.TrimSpace(cliPath)
|
||||
if cliPath != "" && len(args) > 0 {
|
||||
@@ -90,10 +107,34 @@ func NewSchemaCommand() *cobra.Command {
|
||||
}
|
||||
if cliPath != "" {
|
||||
args = []string{cliPath}
|
||||
} else if len(args) > 1 {
|
||||
// Preserve the quoted path form while accepting the equivalent
|
||||
// natural tokenized form: dws schema chat message.
|
||||
args = []string{strings.Join(args, " ")}
|
||||
}
|
||||
if versionedInspect {
|
||||
if all || len(args) == 0 || !compact {
|
||||
return apperrors.NewValidation("versioned Schema inspect requires one tool path and --compact", apperrors.WithReason("invalid_inspect_request"))
|
||||
}
|
||||
for _, name := range []string{"fields", "jq"} {
|
||||
if flag := cmd.Flag(name); flag != nil && flag.Changed {
|
||||
return apperrors.NewValidation("schema-inspect.v1 does not support --"+name, apperrors.WithReason("unsupported_output_filter"))
|
||||
}
|
||||
}
|
||||
if flag := cmd.Flag("format"); flag != nil && flag.Changed && strings.TrimSpace(flag.Value.String()) != "json" {
|
||||
return apperrors.NewValidation("schema-inspect.v1 only supports --format json", apperrors.WithReason("unsupported_output_format"))
|
||||
}
|
||||
}
|
||||
if err := schemaCommandCatalogError(); err != nil {
|
||||
return fmt.Errorf("load typed Schema registry: %w", err)
|
||||
}
|
||||
loaded := deliverySchemaCatalog()
|
||||
if expectedSourceHash != "" && expectedSourceHash != loaded.Snapshot.SourceHash {
|
||||
return apperrors.NewDiscovery("Catalog changed before Schema inspect", apperrors.WithReason("catalog_changed"))
|
||||
}
|
||||
if expectedSurfaceHash != "" && expectedSurfaceHash != loaded.Snapshot.SurfaceHash {
|
||||
return apperrors.NewDiscovery("Catalog surface changed before Schema inspect", apperrors.WithReason("catalog_changed"))
|
||||
}
|
||||
var payload map[string]any
|
||||
var err error
|
||||
if all {
|
||||
@@ -109,12 +150,22 @@ func NewSchemaCommand() *cobra.Command {
|
||||
if compact {
|
||||
payload = stripSchemaPayloadCompact(payload)
|
||||
}
|
||||
if versionedInspect {
|
||||
return output.WriteFiltered(cmd.OutOrStdout(), output.FormatJSON, SchemaInspectV1Response{
|
||||
Version: "schema-inspect.v1",
|
||||
Catalog: CatalogVersionRef{SourceHash: loaded.Snapshot.SourceHash, SurfaceHash: loaded.Snapshot.SurfaceHash},
|
||||
ToolSpec: payload,
|
||||
}, "", "")
|
||||
}
|
||||
return output.WriteFiltered(cmd.OutOrStdout(), output.ResolveFormat(cmd, output.FormatJSON), payload, output.ResolveFields(cmd), output.ResolveJQ(cmd))
|
||||
},
|
||||
}
|
||||
cmd.Flags().Bool("all", false, "输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)")
|
||||
cmd.Flags().Bool("compact", false, "按稳定字段白名单输出 Agent 选参、约束、安全语义和返回契约")
|
||||
cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询")
|
||||
cmd.Flags().String("expected-source-hash", "", "要求 Inspect 使用指定 Catalog source hash")
|
||||
cmd.Flags().String("expected-surface-hash", "", "要求 Inspect 使用指定 Catalog surface hash")
|
||||
cmd.AddCommand(newSchemaSearchCommand())
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,12 @@ func resetSchemaSourceRootAtomicForTest() {
|
||||
schemaSourceRoot = atomic.Value{}
|
||||
}
|
||||
|
||||
// resetDeliveryToolSearchEngineStateForTest drops the memoized default Tool
|
||||
// Search engine so a test can rebuild it against a freshly installed Catalog.
|
||||
func resetDeliveryToolSearchEngineStateForTest() {
|
||||
resetDeliveryToolSearchEngineState()
|
||||
}
|
||||
|
||||
// clearDeclaredDryRunCapabilitiesForTest resets the declared index (tests only).
|
||||
func clearDeclaredDryRunCapabilitiesForTest() {
|
||||
declaredDryRunCapabilities.Range(func(key, _ any) bool {
|
||||
|
||||
@@ -177,6 +177,7 @@ 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 {
|
||||
@@ -218,6 +219,14 @@ 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) {
|
||||
@@ -267,21 +276,18 @@ 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 {
|
||||
blockedSet[excluded] = true
|
||||
if _, isAlias := aliasMap[excluded]; isAlias {
|
||||
continue
|
||||
}
|
||||
if claimedRealSet[excluded] {
|
||||
continue
|
||||
}
|
||||
if _, isReal := realByMorph[excluded]; isReal {
|
||||
continue
|
||||
}
|
||||
blockedSet[excluded] = true
|
||||
}
|
||||
|
||||
// (b) Command scoped aliases override concept reductions.
|
||||
|
||||
@@ -889,13 +889,9 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
Blocked: []string{"cursor"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat category add-conv",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
CLIPath: "chat category 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 create",
|
||||
@@ -920,13 +916,9 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
Blocked: []string{"category-ids"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat category remove-conv",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
CLIPath: "chat category 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 rename",
|
||||
@@ -970,23 +962,19 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat conversation-info",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
"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", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group audit-join-validation",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Ambiguous: []string{"staff-id", "uid", "user", "user-id", "userid"},
|
||||
},
|
||||
{
|
||||
@@ -1006,12 +994,9 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat group dismiss",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
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 get-by-group-id",
|
||||
@@ -1020,12 +1005,9 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat group invite-url",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group members",
|
||||
@@ -1100,52 +1082,37 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat group notice create",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
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 notice edit",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
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 notice get",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
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 notice list",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
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 quit",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
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 rename",
|
||||
@@ -1161,23 +1128,17 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat group set-admin",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"user-ids": "users",
|
||||
"chat-id": "conversation-id",
|
||||
"user-ids": "users",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
|
||||
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 set-history",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
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 share-invite",
|
||||
@@ -1191,151 +1152,111 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat group transfer-owner",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
"chat-id": "conversation-id",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
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 update-alias",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-icon",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-nick",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-settings",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-mute",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-icon",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-nick",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-settings",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat 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-member",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"user-ids": "users",
|
||||
"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", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role add",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role list",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
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-role query-user",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
"chat-id": "conversation-id",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
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-role remove",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
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"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role remove-user",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
"chat-id": "conversation-id",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
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"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role set-user",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
"chat-id": "conversation-id",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
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"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role update",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat hide",
|
||||
@@ -1369,12 +1290,10 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message add-emoji",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"open-message-id": "msg-id",
|
||||
"chat-id": "conversation-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"},
|
||||
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"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message add-favorite",
|
||||
@@ -1391,12 +1310,10 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message add-text-emotion",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"open-message-id": "msg-id",
|
||||
"chat-id": "conversation-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"},
|
||||
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"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message combine-forward",
|
||||
@@ -1427,8 +1344,6 @@ 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",
|
||||
@@ -1453,21 +1368,20 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message list",
|
||||
Aliases: map[string]string{
|
||||
"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",
|
||||
"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",
|
||||
},
|
||||
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"},
|
||||
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"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message list-all",
|
||||
@@ -1514,12 +1428,9 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
},
|
||||
},
|
||||
{
|
||||
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-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-pin-msg",
|
||||
@@ -1534,8 +1445,7 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message list-topic-replies",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
@@ -1549,11 +1459,10 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message read-status",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"msg-id": "message-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"open-message-id": "message-id",
|
||||
"user-ids": "users",
|
||||
"chat-id": "conversation-id",
|
||||
"msg-id": "message-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"},
|
||||
},
|
||||
@@ -1561,30 +1470,25 @@ 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{
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"robot": "robot-code",
|
||||
"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"},
|
||||
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"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message remove-emoji",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"open-message-id": "msg-id",
|
||||
"chat-id": "conversation-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"},
|
||||
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"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message remove-favorite",
|
||||
@@ -1601,12 +1505,10 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message remove-text-emotion",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"open-message-id": "msg-id",
|
||||
"chat-id": "conversation-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"},
|
||||
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"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message reply",
|
||||
@@ -1620,12 +1522,9 @@ 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",
|
||||
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",
|
||||
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-advanced",
|
||||
@@ -1638,28 +1537,25 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message send",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"file": "file-path",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"to-user": "user",
|
||||
"uid": "user",
|
||||
"user-id": "user",
|
||||
"userid": "user",
|
||||
"file": "file-path",
|
||||
"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"},
|
||||
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"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message send-by-bot",
|
||||
Aliases: map[string]string{
|
||||
"at-users": "at-user-ids",
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"robot": "robot-code",
|
||||
"user-ids": "users",
|
||||
"at-users": "at-user-ids",
|
||||
"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"},
|
||||
Ambiguous: []string{"at-ids", "chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message send-by-webhook",
|
||||
@@ -1668,14 +1564,9 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
},
|
||||
},
|
||||
{
|
||||
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 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 set-pin-msg",
|
||||
@@ -1684,8 +1575,6 @@ 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"},
|
||||
},
|
||||
@@ -1696,8 +1585,6 @@ 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"},
|
||||
},
|
||||
@@ -1708,8 +1595,6 @@ 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"},
|
||||
},
|
||||
@@ -1720,17 +1605,13 @@ 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",
|
||||
"group": "conversation-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "open-conversation-ids"},
|
||||
},
|
||||
@@ -2548,7 +2429,7 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"node-id": "node",
|
||||
"url": "node",
|
||||
},
|
||||
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"},
|
||||
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"},
|
||||
Ambiguous: []string{"max-result", "max-results", "next-cursor", "next-page-token", "next-token", "per-page", "size", "take", "top"},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -237,6 +237,42 @@ 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"},
|
||||
@@ -457,6 +493,22 @@ 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{
|
||||
@@ -472,6 +524,58 @@ 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) {
|
||||
|
||||
@@ -60,11 +60,17 @@
|
||||
"chat group members": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."},
|
||||
"chat group members remove": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat message add-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat message add-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat mute": {"scoped_aliases": {"group": "conversation-id", "chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id/--id/--chat remain unchanged; other reviewed openConversationId spellings reduce to --conversation-id."},
|
||||
"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."},
|
||||
"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"},
|
||||
@@ -84,12 +90,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"], "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-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"}, "note": "Recipient and local-file-path aliases are exact to this command; obsolete file metadata flags remain unsupported."},
|
||||
"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 +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."},
|
||||
@@ -98,8 +104,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"], "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 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 +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."},
|
||||
@@ -109,7 +115,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"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."},
|
||||
"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."},
|
||||
"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."},
|
||||
@@ -142,7 +148,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"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."},
|
||||
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "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 +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."},
|
||||
@@ -328,7 +334,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": "override:scoped"},
|
||||
{"command": "chat message add-emoji", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"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"},
|
||||
@@ -466,6 +472,7 @@
|
||||
{"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"},
|
||||
|
||||
@@ -325,7 +325,10 @@ func schemaPayloadFromLoadedCatalog(loaded loadedSchemaCatalog, args []string) (
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, apperrors.NewValidation("unknown runtime schema path " + strconvQuote(raw))
|
||||
// The message carries the path-form boundary because it is the one surface
|
||||
// an Agent is guaranteed to see at failure time.
|
||||
return nil, apperrors.NewValidation("unknown runtime schema path " + strconvQuote(raw) +
|
||||
": canonical paths contain dots and must be a single argument (e.g. dws schema chat.query_msg_read_status), while space-separated tokens resolve as a CLI path")
|
||||
}
|
||||
|
||||
func schemaCatalogSnapshotHash(snapshot SchemaCatalogSnapshot) string {
|
||||
|
||||
@@ -1354,16 +1354,16 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
|
||||
interfaceType string
|
||||
}{
|
||||
{"chat message edit", "conversation-id", "openConversationId", true, ""},
|
||||
{"chat message edit", "msg-id", "openMessageId", true, ""},
|
||||
{"chat message edit", "message-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", "group", "openConversationId", true, ""},
|
||||
{"chat group update-nick", "group", "openConversationId", true, ""},
|
||||
{"chat category list-by-conv", "conversation-id", "openConversationId", true, ""},
|
||||
{"chat group update-nick", "conversation-id", "", 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,7 +1397,8 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Hidden conversation aliases must stay unpublished (merge-base parity).
|
||||
// Manifest-covered migrations hide legacy aliases; manifest-external
|
||||
// commands keep their existing visible flags for compatibility.
|
||||
editLeaf, err := queryDeliverySchemaPayload([]string{"chat message edit"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1421,9 +1422,52 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listParams := schemaMap(listByConv["parameters"])
|
||||
for _, hidden := range []string{"conversation-id", "id"} {
|
||||
if _, ok := listParams[hidden]; ok {
|
||||
t.Fatalf("chat category list-by-conv unexpectedly publishes hidden alias --%s", hidden)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
|
||||
"profile list",
|
||||
"profile switch",
|
||||
"profile use",
|
||||
"schema",
|
||||
"schema search",
|
||||
"skill get",
|
||||
"skill install",
|
||||
"skill search",
|
||||
|
||||
@@ -43,19 +43,14 @@ 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")
|
||||
|
||||
@@ -1127,21 +1127,37 @@ func putRawJSON(payload map[string]any, key string, raw json.RawMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// rawJSONValue decodes a JSON value that may come from an untrusted source, so
|
||||
// it validates before decoding. Callers holding output that json.Marshal just
|
||||
// produced should use typedJSONValue instead of paying the validation scan.
|
||||
func rawJSONValue(raw json.RawMessage) (any, error) {
|
||||
if !json.Valid(raw) {
|
||||
return nil, fmt.Errorf("invalid JSON value")
|
||||
}
|
||||
return decodeValidJSONValue(raw), nil
|
||||
}
|
||||
|
||||
// decodeValidJSONValue decodes JSON whose validity the caller has already
|
||||
// established, either by json.Valid or by having just marshaled it. Decode
|
||||
// errors are unreachable under that precondition and are therefore discarded,
|
||||
// exactly as this path behaved when the decode was inlined into rawJSONValue.
|
||||
func decodeValidJSONValue(raw json.RawMessage) any {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
_ = decoder.Decode(&value)
|
||||
return value, nil
|
||||
return value
|
||||
}
|
||||
|
||||
// typedJSONValue projects a typed value into the generic JSON shape the payload
|
||||
// renderers consume. json.Marshal output is valid by construction, so this path
|
||||
// decodes it directly: routing through rawJSONValue re-scanned every marshaled
|
||||
// document with json.Valid, which measured ~34% of Schema Catalog assembly time
|
||||
// across the 1121-tool set (26.0s -> 17.2s for the internal/app schema suite).
|
||||
func typedJSONValue(value any) (any, error) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rawJSONValue(data)
|
||||
return decodeValidJSONValue(data), nil
|
||||
}
|
||||
@@ -252,7 +252,8 @@ 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 --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.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.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.",
|
||||
@@ -265,6 +266,7 @@ 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",
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ToolSearchV1Request is the stable stdin transport used by Agent hosts.
|
||||
type ToolSearchV1Request struct {
|
||||
Version string `json:"version"`
|
||||
Query string `json:"query"`
|
||||
Subqueries []string `json:"subqueries,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
CandidateLimit int `json:"candidate_limit,omitempty"`
|
||||
ProductIDs []string `json:"product_ids,omitempty"`
|
||||
Effects []string `json:"effects,omitempty"`
|
||||
ExcludeCanonical []string `json:"exclude_canonical,omitempty"`
|
||||
}
|
||||
|
||||
var schemaSearchNewEngine = NewDeliveryToolSearchEngineWithConfig
|
||||
|
||||
// schemaSearchEngineConfig resolves the search config the same way
|
||||
// NewDeliveryToolSearchEngine does (defaults + DWS_TOOL_SEARCH_* env
|
||||
// overrides), then lets an explicit --explain flag win over the env value.
|
||||
func schemaSearchEngineConfig(cmd *cobra.Command) ToolSearchConfig {
|
||||
config := ResolveToolSearchConfigFromEnv(DefaultToolSearchConfig())
|
||||
if explain, _ := cmd.Flags().GetBool("explain"); explain {
|
||||
config.Explain = true
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func newSchemaSearchCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: "按自然语言搜索可用命令 Schema",
|
||||
Long: `按自然语言或精确身份检索 ToolReference。
|
||||
|
||||
输出固定为完整、紧凑的 tool-search.v1 JSON envelope。为保证版本、Catalog
|
||||
绑定和响应字节预算可验证,本命令不支持 --fields、--jq 或非 JSON --format。
|
||||
|
||||
DWS 对 Agent 是一个元工具:已知子命令直接执行,未知子命令才使用 search。
|
||||
选中候选后,必须使用 response catalog 的 source/surface hash Inspect canonical_path,
|
||||
再按 primary_cli_path 执行;Search 结果本身不授权、不执行。`,
|
||||
Example: ` dws schema search --query "查询群消息已读状态"
|
||||
dws schema chat.query_msg_read_status --compact --format json \
|
||||
--expected-source-hash "<search.catalog.source_hash>" \
|
||||
--expected-surface-hash "<search.catalog.surface_hash>"
|
||||
printf '%s' '{"version":"tool-search.v1","query":"发群文件并确认已读","subqueries":["给群里发送文件消息","查询群消息已读状态"]}' |
|
||||
dws schema search --request-json -`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateSchemaSearchOutputFlags(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := schemaSearchRequest(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
engine, err := schemaSearchNewEngine(schemaSearchEngineConfig(cmd))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
internal := ToolSearchRequest{
|
||||
Query: request.Query,
|
||||
Limit: request.Limit,
|
||||
CandidateLimit: request.CandidateLimit,
|
||||
ProductIDs: request.ProductIDs,
|
||||
Effects: request.Effects,
|
||||
ExcludeCanonicalPaths: request.ExcludeCanonical,
|
||||
}
|
||||
var response ToolSearchResponse
|
||||
if len(request.Subqueries) > 0 {
|
||||
response, err = engine.SearchSubqueries(cmd.Context(), request.Subqueries, internal)
|
||||
} else {
|
||||
response, err = engine.Search(cmd.Context(), internal)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The response budget is defined over this compact wire encoding. Do
|
||||
// not run it through pretty/table formatting or post-filtering because
|
||||
// Agent hosts require one complete versioned envelope.
|
||||
if err := json.NewEncoder(cmd.OutOrStdout()).Encode(response); err != nil {
|
||||
return fmt.Errorf("encode tool-search.v1 response: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("query", "", "自然语言意图或 canonical/CLI path")
|
||||
cmd.Flags().Int("limit", defaultToolSearchLimit, fmt.Sprintf("返回引用数量(1-%d;0 表示沿用默认值 %d)", maxToolSearchLimit, defaultToolSearchLimit))
|
||||
cmd.Flags().Int("candidate-limit", defaultToolSearchCandidateLimit, fmt.Sprintf("每路召回候选数量(1-%d;0 表示沿用默认值 %d)", maxToolSearchCandidateLimit, defaultToolSearchCandidateLimit))
|
||||
cmd.Flags().StringSlice("product", nil, "限制产品 ID,可重复或逗号分隔")
|
||||
cmd.Flags().StringSlice("effect", nil, "限制 effect,可重复或逗号分隔")
|
||||
cmd.Flags().StringSlice("exclude", nil, "排除 canonical path,可重复或逗号分隔")
|
||||
cmd.Flags().String("request-json", "", `从 stdin 读取 JSON;值必须为 -;必含 version="tool-search.v1",动作分解字段为 subqueries`)
|
||||
cmd.Flags().Bool("explain", false, "开发诊断:输出 score_breakdown 含原始排序分(公开默认响应不含分数;仅用于离线调参,不建议线上 Agent 使用;会增大响应体积,建议配合 --limit 3)。score_breakdown.score 是未量化的原始分,排序键使用量化到 1e-9 的值,两者末位可能不同,不要用它复算候选顺序")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func validateSchemaSearchOutputFlags(cmd *cobra.Command) error {
|
||||
for _, name := range []string{"fields", "jq"} {
|
||||
if flag := cmd.Flag(name); flag != nil && flag.Changed {
|
||||
return apperrors.NewValidation(
|
||||
"schema search returns one complete tool-search.v1 envelope and does not support --"+name,
|
||||
apperrors.WithReason("unsupported_output_filter"),
|
||||
)
|
||||
}
|
||||
}
|
||||
if flag := cmd.Flag("format"); flag != nil && flag.Changed && strings.TrimSpace(flag.Value.String()) != "json" {
|
||||
return apperrors.NewValidation(
|
||||
"schema search only supports --format json",
|
||||
apperrors.WithReason("unsupported_output_format"),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func schemaSearchRequest(cmd *cobra.Command) (ToolSearchV1Request, error) {
|
||||
requestJSON, _ := cmd.Flags().GetString("request-json")
|
||||
requestJSON = strings.TrimSpace(requestJSON)
|
||||
if requestJSON != "" {
|
||||
if requestJSON != "-" {
|
||||
return ToolSearchV1Request{}, apperrors.NewValidation("--request-json only accepts - (stdin)", apperrors.WithReason("invalid_transport"))
|
||||
}
|
||||
for _, name := range []string{"query", "limit", "candidate-limit", "product", "effect", "exclude"} {
|
||||
if cmd.Flags().Changed(name) {
|
||||
return ToolSearchV1Request{}, apperrors.NewValidation("--request-json cannot be combined with --"+name, apperrors.WithReason("ambiguous_input"))
|
||||
}
|
||||
}
|
||||
request, err := decodeToolSearchV1Request(cmd.InOrStdin())
|
||||
if err != nil {
|
||||
return ToolSearchV1Request{}, err
|
||||
}
|
||||
return validateToolSearchV1Request(request)
|
||||
}
|
||||
query, _ := cmd.Flags().GetString("query")
|
||||
limit, _ := cmd.Flags().GetInt("limit")
|
||||
candidateLimit, _ := cmd.Flags().GetInt("candidate-limit")
|
||||
products, _ := cmd.Flags().GetStringSlice("product")
|
||||
effects, _ := cmd.Flags().GetStringSlice("effect")
|
||||
excluded, _ := cmd.Flags().GetStringSlice("exclude")
|
||||
return validateToolSearchV1Request(ToolSearchV1Request{
|
||||
Version: "tool-search.v1",
|
||||
Query: query,
|
||||
Limit: limit,
|
||||
CandidateLimit: candidateLimit,
|
||||
ProductIDs: products,
|
||||
Effects: effects,
|
||||
ExcludeCanonical: excluded,
|
||||
})
|
||||
}
|
||||
|
||||
func decodeToolSearchV1Request(reader io.Reader) (ToolSearchV1Request, error) {
|
||||
payload, err := io.ReadAll(io.LimitReader(reader, maxToolSearchRequestBytes+1))
|
||||
if err != nil {
|
||||
return ToolSearchV1Request{}, apperrors.NewValidation("read tool search request: "+err.Error(), apperrors.WithReason("read_request_failed"))
|
||||
}
|
||||
if len(payload) > maxToolSearchRequestBytes {
|
||||
return ToolSearchV1Request{}, apperrors.NewValidation(fmt.Sprintf("tool search request exceeds %d bytes", maxToolSearchRequestBytes), apperrors.WithReason("request_too_large"))
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
var request ToolSearchV1Request
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
return ToolSearchV1Request{}, apperrors.NewValidation("decode tool-search.v1 request: "+err.Error(), apperrors.WithReason("invalid_request_json"))
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||
if err == nil {
|
||||
return ToolSearchV1Request{}, apperrors.NewValidation("tool search request must contain exactly one JSON object", apperrors.WithReason("invalid_request_json"))
|
||||
}
|
||||
return ToolSearchV1Request{}, apperrors.NewValidation("decode trailing tool search input: "+err.Error(), apperrors.WithReason("invalid_request_json"))
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func validateToolSearchV1Request(request ToolSearchV1Request) (ToolSearchV1Request, error) {
|
||||
if request.Version != "tool-search.v1" {
|
||||
return ToolSearchV1Request{}, apperrors.NewValidation(`tool search request must include "version":"tool-search.v1"; action decomposition uses the "subqueries" array`, apperrors.WithReason("unsupported_version"))
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// 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.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestSchemaSearchCommandExactAndVersioned(t *testing.T) {
|
||||
installSchemaSearchTestEngine(t)
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.AddCommand(NewSchemaCommand())
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetArgs([]string{"schema", "search", "--query", "chat.read_status"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("schema search execute: %v", err)
|
||||
}
|
||||
var response ToolSearchResponse
|
||||
if err := json.Unmarshal(stdout.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode response: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if response.Version != "tool-search.v1" || response.Strategy != "exact_guard" {
|
||||
t.Fatalf("response = %#v", response)
|
||||
}
|
||||
if len(response.Candidates) != 1 || response.Candidates[0].CanonicalPath != "chat.read_status" {
|
||||
t.Fatalf("candidates = %#v", response.Candidates)
|
||||
}
|
||||
wantCatalog := CatalogVersionRef{SourceHash: "source-test", SurfaceHash: "surface-test"}
|
||||
if response.Catalog != wantCatalog {
|
||||
t.Fatalf("Catalog = %#v, want %#v", response.Catalog, wantCatalog)
|
||||
}
|
||||
for _, removed := range []string{"external_ranking", "degraded", "degraded_reason_code", "warning_codes", "_fallback"} {
|
||||
if strings.Contains(stdout.String(), removed) {
|
||||
t.Fatalf("removed fallback field %q leaked into response: %s", removed, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaSearchRequestRejectsUnboundedOrAmbiguousInput(t *testing.T) {
|
||||
if _, err := decodeToolSearchV1Request(strings.NewReader(`{"version":"tool-search.v1","query":"x","unknown":true}`)); validationReason(err) != "invalid_request_json" {
|
||||
t.Fatal("unknown field was accepted")
|
||||
}
|
||||
if _, err := decodeToolSearchV1Request(strings.NewReader(`{"version":"tool-search.v1","query":"x","external_ranking":{}}`)); validationReason(err) != "invalid_request_json" {
|
||||
t.Fatal("removed external_ranking field was accepted")
|
||||
}
|
||||
oversized := strings.NewReader(strings.Repeat("x", maxToolSearchRequestBytes+1))
|
||||
if _, err := decodeToolSearchV1Request(oversized); validationReason(err) != "request_too_large" {
|
||||
t.Fatal("oversized request was accepted")
|
||||
}
|
||||
_, err := validateToolSearchV1Request(ToolSearchV1Request{Query: "x", Subqueries: []string{"x"}})
|
||||
if validationReason(err) != "unsupported_version" || !strings.Contains(err.Error(), `"version":"tool-search.v1"`) || !strings.Contains(err.Error(), "subqueries") {
|
||||
t.Fatalf("missing-version guidance = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaSearchHelpDocumentsStructuredRequest(t *testing.T) {
|
||||
command := newSchemaSearchCommand()
|
||||
var output bytes.Buffer
|
||||
command.SetOut(&output)
|
||||
command.SetArgs([]string{"--help"})
|
||||
if err := command.Execute(); err != nil {
|
||||
t.Fatalf("schema search help: %v", err)
|
||||
}
|
||||
for _, required := range []string{`"version":"tool-search.v1"`, `"subqueries"`, "--request-json -", "不支持 --fields、--jq", "非 JSON --format"} {
|
||||
if !strings.Contains(output.String(), required) {
|
||||
t.Fatalf("schema search help omits %q:\n%s", required, output.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func installSchemaSearchTestEngine(t *testing.T) {
|
||||
t.Helper()
|
||||
testseam.Swap(t, &schemaSearchNewEngine, func(ToolSearchConfig) (*ToolSearchEngine, error) {
|
||||
return newToolSearchTestEngine(t), nil
|
||||
})
|
||||
}
|
||||
|
||||
func validationReason(err error) string {
|
||||
var typed *apperrors.Error
|
||||
if errors.As(err, &typed) && typed.Category == apperrors.CategoryValidation {
|
||||
return typed.Reason
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -63,12 +63,15 @@ func storeSchemaSourceRootFn(fn func() *cobra.Command) {
|
||||
}
|
||||
|
||||
// resetDeliverySchemaCatalogState clears the lazy Catalog delivery Once/caches
|
||||
// so the next deliverySchemaCatalog() reassembles.
|
||||
// so the next deliverySchemaCatalog() reassembles. The memoized Tool Search
|
||||
// engine is a Catalog derivative (it indexes one generation and caches its
|
||||
// source/surface hashes), so it is invalidated here as well.
|
||||
func resetDeliverySchemaCatalogState() {
|
||||
runtimeDeliverySchemaCatalogOnce = sync.Once{}
|
||||
runtimeDeliverySchemaCatalog = loadedSchemaCatalog{}
|
||||
runtimeDeliverySchemaCatalogErr = nil
|
||||
runtimeDeliverySchemaCatalogLazyCount.Store(0)
|
||||
resetDeliveryToolSearchEngineState()
|
||||
}
|
||||
|
||||
// resetSchemaDeliveryState clears ResolveMeta lookup state and Catalog delivery
|
||||
|
||||
@@ -7,11 +7,30 @@ package cli
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestSchemaHelpDocumentsCanonicalAndCLIPathForms(t *testing.T) {
|
||||
// Positional args are joined with spaces and resolved as a CLI path, so the
|
||||
// tokenized form is not a canonical-path spelling. Help must keep saying so:
|
||||
// an Agent that splits a canonical path on the dot otherwise gets an
|
||||
// "unknown runtime schema path" error with no explanation of the boundary.
|
||||
long := NewSchemaCommand().Long
|
||||
for _, required := range []string{
|
||||
"CLI path",
|
||||
"canonical path",
|
||||
`dws schema "chat message read-status"`,
|
||||
"dws schema chat.query_msg_read_status",
|
||||
} {
|
||||
if !strings.Contains(long, required) {
|
||||
t.Fatalf("schema long help omits the path-form boundary %q:\n%s", required, long)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaUsesDeliveryCatalogWithoutRuntimeLoad(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.AddCommand(NewSchemaCommand())
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,490 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ToolSearchAgentArmDirectSchema = "direct_schema"
|
||||
ToolSearchAgentArmSearchInspect = "search_inspect"
|
||||
)
|
||||
|
||||
// Bootstrap resampling uses fixed seeds so a report can be re-derived from the
|
||||
// same paired runs. The seeds are published in the report so an auditor never
|
||||
// has to read them out of the source to reproduce an interval. The recovery
|
||||
// slice uses a distinct seed because it resamples a different pair subset.
|
||||
const (
|
||||
toolSearchAgentABBootstrapSamples = 10000
|
||||
toolSearchAgentABBootstrapSeed = 927
|
||||
toolSearchAgentABRecoveryBootstrapSeed = 928
|
||||
)
|
||||
|
||||
// ToolSearchAgentABInput is populated by an Agent runner. Every case/trial
|
||||
// must have both arms so the comparison remains paired.
|
||||
type ToolSearchAgentABInput struct {
|
||||
Version string `json:"version"`
|
||||
Catalog CatalogVersionRef `json:"catalog"`
|
||||
Runs []ToolSearchAgentABRun `json:"runs"`
|
||||
}
|
||||
|
||||
// ToolSearchAgentABRun records end-to-end outcomes rather than retrieval
|
||||
// proxies. ContextTokens must come from the selected model's tokenizer.
|
||||
type ToolSearchAgentABRun struct {
|
||||
CaseID string `json:"case_id"`
|
||||
Trial int `json:"trial"`
|
||||
Arm string `json:"arm"`
|
||||
TaskCompleted bool `json:"task_completed"`
|
||||
CorrectToolPlan bool `json:"correct_tool_plan"`
|
||||
UnsafeAction bool `json:"unsafe_action"`
|
||||
RecoveryAttempted bool `json:"recovery_attempted"`
|
||||
RecoverySucceeded bool `json:"recovery_succeeded"`
|
||||
ContextTokens float64 `json:"context_tokens"`
|
||||
ToolCalls float64 `json:"tool_calls"`
|
||||
LatencyMS float64 `json:"latency_ms"`
|
||||
}
|
||||
|
||||
type ToolSearchAgentABArmMetrics struct {
|
||||
Runs int `json:"runs"`
|
||||
TaskSuccessRate float64 `json:"task_success_rate"`
|
||||
CorrectPlanRate float64 `json:"correct_plan_rate"`
|
||||
UnsafeActionRate float64 `json:"unsafe_action_rate"`
|
||||
RecoverySuccessRate float64 `json:"recovery_success_rate"`
|
||||
RecoveryAttempts int `json:"recovery_attempts"`
|
||||
AverageContextTokens float64 `json:"average_context_tokens"`
|
||||
AverageToolCalls float64 `json:"average_tool_calls"`
|
||||
AverageLatencyMS float64 `json:"average_latency_ms"`
|
||||
}
|
||||
|
||||
// ToolSearchAgentABDelta is search_inspect minus direct_schema. A positive
|
||||
// value is desirable for success/plan/recovery and undesirable for unsafe
|
||||
// actions, context, calls, and latency.
|
||||
type ToolSearchAgentABDelta struct {
|
||||
DirectSchema float64 `json:"direct_schema"`
|
||||
SearchInspect float64 `json:"search_inspect"`
|
||||
Delta float64 `json:"delta"`
|
||||
CI95Low float64 `json:"ci95_low"`
|
||||
CI95High float64 `json:"ci95_high"`
|
||||
}
|
||||
|
||||
type ToolSearchAgentABReport struct {
|
||||
Version string `json:"version"`
|
||||
Catalog CatalogVersionRef `json:"catalog"`
|
||||
Cases int `json:"cases"`
|
||||
PairedTrials int `json:"paired_trials"`
|
||||
DirectSchema ToolSearchAgentABArmMetrics `json:"direct_schema"`
|
||||
SearchInspect ToolSearchAgentABArmMetrics `json:"search_inspect"`
|
||||
Deltas map[string]ToolSearchAgentABDelta `json:"deltas"`
|
||||
// BootstrapSamples/BootstrapSeed/RecoveryBootstrapSeed are published so an
|
||||
// auditor can reproduce every interval without reading the source.
|
||||
BootstrapSamples int `json:"bootstrap_samples"`
|
||||
BootstrapSeed int64 `json:"bootstrap_seed"`
|
||||
RecoveryBootstrapSeed int64 `json:"recovery_bootstrap_seed"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
|
||||
// ToolSearchAgentPlanOutput is the strict final response of a non-executing
|
||||
// Agent planning run. Gold tools remain in the local workflow fixture and are
|
||||
// never sent to the evaluated model.
|
||||
type ToolSearchAgentPlanOutput struct {
|
||||
Results []ToolSearchAgentPlanResult `json:"results"`
|
||||
}
|
||||
|
||||
type ToolSearchAgentPlanResult struct {
|
||||
ID string `json:"id"`
|
||||
CanonicalPaths []string `json:"canonical_paths"`
|
||||
}
|
||||
|
||||
type ToolSearchAgentPlanMetrics struct {
|
||||
Cases int `json:"cases"`
|
||||
CompleteRate float64 `json:"complete_rate"`
|
||||
ExactMinimalRate float64 `json:"exact_minimal_rate"`
|
||||
RequiredToolRecall float64 `json:"required_tool_recall"`
|
||||
PlanPrecision float64 `json:"plan_precision"`
|
||||
RequiredTools int `json:"required_tools"`
|
||||
PredictedTools int `json:"predicted_tools"`
|
||||
UnnecessaryTools int `json:"unnecessary_tools"`
|
||||
}
|
||||
|
||||
type ToolSearchAgentPlanningABReport struct {
|
||||
Version string `json:"version"`
|
||||
Model string `json:"model"`
|
||||
DirectSchema ToolSearchAgentPlanMetrics `json:"direct_schema"`
|
||||
SearchInspect ToolSearchAgentPlanMetrics `json:"search_inspect"`
|
||||
Delta map[string]float64 `json:"delta_search_minus_direct"`
|
||||
Limitations []string `json:"limitations"`
|
||||
}
|
||||
|
||||
// ScoreToolSearchAgentPlanningAB scores an answer-free paired planning smoke
|
||||
// run against reviewed workflow requirements. It measures plan coverage and
|
||||
// minimality only; it does not claim that any business task was executed.
|
||||
func ScoreToolSearchAgentPlanningAB(model string, workflows []ToolSearchWorkflowEvaluationCase, direct, search ToolSearchAgentPlanOutput) (ToolSearchAgentPlanningABReport, error) {
|
||||
if err := deliverySchemaCatalogError(); err != nil {
|
||||
return ToolSearchAgentPlanningABReport{}, fmt.Errorf("assemble typed Schema registry for Agent planning score: %w", err)
|
||||
}
|
||||
return scoreToolSearchAgentPlanningAB(deliverySchemaCatalog().Index, model, workflows, direct, search)
|
||||
}
|
||||
|
||||
func scoreToolSearchAgentPlanningAB(index SchemaIndex, model string, workflows []ToolSearchWorkflowEvaluationCase, direct, search ToolSearchAgentPlanOutput) (ToolSearchAgentPlanningABReport, error) {
|
||||
if strings.TrimSpace(model) == "" {
|
||||
return ToolSearchAgentPlanningABReport{}, fmt.Errorf("Agent planning A/B requires a model identifier")
|
||||
}
|
||||
directMetrics, err := scoreToolSearchAgentPlans(index, workflows, direct)
|
||||
if err != nil {
|
||||
return ToolSearchAgentPlanningABReport{}, fmt.Errorf("score direct_schema plans: %w", err)
|
||||
}
|
||||
searchMetrics, err := scoreToolSearchAgentPlans(index, workflows, search)
|
||||
if err != nil {
|
||||
return ToolSearchAgentPlanningABReport{}, fmt.Errorf("score search_inspect plans: %w", err)
|
||||
}
|
||||
return ToolSearchAgentPlanningABReport{
|
||||
Version: "tool-search-agent-planning-ab.v1",
|
||||
Model: strings.TrimSpace(model),
|
||||
DirectSchema: directMetrics,
|
||||
SearchInspect: searchMetrics,
|
||||
Delta: map[string]float64{
|
||||
"complete_rate": searchMetrics.CompleteRate - directMetrics.CompleteRate,
|
||||
"exact_minimal_rate": searchMetrics.ExactMinimalRate - directMetrics.ExactMinimalRate,
|
||||
"required_tool_recall": searchMetrics.RequiredToolRecall - directMetrics.RequiredToolRecall,
|
||||
"plan_precision": searchMetrics.PlanPrecision - directMetrics.PlanPrecision,
|
||||
"unnecessary_tools": float64(searchMetrics.UnnecessaryTools - directMetrics.UnnecessaryTools),
|
||||
},
|
||||
Limitations: []string{
|
||||
"one batch and one model trial per arm; no confidence interval",
|
||||
"reviewed ten-workflow diagnostic fixture is not an independent release test",
|
||||
"planning only: no parameters, business execution, recovery, latency, or tokenizer usage is scored",
|
||||
"extra prerequisites can be operationally valid even when absent from the minimal reviewed gold",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func scoreToolSearchAgentPlans(index SchemaIndex, workflows []ToolSearchWorkflowEvaluationCase, output ToolSearchAgentPlanOutput) (ToolSearchAgentPlanMetrics, error) {
|
||||
byID := make(map[string]ToolSearchAgentPlanResult, len(output.Results))
|
||||
for _, result := range output.Results {
|
||||
if strings.TrimSpace(result.ID) == "" || len(result.CanonicalPaths) == 0 {
|
||||
return ToolSearchAgentPlanMetrics{}, fmt.Errorf("plan result requires id and at least one canonical path")
|
||||
}
|
||||
if _, exists := byID[result.ID]; exists {
|
||||
return ToolSearchAgentPlanMetrics{}, fmt.Errorf("duplicate plan result %q", result.ID)
|
||||
}
|
||||
seen := make(map[string]bool, len(result.CanonicalPaths))
|
||||
for _, canonical := range result.CanonicalPaths {
|
||||
if _, ok := index.Resolve(canonical); !ok {
|
||||
return ToolSearchAgentPlanMetrics{}, fmt.Errorf("plan result %q contains unknown canonical %q", result.ID, canonical)
|
||||
}
|
||||
if seen[canonical] {
|
||||
return ToolSearchAgentPlanMetrics{}, fmt.Errorf("plan result %q repeats canonical %q", result.ID, canonical)
|
||||
}
|
||||
seen[canonical] = true
|
||||
}
|
||||
byID[result.ID] = result
|
||||
}
|
||||
metrics := ToolSearchAgentPlanMetrics{Cases: len(workflows)}
|
||||
var completeCases, exactCases, hits int
|
||||
for _, workflow := range workflows {
|
||||
result, ok := byID[workflow.ID]
|
||||
if !ok {
|
||||
return ToolSearchAgentPlanMetrics{}, fmt.Errorf("missing plan result %q", workflow.ID)
|
||||
}
|
||||
delete(byID, workflow.ID)
|
||||
gold := stringSet(workflow.Required)
|
||||
caseHits := 0
|
||||
for _, canonical := range result.CanonicalPaths {
|
||||
if gold[canonical] {
|
||||
caseHits++
|
||||
}
|
||||
}
|
||||
hits += caseHits
|
||||
metrics.RequiredTools += len(workflow.Required)
|
||||
metrics.PredictedTools += len(result.CanonicalPaths)
|
||||
if caseHits == len(workflow.Required) {
|
||||
completeCases++
|
||||
}
|
||||
if stringSlicesEqual(result.CanonicalPaths, workflow.Required) {
|
||||
exactCases++
|
||||
}
|
||||
}
|
||||
if len(byID) > 0 {
|
||||
unexpected := make([]string, 0, len(byID))
|
||||
for id := range byID {
|
||||
unexpected = append(unexpected, id)
|
||||
}
|
||||
sort.Strings(unexpected)
|
||||
return ToolSearchAgentPlanMetrics{}, fmt.Errorf("unexpected plan results: %s", strings.Join(unexpected, ", "))
|
||||
}
|
||||
if metrics.Cases > 0 {
|
||||
metrics.CompleteRate = float64(completeCases) / float64(metrics.Cases)
|
||||
metrics.ExactMinimalRate = float64(exactCases) / float64(metrics.Cases)
|
||||
}
|
||||
if metrics.RequiredTools > 0 {
|
||||
metrics.RequiredToolRecall = float64(hits) / float64(metrics.RequiredTools)
|
||||
}
|
||||
if metrics.PredictedTools > 0 {
|
||||
metrics.PlanPrecision = float64(hits) / float64(metrics.PredictedTools)
|
||||
}
|
||||
metrics.UnnecessaryTools = metrics.PredictedTools - hits
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
func stringSlicesEqual(left, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type toolSearchAgentPair struct {
|
||||
caseID string
|
||||
direct ToolSearchAgentABRun
|
||||
search ToolSearchAgentABRun
|
||||
}
|
||||
|
||||
// AggregateToolSearchAgentAB validates a paired experiment and computes
|
||||
// case-cluster bootstrap confidence intervals with a fixed seed. Trials of the
|
||||
// same task are averaged inside a case before resampling so repeated model
|
||||
// seeds are not treated as independent tasks.
|
||||
func AggregateToolSearchAgentAB(input ToolSearchAgentABInput) (ToolSearchAgentABReport, error) {
|
||||
if input.Version != "tool-search-agent-ab.v1" {
|
||||
return ToolSearchAgentABReport{}, fmt.Errorf("unsupported Agent A/B input version %q", input.Version)
|
||||
}
|
||||
if strings.TrimSpace(input.Catalog.SourceHash) == "" || strings.TrimSpace(input.Catalog.SurfaceHash) == "" {
|
||||
return ToolSearchAgentABReport{}, fmt.Errorf("Agent A/B input requires both Catalog hashes")
|
||||
}
|
||||
type key struct {
|
||||
caseID string
|
||||
trial int
|
||||
}
|
||||
paired := make(map[key]*toolSearchAgentPair)
|
||||
for _, run := range input.Runs {
|
||||
if strings.TrimSpace(run.CaseID) == "" || run.Trial < 0 {
|
||||
return ToolSearchAgentABReport{}, fmt.Errorf("Agent A/B run requires case_id and non-negative trial")
|
||||
}
|
||||
if run.ContextTokens < 0 || run.ToolCalls < 0 || run.LatencyMS < 0 {
|
||||
return ToolSearchAgentABReport{}, fmt.Errorf("Agent A/B run %s/%d has a negative cost", run.CaseID, run.Trial)
|
||||
}
|
||||
if run.RecoverySucceeded && !run.RecoveryAttempted {
|
||||
return ToolSearchAgentABReport{}, fmt.Errorf("Agent A/B run %s/%d marks recovery success without an attempt", run.CaseID, run.Trial)
|
||||
}
|
||||
itemKey := key{caseID: run.CaseID, trial: run.Trial}
|
||||
pair := paired[itemKey]
|
||||
if pair == nil {
|
||||
pair = &toolSearchAgentPair{caseID: run.CaseID}
|
||||
paired[itemKey] = pair
|
||||
}
|
||||
switch run.Arm {
|
||||
case ToolSearchAgentArmDirectSchema:
|
||||
if pair.direct.Arm != "" {
|
||||
return ToolSearchAgentABReport{}, fmt.Errorf("duplicate direct_schema run for %s/%d", run.CaseID, run.Trial)
|
||||
}
|
||||
pair.direct = run
|
||||
case ToolSearchAgentArmSearchInspect:
|
||||
if pair.search.Arm != "" {
|
||||
return ToolSearchAgentABReport{}, fmt.Errorf("duplicate search_inspect run for %s/%d", run.CaseID, run.Trial)
|
||||
}
|
||||
pair.search = run
|
||||
default:
|
||||
return ToolSearchAgentABReport{}, fmt.Errorf("unknown Agent A/B arm %q", run.Arm)
|
||||
}
|
||||
}
|
||||
if len(paired) == 0 {
|
||||
return ToolSearchAgentABReport{}, fmt.Errorf("Agent A/B input contains no runs")
|
||||
}
|
||||
pairs := make([]toolSearchAgentPair, 0, len(paired))
|
||||
caseSet := make(map[string]bool)
|
||||
for itemKey, pair := range paired {
|
||||
if pair.direct.Arm == "" || pair.search.Arm == "" {
|
||||
return ToolSearchAgentABReport{}, fmt.Errorf("Agent A/B case %s/%d is not paired", itemKey.caseID, itemKey.trial)
|
||||
}
|
||||
pairs = append(pairs, *pair)
|
||||
caseSet[itemKey.caseID] = true
|
||||
}
|
||||
sort.Slice(pairs, func(i, j int) bool {
|
||||
if pairs[i].caseID == pairs[j].caseID {
|
||||
return pairs[i].direct.Trial < pairs[j].direct.Trial
|
||||
}
|
||||
return pairs[i].caseID < pairs[j].caseID
|
||||
})
|
||||
|
||||
directMetrics := aggregateToolSearchAgentArm(pairs, false)
|
||||
searchMetrics := aggregateToolSearchAgentArm(pairs, true)
|
||||
metrics := []struct {
|
||||
name string
|
||||
value func(ToolSearchAgentABRun) float64
|
||||
direct float64
|
||||
search float64
|
||||
}{
|
||||
{"task_success_rate", func(run ToolSearchAgentABRun) float64 { return boolFloat(run.TaskCompleted) }, directMetrics.TaskSuccessRate, searchMetrics.TaskSuccessRate},
|
||||
{"correct_plan_rate", func(run ToolSearchAgentABRun) float64 { return boolFloat(run.CorrectToolPlan) }, directMetrics.CorrectPlanRate, searchMetrics.CorrectPlanRate},
|
||||
{"unsafe_action_rate", func(run ToolSearchAgentABRun) float64 { return boolFloat(run.UnsafeAction) }, directMetrics.UnsafeActionRate, searchMetrics.UnsafeActionRate},
|
||||
{"context_tokens", func(run ToolSearchAgentABRun) float64 { return run.ContextTokens }, directMetrics.AverageContextTokens, searchMetrics.AverageContextTokens},
|
||||
{"tool_calls", func(run ToolSearchAgentABRun) float64 { return run.ToolCalls }, directMetrics.AverageToolCalls, searchMetrics.AverageToolCalls},
|
||||
{"latency_ms", func(run ToolSearchAgentABRun) float64 { return run.LatencyMS }, directMetrics.AverageLatencyMS, searchMetrics.AverageLatencyMS},
|
||||
}
|
||||
deltas := make(map[string]ToolSearchAgentABDelta, len(metrics)+1)
|
||||
for _, metric := range metrics {
|
||||
low, high := toolSearchCaseClusterBootstrap(pairs, metric.value, toolSearchAgentABBootstrapSamples, toolSearchAgentABBootstrapSeed)
|
||||
deltas[metric.name] = ToolSearchAgentABDelta{
|
||||
DirectSchema: metric.direct,
|
||||
SearchInspect: metric.search,
|
||||
Delta: metric.search - metric.direct,
|
||||
CI95Low: low,
|
||||
CI95High: high,
|
||||
}
|
||||
}
|
||||
if directMetrics.RecoveryAttempts > 0 && searchMetrics.RecoveryAttempts > 0 {
|
||||
// Recovery is conditionally defined over attempted recoveries, so its
|
||||
// interval uses the same paired tasks but maps non-attempts to zero only
|
||||
// after both arms attempted recovery for that trial.
|
||||
recoveryPairs := make([]toolSearchAgentPair, 0, len(pairs))
|
||||
for _, pair := range pairs {
|
||||
if pair.direct.RecoveryAttempted && pair.search.RecoveryAttempted {
|
||||
recoveryPairs = append(recoveryPairs, pair)
|
||||
}
|
||||
}
|
||||
if len(recoveryPairs) > 0 {
|
||||
value := func(run ToolSearchAgentABRun) float64 { return boolFloat(run.RecoverySucceeded) }
|
||||
low, high := toolSearchCaseClusterBootstrap(recoveryPairs, value, toolSearchAgentABBootstrapSamples, toolSearchAgentABRecoveryBootstrapSeed)
|
||||
directRecovery := meanAgentRunValue(recoveryPairs, false, value)
|
||||
searchRecovery := meanAgentRunValue(recoveryPairs, true, value)
|
||||
deltas["recovery_success_rate_paired_attempts"] = ToolSearchAgentABDelta{
|
||||
DirectSchema: directRecovery,
|
||||
SearchInspect: searchRecovery,
|
||||
Delta: searchRecovery - directRecovery,
|
||||
CI95Low: low,
|
||||
CI95High: high,
|
||||
}
|
||||
}
|
||||
}
|
||||
return ToolSearchAgentABReport{
|
||||
Version: "tool-search-agent-ab-report.v1",
|
||||
Catalog: input.Catalog,
|
||||
Cases: len(caseSet),
|
||||
PairedTrials: len(pairs),
|
||||
DirectSchema: directMetrics,
|
||||
SearchInspect: searchMetrics,
|
||||
Deltas: deltas,
|
||||
BootstrapSamples: toolSearchAgentABBootstrapSamples,
|
||||
BootstrapSeed: toolSearchAgentABBootstrapSeed,
|
||||
RecoveryBootstrapSeed: toolSearchAgentABRecoveryBootstrapSeed,
|
||||
Method: fmt.Sprintf(
|
||||
"paired by case_id/trial; 95%% percentile bootstrap clustered by case_id; %d resamples; fixed seed %d (recovery slice seed %d)",
|
||||
toolSearchAgentABBootstrapSamples,
|
||||
toolSearchAgentABBootstrapSeed,
|
||||
toolSearchAgentABRecoveryBootstrapSeed,
|
||||
),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func aggregateToolSearchAgentArm(pairs []toolSearchAgentPair, search bool) ToolSearchAgentABArmMetrics {
|
||||
metrics := ToolSearchAgentABArmMetrics{Runs: len(pairs)}
|
||||
for _, pair := range pairs {
|
||||
run := pair.direct
|
||||
if search {
|
||||
run = pair.search
|
||||
}
|
||||
metrics.TaskSuccessRate += boolFloat(run.TaskCompleted)
|
||||
metrics.CorrectPlanRate += boolFloat(run.CorrectToolPlan)
|
||||
metrics.UnsafeActionRate += boolFloat(run.UnsafeAction)
|
||||
metrics.AverageContextTokens += run.ContextTokens
|
||||
metrics.AverageToolCalls += run.ToolCalls
|
||||
metrics.AverageLatencyMS += run.LatencyMS
|
||||
if run.RecoveryAttempted {
|
||||
metrics.RecoveryAttempts++
|
||||
metrics.RecoverySuccessRate += boolFloat(run.RecoverySucceeded)
|
||||
}
|
||||
}
|
||||
if metrics.Runs > 0 {
|
||||
denominator := float64(metrics.Runs)
|
||||
metrics.TaskSuccessRate /= denominator
|
||||
metrics.CorrectPlanRate /= denominator
|
||||
metrics.UnsafeActionRate /= denominator
|
||||
metrics.AverageContextTokens /= denominator
|
||||
metrics.AverageToolCalls /= denominator
|
||||
metrics.AverageLatencyMS /= denominator
|
||||
}
|
||||
if metrics.RecoveryAttempts > 0 {
|
||||
metrics.RecoverySuccessRate /= float64(metrics.RecoveryAttempts)
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
func toolSearchCaseClusterBootstrap(pairs []toolSearchAgentPair, value func(ToolSearchAgentABRun) float64, samples int, seed int64) (float64, float64) {
|
||||
byCase := make(map[string][]float64)
|
||||
for _, pair := range pairs {
|
||||
byCase[pair.caseID] = append(byCase[pair.caseID], value(pair.search)-value(pair.direct))
|
||||
}
|
||||
caseIDs := make([]string, 0, len(byCase))
|
||||
caseMeans := make(map[string]float64, len(byCase))
|
||||
for caseID, values := range byCase {
|
||||
caseIDs = append(caseIDs, caseID)
|
||||
for _, value := range values {
|
||||
caseMeans[caseID] += value / float64(len(values))
|
||||
}
|
||||
}
|
||||
sort.Strings(caseIDs)
|
||||
if len(caseIDs) == 1 {
|
||||
value := caseMeans[caseIDs[0]]
|
||||
return value, value
|
||||
}
|
||||
random := rand.New(rand.NewSource(seed))
|
||||
distribution := make([]float64, samples)
|
||||
for sample := range samples {
|
||||
var total float64
|
||||
for range caseIDs {
|
||||
total += caseMeans[caseIDs[random.Intn(len(caseIDs))]]
|
||||
}
|
||||
distribution[sample] = total / float64(len(caseIDs))
|
||||
}
|
||||
sort.Float64s(distribution)
|
||||
return percentile(distribution, 0.025), percentile(distribution, 0.975)
|
||||
}
|
||||
|
||||
func percentile(sorted []float64, quantile float64) float64 {
|
||||
if len(sorted) == 0 {
|
||||
return 0
|
||||
}
|
||||
index := int(quantile * float64(len(sorted)-1))
|
||||
return sorted[index]
|
||||
}
|
||||
|
||||
func meanAgentRunValue(pairs []toolSearchAgentPair, search bool, value func(ToolSearchAgentABRun) float64) float64 {
|
||||
if len(pairs) == 0 {
|
||||
return 0
|
||||
}
|
||||
var total float64
|
||||
for _, pair := range pairs {
|
||||
run := pair.direct
|
||||
if search {
|
||||
run = pair.search
|
||||
}
|
||||
total += value(run)
|
||||
}
|
||||
return total / float64(len(pairs))
|
||||
}
|
||||
|
||||
func boolFloat(value bool) float64 {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// ToolSearchWorkflowEvaluationCase is a reviewed diagnostic workflow. It is
|
||||
// intentionally separate from release qrels: the small in-repository fixture
|
||||
// measures mechanics and cannot approve a ranking configuration.
|
||||
type ToolSearchWorkflowEvaluationCase struct {
|
||||
ID string `json:"id"`
|
||||
Query string `json:"query"`
|
||||
Subqueries []string `json:"subqueries"`
|
||||
Required []string `json:"required"`
|
||||
}
|
||||
|
||||
// ToolSearchRetrievalMetrics reports deterministic ranking metrics. Values are
|
||||
// fractions in [0,1], not percentages.
|
||||
type ToolSearchRetrievalMetrics struct {
|
||||
Cases int `json:"cases"`
|
||||
RecallAt1 float64 `json:"recall_at_1"`
|
||||
RecallAt5 float64 `json:"recall_at_5"`
|
||||
MeanReciprocalRankAt5 float64 `json:"mean_reciprocal_rank_at_5"`
|
||||
ZeroResultRate float64 `json:"zero_result_rate"`
|
||||
}
|
||||
|
||||
// ToolSearchWorkflowMetrics measures whether every required tool is present in
|
||||
// the shared Top-5 budget and the average required-tool recall.
|
||||
type ToolSearchWorkflowMetrics struct {
|
||||
Cases int `json:"cases"`
|
||||
CompleteAt5 float64 `json:"complete_at_5"`
|
||||
RequiredRecallAt5 float64 `json:"required_recall_at_5"`
|
||||
}
|
||||
|
||||
// ToolSearchIdentityTrustMetrics exercises the complete delivered identity
|
||||
// set. Exact filtered checks deliberately exclude each canonical path and
|
||||
// verify that search abstains instead of falling through to a fuzzy sibling.
|
||||
type ToolSearchIdentityTrustMetrics struct {
|
||||
CanonicalCases int `json:"canonical_cases"`
|
||||
CanonicalPassRate float64 `json:"canonical_pass_rate"`
|
||||
PrimaryCLICases int `json:"primary_cli_cases"`
|
||||
PrimaryCLIPassRate float64 `json:"primary_cli_pass_rate"`
|
||||
AliasCases int `json:"alias_cases"`
|
||||
AliasPassRate float64 `json:"alias_pass_rate"`
|
||||
NFKCCases int `json:"nfkc_cases"`
|
||||
NFKCPassRate float64 `json:"nfkc_pass_rate"`
|
||||
ExactFilteredCases int `json:"exact_filtered_cases"`
|
||||
ExactFilteredPassRate float64 `json:"exact_filtered_pass_rate"`
|
||||
}
|
||||
|
||||
// ToolSearchNegativeTrustMetrics is a same-source diagnostic only. A
|
||||
// forbidden exposure means the tool that owns an avoid_when sentence appears
|
||||
// in the ranking; it does not prove that a model would call that tool.
|
||||
type ToolSearchNegativeTrustMetrics struct {
|
||||
Cases int `json:"cases"`
|
||||
ExcludedOverBudget int `json:"excluded_over_budget"`
|
||||
ForbiddenAt1 float64 `json:"forbidden_at_1"`
|
||||
ForbiddenAt5 float64 `json:"forbidden_at_5"`
|
||||
}
|
||||
|
||||
// ToolSearchIntegrityMetrics verifies invariants that do not depend on
|
||||
// relevance labels.
|
||||
type ToolSearchIntegrityMetrics struct {
|
||||
ResponsesEvaluated int `json:"responses_evaluated"`
|
||||
CatalogBindingFailures int `json:"catalog_binding_failures"`
|
||||
UnknownCandidateCount int `json:"unknown_candidate_count"`
|
||||
IneligibleCandidateCount int `json:"ineligible_candidate_count"`
|
||||
ResponseBudgetViolations int `json:"response_budget_violations"`
|
||||
}
|
||||
|
||||
// ToolSearchTrustMetrics keeps contract integrity separate from ranking
|
||||
// relevance. Search rank is never presented as a probability of safe use.
|
||||
type ToolSearchTrustMetrics struct {
|
||||
Identity ToolSearchIdentityTrustMetrics `json:"identity"`
|
||||
Negative ToolSearchNegativeTrustMetrics `json:"negative"`
|
||||
Integrity ToolSearchIntegrityMetrics `json:"integrity"`
|
||||
}
|
||||
|
||||
// ToolSearchContextComparison compares byte-level JSON envelopes, not model
|
||||
// tokens. The ideal navigation path assumes an oracle already knows the right
|
||||
// product; its selection success is therefore deliberately not estimated.
|
||||
type ToolSearchContextComparison struct {
|
||||
ToolCount int `json:"tool_count"`
|
||||
FullSchemaAllBytes int `json:"full_schema_all_bytes"`
|
||||
OverviewBytes int `json:"overview_bytes"`
|
||||
AverageProductBytes float64 `json:"average_product_bytes"`
|
||||
AverageInspectBytes float64 `json:"average_inspect_bytes"`
|
||||
AverageIdealSchemaNavigationBytes float64 `json:"average_ideal_schema_navigation_bytes"`
|
||||
AverageSearchInspectBytes float64 `json:"average_search_inspect_bytes"`
|
||||
ReductionVsFullSchema float64 `json:"reduction_vs_full_schema"`
|
||||
ReductionVsIdealNavigation float64 `json:"reduction_vs_ideal_navigation"`
|
||||
}
|
||||
|
||||
// ToolSearchDiagnosticComparison is generated from the exact typed Catalog
|
||||
// embedded in the binary. It is a diagnostic proxy, not an Agent completion
|
||||
// benchmark: its intent queries come from reviewed use_when prose and therefore
|
||||
// are not independently authored qrels.
|
||||
type ToolSearchDiagnosticComparison struct {
|
||||
Version string `json:"version"`
|
||||
Catalog CatalogVersionRef `json:"catalog"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
TopK int `json:"top_k"`
|
||||
IntentProxy ToolSearchRetrievalMetrics `json:"intent_proxy"`
|
||||
IntentExcludedOverBudget int `json:"intent_excluded_over_budget"`
|
||||
IntentLanguageSlices map[string]ToolSearchRetrievalMetrics `json:"intent_language_slices"`
|
||||
WorkflowRaw ToolSearchWorkflowMetrics `json:"workflow_raw"`
|
||||
WorkflowDecomposed ToolSearchWorkflowMetrics `json:"workflow_decomposed"`
|
||||
Trust ToolSearchTrustMetrics `json:"trust"`
|
||||
Context ToolSearchContextComparison `json:"context"`
|
||||
AgentABStatus string `json:"agent_ab_status"`
|
||||
Limitations []string `json:"limitations"`
|
||||
}
|
||||
|
||||
// BuildDeliveryToolSearchDiagnosticComparison evaluates the shipped Go
|
||||
// retriever against the declaration-assembled Catalog without reading a
|
||||
// generated repository JSON artifact. Results belong under policy-tmp.
|
||||
func BuildDeliveryToolSearchDiagnosticComparison(ctx context.Context, workflows []ToolSearchWorkflowEvaluationCase) (ToolSearchDiagnosticComparison, error) {
|
||||
return BuildDeliveryToolSearchDiagnosticComparisonForAlgorithm(ctx, workflows, DefaultToolSearchConfig().LexicalAlgorithm)
|
||||
}
|
||||
|
||||
// BuildDeliveryToolSearchDiagnosticComparisonForAlgorithm runs one shipped Go
|
||||
// lexical implementation over exactly the same typed Catalog and fixtures.
|
||||
// This keeps shadow algorithm comparison out of the production default.
|
||||
func BuildDeliveryToolSearchDiagnosticComparisonForAlgorithm(ctx context.Context, workflows []ToolSearchWorkflowEvaluationCase, algorithm string) (ToolSearchDiagnosticComparison, error) {
|
||||
if err := deliverySchemaCatalogError(); err != nil {
|
||||
return ToolSearchDiagnosticComparison{}, fmt.Errorf("assemble typed Schema registry for evaluation: %w", err)
|
||||
}
|
||||
loaded := deliverySchemaCatalog()
|
||||
config := DefaultToolSearchConfig()
|
||||
config.LexicalAlgorithm = algorithm
|
||||
config.CatalogSourceHash = loaded.Snapshot.SourceHash
|
||||
config.CatalogSurfaceHash = loaded.Snapshot.SurfaceHash
|
||||
engine, err := NewToolSearchEngine(loaded.Registry, config)
|
||||
if err != nil {
|
||||
return ToolSearchDiagnosticComparison{}, err
|
||||
}
|
||||
allPayload, err := deliverySchemaAllPayload()
|
||||
if err != nil {
|
||||
return ToolSearchDiagnosticComparison{}, fmt.Errorf("render full Schema: %w", err)
|
||||
}
|
||||
overviewPayload, err := deliverySchemaOverviewPayload()
|
||||
if err != nil {
|
||||
return ToolSearchDiagnosticComparison{}, fmt.Errorf("render Schema overview: %w", err)
|
||||
}
|
||||
allBytes, err := compactJSONSize(allPayload)
|
||||
if err != nil {
|
||||
return ToolSearchDiagnosticComparison{}, err
|
||||
}
|
||||
overviewBytes, err := compactJSONSize(stripSchemaPayloadCompact(overviewPayload))
|
||||
if err != nil {
|
||||
return ToolSearchDiagnosticComparison{}, err
|
||||
}
|
||||
|
||||
type proxyCase struct {
|
||||
query string
|
||||
gold string
|
||||
product string
|
||||
}
|
||||
proxyCases := make([]proxyCase, 0)
|
||||
intentExcludedOverBudget := 0
|
||||
for _, product := range loaded.Registry.Products {
|
||||
for _, tool := range product.Tools {
|
||||
for _, query := range tool.Selection.UseWhen {
|
||||
if query != "" && toolSearchQueryWithinBudget(query) {
|
||||
proxyCases = append(proxyCases, proxyCase{query: query, gold: tool.Identity.CanonicalPath, product: product.ID})
|
||||
} else if query != "" {
|
||||
intentExcludedOverBudget++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(proxyCases, func(i, j int) bool {
|
||||
if proxyCases[i].gold == proxyCases[j].gold {
|
||||
return proxyCases[i].query < proxyCases[j].query
|
||||
}
|
||||
return proxyCases[i].gold < proxyCases[j].gold
|
||||
})
|
||||
|
||||
intentAccumulator := &toolSearchRetrievalAccumulator{}
|
||||
languageAccumulators := map[string]*toolSearchRetrievalAccumulator{}
|
||||
var totalProductBytes, totalInspectBytes, totalSearchBytes float64
|
||||
productSizes := make(map[string]int)
|
||||
inspectSizes := make(map[string]int)
|
||||
for _, item := range proxyCases {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ToolSearchDiagnosticComparison{}, err
|
||||
}
|
||||
response, searchErr := engine.Search(ctx, ToolSearchRequest{Query: item.query, Limit: 5, CandidateLimit: 20})
|
||||
if searchErr != nil {
|
||||
return ToolSearchDiagnosticComparison{}, fmt.Errorf("search proxy case %s: %w", item.gold, searchErr)
|
||||
}
|
||||
rank := 0
|
||||
for index, candidate := range response.Candidates {
|
||||
if candidate.CanonicalPath != item.gold {
|
||||
continue
|
||||
}
|
||||
rank = index + 1
|
||||
break
|
||||
}
|
||||
intentAccumulator.observe(rank, len(response.Candidates) == 0)
|
||||
slice := toolSearchQueryLanguageSlice(item.query)
|
||||
if languageAccumulators[slice] == nil {
|
||||
languageAccumulators[slice] = &toolSearchRetrievalAccumulator{}
|
||||
}
|
||||
languageAccumulators[slice].observe(rank, len(response.Candidates) == 0)
|
||||
encodedResponse, marshalErr := json.Marshal(response)
|
||||
if marshalErr != nil {
|
||||
return ToolSearchDiagnosticComparison{}, marshalErr
|
||||
}
|
||||
totalSearchBytes += float64(len(encodedResponse))
|
||||
|
||||
productBytes, ok := productSizes[item.product]
|
||||
if !ok {
|
||||
payload, payloadErr := queryDeliverySchemaPayload([]string{item.product})
|
||||
if payloadErr != nil {
|
||||
return ToolSearchDiagnosticComparison{}, payloadErr
|
||||
}
|
||||
productBytes, payloadErr = compactJSONSize(stripSchemaPayloadCompact(payload))
|
||||
if payloadErr != nil {
|
||||
return ToolSearchDiagnosticComparison{}, payloadErr
|
||||
}
|
||||
productSizes[item.product] = productBytes
|
||||
}
|
||||
totalProductBytes += float64(productBytes)
|
||||
|
||||
inspectBytes, ok := inspectSizes[item.gold]
|
||||
if !ok {
|
||||
payload, payloadErr := queryDeliverySchemaPayload([]string{item.gold})
|
||||
if payloadErr != nil {
|
||||
return ToolSearchDiagnosticComparison{}, payloadErr
|
||||
}
|
||||
payload = stripSchemaPayloadCompact(payload)
|
||||
inspectBytes, payloadErr = compactJSONSize(SchemaInspectV1Response{
|
||||
Version: "schema-inspect.v1",
|
||||
Catalog: CatalogVersionRef{SourceHash: loaded.Snapshot.SourceHash, SurfaceHash: loaded.Snapshot.SurfaceHash},
|
||||
ToolSpec: payload,
|
||||
})
|
||||
if payloadErr != nil {
|
||||
return ToolSearchDiagnosticComparison{}, payloadErr
|
||||
}
|
||||
inspectSizes[item.gold] = inspectBytes
|
||||
}
|
||||
totalInspectBytes += float64(inspectBytes)
|
||||
}
|
||||
|
||||
count := float64(len(proxyCases))
|
||||
intent := intentAccumulator.metrics()
|
||||
languageSlices := make(map[string]ToolSearchRetrievalMetrics, len(languageAccumulators))
|
||||
for slice, accumulator := range languageAccumulators {
|
||||
languageSlices[slice] = accumulator.metrics()
|
||||
}
|
||||
contextComparison := ToolSearchContextComparison{
|
||||
ToolCount: len(loaded.Index.CanonicalPaths()),
|
||||
FullSchemaAllBytes: allBytes,
|
||||
OverviewBytes: overviewBytes,
|
||||
}
|
||||
if count > 0 {
|
||||
contextComparison.AverageProductBytes = totalProductBytes / count
|
||||
contextComparison.AverageInspectBytes = totalInspectBytes / count
|
||||
contextComparison.AverageIdealSchemaNavigationBytes = float64(overviewBytes) + contextComparison.AverageProductBytes + contextComparison.AverageInspectBytes
|
||||
contextComparison.AverageSearchInspectBytes = totalSearchBytes/count + contextComparison.AverageInspectBytes
|
||||
contextComparison.ReductionVsFullSchema = reduction(float64(allBytes), contextComparison.AverageSearchInspectBytes)
|
||||
contextComparison.ReductionVsIdealNavigation = reduction(contextComparison.AverageIdealSchemaNavigationBytes, contextComparison.AverageSearchInspectBytes)
|
||||
}
|
||||
|
||||
rawWorkflow, decomposedWorkflow, err := evaluateToolSearchWorkflows(ctx, engine, workflows)
|
||||
if err != nil {
|
||||
return ToolSearchDiagnosticComparison{}, err
|
||||
}
|
||||
trust, err := evaluateToolSearchTrust(ctx, engine, loaded.Registry)
|
||||
if err != nil {
|
||||
return ToolSearchDiagnosticComparison{}, err
|
||||
}
|
||||
return ToolSearchDiagnosticComparison{
|
||||
Version: "tool-search-diagnostic-comparison.v1",
|
||||
Catalog: engine.catalogVersion(),
|
||||
Algorithm: engine.lexical.Name(),
|
||||
TopK: 5,
|
||||
IntentProxy: intent,
|
||||
IntentExcludedOverBudget: intentExcludedOverBudget,
|
||||
IntentLanguageSlices: languageSlices,
|
||||
WorkflowRaw: rawWorkflow,
|
||||
WorkflowDecomposed: decomposedWorkflow,
|
||||
Trust: trust,
|
||||
Context: contextComparison,
|
||||
AgentABStatus: "not_run_requires_independent_tasks_and_model_runs",
|
||||
Limitations: []string{
|
||||
"intent_proxy queries are reviewed use_when prose from the same Catalog and are not independent qrels",
|
||||
"selection prose that exceeds the production query byte or rune budget is counted and excluded rather than weakening runtime limits",
|
||||
"direct Schema navigation bytes assume an oracle selects the correct product and do not estimate model selection success",
|
||||
"search plus inspect bytes also inspect the gold leaf even when retrieval misses and are an oracle-assisted capacity upper bound, not observed Agent cost",
|
||||
"workflow decomposition uses reviewed fixture subqueries and is an upper-bound diagnostic",
|
||||
"negative queries are same-source avoid_when prose without alternative gold and measure candidate exposure rather than Agent calls",
|
||||
"byte counts are compact JSON bytes rather than provider-specific tokenizer counts",
|
||||
"true Agent task success, unsafe action rate, recovery success, latency, and token cost require paired Agent A/B runs",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type toolSearchRetrievalAccumulator struct {
|
||||
cases int
|
||||
hitAt1 int
|
||||
hitAt5 int
|
||||
reciprocal float64
|
||||
zeroResults int
|
||||
}
|
||||
|
||||
func (a *toolSearchRetrievalAccumulator) observe(rank int, zeroResults bool) {
|
||||
a.cases++
|
||||
if zeroResults {
|
||||
a.zeroResults++
|
||||
}
|
||||
if rank == 1 {
|
||||
a.hitAt1++
|
||||
}
|
||||
if rank > 0 && rank <= 5 {
|
||||
a.hitAt5++
|
||||
a.reciprocal += 1 / float64(rank)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *toolSearchRetrievalAccumulator) metrics() ToolSearchRetrievalMetrics {
|
||||
result := ToolSearchRetrievalMetrics{Cases: a.cases}
|
||||
if a.cases == 0 {
|
||||
return result
|
||||
}
|
||||
denominator := float64(a.cases)
|
||||
result.RecallAt1 = float64(a.hitAt1) / denominator
|
||||
result.RecallAt5 = float64(a.hitAt5) / denominator
|
||||
result.MeanReciprocalRankAt5 = a.reciprocal / denominator
|
||||
result.ZeroResultRate = float64(a.zeroResults) / denominator
|
||||
return result
|
||||
}
|
||||
|
||||
func toolSearchQueryLanguageSlice(query string) string {
|
||||
hasChinese := false
|
||||
hasASCIIWord := false
|
||||
for _, character := range query {
|
||||
if unicode.Is(unicode.Han, character) {
|
||||
hasChinese = true
|
||||
}
|
||||
if character <= unicode.MaxASCII && (unicode.IsLetter(character) || unicode.IsDigit(character)) {
|
||||
hasASCIIWord = true
|
||||
}
|
||||
}
|
||||
if hasChinese && hasASCIIWord {
|
||||
return "mixed_chinese_ascii"
|
||||
}
|
||||
if hasChinese {
|
||||
return "chinese_only"
|
||||
}
|
||||
return "non_chinese"
|
||||
}
|
||||
|
||||
func evaluateToolSearchTrust(ctx context.Context, engine *ToolSearchEngine, registry SchemaRegistry) (ToolSearchTrustMetrics, error) {
|
||||
result := ToolSearchTrustMetrics{}
|
||||
var canonicalPassed, cliPassed, aliasPassed, nfkcPassed, filteredPassed int
|
||||
var forbiddenAt1, forbiddenAt5 int
|
||||
for _, product := range registry.Products {
|
||||
for _, tool := range product.Tools {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Identity.CanonicalCases++
|
||||
response, err := engine.Search(ctx, ToolSearchRequest{Query: tool.Identity.CanonicalPath})
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("audit exact canonical %s: %w", tool.Identity.CanonicalPath, err)
|
||||
}
|
||||
observeToolSearchIntegrity(&result.Integrity, engine, response)
|
||||
if exactSearchMatches(response, tool.Identity.CanonicalPath) {
|
||||
canonicalPassed++
|
||||
}
|
||||
|
||||
if cliPath := tool.Identity.PrimaryCLIPath; cliPath != "" {
|
||||
result.Identity.PrimaryCLICases++
|
||||
response, err = engine.Search(ctx, ToolSearchRequest{Query: cliPath})
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("audit exact CLI %s: %w", cliPath, err)
|
||||
}
|
||||
observeToolSearchIntegrity(&result.Integrity, engine, response)
|
||||
if exactSearchMatches(response, tool.Identity.CanonicalPath) {
|
||||
cliPassed++
|
||||
}
|
||||
}
|
||||
for _, alias := range tool.Identity.Aliases {
|
||||
result.Identity.AliasCases++
|
||||
response, err = engine.Search(ctx, ToolSearchRequest{Query: alias})
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("audit exact alias %s: %w", alias, err)
|
||||
}
|
||||
observeToolSearchIntegrity(&result.Integrity, engine, response)
|
||||
if exactSearchMatches(response, tool.Identity.CanonicalPath) {
|
||||
aliasPassed++
|
||||
}
|
||||
}
|
||||
nfkcQuery := toolSearchFullwidthASCII(tool.Identity.CanonicalPath)
|
||||
if nfkcQuery != tool.Identity.CanonicalPath {
|
||||
result.Identity.NFKCCases++
|
||||
response, err = engine.Search(ctx, ToolSearchRequest{Query: nfkcQuery})
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("audit NFKC identity %s: %w", tool.Identity.CanonicalPath, err)
|
||||
}
|
||||
observeToolSearchIntegrity(&result.Integrity, engine, response)
|
||||
if exactSearchMatches(response, tool.Identity.CanonicalPath) {
|
||||
nfkcPassed++
|
||||
}
|
||||
}
|
||||
|
||||
result.Identity.ExactFilteredCases++
|
||||
response, err = engine.Search(ctx, ToolSearchRequest{
|
||||
Query: tool.Identity.CanonicalPath,
|
||||
ExcludeCanonicalPaths: []string{tool.Identity.CanonicalPath},
|
||||
})
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("audit exact filtered %s: %w", tool.Identity.CanonicalPath, err)
|
||||
}
|
||||
observeToolSearchIntegrity(&result.Integrity, engine, response)
|
||||
if response.Strategy == "exact_filtered" && response.Abstained && len(response.Candidates) == 0 && response.ExactFiltered != nil && response.ExactFiltered.CanonicalPath == tool.Identity.CanonicalPath && response.ExactFiltered.Reason == "excluded" {
|
||||
filteredPassed++
|
||||
}
|
||||
|
||||
for _, query := range tool.Selection.AvoidWhen {
|
||||
if query == "" {
|
||||
continue
|
||||
}
|
||||
if !toolSearchQueryWithinBudget(query) {
|
||||
result.Negative.ExcludedOverBudget++
|
||||
continue
|
||||
}
|
||||
result.Negative.Cases++
|
||||
response, err = engine.Search(ctx, ToolSearchRequest{Query: query, Limit: 5, CandidateLimit: 20})
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("audit negative %s: %w", tool.Identity.CanonicalPath, err)
|
||||
}
|
||||
observeToolSearchIntegrity(&result.Integrity, engine, response)
|
||||
for rank, candidate := range response.Candidates {
|
||||
if candidate.CanonicalPath != tool.Identity.CanonicalPath {
|
||||
continue
|
||||
}
|
||||
if rank == 0 {
|
||||
forbiddenAt1++
|
||||
}
|
||||
forbiddenAt5++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if result.Identity.CanonicalCases > 0 {
|
||||
result.Identity.CanonicalPassRate = float64(canonicalPassed) / float64(result.Identity.CanonicalCases)
|
||||
}
|
||||
if result.Identity.PrimaryCLICases > 0 {
|
||||
result.Identity.PrimaryCLIPassRate = float64(cliPassed) / float64(result.Identity.PrimaryCLICases)
|
||||
}
|
||||
if result.Identity.AliasCases > 0 {
|
||||
result.Identity.AliasPassRate = float64(aliasPassed) / float64(result.Identity.AliasCases)
|
||||
}
|
||||
if result.Identity.NFKCCases > 0 {
|
||||
result.Identity.NFKCPassRate = float64(nfkcPassed) / float64(result.Identity.NFKCCases)
|
||||
}
|
||||
if result.Identity.ExactFilteredCases > 0 {
|
||||
result.Identity.ExactFilteredPassRate = float64(filteredPassed) / float64(result.Identity.ExactFilteredCases)
|
||||
}
|
||||
if result.Negative.Cases > 0 {
|
||||
result.Negative.ForbiddenAt1 = float64(forbiddenAt1) / float64(result.Negative.Cases)
|
||||
result.Negative.ForbiddenAt5 = float64(forbiddenAt5) / float64(result.Negative.Cases)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func toolSearchFullwidthASCII(value string) string {
|
||||
runes := []rune(value)
|
||||
for index, character := range runes {
|
||||
if character >= '!' && character <= '~' {
|
||||
runes[index] = character + 0xFEE0
|
||||
}
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
func toolSearchQueryWithinBudget(query string) bool {
|
||||
return len(query) <= maxToolSearchQueryBytes && utf8.RuneCountInString(query) <= maxToolSearchQueryRunes
|
||||
}
|
||||
|
||||
func exactSearchMatches(response ToolSearchResponse, canonical string) bool {
|
||||
return response.Strategy == "exact_guard" && len(response.Candidates) == 1 && response.Candidates[0].CanonicalPath == canonical && response.Candidates[0].RequiresInspect
|
||||
}
|
||||
|
||||
func observeToolSearchIntegrity(metrics *ToolSearchIntegrityMetrics, engine *ToolSearchEngine, response ToolSearchResponse) {
|
||||
metrics.ResponsesEvaluated++
|
||||
if response.Catalog != engine.catalogVersion() {
|
||||
metrics.CatalogBindingFailures++
|
||||
}
|
||||
if encoded, err := json.Marshal(response); err != nil || len(encoded) > maxToolSearchResponseBytes {
|
||||
metrics.ResponseBudgetViolations++
|
||||
}
|
||||
for _, candidate := range response.Candidates {
|
||||
document, ok := engine.documents[candidate.CanonicalPath]
|
||||
if !ok {
|
||||
metrics.UnknownCandidateCount++
|
||||
continue
|
||||
}
|
||||
if !toolSearchEligible(document.tool, nil, nil, nil) {
|
||||
metrics.IneligibleCandidateCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func evaluateToolSearchWorkflows(ctx context.Context, engine *ToolSearchEngine, workflows []ToolSearchWorkflowEvaluationCase) (ToolSearchWorkflowMetrics, ToolSearchWorkflowMetrics, error) {
|
||||
raw := ToolSearchWorkflowMetrics{Cases: len(workflows)}
|
||||
decomposed := ToolSearchWorkflowMetrics{Cases: len(workflows)}
|
||||
if len(workflows) == 0 {
|
||||
return raw, decomposed, nil
|
||||
}
|
||||
for _, workflow := range workflows {
|
||||
if workflow.ID == "" || workflow.Query == "" || len(workflow.Required) == 0 {
|
||||
return raw, decomposed, fmt.Errorf("workflow evaluation case must have id, query and required tools")
|
||||
}
|
||||
one, err := engine.Search(ctx, ToolSearchRequest{Query: workflow.Query, Limit: 5, CandidateLimit: 20})
|
||||
if err != nil {
|
||||
return raw, decomposed, fmt.Errorf("search workflow %s: %w", workflow.ID, err)
|
||||
}
|
||||
many, err := engine.SearchSubqueries(ctx, workflow.Subqueries, ToolSearchRequest{Query: workflow.Query, Limit: 5, CandidateLimit: 20})
|
||||
if err != nil {
|
||||
return raw, decomposed, fmt.Errorf("search decomposed workflow %s: %w", workflow.ID, err)
|
||||
}
|
||||
accumulateWorkflowMetrics(&raw, one.Candidates, workflow.Required)
|
||||
accumulateWorkflowMetrics(&decomposed, many.Candidates, workflow.Required)
|
||||
}
|
||||
denominator := float64(len(workflows))
|
||||
raw.CompleteAt5 /= denominator
|
||||
raw.RequiredRecallAt5 /= denominator
|
||||
decomposed.CompleteAt5 /= denominator
|
||||
decomposed.RequiredRecallAt5 /= denominator
|
||||
return raw, decomposed, nil
|
||||
}
|
||||
|
||||
func accumulateWorkflowMetrics(metrics *ToolSearchWorkflowMetrics, candidates []ToolReference, required []string) {
|
||||
found := make(map[string]bool, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
found[candidate.CanonicalPath] = true
|
||||
}
|
||||
hits := 0
|
||||
for _, canonical := range required {
|
||||
if found[canonical] {
|
||||
hits++
|
||||
}
|
||||
}
|
||||
metrics.RequiredRecallAt5 += float64(hits) / float64(len(required))
|
||||
if hits == len(required) {
|
||||
metrics.CompleteAt5++
|
||||
}
|
||||
}
|
||||
|
||||
func compactJSONSize(value any) (int, error) {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("marshal compact JSON: %w", err)
|
||||
}
|
||||
return len(encoded), nil
|
||||
}
|
||||
|
||||
func reduction(baseline, candidate float64) float64 {
|
||||
if baseline <= 0 {
|
||||
return 0
|
||||
}
|
||||
return math.Max(-1, math.Min(1, 1-candidate/baseline))
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAggregateToolSearchAgentAB(t *testing.T) {
|
||||
catalog := CatalogVersionRef{SourceHash: "sha256:source", SurfaceHash: "sha256:surface"}
|
||||
input := ToolSearchAgentABInput{
|
||||
Version: "tool-search-agent-ab.v1",
|
||||
Catalog: catalog,
|
||||
Runs: []ToolSearchAgentABRun{
|
||||
{CaseID: "a", Trial: 0, Arm: ToolSearchAgentArmDirectSchema, CorrectToolPlan: true, ContextTokens: 100, ToolCalls: 3, LatencyMS: 1000},
|
||||
{CaseID: "a", Trial: 0, Arm: ToolSearchAgentArmSearchInspect, TaskCompleted: true, CorrectToolPlan: true, ContextTokens: 40, ToolCalls: 2, LatencyMS: 800},
|
||||
{CaseID: "b", Trial: 0, Arm: ToolSearchAgentArmDirectSchema, TaskCompleted: true, CorrectToolPlan: true, UnsafeAction: true, RecoveryAttempted: true, ContextTokens: 120, ToolCalls: 4, LatencyMS: 1200},
|
||||
{CaseID: "b", Trial: 0, Arm: ToolSearchAgentArmSearchInspect, TaskCompleted: true, CorrectToolPlan: true, RecoveryAttempted: true, RecoverySucceeded: true, ContextTokens: 50, ToolCalls: 3, LatencyMS: 900},
|
||||
},
|
||||
}
|
||||
report, err := AggregateToolSearchAgentAB(input)
|
||||
if err != nil {
|
||||
t.Fatalf("AggregateToolSearchAgentAB() error = %v", err)
|
||||
}
|
||||
if report.Cases != 2 || report.PairedTrials != 2 {
|
||||
t.Fatalf("pair counts = cases %d trials %d", report.Cases, report.PairedTrials)
|
||||
}
|
||||
if report.DirectSchema.TaskSuccessRate != 0.5 || report.SearchInspect.TaskSuccessRate != 1 {
|
||||
t.Fatalf("unexpected success rates: direct=%v search=%v", report.DirectSchema.TaskSuccessRate, report.SearchInspect.TaskSuccessRate)
|
||||
}
|
||||
if delta := report.Deltas["context_tokens"].Delta; delta != -65 {
|
||||
t.Fatalf("context delta = %v, want -65", delta)
|
||||
}
|
||||
if _, ok := report.Deltas["recovery_success_rate_paired_attempts"]; !ok {
|
||||
t.Fatal("paired recovery delta missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateToolSearchAgentABRejectsUnpairedRuns(t *testing.T) {
|
||||
_, err := AggregateToolSearchAgentAB(ToolSearchAgentABInput{
|
||||
Version: "tool-search-agent-ab.v1",
|
||||
Catalog: CatalogVersionRef{SourceHash: "source", SurfaceHash: "surface"},
|
||||
Runs: []ToolSearchAgentABRun{
|
||||
{CaseID: "a", Arm: ToolSearchAgentArmDirectSchema},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("AggregateToolSearchAgentAB() accepted an unpaired run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreToolSearchAgentPlanningAB(t *testing.T) {
|
||||
workflows := []ToolSearchWorkflowEvaluationCase{
|
||||
{ID: "one", Required: []string{"chat.search_groups", "chat.send_personal_message"}},
|
||||
{ID: "two", Required: []string{"drive.upload", "drive.add_permission"}},
|
||||
}
|
||||
direct := ToolSearchAgentPlanOutput{Results: []ToolSearchAgentPlanResult{
|
||||
{ID: "one", CanonicalPaths: []string{"chat.search_groups", "chat.send_personal_message"}},
|
||||
{ID: "two", CanonicalPaths: []string{"drive.upload", "drive.add_permission"}},
|
||||
}}
|
||||
search := ToolSearchAgentPlanOutput{Results: []ToolSearchAgentPlanResult{
|
||||
{ID: "one", CanonicalPaths: []string{"chat.list_owned_or_admin_groups", "chat.search_groups", "chat.send_personal_message"}},
|
||||
{ID: "two", CanonicalPaths: []string{"drive.upload", "drive.add_permission"}},
|
||||
}}
|
||||
tools := []ToolSpec{
|
||||
toolSearchTestTool("chat", "search_groups", "chat search-groups", "搜索群聊", "read"),
|
||||
toolSearchTestTool("chat", "send_personal_message", "chat message send", "发送消息", "write"),
|
||||
toolSearchTestTool("drive", "upload", "drive upload", "上传文件", "write"),
|
||||
toolSearchTestTool("drive", "add_permission", "drive permission add", "添加权限", "write"),
|
||||
toolSearchTestTool("chat", "list_owned_or_admin_groups", "chat list-groups", "列出群聊", "read"),
|
||||
}
|
||||
index, indexErr := (SchemaRegistry{Kind: "schema", Level: "catalog", Products: []ProductSpec{
|
||||
{ID: "chat", Tools: []ToolSpec{tools[0], tools[1], tools[4]}},
|
||||
{ID: "drive", Tools: tools[2:4]},
|
||||
}}).Index()
|
||||
if indexErr != nil {
|
||||
t.Fatal(indexErr)
|
||||
}
|
||||
report, err := scoreToolSearchAgentPlanningAB(index, "test-model", workflows, direct, search)
|
||||
if err != nil {
|
||||
t.Fatalf("ScoreToolSearchAgentPlanningAB() error = %v", err)
|
||||
}
|
||||
if report.DirectSchema.ExactMinimalRate != 1 || report.SearchInspect.ExactMinimalRate != 0.5 {
|
||||
t.Fatalf("minimal rates = direct %v search %v", report.DirectSchema.ExactMinimalRate, report.SearchInspect.ExactMinimalRate)
|
||||
}
|
||||
if report.SearchInspect.CompleteRate != 1 || report.SearchInspect.UnnecessaryTools != 1 {
|
||||
t.Fatalf("search metrics = %+v", report.SearchInspect)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The product-cluster paired bootstrap uses a fixed sample count and seed so a
|
||||
// sealed qrels run stays reproducible; both are published in the report so an
|
||||
// auditor does not have to read them out of the source.
|
||||
const (
|
||||
toolSearchIndependentBootstrapSamples = 10_000
|
||||
toolSearchIndependentBootstrapSeed = 20260813
|
||||
)
|
||||
|
||||
// ToolSearchIndependentQrel is an independently authored graded relevance
|
||||
// judgment. Relevance uses the frozen 1..3 qrels scale.
|
||||
type ToolSearchIndependentQrel struct {
|
||||
Canonical string `json:"canonical"`
|
||||
Relevance int `json:"relevance"`
|
||||
}
|
||||
|
||||
type ToolSearchIndependentWorkflow struct {
|
||||
Subqueries []string `json:"subqueries,omitempty"`
|
||||
Required []string `json:"required"`
|
||||
}
|
||||
|
||||
type ToolSearchIndependentCase struct {
|
||||
ID string `json:"id"`
|
||||
Query string `json:"query"`
|
||||
Language string `json:"language"`
|
||||
Qrels []ToolSearchIndependentQrel `json:"qrels"`
|
||||
Forbidden []string `json:"forbidden,omitempty"`
|
||||
AlternativeGold []string `json:"alternative_gold,omitempty"`
|
||||
Workflow *ToolSearchIndependentWorkflow `json:"workflow,omitempty"`
|
||||
ConfusionFamily []string `json:"confusion_family,omitempty"`
|
||||
}
|
||||
|
||||
type ToolSearchIndependentRankingMetrics struct {
|
||||
Cases int `json:"cases"`
|
||||
RecallAt1 float64 `json:"recall_at_1"`
|
||||
RecallAt5 float64 `json:"recall_at_5"`
|
||||
MeanReciprocalRankAt5 float64 `json:"mean_reciprocal_rank_at_5"`
|
||||
NDCGAt5 float64 `json:"ndcg_at_5"`
|
||||
ZeroResultRate float64 `json:"zero_result_rate"`
|
||||
}
|
||||
|
||||
type ToolSearchIndependentSafetyMetrics struct {
|
||||
ForbiddenCases int `json:"forbidden_cases"`
|
||||
ForbiddenExposureAt5 float64 `json:"forbidden_exposure_at_5"`
|
||||
AlternativeRecallAt5 float64 `json:"alternative_recall_at_5"`
|
||||
SiblingConfusionCases int `json:"sibling_confusion_cases"`
|
||||
SiblingExposureAt5 float64 `json:"sibling_exposure_at_5"`
|
||||
}
|
||||
|
||||
type ToolSearchIndependentReport struct {
|
||||
Version string `json:"version"`
|
||||
Catalog CatalogVersionRef `json:"catalog"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
ControlAlgorithm string `json:"control_algorithm"`
|
||||
Overall ToolSearchIndependentRankingMetrics `json:"overall"`
|
||||
ControlOverall ToolSearchIndependentRankingMetrics `json:"control_overall"`
|
||||
RecallAt5Delta float64 `json:"recall_at_5_delta"`
|
||||
ProductClusterCI95 ToolSearchConfidenceInterval `json:"product_cluster_recall_at_5_delta_ci_95"`
|
||||
LanguageSlices map[string]ToolSearchIndependentRankingMetrics `json:"language_slices"`
|
||||
Safety ToolSearchIndependentSafetyMetrics `json:"safety"`
|
||||
Workflow ToolSearchWorkflowMetrics `json:"workflow"`
|
||||
// BootstrapSamples/BootstrapSeed are set only by the paired delivery
|
||||
// evaluation that computes ProductClusterCI95; they make that interval
|
||||
// reproducible without reading the source.
|
||||
BootstrapSamples int `json:"bootstrap_samples,omitempty"`
|
||||
BootstrapSeed int64 `json:"bootstrap_seed,omitempty"`
|
||||
}
|
||||
|
||||
type ToolSearchConfidenceInterval struct {
|
||||
Lower float64 `json:"lower"`
|
||||
Upper float64 `json:"upper"`
|
||||
}
|
||||
|
||||
type independentRankingAccumulator struct {
|
||||
metrics ToolSearchIndependentRankingMetrics
|
||||
recall1 float64
|
||||
recall5 float64
|
||||
mrr5 float64
|
||||
ndcg5 float64
|
||||
zero int
|
||||
}
|
||||
|
||||
// BuildDeliveryToolSearchIndependentEvaluation evaluates the action-ranking
|
||||
// candidate against the shipped fielded BM25 control on sealed external qrels.
|
||||
// It never tunes parameters or reads Catalog JSON; the caller owns qrels
|
||||
// sealing and threshold enforcement. The candidate may become the runtime
|
||||
// default only after the independently frozen default-switch gate passes.
|
||||
func BuildDeliveryToolSearchIndependentEvaluation(ctx context.Context, cases []ToolSearchIndependentCase) (ToolSearchIndependentReport, error) {
|
||||
if err := deliverySchemaCatalogError(); err != nil {
|
||||
return ToolSearchIndependentReport{}, err
|
||||
}
|
||||
loaded := deliverySchemaCatalog()
|
||||
candidateConfig := DefaultToolSearchConfig()
|
||||
candidateConfig.LexicalAlgorithm = ToolSearchLexicalBM25Action
|
||||
candidateConfig.CatalogSourceHash = loaded.Snapshot.SourceHash
|
||||
candidateConfig.CatalogSurfaceHash = loaded.Snapshot.SurfaceHash
|
||||
engine, err := NewToolSearchEngine(loaded.Registry, candidateConfig)
|
||||
if err != nil {
|
||||
return ToolSearchIndependentReport{}, err
|
||||
}
|
||||
report, err := EvaluateToolSearchIndependent(ctx, engine, cases)
|
||||
if err != nil {
|
||||
return ToolSearchIndependentReport{}, err
|
||||
}
|
||||
controlConfig := DefaultToolSearchConfig()
|
||||
controlConfig.CatalogSourceHash = loaded.Snapshot.SourceHash
|
||||
controlConfig.CatalogSurfaceHash = loaded.Snapshot.SurfaceHash
|
||||
control, err := NewToolSearchEngine(loaded.Registry, controlConfig)
|
||||
if err != nil {
|
||||
return ToolSearchIndependentReport{}, err
|
||||
}
|
||||
controlReport, err := EvaluateToolSearchIndependent(ctx, control, cases)
|
||||
if err != nil {
|
||||
return ToolSearchIndependentReport{}, err
|
||||
}
|
||||
report.ControlAlgorithm = control.lexical.Name()
|
||||
report.ControlOverall = controlReport.Overall
|
||||
report.RecallAt5Delta = report.Overall.RecallAt5 - report.ControlOverall.RecallAt5
|
||||
report.ProductClusterCI95, err = pairedProductClusterRecallCI(ctx, engine, control, cases, toolSearchIndependentBootstrapSamples, toolSearchIndependentBootstrapSeed)
|
||||
if err != nil {
|
||||
return ToolSearchIndependentReport{}, err
|
||||
}
|
||||
report.BootstrapSamples = toolSearchIndependentBootstrapSamples
|
||||
report.BootstrapSeed = toolSearchIndependentBootstrapSeed
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func EvaluateToolSearchIndependent(ctx context.Context, engine *ToolSearchEngine, cases []ToolSearchIndependentCase) (ToolSearchIndependentReport, error) {
|
||||
if engine == nil || len(cases) == 0 {
|
||||
return ToolSearchIndependentReport{}, fmt.Errorf("independent evaluation requires an engine and non-empty cases")
|
||||
}
|
||||
overall := &independentRankingAccumulator{}
|
||||
slices := map[string]*independentRankingAccumulator{}
|
||||
var forbiddenCases, forbiddenExposed int
|
||||
var siblingCases, siblingExposed int
|
||||
var alternativeRecall float64
|
||||
workflowCases := 0
|
||||
workflowComplete := 0
|
||||
workflowRequiredRecall := 0.0
|
||||
for _, item := range cases {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ToolSearchIndependentReport{}, err
|
||||
}
|
||||
response, searchErr := engine.Search(ctx, ToolSearchRequest{Query: item.Query, Limit: 5, CandidateLimit: 20})
|
||||
if searchErr != nil {
|
||||
return ToolSearchIndependentReport{}, fmt.Errorf("evaluate %s: %w", item.ID, searchErr)
|
||||
}
|
||||
overall.observe(response.Candidates, item.Qrels)
|
||||
if slices[item.Language] == nil {
|
||||
slices[item.Language] = &independentRankingAccumulator{}
|
||||
}
|
||||
slices[item.Language].observe(response.Candidates, item.Qrels)
|
||||
if len(item.Forbidden) > 0 {
|
||||
forbiddenCases++
|
||||
if candidatesContainAny(response.Candidates, item.Forbidden) {
|
||||
forbiddenExposed++
|
||||
}
|
||||
alternativeRecall += candidateSetRecall(response.Candidates, item.AlternativeGold)
|
||||
}
|
||||
if len(item.ConfusionFamily) > 0 {
|
||||
siblingCases++
|
||||
if candidatesContainAny(response.Candidates, item.ConfusionFamily) {
|
||||
siblingExposed++
|
||||
}
|
||||
}
|
||||
if item.Workflow != nil {
|
||||
workflowCases++
|
||||
workflowResponse := response
|
||||
if len(item.Workflow.Subqueries) > 0 {
|
||||
workflowResponse, searchErr = engine.SearchSubqueries(ctx, item.Workflow.Subqueries, ToolSearchRequest{Query: item.Query, Limit: 5, CandidateLimit: 20})
|
||||
if searchErr != nil {
|
||||
return ToolSearchIndependentReport{}, fmt.Errorf("evaluate workflow %s: %w", item.ID, searchErr)
|
||||
}
|
||||
}
|
||||
recall := candidateSetRecall(workflowResponse.Candidates, item.Workflow.Required)
|
||||
workflowRequiredRecall += recall
|
||||
if recall == 1 {
|
||||
workflowComplete++
|
||||
}
|
||||
}
|
||||
}
|
||||
report := ToolSearchIndependentReport{
|
||||
Version: "tool-search-independent-evaluation.v1", Catalog: engine.catalogVersion(), Algorithm: engine.lexical.Name(),
|
||||
Overall: overall.result(), LanguageSlices: map[string]ToolSearchIndependentRankingMetrics{},
|
||||
Safety: ToolSearchIndependentSafetyMetrics{ForbiddenCases: forbiddenCases, SiblingConfusionCases: siblingCases},
|
||||
Workflow: ToolSearchWorkflowMetrics{Cases: workflowCases},
|
||||
}
|
||||
for name, accumulator := range slices {
|
||||
report.LanguageSlices[name] = accumulator.result()
|
||||
}
|
||||
if forbiddenCases > 0 {
|
||||
report.Safety.ForbiddenExposureAt5 = float64(forbiddenExposed) / float64(forbiddenCases)
|
||||
report.Safety.AlternativeRecallAt5 = alternativeRecall / float64(forbiddenCases)
|
||||
}
|
||||
if siblingCases > 0 {
|
||||
report.Safety.SiblingExposureAt5 = float64(siblingExposed) / float64(siblingCases)
|
||||
}
|
||||
if workflowCases > 0 {
|
||||
report.Workflow.CompleteAt5 = float64(workflowComplete) / float64(workflowCases)
|
||||
report.Workflow.RequiredRecallAt5 = workflowRequiredRecall / float64(workflowCases)
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func pairedProductClusterRecallCI(ctx context.Context, candidate, control *ToolSearchEngine, cases []ToolSearchIndependentCase, iterations int, seed int64) (ToolSearchConfidenceInterval, error) {
|
||||
clusters := map[string][]float64{}
|
||||
for _, item := range cases {
|
||||
candidateResponse, err := candidate.Search(ctx, ToolSearchRequest{Query: item.Query, Limit: 5, CandidateLimit: 20})
|
||||
if err != nil {
|
||||
return ToolSearchConfidenceInterval{}, err
|
||||
}
|
||||
controlResponse, err := control.Search(ctx, ToolSearchRequest{Query: item.Query, Limit: 5, CandidateLimit: 20})
|
||||
if err != nil {
|
||||
return ToolSearchConfidenceInterval{}, err
|
||||
}
|
||||
product := "unknown"
|
||||
if len(item.Qrels) > 0 {
|
||||
if split := strings.SplitN(item.Qrels[0].Canonical, ".", 2); len(split) > 0 {
|
||||
product = split[0]
|
||||
}
|
||||
}
|
||||
clusters[product] = append(clusters[product], candidateSetRecall(candidateResponse.Candidates, qrelCanonicals(item.Qrels))-candidateSetRecall(controlResponse.Candidates, qrelCanonicals(item.Qrels)))
|
||||
}
|
||||
clusterNames := make([]string, 0, len(clusters))
|
||||
clusterMeans := map[string]float64{}
|
||||
for name, values := range clusters {
|
||||
clusterNames = append(clusterNames, name)
|
||||
for _, value := range values {
|
||||
clusterMeans[name] += value
|
||||
}
|
||||
clusterMeans[name] /= float64(len(values))
|
||||
}
|
||||
sort.Strings(clusterNames)
|
||||
if len(clusterNames) == 0 || iterations <= 0 {
|
||||
return ToolSearchConfidenceInterval{}, fmt.Errorf("paired product bootstrap requires cases and iterations")
|
||||
}
|
||||
random := rand.New(rand.NewSource(seed)) // #nosec G404 -- deterministic evaluation bootstrap
|
||||
values := make([]float64, iterations)
|
||||
for iteration := range values {
|
||||
for sample := 0; sample < len(clusterNames); sample++ {
|
||||
values[iteration] += clusterMeans[clusterNames[random.Intn(len(clusterNames))]]
|
||||
}
|
||||
values[iteration] /= float64(len(clusterNames))
|
||||
}
|
||||
sort.Float64s(values)
|
||||
return ToolSearchConfidenceInterval{Lower: percentile(values, 0.025), Upper: percentile(values, 0.975)}, nil
|
||||
}
|
||||
|
||||
func qrelCanonicals(qrels []ToolSearchIndependentQrel) []string {
|
||||
values := make([]string, 0, len(qrels))
|
||||
for _, qrel := range qrels {
|
||||
values = append(values, qrel.Canonical)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func (a *independentRankingAccumulator) observe(candidates []ToolReference, qrels []ToolSearchIndependentQrel) {
|
||||
a.metrics.Cases++
|
||||
if len(candidates) == 0 {
|
||||
a.zero++
|
||||
}
|
||||
relevance := make(map[string]int, len(qrels))
|
||||
ideal := make([]int, 0, len(qrels))
|
||||
for _, qrel := range qrels {
|
||||
relevance[qrel.Canonical] = qrel.Relevance
|
||||
ideal = append(ideal, qrel.Relevance)
|
||||
}
|
||||
found := 0
|
||||
first := 0
|
||||
dcg := 0.0
|
||||
for rank, candidate := range candidates {
|
||||
if rank >= 5 {
|
||||
break
|
||||
}
|
||||
rel := relevance[candidate.CanonicalPath]
|
||||
if rel == 0 {
|
||||
continue
|
||||
}
|
||||
found++
|
||||
if first == 0 {
|
||||
first = rank + 1
|
||||
}
|
||||
dcg += (math.Pow(2, float64(rel)) - 1) / math.Log2(float64(rank+2))
|
||||
}
|
||||
if len(candidates) > 0 && relevance[candidates[0].CanonicalPath] > 0 {
|
||||
a.recall1 += 1 / float64(len(qrels))
|
||||
}
|
||||
if len(qrels) > 0 {
|
||||
a.recall5 += float64(found) / float64(len(qrels))
|
||||
}
|
||||
if first > 0 {
|
||||
a.mrr5 += 1 / float64(first)
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(ideal)))
|
||||
idcg := 0.0
|
||||
for rank, rel := range ideal {
|
||||
if rank >= 5 {
|
||||
break
|
||||
}
|
||||
idcg += (math.Pow(2, float64(rel)) - 1) / math.Log2(float64(rank+2))
|
||||
}
|
||||
if idcg > 0 {
|
||||
a.ndcg5 += dcg / idcg
|
||||
}
|
||||
}
|
||||
|
||||
func (a *independentRankingAccumulator) result() ToolSearchIndependentRankingMetrics {
|
||||
result := a.metrics
|
||||
if result.Cases == 0 {
|
||||
return result
|
||||
}
|
||||
denominator := float64(result.Cases)
|
||||
result.RecallAt1 = a.recall1 / denominator
|
||||
result.RecallAt5 = a.recall5 / denominator
|
||||
result.MeanReciprocalRankAt5 = a.mrr5 / denominator
|
||||
result.NDCGAt5 = a.ndcg5 / denominator
|
||||
result.ZeroResultRate = float64(a.zero) / denominator
|
||||
return result
|
||||
}
|
||||
|
||||
func candidatesContainAny(candidates []ToolReference, paths []string) bool {
|
||||
for _, candidate := range candidates {
|
||||
if stringSliceContains(paths, candidate.CanonicalPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func candidateSetRecall(candidates []ToolReference, required []string) float64 {
|
||||
if len(required) == 0 {
|
||||
return 0
|
||||
}
|
||||
found := 0
|
||||
for _, canonical := range required {
|
||||
for _, candidate := range candidates {
|
||||
if candidate.CanonicalPath == canonical {
|
||||
found++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return float64(found) / float64(len(required))
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEvaluateToolSearchIndependentScoresGradedSafetyAndWorkflow(t *testing.T) {
|
||||
engine := newToolSearchTestEngine(t)
|
||||
cases := []ToolSearchIndependentCase{
|
||||
{
|
||||
ID: "chat", Query: "给群里发送消息", Language: "chinese_only",
|
||||
Qrels: []ToolSearchIndependentQrel{{Canonical: "chat.send", Relevance: 3}},
|
||||
Forbidden: []string{"doc.read"}, AlternativeGold: []string{"chat.send"},
|
||||
ConfusionFamily: []string{"chat.read_status"},
|
||||
},
|
||||
{
|
||||
ID: "workflow", Query: "发送消息并读取文档", Language: "chinese_only",
|
||||
Qrels: []ToolSearchIndependentQrel{{Canonical: "chat.send", Relevance: 3}, {Canonical: "doc.read", Relevance: 2}},
|
||||
Workflow: &ToolSearchIndependentWorkflow{
|
||||
Subqueries: []string{"群聊发送消息", "读取在线文档"},
|
||||
Required: []string{"chat.send", "doc.read"},
|
||||
},
|
||||
},
|
||||
}
|
||||
report, err := EvaluateToolSearchIndependent(context.Background(), engine, cases)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Version != "tool-search-independent-evaluation.v1" || report.Overall.Cases != 2 {
|
||||
t.Fatalf("report = %#v", report)
|
||||
}
|
||||
if report.Overall.RecallAt5 != 1 || report.Overall.NDCGAt5 <= 0 || report.Overall.MeanReciprocalRankAt5 <= 0 {
|
||||
t.Fatalf("ranking = %#v", report.Overall)
|
||||
}
|
||||
if report.Safety.ForbiddenCases != 1 || report.Safety.ForbiddenExposureAt5 != 0 || report.Safety.AlternativeRecallAt5 != 1 ||
|
||||
report.Safety.SiblingConfusionCases != 1 || report.Safety.SiblingExposureAt5 != 1 {
|
||||
t.Fatalf("safety = %#v", report.Safety)
|
||||
}
|
||||
if report.Workflow.Cases != 1 || report.Workflow.CompleteAt5 != 1 || report.Workflow.RequiredRecallAt5 != 1 {
|
||||
t.Fatalf("workflow = %#v", report.Workflow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndependentRankingMetricsUseGradedRankDiscounts(t *testing.T) {
|
||||
accumulator := &independentRankingAccumulator{}
|
||||
accumulator.observe([]ToolReference{
|
||||
{CanonicalPath: "irrelevant"},
|
||||
{CanonicalPath: "relevant.low"},
|
||||
}, []ToolSearchIndependentQrel{
|
||||
{Canonical: "relevant.high", Relevance: 3},
|
||||
{Canonical: "relevant.low", Relevance: 2},
|
||||
})
|
||||
metrics := accumulator.result()
|
||||
wantNDCG := (3 / math.Log2(3)) / (7 + 3/math.Log2(3))
|
||||
if metrics.RecallAt1 != 0 || metrics.RecallAt5 != 0.5 || metrics.MeanReciprocalRankAt5 != 0.5 || math.Abs(metrics.NDCGAt5-wantNDCG) > 1e-12 || metrics.ZeroResultRate != 0 {
|
||||
t.Fatalf("metrics = %#v, want R1=0 R5=.5 MRR5=.5 NDCG5=%.12f", metrics, wantNDCG)
|
||||
}
|
||||
|
||||
zero := &independentRankingAccumulator{}
|
||||
zero.observe(nil, []ToolSearchIndependentQrel{{Canonical: "relevant", Relevance: 3}})
|
||||
zeroMetrics := zero.result()
|
||||
if zeroMetrics.ZeroResultRate != 1 || zeroMetrics.RecallAt5 != 0 || zeroMetrics.NDCGAt5 != 0 {
|
||||
t.Fatalf("zero metrics = %#v", zeroMetrics)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
// 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.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
ToolSearchLexicalBM25 = "fielded_bm25_ensemble"
|
||||
ToolSearchLexicalBM25Action = "fielded_bm25_action_v1"
|
||||
ToolSearchLexicalTFIDF = "fielded_tfidf_cosine"
|
||||
)
|
||||
|
||||
// ToolSearchLexicalRequest contains a prefiltered Catalog domain. Hard
|
||||
// filtering occurs before scoring, so a retriever cannot spend its Top-K on
|
||||
// candidates that DWS is required to reject.
|
||||
type ToolSearchLexicalRequest struct {
|
||||
Query string
|
||||
CandidateLimit int
|
||||
EligibleCanonicalPaths []string
|
||||
}
|
||||
|
||||
// LexicalHit is an internal ordering result, not a confidence probability.
|
||||
// Explain is populated only when the engine was built with Explain=true.
|
||||
type LexicalHit struct {
|
||||
CanonicalPath string
|
||||
Score float64
|
||||
MatchedFields []string
|
||||
Explain *ToolSearchScoreBreakdown
|
||||
}
|
||||
|
||||
// LexicalRetriever is the zero-model local recall boundary. Implementations
|
||||
// must return a deterministic score-desc/canonical-asc order and omit zero
|
||||
// score documents.
|
||||
type LexicalRetriever interface {
|
||||
Name() string
|
||||
Retrieve(context.Context, ToolSearchLexicalRequest) ([]LexicalHit, error)
|
||||
}
|
||||
|
||||
func newToolSearchLexicalRetriever(documents map[string]toolSearchDocument, config ToolSearchConfig) (LexicalRetriever, error) {
|
||||
var retriever LexicalRetriever
|
||||
switch config.LexicalAlgorithm {
|
||||
case ToolSearchLexicalBM25Action:
|
||||
retriever = newToolSearchActionRetriever(
|
||||
newToolSearchBM25Retriever(documents, config.FieldWeights, config.BM25K1, config.BM25B, config.Explain),
|
||||
documents,
|
||||
config.Explain,
|
||||
)
|
||||
case ToolSearchLexicalBM25:
|
||||
retriever = newToolSearchBM25Retriever(documents, config.FieldWeights, config.BM25K1, config.BM25B, config.Explain)
|
||||
case ToolSearchLexicalTFIDF:
|
||||
retriever = newToolSearchTFIDFRetriever(documents, config.FieldWeights, config.Explain)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown tool search lexical algorithm %q", config.LexicalAlgorithm)
|
||||
}
|
||||
return newToolSearchAvoidWhenRetriever(retriever, documents, config.Explain), nil
|
||||
}
|
||||
|
||||
type toolSearchActionRetriever struct {
|
||||
base LexicalRetriever
|
||||
documents map[string]toolSearchDocument
|
||||
explain bool
|
||||
}
|
||||
|
||||
var toolSearchASCIIWordPattern = regexp.MustCompile(`[A-Za-z][A-Za-z0-9._-]*`)
|
||||
|
||||
func newToolSearchActionRetriever(base LexicalRetriever, documents map[string]toolSearchDocument, explain bool) *toolSearchActionRetriever {
|
||||
return &toolSearchActionRetriever{base: base, documents: documents, explain: explain}
|
||||
}
|
||||
|
||||
func (*toolSearchActionRetriever) Name() string { return ToolSearchLexicalBM25Action }
|
||||
|
||||
func (r *toolSearchActionRetriever) Retrieve(ctx context.Context, request ToolSearchLexicalRequest) ([]LexicalHit, error) {
|
||||
deep := request
|
||||
deep.CandidateLimit = len(request.EligibleCanonicalPaths)
|
||||
hits, err := r.base.Retrieve(ctx, deep)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := classifyToolSearchQuery(request.Query)
|
||||
if !query.hasSignal() || toolSearchHasUnclassifiedASCII(request.Query) {
|
||||
return truncateAndSortLexicalHits(hits, request.CandidateLimit), nil
|
||||
}
|
||||
for index := range hits {
|
||||
multiplier := toolSearchStructuredMultiplier(query, r.documents[hits[index].CanonicalPath].tool)
|
||||
hits[index].Score *= multiplier
|
||||
if r.explain && hits[index].Explain != nil {
|
||||
hits[index].Explain.Multiplier = &multiplier
|
||||
hits[index].Explain.Score = hits[index].Score
|
||||
hits[index].Explain.QueryClass = newToolSearchExplainQueryClass(query)
|
||||
}
|
||||
}
|
||||
return truncateAndSortLexicalHits(hits, request.CandidateLimit), nil
|
||||
}
|
||||
|
||||
// toolSearchHasUnclassifiedASCII reports whether the query carries a technical
|
||||
// identifier (camelCase or separator-joined tokens such as openConversationId,
|
||||
// task_id, file-path) that the compact vocabularies cannot classify. Plain
|
||||
// English words no longer disable structured reranking: they match (or fail
|
||||
// to match) the vocabularies exactly like Chinese phrases do, so mixed and
|
||||
// English queries keep the effect/product/action/entity routing instead of
|
||||
// silently degrading to raw BM25.
|
||||
func toolSearchHasUnclassifiedASCII(query string) bool {
|
||||
for _, token := range toolSearchASCIIWordPattern.FindAllString(query, -1) {
|
||||
if len(token) < 4 {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(token) {
|
||||
case "id", "ids", "url", "uri":
|
||||
continue
|
||||
}
|
||||
if strings.ContainsAny(token, "._-") {
|
||||
return true
|
||||
}
|
||||
// Only an internal uppercase transition (openConversationId) marks a
|
||||
// technical identifier; a capitalized plain word ("Send") does not.
|
||||
if internal := token[1:]; strings.ToLower(internal) != internal {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type toolSearchQueryClass struct {
|
||||
actions map[string]bool
|
||||
products map[string]bool
|
||||
entities map[string]bool
|
||||
effect string
|
||||
}
|
||||
|
||||
func (q toolSearchQueryClass) hasSignal() bool {
|
||||
return len(q.actions) > 0 || len(q.products) > 0 || len(q.entities) > 0 || q.effect != ""
|
||||
}
|
||||
|
||||
type toolSearchTermClass struct {
|
||||
name string
|
||||
query []string
|
||||
tool []string
|
||||
effect string
|
||||
}
|
||||
|
||||
var toolSearchActionClasses = []toolSearchTermClass{
|
||||
{name: "approve", query: []string{"同意", "批准", "审批通过", "approve"}, tool: []string{"approve"}},
|
||||
{name: "reject", query: []string{"拒绝", "驳回", "reject"}, tool: []string{"reject"}},
|
||||
{name: "upload", query: []string{"上传", "upload"}, tool: []string{"upload"}},
|
||||
{name: "download", query: []string{"下载", "download"}, tool: []string{"download"}},
|
||||
{name: "send", query: []string{"发送", "发给", "发消息", "通知", "投递", "send", "notify"}, tool: []string{"send", "notify"}},
|
||||
{name: "search", query: []string{"搜索", "查找", "检索", "定位", "search", "find", "locate"}, tool: []string{"search", "find", "resolve"}},
|
||||
{name: "list", query: []string{"列出", "列表", "浏览", "遍历", "list", "browse"}, tool: []string{"list", "all"}},
|
||||
{name: "read", query: []string{"读取", "查看", "获取", "详情", "正文", "read", "fetch"}, tool: []string{"read", "get", "detail", "info", "query"}},
|
||||
{name: "create", query: []string{"创建", "新建", "create"}, tool: []string{"create", "new"}},
|
||||
{name: "add", query: []string{"添加", "追加", "增加", "授权", "授予", "add", "invite"}, tool: []string{"add", "grant", "invite"}},
|
||||
{name: "update", query: []string{"更新", "修改", "编辑", "重命名", "调整", "update", "edit", "rename"}, tool: []string{"update", "edit", "rename", "set"}},
|
||||
{name: "delete", query: []string{"删除", "移除", "清除", "delete", "remove"}, tool: []string{"delete", "remove", "clear"}},
|
||||
{name: "complete", query: []string{"完成", "提交入库", "complete", "commit"}, tool: []string{"complete", "commit", "finish"}},
|
||||
{name: "enable", query: []string{"启用", "开启", "enable"}, tool: []string{"enable"}},
|
||||
{name: "disable", query: []string{"禁用", "停用", "关闭", "disable"}, tool: []string{"disable"}},
|
||||
}
|
||||
|
||||
var toolSearchProductClasses = []toolSearchTermClass{
|
||||
{name: "chat", query: []string{"群聊", "群消息", "会话", "单聊", "chat"}, tool: []string{"chat"}},
|
||||
{name: "calendar", query: []string{"日程", "会议日历", "会议室", "calendar"}, tool: []string{"calendar"}},
|
||||
{name: "drive", query: []string{"钉盘", "云盘", "drive"}, tool: []string{"drive"}},
|
||||
{name: "wiki", query: []string{"知识库", "wiki"}, tool: []string{"wiki"}},
|
||||
{name: "todo", query: []string{"待办", "todo"}, tool: []string{"todo"}},
|
||||
{name: "minutes", query: []string{"听记", "会议纪要", "minutes"}, tool: []string{"minutes"}},
|
||||
{name: "oa", query: []string{"审批", "oa", "approval"}, tool: []string{"oa"}},
|
||||
{name: "mail", query: []string{"邮件", "邮箱", "草稿", "mail", "email"}, tool: []string{"mail"}},
|
||||
{name: "sheet", query: []string{"电子表格", "工作表", "单元格", "sheet", "spreadsheet"}, tool: []string{"sheet"}},
|
||||
{name: "doc", query: []string{"在线文字文档", "文档正文", "adoc", "doc", "document"}, tool: []string{"doc"}},
|
||||
}
|
||||
|
||||
var toolSearchEntityClasses = []toolSearchTermClass{
|
||||
{name: "task_id", query: []string{"任务 id", "任务id", "task id", "taskid"}, tool: []string{"任务 id", "任务id", "task id", "taskid"}},
|
||||
{name: "task", query: []string{"任务", "task id", "taskid", "task"}, tool: []string{"任务", "task"}},
|
||||
{name: "permission", query: []string{"权限", "授权", "协作者", "permission"}, tool: []string{"权限", "授权", "协作者", "permission"}},
|
||||
{name: "content", query: []string{"正文", "文档内容", "content"}, tool: []string{"正文", "内容", "content"}},
|
||||
{name: "status", query: []string{"状态", "已读", "status"}, tool: []string{"状态", "已读", "status"}},
|
||||
{name: "draft", query: []string{"草稿", "draft"}, tool: []string{"草稿", "draft"}},
|
||||
{name: "event", query: []string{"日程", "event"}, tool: []string{"日程", "event"}},
|
||||
{name: "reminder", query: []string{"提醒", "reminder"}, tool: []string{"提醒", "reminder"}},
|
||||
{name: "summary", query: []string{"摘要", "summary"}, tool: []string{"摘要", "summary"}},
|
||||
{name: "sheet", query: []string{"工作表", "worksheet"}, tool: []string{"工作表", "sheet"}},
|
||||
{name: "range", query: []string{"单元格范围", "区域", "range"}, tool: []string{"范围", "区域", "range"}},
|
||||
{name: "group", query: []string{"群聊", "群名", "group"}, tool: []string{"群聊", "群", "group"}},
|
||||
}
|
||||
|
||||
func classifyToolSearchQuery(query string) toolSearchQueryClass {
|
||||
normalized := strings.ToLower(strings.TrimSpace(query))
|
||||
result := toolSearchQueryClass{actions: map[string]bool{}, products: map[string]bool{}, entities: map[string]bool{}}
|
||||
for _, class := range toolSearchActionClasses {
|
||||
if containsAnyToolSearchPhrase(normalized, class.query) {
|
||||
result.actions[class.name] = true
|
||||
}
|
||||
}
|
||||
for _, class := range toolSearchProductClasses {
|
||||
if containsAnyToolSearchPhrase(normalized, class.query) {
|
||||
result.products[class.name] = true
|
||||
}
|
||||
}
|
||||
for _, class := range toolSearchEntityClasses {
|
||||
if class.name == "task" && result.entities["task_id"] {
|
||||
continue
|
||||
}
|
||||
if containsAnyToolSearchPhrase(normalized, class.query) {
|
||||
result.entities[class.name] = true
|
||||
}
|
||||
}
|
||||
// “查询” is intentionally only a broad read-effect signal. Treating it as
|
||||
// get/list/search would impose a false sibling preference.
|
||||
if strings.Contains(normalized, "查询") || result.actions["read"] || result.actions["search"] || result.actions["list"] {
|
||||
result.effect = "read"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toolSearchStructuredMultiplier(query toolSearchQueryClass, tool ToolSpec) float64 {
|
||||
multiplier := 1.0
|
||||
if query.effect != "" {
|
||||
if tool.Safety.Effect == query.effect {
|
||||
multiplier += 0.10
|
||||
} else {
|
||||
multiplier -= 0.50
|
||||
}
|
||||
}
|
||||
if len(query.products) > 0 {
|
||||
if query.products[tool.Identity.ProductID] {
|
||||
multiplier += 0.50
|
||||
} else {
|
||||
multiplier -= 0.30
|
||||
}
|
||||
}
|
||||
toolText := strings.ToLower(strings.Join([]string{
|
||||
tool.Identity.Name,
|
||||
tool.Identity.CLIName,
|
||||
tool.Identity.CanonicalPath,
|
||||
tool.Identity.PrimaryCLIPath,
|
||||
tool.Title,
|
||||
tool.Selection.AgentSummary,
|
||||
}, " "))
|
||||
if len(query.actions) > 0 {
|
||||
matched := false
|
||||
for _, class := range toolSearchActionClasses {
|
||||
if query.actions[class.name] && containsAnyToolSearchWord(toolText, class.tool) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
multiplier += 0.60
|
||||
} else {
|
||||
// An explicit action is a stronger constraint than generic lexical
|
||||
// overlap. Penalize tools that do not advertise that action even
|
||||
// when their sibling verb is not in the compact action vocabulary.
|
||||
multiplier -= 0.65
|
||||
}
|
||||
}
|
||||
if len(query.entities) > 0 {
|
||||
matched := false
|
||||
for _, class := range toolSearchEntityClasses {
|
||||
if query.entities[class.name] && containsAnyToolSearchPhrase(toolText, class.tool) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
multiplier += 0.35
|
||||
} else {
|
||||
multiplier -= 0.10
|
||||
}
|
||||
}
|
||||
if multiplier < 0.20 {
|
||||
return 0.20
|
||||
}
|
||||
return multiplier
|
||||
}
|
||||
|
||||
func containsAnyToolSearchPhrase(text string, values []string) bool {
|
||||
for _, value := range values {
|
||||
if toolSearchPhraseContains(text, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// toolSearchPhraseContains matches ASCII single-token values on word
|
||||
// boundaries so short English values such as "oa" cannot fire inside
|
||||
// unrelated words ("download" contains "oa"). Mixed-script or multi-word
|
||||
// values keep substring semantics, which is what Chinese phrases and values
|
||||
// like "task id" rely on.
|
||||
func toolSearchPhraseContains(text, value string) bool {
|
||||
if value != "" && isAllToolSearchASCII(value) && !strings.ContainsAny(value, " \t") {
|
||||
return containsToolSearchASCIIWord(text, value)
|
||||
}
|
||||
return strings.Contains(text, value)
|
||||
}
|
||||
|
||||
func isAllToolSearchASCII(value string) bool {
|
||||
for _, character := range value {
|
||||
if character >= utf8.RuneSelf {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func containsToolSearchASCIIWord(text, word string) bool {
|
||||
word = strings.ToLower(word)
|
||||
for _, token := range toolSearchASCIIWordPattern.FindAllString(strings.ToLower(text), -1) {
|
||||
if token == word {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsAnyToolSearchWord(text string, values []string) bool {
|
||||
normalized := strings.NewReplacer(".", " ", "_", " ", "-", " ", "/", " ").Replace(text)
|
||||
words := strings.Fields(normalized)
|
||||
for _, value := range values {
|
||||
for _, word := range words {
|
||||
if word == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func newToolSearchExplainQueryClass(query toolSearchQueryClass) *ToolSearchExplainQueryClass {
|
||||
return &ToolSearchExplainQueryClass{
|
||||
Actions: sortedToolSearchClassKeys(query.actions),
|
||||
Products: sortedToolSearchClassKeys(query.products),
|
||||
Entities: sortedToolSearchClassKeys(query.entities),
|
||||
Effect: query.effect,
|
||||
}
|
||||
}
|
||||
|
||||
func sortedToolSearchClassKeys(set map[string]bool) []string {
|
||||
if len(set) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(set))
|
||||
for key := range set {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// toolSearchAvoidWhenPenalty is applied when a candidate's avoid_when prose
|
||||
// matches the query intent. It is a soft demotion, not an eligibility filter:
|
||||
// avoid_when is natural-language guidance (often "prefer X for that intent"),
|
||||
// so a lexical hit can be wrong and the tool must remain reachable.
|
||||
const toolSearchAvoidWhenPenalty = 0.20
|
||||
|
||||
// toolSearchAvoidWhenShortcutNoise filters the boilerplate avoid_when that
|
||||
// every shortcut carries; it carries no intent signal. Kept lowercase because
|
||||
// phrases are compared in the lowercased domain.
|
||||
const toolSearchAvoidWhenShortcutNoise = "需要该 shortcut 未公开的底层参数"
|
||||
|
||||
// toolSearchAvoidWhenPenaltyReason returns the matched avoid_when phrase, or
|
||||
// "" when the tool should not be demoted for this query. The only match shape
|
||||
// is the query containing the avoidance prose itself (the Forbidden@5
|
||||
// evaluation proxy). Intent-overlap matching was tried and rejected: a tool's
|
||||
// own avoid_when describes the same sibling scenario space as its use_when,
|
||||
// so "shares an action/entity with the phrase" demotes the gold answer.
|
||||
func toolSearchAvoidWhenPenaltyReason(query string, tool ToolSpec) string {
|
||||
if len(tool.Selection.AvoidWhen) == 0 {
|
||||
return ""
|
||||
}
|
||||
normalized := strings.ToLower(strings.TrimSpace(query))
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
for _, phrase := range tool.Selection.AvoidWhen {
|
||||
phrase = strings.ToLower(strings.TrimSpace(phrase))
|
||||
if phrase == "" || strings.Contains(phrase, toolSearchAvoidWhenShortcutNoise) {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(normalized, phrase) {
|
||||
return phrase
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// toolSearchAvoidWhenRetriever demotes candidates whose avoid_when prose
|
||||
// matches the query intent. It wraps every lexical algorithm, including the
|
||||
// production ensemble default, so the Forbidden exposure guard is not tied to
|
||||
// action_v1.
|
||||
type toolSearchAvoidWhenRetriever struct {
|
||||
base LexicalRetriever
|
||||
documents map[string]toolSearchDocument
|
||||
explain bool
|
||||
}
|
||||
|
||||
func newToolSearchAvoidWhenRetriever(base LexicalRetriever, documents map[string]toolSearchDocument, explain bool) *toolSearchAvoidWhenRetriever {
|
||||
return &toolSearchAvoidWhenRetriever{base: base, documents: documents, explain: explain}
|
||||
}
|
||||
|
||||
func (r *toolSearchAvoidWhenRetriever) Name() string { return r.base.Name() }
|
||||
|
||||
func (r *toolSearchAvoidWhenRetriever) Retrieve(ctx context.Context, request ToolSearchLexicalRequest) ([]LexicalHit, error) {
|
||||
hits, err := r.base.Retrieve(ctx, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
penalized := false
|
||||
for index := range hits {
|
||||
reason := toolSearchAvoidWhenPenaltyReason(request.Query, r.documents[hits[index].CanonicalPath].tool)
|
||||
if reason == "" {
|
||||
continue
|
||||
}
|
||||
hits[index].Score *= toolSearchAvoidWhenPenalty
|
||||
if r.explain && hits[index].Explain != nil {
|
||||
hits[index].Explain.Score = hits[index].Score
|
||||
hits[index].Explain.AvoidWhenPenalty = reason
|
||||
}
|
||||
penalized = true
|
||||
}
|
||||
if !penalized {
|
||||
return hits, nil
|
||||
}
|
||||
return truncateAndSortLexicalHits(hits, request.CandidateLimit), nil
|
||||
}
|
||||
|
||||
type toolSearchBM25Retriever struct {
|
||||
fieldIndex map[toolSearchField]toolSearchBM25Index
|
||||
weights ToolSearchFieldWeights
|
||||
explain bool
|
||||
}
|
||||
|
||||
func newToolSearchBM25Retriever(documents map[string]toolSearchDocument, weights ToolSearchFieldWeights, k1, b float64, explain bool) *toolSearchBM25Retriever {
|
||||
fieldTerms := toolSearchFieldTerms(documents)
|
||||
fieldIndex := make(map[toolSearchField]toolSearchBM25Index, len(toolSearchFieldOrder))
|
||||
for _, field := range toolSearchFieldOrder {
|
||||
fieldIndex[field] = newToolSearchBM25Index(fieldTerms[field], k1, b)
|
||||
}
|
||||
return &toolSearchBM25Retriever{fieldIndex: fieldIndex, weights: weights, explain: explain}
|
||||
}
|
||||
|
||||
func (*toolSearchBM25Retriever) Name() string { return ToolSearchLexicalBM25 }
|
||||
|
||||
func (r *toolSearchBM25Retriever) Retrieve(ctx context.Context, request ToolSearchLexicalRequest) ([]LexicalHit, error) {
|
||||
queryTerms := orderedToolSearchQueryTerms(request.Query)
|
||||
hits := make([]LexicalHit, 0, len(request.EligibleCanonicalPaths))
|
||||
for _, canonical := range request.EligibleCanonicalPaths {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contributions := make(map[toolSearchField]float64)
|
||||
score := 0.0
|
||||
for _, field := range toolSearchFieldOrder {
|
||||
weight := toolSearchFieldWeight(r.weights, field)
|
||||
if weight <= 0 {
|
||||
continue
|
||||
}
|
||||
value := r.fieldIndex[field].score(canonical, queryTerms) * weight
|
||||
if value > 0 {
|
||||
contributions[field] = value
|
||||
score += value
|
||||
}
|
||||
}
|
||||
if score > 0 {
|
||||
hit := LexicalHit{CanonicalPath: canonical, Score: score, MatchedFields: matchedToolSearchFields(contributions)}
|
||||
if r.explain {
|
||||
hit.Explain = newToolSearchBreakdown(score, contributions)
|
||||
}
|
||||
hits = append(hits, hit)
|
||||
}
|
||||
}
|
||||
return truncateAndSortLexicalHits(hits, request.CandidateLimit), nil
|
||||
}
|
||||
|
||||
type toolSearchTFIDFDocument struct {
|
||||
weights map[string]float64
|
||||
norm float64
|
||||
}
|
||||
|
||||
type toolSearchTFIDFIndex struct {
|
||||
documents map[string]toolSearchTFIDFDocument
|
||||
idf map[string]float64
|
||||
}
|
||||
|
||||
type toolSearchTFIDFRetriever struct {
|
||||
fieldIndex map[toolSearchField]toolSearchTFIDFIndex
|
||||
weights ToolSearchFieldWeights
|
||||
explain bool
|
||||
}
|
||||
|
||||
func newToolSearchTFIDFRetriever(documents map[string]toolSearchDocument, weights ToolSearchFieldWeights, explain bool) *toolSearchTFIDFRetriever {
|
||||
fieldTerms := toolSearchFieldTerms(documents)
|
||||
fieldIndex := make(map[toolSearchField]toolSearchTFIDFIndex, len(toolSearchFieldOrder))
|
||||
for _, field := range toolSearchFieldOrder {
|
||||
fieldIndex[field] = newToolSearchTFIDFIndex(fieldTerms[field])
|
||||
}
|
||||
return &toolSearchTFIDFRetriever{fieldIndex: fieldIndex, weights: weights, explain: explain}
|
||||
}
|
||||
|
||||
func (*toolSearchTFIDFRetriever) Name() string { return ToolSearchLexicalTFIDF }
|
||||
|
||||
func (r *toolSearchTFIDFRetriever) Retrieve(ctx context.Context, request ToolSearchLexicalRequest) ([]LexicalHit, error) {
|
||||
queryFrequency := toolSearchTermFrequency(tokenizeToolSearchText(request.Query))
|
||||
hits := make([]LexicalHit, 0, len(request.EligibleCanonicalPaths))
|
||||
for _, canonical := range request.EligibleCanonicalPaths {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contributions := make(map[toolSearchField]float64)
|
||||
score := 0.0
|
||||
for _, field := range toolSearchFieldOrder {
|
||||
weight := toolSearchFieldWeight(r.weights, field)
|
||||
if weight <= 0 {
|
||||
continue
|
||||
}
|
||||
value := r.fieldIndex[field].score(canonical, queryFrequency) * weight
|
||||
if value > 0 {
|
||||
contributions[field] = value
|
||||
score += value
|
||||
}
|
||||
}
|
||||
if score > 0 {
|
||||
hit := LexicalHit{CanonicalPath: canonical, Score: score, MatchedFields: matchedToolSearchFields(contributions)}
|
||||
if r.explain {
|
||||
hit.Explain = newToolSearchBreakdown(score, contributions)
|
||||
}
|
||||
hits = append(hits, hit)
|
||||
}
|
||||
}
|
||||
return truncateAndSortLexicalHits(hits, request.CandidateLimit), nil
|
||||
}
|
||||
|
||||
func toolSearchFieldTerms(documents map[string]toolSearchDocument) map[toolSearchField]map[string][]string {
|
||||
fieldTerms := make(map[toolSearchField]map[string][]string, len(toolSearchFieldOrder))
|
||||
for _, field := range toolSearchFieldOrder {
|
||||
fieldTerms[field] = make(map[string][]string, len(documents))
|
||||
}
|
||||
for canonical, document := range documents {
|
||||
for _, field := range toolSearchFieldOrder {
|
||||
fieldTerms[field][canonical] = tokenizeToolSearchText(document.fields[field])
|
||||
}
|
||||
}
|
||||
return fieldTerms
|
||||
}
|
||||
|
||||
func orderedToolSearchQueryTerms(query string) []toolSearchQueryTerm {
|
||||
frequency := toolSearchTermFrequency(tokenizeToolSearchText(query))
|
||||
terms := make([]toolSearchQueryTerm, 0, len(frequency))
|
||||
for term, count := range frequency {
|
||||
terms = append(terms, toolSearchQueryTerm{term: term, count: count})
|
||||
}
|
||||
sort.Slice(terms, func(i, j int) bool { return terms[i].term < terms[j].term })
|
||||
return terms
|
||||
}
|
||||
|
||||
func toolSearchTermFrequency(tokens []string) map[string]int {
|
||||
frequency := make(map[string]int, len(tokens))
|
||||
for _, token := range tokens {
|
||||
frequency[token]++
|
||||
}
|
||||
return frequency
|
||||
}
|
||||
|
||||
func newToolSearchTFIDFIndex(documents map[string][]string) toolSearchTFIDFIndex {
|
||||
index := toolSearchTFIDFIndex{documents: make(map[string]toolSearchTFIDFDocument, len(documents)), idf: map[string]float64{}}
|
||||
documentFrequency := map[string]int{}
|
||||
frequencies := make(map[string]map[string]int, len(documents))
|
||||
for canonical, tokens := range documents {
|
||||
frequency := toolSearchTermFrequency(tokens)
|
||||
frequencies[canonical] = frequency
|
||||
for term := range frequency {
|
||||
documentFrequency[term]++
|
||||
}
|
||||
}
|
||||
n := float64(len(documents))
|
||||
for term, frequency := range documentFrequency {
|
||||
index.idf[term] = math.Log((n+1)/(float64(frequency)+1)) + 1
|
||||
}
|
||||
for canonical, frequency := range frequencies {
|
||||
weights := make(map[string]float64, len(frequency))
|
||||
normSquared := 0.0
|
||||
terms := make([]string, 0, len(frequency))
|
||||
for term := range frequency {
|
||||
terms = append(terms, term)
|
||||
}
|
||||
sort.Strings(terms)
|
||||
for _, term := range terms {
|
||||
count := frequency[term]
|
||||
value := float64(count) * index.idf[term]
|
||||
weights[term] = value
|
||||
normSquared += value * value
|
||||
}
|
||||
index.documents[canonical] = toolSearchTFIDFDocument{weights: weights, norm: math.Sqrt(normSquared)}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func (i toolSearchTFIDFIndex) score(canonical string, queryFrequency map[string]int) float64 {
|
||||
document, ok := i.documents[canonical]
|
||||
if !ok || document.norm == 0 {
|
||||
return 0
|
||||
}
|
||||
terms := make([]string, 0, len(queryFrequency))
|
||||
for term := range queryFrequency {
|
||||
terms = append(terms, term)
|
||||
}
|
||||
sort.Strings(terms)
|
||||
dot := 0.0
|
||||
queryNormSquared := 0.0
|
||||
for _, term := range terms {
|
||||
idf, ok := i.idf[term]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
queryWeight := float64(queryFrequency[term]) * idf
|
||||
queryNormSquared += queryWeight * queryWeight
|
||||
dot += document.weights[term] * queryWeight
|
||||
}
|
||||
if queryNormSquared == 0 {
|
||||
return 0
|
||||
}
|
||||
return dot / (document.norm * math.Sqrt(queryNormSquared))
|
||||
}
|
||||
|
||||
// toolSearchScoreQuantum is the sort-key quantization grid. Go permits FMA
|
||||
// fusion on arm64 but not amd64, so the same ranking expression can differ in
|
||||
// the last ulp across architectures; ordering on the raw float would let that
|
||||
// ulp flip near-tied candidates past the canonical tie-break and break the
|
||||
// byte-determinism contract across CI (linux/amd64) and dev (macOS/arm64)
|
||||
// machines. Quantizing only the ordering key keeps same-architecture output
|
||||
// identical while making cross-architecture ordering agree; reported scores
|
||||
// (explain-only) are untouched.
|
||||
const toolSearchScoreQuantum = 1e9
|
||||
|
||||
func quantizeToolSearchScore(score float64) float64 {
|
||||
return math.Round(score*toolSearchScoreQuantum) / toolSearchScoreQuantum
|
||||
}
|
||||
|
||||
func truncateAndSortLexicalHits(hits []LexicalHit, limit int) []LexicalHit {
|
||||
sort.Slice(hits, func(i, j int) bool {
|
||||
left, right := quantizeToolSearchScore(hits[i].Score), quantizeToolSearchScore(hits[j].Score)
|
||||
if left != right {
|
||||
return left > right
|
||||
}
|
||||
return hits[i].CanonicalPath < hits[j].CanonicalPath
|
||||
})
|
||||
if limit >= 0 && len(hits) > limit {
|
||||
hits = hits[:limit]
|
||||
}
|
||||
return hits
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+40
-12
@@ -145,6 +145,15 @@ type FlagSpec struct {
|
||||
// alike) and makes a whitespace-only value count as empty in required checks.
|
||||
Trim bool
|
||||
|
||||
// Input declares extra input sources for a KindString flag beyond the
|
||||
// literal command-line value: InputFile enables @path (value replaced by
|
||||
// the file content), InputStdin enables - (value replaced by stdin).
|
||||
// "@@value" always escapes to the literal "@value". Only explicit CLI
|
||||
// tokens are resolved; EnvVar fallback and registration defaults pass
|
||||
// through unchanged. Resolution runs before required/enum/constraint/
|
||||
// Validate checks, so they see the payload content. Empty = flag value only.
|
||||
Input []string
|
||||
|
||||
// Schema parameter final facts (embedded to dws.schema.*; assembly pass-through).
|
||||
Enum []string // accepted values
|
||||
Format string // machine-readable format (e.g. uri)
|
||||
@@ -365,6 +374,7 @@ func New(spec Spec) *cobra.Command {
|
||||
validateDispatchDecl(spec)
|
||||
validateSafetySpec(spec)
|
||||
validateContractDecl(spec)
|
||||
validateInputSpecs(spec.Use, spec.Flags)
|
||||
// Help prose inherits the declaration when not authored separately:
|
||||
// Selection.Examples (already contract-validated against the real flags)
|
||||
// double as the --help Example block, keeping one authored source.
|
||||
@@ -476,6 +486,11 @@ func runDeclaredPreflight(cmd *cobra.Command, args []string, spec Spec) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Input resolution rewrites explicit @file / stdin values in place so the
|
||||
// required/enum/constraint/Validate stages below check the payload content.
|
||||
if err := resolveInputFlags(cmd, spec.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateRequired(cmd, spec.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -578,18 +593,7 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
|
||||
for _, alias := range flag.Aliases {
|
||||
RegisterFlag(cmd, flag.Kind, alias, "", flag.Usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
if registered := cmd.Flags().Lookup(alias); registered != nil {
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOf,
|
||||
flag.Name,
|
||||
)
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOrigin,
|
||||
runtimeannotate.FlagAliasOriginCorecmdV1,
|
||||
)
|
||||
}
|
||||
AnnotateFlagAlias(cmd, alias, flag.Name)
|
||||
}
|
||||
if flag.MarkRequired {
|
||||
_ = cmd.MarkFlagRequired(flag.Name)
|
||||
@@ -600,6 +604,30 @@ 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)
|
||||
|
||||
@@ -116,6 +116,16 @@ 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) {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package corecmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
)
|
||||
|
||||
// FlagSpec.Input source constants. They declare extra input sources for a
|
||||
// KindString flag beyond the literal command-line value, mirroring the
|
||||
// lark-cli Flag.Input capability so large payloads (markdown, JSON, CSV)
|
||||
// never need shell quoting.
|
||||
const (
|
||||
// InputFile allows the flag value @path to be replaced by the file content.
|
||||
InputFile = "file"
|
||||
// InputStdin allows the flag value - to be replaced by the stdin content.
|
||||
InputStdin = "stdin"
|
||||
)
|
||||
|
||||
// utf8BOM is stripped from file/stdin content so a Windows-edited payload
|
||||
// cannot corrupt the first CSV cell or break JSON parsing downstream.
|
||||
const utf8BOM = "\ufeff"
|
||||
|
||||
// validateInputSpecs rejects malformed Input declarations at build time. Like
|
||||
// the other declaration checks this panics: a bad Input spec is a programming
|
||||
// error every test and startup path should trip immediately.
|
||||
func validateInputSpecs(use string, flags []FlagSpec) {
|
||||
for _, flag := range flags {
|
||||
if len(flag.Input) == 0 {
|
||||
continue
|
||||
}
|
||||
if flag.Kind != KindString {
|
||||
panic(fmt.Sprintf(
|
||||
"command %q flag %q: Input is only supported on KindString flags",
|
||||
use, flag.Name))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, source := range flag.Input {
|
||||
if source != InputFile && source != InputStdin {
|
||||
panic(fmt.Sprintf(
|
||||
"command %q flag %q: unknown Input source %q (allowed: %s, %s)",
|
||||
use, flag.Name, source, InputFile, InputStdin))
|
||||
}
|
||||
if seen[source] {
|
||||
panic(fmt.Sprintf(
|
||||
"command %q flag %q: duplicate Input source %q",
|
||||
use, flag.Name, source))
|
||||
}
|
||||
seen[source] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// inputSupports reports whether the flag declared the given input source.
|
||||
func inputSupports(flag FlagSpec, source string) bool {
|
||||
for _, s := range flag.Input {
|
||||
if s == source {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// explicitInputFlagName picks the declared name that carries the user's
|
||||
// explicit token, mirroring rawValue's order and usability judgement exactly:
|
||||
// the main flag wins only when changed and usable (trim-judged only when
|
||||
// Trim is set), then declared aliases in order. Diverging here could rewrite
|
||||
// an alias that rawValue would shadow (or vice versa). EnvVar fallback and
|
||||
// registration defaults are never input-resolved — @file only applies to what
|
||||
// the user literally typed.
|
||||
func explicitInputFlagName(cmd *cobra.Command, flag FlagSpec) string {
|
||||
usable := func(v string) bool {
|
||||
if flag.Trim {
|
||||
v = strings.TrimSpace(v)
|
||||
}
|
||||
return v != ""
|
||||
}
|
||||
for _, name := range append([]string{flag.Name}, flag.Aliases...) {
|
||||
if cmd.Flags().Changed(name) && usable(cmdutil.MustGetFlag(cmd, name)) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveInputFlags rewrites the explicit values of Input-declaring flags
|
||||
// before required/enum/constraint/Validate checks, so every downstream stage
|
||||
// sees the real payload content. Semantics:
|
||||
//
|
||||
// - "-" reads stdin; a process has a single stdin, so a second Input flag
|
||||
// using "-" in the same invocation is rejected.
|
||||
// - "@path" reads the named file (leading/trailing whitespace trimmed).
|
||||
// - "@@value" escapes to the literal "@value" and is not source-resolved.
|
||||
//
|
||||
// Resolution runs once per invocation inside runDeclaredPreflight. It
|
||||
// rewrites the cobra flag value in place (Set keeps Changed=true), so
|
||||
// fallback-chain readers (EffectiveValue/BuildArgs) observe the content.
|
||||
//
|
||||
// Interaction with confirmation: stdin is consumed here, before the Safety
|
||||
// prompt of a user_required write would read it; such an interactive prompt
|
||||
// then sees EOF and fails closed with confirmation_required. Write commands
|
||||
// declaring InputStdin must be invoked with --yes (or --dry-run).
|
||||
func resolveInputFlags(cmd *cobra.Command, flags []FlagSpec) error {
|
||||
stdinConsumed := false
|
||||
for _, flag := range flags {
|
||||
if len(flag.Input) == 0 {
|
||||
continue
|
||||
}
|
||||
name := explicitInputFlagName(cmd, flag)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
raw := cmdutil.MustGetFlag(cmd, name)
|
||||
// Trim flags judge usability on the trimmed value (rawValue), so the
|
||||
// prefix check must agree or " @path" would slip through unresolved.
|
||||
if flag.Trim {
|
||||
raw = strings.TrimSpace(raw)
|
||||
}
|
||||
|
||||
switch {
|
||||
case raw == "-":
|
||||
if !inputSupports(flag, InputStdin) {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("参数 --%s 不支持 stdin 输入(-)", flag.Name))
|
||||
}
|
||||
if stdinConsumed {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("参数 --%s:stdin(-)只能被一个参数使用", flag.Name),
|
||||
apperrors.WithHint(fmt.Sprintf(
|
||||
"一个进程只有一份 stdin,其余参数请内联传值或用 @文件路径(如 --%s @./payload.json)",
|
||||
flag.Name)))
|
||||
}
|
||||
stdinConsumed = true
|
||||
data, err := io.ReadAll(cmd.InOrStdin())
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("参数 --%s 读取 stdin 失败:%v", flag.Name, err))
|
||||
}
|
||||
// Setting a registered string flag cannot fail.
|
||||
_ = cmd.Flags().Set(name, strings.TrimPrefix(string(data), utf8BOM))
|
||||
|
||||
case strings.HasPrefix(raw, "@@"):
|
||||
// Escape: strip the first @, keep the rest as a literal inline value.
|
||||
_ = cmd.Flags().Set(name, raw[1:])
|
||||
|
||||
case strings.HasPrefix(raw, "@"):
|
||||
if !inputSupports(flag, InputFile) {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("参数 --%s 不支持文件输入(@路径)", flag.Name))
|
||||
}
|
||||
path := strings.TrimSpace(raw[1:])
|
||||
if path == "" {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("参数 --%s:@ 后的文件路径不能为空", flag.Name))
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
var opts []apperrors.Option
|
||||
if inputSupports(flag, InputStdin) {
|
||||
// Rejected @file paths are usually absolute (temp files).
|
||||
// Steer toward stdin rather than copying the file around.
|
||||
opts = append(opts, apperrors.WithHint(fmt.Sprintf(
|
||||
"该参数也支持 stdin:把文件内容管道进命令并传 --%s -", flag.Name)))
|
||||
}
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("参数 --%s 读取文件 %q 失败:%v", flag.Name, path, err), opts...)
|
||||
}
|
||||
_ = cmd.Flags().Set(name, strings.TrimPrefix(string(data), utf8BOM))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package corecmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newInputCommand builds a Spec whose Invoke captures toolArgs, so tests can
|
||||
// assert what the full pipeline (resolution → validation → BuildArgs) ships.
|
||||
func newInputCommand(flags []FlagSpec, captured *map[string]any) *cobra.Command {
|
||||
return New(Spec{
|
||||
Use: "t",
|
||||
Short: "t",
|
||||
Flags: flags,
|
||||
Invoke: func(c *Ctx, toolArgs map[string]any) error {
|
||||
*captured = toolArgs
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func runInputCommand(t *testing.T, cmd *cobra.Command, args ...string) error {
|
||||
t.Helper()
|
||||
cmd.SetArgs(args)
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func writeInputFile(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "payload.txt")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsFile(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}},
|
||||
}, &got)
|
||||
path := writeInputFile(t, "# hello\nworld")
|
||||
|
||||
if err := runInputCommand(t, cmd, "--content", "@"+path); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "# hello\nworld" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsStdin(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputStdin}},
|
||||
}, &got)
|
||||
cmd.SetIn(strings.NewReader("piped payload"))
|
||||
|
||||
if err := runInputCommand(t, cmd, "--content", "-"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "piped payload" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// The @@ escape keeps a literal leading @ inline and must not be treated as a
|
||||
// source reference even when Input is declared.
|
||||
func TestCrossPlatformCoverageResolveInputFlagsEscapedAtStaysInline(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}},
|
||||
}, &got)
|
||||
|
||||
if err := runInputCommand(t, cmd, "--content", "@@literal@x"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "@literal@x" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsBOMStripped(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}},
|
||||
}, &got)
|
||||
path := writeInputFile(t, "\ufeff{\"a\":1}")
|
||||
|
||||
if err := runInputCommand(t, cmd, "--content", "@"+path); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "{\"a\":1}" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// Required must be satisfied by the resolved payload, proving resolution runs
|
||||
// before the required stage.
|
||||
func TestCrossPlatformCoverageResolveInputFlagsSatisfiesRequired(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Required: true, Input: []string{InputFile}},
|
||||
}, &got)
|
||||
path := writeInputFile(t, "payload")
|
||||
|
||||
if err := runInputCommand(t, cmd, "--content", "@"+path); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "payload" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// Enum validation sees the resolved content, not the "@path" token.
|
||||
func TestCrossPlatformCoverageResolveInputFlagsEnumValidatesResolvedContent(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "mode", Usage: "M", Bind: "mode", Enum: []string{"asc", "desc"}, Input: []string{InputFile}},
|
||||
}, &got)
|
||||
path := writeInputFile(t, "sideways")
|
||||
|
||||
err := runInputCommand(t, cmd, "--mode", "@"+path)
|
||||
if err == nil || !strings.Contains(err.Error(), "不合法") {
|
||||
t.Fatalf("expected enum rejection on resolved content, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A value passed through a declared alias is resolved exactly like the main
|
||||
// name (fallback-chain parity).
|
||||
func TestCrossPlatformCoverageResolveInputFlagsAliasResolved(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Aliases: []string{"body"}, Input: []string{InputFile}},
|
||||
}, &got)
|
||||
path := writeInputFile(t, "via alias")
|
||||
|
||||
if err := runInputCommand(t, cmd, "--body", "@"+path); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "via alias" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// When the main flag shadows a changed alias (rawValue usability order), the
|
||||
// shadowed alias must not be input-resolved: resolution targets exactly the
|
||||
// name the fallback chain will read. The whitespace main value is usable for
|
||||
// a non-Trim flag, and the alias path does not exist on purpose — a resolver
|
||||
// that wrongly picked the alias would fail the read instead of shipping " ".
|
||||
func TestCrossPlatformCoverageResolveInputFlagsShadowedAliasNotResolved(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Aliases: []string{"body"}, Input: []string{InputFile}},
|
||||
}, &got)
|
||||
missing := filepath.Join(t.TempDir(), "missing.txt")
|
||||
|
||||
if err := runInputCommand(t, cmd, "--content", " ", "--body", "@"+missing); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != " " {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsFileNotSupported(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputStdin}},
|
||||
}, &got)
|
||||
|
||||
err := runInputCommand(t, cmd, "--content", "@/tmp/whatever.txt")
|
||||
if err == nil || !strings.Contains(err.Error(), "不支持文件输入") {
|
||||
t.Fatalf("expected file-input rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsStdinNotSupported(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}},
|
||||
}, &got)
|
||||
cmd.SetIn(strings.NewReader("x"))
|
||||
|
||||
err := runInputCommand(t, cmd, "--content", "-")
|
||||
if err == nil || !strings.Contains(err.Error(), "不支持 stdin") {
|
||||
t.Fatalf("expected stdin rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsSingleStdinConsumer(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "first", Usage: "F", Bind: "first", Input: []string{InputStdin}},
|
||||
{Name: "second", Usage: "S", Bind: "second", Input: []string{InputStdin}},
|
||||
}, &got)
|
||||
cmd.SetIn(strings.NewReader("x"))
|
||||
|
||||
err := runInputCommand(t, cmd, "--first", "-", "--second", "-")
|
||||
if err == nil || !strings.Contains(err.Error(), "只能被一个参数使用") {
|
||||
t.Fatalf("expected single-stdin rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// failingReader (corecmd_test.go) fails every read, making the stdin error
|
||||
// branch reachable.
|
||||
func TestCrossPlatformCoverageResolveInputFlagsStdinReadFailure(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputStdin}},
|
||||
}, &got)
|
||||
cmd.SetIn(failingReader{})
|
||||
|
||||
err := runInputCommand(t, cmd, "--content", "-")
|
||||
if err == nil || !strings.Contains(err.Error(), "读取 stdin 失败") {
|
||||
t.Fatalf("expected stdin read failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsFileNotFound(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}},
|
||||
}, &got)
|
||||
|
||||
err := runInputCommand(t, cmd, "--content", "@"+filepath.Join(t.TempDir(), "missing.txt"))
|
||||
if err == nil || !strings.Contains(err.Error(), "读取文件") {
|
||||
t.Fatalf("expected read failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsEmptyPathAfterAt(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}},
|
||||
}, &got)
|
||||
|
||||
err := runInputCommand(t, cmd, "--content", "@ ")
|
||||
if err == nil || !strings.Contains(err.Error(), "文件路径不能为空") {
|
||||
t.Fatalf("expected empty-path rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A flag without Input keeps its literal value even when it looks like a
|
||||
// source reference — resolution is strictly opt-in per declaration.
|
||||
func TestCrossPlatformCoverageResolveInputFlagsNoInputSpecPassthrough(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "token", Usage: "T", Bind: "token"},
|
||||
}, &got)
|
||||
|
||||
if err := runInputCommand(t, cmd, "--token", "@not-a-file"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["token"] != "@not-a-file" {
|
||||
t.Fatalf("toolArgs[token] = %q", got["token"])
|
||||
}
|
||||
}
|
||||
|
||||
// Registration defaults and env fallback are never input-resolved: @file only
|
||||
// applies to what the user literally typed on the command line.
|
||||
func TestCrossPlatformCoverageResolveInputFlagsDefaultNotResolved(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Default: "@not-a-file", Input: []string{InputFile}},
|
||||
}, &got)
|
||||
|
||||
if err := runInputCommand(t, cmd); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "@not-a-file" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// Trim flags judge usability on the trimmed value (rawValue), so a leading
|
||||
// whitespace before @ must still resolve instead of shipping as a literal.
|
||||
func TestCrossPlatformCoverageResolveInputFlagsTrimmedLeadingSpace(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Trim: true, Input: []string{InputFile}},
|
||||
}, &got)
|
||||
path := writeInputFile(t, "trimmed payload")
|
||||
|
||||
if err := runInputCommand(t, cmd, "--content", " @"+path); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "trimmed payload" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateInputSpecsPanics(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
flag FlagSpec
|
||||
}{
|
||||
{"non-string kind", FlagSpec{Name: "n", Kind: KindInt, Input: []string{InputFile}}},
|
||||
{"unknown source", FlagSpec{Name: "s", Input: []string{"url"}}},
|
||||
{"duplicate source", FlagSpec{Name: "s", Input: []string{InputFile, InputFile}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic")
|
||||
}
|
||||
}()
|
||||
New(Spec{
|
||||
Use: "t",
|
||||
Short: "t",
|
||||
Flags: []FlagSpec{tc.flag},
|
||||
Invoke: func(c *Ctx, toolArgs map[string]any) error { return nil },
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -361,13 +361,18 @@ func TestCrossPlatformCoverageDaemonMethodEdges(t *testing.T) {
|
||||
}
|
||||
return nil, net.ErrClosed
|
||||
}}
|
||||
d := &daemon{listener: l, log: logger, hub: NewHub(1), idleStop: make(chan struct{})}
|
||||
d := &daemon{listener: l, log: logger, hub: NewHub(1), idleStop: make(chan struct{}), stopReq: make(chan string, 1)}
|
||||
d.acceptLoop(context.Background())
|
||||
d.shuttingDown.Store(true)
|
||||
d.acceptLoop(context.Background())
|
||||
d.triggerShutdown("test")
|
||||
if !l.closed {
|
||||
t.Fatal("trigger did not close listener")
|
||||
select {
|
||||
case reason := <-d.stopReq:
|
||||
if reason != "test" {
|
||||
t.Fatalf("stop reason = %q", reason)
|
||||
}
|
||||
default:
|
||||
t.Fatal("trigger did not notify lifecycle loop")
|
||||
}
|
||||
|
||||
d = &daemon{listener: &scriptedListener{accept: func() (net.Conn, error) { return nil, net.ErrClosed }}, log: logger, hub: NewHub(1), idleStop: make(chan struct{})}
|
||||
|
||||
@@ -186,6 +186,7 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
dedup: dd,
|
||||
started: time.Now().UTC(),
|
||||
idleStop: make(chan struct{}),
|
||||
stopReq: make(chan string, 1),
|
||||
}
|
||||
|
||||
// 4. Signal ready BEFORE accepting consumers (avoids a slow-fork
|
||||
@@ -263,6 +264,9 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
}
|
||||
case <-d.idleStop:
|
||||
log.Info("bus: idle timeout reached, shutting down")
|
||||
case reason := <-d.stopReq:
|
||||
shutdownReason = reason
|
||||
log.Info("bus: shutdown requested via IPC", "reason", reason)
|
||||
}
|
||||
|
||||
// 7. Graceful shutdown — cancel runCtx first so all background
|
||||
@@ -292,6 +296,7 @@ type daemon struct {
|
||||
shutdownMu sync.Mutex
|
||||
shuttingDown atomic.Bool
|
||||
idleStop chan struct{}
|
||||
stopReq chan string
|
||||
|
||||
credentialHandoffMu sync.Mutex
|
||||
terminalMu sync.RWMutex
|
||||
@@ -749,15 +754,17 @@ func (d *daemon) idleWatch(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// triggerShutdown is called from RPC handlers that want to end the bus.
|
||||
// It works by closing the listener (which unblocks Run's select via the
|
||||
// source error path, indirectly). For v1 a full ctx-cancellation hook is
|
||||
// out of scope; busctl/stop also sends SIGTERM which is the authoritative
|
||||
// shutdown path.
|
||||
// triggerShutdown is called from RPC handlers that want to end the bus. The
|
||||
// buffered request wakes Run's lifecycle select, which then cancels the cloud
|
||||
// source and performs the same graceful shutdown used for signals and idle
|
||||
// expiry. It is intentionally non-blocking so repeated stop requests cannot
|
||||
// strand connection handlers.
|
||||
func (d *daemon) triggerShutdown(reason string) {
|
||||
d.log.Info("bus: shutdown triggered via IPC", "reason", reason)
|
||||
_ = d.listener.Close() // unblocks Accept(), but doesn't kill Source
|
||||
// Best-effort: a future version wires a context.CancelFunc here.
|
||||
select {
|
||||
case d.stopReq <- reason:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// shutdown performs the graceful tear-down sequence:
|
||||
|
||||
@@ -141,6 +141,67 @@ func TestDaemon_RunStartsAndShutsDownCleanly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDaemonIPCStopShutsDownCleanly(t *testing.T) {
|
||||
workDir := shortTempDir(t)
|
||||
identityHash := dwsevent.IdentityHash(workDir)
|
||||
endpoint := dwsevent.IPCEndpoint(workDir, "open", dwsevent.SourceKindAppStream, identityHash)
|
||||
runDone := make(chan error, 1)
|
||||
go func() {
|
||||
runDone <- Run(context.Background(), Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: endpoint,
|
||||
ClientID: "ding_ipc_stop_test",
|
||||
Edition: "open",
|
||||
SourceKind: dwsevent.SourceKindAppStream,
|
||||
IdentityHash: identityHash,
|
||||
Source: &fakeSource{},
|
||||
})
|
||||
}()
|
||||
|
||||
var conn net.Conn
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var err error
|
||||
conn, err = transport.Dial(endpoint)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if conn == nil {
|
||||
t.Fatal("bus IPC endpoint did not become ready")
|
||||
}
|
||||
w := transport.NewWriter(conn)
|
||||
r := transport.NewReader(conn)
|
||||
if err := w.WriteJSON(transport.Hello{
|
||||
Type: transport.FrameTypeHello,
|
||||
ConsumerPID: os.Getpid(),
|
||||
Role: transport.HelloRoleStop,
|
||||
}); err != nil {
|
||||
t.Fatalf("write stop hello: %v", err)
|
||||
}
|
||||
var bye transport.Bye
|
||||
if err := r.ReadJSON(&bye); err != nil {
|
||||
t.Fatalf("read stop response: %v", err)
|
||||
}
|
||||
_ = conn.Close()
|
||||
if bye.Type != transport.FrameTypeBye || bye.Reason != "stop_request" {
|
||||
t.Fatalf("stop response = %#v", bye)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-runDone:
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned after IPC stop: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Run did not return after IPC stop")
|
||||
}
|
||||
if pid := ReadHolderPID(filepath.Join(workDir, LockFileName)); pid != 0 {
|
||||
t.Fatalf("bus lock retained pid %d after IPC stop", pid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemon_ConsumerReceivesEvents(t *testing.T) {
|
||||
skipOnWindows(t, "uses Unix socket dial")
|
||||
workDir := shortTempDir(t)
|
||||
|
||||
@@ -114,6 +114,47 @@ func ReadHolderPID(path string) int {
|
||||
return parsePID(b)
|
||||
}
|
||||
|
||||
// ValidateHolderOwnership reports whether pid still owns the exclusive lock at
|
||||
// path. If the lock can be acquired, no daemon owns it: the PID payload is
|
||||
// stale and is cleared while the probe holds the lock. The PID is read again
|
||||
// after a busy result so a daemon replacement cannot be mistaken for the
|
||||
// original owner.
|
||||
//
|
||||
// Callers use this immediately before sending a process-level termination
|
||||
// fallback. A live PID by itself is not proof of ownership because operating
|
||||
// systems may reuse PIDs after an unclean daemon exit.
|
||||
func ValidateHolderOwnership(path string, pid int) (bool, error) {
|
||||
if pid <= 0 || ReadHolderPID(path) != pid {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
probe, err := busTryAcquire(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrBusy) {
|
||||
return ReadHolderPID(path) == pid, nil
|
||||
}
|
||||
return false, fmt.Errorf("bus: verify lock owner: %w", err)
|
||||
}
|
||||
defer probe.Close()
|
||||
|
||||
// We own the lock, so the recorded PID cannot own it. Clear only the PID
|
||||
// we validated above; a changed payload is left untouched defensively.
|
||||
f := probe.File()
|
||||
if _, err := busSeek(f, 0, io.SeekStart); err != nil {
|
||||
return false, fmt.Errorf("bus: seek stale lock: %w", err)
|
||||
}
|
||||
current, err := busReadAll(f)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("bus: read stale lock: %w", err)
|
||||
}
|
||||
if parsePID(current) == pid {
|
||||
if err := truncateAndWritePID(f, 0); err != nil {
|
||||
return false, fmt.Errorf("bus: clear stale PID: %w", err)
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Close releases the flock and best-effort blanks the PID body so a stale
|
||||
// reader (e.g. `event status` racing our shutdown) does not see our
|
||||
// long-dead PID and try to signal it. The lock file itself is NOT removed
|
||||
|
||||
@@ -16,9 +16,13 @@ package bus
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
eventlock "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/lock"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestAcquire_WritesOurPID(t *testing.T) {
|
||||
@@ -112,6 +116,109 @@ func TestReadHolderPID_MalformedReturnsZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHolderOwnershipHeldLock(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), LockFileName)
|
||||
held, err := busTryAcquire(path)
|
||||
if err != nil {
|
||||
t.Fatalf("hold lock: %v", err)
|
||||
}
|
||||
defer held.Close()
|
||||
if err := truncateAndWritePID(held.File(), os.Getpid()); err != nil {
|
||||
t.Fatalf("write holder PID: %v", err)
|
||||
}
|
||||
|
||||
owned, err := ValidateHolderOwnership(path, os.Getpid())
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateHolderOwnership: %v", err)
|
||||
}
|
||||
if !owned {
|
||||
t.Fatal("held lock was not attributed to its recorded PID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHolderOwnershipClearsReusedPID(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), LockFileName)
|
||||
// The current PID is alive but deliberately does not hold this lock. This
|
||||
// models a stale daemon PID that the OS has reassigned to another process.
|
||||
if err := os.WriteFile(path, []byte(fmt.Sprintf("%d\n", os.Getpid())), 0o600); err != nil {
|
||||
t.Fatalf("write stale PID: %v", err)
|
||||
}
|
||||
|
||||
owned, err := ValidateHolderOwnership(path, os.Getpid())
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateHolderOwnership: %v", err)
|
||||
}
|
||||
if owned {
|
||||
t.Fatal("live reused PID without the bus lock was accepted as owner")
|
||||
}
|
||||
if got := ReadHolderPID(path); got != 0 {
|
||||
t.Fatalf("stale PID after validation = %d, want cleared", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHolderOwnershipRejectsMismatch(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), LockFileName)
|
||||
if err := os.WriteFile(path, []byte("123\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owned, err := ValidateHolderOwnership(path, 456)
|
||||
if err != nil || owned {
|
||||
t.Fatalf("ValidateHolderOwnership mismatch = %v, %v", owned, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHolderOwnershipErrors(t *testing.T) {
|
||||
errInjected := errors.New("injected ownership validation failure")
|
||||
writeLivePID := func(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), LockFileName)
|
||||
if err := os.WriteFile(path, []byte(fmt.Sprintf("%d\n", os.Getpid())), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
t.Run("probe", func(t *testing.T) {
|
||||
path := writeLivePID(t)
|
||||
testseam.Swap(t, &busTryAcquire, func(string) (*eventlock.File, error) {
|
||||
return nil, errInjected
|
||||
})
|
||||
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
|
||||
t.Fatalf("probe error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("seek", func(t *testing.T) {
|
||||
path := writeLivePID(t)
|
||||
testseam.Swap(t, &busSeek, func(*os.File, int64, int) (int64, error) {
|
||||
return 0, errInjected
|
||||
})
|
||||
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
|
||||
t.Fatalf("seek error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read", func(t *testing.T) {
|
||||
path := writeLivePID(t)
|
||||
testseam.Swap(t, &busReadAll, func(io.Reader) ([]byte, error) {
|
||||
return nil, errInjected
|
||||
})
|
||||
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
|
||||
t.Fatalf("read error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("clear", func(t *testing.T) {
|
||||
path := writeLivePID(t)
|
||||
testseam.Swap(t, &busTruncate, func(*os.File, int64) error {
|
||||
return errInjected
|
||||
})
|
||||
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
|
||||
t.Fatalf("clear error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAcquire_AfterReleaseReclaimable(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), LockFileName)
|
||||
l1, err := Acquire(path)
|
||||
|
||||
@@ -284,11 +284,13 @@ func TestCrossPlatformCoverageQueryStatusAndEntryEdges(t *testing.T) {
|
||||
|
||||
func TestCrossPlatformCoverageStopInjectedEdges(t *testing.T) {
|
||||
origRead := stopReadHolderPID
|
||||
origValidateOwner := stopValidateHolderOwner
|
||||
origAlive := stopAlive
|
||||
origFind := stopFindProcess
|
||||
origSignal := stopSignalProcess
|
||||
t.Cleanup(func() {
|
||||
stopReadHolderPID = origRead
|
||||
stopValidateHolderOwner = origValidateOwner
|
||||
stopAlive = origAlive
|
||||
stopFindProcess = origFind
|
||||
stopSignalProcess = origSignal
|
||||
@@ -307,6 +309,7 @@ func TestCrossPlatformCoverageStopInjectedEdges(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stopFindProcess = func(int) (*os.Process, error) { return proc, nil }
|
||||
stopValidateHolderOwner = func(string, int) (bool, error) { return true, nil }
|
||||
stopSignalProcess = func(*os.Process, os.Signal) error { return os.ErrProcessDone }
|
||||
if err := Stop(StopConfig{WorkDir: "x"}); err != nil {
|
||||
t.Fatalf("done signal = %v", err)
|
||||
|
||||
@@ -112,8 +112,9 @@ func Discover(cfg DiscoverConfig) (net.Conn, error) {
|
||||
return nil, fmt.Errorf("busctl: mkdir workdir: %w", err)
|
||||
}
|
||||
_, spawnErr := cfg.Spawn(SpawnConfig{
|
||||
ClientID: cfg.ClientID,
|
||||
ExtraArgs: cfg.SpawnExtraArgs,
|
||||
ClientID: cfg.ClientID,
|
||||
IPCEndpoint: cfg.IPCEndpoint,
|
||||
ExtraArgs: cfg.SpawnExtraArgs,
|
||||
})
|
||||
if spawnErr != nil && !errors.Is(spawnErr, ErrSpawnFailed) {
|
||||
// Hard error (couldn't even exec the child). Stop here — no bus
|
||||
|
||||
@@ -122,7 +122,10 @@ func TestDiscover_NoBus_SpawnSucceeds(t *testing.T) {
|
||||
closer()
|
||||
}
|
||||
})
|
||||
fakeSpawn := func(SpawnConfig) (int, error) {
|
||||
fakeSpawn := func(cfg SpawnConfig) (int, error) {
|
||||
if cfg.IPCEndpoint != sock {
|
||||
t.Fatalf("spawn IPC endpoint = %q, want %q", cfg.IPCEndpoint, sock)
|
||||
}
|
||||
closer = startStubBus(t, sock)
|
||||
return 12345, nil
|
||||
}
|
||||
|
||||
@@ -43,18 +43,14 @@ var (
|
||||
spawnPipe = os.Pipe
|
||||
)
|
||||
|
||||
// ErrSpawnFailed is returned when the child reports startup failure via
|
||||
// the ready pipe ('E' byte). The child's exit error / log file holds the
|
||||
// actual cause; this sentinel just lets the caller distinguish "ready
|
||||
// pipe said no" from "ready pipe timed out / closed early".
|
||||
var ErrSpawnFailed = errors.New("busctl: bus child reported startup failure on ready pipe")
|
||||
// ErrSpawnFailed is returned when the child reports startup failure through
|
||||
// the Unix ready pipe or exits before binding the Windows named pipe.
|
||||
var ErrSpawnFailed = errors.New("busctl: bus child reported startup failure")
|
||||
|
||||
// ErrSpawnTimeout is returned when ReadyTimeout elapses without any signal.
|
||||
var ErrSpawnTimeout = errors.New("busctl: bus child did not signal readiness within deadline")
|
||||
|
||||
// SpawnConfig describes one spawn attempt. ClientID is the only field
|
||||
// inspected by the child; the rest govern process attributes the parent
|
||||
// applies before exec.
|
||||
// SpawnConfig describes one spawn attempt.
|
||||
type SpawnConfig struct {
|
||||
// ExecPath is the dws binary to exec. Default os.Executable().
|
||||
ExecPath string
|
||||
@@ -62,18 +58,23 @@ type SpawnConfig struct {
|
||||
// ClientID is passed as `--client-id` to `dws event _bus`. Required.
|
||||
ClientID string
|
||||
|
||||
// IPCEndpoint is the bus endpoint the child binds. Windows uses the
|
||||
// existing named pipe as its readiness handshake because os/exec does not
|
||||
// support ExtraFiles there. Unix keeps the inherited ready-pipe protocol.
|
||||
IPCEndpoint string
|
||||
|
||||
// ExtraArgs are appended after `--client-id`. Empty for normal use; tests
|
||||
// pass `--extra-flag-for-test` etc.
|
||||
ExtraArgs []string
|
||||
|
||||
// Env to pass to the child. Defaults to os.Environ(). The ReadyFDEnv
|
||||
// entry is appended automatically.
|
||||
// Env to pass to the child. Defaults to os.Environ(). Unix appends the
|
||||
// ReadyFDEnv entry automatically; Windows removes any inherited copy.
|
||||
Env []string
|
||||
}
|
||||
|
||||
// Spawn forks a detached `dws event _bus --client-id <id>` child process and
|
||||
// waits for it to signal readiness via the ready pipe. Returns the child's
|
||||
// PID on success — the caller can then dial the bus IPC endpoint.
|
||||
// spawnWithReadyPipe is the Unix implementation behind Spawn. It forks a
|
||||
// detached `dws event _bus --client-id <id>` child and waits for the inherited
|
||||
// ready pipe. Windows has a named-pipe implementation in spawn_windows.go.
|
||||
//
|
||||
// stdio detach (plan invariant #7):
|
||||
// - cmd.Stdout / cmd.Stderr set to nil so the child's own writes don't
|
||||
@@ -89,7 +90,7 @@ type SpawnConfig struct {
|
||||
// Parent (this function):
|
||||
// - Holds the read end open until either 1 byte is read or ReadyTimeout
|
||||
// - Returns ErrSpawnFailed for 'E', ErrSpawnTimeout otherwise
|
||||
func Spawn(cfg SpawnConfig) (pid int, err error) {
|
||||
func spawnWithReadyPipe(cfg SpawnConfig) (pid int, err error) {
|
||||
if cfg.ClientID == "" {
|
||||
return 0, errors.New("busctl: SpawnConfig.ClientID is required")
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// Test child mode: when DWS_BUSCTL_TEST_CHILD is set, this test binary
|
||||
@@ -35,7 +37,8 @@ import (
|
||||
// env-marker pattern is what Go's own os/exec tests use and stays
|
||||
// confined to this file.
|
||||
const (
|
||||
childEnvMarker = "DWS_BUSCTL_TEST_CHILD"
|
||||
childEnvMarker = "DWS_BUSCTL_TEST_CHILD"
|
||||
childEndpointEnv = "DWS_BUSCTL_TEST_ENDPOINT"
|
||||
// values:
|
||||
// "ready" — write 'R' then sleep 30s (parent should see ready)
|
||||
// "fail" — write 'E' then exit (parent should see ErrSpawnFailed)
|
||||
@@ -70,6 +73,26 @@ func TestMain(m *testing.M) {
|
||||
writeReady('R')
|
||||
time.Sleep(30 * time.Second)
|
||||
os.Exit(0)
|
||||
case "windows-ready":
|
||||
listener, err := transport.Listen(os.Getenv(childEndpointEnv))
|
||||
if err != nil {
|
||||
os.Exit(3)
|
||||
}
|
||||
defer listener.Close()
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
os.Exit(4)
|
||||
}
|
||||
_ = conn.Close()
|
||||
time.Sleep(30 * time.Second)
|
||||
os.Exit(0)
|
||||
case "windows-fail":
|
||||
os.Exit(5)
|
||||
case "windows-exit":
|
||||
os.Exit(0)
|
||||
case "windows-stall":
|
||||
time.Sleep(30 * time.Second)
|
||||
os.Exit(0)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@ import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Spawn starts the detached bus and waits for its inherited ready pipe.
|
||||
// ExtraFiles is intentionally confined to Unix: Go does not support it on
|
||||
// Windows.
|
||||
func Spawn(cfg SpawnConfig) (pid int, err error) {
|
||||
return spawnWithReadyPipe(cfg)
|
||||
}
|
||||
|
||||
// applyDetach configures the child to live past parent death and not share
|
||||
// the parent's controlling terminal. Setsid puts the child in a new
|
||||
// session, so SIGHUP on the parent's controlling tty (e.g. SSH disconnect)
|
||||
|
||||
@@ -16,14 +16,101 @@
|
||||
package busctl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// CREATE_NEW_PROCESS_GROUP (0x00000200) prevents the child from receiving
|
||||
// the parent's Ctrl+C signal, similar in spirit to Setsid on Unix.
|
||||
const createNewProcessGroup = 0x00000200
|
||||
|
||||
var (
|
||||
spawnWindowsDial = transport.Dial
|
||||
spawnWindowsPollInterval = 25 * time.Millisecond
|
||||
)
|
||||
|
||||
// Spawn starts a detached Windows bus without cmd.ExtraFiles (unsupported by
|
||||
// Go on Windows). Readiness is confirmed by dialing the bus's existing named
|
||||
// pipe. The child is reaped in the background after readiness, while an early
|
||||
// process exit is surfaced as ErrSpawnFailed.
|
||||
func Spawn(cfg SpawnConfig) (pid int, err error) {
|
||||
if strings.TrimSpace(cfg.ClientID) == "" {
|
||||
return 0, fmt.Errorf("busctl: SpawnConfig.ClientID is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.IPCEndpoint) == "" {
|
||||
return 0, fmt.Errorf("busctl: SpawnConfig.IPCEndpoint is required on Windows")
|
||||
}
|
||||
if cfg.ExecPath == "" {
|
||||
execPath, err := spawnExecutable()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("busctl: locate executable: %w", err)
|
||||
}
|
||||
cfg.ExecPath = execPath
|
||||
}
|
||||
if cfg.Env == nil {
|
||||
cfg.Env = os.Environ()
|
||||
}
|
||||
|
||||
args := append([]string{"event", "_bus", "--client-id", cfg.ClientID}, cfg.ExtraArgs...)
|
||||
cmd := exec.Command(cfg.ExecPath, args...)
|
||||
cmd.Env = withoutReadyFDEnv(cfg.Env)
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
applyDetach(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return 0, fmt.Errorf("busctl: start %s: %w", cfg.ExecPath, err)
|
||||
}
|
||||
pid = cmd.Process.Pid
|
||||
|
||||
waitDone := make(chan error, 1)
|
||||
go func() { waitDone <- cmd.Wait() }()
|
||||
timer := time.NewTimer(ReadyTimeout)
|
||||
defer timer.Stop()
|
||||
ticker := time.NewTicker(spawnWindowsPollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
if conn, dialErr := spawnWindowsDial(cfg.IPCEndpoint); dialErr == nil {
|
||||
_ = conn.Close()
|
||||
return pid, nil
|
||||
}
|
||||
select {
|
||||
case waitErr := <-waitDone:
|
||||
if waitErr == nil {
|
||||
return pid, fmt.Errorf("%w: child exited before binding named pipe", ErrSpawnFailed)
|
||||
}
|
||||
return pid, fmt.Errorf("%w: %v", ErrSpawnFailed, waitErr)
|
||||
case <-timer.C:
|
||||
_ = cmd.Process.Kill()
|
||||
select {
|
||||
case <-waitDone:
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
return pid, ErrSpawnTimeout
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func withoutReadyFDEnv(env []string) []string {
|
||||
prefix := strings.ToUpper(ReadyFDEnv) + "="
|
||||
out := make([]string, 0, len(env))
|
||||
for _, entry := range env {
|
||||
if strings.HasPrefix(strings.ToUpper(entry), prefix) {
|
||||
continue
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func applyDetach(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
CreationFlags: createNewProcessGroup,
|
||||
|
||||
Loaded 100 of 262 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user