Compare commits

...
90 Commits
Author SHA1 Message Date
github-actions[bot] b2cbca2762 chore: update beta formula for v1.0.56-beta.3 [skip ci] 2026-08-03 12:55:19 +00:00
chichuan 9ce95db08e Merge pull request #855 from DingTalk-Real-AI/codex/changelog-v1.0.56-beta.3
docs: seal v1.0.56-beta.3 changelog
2026-08-03 20:39:44 +08:00
chichuan e99c20a0a1 docs: seal v1.0.56-beta.3 changelog 2026-08-03 20:34:17 +08:00
github-actions[bot] 96bfae079a Merge pull request #846 from wxianfeng/fix/event-unix-socket-tmpdir
fix(event): use secure Unix bus runtime directory
2026-08-03 16:04:41 +08:00
wxianfeng bb18cdba3b Merge upstream/main into fix/event-unix-socket-tmpdir 2026-08-03 15:45:38 +08:00
wxianfeng 015a1f85ca fix(event): satisfy platform coverage gate 2026-08-03 15:42:17 +08:00
github-actions[bot] 8854e0d1d4 Merge pull request #851 from abucraft/codex/aitable-workflow-docs
feat: add aitable workflow edit example command
2026-08-03 07:01:27 +00:00
镜玄 22862508b8 feat: add aitable workflow edit example command 2026-08-03 14:47:59 +08:00
wxianfeng 5459bcc524 fix(event): secure Unix bus runtime directory 2026-08-03 11:40:01 +08:00
wxianfeng 029c665029 fix(event): place Unix bus sockets in temp dir 2026-07-31 18:01:34 +08:00
github-actions[bot] 187787040b chore: update beta formula for v1.0.56-beta.2 [skip ci] 2026-07-30 15:00:34 +00:00
chichuan cd6e854bf1 Merge pull request #843 from DingTalk-Real-AI/codex/changelog-v1.0.56-beta.2-admission-final
docs(release): clarify v1.0.56-beta.2 skill routing
2026-07-30 22:48:59 +08:00
chichuan 61124f8768 docs(release): clarify v1.0.56-beta.2 skill routing 2026-07-30 22:44:52 +08:00
github-actions[bot] 6cfeac3179 Merge pull request #842 from DingTalk-Real-AI/codex/changelog-v1.0.56-beta.2-admission
docs(release): complete v1.0.56-beta.2 notes
2026-07-30 22:43:08 +08:00
chichuan acd293cc83 docs(release): complete v1.0.56-beta.2 notes 2026-07-30 22:40:59 +08:00
github-actions[bot] d2045c3441 Merge pull request #841 from DingTalk-Real-AI/codex/changelog-v1.0.56-beta.2
docs(release): seal v1.0.56-beta.2 changelog
2026-07-30 22:38:42 +08:00
chichuan 4de41c27c7 docs(release): add v1.0.56-beta.2 notes 2026-07-30 22:36:34 +08:00
github-actions[bot] 5df2860e66 Merge pull request #831 from wxianfeng/fix/agent-product-header-separation
fix: separate Agent Product from claw-type
2026-07-30 14:35:55 +00:00
chichuan f3390b6875 Merge branch 'main' into fix/agent-product-header-separation 2026-07-30 22:16:13 +08:00
github-actions[bot] 55f25d8d48 Merge pull request #835 from DingTalk-Real-AI/codex/skill-token-shallow-water
perf(skills): reduce common-path context loading
2026-07-30 14:04:51 +00:00
chichuan 67fbf65916 Merge branch 'main' into fix/agent-product-header-separation 2026-07-30 21:54:11 +08:00
chichuan 7f82e46adf Merge branch 'main' into codex/skill-token-shallow-water 2026-07-30 21:52:17 +08:00
chichuan 1fb1ae3e23 Merge remote-tracking branch 'origin/main' into codex/pr-831-conflict-fix
# Conflicts:
#	CHANGELOG.md
2026-07-30 21:47:14 +08:00
github-actions[bot] fd0ab16c7f chore: update beta formula for v1.0.56-beta.1 [skip ci] 2026-07-30 13:46:57 +00:00
chichuan daaad35f5b Merge pull request #840 from DingTalk-Real-AI/codex/changelog-v1.0.56-beta.1-followup
docs(release): complete v1.0.56-beta.1 notes
2026-07-30 21:33:31 +08:00
chichuan 953a36f4c9 docs(release): complete v1.0.56-beta.1 notes 2026-07-30 21:30:31 +08:00
github-actions[bot] e015f40ae2 Merge pull request #836 from DingTalk-Real-AI/codex/changelog-v1.0.56-beta.1
docs(release): add v1.0.56-beta.1 notes
2026-07-30 21:27:07 +08:00
chichuan 84226b963c Merge branch 'main' into codex/changelog-v1.0.56-beta.1 2026-07-30 21:22:16 +08:00
chichuan 1d1c06aaae Merge branch 'main' into fix/agent-product-header-separation 2026-07-30 21:17:23 +08:00
github-actions[bot] f34f9e8223 Merge pull request #839 from DingTalk-Real-AI/codex/fix-multi-profile-e2e-timeout
ci: increase integration test timeouts
2026-07-30 21:14:44 +08:00
chichuan 3519965285 ci: increase integration test timeouts 2026-07-30 20:52:18 +08:00
chichuan a54ee24acb Merge branch 'main' into fix/agent-product-header-separation 2026-07-30 20:21:34 +08:00
chichuan a7074bf53f Merge pull request #838 from DingTalk-Real-AI/codex/fix-scoped-coverage-timeout
fix: align scoped coverage and test timeout
2026-07-30 20:20:01 +08:00
chichuan 21144af79b fix: align scoped coverage and test timeout 2026-07-30 20:06:24 +08:00
chichuan 320582f98c Merge branch 'main' into fix/agent-product-header-separation 2026-07-30 19:04:13 +08:00
Dennis e04ff5a12b Merge remote-tracking branch 'origin/main' into codex/skill-token-shallow-water
# Conflicts:
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
2026-07-30 17:39:06 +08:00
github-actions[bot] 3bdc30badb Merge pull request #834 from wxianfeng/fix/event-subscription-retry-storm
fix(event): prevent subscription retry storms
2026-07-30 17:18:27 +08:00
wxianfeng c569def067 docs: clarify disabled AI tag argument shape 2026-07-30 16:55:46 +08:00
wxianfeng b82e975429 test(app): preserve audit sink ownership in coverage gate 2026-07-30 16:25:56 +08:00
wxianfeng 2808e71cb6 fix(event): address retry storm review 2026-07-30 16:08:19 +08:00
chichuan 584b1bd9d4 docs(release): add v1.0.56-beta.1 notes 2026-07-30 15:42:05 +08:00
Dennis c350311048 chore: keep analysis report out of PR 2026-07-30 15:20:51 +08:00
Dennis 158e7ec701 perf(skills): reduce common-path context loading 2026-07-30 15:17:48 +08:00
wxianfeng 125a101487 fix: separate Agent Product from claw-type 2026-07-30 14:34:22 +08:00
wxianfeng ec99654854 fix(event): prevent subscription retry storms 2026-07-30 13:49:08 +08:00
github-actions[bot] 9aa76ea748 Merge pull request #806 from DingTalk-Real-AI/fix/param-hallucination
feat(param): 参数概念归一化治理与 IM 场景完善
2026-07-30 04:00:22 +00:00
克谨 885c3fe021 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-30 11:46:37 +08:00
github-actions[bot] d0d56cbaf5 Merge pull request #817 from DingTalk-Real-AI/codex/im-shortcut-gap-fill
feat(im): close shortcut capability gaps
2026-07-30 11:42:23 +08:00
chichuan 9dbbd64f3c Merge branch 'main' into codex/im-shortcut-gap-fill 2026-07-30 11:31:19 +08:00
github-actions[bot] 7ba12a8e4c chore: update formula for v1.0.55 [skip ci] 2026-07-30 03:11:41 +00:00
克谨 dfba9546f4 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-30 11:06:02 +08:00
克谨 b3ba9fee97 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-30 10:43:51 +08:00
Dennis 41372b0597 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-gap-fill 2026-07-30 10:32:58 +08:00
Dennis cffc48406c fix(im): resolve direct recipients via contact search 2026-07-30 10:29:39 +08:00
Dennis f9e3476d42 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-gap-fill 2026-07-30 10:02:34 +08:00
克谨 1e04e301ea Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-30 09:44:52 +08:00
克谨 5f038d440b test: reduce parameter alias race runtime 2026-07-30 09:44:30 +08:00
Dennis 2b48f27a4b fix(im): harden shortcut review follow-ups 2026-07-29 23:35:52 +08:00
Dennis bf79a67efe fix(im): close shortcut review gaps 2026-07-29 21:25:24 +08:00
克谨 8936c20ef0 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-29 20:07:03 +08:00
Dennis 95d262bbb2 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-gap-fill 2026-07-29 19:32:18 +08:00
Dennis d5c260c7c0 fix(im): address shortcut review regressions 2026-07-29 19:31:48 +08:00
Dennis 9c297d0520 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-gap-fill 2026-07-29 18:02:49 +08:00
Dennis 37230d2d4d chore(schema): refresh shortcut skill source hashes 2026-07-29 18:01:54 +08:00
Dennis fde6b59074 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-gap-fill
# Conflicts:
#	internal/app/schema_shortcut_contract_test.go
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
#	internal/cli/schema_command_registry/products/chat.json
#	internal/cli/schema_hints/runtime-surface-completeness.json
#	skills/multi/dingtalk-chat/SKILL.md
#	skills/multi/dingtalk-chat/references/chat.md
2026-07-29 17:51:27 +08:00
克谨 e2abc70e84 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-29 17:35:07 +08:00
克谨 15a27a9f83 fix(cli): harden parameter preparse normalization 2026-07-29 17:33:44 +08:00
wxianfeng 4567dd1cd6 fix(im): gate optional resource downloads at runtime 2026-07-29 15:33:01 +08:00
Dennis 75bb01bb64 docs(skill): align IM shortcut routing 2026-07-29 14:45:28 +08:00
Dennis 11a7ab8b7c fix(im): harden shortcut downloads and message context 2026-07-29 14:20:39 +08:00
克谨 2e1cce8501 test(param): align category alias fixtures with title limits 2026-07-29 13:34:59 +08:00
Dennis 41e0fb381a feat(im): close shortcut capability gaps 2026-07-29 13:29:53 +08:00
克谨 870fba823b Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-29 13:18:47 +08:00
克谨 d083de5f84 fix(cli): normalize explicit boolean flag values safely 2026-07-29 13:18:27 +08:00
克谨 1fb966dbff fix(cli): centralize parameter alias generation entrypoint 2026-07-29 13:17:55 +08:00
克谨 7987fb3a35 chore(ci): retrigger pull request checks 2026-07-29 10:02:28 +08:00
克谨 3d6a9c6232 test(pipeline): cover shared flag matchers 2026-07-29 10:02:28 +08:00
克谨 195569ddfa fix(cli): harden parameter preparse integration 2026-07-29 10:02:28 +08:00
克谨 7827856876 fix(param): align aliases and bound exhaustive tests 2026-07-29 10:02:28 +08:00
克谨 f79f41e930 chore(param): exclude normalization specs from review 2026-07-29 10:02:27 +08:00
克谨 bfd53df9b2 feat(param): expand reviewed IM parameter normalization 2026-07-29 10:02:27 +08:00
克谨 4db117893e fix(param): freeze reviewed normalization baseline
Restore calendar helper behavior to main, finalize reviewed alias/guard decisions, cover payload and dry-run paths, and record the local migration freeze checkpoint.
2026-07-29 10:02:27 +08:00
克谨 b314749ef7 test(param): cover final alias payloads and guard errors 2026-07-29 10:02:27 +08:00
克谨 5133103a54 fix(param): harden command-scoped normalization safety 2026-07-29 10:02:27 +08:00
克谨 9faa332306 chore: ignore stray compiled param-aliases generator binary 2026-07-29 10:02:27 +08:00
克谨 17fa1e1b34 refactor(calendar): read canonical flags in event list after normalization
Now that alias spellings are normalized to canonical flags in the PreParse
pipeline, drop the redundant flagOrFallback tails in the event-list handler and
read --start/--end/--calendar-id/--cursor/--limit directly (keeping --count as a
deliberately separate flag). Behaviour is unchanged; the pilot test guards it.
2026-07-29 10:01:53 +08:00
克谨 af1f8ccd05 test(param): fixture regression through delivery path + co-occurrence gate
Add the ⑥ regression gate that replays every reviewed validation_fixture bad case
through the real embedded PreParse pipeline and asserts the canonical outcome
(accepting either semantic rewrite or native real-flag acceptance, failing only
on a genuine unknown-flag hallucination). Add check-param-concepts.sh (dictionary
schema/loader invariants) and check-param-alias-cooccurrence.sh (full-tree
co-occurrence scan), and wire all three into make policy.
2026-07-29 10:01:53 +08:00
克谨 c26cbbbbb8 feat(param): wire semantic alias table into PreParse; pilot calendar event list
Unify runtime morphology on pkg/cmdutil.Morph (same function the generator uses),
add a SemanticAliasHandler that looks up the embedded generated table after
morphological normalization and rewrites synonyms to the command's canonical flag
(leaving blocked/ambiguous synonyms untouched for the did-you-mean path), and
thread the command CLIPath through the pipeline Context. Pilot the mechanism on
'calendar event list' by removing its hand-written hidden spelling variants; a
behaviour-preservation test locks the outcome.
2026-07-29 10:01:53 +08:00
克谨 2733f510af feat(param): generate per-command alias table from concepts
Add internal/generator/cmd_param_aliases: reads the reviewed dictionary plus the
live Cobra tree, reduces each concept against a command's real flags (>=2 visible
real flags without a reviewed ambiguous entry fails generation), and emits the
committed internal/cli/param_aliases_generated.go table with lookup helpers.
Extend generate-schema and check-generated-drift.sh to treat the dictionary as a
reviewed input and byte-guard the generated table.
2026-07-29 10:01:53 +08:00
克谨 abc62622fb feat(param): add reviewed param-concept dictionary, closed schema, and loader
Introduce internal/cli/param_concepts.json as the single reviewed source of
parameter-normalization concepts and per-command overrides, guarded by a closed
JSON schema and a go:embed loader with contract tests. Add the design spec.
2026-07-29 10:00:41 +08:00
181 changed files with 30346 additions and 1820 deletions
+9 -3
View File
@@ -438,7 +438,7 @@ jobs:
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: 10
timeout-minutes: 20
steps:
- name: Check out repository
uses: actions/checkout@v4
@@ -483,7 +483,7 @@ jobs:
exit 0
fi
mapfile -t packages <<< "$package_output"
go test -v -race -count=1 -timeout=8m "${packages[@]}"
go test -v -race -count=1 -timeout=15m "${packages[@]}"
test-race:
name: "Test (race: ${{ matrix.shard }})"
@@ -523,7 +523,7 @@ jobs:
test -n "$package_output"
mapfile -t packages <<< "$package_output"
test "${#packages[@]}" -gt 0
go test -v -race -count=1 -timeout=10m "${packages[@]}"
go test -v -race -count=1 -timeout=12m "${packages[@]}"
test-release-scripts:
name: Test (workflow and release contracts)
@@ -1160,14 +1160,20 @@ jobs:
- name: Enforce coverage gate
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
env:
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
COVERAGE_TARGET: "100"
COVERAGE_ENFORCE_OVERALL: "false"
COVERAGE_OVERALL_TOLERANCE: "0"
run: |
policy_profile=coverage-policy.txt
if [ "$FULL_SUITE" != true ]; then
policy_profile=
fi
additional_profile=
if [ -f coverage-shortcut.txt ]; then
additional_profile=coverage-shortcut.txt
fi
COVERAGE_DIFF_PROFILE="$policy_profile" \
COVERAGE_ADDITIONAL_DIFF_PROFILE="$additional_profile" \
make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
+1
View File
@@ -51,5 +51,6 @@ jobs:
path: |
.tmp-bin/multi-profile-e2e.*/out
.tmp-bin/multi-profile-e2e.log
include-hidden-files: true
if-no-files-found: ignore
retention-days: 3
+3
View File
@@ -66,3 +66,6 @@ dwsbin
# Local coverage artifacts
coverage-shortcut.txt
coverage-*.txt
# stray compiled generator binary (source lives in internal/generator/cmd_param_aliases/)
/cmd_param_aliases
+6 -5
View File
@@ -95,11 +95,12 @@ The Schema system has two physically separated processes:
**Generation** (build-time, slow, reviewed, one-way):
- Entry point: `internal/cli/gen.go` (all `//go:generate` pragmas isolated here,
not in business code).
- Tools: `internal/generator/cmd_schema_agent_metadata` + `cmd_schema_catalog`
(standalone Go mains).
- Inputs: 6 authored sources (registry + hints metadata + hints selection +
MCP metadata + parameter bindings + cobra tree).
- Output: `schema_catalog/` (per-product shards) + `schema_agent_metadata/`.
- Tools: `internal/generator/cmd_schema_agent_metadata` + `cmd_schema_catalog` +
`cmd_param_aliases` (standalone Go mains).
- Inputs: 7 authored source groups (registry + hints metadata + hints selection +
MCP metadata + parameter bindings + reviewed parameter concepts + cobra tree).
- Output: `schema_catalog/` (per-product shards) + `schema_agent_metadata/` +
`param_aliases_generated.go`.
- Refresh MCP metadata: `make fetch-mcp-metadata` (iterates 26 MCP server
endpoints, merges with previous data for cross-server interface_ref).
- Gates: `make generate-schema` (byte guards on inputs), `check-generated-drift.sh`,
+54
View File
@@ -6,6 +6,60 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.56-beta.3] - 2026-08-03
This beta adds PRs #846 and #851 on top of v1.0.56-beta.2. It adds a
service-provided Aitable workflow-editing reference command and makes local
event-bus IPC reliable on shared filesystems by placing Unix sockets in a
validated private runtime directory.
### Added
- **Aitable workflow editing reference** (#851) — adds `dws aitable workflow edit-example`, a parameter-free read command that returns the service-provided workflow editing documentation and `workflow-dsl/v1` examples through `aitable/edit_workflow_example`.
### Fixed
- **Event bus sockets on shared filesystems** (#846) — Unix event buses now place their local IPC socket in a private per-user runtime directory (`XDG_RUNTIME_DIR` when available, otherwise a `0700` per-UID directory under the system temporary directory) while retaining locks, metadata, logs, and subscription state in the configured Workdir. Listener and dial paths validate directory ownership and permissions before use. This prevents `dws event consume` from failing with `bind: errno 524` when `~/.dws` is hosted on NFS, CSI, FUSE, or another filesystem that does not support Unix Domain Sockets without exposing the socket directly in a shared `/tmp` root. When `XDG_RUNTIME_DIR` is unavailable, the per-UID directory name is deterministic: ownership validation prevents endpoint hijacking, but another local user can pre-create the directory to deny service; multi-user deployments should provide a private `XDG_RUNTIME_DIR`.
## [1.0.56-beta.2] - 2026-07-30
This beta adds PRs #831 and #835 on top of v1.0.56-beta.1. It separates
Agent Product observability and IM display identity from the stable
edition-owned PAT and routing identity, and reduces common-path Skill context
loading without changing the public command or Runtime Schema surface.
### Changed
- **Agent Product identity separation** (#831) — sends `DWS_AGENT_PRODUCT` through the new `x-dws-agent-product` observability Header and uses a valid non-empty value for the IM `clawType` display label whenever `--ai-tag` is enabled. Because `--ai-tag` defaults to `true`, callers that set `DWS_AGENT_PRODUCT` change the displayed label by default. With `--ai-tag=false`, native `chat message send` / `reply` calls preserve their existing wire shape by sending an empty IM `clawType`, while shortcut calls omit the argument. Unset or empty Product values omit the Header and preserve the active edition's IM display default.
- **Agent Host dimension convention** (#831) — new integrations should send the runtime form (`cloud` or `desktop`) through `DWS_AGENT_HOST` and report the product separately through `DWS_AGENT_PRODUCT`. Legacy combined labels such as `qwenwork_cloud` remain syntactically valid for compatibility.
- **Reduced common-path Skill context** (#835) — keeps the complete 97-command Chat Shortcut inventory in Runtime Catalog and leaf Schema while routing common intents through compact Skill tables and references. When an exact command path is already known, the mono Skill no longer requires eager loading of a complete product reference. The generated Skill policy now detects drift, forced full-reference loading, and context-budget regressions; the common Chat plus shared activation estimate drops from 7,301 to 4,771 `o200k_base` tokens without changing the 845-tool Schema surface.
### Fixed
- **Stable PAT/routing identity** (#831) — restores the CLI-emitted open-source HTTP `claw-type` and PAT `hostControl.clawType` to the edition-fixed `openClaw` value. `DWS_AGENT_PRODUCT` no longer changes those wire values, and the client continues to derive PAT, authentication, routing, and Discovery behaviour from the existing independent signals.
- **Portable generated Skill validation** (#835) — resolves the mono Skill name by scanning upward from the generated target, keeping `--check` independent of the repository checkout path and preventing false drift failures when an ancestor directory resembles a Skill name.
## [1.0.56-beta.1] - 2026-07-30
This beta starts the v1.0.56 line on top of v1.0.55 and packages PRs #817,
#806, and #834, together with release-validation fixes #838 and #839. It closes
the remaining Agent-visible IM shortcut gaps, introduces reviewed
command-scoped parameter normalization without guessing business identifiers
or values, and prevents deterministic personal-event subscription failures
from becoming unbounded retry storms.
### Added
- **Complete IM shortcut workflows** (#817) — publishes the previously excluded `+chat-messages`, `+messages-send`, `+messages-send-card`, `+search-msg`, and `+thread-replies` shortcuts in Runtime Schema. Unified send, streaming-card delivery, advanced search, thread replies, and opt-in resource downloads now share reviewed parameters, selection guidance, and runtime-aligned safety semantics.
- **Reviewed parameter concept normalization** (#806) — adds a closed parameter-concept dictionary and generated command-level alias table, covering reviewed IM synonyms while preserving the boundaries between group, conversation, user, open-user, cursor, and paging identifiers.
### Fixed
- **Message delivery and resource handling** (#817) — resolves direct recipients through exact contact search, preserves rich and nested message resources, avoids same-name download overwrites, and prevents read shortcuts from silently returning empty results on non-interactive input.
- **Parameter parsing safety** (#806) — rejects ambiguous, blocked, or conflicting aliases before dispatch, normalizes explicit boolean values such as `--dry-run false`, and keeps internal pre-parse handler details out of user-visible errors.
- **Personal-event subscription retry safety** (#834) — adds cross-process attempt claims, deterministic backoff and jitter, `Retry-After` handling, terminal holds, compare-and-swap completion, and fail-closed state handling across all public personal-event subscriptions, preventing deterministic failures from causing unbounded callback retries.
- **Scoped CI and release validation reliability** (#838, #839) — keeps scoped coverage aligned with intentionally skipped supporting profiles, gives focused race and Multi-profile E2E suites enough time for the current `internal/app` workload, and preserves hidden E2E diagnostics on failure.
## [1.0.55-beta.8] - 2026-07-30
This beta revalidates the `v1.0.55-beta.7` product baseline through a complete
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.55-beta.8"
version "1.0.56-beta.3"
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.55-beta.8/dws-darwin-arm64.tar.gz"
sha256 "07fabf720fa98f82c56027df703a3ad3f0aec16c957ea684047928f9f74fa00f"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-darwin-arm64.tar.gz"
sha256 "5d35bb3fca7883a4ee51e1561aefd6b954b104313359a7c05b30a333ea41e749"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-darwin-amd64.tar.gz"
sha256 "fbec64dc5c3463a04de9720ffb1fd247196e619ea81b976b47ecbf751a17fb72"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-darwin-amd64.tar.gz"
sha256 "5a94069a3ab2c811d915639bbfd9ff17035b77501d5f9070c0b540ffe525a41b"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-linux-arm64.tar.gz"
sha256 "f111cdffef0188ddf954d2174fa0d318069bcec80104775483f13f645aa8089b"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-linux-arm64.tar.gz"
sha256 "e9cbc1c647f3ea703e1b93264c0e7d92871cfabe26fef26fbe17b9dff4b80c0f"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-linux-amd64.tar.gz"
sha256 "d69475b7f3cec4bad4c051834df22fbfadfa7075b82347e6ca7490f67d71d0b1"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-linux-amd64.tar.gz"
sha256 "7c475841ea871d204f9f6efc7d6e5378cf19db4541aeaa8a27d1387fa6f2a1f1"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-skills.zip"
sha256 "be8c9267704cfef1319fc9cd2fcba5aebe45b670b4dc37d3dae15f65523356f8"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-skills.zip"
sha256 "b29b35345613bc9260ae37578eb982472591c9dc548b02176b5b9ba0bdc431f2"
end
def install
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCli < Formula
desc "Automate DingTalk workspace tasks from the terminal"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.54"
version "1.0.55"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-darwin-arm64.tar.gz"
sha256 "8ae0e52cf973f6fb3df61c67a41fd11e2df417a0c815762b6060cbcb5e600c08"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-darwin-arm64.tar.gz"
sha256 "dd753bbd051e5dd007cf433b8aa211c4a221dd73dfcb0b3783fa924d09f12351"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-darwin-amd64.tar.gz"
sha256 "11b711b9d70dea62304bf5f8206c56b4e7ea91148dafe97fb7c0f844a2a61da3"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-darwin-amd64.tar.gz"
sha256 "f465eb7ac38a8a84eac4eb821fd15424bfc6f6245a60fa695ba97a639970dd77"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-linux-arm64.tar.gz"
sha256 "9c7ecb4c8cd55644b2faa73f6ce7843c0279b23793e23deb5061692ea71a0cf1"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-linux-arm64.tar.gz"
sha256 "5961be0fd551ec8e69b6fff2b1609f73486f7e6c3ffe8eb4bb99fa1ed691b401"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-linux-amd64.tar.gz"
sha256 "8a0bc245747fc3facf98c8103c06da46852a30bff31ac93b0aa874e8c7e46db7"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-linux-amd64.tar.gz"
sha256 "051ba404a5f6a8fb15def0e0f5d9d273cf9d63f881df2fffe159f2c4ea3366e7"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-skills.zip"
sha256 "7450fd0115c75bfe6820c7099f348973d9353cca9d8d647c9cddcd70978a7ec0"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-skills.zip"
sha256 "bd35f674f184001f5a03c7b5fa6029ebcda54f0054e15cd608b5b5e213ce2d05"
end
def install
+30 -2
View File
@@ -8,7 +8,7 @@ POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go
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 cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata 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 cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata 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
@@ -30,6 +30,7 @@ help:
@printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n"
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
@printf " make skill-context-budget - Check generated Skill drift and common-path context budgets\n"
@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"
@@ -84,9 +85,13 @@ fmt:
policy: test-auth-legacy-compat
@mkdir -p "$(POLICY_GOTMPDIR)"
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
@$(POLICY_ENV) ./scripts/policy/check-schema-command-registry.sh
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
@$(POLICY_ENV) ./scripts/policy/check-param-concepts.sh
@$(POLICY_ENV) ./scripts/policy/check-param-alias-cooccurrence.sh
@$(POLICY_ENV) $(GO) test -count=1 ./internal/app -run '^(TestParamAlias(FixtureThroughEmbeddedDeliveryPath|ReadCommandFinalPayload|WriteCommandFinalPayload|CanonicalConflictFailsBeforeRunE|BlockedFlagReachesReviewedFinalError)|TestFlagConflictErrorFormattingIsDeterministic)$$'
@$(POLICY_ENV) ./scripts/policy/check-schema-catalog.sh
@$(POLICY_ENV) ./scripts/policy/check-schema-binary.sh
@$(POLICY_ENV) $(MAKE) test-schema-agent-examples
@@ -118,6 +123,9 @@ schema-compatibility:
skill-command-integrity:
@./scripts/policy/check-skill-commands.sh
skill-context-budget:
@./scripts/policy/check-skill-context-budget.sh
cli-smoke:
@./scripts/policy/check-cli-smoke.sh
@@ -130,10 +138,14 @@ test-schema-agent-examples:
generate-schema:
@set -e; \
registry_guard=$$(mktemp -d); \
concepts_guard=$$(mktemp); \
concepts_schema_guard=$$(mktemp); \
metadata_guard=$$(mktemp -d); \
selection_guard=$$(mktemp -d); \
trap 'rm -rf "$$registry_guard" "$$metadata_guard" "$$selection_guard"' EXIT HUP INT TERM; \
trap 'rm -rf "$$registry_guard" "$$concepts_guard" "$$concepts_schema_guard" "$$metadata_guard" "$$selection_guard"' EXIT HUP INT TERM; \
cp -R internal/cli/schema_command_registry/ "$$registry_guard/"; \
cp internal/cli/param_concepts.json "$$concepts_guard"; \
cp internal/cli/param_concepts.schema.json "$$concepts_schema_guard"; \
cp -R internal/cli/schema_hints/metadata/. "$$metadata_guard/"; \
cp -R internal/cli/schema_hints/selection/. "$$selection_guard/"; \
$(GO) generate ./internal/cli; \
@@ -141,6 +153,22 @@ generate-schema:
printf '%s\n' 'generation modified reviewed input internal/cli/schema_command_registry/' >&2; \
exit 1; \
}; \
cmp -s internal/cli/param_concepts.json "$$concepts_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.json' >&2; \
exit 1; \
}; \
cmp -s internal/cli/param_concepts.schema.json "$$concepts_schema_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \
exit 1; \
}; \
cmp -s internal/cli/param_concepts.json "$$concepts_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.json' >&2; \
exit 1; \
}; \
cmp -s internal/cli/param_concepts.schema.json "$$concepts_schema_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \
exit 1; \
}; \
diff -qr internal/cli/schema_hints/metadata "$$metadata_guard" >/dev/null || { \
printf '%s\n' 'generation modified reviewed input internal/cli/schema_hints/metadata' >&2; \
exit 1; \
+68 -1
View File
@@ -4,7 +4,7 @@ Defines the stable `dws event consume` subprocess contract so an
orchestrator can determine when the consumer is ready, stop it cleanly,
and machine-read why it exited.
Scope of this branch: the four **contract** items below. Reconnect
Scope of this branch: the five **contract** items below. Reconnect
resilience (keeping the stream alive across a transient upstream drop) is
tracked separately and intentionally out of scope here.
@@ -92,6 +92,73 @@ Ownership-based cleanup:
- T4c (control): `kill -9` leaves subscribe_id lingering (documented risk;
we only guarantee SIGTERM is clean, we do not fix kill -9 itself).
### 5. Subscription-create retry orchestration and local guard
This policy covers all 16 public personal-event keys and every logical
subscription in a multi-event command. It applies only before the ready
marker; reconnecting an established Stream remains a separate mechanism.
- The `0/2/1` limits below are an **Agent/host orchestration contract**, not
a CLI-enforced persisted total-attempt cap. Each `dws event consume`
process sends at most one subscription-create HTTP request for a logical
subscription and performs no in-process automatic retry. The CLI persists
only the `in_flight`, `cooldown`, and `terminal_hold` guard states; it does
not persist or enforce the Agent/host attempt count across invocations.
- ID resolution, `event consume`, and later `event status/stop` must use the
same `--profile`. A user or conversation ID resolved under another profile
must not be reused for the current subscription.
- A logical subscription is keyed by the current profile/identity, event key,
rule type, target, and filters. A new `subscribe_id`, `trace_id`, or process
does not create a new logical operation or reset the Agent/host budget.
- For the Agent/host, `retryable=false` means
`max_additional_attempts=0`.
- For the Agent/host, `retryable=true` means
`max_additional_attempts=2`. It must honor `retry_after_seconds` or
`next_retry_at` when present and must not retry early.
- For the Agent/host, an omitted retryable value
(`retryable=unknown`) means `max_additional_attempts=1`; a second unknown
failure stops the operation.
- `in_flight` means the original logical request is still running.
`cooldown` and `terminal_hold` mean a guard is already delaying or blocking
it. These states must not recursively launch `event consume`, start a
parallel equivalent subscription, or bypass the guard with a new subId or
trace. The caller waits for the original request/guard or stops, while the
Agent/host keeps its own orchestration count.
- A multi-event command remains one original operation. A caller must not
split out a failed event, reorder events, or restart the command to bypass
a budget. Existing startup rollback cleans subscriptions created before a
later item fails.
#### Local guard state operations
- The default open-edition state file is
`~/.dws/events/open/personal_stream/<identity_hash>/personal_subscription_attempts.json`.
The config root follows `DWS_CONFIG_DIR` when set, and another edition uses
that edition's directory instead of `open`.
- The identity directory is mode `0700`; both
`personal_subscription_attempts.json` and
`personal_subscription_attempts.lock` are mode `0600`.
- A failure streak resets after 24h without another failure. A
`terminal_hold` lasts 1h. Prefer waiting until the reported
`next_retry_at`; do not clear the file as a normal retry mechanism.
- For emergency recovery, first ensure that no subscription-create process is
running for that identity. Delete only
`personal_subscription_attempts.json`, never the lock file. This clears
every protection record for that identity, not just one event.
**Verification**
- T5a (policy): skill/docs tests pin the Agent/host 0/2/1 orchestration
contract and explicitly reject describing it as a CLI-persisted hard cap.
- T5b (CLI): one process issues at most one create request per logical
subscription; a changed subId/trace or process restart does not bypass the
persisted fingerprint guard.
- T5c: `in_flight`/`cooldown` does not recursively issue another create.
- T5d: multi-event startup cannot be split or reordered to bypass the guard,
and a partial startup still rolls back earlier subscriptions.
- T5e: state-store tests cover `0700`/`0600` permissions, 24h reset, 1h
`terminal_hold`, and identity-scoped cleanup; skill/docs tests pin the
operational recovery instructions.
## Out of scope (next branch)
**Reconnect resilience** — today `personal source` retries only
+47 -25
View File
@@ -5,8 +5,8 @@
| Variable | Purpose / 用途 |
|---------|---------|
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
| `DWS_AGENT_PRODUCT` | Optional, caller-declared Agent product sent through the existing HTTP `claw-type` header (for example `qwenwork`). Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9_-]*$`. Unset or empty values preserve the edition default (`openClaw` in the open-source build). / 可选、由调用方声明的 Agent 产品标识,经校验后覆盖 HTTP `claw-type` 请求头;未设置或为空时保持当前发行版默认值 |
| `DWS_AGENT_HOST` | Optional, caller-declared Agent runtime form sent as `x-dws-agent-host` (for example `cloud` or `desktop`). Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. / 可选、由调用方声明的 Agent 运行形态,经校验后作为 `x-dws-agent-host` 发送;未设置时省略 |
| `DWS_AGENT_PRODUCT` | Optional, caller-declared Agent product sent as `x-dws-agent-product` (for example `qwenwork`) for downstream logs/BI and used as the IM `clawType` display label when `--ai-tag` is enabled. `--ai-tag` defaults to `true`, so a configured Product changes the displayed label by default. With `--ai-tag=false`, native `chat message send` / `reply` calls send an empty `clawType`, while shortcut calls omit the argument. Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9_-]*$`. Unset or empty values omit the Header and use the edition's IM display default. This client never uses Product to change the separate HTTP `claw-type` PAT/routing label. / 可选、由调用方声明的 Agent 产品标识,经校验后作为 `x-dws-agent-product` 发送,并用于 IM 小尾巴;`--ai-tag` 默认为 `true`,因此配置 Product 后默认会改变展示标签。使用 `--ai-tag=false` 时,原生 `chat message send` / `reply` 发送空的 `clawType`,shortcut 调用则省略该参数。未设置时省略请求头且 IM 使用发行版默认值;本客户端不会用 Product 修改独立的 HTTP `claw-type` |
| `DWS_AGENT_HOST` | Optional, caller-declared Agent runtime form sent as `x-dws-agent-host` (for example `cloud` or `desktop`) for downstream logs/BI. Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. This client does not use Host for PAT, authentication, Discovery, or MCP endpoint selection. / 可选、由调用方声明的 Agent 运行形态,经校验后作为 `x-dws-agent-host` 发送给下游日志/BI;本客户端不使用该值进行 PAT、鉴权、Discovery 或 MCP 端点选择,未设置时省略 |
| `DWS_<PRODUCT>_MCP_URL` | Override a product MCP endpoint for local development / 本地开发时覆盖指定产品 MCP endpoint |
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
@@ -14,23 +14,36 @@
| `DWS_ALLOW_HTTP_ENDPOINTS` | Set `1` to allow HTTP for loopback during dev / 设为 `1` 允许回环地址 HTTP,仅用于开发调试 |
| `DWS_DISABLE_KEYCHAIN` | macOS only. Set `1` to skip system Keychain for the encryption key and use file-based storage (same scheme as Linux). For sandboxed runtimes (e.g. Codex App) that block Keychain APIs. Weakens at-rest protection — DEK and ciphertext live in the same directory. / 仅 macOS。设为 `1` 时跳过系统 Keychain,密钥以文件形式存储(与 Linux 一致)。用于 Keychain API 被拦截的沙盒环境(如 Codex App)。代价是 DEK 与密文同目录,保护强度低于默认方案 |
### Agent Product and Host trust model / Agent 产品与运行形态的信任模型
### Agent Product, Host, and `claw-type` / Agent 产品、运行形态与 `claw-type`
`DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` are caller-declared selection and
observation signals. They are not credentials, attestations, or proof of the
calling host's identity. DingTalk services may record them for logs/BI and may
combine supported values with separately authenticated context for PAT
compatibility, PAT identity/source derivation, or Discovery eligibility. A
service must allowlist supported values and must never grant access, bypass
authentication, or skip authorization solely because either Header claims a
particular product or runtime form. They are not used to select ordinary MCP
tool endpoints.
`DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` are caller-declared observation
signals. They are not credentials, attestations, or proof of the calling
host's identity. The CLI validates and emits `x-dws-agent-product` and
`x-dws-agent-host`, but does not use either value to derive its authentication,
PAT mode, Discovery behaviour, or ordinary MCP endpoint selection. Downstream
services own and must document their own contracts for these caller-declared
Headers.
`DWS_AGENT_PRODUCT` controls only the HTTP `claw-type` Header. The similarly
named `clawType` tool argument on IM send operations is an independent
message-display axis used for the “Send from AI” label. It remains controlled
by the active edition's `ClawTypeValue` and `--ai-tag`; changing
`DWS_AGENT_PRODUCT` does not change that message label.
Service integrators should treat both Headers as untrusted input, allowlist
expected values, and should not grant access, bypass authentication, or skip
authorization solely because a Header claims a particular Product or Host.
The HTTP `claw-type` Header is a separate, edition-fixed PAT/routing label:
`openClaw` in the open-source build. `DWS_AGENT_PRODUCT` never changes it or
PAT `hostControl.clawType`. On IM send/reply operations with `--ai-tag`,
however, a valid non-empty Product value is used as the `clawType` tool
argument so the delivered message carries the matching “Send from AI” label.
Because `--ai-tag` defaults to `true`, this display change is enabled by
default for callers that set Product. With `--ai-tag=false`, native
`chat message send` / `reply` calls serialize `clawType: ""`, while shortcut
calls omit the argument; this client does not assume downstream services treat
an empty value and an absent key as equivalent. The display-value precedence
when the tag is enabled is valid non-empty `DWS_AGENT_PRODUCT`, then the active
edition's `ClawTypeValue`, then `openClaw`.
Do not set arbitrary Product values that the target downstream and IM services
have not explicitly enabled; an unknown value may be ignored or may not render
the expected label.
For QwenWork, report the dimensions separately:
@@ -39,16 +52,25 @@ DWS_AGENT_PRODUCT=qwenwork
DWS_AGENT_HOST=cloud # or desktop
```
Do not set arbitrary product values that the target service has not explicitly
enabled. Older combined Host labels such as `qwenwork_cloud` still satisfy the
generic syntax for compatibility, but new integrations should use the
two-dimensional convention above.
Older combined Host labels such as `qwenwork_cloud` still satisfy the generic
syntax for compatibility, but new integrations should use the two-dimensional
convention above.
`DWS_AGENT_PRODUCT` 和 `DWS_AGENT_HOST` 均由调用方声明,不是认证凭据,也不能证明
真实宿主身份。服务端可以在独立认证上下文中将受支持值用于日志/BI、PAT 兼容策略、
PAT 身份/来源派生或 Discovery 准入,但不得仅凭这两个 Header 放权、绕过认证或跳过
授权。HTTP `claw-type` 与 IM 消息发送参数 `clawType` 是两个独立维度;后者仅控制
“Send from AI”展示,仍由发行版 `ClawTypeValue` 和 `--ai-tag` 决定。
真实宿主身份。CLI 只负责校验并发送 `x-dws-agent-product` 与 `x-dws-agent-host`,
不会用它们派生本客户端的鉴权、PAT 模式、Discovery 行为或 MCP 端点;下游服务的
使用契约由对应服务自行定义和说明。HTTP `claw-type` 是发行版固定的 PAT/路由标签,
开源版固定为 `openClaw`,不受 `DWS_AGENT_PRODUCT` 影响。
服务集成方应将这两个请求头视为不可信输入并对白名单值做校验,不应仅因请求头声明了
某个 Product 或 Host 就授予访问、绕过认证或跳过鉴权。
`--ai-tag` 默认为 `true`,因此配置合法非空 Product 后,默认发送的 IM 工具参数
`clawType` 及小尾巴会随之改变。传入 `--ai-tag=false` 时,原生
`chat message send` / `reply` 会发送 `clawType: ""`,shortcut 调用则省略该参数;
本客户端不假定下游会将空值与键缺失等价处理。启用小尾巴时,展示值优先级依次为
`DWS_AGENT_PRODUCT`、当前发行版的 `ClawTypeValue`、`openClaw`。不要传入目标下游及
IM 服务未明确支持的 Product 值,否则可能被忽略或无法展示预期标签。
## Exit Codes / 退出码
+1 -1
View File
@@ -33,7 +33,7 @@ func init() {
configmeta.Register(configmeta.ConfigItem{
Name: envDWSAgentHost,
Category: configmeta.CategoryExternal,
Description: "调用 DWS 的 Agent 运行形态标识;服务端可结合产品用于观测和 PAT 兼容策略",
Description: "调用 DWS 的 Agent 运行形态标识;作为 x-dws-agent-host 发送供下游观测,本客户端不使用该值改变 PAT、鉴权或路由",
Example: "cloud",
})
}
+19 -18
View File
@@ -24,8 +24,8 @@ func init() {
configmeta.Register(configmeta.ConfigItem{
Name: agentproduct.EnvName,
Category: configmeta.CategoryExternal,
Description: "调用方声明的 Agent 产品标识;覆盖 HTTP claw-type,但不是认证凭据",
DefaultValue: "由当前发行版决定",
Description: "调用方声明的 Agent 产品标识;作为 x-dws-agent-product 发送并用于 IM 小尾巴,本客户端不使用该值改变 HTTP claw-type/PAT",
DefaultValue: "未设置(请求头省略,IM 使用当前发行版默认值)",
Example: "qwenwork",
})
}
@@ -47,25 +47,26 @@ func invalidAgentProductError() error {
)
}
// resolveEffectiveAgentProduct resolves the request-header identity with one
// shared precedence rule: a valid non-empty runtime override wins, otherwise
// the edition's MergeHeaders value wins, otherwise the OSS default is used.
// Invalid runtime input falls back here for library callers that bypass root
// validation; normal CLI execution rejects it before network access.
func resolveEffectiveAgentProduct(headers map[string]string) string {
fallback := edition.DefaultOSSClawType
if value := headers[agentproduct.HeaderName]; value != "" {
fallback = value
// resolveEditionClawType resolves the fixed routing/PAT identity supplied by
// the active edition. DWS_AGENT_PRODUCT is deliberately not consulted.
func resolveEditionClawType(headers map[string]string) string {
if value := headers["claw-type"]; value != "" {
return value
}
value, err := agentproduct.ResolveFromEnv(fallback)
if err != nil {
return fallback
}
return value
return edition.DefaultOSSClawType
}
func applyAgentProductOverride(headers map[string]string) map[string]string {
value := resolveEffectiveAgentProduct(headers)
// applyAgentProductHeader injects only a valid, non-empty caller-declared
// product. Invalid values are omitted on library paths that bypass root
// validation; normal CLI execution rejects them before network access.
func applyAgentProductHeader(headers map[string]string) map[string]string {
value, err := agentproduct.ResolveFromEnv("")
if err != nil || value == "" {
if headers != nil {
delete(headers, agentproduct.HeaderName)
}
return headers
}
if headers == nil {
headers = make(map[string]string)
}
+100 -34
View File
@@ -26,13 +26,16 @@ import (
"github.com/spf13/cobra"
)
func TestUnsetAgentProductKeepsOpenSourceDefault(t *testing.T) {
func TestUnsetAgentProductOmitsHeaderAndKeepsOpenSourceClawType(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != edition.DefaultOSSClawType {
t.Fatalf("%s = %q, want %q", agentproduct.HeaderName, got, edition.DefaultOSSClawType)
if got := headers["claw-type"]; got != edition.DefaultOSSClawType {
t.Fatalf("claw-type = %q, want %q", got, edition.DefaultOSSClawType)
}
if _, ok := headers[agentproduct.HeaderName]; ok {
t.Fatalf("unset Product must omit %s", agentproduct.HeaderName)
}
}
@@ -59,38 +62,46 @@ func TestParseAgentProductReturnsStableValidationError(t *testing.T) {
}
}
func TestResolveIdentityHeadersAgentProductPrecedence(t *testing.T) {
func TestResolveIdentityHeadersSeparatesAgentProductFromClawType(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
headers["claw-type"] = "wukong"
headers[agentproduct.HeaderName] = "merge-product-must-not-win"
headers["x-edition-header"] = "preserved"
return headers
},
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "enterprise-default"
headers["claw-type"] = "credential-must-not-win"
headers[agentproduct.HeaderName] = "credential-product-must-not-win"
headers["x-enterprise-header"] = "preserved"
return headers
},
})
t.Run("unset keeps edition default", func(t *testing.T) {
t.Run("unset omits Product and keeps edition claw-type", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, "")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "wukong" {
t.Fatalf("%s = %q, want wukong", agentproduct.HeaderName, got)
if got := headers["claw-type"]; got != "wukong" {
t.Fatalf("claw-type = %q, want wukong", got)
}
if _, ok := headers[agentproduct.HeaderName]; ok {
t.Fatalf("unset Product must omit %s", agentproduct.HeaderName)
}
})
t.Run("valid override is final", func(t *testing.T) {
t.Run("valid Product is final without changing claw-type", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, " qwenwork ")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
}
if got := headers["claw-type"]; got != "wukong" {
t.Fatalf("claw-type = %q, want wukong", got)
}
if got := headers["x-edition-header"]; got != "preserved" {
t.Fatalf("edition header = %q, want preserved", got)
}
@@ -102,21 +113,48 @@ func TestResolveIdentityHeadersAgentProductPrecedence(t *testing.T) {
}
})
t.Run("invalid library input falls back to edition", func(t *testing.T) {
t.Run("invalid library input omits Product and keeps edition claw-type", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwen work")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "wukong" {
t.Fatalf("%s = %q, want wukong", agentproduct.HeaderName, got)
if got := headers["claw-type"]; got != "wukong" {
t.Fatalf("claw-type = %q, want wukong", got)
}
if _, ok := headers[agentproduct.HeaderName]; ok {
t.Fatalf("invalid Product must omit %s", agentproduct.HeaderName)
}
})
}
func TestApplyAgentProductOverrideAllocatesHeaders(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwenwork")
func TestApplyAgentProductHeader(t *testing.T) {
t.Run("valid value allocates headers", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwenwork")
headers := applyAgentProductOverride(nil)
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
headers := applyAgentProductHeader(nil)
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
}
})
for _, tc := range []struct {
name string
value string
}{
{name: "empty value", value: ""},
{name: "invalid value", value: "qwen work"},
} {
t.Run(tc.name+" removes inherited header", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, tc.value)
headers := applyAgentProductHeader(map[string]string{
agentproduct.HeaderName: "must-not-leak",
"x-preserved": "yes",
})
if _, ok := headers[agentproduct.HeaderName]; ok {
t.Fatalf("%s must be omitted", agentproduct.HeaderName)
}
if got := headers["x-preserved"]; got != "yes" {
t.Fatalf("x-preserved = %q, want yes", got)
}
})
}
}
@@ -167,17 +205,17 @@ func TestEffectiveClawTypeDoesNotInvokeEnterpriseCredentialHeaders(t *testing.T)
hookCalled := false
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
headers["claw-type"] = "wukong"
return headers
},
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
hookCalled = true
headers[agentproduct.HeaderName] = "enterprise-default"
headers["claw-type"] = "enterprise-default"
return headers
},
})
t.Setenv(agentproduct.EnvName, "")
t.Setenv(agentproduct.EnvName, "qwenwork")
if got := effectiveClawType(); got != "wukong" {
t.Fatalf("effectiveClawType() = %q, want wukong", got)
}
@@ -186,7 +224,7 @@ func TestEffectiveClawTypeDoesNotInvokeEnterpriseCredentialHeaders(t *testing.T)
}
}
func TestAgentProductHeaderIsSeparateFromMessageClawType(t *testing.T) {
func TestAgentProductControlsObservabilityHeaderAndMessageClawTypeOnly(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "qwenwork")
@@ -195,20 +233,24 @@ func TestAgentProductHeaderIsSeparateFromMessageClawType(t *testing.T) {
edition.Override(&edition.Hooks{
ClawTypeValue: "message-brand",
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
headers["claw-type"] = "wukong"
return headers
},
})
if got := resolveIdentityHeaders()[agentproduct.HeaderName]; got != "qwenwork" {
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("HTTP %s = %q, want qwenwork", agentproduct.HeaderName, got)
}
if got := edition.ClawType(); got != "message-brand" {
t.Fatalf("message clawType = %q, want message-brand", got)
if got := headers["claw-type"]; got != "wukong" {
t.Fatalf("HTTP claw-type = %q, want wukong", got)
}
if got := edition.ClawType(); got != "qwenwork" {
t.Fatalf("message clawType = %q, want qwenwork", got)
}
}
func TestResolveIdentityHeadersRestoresAgentProductAfterNilCredentialHeaders(t *testing.T) {
func TestResolveIdentityHeadersRestoresIdentityAfterNilCredentialHeaders(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "qwenwork")
@@ -218,7 +260,7 @@ func TestResolveIdentityHeadersRestoresAgentProductAfterNilCredentialHeaders(t *
credentialHookCalled := false
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
headers["claw-type"] = "wukong"
return headers
},
EnterpriseCredentialHeaders: func(map[string]string) map[string]string {
@@ -234,25 +276,49 @@ func TestResolveIdentityHeadersRestoresAgentProductAfterNilCredentialHeaders(t *
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
}
if got := headers["claw-type"]; got != "wukong" {
t.Fatalf("claw-type = %q, want wukong", got)
}
}
func TestEffectiveClawTypeUsesAgentProductOverride(t *testing.T) {
func TestResolveIdentityHeadersRestoresDefaultsAfterNilMergeHeaders(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(map[string]string) map[string]string {
return nil
},
})
headers := resolveIdentityHeaders()
if got := headers["claw-type"]; got != edition.DefaultOSSClawType {
t.Fatalf("claw-type = %q, want %q", got, edition.DefaultOSSClawType)
}
if _, ok := headers[agentproduct.HeaderName]; ok {
t.Fatalf("unset Product must omit %s", agentproduct.HeaderName)
}
}
func TestEffectiveClawTypeIgnoresAgentProduct(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
headers["claw-type"] = "wukong"
return headers
},
})
t.Setenv(agentproduct.EnvName, "qwenwork")
if got := effectiveClawType(); got != "qwenwork" {
t.Fatalf("effectiveClawType() = %q, want qwenwork", got)
if got := effectiveClawType(); got != "wukong" {
t.Fatalf("effectiveClawType() = %q, want wukong", got)
}
t.Setenv(authpkg.AgentCodeEnv, "agent-code")
if got := apperrors.HostControlBlock()["clawType"]; got != "qwenwork" {
t.Fatalf("hostControl.clawType = %q, want qwenwork", got)
if got := apperrors.HostControlBlock()["clawType"]; got != "wukong" {
t.Fatalf("hostControl.clawType = %q, want wukong", got)
}
t.Setenv(agentproduct.EnvName, "")
+8
View File
@@ -199,6 +199,14 @@ func TestCrossPlatformCoverageAuditRuntimeCoverage(t *testing.T) {
sharedAuditSink = previousSink
loadTokenForProfile = previousLoader
auditSinkOnce, auditCloseOnce = sync.Once{}, sync.Once{}
// The process-wide sink was initialized by TestMain. Preserve that
// initialized state when restoring it: leaving auditSinkOnce unused
// lets a later runner overwrite the live sink without closing its
// .audit.lock handle, which makes TestMain cleanup fail on Windows.
auditSinkOnce.Do(func() {})
if got := setupAuditSink(); got != previousSink {
t.Errorf("restored audit sink = %T, want original %T", got, previousSink)
}
resetAuditIdentityCache()
})
+228
View File
@@ -0,0 +1,228 @@
// 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"
"reflect"
"sort"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
func TestAllDistributionBooleanFlagTypesNormalizeDetachedLiterals(t *testing.T) {
root := NewSchemaSourceRootCommand()
unique := make(map[string]pipeline.FlagInfo)
var visit func(*cobra.Command)
visit = func(command *cobra.Command) {
for _, spec := range pipeline.FlagInfoFromCommand(command) {
if spec.Type != "bool" && spec.Type != "boolean" {
continue
}
key := strings.Join([]string{spec.Name, spec.Shorthand, spec.Type}, "\x00")
unique[key] = spec
}
for _, child := range command.Commands() {
visit(child)
}
}
visit(root)
keys := make([]string, 0, len(unique))
for key := range unique {
keys = append(keys, key)
}
sort.Strings(keys)
if len(keys) < 80 {
t.Fatalf("boolean flag contract coverage is unexpectedly small: %d", len(keys))
}
for _, key := range keys {
spec := unique[key]
for _, value := range []string{"true", "false"} {
t.Run(spec.Name+"/"+value, func(t *testing.T) {
ctx := &pipeline.Context{
Command: "dws contract probe",
Args: []string{"--" + spec.Name, value},
FlagSpecs: []pipeline.FlagInfo{spec},
}
if err := (handlers.BoolValueHandler{}).Handle(ctx); err != nil {
t.Fatalf("BoolValueHandler.Handle() error = %v", err)
}
want := []string{"--" + spec.Name + "=" + value}
if !reflect.DeepEqual(ctx.Args, want) {
t.Fatalf("normalized args = %v, want %v", ctx.Args, want)
}
flags := pflag.NewFlagSet(spec.Name, pflag.ContinueOnError)
flags.Bool(spec.Name, false, "")
if err := flags.Parse(ctx.Args); err != nil {
t.Fatalf("pflag rejected normalized args %v: %v", ctx.Args, err)
}
got, err := flags.GetBool(spec.Name)
if err != nil || got != (value == "true") || !flags.Changed(spec.Name) {
t.Fatalf("parsed %s = %v, changed=%v, error=%v", spec.Name, got, flags.Changed(spec.Name), err)
}
})
}
}
t.Logf("verified detached boolean syntax for %d distinct distribution flag contracts", len(keys))
}
func TestBooleanSyntaxPreservesDefaultsRequiredAndChangedContracts(t *testing.T) {
tests := []struct {
name string
path string
flag string
value string
wantDefault string
wantValue string
}{
{name: "root default false", path: "chat bot find", flag: "dry-run", value: "false", wantDefault: "false", wantValue: "false"},
{name: "root mock default false", path: "chat bot find", flag: "mock", value: "true", wantDefault: "false", wantValue: "true"},
{name: "local force default false", path: "upgrade", flag: "force", value: "false", wantDefault: "false", wantValue: "false"},
{name: "local default true", path: "sheet find", flag: "match-case", value: "false", wantDefault: "true", wantValue: "false"},
{name: "required explicit false", path: "contact dept create", flag: "create-dept-group", value: "false", wantDefault: "false", wantValue: "false"},
{name: "changed false remains explicit", path: "sheet csv-put", flag: "allow-overwrite", value: "false", wantDefault: "false", wantValue: "false"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := NewSchemaSourceRootCommand()
leaf := resolveParamLeaf(root, test.path)
if leaf == nil {
t.Fatalf("command %q is not runnable", test.path)
}
flag := booleanContractFlag(leaf, test.flag)
if flag == nil || flag.DefValue != test.wantDefault || flag.Changed {
t.Fatalf("initial --%s contract = %#v, want default %q and unchanged", test.flag, flag, test.wantDefault)
}
pathArgs := strings.Fields(test.path)
rawArgs := append(append([]string(nil), pathArgs...), "--"+test.flag, test.value)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), rawArgs)
if err != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, err)
}
if ctx == nil {
t.Fatal("RunPreParseArgs returned nil context")
}
flagArgs := ctx.Args[len(pathArgs):]
if err := leaf.ParseFlags(flagArgs); err != nil {
t.Fatalf("ParseFlags(%v) error = %v", flagArgs, err)
}
flag = booleanContractFlag(leaf, test.flag)
if flag == nil || flag.Value.String() != test.wantValue || !flag.Changed {
t.Fatalf("final --%s contract = %#v, want value %q and changed", test.flag, flag, test.wantValue)
}
})
}
}
func TestDetachedDryRunValuesReachTheExpectedFinalDispatchBoundary(t *testing.T) {
base := []string{
"mail", "folder", "update",
"--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder",
}
bareArgs := append(append([]string(nil), base...), "--dry-run")
_, barePreview, bareAttempts, bareErr := executeParamAliasDryRunE2E(t, bareArgs...)
if bareErr != nil || !barePreview.DryRun || barePreview.Executed || len(bareAttempts) != 0 {
t.Fatalf("bare dry-run = preview:%#v attempts:%#v error:%v", barePreview, bareAttempts, bareErr)
}
trueArgs := append(append([]string(nil), base...), "--dry-run", "TRUE")
trueCtx, truePreview, trueAttempts, trueErr := executeParamAliasDryRunE2E(t, trueArgs...)
if trueErr != nil || !reflect.DeepEqual(truePreview, barePreview) || len(trueAttempts) != 0 {
t.Fatalf("detached true = context:%#v preview:%#v attempts:%#v error:%v", trueCtx, truePreview, trueAttempts, trueErr)
}
if !hasBooleanCorrection(trueCtx, "--dry-run TRUE", "--dry-run=true") {
t.Fatalf("detached true correction = %#v", trueCtx)
}
falseCases := []struct {
name string
args []string
}{
{name: "detached", args: append(append([]string(nil), base...), "--dry-run", "false")},
{name: "explicit", args: append(append([]string(nil), base...), "--dry-run=false")},
}
var wantAttempts []any
for _, test := range falseCases {
t.Run(test.name, func(t *testing.T) {
ctx, _, attempts, err := executeParamAliasDryRunE2E(t, test.args...)
if err == nil || !strings.Contains(err.Error(), "dry-run reached the injected command runner") {
t.Fatalf("dry-run=false dispatch error = %v", err)
}
if len(attempts) != 1 || attempts[0].DryRun {
t.Fatalf("dry-run=false attempts = %#v", attempts)
}
if test.name == "detached" && !hasBooleanCorrection(ctx, "--dry-run false", "--dry-run=false") {
t.Fatalf("detached false correction = %#v", ctx)
}
serialized := []any{attempts[0].CanonicalProduct, attempts[0].Tool, attempts[0].Params, attempts[0].DryRun}
if wantAttempts == nil {
wantAttempts = serialized
} else if !reflect.DeepEqual(serialized, wantAttempts) {
t.Fatalf("detached and explicit false dispatch differ\nwant=%#v\ngot=%#v", wantAttempts, serialized)
}
})
}
}
func TestContradictoryBooleanValuesFailBeforeDestructiveDispatch(t *testing.T) {
caller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasE2E(t, caller,
"mail", "thread", "trash",
"--email", "user@example.com", "--id", "conversation-1",
"--yes", "true", "--yes=false",
)
var conflict *pipeline.BoolValueConflictError
if !stderrors.As(err, &conflict) {
t.Fatalf("conflicting confirmation error = %v, want BoolValueConflictError (ctx=%#v)", err, ctx)
}
if conflict.Flag != "yes" || !reflect.DeepEqual(conflict.Values, []string{"false", "true"}) {
t.Fatalf("conflict = %#v", conflict)
}
if len(caller.calls) != 0 {
t.Fatalf("conflicting confirmation reached destructive dispatch: %#v", caller.calls)
}
}
func booleanContractFlag(command *cobra.Command, name string) *pflag.Flag {
if command == nil {
return nil
}
if flag := command.Flags().Lookup(name); flag != nil {
return flag
}
return command.InheritedFlags().Lookup(name)
}
func hasBooleanCorrection(ctx *pipeline.Context, original, corrected string) bool {
if ctx == nil {
return false
}
for _, correction := range ctx.Corrections {
if correction.Handler == "boolvalue" && correction.Original == original && correction.Corrected == corrected {
return true
}
}
return false
}
+4 -3
View File
@@ -1657,12 +1657,13 @@ func TestCrossPlatformCoveragePersonalEventCommandRuntimeCoverage(t *testing.T)
CorpID: "corp", UserID: "user", ClientID: "client",
})
t.Setenv("DWS_CONFIG_DIR", configDir)
var cancelCount int
var subscribeCount, cancelCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/event/sublist":
_ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{{"subId": "sub", "eventKey": personal.EventMention, "ruleType": "at", "status": "active", "sourceId": "open"}}, "total": 1})
case "/subscription/user":
subscribeCount++
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "result": []string{"created"}})
case "/subscription/cancel":
cancelCount++
@@ -1697,8 +1698,8 @@ func TestCrossPlatformCoveragePersonalEventCommandRuntimeCoverage(t *testing.T)
if err := runPersonalEventConsume(cmd, personalConsumeOptions{Common: commonConsumeOptions{Foreground: true}, EventKey: personal.EventMention, ControlBaseURL: server.URL, StreamTicketMode: "invalid"}); err == nil {
t.Fatal("invalid foreground consume succeeded")
}
if cancelCount == 0 {
t.Fatal("failed foreground consume did not clean up subscription")
if subscribeCount != 0 || cancelCount != 0 {
t.Fatalf("invalid local configuration reached subscription control: subscribe=%d cancel=%d", subscribeCount, cancelCount)
}
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub", All: true, ControlBaseURL: server.URL}); err == nil {
+1 -1
View File
@@ -161,7 +161,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
"subscribe-id", "rule", "event-types", "filter",
"foreground", "force", "debug-raw-events",
); err != nil {
return fmt.Errorf("event consume: %w", err)
return fmt.Errorf("event consume: %w", personalSubscriptionValidationError(err))
}
}
personalOpts.Common = commonConsumeOptions{
+552
View File
@@ -0,0 +1,552 @@
// 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 (
"context"
"errors"
"fmt"
"io"
"math"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
const personalSubscriptionAttemptOperation = "event.consume.personal.subscribe"
type personalSubscriptionAttemptStore interface {
Claim([]personal.AttemptSpec, time.Duration) (*personal.AttemptClaim, error)
CompleteSuccess(*personal.AttemptClaim) error
CompleteFailure(*personal.AttemptClaim, []string, personal.AttemptFailure) (personal.AttemptHold, error)
Release(*personal.AttemptClaim) error
}
var (
personalNewSubscriptionAttemptStore = func(workDir string) personalSubscriptionAttemptStore {
return personal.NewAttemptStore(workDir)
}
personalSubscriptionAttemptNow = time.Now
)
type personalSubscriptionAttemptItem struct {
eventKey string
fingerprint string
}
type personalSubscriptionAttemptReservation struct {
store personalSubscriptionAttemptStore
claim *personal.AttemptClaim
items []personalSubscriptionAttemptItem
}
type personalSubscriptionFailureClass struct {
retryability personal.Retryability
retryAfter time.Duration
code string
traceID string
reason string
auth bool
}
func reservePersonalSubscriptionAttempts(
workDir string,
client *personal.Client,
identity personal.Identity,
profileSelector string,
plans []personalConsumeOptions,
) (*personalSubscriptionAttemptReservation, error) {
if len(plans) == 0 {
return nil, personalSubscriptionGuardError(
errors.New("personal event: no subscription attempts to reserve"),
)
}
if client == nil {
return nil, personalSubscriptionGuardError(
errors.New("personal event: nil subscription control client"),
)
}
if err := validatePersonalSubscriptionEndpoint(client.BaseURL); err != nil {
return nil, personalSubscriptionValidationError(err)
}
items := make([]personalSubscriptionAttemptItem, 0, len(plans))
specs := make([]personal.AttemptSpec, 0, len(plans))
for _, plan := range plans {
prepared, err := preparePersonalSubscription(identity, plan)
if err != nil {
return nil, personalSubscriptionValidationError(err)
}
fingerprint := personal.Fingerprint(
client.BaseURL,
prepared.Request.IdempotencyKey,
profileSelector,
)
items = append(items, personalSubscriptionAttemptItem{
eventKey: prepared.EventKey,
fingerprint: fingerprint,
})
specs = append(specs, personal.AttemptSpec{
Fingerprint: fingerprint,
EventKey: prepared.EventKey,
})
}
store := personalNewSubscriptionAttemptStore(workDir)
if store == nil {
return nil, personalSubscriptionGuardError(
errors.New("personal event: subscription attempt store is unavailable"),
)
}
claim, err := store.Claim(specs, personalSubscriptionAttemptLease(client, len(specs)))
if err != nil {
var blocked *personal.AttemptBlockedError
if errors.As(err, &blocked) {
return nil, personalSubscriptionBlockedError(blocked)
}
return nil, personalSubscriptionGuardError(err)
}
return &personalSubscriptionAttemptReservation{
store: store,
claim: claim,
items: items,
}, nil
}
func validatePersonalSubscriptionEndpoint(raw string) error {
raw = strings.TrimSpace(raw)
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" ||
(!strings.EqualFold(parsed.Scheme, "http") &&
!strings.EqualFold(parsed.Scheme, "https")) {
if err == nil {
err = errors.New("an absolute http(s) URL is required")
}
return fmt.Errorf("personal event: invalid subscription control endpoint %q: %w", raw, err)
}
return nil
}
func personalSubscriptionAttemptLease(client *personal.Client, batchSize int) time.Duration {
const (
leaseOverhead = 30 * time.Second
minLease = time.Minute
maxLease = 10 * time.Minute
)
if batchSize < 1 {
batchSize = 1
}
timeout := config.HTTPTimeout
if client != nil && client.HTTPClient != nil && client.HTTPClient.Timeout > 0 {
timeout = client.HTTPClient.Timeout
}
maxRequestBudget := maxLease - leaseOverhead
if timeout <= 0 || timeout > maxRequestBudget/time.Duration(batchSize) {
return maxLease
}
lease := timeout*time.Duration(batchSize) + leaseOverhead
if lease < minLease {
return minLease
}
return lease
}
func (r *personalSubscriptionAttemptReservation) completeSuccess() error {
if r == nil {
return nil
}
if r.store == nil || r.claim == nil {
return personalSubscriptionGuardError(
errors.New("personal event: subscription attempt reservation is incomplete"),
)
}
if err := r.store.CompleteSuccess(r.claim); err != nil {
return personalSubscriptionGuardError(err)
}
return nil
}
func (r *personalSubscriptionAttemptReservation) completeFailure(
ctx context.Context,
failedIndex int,
succeededCount int,
cause error,
override *personalSubscriptionFailureClass,
) error {
if r == nil {
return cause
}
if r.store == nil || r.claim == nil {
return personalSubscriptionGuardError(errors.Join(
cause,
errors.New("personal event: subscription attempt reservation is incomplete"),
))
}
if failedIndex < 0 || failedIndex >= len(r.items) ||
succeededCount < 0 || succeededCount > failedIndex {
return personalSubscriptionGuardError(errors.Join(
cause,
errors.New("personal event: invalid subscription attempt completion indexes"),
))
}
if personalSubscriptionCanceled(ctx, cause) {
// Cancellation is not a failed attempt. Restoring the claim normally
// completes immediately; if the lock cannot be acquired, leaving the
// finite lease behind is still safer than recording a false failure.
_ = r.store.Release(r.claim)
return cause
}
classification := classifyPersonalSubscriptionFailure(cause, personalSubscriptionAttemptNow())
if override != nil {
classification = *override
}
succeeded := make([]string, 0, succeededCount)
for i := 0; i < succeededCount; i++ {
succeeded = append(succeeded, r.items[i].fingerprint)
}
hold, err := r.store.CompleteFailure(r.claim, succeeded, personal.AttemptFailure{
Fingerprint: r.items[failedIndex].fingerprint,
Retryability: classification.retryability,
RetryAfter: classification.retryAfter,
ErrorCode: classification.code,
TraceID: classification.traceID,
})
if err != nil {
return personalSubscriptionGuardError(errors.Join(cause, err))
}
return personalSubscriptionFailureError(cause, classification, hold)
}
func personalSubscriptionCanceled(ctx context.Context, err error) bool {
if errors.Is(err, context.Canceled) {
return true
}
return ctx != nil && errors.Is(ctx.Err(), context.Canceled)
}
func classifyPersonalSubscriptionFailure(err error, now time.Time) personalSubscriptionFailureClass {
classification := personalSubscriptionFailureClass{
retryability: personal.RetryabilityUnknown,
reason: "personal_subscription_unknown",
}
var apiErr *personal.APIError
if errors.As(err, &apiErr) {
classification.code = strings.TrimSpace(apiErr.Code)
classification.traceID = strings.TrimSpace(apiErr.TraceID)
classification.retryAfter = personalAPIRetryDelay(apiErr, now)
classification.auth = personalSubscriptionAuthFailure(apiErr.HTTPStatus, apiErr.Code)
switch {
case apiErr.Retryable != nil && *apiErr.Retryable:
classification.retryability = personal.RetryabilityRetryable
classification.reason = "personal_subscription_server_retryable"
case apiErr.Retryable != nil:
classification.retryability = personal.RetryabilityNonRetryable
classification.reason = "personal_subscription_server_non_retryable"
case apiErr.HTTPStatus == http.StatusRequestTimeout ||
apiErr.HTTPStatus == http.StatusTooEarly ||
apiErr.HTTPStatus == http.StatusTooManyRequests ||
apiErr.HTTPStatus >= http.StatusInternalServerError:
classification.retryability = personal.RetryabilityRetryable
classification.reason = "personal_subscription_transient_http"
case apiErr.HTTPStatus == http.StatusUnauthorized ||
apiErr.HTTPStatus == http.StatusForbidden:
classification.retryability = personal.RetryabilityNonRetryable
classification.reason = "personal_subscription_auth"
case personalSubscriptionTerminalBusinessCode(apiErr.Code):
classification.retryability = personal.RetryabilityNonRetryable
classification.reason = "personal_subscription_business_rejected"
case personalSubscriptionErrorHasSubscribeID(apiErr):
// A few legacy/proxy error shapes include an existing subscription
// ID without a stable server contract. Keep the response as an
// error, but do not turn that unverified shape into a one-hour hold.
classification.reason = "personal_subscription_unverified_existing_id"
case apiErr.HTTPStatus >= http.StatusBadRequest:
classification.retryability = personal.RetryabilityNonRetryable
classification.reason = "personal_subscription_http_rejected"
}
return classification
}
if errors.Is(err, context.DeadlineExceeded) {
classification.retryability = personal.RetryabilityRetryable
classification.reason = "personal_subscription_timeout"
return classification
}
var urlErr *url.Error
if errors.As(err, &urlErr) {
if strings.EqualFold(strings.TrimSpace(urlErr.Op), "parse") {
classification.retryability = personal.RetryabilityNonRetryable
classification.reason = "personal_subscription_invalid"
return classification
}
classification.retryability = personal.RetryabilityRetryable
classification.reason = "personal_subscription_network"
return classification
}
var netErr net.Error
if errors.As(err, &netErr) {
classification.retryability = personal.RetryabilityRetryable
classification.reason = "personal_subscription_network"
return classification
}
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
classification.retryability = personal.RetryabilityRetryable
classification.reason = "personal_subscription_network"
return classification
}
lower := strings.ToLower(err.Error())
if strings.Contains(lower, "access token") || strings.Contains(lower, "oauth") {
classification.retryability = personal.RetryabilityNonRetryable
classification.reason = "personal_subscription_auth"
classification.auth = true
}
return classification
}
func personalSubscriptionErrorHasSubscribeID(apiErr *personal.APIError) bool {
if apiErr == nil {
return false
}
subscribeID, ok := apiErr.Details["subscribe_id"].(string)
return ok && strings.TrimSpace(subscribeID) != ""
}
func personalAPIRetryDelay(apiErr *personal.APIError, now time.Time) time.Duration {
if apiErr == nil {
return 0
}
var delay time.Duration
if apiErr.RetryAfterSeconds != nil {
delay = maxPersonalRetryDelay(delay, personalRetrySeconds(*apiErr.RetryAfterSeconds))
}
if apiErr.NextRetryAt != nil {
delay = maxPersonalRetryDelay(delay, apiErr.NextRetryAt.Sub(now))
}
if raw, ok := apiErr.Details["retry_after"].(string); ok {
raw = strings.TrimSpace(raw)
if seconds, err := strconv.ParseInt(raw, 10, 64); err == nil {
delay = maxPersonalRetryDelay(delay, personalRetrySeconds(seconds))
} else if next, err := http.ParseTime(raw); err == nil {
delay = maxPersonalRetryDelay(delay, next.Sub(now))
}
}
return delay
}
func personalRetrySeconds(seconds int64) time.Duration {
if seconds <= 0 {
return 0
}
if seconds > math.MaxInt64/int64(time.Second) {
return time.Duration(math.MaxInt64)
}
return time.Duration(seconds) * time.Second
}
func maxPersonalRetryDelay(left, right time.Duration) time.Duration {
if right > left {
return right
}
return left
}
func personalSubscriptionTerminalBusinessCode(raw string) bool {
code := strings.ToUpper(strings.TrimSpace(raw))
replacer := strings.NewReplacer("-", "_", ".", "_", " ", "_")
code = replacer.Replace(code)
// Keep this list deliberately conservative. Unknown server codes must stay
// unknown so a newly introduced transient condition cannot accidentally be
// converted into a one-hour terminal hold.
switch code {
case "INVALID_PARAM", "INVALID_PARAMS", "INVALID_PARAMETER", "INVALID_PARAMETERS",
"ILLEGAL_PARAM", "ILLEGAL_PARAMS", "ILLEGAL_PARAMETER", "ILLEGAL_PARAMETERS",
"PARAM_ERROR", "PARAMETER_ERROR",
"CLIENT_ID_REQUIRED", "SOURCE_ID_REQUIRED", "EVENT_KEY_REQUIRED", "RULE_TYPE_REQUIRED",
"NO_AUTH", "NO_PERMISSION", "PERMISSION_DENIED", "ACCESS_DENIED",
"FORBIDDEN", "UNAUTHORIZED",
"NOT_FOUND", "NOT_EXIST", "NOT_SUPPORTED", "UNSUPPORTED",
"UNIFIED_APP_ID_NOT_FOUND":
return true
}
// Resource-qualified variants are stable business-rejection shapes. Avoid
// broad substring matching (for example, RETRY_REQUIRED must remain
// unknown).
for _, suffix := range []string{
"_NOT_BELONG_TO_ORG",
"_DOES_NOT_BELONG_TO_ORG",
"_NOT_FOUND",
"_NOT_EXIST",
"_NOT_SUPPORTED",
"_UNSUPPORTED",
"_NO_PERMISSION",
"_PERMISSION_DENIED",
"_ACCESS_DENIED",
} {
if strings.HasSuffix(code, suffix) {
return true
}
}
return false
}
func personalSubscriptionAuthFailure(status int, rawCode string) bool {
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return true
}
code := strings.ToUpper(strings.TrimSpace(rawCode))
for _, marker := range []string{
"NO_AUTH", "UNAUTHORIZED", "FORBIDDEN", "PERMISSION", "ACCESS_DENIED",
} {
if strings.Contains(code, marker) {
return true
}
}
return false
}
func personalSubscriptionFailureError(
cause error,
classification personalSubscriptionFailureClass,
hold personal.AttemptHold,
) error {
options := personalSubscriptionErrorOptions(
classification.retryability,
hold.RetryAfter,
hold.NextAllowedAt,
classification.code,
classification.traceID,
classification.reason,
cause,
)
message := cause.Error()
if classification.retryability == personal.RetryabilityNonRetryable {
if classification.auth {
return apperrors.NewAuth(message, options...)
}
return apperrors.NewValidation(message, options...)
}
return apperrors.NewAPI(message, options...)
}
func personalSubscriptionBlockedError(blocked *personal.AttemptBlockedError) error {
if blocked == nil {
return personalSubscriptionGuardError(
errors.New("personal event: nil blocked subscription attempt"),
)
}
reason := "personal_subscription_" + string(blocked.State)
options := personalSubscriptionErrorOptions(
blocked.Retryability,
blocked.RetryAfter,
blocked.NextAllowedAt,
blocked.ErrorCode,
blocked.TraceID,
reason,
blocked,
)
if blocked.Retryability == personal.RetryabilityNonRetryable {
if personalSubscriptionAuthFailure(0, blocked.ErrorCode) {
return apperrors.NewAuth(blocked.Error(), options...)
}
return apperrors.NewValidation(blocked.Error(), options...)
}
return apperrors.NewAPI(blocked.Error(), options...)
}
func personalSubscriptionErrorOptions(
retryability personal.Retryability,
retryAfter time.Duration,
nextRetryAt time.Time,
code string,
traceID string,
reason string,
cause error,
) []apperrors.Option {
options := []apperrors.Option{
apperrors.WithOperation(personalSubscriptionAttemptOperation),
apperrors.WithReason(reason),
apperrors.WithCause(cause),
}
if retryable, known := retryability.Value(); known {
options = append(options, apperrors.WithRetryable(retryable))
}
if retryAfter > 0 {
options = append(options, apperrors.WithRetryAfterSeconds(ceilPersonalRetrySeconds(retryAfter)))
}
if !nextRetryAt.IsZero() {
options = append(options, apperrors.WithNextRetryAt(nextRetryAt))
}
if code != "" || traceID != "" {
options = append(options, apperrors.WithServerDiag(apperrors.ServerDiagnostics{
TraceID: strings.TrimSpace(traceID),
ServerErrorCode: strings.TrimSpace(code),
}))
}
return options
}
func ceilPersonalRetrySeconds(delay time.Duration) int64 {
if delay <= 0 {
return 0
}
seconds := int64(delay / time.Second)
if delay%time.Second != 0 {
seconds++
}
return seconds
}
func personalSubscriptionGuardError(cause error) error {
if cause == nil {
cause = errors.New("personal event: subscription attempt guard failed")
}
return apperrors.NewInternal(
fmt.Sprintf("personal subscription attempt guard failed: %v", cause),
apperrors.WithOperation(personalSubscriptionAttemptOperation),
apperrors.WithReason("personal_subscription_guard_failed"),
apperrors.WithRetryable(false),
apperrors.WithCause(cause),
)
}
func personalSubscriptionValidationError(cause error) error {
if cause == nil {
cause = errors.New("personal event: invalid subscription parameters")
}
return apperrors.NewValidation(
cause.Error(),
apperrors.WithOperation(personalSubscriptionAttemptOperation),
apperrors.WithReason("personal_subscription_invalid"),
apperrors.WithRetryable(false),
apperrors.WithCause(cause),
)
}
func personalSubscriptionLocalFailure() personalSubscriptionFailureClass {
return personalSubscriptionFailureClass{
retryability: personal.RetryabilityUnknown,
reason: "personal_subscription_local_failure",
}
}
File diff suppressed because it is too large Load Diff
+229 -113
View File
@@ -227,7 +227,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions) error {
ctx := c.Context()
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return err
return personalSubscriptionValidationError(err)
}
rawFormat := ""
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
@@ -238,7 +238,7 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
}
if err := validatePersonalEventOutputMode(opts.Flatten, opts.DebugRawEvents, normalised); err != nil {
return fmt.Errorf("event consume --as user: %w", err)
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
projector := personalEventProjector(opts.DebugRawEvents, opts.Flatten)
@@ -255,12 +255,12 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
if opts.Common.DryRun {
if strings.TrimSpace(opts.SubscribeID) == "" {
if err := validatePersonalSubscriptionOptions(opts); err != nil {
return fmt.Errorf("event consume --as user: %w", err)
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
}
cfg := consume.Config{
@@ -284,16 +284,100 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
DryRun: true,
}
applyPersonalConsumeFilters(&cfg, opts, strings.TrimSpace(opts.SubscribeID), opts.EventKey)
return personalConsumeRun(ctx, cfg)
if err := personalConsumeRun(ctx, cfg); err != nil {
return personalSubscriptionValidationError(err)
}
return nil
}
cfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL, spawnProfileSelector),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
EventKey: opts.EventKey,
Format: normalised,
Flatten: opts.Flatten,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Projector: projector,
Stdout: c.OutOrStdout(),
Stderr: c.ErrOrStderr(),
Quiet: opts.Common.Quiet,
Foreground: opts.Common.Foreground,
Force: opts.Common.Force,
}
// Complete all local validation before creating a remote subscription.
// Otherwise an invalid output mode can repeatedly create and roll back a
// valid subscription when an outer agent relaunches the command.
applyEventConsumeStdin(&cfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
if err := personalValidateConsumeConfig(cfg); err != nil {
return personalSubscriptionValidationError(err)
}
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
if err := personalValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
return personalSubscriptionValidationError(err)
}
}
var foregroundSource *source.PersonalSource
if opts.Common.Foreground {
foregroundSource, err = personalNewStreamSource(ctx, personalStreamSourceOptions{
ConfigDir: configDir,
Identity: identity,
TicketMode: opts.StreamTicketMode,
TicketURL: opts.StreamTicketURL,
})
if err != nil {
return personalSubscriptionValidationError(err)
}
}
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
var attempt *personalSubscriptionAttemptReservation
if strings.TrimSpace(opts.SubscribeID) == "" {
attempt, err = reservePersonalSubscriptionAttempts(
workDir,
client,
identity,
spawnProfileSelector,
[]personalConsumeOptions{opts},
)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
}
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, opts)
if err != nil {
err = attempt.completeFailure(ctx, 0, 0, err, nil)
return fmt.Errorf("event consume --as user: %w", err)
}
if sub.SubscribeID == "" {
return fmt.Errorf("event consume --as user: server returned empty subscribe_id")
if sub == nil {
err = attempt.completeFailure(
ctx,
0,
0,
errors.New("personal event: server returned an empty subscription"),
nil,
)
return fmt.Errorf("event consume --as user: %w", err)
}
if strings.TrimSpace(sub.SubscribeID) == "" {
err = attempt.completeFailure(
ctx,
0,
0,
errors.New("personal event: server returned empty subscribe_id"),
nil,
)
return fmt.Errorf("event consume --as user: %w", err)
}
cleanup := func(cleanupCtx context.Context) {
_ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID)
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
}
if err := personalUpsertRunState(workDir, personal.RunState{
SubscribeID: sub.SubscribeID,
@@ -303,11 +387,21 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
SourceID: identity.SourceID,
IdentityHash: identityHash,
}); err != nil {
return fmt.Errorf("event consume --as user: save run state: %w", err)
wrapped := fmt.Errorf("save run state: %w", err)
if attempt != nil {
cleanupCtx := context.Background()
if personalSubscriptionCanceled(ctx, wrapped) {
cleanupCtx = ctx
}
classification := personalSubscriptionLocalFailure()
wrapped = attempt.completeFailure(ctx, 0, 0, wrapped, &classification)
cleanup(cleanupCtx)
}
return fmt.Errorf("event consume --as user: %w", wrapped)
}
cleanup := func() {
_ = personalDeleteSubscription(client, context.Background(), sub.SubscribeID)
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
if err := attempt.completeSuccess(); err != nil {
cleanup(context.Background())
return fmt.Errorf("event consume --as user: %w", err)
}
// Ownership-based cleanup: a subscription this run CREATED is
// unsubscribed on exit
@@ -317,59 +411,17 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
// either way.
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
if opts.Ephemeral || selfCreated {
defer cleanup()
defer cleanup(context.Background())
}
cfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL, spawnProfileSelector),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
EventKey: eventKey,
Format: normalised,
Flatten: opts.Flatten,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Projector: projector,
ReadySubscribeID: sub.SubscribeID,
Stdout: c.OutOrStdout(),
Stderr: c.ErrOrStderr(),
Quiet: opts.Common.Quiet,
Foreground: opts.Common.Foreground,
Force: opts.Common.Force,
}
// Arm the stdin-EOF shutdown watcher only for a pipe-style, unbounded
// run (see shouldWatchStdinEOF).
applyEventConsumeStdin(&cfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
cfg.EventKey = eventKey
cfg.ReadySubscribeID = sub.SubscribeID
applyPersonalConsumeFilters(&cfg, opts, sub.SubscribeID, eventKey)
if opts.DebugRawEvents && !opts.Common.Quiet {
fmt.Fprintf(c.ErrOrStderr(), "debug raw events enabled: local event filters disabled\nworkdir: %s\nbus_log: %s\n",
workDir, filepath.Join(workDir, "bus.log"))
}
if err := personalValidateConsumeConfig(cfg); err != nil {
return err
}
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
if err := personalValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
return err
}
}
if opts.Common.Foreground {
src, err := personalNewStreamSource(ctx, personalStreamSourceOptions{
ConfigDir: configDir,
Identity: identity,
TicketMode: opts.StreamTicketMode,
TicketURL: opts.StreamTicketURL,
})
if err != nil {
if !opts.Ephemeral {
cleanup()
}
return err
}
busCfg := bus.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
@@ -378,18 +430,18 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: identityHash,
SourceID: identity.SourceID,
Source: src,
Source: foregroundSource,
}
bus.ApplyEnvTuning(&busCfg)
err = personalBusRun(ctx, busCfg)
if err != nil && !opts.Ephemeral {
cleanup()
cleanup(context.Background())
}
return err
}
err = personalConsumeRun(ctx, cfg)
if err != nil && !opts.Ephemeral {
cleanup()
cleanup(context.Background())
}
return err
}
@@ -403,7 +455,7 @@ type personalMultiSubscription struct {
func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions) error {
plans, err := preparePersonalMultiOptions(opts)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
rawFormat := ""
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
@@ -414,7 +466,7 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
}
if err := validatePersonalEventOutputMode(opts.Flatten, opts.DebugRawEvents, normalised); err != nil {
return fmt.Errorf("event consume --as user: %w", err)
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
projector := personalEventProjector(false, opts.Flatten)
@@ -428,15 +480,16 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
spawnProfileSelector := personalBusProfileSelector(configDir, identity)
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
baseCfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir)),
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
@@ -451,11 +504,11 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
}
applyEventConsumeStdin(&baseCfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
if err := personalValidateConsumeConfig(baseCfg); err != nil {
return err
return personalSubscriptionValidationError(err)
}
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
if err := personalValidateNoOutputConflict(baseCfg, o.Value.String()); err != nil {
return err
return personalSubscriptionValidationError(err)
}
}
if opts.Common.DryRun {
@@ -464,13 +517,23 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
}
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
attempt, err := reservePersonalSubscriptionAttempts(
workDir,
client,
identity,
spawnProfileSelector,
plans,
)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
created := make([]personalMultiSubscription, 0, len(plans))
cleanup := func() {
cleanup := func(cleanupCtx context.Context) {
ids := make([]string, 0, len(created))
for i := len(created) - 1; i >= 0; i-- {
id := strings.TrimSpace(created[i].Sub.SubscribeID)
ids = append(ids, id)
if err := personalDeleteSubscription(client, context.Background(), id); err != nil {
if err := personalDeleteSubscription(client, cleanupCtx, id); err != nil {
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to clean personal subscription %s: %v\n", id, err)
}
}
@@ -480,26 +543,45 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
}
}
}
failAndCleanup := func(
failedIndex int,
succeededCount int,
cause error,
override *personalSubscriptionFailureClass,
) error {
cleanupCtx := context.Background()
if personalSubscriptionCanceled(ctx, cause) {
cleanupCtx = ctx
}
completed := attempt.completeFailure(ctx, failedIndex, succeededCount, cause, override)
// Persist the hold (or release a canceled claim) before any potentially
// slow remote rollback. Otherwise the attempt lease can expire while
// deleting earlier subscriptions and admit a duplicate create batch.
cleanup(cleanupCtx)
return completed
}
seenSubscribeIDs := make(map[string]struct{}, len(plans))
for _, plan := range plans {
for i, plan := range plans {
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, plan)
if err != nil {
cleanup()
err = failAndCleanup(i, len(created), err, nil)
return fmt.Errorf("event consume --as user: create subscription for %s: %w", plan.EventKey, err)
}
if sub == nil {
cleanup()
return fmt.Errorf("event consume --as user: server returned an empty subscription for %s", plan.EventKey)
cause := fmt.Errorf("personal event: server returned an empty subscription for %s", plan.EventKey)
cause = failAndCleanup(i, len(created), cause, nil)
return fmt.Errorf("event consume --as user: %w", cause)
}
id := strings.TrimSpace(sub.SubscribeID)
if id == "" {
cleanup()
return fmt.Errorf("event consume --as user: server returned empty subscribe_id for %s", plan.EventKey)
cause := fmt.Errorf("personal event: server returned empty subscribe_id for %s", plan.EventKey)
cause = failAndCleanup(i, len(created), cause, nil)
return fmt.Errorf("event consume --as user: %w", cause)
}
if _, exists := seenSubscribeIDs[id]; exists {
_ = personalDeleteSubscription(client, context.Background(), id)
cleanup()
return fmt.Errorf("event consume --as user: server returned duplicate subscribe_id %s", id)
cause := fmt.Errorf("personal event: server returned duplicate subscribe_id %s", id)
cause = failAndCleanup(i, len(created), cause, nil)
return fmt.Errorf("event consume --as user: %w", cause)
}
seenSubscribeIDs[id] = struct{}{}
item := personalMultiSubscription{Sub: sub, EventKey: eventKey, RuleType: ruleType}
@@ -512,11 +594,17 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
SourceID: identity.SourceID,
IdentityHash: identityHash,
}); err != nil {
cleanup()
return fmt.Errorf("event consume --as user: save run state for %s: %w", eventKey, err)
cause := fmt.Errorf("save run state for %s: %w", eventKey, err)
classification := personalSubscriptionLocalFailure()
cause = failAndCleanup(i, len(created)-1, cause, &classification)
return fmt.Errorf("event consume --as user: %w", cause)
}
}
defer cleanup()
if err := attempt.completeSuccess(); err != nil {
cleanup(context.Background())
return fmt.Errorf("event consume --as user: %w", err)
}
defer cleanup(context.Background())
specs := make([]consume.ConsumerSpec, 0, len(created))
for _, item := range created {
@@ -705,6 +793,59 @@ func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
return err
}
type personalPreparedSubscription struct {
EventKey string
RuleType string
Request personal.CreateSubscriptionRequest
}
func preparePersonalSubscription(identity personal.Identity, opts personalConsumeOptions) (personalPreparedSubscription, error) {
if strings.TrimSpace(opts.EventKey) == "" {
return personalPreparedSubscription{}, fmt.Errorf("event_key is required unless --subscribe-id is provided")
}
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return personalPreparedSubscription{}, err
}
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
RuleType: opts.Rule,
UserID: opts.UserID,
OpenDingTalkID: opts.OpenDingTalkID,
GroupID: opts.GroupID,
})
if err != nil {
return personalPreparedSubscription{}, err
}
filter, filterCanonical, err := personal.BuildFilter(opts.FilterJSON, opts.QueryCSV)
if err != nil {
return personalPreparedSubscription{}, err
}
req := personal.CreateSubscriptionRequest{
EventKey: opts.EventKey,
RuleType: ruleType,
Name: opts.Name,
RuleParam: ruleParam,
Filter: filter,
Delivery: map[string]any{"mode": "stream"},
IdempotencyKey: personal.IdempotencyKey(identity, opts.EventKey, ruleType, ruleParam, filterCanonical),
}
if opts.TTL > 0 {
req.TTLSeconds = int64(opts.TTL.Seconds())
}
return personalPreparedSubscription{
EventKey: opts.EventKey,
RuleType: ruleType,
Request: req,
}, nil
}
func createPreparedPersonalSubscription(ctx context.Context, client *personal.Client, plan personalPreparedSubscription) (*personal.Subscription, string, string, error) {
sub, err := personalCreateSubscription(client, ctx, plan.Request)
if err != nil {
return nil, "", "", err
}
return sub, plan.EventKey, plan.RuleType, nil
}
func ensurePersonalSubscription(ctx context.Context, client *personal.Client, identity personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
if strings.TrimSpace(opts.SubscribeID) != "" {
sub, err := personalGetSubscription(client, ctx, opts.SubscribeID)
@@ -727,42 +868,11 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id
sub.SubscribeID = strings.TrimSpace(opts.SubscribeID)
return sub, eventKey, ruleType, nil
}
if strings.TrimSpace(opts.EventKey) == "" {
return nil, "", "", fmt.Errorf("event_key is required unless --subscribe-id is provided")
}
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return nil, "", "", err
}
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
RuleType: opts.Rule,
UserID: opts.UserID,
OpenDingTalkID: opts.OpenDingTalkID,
GroupID: opts.GroupID,
})
plan, err := preparePersonalSubscription(identity, opts)
if err != nil {
return nil, "", "", err
}
filter, filterCanonical, err := personal.BuildFilter(opts.FilterJSON, opts.QueryCSV)
if err != nil {
return nil, "", "", err
}
req := personal.CreateSubscriptionRequest{
EventKey: opts.EventKey,
RuleType: ruleType,
Name: opts.Name,
RuleParam: ruleParam,
Filter: filter,
Delivery: map[string]any{"mode": "stream"},
IdempotencyKey: personal.IdempotencyKey(identity, opts.EventKey, ruleType, ruleParam, filterCanonical),
}
if opts.TTL > 0 {
req.TTLSeconds = int64(opts.TTL.Seconds())
}
sub, err := personalCreateSubscription(client, ctx, req)
if err != nil {
return nil, "", "", err
}
return sub, opts.EventKey, ruleType, nil
return createPreparedPersonalSubscription(ctx, client, plan)
}
func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error {
@@ -829,7 +939,7 @@ func ensurePublicPersonalEvent(eventKey string) error {
if eventKey == "" {
return nil
}
if def, ok := personal.Lookup(eventKey); ok && !def.Public {
if def, ok := personalLookupDefinition(eventKey); ok && !def.Public {
return personal.PublicAvailabilityError(eventKey)
}
return nil
@@ -1091,6 +1201,12 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI
func newPersonalEventControlClient(configDir, baseURL string, identity personal.Identity) *personal.Client {
identity.AccessToken = ""
client := personal.NewClient(baseURL, identity)
version := strings.TrimSpace(RawVersion())
if version == "" {
version = "unknown"
}
client.ClientVersion = version
client.UserAgent = "dws-cli/" + version
client.AccessTokenProvider = func(ctx context.Context) (string, error) {
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
}
@@ -77,6 +77,7 @@ func TestCrossPlatformCoveragePersonalEventRemainingSchemaAndSubscriptionCoverag
func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T) {
oldIdentity := personalResolveEventIdentity
oldEnsure := personalEnsureSubscription
oldAttemptStore := personalNewSubscriptionAttemptStore
oldUpsert := personalUpsertRunState
oldDelete := personalDeleteSubscription
oldRemove := personalRemoveRunStates
@@ -88,6 +89,7 @@ func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T
t.Cleanup(func() {
personalResolveEventIdentity = oldIdentity
personalEnsureSubscription = oldEnsure
personalNewSubscriptionAttemptStore = oldAttemptStore
personalUpsertRunState = oldUpsert
personalDeleteSubscription = oldDelete
personalRemoveRunStates = oldRemove
@@ -97,6 +99,9 @@ func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T
personalNewStreamSource = oldNewSource
personalBusRun = oldBusRun
})
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
return personalNoopAttemptStore{}
}
wantErr := errors.New("consume")
cmd := newPersonalCoverageCommand()
@@ -154,11 +159,11 @@ func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
return nil, wantErr
}
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes == 0 {
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes != 0 {
t.Fatalf("foreground source error = %v deletes=%d", err, deletes)
}
before := deletes
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Ephemeral: true, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes == before {
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Ephemeral: true, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes != before {
t.Fatalf("ephemeral source error = %v deletes=%d", err, deletes)
}
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) { return nil, nil }
+224 -2
View File
@@ -12,6 +12,7 @@ import (
"reflect"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
@@ -251,7 +252,7 @@ func TestPreparePersonalMultiOptionsRejectsSingleOnlyFlags(t *testing.T) {
}
}
func TestEventConsumeMultiRejectsExplicitSingleOnlyFlagsEvenWhenEmpty(t *testing.T) {
func TestCrossPlatformCoverageEventConsumeMultiRejectsExplicitSingleOnlyFlagsEvenWhenEmpty(t *testing.T) {
oldRun := eventRunPersonalConsume
defer func() { eventRunPersonalConsume = oldRun }()
eventRunPersonalConsume = func(*cobra.Command, personalConsumeOptions) error {
@@ -379,7 +380,158 @@ func TestRunPersonalEventConsumeManyRollsBackPartialCreation(t *testing.T) {
}
}
func TestRunPersonalEventConsumeManyRejectsInvalidSubscriptionResults(t *testing.T) {
func TestCrossPlatformCoverageRunPersonalEventConsumeManyPersistsFailureBeforeRollback(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
var order []string
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
return &personalOrderingAttemptStore{order: &order}
}
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token",
ClientID: "client",
SourceID: "open",
LocalSubject: "subject",
}, nil
}
calls := 0
personalEnsureSubscription = func(
_ context.Context,
_ *personal.Client,
_ personal.Identity,
opts personalConsumeOptions,
) (*personal.Subscription, string, string, error) {
calls++
if calls == 2 {
return nil, "", "", errors.New("second subscription failed")
}
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "all", nil
}
personalUpsertRunState = func(string, personal.RunState) error { return nil }
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, _ string) error {
order = append(order, "delete")
return nil
}
personalRemoveRunStates = func(string, []string) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
})
if err == nil {
t.Fatal("partial creation unexpectedly succeeded")
}
if !reflect.DeepEqual(order, []string{"complete_failure", "delete"}) {
t.Fatalf("failure/rollback order = %#v", order)
}
}
func TestCrossPlatformCoverageRunPersonalEventConsumeSinglePersistsLocalFailureBeforeRollback(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
var order []string
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
return &personalOrderingAttemptStore{order: &order}
}
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token",
ClientID: "client",
SourceID: "open",
LocalSubject: "subject",
}, nil
}
personalEnsureSubscription = func(
_ context.Context,
_ *personal.Client,
_ personal.Identity,
opts personalConsumeOptions,
) (*personal.Subscription, string, string, error) {
return &personal.Subscription{SubscribeID: "sub-one"}, opts.EventKey, "all", nil
}
personalUpsertRunState = func(string, personal.RunState) error {
return errors.New("state disk failed")
}
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, _ string) error {
order = append(order, "delete")
return nil
}
personalRemoveRunStates = func(string, []string) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
EventKey: personal.EventMention,
})
if err == nil {
t.Fatal("run-state failure unexpectedly succeeded")
}
if !reflect.DeepEqual(order, []string{"complete_failure", "delete"}) {
t.Fatalf("failure/rollback order = %#v", order)
}
}
func TestCrossPlatformCoverageRunPersonalEventConsumeManyCancellationReleasesBeforeCanceledCleanup(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
var order []string
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
return &personalOrderingAttemptStore{order: &order}
}
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token",
ClientID: "client",
SourceID: "open",
LocalSubject: "subject",
}, nil
}
calls := 0
personalEnsureSubscription = func(
_ context.Context,
_ *personal.Client,
_ personal.Identity,
opts personalConsumeOptions,
) (*personal.Subscription, string, string, error) {
calls++
if calls == 2 {
return nil, "", "", context.Canceled
}
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "all", nil
}
personalUpsertRunState = func(string, personal.RunState) error { return nil }
personalDeleteSubscription = func(_ *personal.Client, cleanupCtx context.Context, _ string) error {
if cleanupCtx.Err() == nil {
t.Fatal("cancellation cleanup received a live context")
}
order = append(order, "delete")
return cleanupCtx.Err()
}
personalRemoveRunStates = func(string, []string) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
cmd := newPersonalCoverageCommand()
ctx, cancel := context.WithCancel(context.Background())
cancel()
cmd.SetContext(ctx)
err := runPersonalEventConsume(cmd, personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation error = %v", err)
}
if !reflect.DeepEqual(order, []string{"release", "delete"}) {
t.Fatalf("release/canceled-cleanup order = %#v", order)
}
}
func TestCrossPlatformCoverageRunPersonalEventConsumeManyRejectsInvalidSubscriptionResults(t *testing.T) {
for _, test := range []struct {
name string
ensure func(int, personalConsumeOptions) *personal.Subscription
@@ -641,16 +793,21 @@ func installPersonalManySeams(t *testing.T) func() {
oldIdentity := personalResolveEventIdentity
oldLookup := personalLookupDefinition
oldEnsure := personalEnsureSubscription
oldAttemptStore := personalNewSubscriptionAttemptStore
oldUpsert := personalUpsertRunState
oldDelete := personalDeleteSubscription
oldRemove := personalRemoveRunStates
oldRunMany := personalConsumeRunMany
oldValidate := personalValidateConsumeConfig
oldConflict := personalValidateNoOutputConflict
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
return personalNoopAttemptStore{}
}
return func() {
personalResolveEventIdentity = oldIdentity
personalLookupDefinition = oldLookup
personalEnsureSubscription = oldEnsure
personalNewSubscriptionAttemptStore = oldAttemptStore
personalUpsertRunState = oldUpsert
personalDeleteSubscription = oldDelete
personalRemoveRunStates = oldRemove
@@ -659,3 +816,68 @@ func installPersonalManySeams(t *testing.T) func() {
personalValidateNoOutputConflict = oldConflict
}
}
type personalNoopAttemptStore struct{}
func (personalNoopAttemptStore) Claim(specs []personal.AttemptSpec, _ time.Duration) (*personal.AttemptClaim, error) {
fingerprints := make([]string, 0, len(specs))
for _, spec := range specs {
fingerprints = append(fingerprints, spec.Fingerprint)
}
return &personal.AttemptClaim{
AttemptID: "test-attempt",
Fingerprints: fingerprints,
}, nil
}
func (personalNoopAttemptStore) CompleteSuccess(*personal.AttemptClaim) error {
return nil
}
func (personalNoopAttemptStore) CompleteFailure(
_ *personal.AttemptClaim,
_ []string,
failure personal.AttemptFailure,
) (personal.AttemptHold, error) {
return personal.AttemptHold{
Fingerprint: failure.Fingerprint,
Retryability: failure.Retryability,
}, nil
}
func (personalNoopAttemptStore) Release(*personal.AttemptClaim) error {
return nil
}
type personalOrderingAttemptStore struct {
order *[]string
}
func (s *personalOrderingAttemptStore) Claim(
specs []personal.AttemptSpec,
lease time.Duration,
) (*personal.AttemptClaim, error) {
return personalNoopAttemptStore{}.Claim(specs, lease)
}
func (s *personalOrderingAttemptStore) CompleteSuccess(*personal.AttemptClaim) error {
*s.order = append(*s.order, "complete_success")
return nil
}
func (s *personalOrderingAttemptStore) CompleteFailure(
_ *personal.AttemptClaim,
_ []string,
failure personal.AttemptFailure,
) (personal.AttemptHold, error) {
*s.order = append(*s.order, "complete_failure")
return personal.AttemptHold{
Fingerprint: failure.Fingerprint,
Retryability: failure.Retryability,
}, nil
}
func (s *personalOrderingAttemptStore) Release(*personal.AttemptClaim) error {
*s.order = append(*s.order, "release")
return nil
}
+62
View File
@@ -103,3 +103,65 @@ func TestFlagErrorWithSuggestions_fallbackTailHint(t *testing.T) {
t.Fatalf("err tail = %q, want suffix See 'send --help' for usage.", msg)
}
}
func TestFlagErrorWithSuggestionsReviewedProtectionRoutes(t *testing.T) {
root := NewRootCommand()
for _, tc := range []struct {
path []string
flag string
wantReason string
wantHint string
}{
{path: []string{"chat", "message", "list-by-sender"}, flag: "time", wantReason: "blocked_flag", wantHint: "blocked"},
{path: []string{"drive", "list"}, flag: "space", wantReason: "ambiguous_flag", wantHint: "ambiguous"},
} {
t.Run(strings.Join(tc.path, "/"), func(t *testing.T) {
cmd := mustFindCommand(t, root, tc.path...)
err := flagErrorWithSuggestions(cmd, fmt.Errorf("unknown flag: --%s", tc.flag))
var ae *apperrors.Error
if !stderrors.As(err, &ae) {
t.Fatalf("want *apperrors.Error, got %T", err)
}
if ae.Reason != tc.wantReason || !strings.Contains(ae.Hint, tc.wantHint) || !strings.Contains(ae.Hint, "--help") {
t.Fatalf("protected error = reason %q hint %q", ae.Reason, ae.Hint)
}
})
}
}
func TestReviewedFlagProtectionAndInstallerEdges(t *testing.T) {
if flag, protection, ok := reviewedFlagProtection(nil, "unknown flag: --time"); ok || flag != "" || protection != "" {
t.Fatalf("nil command protection = %q, %q, %v", flag, protection, ok)
}
installReviewedFlagProtectionHandlers(nil)
root := NewRootCommand()
cmd := mustFindCommand(t, root, "chat", "message", "list-by-sender")
flag, protection, ok := reviewedFlagProtection(cmd, "unknown flag: --time=value")
if !ok || flag != "time" || protection != "blocked" {
t.Fatalf("delimited protected flag = %q, %q, %v", flag, protection, ok)
}
if flag, protection, ok := reviewedFlagProtection(cmd, "unknown flag: --not-reviewed"); ok || flag != "" || protection != "" {
t.Fatalf("unreviewed flag protection = %q, %q, %v", flag, protection, ok)
}
}
func TestReviewedFlagProtectionInstallerPreservesLocalHandler(t *testing.T) {
root := NewRootCommand()
cmd := mustFindCommand(t, root, "contact", "dept", "list-children")
handler := cmd.FlagErrorFunc()
unreviewed := handler(cmd, fmt.Errorf("unknown flag: --not-reviewed"))
var structured *apperrors.Error
if stderrors.As(unreviewed, &structured) {
t.Fatalf("unreviewed error bypassed the command's local handler: %#v", structured)
}
if !strings.HasSuffix(unreviewed.Error(), "See 'dws contact dept list-children --help' for usage.") {
t.Fatalf("local handler output = %q", unreviewed)
}
guarded := handler(cmd, fmt.Errorf("unknown flag: --name"))
if !stderrors.As(guarded, &structured) || structured.Reason != "blocked_flag" {
t.Fatalf("reviewed guard did not use the central handler: %#v", guarded)
}
}
+826
View File
@@ -0,0 +1,826 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"context"
"encoding/json"
stderrors "errors"
"io"
"os"
"reflect"
"sort"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type paramAliasToolCall struct {
server string
tool string
args map[string]any
}
type paramAliasCaptureCaller struct {
calls []paramAliasToolCall
}
func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
copyArgs := make(map[string]any, len(args))
for key, value := range args {
copyArgs[key] = value
}
c.calls = append(c.calls, paramAliasToolCall{server: server, tool: tool, args: copyArgs})
text := paramAliasResponseForTool(tool)
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
// paramAliasResponseForTool supplies deterministic, business-shape-valid
// responses for the complete-command equivalence matrix. Most commands only
// print the transport result and need an empty object; smart shortcuts that
// inspect a read response receive the smallest shape that lets their full RunE
// complete without falling back to a validation error.
func paramAliasResponseForTool(tool string) string {
switch tool {
case "list_calendar_events":
return `{"result":{"events":[]}}`
case "search_mail_users":
return `{"users":[{"name":"Fixture User","email":"fixture@example.com","id":"fixture-user"}]}`
case "search_dept_by_keyword":
return `{"deptList":[{"deptId":1,"name":"Fixture Dept"}]}`
case "search_groups":
return `{"result":{"items":[{"openConversationId":"fixture-conversation","title":"Fixture Group"}]}}`
default:
return `{}`
}
}
func (*paramAliasCaptureCaller) Format() string { return "json" }
func (*paramAliasCaptureCaller) DryRun() bool { return false }
func (*paramAliasCaptureCaller) Fields() string { return "" }
func (*paramAliasCaptureCaller) JQ() string { return "" }
// paramAliasCaptureRunner covers helpers (currently dev app) that dispatch
// through executor.Runner instead of edition.ToolCaller. Keeping both capture
// boundaries in one call list lets the matrix compare the final request shape
// without knowing which transport adapter a command uses.
type paramAliasCaptureRunner struct {
caller *paramAliasCaptureCaller
}
func (r *paramAliasCaptureRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
copyArgs := make(map[string]any, len(invocation.Params))
for key, value := range invocation.Params {
copyArgs[key] = value
}
r.caller.calls = append(r.caller.calls, paramAliasToolCall{
server: invocation.CanonicalProduct,
tool: invocation.Tool,
args: copyArgs,
})
invocation.Implemented = true
return executor.Result{Invocation: invocation, Response: map[string]any{}}, nil
}
type paramAliasDryRunRejectRunner struct {
attempts []executor.Invocation
}
func (r *paramAliasDryRunRejectRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.attempts = append(r.attempts, invocation)
return executor.Result{}, stderrors.New("dry-run reached the injected command runner")
}
type paramAliasDryRunPreview struct {
DryRun bool `json:"dry_run"`
Executed bool `json:"executed"`
Tool string `json:"tool"`
Arguments map[string]any `json:"arguments"`
}
// executeParamAliasDryRunE2E uses the existing root --dry-run barrier as a
// parameter-normalization probe. These commands do not publish command-owned
// dry-run capabilities in Schema; the test deliberately makes no such claim.
// A reject runner proves the preview stops before endpoint resolution,
// authentication, or transport execution.
func executeParamAliasDryRunE2E(t *testing.T, args ...string) (*pipeline.Context, paramAliasDryRunPreview, []executor.Invocation, error) {
t.Helper()
originalArgs := os.Args
os.Args = append([]string{"dws"}, args...)
defer func() { os.Args = originalArgs }()
captureFile, err := os.CreateTemp(t.TempDir(), "param-alias-dry-run-*.json")
if err != nil {
t.Fatalf("create dry-run output capture: %v", err)
}
defer captureFile.Close()
originalStdout := os.Stdout
originalCaller := helpers.GetCaller()
os.Stdout = captureFile
defer func() {
os.Stdout = originalStdout
helpers.InitDeps(originalCaller)
}()
rejectRunner := &paramAliasDryRunRejectRunner{}
originalRunnerFactory := rootNewCommandRunnerWithFlags
rootNewCommandRunnerWithFlags = func(cli.CatalogLoader, *GlobalFlags) executor.Runner {
return rejectRunner
}
root := NewRootCommand()
rootNewCommandRunnerWithFlags = originalRunnerFactory
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
ctx, executeErr := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if executeErr == nil {
executeErr = root.Execute()
}
if err := captureFile.Sync(); err != nil {
t.Fatalf("sync dry-run output capture: %v", err)
}
if _, err := captureFile.Seek(0, io.SeekStart); err != nil {
t.Fatalf("rewind dry-run output capture: %v", err)
}
output, err := io.ReadAll(captureFile)
if err != nil {
t.Fatalf("read dry-run output capture: %v", err)
}
var preview paramAliasDryRunPreview
if executeErr == nil {
if err := json.Unmarshal(output, &preview); err != nil {
t.Fatalf("decode dry-run preview: %v\noutput=%s", err, output)
}
}
return ctx, preview, append([]executor.Invocation(nil), rejectRunner.attempts...), executeErr
}
func executeParamAliasE2E(t *testing.T, caller *paramAliasCaptureCaller, args ...string) (*pipeline.Context, error) {
t.Helper()
originalArgs := os.Args
os.Args = append([]string{"dws"}, args...)
defer func() { os.Args = originalArgs }()
originalRunnerFactory := rootNewCommandRunnerWithFlags
rootNewCommandRunnerWithFlags = func(cli.CatalogLoader, *GlobalFlags) executor.Runner {
return &paramAliasCaptureRunner{caller: caller}
}
root := NewRootCommand()
rootNewCommandRunnerWithFlags = originalRunnerFactory
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
originalCaller := helpers.GetCaller()
helpers.InitDeps(caller)
defer helpers.InitDeps(originalCaller)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
return ctx, err
}
return ctx, root.Execute()
}
func TestBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
tests := []struct {
name string
confirmation []string
wantError string
wantCalls int
wantOriginal string
wantCorrection string
}{
{name: "bare yes confirms", confirmation: []string{"--yes"}, wantCalls: 1},
{name: "glued false stays unconfirmed", confirmation: []string{"--yesfalse"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yesfalse", wantCorrection: "--yes=false"},
{name: "glued true confirms", confirmation: []string{"--yestrue"}, wantCalls: 1, wantOriginal: "--yestrue", wantCorrection: "--yes=true"},
{name: "detached false stays unconfirmed", confirmation: []string{"--yes", "false"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yes false", wantCorrection: "--yes=false"},
{name: "detached no stays unconfirmed", confirmation: []string{"--yes", "no"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yes no", wantCorrection: "--yes=false"},
{name: "detached zero stays unconfirmed", confirmation: []string{"--yes", "0"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yes 0", wantCorrection: "--yes=false"},
{name: "detached true confirms", confirmation: []string{"--yes", "true"}, wantCalls: 1, wantOriginal: "--yes true", wantCorrection: "--yes=true"},
{name: "detached yes confirms", confirmation: []string{"--yes", "yes"}, wantCalls: 1, wantOriginal: "--yes yes", wantCorrection: "--yes=true"},
{name: "detached one confirms", confirmation: []string{"--yes", "1"}, wantCalls: 1, wantOriginal: "--yes 1", wantCorrection: "--yes=true"},
{name: "explicit false remains unconfirmed", confirmation: []string{"--yes=false"}, wantError: "请添加 --yes 确认执行"},
{name: "explicit true confirms", confirmation: []string{"--yes=true"}, wantCalls: 1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &paramAliasCaptureCaller{}
args := []string{
"mail", "thread", "trash",
"--email", "user@example.com",
"--id", "conversation-1",
}
args = append(args, test.confirmation...)
ctx, err := executeParamAliasE2E(t, caller, args...)
if test.wantError == "" {
if err != nil {
t.Fatalf("confirmed command error = %v", err)
}
} else if err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("command error = %v, want substring %q", err, test.wantError)
}
if test.wantCorrection == "" {
if ctx != nil && len(ctx.Corrections) != 0 {
t.Fatalf("confirmation spelling received corrections: %#v", ctx.Corrections)
}
} else if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != test.wantOriginal || ctx.Corrections[0].Corrected != test.wantCorrection {
t.Fatalf("confirmation corrections = %#v, want %q -> %q", ctx, test.wantOriginal, test.wantCorrection)
}
if len(caller.calls) != test.wantCalls {
t.Fatalf("destructive calls = %#v, want %d", caller.calls, test.wantCalls)
}
})
}
}
func TestParamAliasReadCommandFinalPayload(t *testing.T) {
caller := &paramAliasCaptureCaller{}
start := "2026-03-10T14:00:00+08:00"
end := "2026-03-10T18:00:00+08:00"
ctx, err := executeParamAliasE2E(t, caller,
"calendar", "event", "list",
"--date", start,
"--end-time", end,
"--calendar", "primary",
"--max-results", "7",
"--next-cursor", "cursor-1",
)
if err != nil {
t.Fatalf("calendar alias E2E error = %v", err)
}
if len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--date" || ctx.Corrections[0].Corrected != "--start" {
t.Fatalf("calendar corrections = %#v, want only --date to be normalized centrally", ctx.Corrections)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "list_calendar_events" {
t.Fatalf("calendar calls = %#v", caller.calls)
}
startMS, _ := cmdutil.ParseISOTimeToMillis("start", start)
endMS, _ := cmdutil.ParseISOTimeToMillis("end", end)
want := map[string]any{
"startTime": startMS,
"endTime": endMS,
"calendarId": "primary",
"limit": 7,
"cursor": "cursor-1",
}
if !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("calendar payload = %#v, want %#v", caller.calls[0].args, want)
}
}
func TestParamAliasWriteCommandFinalPayload(t *testing.T) {
caller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasE2E(t, caller,
"chat", "message", "send",
"--to-user", "D-recipient",
"--text", "hello alias",
"--uuid", "alias-e2e",
)
if err != nil {
t.Fatalf("chat write alias E2E error = %v", err)
}
if len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--to-user" || ctx.Corrections[0].Corrected != "--user" {
t.Fatalf("chat corrections = %#v", ctx.Corrections)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "send_personal_message" {
t.Fatalf("chat calls = %#v", caller.calls)
}
payload := caller.calls[0].args
if payload["receiverOpenDingTalkId"] != "D-recipient" || payload["uuid"] != "alias-e2e" || payload["msgType"] != "markdown" {
t.Fatalf("chat payload identity fields = %#v", payload)
}
content, _ := payload["content"].(string)
if !strings.Contains(content, "hello alias") {
t.Fatalf("chat payload content = %q", content)
}
for _, forbidden := range []string{"user", "to-user", "userId"} {
if _, exists := payload[forbidden]; exists {
t.Fatalf("chat payload leaked pre-normalization field %q: %#v", forbidden, payload)
}
}
}
func TestChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
tests := []struct {
name string
command []string
tool string
required []string
}{
{
name: "add emoji",
command: []string{"chat", "message", "add-emoji"},
tool: "add_emoji_reaction",
required: []string{"--msg-id", "message-1", "--emoji", "like"},
},
{
name: "remove emoji",
command: []string{"chat", "message", "remove-emoji"},
tool: "remove_emoji_reaction",
required: []string{"--msg-id", "message-1", "--emoji", "like"},
},
{
name: "add text emotion",
command: []string{"chat", "message", "add-text-emotion"},
tool: "add_text_emotion",
required: []string{
"--msg-id", "message-1", "--emotion-id", "emotion-1",
"--emotion-name", "like", "--text", "nice", "--background-id", "background-1",
},
},
{
name: "remove text emotion",
command: []string{"chat", "message", "remove-text-emotion"},
tool: "remove_text_emotion",
required: []string{
"--msg-id", "message-1", "--emotion-id", "emotion-1",
"--emotion-name", "like", "--text", "nice", "--background-id", "background-1",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
canonicalArgs := append([]string(nil), test.command...)
canonicalArgs = append(canonicalArgs, "--conversation-id", "conversation-1")
canonicalArgs = append(canonicalArgs, test.required...)
canonicalCaller := &paramAliasCaptureCaller{}
if _, err := executeParamAliasE2E(t, canonicalCaller, canonicalArgs...); err != nil {
t.Fatalf("canonical execution failed: %v", err)
}
if len(canonicalCaller.calls) != 1 || canonicalCaller.calls[0].tool != test.tool {
t.Fatalf("canonical calls = %#v, want one %s call", canonicalCaller.calls, test.tool)
}
if canonicalCaller.calls[0].args["openConversationId"] != "conversation-1" {
t.Fatalf("canonical payload = %#v", canonicalCaller.calls[0].args)
}
// Numeric --group-id is a different identifier domain and is covered
// by TestAllReviewedParamAliasGuardsReachRuntimeContract.
for _, alias := range []string{"chat-id", "open-conversation-id"} {
t.Run(alias, func(t *testing.T) {
aliasArgs := append([]string(nil), test.command...)
aliasArgs = append(aliasArgs, "--"+alias, "conversation-1")
aliasArgs = append(aliasArgs, test.required...)
aliasCaller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasE2E(t, aliasCaller, aliasArgs...)
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" {
t.Fatalf("alias corrections = %#v", ctx)
}
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("final calls differ\ncanonical=%#v\nalias=%#v", canonicalCaller.calls, aliasCaller.calls)
}
})
}
})
}
}
func TestAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
root := NewRootCommand()
engine := newPipelineEngine()
entries, err := cli.ReduceParamAliases(root)
if err != nil {
t.Fatalf("ReduceParamAliases() error = %v", err)
}
chatEntries := 0
aliasCases := 0
guardCases := map[pipeline.FlagProtection]int{}
for _, entry := range entries {
if !strings.HasPrefix(entry.CLIPath, "chat ") {
continue
}
chatEntries++
leaf := resolveParamLeaf(root, entry.CLIPath)
if leaf == nil {
t.Fatalf("generated chat parameter path %q is not runnable", entry.CLIPath)
}
aliases := make([]string, 0, len(entry.Aliases))
for emitted := range entry.Aliases {
aliases = append(aliases, emitted)
}
sort.Strings(aliases)
for _, emitted := range aliases {
emitted := emitted
canonical := entry.Aliases[emitted]
aliasCases++
t.Run(entry.CLIPath+"/alias/"+emitted, func(t *testing.T) {
value := paramFixtureValue(leaf, emitted, canonical)
rawArgs := append(strings.Fields(entry.CLIPath), "--"+emitted, value)
ctx, runErr := pipeline.RunPreParseArgs(root, engine, rawArgs)
if runErr != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, runErr)
}
if ctx == nil {
t.Fatal("RunPreParseArgs returned nil context")
}
flagArgs := ctx.Args[len(strings.Fields(entry.CLIPath)):]
if len(flagArgs) < 2 || flagArgs[0] != "--"+canonical || flagArgs[1] != value {
t.Fatalf("runtime alias %q => %q produced args %v", emitted, canonical, ctx.Args)
}
if parseErr := leaf.ParseFlags(flagArgs); parseErr != nil {
t.Fatalf("canonical Cobra ParseFlags(%v) error = %v", flagArgs, parseErr)
}
})
}
for _, guard := range []struct {
protection pipeline.FlagProtection
emitted []string
}{
{protection: pipeline.FlagProtectionBlocked, emitted: entry.Blocked},
{protection: pipeline.FlagProtectionAmbiguous, emitted: entry.Ambiguous},
} {
for _, emitted := range guard.emitted {
emitted := emitted
protection := guard.protection
guardCases[protection]++
t.Run(entry.CLIPath+"/"+string(protection)+"/"+emitted, func(t *testing.T) {
value := paramFixtureValue(leaf, emitted, "did-you-mean:"+string(protection))
rawArgs := append(strings.Fields(entry.CLIPath), "--"+emitted, value)
ctx, runErr := pipeline.RunPreParseArgs(root, engine, rawArgs)
if runErr != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, runErr)
}
morphed := cmdutil.Morph(emitted)
if ctx == nil || ctx.ProtectedFlags[morphed] != protection {
t.Fatalf("runtime guard %q protection = %#v, want %s", emitted, ctx, protection)
}
assertLeftUnchanged(t, ctx, emitted, value)
flagArgs := ctx.Args[len(strings.Fields(entry.CLIPath)):]
if parseErr := leaf.ParseFlags(flagArgs); parseErr == nil || !strings.Contains(parseErr.Error(), "unknown flag") {
t.Fatalf("guarded Cobra ParseFlags(%v) error = %v, want unknown flag", flagArgs, parseErr)
}
})
}
}
}
if chatEntries == 0 || aliasCases == 0 || guardCases[pipeline.FlagProtectionBlocked] == 0 || guardCases[pipeline.FlagProtectionAmbiguous] == 0 {
t.Fatalf("chat parameter coverage is vacuous: entries=%d aliases=%d blocked=%d ambiguous=%d", chatEntries, aliasCases, guardCases[pipeline.FlagProtectionBlocked], guardCases[pipeline.FlagProtectionAmbiguous])
}
t.Logf("verified generated chat parameter routes: entries=%d aliases=%d blocked=%d ambiguous=%d", chatEntries, aliasCases, guardCases[pipeline.FlagProtectionBlocked], guardCases[pipeline.FlagProtectionAmbiguous])
}
func TestIMUserIDHallucinationRoutes(t *testing.T) {
tests := []struct {
command string
want string
}{
// These paths are reduced by the reviewed user_id concept.
{command: "chat +chat-role-query-user", want: "user"},
{command: "chat +chat-role-set-user", want: "user"},
{command: "chat +messages-list-direct", want: "user"},
{command: "chat chmod", want: "user"},
{command: "chat message list", want: "user"},
{command: "chat message send", want: "user"},
// These commands already own a hidden --userId compatibility flag.
// The format/spelling handler rewrites --user-id to that real flag, and
// the command's existing flagOrFallback wiring preserves its semantics.
{command: "chat conversation-info", want: "userId"},
{command: "chat group transfer-owner", want: "userId"},
{command: "chat group-role query-user", want: "userId"},
{command: "chat group-role remove-user", want: "userId"},
{command: "chat group-role set-user", want: "userId"},
{command: "chat group set-admin", want: "userId"},
{command: "chat group-mute-member", want: "userId"},
{command: "chat message read-status", want: "userId"},
{command: "chat message search-advanced", want: "userId"},
}
for _, test := range tests {
t.Run(test.command, func(t *testing.T) {
root := NewRootCommand()
leaf := resolveParamLeaf(root, test.command)
if leaf == nil {
t.Fatalf("IM command %q is not runnable", test.command)
}
rawArgs := append(strings.Fields(test.command), "--user-id", "fixture-user")
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), rawArgs)
if err != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, err)
}
if ctx == nil {
t.Fatal("RunPreParseArgs returned nil context")
}
flagArgs := ctx.Args[len(strings.Fields(test.command)):]
if len(flagArgs) != 2 || flagArgs[0] != "--"+test.want || flagArgs[1] != "fixture-user" {
t.Fatalf("--user-id route = %v, want --%s fixture-user", flagArgs, test.want)
}
if err := leaf.ParseFlags(flagArgs); err != nil {
t.Fatalf("Cobra ParseFlags(%v) error = %v", flagArgs, err)
}
})
}
}
func TestHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
const command = "chat message list-direct"
if _, ok := cli.LookupParamAlias(command); ok {
t.Fatalf("hidden command %q unexpectedly entered the public generated alias table", command)
}
root := NewRootCommand()
leaf := resolveParamLeaf(root, command)
if leaf == nil || !leaf.Hidden {
t.Fatalf("%q must remain a live hidden compatibility command", command)
}
rawArgs := append(strings.Fields(command), "--user-id", "fixture-user")
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), rawArgs)
if err != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, err)
}
if ctx == nil {
t.Fatal("RunPreParseArgs returned nil context")
}
flagArgs := ctx.Args[len(strings.Fields(command)):]
if err := leaf.ParseFlags(flagArgs); err == nil || !strings.Contains(err.Error(), "unknown flag") {
t.Fatalf("hidden command ParseFlags(%v) error = %v, want unknown flag", flagArgs, err)
}
}
func TestSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing.T) {
tests := []struct {
name string
tool string
canonicalArgs []string
aliasArgs []string
wantCorrections int
wantArgKeys []string
}{
{
name: "calendar read with multiple aliases",
tool: "list_calendar_events",
canonicalArgs: []string{
"--dry-run", "calendar", "event", "list",
"--start", "2026-03-10T14:00:00+08:00",
"--end", "2026-03-10T18:00:00+08:00",
"--calendar-id", "primary", "--limit", "7", "--cursor", "cursor-1",
},
aliasArgs: []string{
"--dry-run", "calendar", "event", "list",
"--date", "2026-03-10T14:00:00+08:00",
"--end-time", "2026-03-10T18:00:00+08:00",
"--calendar", "primary", "--max-results", "7", "--next-cursor", "cursor-1",
},
wantCorrections: 1,
wantArgKeys: []string{"calendarId", "cursor", "endTime", "limit", "startTime"},
},
{
name: "chat write scoped recipient alias",
tool: "send_personal_message",
canonicalArgs: []string{
"--dry-run", "chat", "message", "send",
"--user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
},
aliasArgs: []string{
"--dry-run", "chat", "message", "send",
"--to-user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
},
wantCorrections: 1,
wantArgKeys: []string{"clawType", "content", "msgType", "receiverOpenDingTalkId", "uuid"},
},
{
name: "mail write folder id concept alias",
tool: "update_mail_folder",
canonicalArgs: []string{
"--dry-run", "mail", "folder", "update",
"--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder",
},
aliasArgs: []string{
"--dry-run", "mail", "folder", "update",
"--email", "fixture@example.com", "--folder-id", "folder-1", "--name", "Fixture Folder",
},
wantCorrections: 1,
wantArgKeys: []string{"email", "id", "name"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, canonical, canonicalAttempts, canonicalErr := executeParamAliasDryRunE2E(t, test.canonicalArgs...)
if canonicalErr != nil {
t.Fatalf("canonical dry-run failed: %v", canonicalErr)
}
ctx, alias, aliasAttempts, aliasErr := executeParamAliasDryRunE2E(t, test.aliasArgs...)
if aliasErr != nil {
t.Fatalf("alias dry-run failed: %v\ncontext=%#v", aliasErr, ctx)
}
if ctx == nil || len(ctx.Corrections) != test.wantCorrections {
t.Fatalf("alias dry-run corrections = %#v, want %d", ctx, test.wantCorrections)
}
if len(canonicalAttempts) != 0 || len(aliasAttempts) != 0 {
t.Fatalf("dry-run reached command runner\ncanonical=%#v\nalias=%#v", canonicalAttempts, aliasAttempts)
}
for label, preview := range map[string]paramAliasDryRunPreview{"canonical": canonical, "alias": alias} {
if !preview.DryRun || preview.Executed {
t.Fatalf("%s preview execution state = %#v", label, preview)
}
if preview.Tool != test.tool {
t.Fatalf("%s preview tool = %q, want %q", label, preview.Tool, test.tool)
}
keys := make([]string, 0, len(preview.Arguments))
for key := range preview.Arguments {
keys = append(keys, key)
}
sort.Strings(keys)
if !reflect.DeepEqual(keys, test.wantArgKeys) {
t.Fatalf("%s preview argument keys = %v, want %v", label, keys, test.wantArgKeys)
}
}
if !reflect.DeepEqual(alias, canonical) {
t.Fatalf("dry-run previews differ\ncanonical=%#v\nalias=%#v", canonical, alias)
}
})
}
}
func TestParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
caller := &paramAliasCaptureCaller{}
for _, args := range [][]string{
{"calendar", "event", "list", "--date", "2026-03-10", "--start", "2026-03-11"},
{"calendar", "event", "list", "--start", "2026-03-11", "--date", "2026-03-10"},
} {
root := NewRootCommand()
root.SetArgs(args)
originalCaller := helpers.GetCaller()
helpers.InitDeps(caller)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
helpers.InitDeps(originalCaller)
var conflict *pipeline.FlagConflictError
if !stderrors.As(err, &conflict) {
t.Fatalf("RunPreParseArgs(%v) error = %v, want FlagConflictError (ctx=%#v)", args, err, ctx)
}
if conflict.Canonical != "start" || !reflect.DeepEqual(conflict.Spellings, []string{"date", "start"}) {
t.Fatalf("conflict = %#v", conflict)
}
}
if len(caller.calls) != 0 {
t.Fatalf("conflicting argv reached RunE/tool dispatch: %#v", caller.calls)
}
}
func TestAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
}
paths := make(map[string]bool)
for _, concept := range concepts.Concepts {
for _, path := range concept.Commands {
paths[path] = true
}
}
sourceGuards := make(map[string]pipeline.FlagProtection)
for _, override := range concepts.Overrides {
paths[override.CommandPath] = true
for _, emitted := range override.Block {
sourceGuards[override.CommandPath+"\x00"+cmdutil.Morph(emitted)] = pipeline.FlagProtectionBlocked
}
for _, emitted := range override.Ambiguous {
sourceGuards[override.CommandPath+"\x00"+cmdutil.Morph(emitted)] = pipeline.FlagProtectionAmbiguous
}
}
orderedPaths := make([]string, 0, len(paths))
for path := range paths {
orderedPaths = append(orderedPaths, path)
}
sort.Strings(orderedPaths)
root := NewRootCommand()
engine := newPipelineEngine()
guardCounts := map[pipeline.FlagProtection]int{}
testedGuards := make(map[string]pipeline.FlagProtection)
for _, path := range orderedPaths {
entry, ok := cli.LookupParamAlias(path)
if !ok {
continue
}
leaf := resolveParamLeaf(root, path)
if leaf == nil {
t.Fatalf("generated guard path %q is not runnable", path)
}
for _, protectionCase := range []struct {
protection pipeline.FlagProtection
emitted []string
}{
{protection: pipeline.FlagProtectionBlocked, emitted: entry.Blocked},
{protection: pipeline.FlagProtectionAmbiguous, emitted: entry.Ambiguous},
} {
for _, emitted := range protectionCase.emitted {
protectionCase := protectionCase
emitted := emitted
key := path + "\x00" + cmdutil.Morph(emitted)
if previous, duplicate := testedGuards[key]; duplicate {
t.Fatalf("generated guard %q/%q is classified twice: %s and %s", path, emitted, previous, protectionCase.protection)
}
testedGuards[key] = protectionCase.protection
guardCounts[protectionCase.protection]++
t.Run(path+"/"+emitted, func(t *testing.T) {
value := "FIXTURE_VALUE"
pathArgs := strings.Fields(path)
args := append(append([]string(nil), pathArgs...), "--"+emitted, value)
ctx, runErr := pipeline.RunPreParseArgs(root, engine, args)
if runErr != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", args, runErr)
}
morphed := cmdutil.Morph(emitted)
if ctx == nil || ctx.ProtectedFlags[morphed] != protectionCase.protection {
t.Fatalf("guard protection = %#v, want %s for %q", ctx, protectionCase.protection, morphed)
}
assertLeftUnchanged(t, ctx, emitted, value)
flagArgs := ctx.Args[len(pathArgs):]
if parseErr := leaf.ParseFlags(flagArgs); parseErr == nil || !strings.Contains(parseErr.Error(), "unknown flag") {
t.Fatalf("guarded Cobra ParseFlags(%v) error = %v, want unknown flag", flagArgs, parseErr)
}
})
}
}
}
for key, want := range sourceGuards {
if got, ok := testedGuards[key]; !ok || got != want {
t.Fatalf("reviewed source guard %q delivered as %s (present=%t), want %s", key, got, ok, want)
}
}
if guardCounts[pipeline.FlagProtectionBlocked] == 0 || guardCounts[pipeline.FlagProtectionAmbiguous] == 0 {
t.Fatalf("reviewed guard coverage is vacuous: blocked %d ambiguous %d", guardCounts[pipeline.FlagProtectionBlocked], guardCounts[pipeline.FlagProtectionAmbiguous])
}
}
func TestRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testing.T) {
for _, test := range []struct {
path string
emitted string
protection pipeline.FlagProtection
reason string
}{
{path: "chat message list-by-sender", emitted: "time", protection: pipeline.FlagProtectionBlocked, reason: "blocked_flag"},
{path: "drive list", emitted: "space", protection: pipeline.FlagProtectionAmbiguous, reason: "ambiguous_flag"},
} {
test := test
t.Run(test.path+"/"+test.emitted, func(t *testing.T) {
value := "FIXTURE_VALUE"
args := append(strings.Fields(test.path), "--"+test.emitted, value)
caller := &paramAliasCaptureCaller{}
ctx, executeErr := executeParamAliasE2E(t, caller, args...)
morphed := cmdutil.Morph(test.emitted)
if ctx == nil || ctx.ProtectedFlags[morphed] != test.protection {
t.Fatalf("guard protection = %#v, want %s for %q", ctx, test.protection, morphed)
}
assertLeftUnchanged(t, ctx, test.emitted, value)
var appErr *apperrors.Error
if !stderrors.As(executeErr, &appErr) {
t.Fatalf("final error = %T %v, want *errors.Error", executeErr, executeErr)
}
if appErr.Category != apperrors.CategoryValidation || appErr.Reason != test.reason || apperrors.ExitCode(executeErr) != 3 {
t.Fatalf("final error contract = category %q reason %q exit %d, want validation/%s/3", appErr.Category, appErr.Reason, apperrors.ExitCode(executeErr), test.reason)
}
if !strings.Contains(appErr.Message, "unknown flag: --"+test.emitted) || !strings.Contains(appErr.Message, "See 'dws "+test.path+" --help' for usage.") {
t.Fatalf("final error message = %q", appErr.Message)
}
if !strings.Contains(appErr.Hint, "--"+test.emitted) || !strings.Contains(appErr.Hint, "--help") {
t.Fatalf("final error hint = %q", appErr.Hint)
}
wantAction := "Run 'dws " + test.path + " --help' for valid flags"
if !reflect.DeepEqual(appErr.Actions, []string{wantAction}) || len(appErr.AvailableFlags) == 0 || appErr.Cause == nil {
t.Fatalf("final recovery fields = actions %v flags %v cause %v", appErr.Actions, appErr.AvailableFlags, appErr.Cause)
}
if len(caller.calls) != 0 {
t.Fatalf("guarded flag reached RunE/tool dispatch: %#v", caller.calls)
}
})
}
}
func TestFlagConflictErrorFormattingIsDeterministic(t *testing.T) {
err := (&pipeline.FlagConflictError{Command: "dws demo", Canonical: "start", Spellings: []string{"start", "date"}}).Error()
want := `conflicting parameter spellings for --start on "dws demo": --date, --start; pass exactly one spelling`
if err != want {
t.Fatalf("FlagConflictError = %q, want %q", err, want)
}
}
+220
View File
@@ -0,0 +1,220 @@
// 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 (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// TestParamAliasFixtureThroughEmbeddedDeliveryPath is the ⑥ regression gate.
// It reads the reviewed validation_fixture straight from the embedded concept
// dictionary and asserts every reviewed bad case through the REAL delivery
// path — not a generator unit test and not a reimplementation of the reduction
// logic:
//
// - the runtime PreParse engine built by newPipelineEngine() (the exact
// handler chain root.go installs, whose SemanticAliasHandler is wired to
// cli.LookupParamAlias over the embedded generated table),
// - one distribution-owned Cobra tree, reused because PreParse reads command
// and flag metadata but does not parse or mutate individual flag values,
// and
// - the embedded cli.LookupParamAlias query used to prove that a
// did-you-mean case is an intentional block/ambiguous guard rather than a
// name that merely happens to be absent from the table.
//
// Fixture expect semantics (see spec §⑥):
// - expect=<realFlag> : emitted must reduce to that canonical flag.
// - expect=did-you-mean:blocked : block guard hit; never auto-rewritten.
// - expect=did-you-mean:ambiguous: co-occurrence guard hit; never rewritten.
func TestParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
}
if len(concepts.Fixture) == 0 {
t.Fatal("validation_fixture declares no cases; ⑥ gate would be vacuous")
}
// The exact runtime handler chain (alias → semantic → sticky →
// paramname), with the semantic table sourced from the embedded generated
// snapshot. Build the distribution-owned tree once: constructing the full
// 800+ command tree for every fixture made the macOS race package exceed its
// 10-minute budget, while RunPreParseArgs itself only reads this tree.
engine := newPipelineEngine()
root := NewSchemaSourceRootCommand()
for _, c := range concepts.Fixture {
t.Run(c.Command+"/"+c.Emitted, func(t *testing.T) {
leaf := resolveParamLeaf(root, c.Command)
if leaf == nil {
t.Fatalf("fixture command %q is not a live Cobra command", c.Command)
}
// Fixture command paths carry no "dws" prefix; LookupParamAlias
// normalizes to the same key the generator used, so the runtime
// lookup is byte-identical to the build-time key.
entry, hasEntry := cli.LookupParamAlias(c.Command)
fixtureValue := paramFixtureValue(leaf, c.Emitted, c.Expect)
rawArgs := append(strings.Fields(c.Command), "--"+c.Emitted, fixtureValue)
root.SetArgs(rawArgs)
ctx, err := pipeline.RunPreParseArgs(root, engine, rawArgs)
if err != nil {
t.Fatalf("RunPreParseArgs error = %v", err)
}
if ctx == nil {
t.Fatal("RunPreParseArgs skipped a fixture command with real flags")
}
morphed := cmdutil.Morph(c.Emitted)
switch c.Expect {
case "did-you-mean:ambiguous":
if !hasEntry || !entry.IsAmbiguous(morphed) {
t.Fatalf("%q on %q: expected co-occurrence guard (ambiguous) but embedded entry does not classify it; ambiguous=%v", c.Emitted, c.Command, entry.Ambiguous)
}
if commandHasRealFlagByMorph(leaf, morphed) {
t.Fatalf("guarded --%s on %q is a real Cobra flag and would bypass the unknown-flag recovery path", c.Emitted, c.Command)
}
assertLeftUnchanged(t, ctx, c.Emitted, fixtureValue)
case "did-you-mean:blocked":
if !hasEntry || !entry.IsBlocked(morphed) {
t.Fatalf("%q on %q: expected block guard but embedded entry does not classify it; blocked=%v", c.Emitted, c.Command, entry.Blocked)
}
if commandHasRealFlagByMorph(leaf, morphed) {
t.Fatalf("guarded --%s on %q is a real Cobra flag and would bypass the unknown-flag recovery path", c.Emitted, c.Command)
}
assertLeftUnchanged(t, ctx, c.Emitted, fixtureValue)
default:
// Real-flag expect: the reviewed canonical outcome is delivered
// one of two equally valid ways, and the gate accepts either
// (failing only on a genuine unknown-flag hallucination):
// 1. semantic rewrite — the emitted synonym is not a real flag,
// so the embedded table rewrites it to the canonical flag; or
// 2. native acceptance — the emitted synonym is still a genuine
// (usually hidden) real flag the command accepts directly and
// maps to the same entity via its fallback wiring. Native
// compatibility flags intentionally remain command-owned.
if !commandHasRealFlagByMorph(leaf, cmdutil.Morph(c.Expect)) {
t.Fatalf("reviewed canonical --%s on %q is not a real Cobra flag", c.Expect, c.Command)
}
flagArgs := ctx.Args[len(strings.Fields(c.Command)):]
if len(flagArgs) < 2 || flagArgs[1] != fixtureValue {
t.Fatalf("%q on %q lost its value: args=%v", c.Emitted, c.Command, ctx.Args)
}
got := flagArgs[0]
gotBare := strings.SplitN(strings.TrimPrefix(got, "--"), "=", 2)[0]
switch {
case got == "--"+c.Expect:
// (1) rewritten; the embedded table must agree.
if !hasEntry {
t.Fatalf("%q on %q was rewritten without an embedded alias entry", c.Emitted, c.Command)
}
if canon, hit := entry.ResolveAlias(morphed); !hit || canon != c.Expect {
t.Fatalf("embedded table ResolveAlias(%q) on %q = %q (hit=%v), want %q", morphed, c.Command, canon, hit, c.Expect)
}
case cmdutil.Morph(gotBare) == morphed && commandHasRealFlagByMorph(leaf, morphed):
// (2) not rewritten — only valid if the command natively
// accepts the emitted synonym as a real flag.
default:
t.Fatalf("%q on %q reduced to unexpected %q, want --%s or native --%s (args=%v)", c.Emitted, c.Command, got, c.Expect, c.Emitted, ctx.Args)
}
}
})
}
}
// assertLeftUnchanged verifies a guarded (blocked/ambiguous) synonym is never
// silently rewritten: the flag token and its value survive verbatim so the
// unknown-flag did-you-mean path can surface the reviewed candidates.
func assertLeftUnchanged(t *testing.T, ctx *pipeline.Context, emitted, value string) {
t.Helper()
flagIndex := -1
for i, arg := range ctx.Args {
if arg == "--"+emitted || strings.HasPrefix(arg, "--"+emitted+"=") {
flagIndex = i
break
}
}
if flagIndex < 0 {
t.Fatalf("guarded synonym --%s disappeared: args=%v", emitted, ctx.Args)
}
if got := ctx.Args[flagIndex]; got != "--"+emitted {
t.Fatalf("guarded synonym --%s was rewritten to %q (must be left for did-you-mean): args=%v", emitted, got, ctx.Args)
}
if len(ctx.Args) <= flagIndex+1 || ctx.Args[flagIndex+1] != value {
t.Fatalf("guarded synonym --%s lost its value: args=%v", emitted, ctx.Args)
}
for _, corr := range ctx.Corrections {
if corr.Handler == "semantic-alias" && corr.Original == "--"+emitted {
t.Fatalf("guarded synonym --%s was corrected by %s (must not be): %+v", emitted, corr.Handler, corr)
}
}
}
func paramFixtureValue(cmd *cobra.Command, emitted, expect string) string {
if cmd == nil {
return "FIXTURE_VALUE"
}
wanted := []string{emitted}
if !strings.HasPrefix(expect, "did-you-mean:") {
wanted = append(wanted, expect)
}
for _, name := range wanted {
var found *pflag.Flag
cmd.Flags().VisitAll(func(flag *pflag.Flag) {
if found == nil && cmdutil.Morph(flag.Name) == cmdutil.Morph(name) {
found = flag
}
})
if found == nil {
continue
}
switch found.Value.Type() {
case "bool":
return "true"
case "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64", "float32", "float64":
return "1"
}
}
return "FIXTURE_VALUE"
}
// resolveParamLeaf resolves a fixture command path (no "dws" prefix, e.g.
// "chat message search-advanced") to its live Cobra command, or nil.
func resolveParamLeaf(root *cobra.Command, path string) *cobra.Command {
cmd, _, err := root.Find(strings.Fields(path))
if err != nil || cmd == nil || cmd == root {
return nil
}
return cmd
}
// commandHasRealFlagByMorph reports whether the command has any real flag
// (local or inherited, including hidden) whose Morph matches morphed — the same
// notion of "real flag" the build-time reducer uses to absorb legacy synonyms.
func commandHasRealFlagByMorph(cmd *cobra.Command, morphed string) bool {
found := false
check := func(f *pflag.Flag) {
if f.Name != "help" && cmdutil.Morph(f.Name) == morphed {
found = true
}
}
cmd.Flags().VisitAll(check)
cmd.InheritedFlags().VisitAll(check)
return found
}
@@ -0,0 +1,339 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
// paramAliasCompleteCommands is deliberately keyed by the exact reviewed
// fixture command path. Every argv is a complete, business-valid invocation:
// required companion flags are present, time and enum values are valid, and
// write commands use the capture caller rather than a real transport. The
// target canonical flag must occur exactly once so the test can replace only
// its spelling while holding every other input constant.
var paramAliasCompleteCommands = map[string][]string{
"aitable +base-search": {"aitable", "+base-search", "--query", "fixture"},
"aitable +field-get": {"aitable", "+field-get", "--base-id", "base-1", "--table-id", "table-1"},
"aitable +list-tables": {"aitable", "+list-tables", "--base", "base-1"},
"aitable +record-query": {"aitable", "+record-query", "--base-id", "base-1", "--table-id", "table-1", "--query", "fixture"},
"aitable +record-share-url": {"aitable", "+record-share-url", "--base-id", "base-1", "--table-id", "table-1", "--record-ids", "record-1"},
"aitable +table-get": {"aitable", "+table-get", "--base-id", "base-1"},
"aitable record query": {"aitable", "record", "query", "--base-id", "base-1", "--table-id", "table-1", "--limit", "7"},
"attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
"chat +category-create": {"chat", "+category-create", "--title", "Fixture Cat", "--yes"},
"chat +category-rename": {"chat", "+category-rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
"chat +group-members": {"chat", "+group-members", "--group", "Fixture Group"},
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"},
"chat +unread-chats": {"chat", "+unread-chats", "--count", "7", "--exclude-muted"},
"chat bot find": {"chat", "bot", "find", "--query", "fixture", "--limit", "7"},
"chat bot search": {"chat", "bot", "search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
"chat category create": {"chat", "category", "create", "--title", "Fixture Cat", "--yes"},
"chat category create-smart": {"chat", "category", "create-smart", "--name", "Fixture Smart Category", "--keywords", "fixture,priority", "--yes"},
"chat category rename": {"chat", "category", "rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
"chat group members": {"chat", "group", "members", "--id", "fixture-conversation"},
"chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", "D-user-1"},
"chat group members add-bot": {"chat", "group", "members", "add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
"chat group members list-by-ids": {"chat", "group", "members", "list-by-ids", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
"chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", "D-user-1", "--yes"},
"chat group members remove-bot": {"chat", "group", "members", "remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
"chat group rename": {"chat", "group", "rename", "--id", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
"chat group set-admin": {"chat", "group", "set-admin", "--group", "fixture-conversation", "--user", "user-1", "--yes"},
"chat message add-emoji": {"chat", "message", "add-emoji", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--emoji", "赞", "--yes"},
"chat message add-favorite": {"chat", "message", "add-favorite", "--open-message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--yes"},
"chat message combine-forward": {"chat", "message", "combine-forward", "--src-conversation-id", "fixture-source", "--msg-ids", "message-1,message-2", "--dest-conversation-id", "fixture-destination", "--yes"},
"chat message forward-topic": {"chat", "message", "forward-topic", "--src-msg-id", "message-1", "--src-conversation-id", "fixture-source", "--src-thread-id", "convThread-fixture", "--dest-conversation-id", "fixture-destination", "--yes"},
"chat message list": {"chat", "message", "list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat message list-all": {"chat", "message", "list-all", "--start", "2026-03-10 00:00:00", "--end", "2026-03-11 00:00:00"},
"chat message list-by-sender": {"chat", "message", "list-by-sender", "--sender-user-id", "user-1", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
"chat message list-favorites": {"chat", "message", "list-favorites", "--cursor", "2", "--size", "7"},
"chat message list-by-ids": {"chat", "message", "list-by-ids", "--msg-ids", "message-1,message-2"},
"chat message list-unread-conversations": {"chat", "message", "list-unread-conversations", "--count", "7", "--exclude-muted"},
"chat message recall": {"chat", "message", "recall", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
"chat message search-advanced": {"chat", "message", "search-advanced", "--conversation-ids", "fixture-conversation", "--query", "fixture"},
"chat message send": {"chat", "message", "send", "--user", "D-recipient", "--text", "hello fixture", "--uuid", "param-alias-equivalence", "--yes"},
"chat message send-by-bot": {"chat", "message", "send-by-bot", "--robot-code", "robot-1", "--group", "fixture-conversation", "--title", "Fixture Alert", "--text", "@user-1 @user-2 fixture", "--at-user-ids", "user-1,user-2", "--yes"},
"chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
"contact +dept-members": {"contact", "+dept-members", "--dept", "Fixture Dept"},
"contact +list-sub-depts": {"contact", "+list-sub-depts", "--dept", "1"},
"contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"},
"contact +search-user": {"contact", "+search-user", "--query", "Fixture User"},
"contact dept list-children": {"contact", "dept", "list-children", "--dept", "1"},
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1"},
"dev app get": {"dev", "app", "get", "--unified-app-id", "app-1"},
"devdoc article search": {"devdoc", "article", "search", "--query", "fixture", "--page", "2", "--size", "7"},
"ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"},
"ding message receiver-status": {"ding", "message", "receiver-status", "--ding-id", "ding-1"},
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1", "--yes"},
"doc +template-search": {"doc", "+template-search", "--query", "fixture", "--source", "MY", "--limit", "7"},
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
"drive info": {"drive", "info", "--node", "node-1", "--space-id", "space-1"},
"drive list": {"drive", "list", "--folder", "folder-1", "--limit", "7"},
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"},
"mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder", "--yes"},
"mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"},
"mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"},
"mail user search": {"mail", "user", "search", "--keyword", "fixture"},
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "1"},
"oa +search-forms": {"oa", "+search-forms", "--query", "fixture"},
"oa approval search-forms": {"oa", "approval", "search-forms", "--query", "fixture"},
"report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"},
}
// A command can expose more than one mutually exclusive canonical route. In
// that case the shared command template above cannot contain every canonical
// flag at once, so select a fixture-specific complete invocation here.
var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"chat message list": {
"user": {"chat", "message", "list", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
},
"chat message list-by-sender": {
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", "D-sender", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
},
"chat message send": {
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--uuid", "param-alias-equivalence-group", "--yes"},
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--uuid", "param-alias-equivalence-file", "--yes"},
},
}
// paramAliasNewIMCases is the exact set of aliases added by the reviewed IM
// optimization. The dedicated gate below requires every one to remain active
// in the embedded generated table and equivalent at the final transport.
var paramAliasNewIMCases = []struct {
command string
emitted string
canonical string
}{
{command: "chat +bot-find", emitted: "name", canonical: "query"},
{command: "chat bot find", emitted: "name", canonical: "query"},
{command: "chat +bot-search", emitted: "query", canonical: "name"},
{command: "chat +bot-search", emitted: "current-page", canonical: "page"},
{command: "chat +category-create", emitted: "name", canonical: "title"},
{command: "chat +category-rename", emitted: "name", canonical: "title"},
{command: "chat +messages-list-direct", emitted: "start", canonical: "time"},
{command: "chat +messages-list-unread-conversations", emitted: "limit", canonical: "count"},
{command: "chat +messages-list-unread-conversations", emitted: "size", canonical: "count"},
{command: "chat +messages-send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
{command: "chat +unread-chats", emitted: "limit", canonical: "count"},
{command: "chat +unread-chats", emitted: "size", canonical: "count"},
{command: "chat bot search", emitted: "query", canonical: "name"},
{command: "chat bot search", emitted: "current-page", canonical: "page"},
{command: "chat category create", emitted: "name", canonical: "title"},
{command: "chat category create-smart", emitted: "title", canonical: "name"},
{command: "chat category rename", emitted: "name", canonical: "title"},
{command: "chat message list", emitted: "start", canonical: "time"},
{command: "chat message list-by-sender", emitted: "user-id", canonical: "sender-user-id"},
{command: "chat message list-by-sender", emitted: "open-dingtalk-id", canonical: "sender-open-dingtalk-id"},
{command: "chat message list-favorites", emitted: "limit", canonical: "size"},
{command: "chat message list-unread-conversations", emitted: "limit", canonical: "count"},
{command: "chat message list-unread-conversations", emitted: "size", canonical: "count"},
{command: "chat message send", emitted: "file", canonical: "file-path"},
{command: "chat message send-by-bot", emitted: "at-users", canonical: "at-user-ids"},
{command: "chat message send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
}
// paramAliasRepresentativePayloadCases keeps final transport coverage across
// old concept aliases, command overrides, native compatibility flags, read and
// write commands, and different products. Every reviewed alias is still
// checked through the embedded PreParse delivery path and against a complete
// business-valid command template. The separate IM gate below continues to
// execute every alias introduced by the current IM optimization.
//
// Keeping the older 100+ aliases at the contract layer avoids rebuilding and
// executing the complete 800+ command Root twice per spelling under -race.
// That duplicated command construction was enough to push the pre-existing
// macOS app suite beyond its package-level 10-minute timeout.
var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
paramAliasPayloadCaseKey("contact user profile get", "user-id"): true, // native compatibility flag
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
}
func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
}
activeCommands := make(map[string]bool)
activeCases := 0
executedRepresentatives := make(map[string]bool)
for _, fixture := range concepts.Fixture {
if strings.HasPrefix(fixture.Expect, "did-you-mean:") {
continue
}
activeCommands[fixture.Command] = true
activeCases++
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
if !ok {
t.Errorf("reviewed active fixture %q/%q has no complete-command E2E template", fixture.Command, fixture.Emitted)
continue
}
canonicalArgs := append([]string(nil), complete...)
aliasArgs, replacements := replaceLongFlag(canonicalArgs, fixture.Expect, fixture.Emitted)
if replacements != 1 {
t.Errorf("complete command for %q/%q must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Command, fixture.Emitted, fixture.Expect, replacements, canonicalArgs)
continue
}
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
if !paramAliasRepresentativePayloadCases[caseKey] {
continue
}
executedRepresentatives[caseKey] = true
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
canonicalCaller := &paramAliasCaptureCaller{}
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
if canonicalErr != nil {
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
}
if len(canonicalCaller.calls) == 0 {
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
}
aliasCaller := &paramAliasCaptureCaller{}
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
if aliasErr != nil {
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
}
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
}
})
}
if activeCases == 0 {
t.Fatal("reviewed fixture contains no active alias cases")
}
for command := range paramAliasCompleteCommands {
if !activeCommands[command] {
t.Errorf("complete-command E2E template %q has no active reviewed fixture", command)
}
}
for command := range activeCommands {
if _, ok := paramAliasCompleteCommands[command]; !ok {
t.Errorf("active reviewed command %q has no complete-command E2E template", command)
}
}
if len(activeCommands) != len(paramAliasCompleteCommands) {
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(paramAliasCompleteCommands), len(activeCommands), activeCases)
}
for caseKey := range paramAliasRepresentativePayloadCases {
if !executedRepresentatives[caseKey] {
t.Errorf("representative final-payload case %q has no active reviewed fixture", caseKey)
}
}
if len(executedRepresentatives) != len(paramAliasRepresentativePayloadCases) {
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), len(paramAliasRepresentativePayloadCases))
}
}
func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
activeAliases := 0
for _, test := range paramAliasNewIMCases {
test := test
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
if !ok {
t.Fatal("reviewed IM alias has no complete-command E2E template")
}
canonicalArgs := append([]string(nil), complete...)
aliasArgs, replacements := replaceLongFlag(canonicalArgs, test.canonical, test.emitted)
if replacements != 1 {
t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, canonicalArgs)
}
canonicalCaller := &paramAliasCaptureCaller{}
if _, err := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...); err != nil {
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", err, canonicalArgs, canonicalCaller.calls)
}
if len(canonicalCaller.calls) == 0 {
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
}
entry, exists := cli.LookupParamAlias(test.command)
target, active := entry.ResolveAlias(test.emitted)
if !exists || !active {
return
}
if target != test.canonical {
t.Fatalf("active reviewed IM alias --%s resolves to --%s, want --%s", test.emitted, target, test.canonical)
}
activeAliases++
aliasCaller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
if err != nil {
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", err, aliasArgs, aliasCaller.calls)
}
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
}
})
}
if activeAliases != len(paramAliasNewIMCases) {
t.Fatalf("new IM aliases active in embedded table = %d, want %d", activeAliases, len(paramAliasNewIMCases))
}
}
func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
complete, ok := paramAliasCompleteCommands[command]
if variants := paramAliasCompleteCommandVariants[command]; variants != nil {
if variant, exists := variants[canonical]; exists {
return variant, true
}
}
return complete, ok
}
func paramAliasPayloadCaseKey(command, emitted string) string {
return command + "\x00" + emitted
}
func executeParamAliasPayloadE2E(t *testing.T, caller *paramAliasCaptureCaller, args ...string) (*pipeline.Context, error) {
t.Helper()
return executeParamAliasE2E(t, caller, args...)
}
func replaceLongFlag(args []string, canonical, emitted string) ([]string, int) {
out := append([]string(nil), args...)
replacements := 0
for index, arg := range out {
if arg == "--"+canonical {
out[index] = "--" + emitted
replacements++
continue
}
if strings.HasPrefix(arg, "--"+canonical+"=") {
out[index] = "--" + emitted + strings.TrimPrefix(arg, "--"+canonical)
replacements++
}
}
return out, replacements
}
@@ -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 (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
// TestCalendarEventListNativeFallbacksAndCentralAliasesCoexist locks the
// boundary between the command's original hidden compatibility flags and the
// new central semantic normalizer. Existing real flags stay untouched and are
// handled by calendar.go's flagOrFallback chain; only spellings that are not
// real flags (for example --date, --from, and --since) are rewritten centrally.
func TestCalendarEventListNativeFallbacksAndCentralAliasesCoexist(t *testing.T) {
engine := newPipelineEngine()
cases := []struct {
emitted string
value string
canonical string
isInt bool
native bool
}{
// Existing Calendar compatibility flags remain native.
{"start-time", "2026-03-10T14:00:00+08:00", "start", false, true},
{"startTime", "2026-03-10T14:00:00+08:00", "start", false, true},
{"start_time", "2026-03-10T14:00:00+08:00", "start", false, true},
{"start-date", "2026-03-10T14:00:00+08:00", "start", false, true},
{"min-time", "2026-03-10T14:00:00+08:00", "start", false, true},
{"time-min", "2026-03-10T14:00:00+08:00", "start", false, true},
{"end-time", "2026-03-10T18:00:00+08:00", "end", false, true},
{"endTime", "2026-03-10T18:00:00+08:00", "end", false, true},
{"end-date", "2026-03-10T18:00:00+08:00", "end", false, true},
{"max-time", "2026-03-10T18:00:00+08:00", "end", false, true},
{"time-max", "2026-03-10T18:00:00+08:00", "end", false, true},
{"max-results", "50", "limit", true, true},
{"maxResults", "50", "limit", true, true},
{"page-size", "50", "limit", true, true},
{"size", "50", "limit", true, true},
{"next-cursor", "TOKEN123", "cursor", false, true},
{"nextCursor", "TOKEN123", "cursor", false, true},
{"page-token", "TOKEN123", "cursor", false, true},
{"next-token", "TOKEN123", "cursor", false, true},
{"calendar", "primary", "calendar-id", false, true},
{"calendarId", "primary", "calendar-id", false, true},
// These spellings have no native Calendar flag and remain central aliases.
{"from", "2026-03-10T14:00:00+08:00", "start", false, false},
{"since", "2026-03-10T14:00:00+08:00", "start", false, false},
{"date", "2026-03-10T14:00:00+08:00", "start", false, false},
}
for _, tc := range cases {
t.Run(tc.emitted, func(t *testing.T) {
// Fresh command tree per case: ParseFlags mutates flag state.
root := NewRootCommand()
target := mustFindCommand(t, root, "calendar", "event", "list")
ctx := &pipeline.Context{
Args: []string{"calendar", "event", "list", "--" + tc.emitted, tc.value},
Command: target.CommandPath(),
FlagSpecs: pipeline.FlagInfoFromCommand(target),
}
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
t.Fatalf("PreParse error = %v", err)
}
parsedFlag := tc.canonical
if tc.native {
parsedFlag = tc.emitted
if len(ctx.Corrections) != 0 {
t.Fatalf("native --%s triggered central corrections: %#v", tc.emitted, ctx.Corrections)
}
if joined := strings.Join(ctx.Args, " "); !strings.Contains(joined, "--"+tc.emitted+" "+tc.value) {
t.Fatalf("native --%s did not survive unchanged: args = %v", tc.emitted, ctx.Args)
}
} else {
if joined := strings.Join(ctx.Args, " "); !strings.Contains(joined, "--"+tc.canonical+" "+tc.value) {
t.Fatalf("--%s not reduced to --%s: args = %v", tc.emitted, tc.canonical, ctx.Args)
}
if len(ctx.Corrections) != 1 {
t.Fatalf("central --%s corrections = %#v, want one", tc.emitted, ctx.Corrections)
}
}
flagArgs := ctx.Args[3:]
if err := target.ParseFlags(flagArgs); err != nil {
t.Fatalf("Cobra ParseFlags(%v) error = %v", flagArgs, err)
}
if tc.isInt {
got, err := target.Flags().GetInt(parsedFlag)
if err != nil || got != 50 {
t.Fatalf("flag --%s = %d (err %v), want 50", parsedFlag, got, err)
}
} else {
got, err := target.Flags().GetString(parsedFlag)
if err != nil || got != tc.value {
t.Fatalf("flag --%s = %q (err %v), want %q", parsedFlag, got, err, tc.value)
}
}
})
}
}
// TestCalendarEventListKeepsCountExclusion pins the reviewed decision that
// pagination_size deliberately excludes --count (count != limit). The kept
// hidden --count flag must be left untouched by the pipeline: it is a real
// flag, not a concept member, so it must not be rewritten to --limit.
func TestCalendarEventListKeepsCountExclusion(t *testing.T) {
engine := newPipelineEngine()
root := NewRootCommand()
target := mustFindCommand(t, root, "calendar", "event", "list")
ctx := &pipeline.Context{
Args: []string{"calendar", "event", "list", "--count", "5"},
Command: target.CommandPath(),
FlagSpecs: pipeline.FlagInfoFromCommand(target),
}
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
t.Fatalf("PreParse error = %v", err)
}
if joined := strings.Join(ctx.Args, " "); !strings.Contains(joined, "--count 5") {
t.Fatalf("--count must not be rewritten: args = %v", ctx.Args)
}
if len(ctx.Corrections) != 0 {
t.Fatalf("--count triggered corrections %#v, want none", ctx.Corrections)
}
if err := target.ParseFlags(ctx.Args[3:]); err != nil {
t.Fatalf("Cobra ParseFlags error = %v", err)
}
if got, _ := target.Flags().GetInt("count"); got != 5 {
t.Fatalf("flag --count = %d, want 5", got)
}
}
+3 -3
View File
@@ -569,9 +569,9 @@ func handlePatAuthCheck(
// In host-controlled PAT mode (driven solely by DINGTALK_DWS_AGENTCODE),
// or when flowId is absent, the CLI returns machine-readable JSON to
// stderr and leaves UI/polling/retry to the host. `claw-type` is NOT
// used for this decision — it is only forwarded on the wire via
// the edition default / DWS_AGENT_PRODUCT override and surfaced in
// hostControl for traceability.
// used for this decision — its edition-fixed value is forwarded on the
// wire and surfaced in hostControl for traceability. DWS_AGENT_PRODUCT
// does not affect this PAT contract.
if hostOwnedPAT || patData.Data.FlowID == "" {
if hostOwnedPAT {
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorForHostControl(patErr.RawJSON)}
+3 -3
View File
@@ -1007,11 +1007,11 @@ func TestHandlePatAuthCheck_HostControlledFlowIDPassthrough(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", tmpDir)
// Host-owned decision: driven ONLY by DINGTALK_DWS_AGENTCODE.
// DINGTALK_AGENT is set to demonstrate it does NOT leak into
// hostControl.clawType. With no DWS_AGENT_PRODUCT override the
// open-source edition default remains "openClaw".
// hostControl.clawType. DWS_AGENT_PRODUCT is also set to demonstrate
// that Product does not change the open-source fixed "openClaw" value.
t.Setenv(authpkg.AgentCodeEnv, "agt-sales")
t.Setenv("DINGTALK_AGENT", "sales-copilot")
t.Setenv(agentproduct.EnvName, "")
t.Setenv(agentproduct.EnvName, "qwenwork")
mock := &mockRunner{
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
+3 -4
View File
@@ -29,9 +29,8 @@ import (
// - Host-owned is triggered iff DINGTALK_DWS_AGENTCODE is non-empty.
// - When triggered, `clawType` in the emitted hostControl block MUST be the
// exact value the CLI actually injects on the wire. Each edition supplies
// its existing default and an optional valid DWS_AGENT_PRODUCT overrides
// it. Invalid input falls back here for library compatibility; root command
// execution rejects it before network access.
// its fixed value; DWS_AGENT_PRODUCT is a separate observability and IM
// message-display signal and never affects this PAT value.
// - When DINGTALK_DWS_AGENTCODE is empty the provider returns "" so
// HostControlBlock yields nil and no hostControl block is emitted.
func init() {
@@ -59,5 +58,5 @@ func effectiveClawType() string {
headers = h.MergeHeaders(headers)
}
}
return resolveEffectiveAgentProduct(headers)
return resolveEditionClawType(headers)
}
+100
View File
@@ -0,0 +1,100 @@
// 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 (
"bytes"
stderrors "errors"
"io"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
func TestLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
tests := []struct {
name string
args []string
}{
{name: "camel case", args: []string{"--dryRun", "chat", "bot", "find", "--help"}},
{name: "fuzzy boolean", args: []string{"--dry-rnu", "chat", "bot", "find", "--help"}},
{name: "fuzzy value", args: []string{"--profle", "corp:user", "chat", "bot", "find", "--help"}},
{name: "sticky value", args: []string{"--timeout30", "chat", "bot", "find", "--help"}},
{name: "sticky boolean value", args: []string{"--verbosefalse", "chat", "bot", "find", "--help"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
if err != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", test.args, err)
}
if ctx == nil || ctx.Command != "dws chat bot find" || len(ctx.Corrections) == 0 {
t.Fatalf("RunPreParseArgs(%v) context = %#v", test.args, ctx)
}
if err := root.Execute(); err != nil {
t.Fatalf("corrected leading persistent flag failed: %v", err)
}
})
}
}
func TestPreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
root := NewSchemaSourceRootCommand()
args := []string{
"chat", "message", "send",
"--user-id", "123", "--user", "456", "--text", "hi",
"--format", "table", "--debug",
}
_, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err == nil {
t.Fatal("alias/canonical conflict unexpectedly succeeded")
}
if wantsJSONErrors(root) {
t.Fatal("--format table was not applied before rendering the PreParse error")
}
if got := resolveVerbosity(root); got != apperrors.VerbosityDebug {
t.Fatalf("PreParse error verbosity = %v, want debug", got)
}
err = newPreParseValidationError(err)
var structured *apperrors.Error
if !stderrors.As(err, &structured) {
t.Fatalf("PreParse validation error = %T, want *errors.Error", err)
}
if strings.Contains(structured.Message, "pipeline") || strings.Contains(structured.Message, "semantic-alias") ||
strings.Contains(structured.Cause.Error(), "pipeline") || strings.Contains(structured.Cause.Error(), "semantic-alias") {
t.Fatalf("internal pipeline identity leaked to user error: message=%q cause=%q", structured.Message, structured.Cause)
}
var conflict *pipeline.FlagConflictError
if !stderrors.As(err, &conflict) {
t.Fatalf("PreParse validation error lost FlagConflictError: %v", err)
}
var output bytes.Buffer
if printErr := printExecutionError(root, &output, &output, err); printErr != nil {
t.Fatalf("printExecutionError() error = %v", printErr)
}
rendered := output.String()
if strings.HasPrefix(strings.TrimSpace(rendered), "{") {
t.Fatalf("--format table rendered JSON:\n%s", rendered)
}
if !strings.Contains(rendered, "Reason: parameter_conflict") || !strings.Contains(rendered, "Cause:") {
t.Fatalf("--debug details missing from early error:\n%s", rendered)
}
}
+111 -6
View File
@@ -76,6 +76,7 @@ var (
rootPluginLoadHooks = (*plugin.Plugin).LoadHooks
rootPluginSyncSkills = plugin.SyncSkills
rootAuthLoadTokenData = authpkg.LoadTokenData
rootNewCommandRunnerWithFlags = newCommandRunnerWithFlags
)
// Execute runs the root command and returns the process exit code.
@@ -114,7 +115,11 @@ func Execute() (exitCode int) {
// Run PreParse handlers on raw argv before Cobra parses flags.
// This corrects model-generated errors like --userId → --user-id
// and --limit100 → --limit 100.
rootRunPreParse(root, engine)
if err := rootRunPreParse(root, engine); err != nil {
err = newPreParseValidationError(err)
_ = printExecutionError(root, os.Stdout, os.Stderr, err)
return apperrors.ExitCode(err)
}
executed, err := rootExecuteCommand(root)
if err != nil {
@@ -136,6 +141,22 @@ func Execute() (exitCode int) {
return 0
}
// newPreParseValidationError keeps pipeline handler identity in internal logs
// while exposing only the underlying parameter-domain error to CLI users.
func newPreParseValidationError(err error) error {
userErr := err
var handlerErr *pipeline.HandlerError
if stderrors.As(err, &handlerErr) && handlerErr.Unwrap() != nil {
userErr = handlerErr.Unwrap()
}
return apperrors.NewValidation(
userErr.Error(),
apperrors.WithReason("parameter_conflict"),
apperrors.WithHint("Remove the duplicate alias/canonical spelling and pass the parameter exactly once."),
apperrors.WithCause(userErr),
)
}
func isUnknownCommandError(err error) bool {
return err != nil && strings.Contains(err.Error(), "unknown command")
}
@@ -183,6 +204,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, 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"
if protection == pipeline.FlagProtectionAmbiguous {
hint = fmt.Sprintf("Parameter --%s is ambiguous on %q and cannot be normalized safely; choose the intended explicit flag from --help.", flag, cmd.CommandPath())
reason = "ambiguous_flag"
}
return apperrors.NewValidation(
msgWithTail,
apperrors.WithHint(hint),
apperrors.WithReason(reason),
apperrors.WithCause(err),
apperrors.WithActions(fmt.Sprintf("Run '%s --help' for valid flags", cmd.CommandPath())),
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
)
}
// Common flag aliases and suggestions
suggestions := map[string]string{
@@ -231,6 +268,33 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
return fmt.Errorf("%s%s", errMsg, tail)
}
func reviewedFlagProtection(cmd *cobra.Command, errMsg string) (string, pipeline.FlagProtection, bool) {
if cmd == nil {
return "", "", false
}
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]
}
entry, ok := cli.LookupParamAlias(cmd.CommandPath())
if !ok {
return "", "", false
}
morphed := cmdutil.Morph(flag)
if entry.IsBlocked(morphed) {
return flag, pipeline.FlagProtectionBlocked, true
}
if entry.IsAmbiguous(morphed) {
return flag, pipeline.FlagProtectionAmbiguous, true
}
return "", "", false
}
func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err error) error {
var raw apperrors.RawStderrError
if stderrors.As(err, &raw) {
@@ -339,7 +403,7 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
loader := cli.EnvironmentLoader{
LookupEnv: os.LookupEnv,
}
runner := newCommandRunnerWithFlags(loader, flags)
runner := rootNewCommandRunnerWithFlags(loader, flags)
root := &cobra.Command{
Use: "dws",
@@ -453,11 +517,38 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
configureRootHelp(root)
// Set custom flag error handler for better UX
root.SetFlagErrorFunc(flagErrorWithSuggestions)
installReviewedFlagProtectionHandlers(root)
root.SetContext(rootCtx)
return root
}
// installReviewedFlagProtectionHandlers makes reviewed blocked/ambiguous
// parameters authoritative even when an older command subtree has installed a
// local FlagErrorFunc. Commands without a reviewed guard keep their existing
// handler or inherit the root handler as before.
func installReviewedFlagProtectionHandlers(root *cobra.Command) {
if root == nil {
return
}
var visit func(*cobra.Command)
visit = func(cmd *cobra.Command) {
if entry, ok := cli.LookupParamAlias(cmd.CommandPath()); ok && (len(entry.Blocked) > 0 || len(entry.Ambiguous) > 0) {
previous := cmd.FlagErrorFunc()
cmd.SetFlagErrorFunc(func(current *cobra.Command, err error) error {
if _, _, guarded := reviewedFlagProtection(current, err.Error()); guarded {
return flagErrorWithSuggestions(current, err)
}
return previous(current, err)
})
}
for _, child := range cmd.Commands() {
visit(child)
}
}
visit(root)
}
func preparseProfileFlag(args []string) string {
args, _ = normalizeProfileFlagArgs(args)
for i := 0; i < len(args); i++ {
@@ -1252,13 +1343,27 @@ func newPipelineEngine() *pipeline.Engine {
// Register handler runs during command tree building.
handlers.RegisterHandler{},
// PreParse handlers run in order: alias → sticky → paramname.
// Alias normalises case first (--userId → --user-id), then
// sticky splits glued values (--limit100 → --limit 100), then
// paramname fixes near-miss typos (--limt → --limit).
// PreParse handlers run in order: alias → semantic → sticky → paramname
// → boolvalue.
// Alias normalises case first (--userId → --user-id), then semantic
// resolves reviewed synonyms to the real flag (--keyword → --query),
// then sticky splits glued values (--limit100 → --limit 100), then
// paramname fixes near-miss typos (--limt → --limit). Boolvalue runs
// last so detached values for every real boolean flag (for example
// `--dry-run false`) become explicit `--flag=false` tokens before pflag
// can interpret the bare flag as true.
handlers.AliasHandler{},
handlers.SemanticAliasHandler{
// Inject the build-time reduced alias table with native types so
// the handler package stays decoupled from cli.
Lookup: func(rawCommandPath string) (map[string]string, []string, []string, bool) {
e, ok := cli.LookupParamAlias(rawCommandPath)
return e.Aliases, e.Blocked, e.Ambiguous, ok
},
},
handlers.StickyHandler{},
handlers.ParamNameHandler{},
handlers.BoolValueHandler{},
// PostParse handlers normalise structured values.
handlers.ParamValueHandler{},
+7 -1
View File
@@ -40,7 +40,7 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) {}
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
rootResetRecoveryState = func() {}
rootStopAllStdioClients = func() {}
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
@@ -52,6 +52,12 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
t.Fatalf("successful Execute code = %d", code)
}
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")
}
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
wantErr := errors.New("unknown command missing")
rootLatestRecoveryCapture = func() *recovery.LastError { return &recovery.LastError{EventID: "evt-test"} }
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
+20 -13
View File
@@ -1005,9 +1005,8 @@ func resolveIdentityHeaders() map[string]string {
// Inject environment variable based headers for MCP gateway tracking.
// DINGTALK_AGENT, if set by the caller, is forwarded verbatim as the
// x-dingtalk-agent header. It does NOT influence claw-type (which comes
// from the edition default plus the explicit DWS_AGENT_PRODUCT override)
// and it does NOT influence the host-owned PAT decision (driven solely by
// x-dingtalk-agent header. It does NOT influence the edition-fixed
// claw-type or the host-owned PAT decision (driven solely by
// DINGTALK_DWS_AGENTCODE).
sessionID := os.Getenv(envDingtalkSessionID)
if sessionID == "" {
@@ -1068,22 +1067,30 @@ func resolveIdentityHeaders() map[string]string {
if fn := edition.Get().MergeHeaders; fn != nil {
headers = fn(headers)
}
// Resolve the Agent Product before credential injection. The credential
// hook has a separate contract and must not be able to replace the
// request identity used by PAT hostControl serialization.
headers = applyAgentProductOverride(headers)
agentProduct := headers[agentproduct.HeaderName]
if headers == nil {
headers = make(map[string]string)
}
// claw-type is the edition-fixed routing/PAT identity. Agent Product is a
// separate caller-declared observability and IM-display dimension.
clawType := resolveEditionClawType(headers)
headers["claw-type"] = clawType
headers = applyAgentProductHeader(headers)
agentProduct, hasAgentProduct := headers[agentproduct.HeaderName]
if fn := edition.Get().EnterpriseCredentialHeaders; fn != nil {
headers = fn(headers)
}
if headers == nil {
headers = make(map[string]string)
}
// DWS_AGENT_PRODUCT is the explicit caller override for the existing
// claw-type wire header. Reassert the resolved product after credential
// injection so that hook cannot alter identity. Invalid values are ignored
// on this best-effort library path; root execution rejects them earlier.
headers[agentproduct.HeaderName] = agentProduct
// Credential hooks cannot alter either identity dimension. Restore the
// fixed claw-type and the validated Product Header (or its absence).
headers["claw-type"] = clawType
if hasAgentProduct {
headers[agentproduct.HeaderName] = agentProduct
} else {
delete(headers, agentproduct.HeaderName)
}
return headers
}
@@ -17,7 +17,7 @@ import (
const (
publicShortcutCount = 265
schemaPublishedShortcutCount = 210
schemaPublishedShortcutCount = 215
)
func TestEmbeddedSchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -106,7 +106,7 @@ func TestEmbeddedShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
product := executeShortcutSchemaQuery(t, "chat")
productPayload, _ := product["product"].(map[string]any)
if got, want := int(product["count"].(float64)), 124; got != want {
if got, want := int(product["count"].(float64)), 129; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
@@ -116,8 +116,8 @@ func TestEmbeddedShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
shortcutCount++
}
}
if shortcutCount != 42 {
t.Fatalf("schema chat shortcut summaries = %d, want 42", shortcutCount)
if shortcutCount != 47 {
t.Fatalf("schema chat shortcut summaries = %d, want 47", shortcutCount)
}
}
@@ -388,6 +388,7 @@ func shortcutCustomConstraintEvidence(description string) []string {
"绝对路径",
"..",
"最多 15 个字符",
"能力矩阵",
}
evidence := make([]string, 0, len(probes))
for _, probe := range probes {
+6 -3
View File
@@ -11,11 +11,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// gen.go is the single entry point for schema metadata generation. It isolates
// gen.go is the single entry point for reviewed CLI asset generation. It isolates
// all //go:generate pragmas from business code so that:
// - schema_agent_metadata.go / schema_catalog.go contain only types + embed.
// - Generation is a standalone process (make generate-schema triggers this).
// - The 6-input → 1-output contract is documented in one place.
// - The authored-input → generated-output contract is documented in one place.
//
// Generation inputs (authored, reviewed):
// 1. schema_command_registry/ identity (canonical/aliases/navigation)
@@ -23,11 +23,13 @@
// 3. schema_hints/selection/*.json selection (use_when/avoid_when)
// 4. schema_mcp_metadata.json MCP server tool definitions
// 5. schema_parameter_bindings.json parameter type/property mappings
// 6. cobra command tree (Go runtime) flags/usage/required (reflected)
// 6. param_concepts.json + schema reviewed parameter synonym policy
// 7. cobra command tree (Go runtime) flags/usage/required (reflected)
//
// Generation outputs (embedded at build):
// - schema_agent_metadata/*.json per-product agent metadata
// - schema_catalog/ per-product catalog shards
// - param_aliases_generated.go per-command parameter normalization
package cli
@@ -36,3 +38,4 @@ package cli
// package cached by the preceding metadata generator with the old embedded
// JSON files.
//go:generate go run -a ../generator/cmd_schema_catalog -root ../.. -output schema_catalog
//go:generate go run ../generator/cmd_param_aliases -root ../.. -output param_aliases_generated.go
@@ -0,0 +1,59 @@
// 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"
"os"
"path/filepath"
"strings"
"testing"
)
func TestGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
entries, err := os.ReadDir(".")
if err != nil {
t.Fatalf("read internal/cli: %v", err)
}
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || filepath.Ext(name) != ".go" || name == "gen.go" {
continue
}
content, err := os.ReadFile(name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
for _, line := range bytes.Split(content, []byte("\n")) {
if strings.HasPrefix(strings.TrimSpace(string(line)), "//go:generate") {
t.Errorf("%s contains //go:generate; all directives must stay in gen.go", name)
}
}
}
content, err := os.ReadFile("gen.go")
if err != nil {
t.Fatalf("read gen.go: %v", err)
}
for _, generator := range []string{
"cmd_schema_agent_metadata",
"cmd_schema_catalog",
"cmd_param_aliases",
} {
if !bytes.Contains(content, []byte("//go:generate go run")) || !bytes.Contains(content, []byte(generator)) {
t.Errorf("gen.go does not register %s", generator)
}
}
}
+427
View File
@@ -0,0 +1,427 @@
// 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"
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// ParamAliasEntry is the reduced parameter-alias table for one runnable Cobra
// leaf. It is the typed value the build-time generator serializes into
// param_aliases_generated.go and that the runtime normalizer (P2) consumes.
//
// Aliases maps an already-morphed emitted name to the command's canonical real
// flag; the runtime looks up Morph(emitted) here to resolve a synonym. Blocked
// lists morphed names that must never be reduced (they route to did-you-mean),
// and Ambiguous lists morphed names that a reviewed co-occurrence guard leaves
// unresolved on purpose.
type ParamAliasEntry struct {
CLIPath string `json:"cli_path"`
Aliases map[string]string `json:"aliases,omitempty"`
Blocked []string `json:"blocked,omitempty"`
Ambiguous []string `json:"ambiguous,omitempty"`
}
// ReduceParamAliases resolves the reviewed concept dictionary against every
// runnable leaf's real flags and returns the per-command alias table. It is the
// single source of the reduction algorithm, shared by the generator and tests
// so the build-time (intersection) and generated views can never disagree.
//
// The reduction is deliberately mechanical (no NLU): for each concept it morphs
// the concept members (plus any command-bound generic flag) and intersects them
// with the command's morphed real flags. An intersection of exactly one real
// flag yields aliases onto it; two or more real flags is a co-occurrence that
// must be an explicitly reviewed `ambiguous` entry or generation fails. Command
// scoped aliases override, blocks are removed and recorded, and every override
// path and target is validated against the live tree.
func ReduceParamAliases(root *cobra.Command) ([]ParamAliasEntry, error) {
concepts, err := LoadParamConcepts()
if err != nil {
return nil, fmt.Errorf("load reviewed parameter concepts: %w", err)
}
if root == nil {
return nil, fmt.Errorf("parameter alias source root is nil")
}
overrideByPath := make(map[string]CommandOverride, len(concepts.Overrides))
for _, ov := range concepts.Overrides {
overrideByPath[ov.CommandPath] = ov
}
usedOverride := make(map[string]bool, len(overrideByPath))
conceptsByPath := make(map[string][]Concept)
usedConceptScope := make(map[string]bool)
for _, concept := range concepts.Concepts {
for _, path := range concept.Commands {
conceptsByPath[path] = append(conceptsByPath[path], concept)
}
}
var problems []string
var entries []ParamAliasEntry
walkRunnableParamCommands(root, func(leaf *cobra.Command) {
path := normalizeSchemaCLIPath(leaf.CommandPath())
realByMorph := realFlagsByMorph(leaf)
ov, hasOverride := overrideByPath[path]
if hasOverride {
usedOverride[path] = true
}
scopedConcepts := conceptsByPath[path]
for _, concept := range scopedConcepts {
usedConceptScope[concept.ID+"\x00"+path] = true
if !conceptHasRealFlag(concept, ov, realByMorph) {
problems = append(problems, fmt.Sprintf("concept %q reviewed command %q has no matching real flag or reviewed bind", concept.ID, path))
}
}
entry, entryProblems := reduceLeafParamAliases(path, realByMorph, scopedConcepts, ov)
problems = append(problems, entryProblems...)
if entry != nil {
entries = append(entries, *entry)
}
})
for path := range overrideByPath {
if !usedOverride[path] {
problems = append(problems, fmt.Sprintf("command_override %q does not match any runnable Cobra leaf", path))
}
}
for _, concept := range concepts.Concepts {
for _, path := range concept.Commands {
if !usedConceptScope[concept.ID+"\x00"+path] {
problems = append(problems, fmt.Sprintf("concept %q command scope %q does not match any runnable Cobra command", concept.ID, path))
}
}
}
if len(problems) > 0 {
sort.Strings(problems)
return nil, fmt.Errorf("parameter alias reduction failed:\n - %s", strings.Join(problems, "\n - "))
}
sort.Slice(entries, func(i, j int) bool { return entries[i].CLIPath < entries[j].CLIPath })
return entries, nil
}
// walkRunnableParamCommands invokes fn for every runnable command in the tree,
// including runnable parents such as `chat group members` that expose their own
// flags while also owning subcommands. Parameter aliasing applies to any command
// that accepts flags, which is broader than the schema's leaf-only traversal.
func walkRunnableParamCommands(root *cobra.Command, fn func(*cobra.Command)) {
if root == nil {
return
}
var walk func(*cobra.Command)
walk = func(cmd *cobra.Command) {
if cmd.Runnable() {
fn(cmd)
}
for _, sub := range cmd.Commands() {
if sub.Name() == "help" {
continue
}
if !sub.IsAvailableCommand() && !hasRuntimeSchemaCommand(sub) {
continue
}
walk(sub)
}
}
walk(root)
}
// realFlag is one of a leaf's real flags, remembering whether it is hidden so
// the reduction can treat a hidden legacy alias flag (for example a hand-written
// --base living next to the visible --base-id) as an absorbable synonym rather
// than a genuine co-occurrence.
type realFlag struct {
name string
hidden bool
}
// realFlagsByMorph maps each of a leaf's real flags (local + inherited) by its
// morphed name to the real flags that share that morph.
func realFlagsByMorph(leaf *cobra.Command) map[string][]realFlag {
byMorph := make(map[string][]realFlag)
visitManualAgentCommandFlags(leaf, func(flag *pflag.Flag) {
if flag == nil || flag.Name == "help" {
return
}
key := cmdutil.Morph(flag.Name)
byMorph[key] = appendRealFlag(byMorph[key], realFlag{name: flag.Name, hidden: flag.Hidden})
})
return byMorph
}
// reduceLeafParamAliases computes one leaf's alias entry and returns any
// contract problems. A nil entry means the leaf produced no aliases, blocks, or
// ambiguous guards.
func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, concepts []Concept, ov CommandOverride) (*ParamAliasEntry, []string) {
var problems []string
aliasMap := make(map[string]string)
blockedSet := make(map[string]bool)
excludedSet := make(map[string]bool)
pendingReview := ov.Confirm || ov.Investigate
for boundFlag, conceptID := range ov.Bind {
if _, ok := realByMorph[cmdutil.Morph(boundFlag)]; !ok {
problems = append(problems, fmt.Sprintf("command_override %q binds %q to concept %q but %q is not a real flag", path, boundFlag, conceptID, boundFlag))
}
}
// (a) Concept auto-reduction. The caller has already admitted only the
// concepts whose reviewed command scope contains this exact leaf.
for _, concept := range concepts {
eff := make(map[string]bool, len(concept.Members)+2)
for _, member := range concept.Members {
eff[cmdutil.Morph(member)] = true
}
for boundFlag, conceptID := range ov.Bind {
if conceptID == concept.ID {
if pendingReview {
for _, member := range concept.Members {
morphed := cmdutil.Morph(member)
if _, isReal := realByMorph[morphed]; !isReal {
blockedSet[morphed] = true
}
}
} else {
eff[cmdutil.Morph(boundFlag)] = true
}
}
}
// Gather the concept's candidate real flags on this command, then
// choose a canonical. A single visible real flag wins and absorbs the
// rest (including hidden legacy alias flags). Two or more visible real
// flags is a genuine co-occurrence that must be reviewed.
var candidates []realFlag
for key := range eff {
candidates = append(candidates, realByMorph[key]...)
}
if len(candidates) == 0 {
continue
}
visible := distinctRealNames(candidates, true)
var canon string
switch len(visible) {
case 1:
canon = visible[0]
case 0:
names := distinctRealNames(candidates, false)
if len(names) != 1 {
continue
}
canon = names[0]
default:
// Genuine co-occurrence: this concept intersects two or more
// visible real flags, so it cannot be auto-reduced. Require that
// every emittable synonym of THIS concept (a concept member that is
// not itself a real flag on the command) is acknowledged in the
// reviewed ambiguous whitelist. Checking only that the command has
// some ambiguous entry would let one concept's whitelist silently
// vouch for a different concept's unreviewed co-occurrence.
ambiguousSet := make(map[string]bool, len(ov.Ambiguous))
for _, a := range ov.Ambiguous {
ambiguousSet[cmdutil.Morph(a)] = true
}
var unreviewed []string
for m := range eff {
if _, isReal := realByMorph[m]; isReal {
continue
}
if !ambiguousSet[m] {
unreviewed = append(unreviewed, m)
}
}
if len(unreviewed) > 0 {
sort.Strings(visible)
sort.Strings(unreviewed)
problems = append(problems, fmt.Sprintf("command %q concept %q intersects visible real flags %s; unreviewed emittable members %s must be listed in the ambiguous whitelist", path, concept.ID, strings.Join(visible, ","), strings.Join(unreviewed, ",")))
}
continue
}
for m := range eff {
if _, isReal := realByMorph[m]; isReal {
continue
}
if prev, ok := aliasMap[m]; ok && prev != canon {
problems = append(problems, fmt.Sprintf("command %q emitted %q reduces to both %q and %q", path, m, prev, canon))
continue
}
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
}
}
// (b) Command scoped aliases override concept reductions.
for emitted, target := range ov.ScopedAliases {
morphedEmitted := cmdutil.Morph(emitted)
reals, ok := realByMorph[cmdutil.Morph(target)]
if !ok {
problems = append(problems, fmt.Sprintf("command_override %q scoped alias %q->%q targets %q which is not a real flag", path, emitted, target, target))
continue
}
if _, sourceIsReal := realByMorph[morphedEmitted]; sourceIsReal {
problems = append(problems, fmt.Sprintf("command_override %q scoped alias source %q is already a real flag; keep its native compatibility path or remove it before enabling semantic rewrite", path, emitted))
continue
}
if pendingReview {
delete(aliasMap, morphedEmitted)
blockedSet[morphedEmitted] = true
continue
}
delete(blockedSet, morphedEmitted)
aliasMap[morphedEmitted] = canonicalRealName(reals)
}
// (c) Blocks are removed from the alias map and recorded for did-you-mean.
for _, b := range ov.Block {
mb := cmdutil.Morph(b)
if _, isReal := realByMorph[mb]; isReal {
problems = append(problems, fmt.Sprintf("command_override %q blocks %q but it is already a real flag; blocking must not disable a canonical/native parameter", path, b))
continue
}
delete(aliasMap, mb)
blockedSet[mb] = true
}
ambiguous := make([]string, 0, len(ov.Ambiguous))
for _, a := range ov.Ambiguous {
ma := cmdutil.Morph(a)
if _, isReal := realByMorph[ma]; isReal {
problems = append(problems, fmt.Sprintf("command_override %q marks %q ambiguous but it is already a real flag", path, a))
continue
}
if canon, ok := aliasMap[ma]; ok {
problems = append(problems, fmt.Sprintf("command %q name %q is both auto-reduced to %q and marked ambiguous; a name cannot be aliased and ambiguous at once", path, a, canon))
}
delete(aliasMap, ma)
delete(blockedSet, ma)
ambiguous = append(ambiguous, ma)
}
blocked := make([]string, 0, len(blockedSet))
for b := range blockedSet {
blocked = append(blocked, b)
}
if len(aliasMap) == 0 && len(blocked) == 0 && len(ambiguous) == 0 {
return nil, problems
}
return &ParamAliasEntry{
CLIPath: path,
Aliases: aliasMap,
Blocked: sortedUnique(blocked),
Ambiguous: sortedUnique(ambiguous),
}, problems
}
func conceptHasRealFlag(concept Concept, ov CommandOverride, realByMorph map[string][]realFlag) bool {
for _, member := range concept.Members {
if _, ok := realByMorph[cmdutil.Morph(member)]; ok {
return true
}
}
for boundFlag, conceptID := range ov.Bind {
if conceptID == concept.ID {
if _, ok := realByMorph[cmdutil.Morph(boundFlag)]; ok {
return true
}
}
}
return false
}
func appendRealFlag(list []realFlag, value realFlag) []realFlag {
for i, existing := range list {
if existing.name == value.name {
// Prefer the visible record if any registration is visible.
if existing.hidden && !value.hidden {
list[i] = value
}
return list
}
}
list = append(list, value)
sort.Slice(list, func(i, j int) bool { return list[i].name < list[j].name })
return list
}
// distinctRealNames returns the sorted unique flag names among candidates,
// optionally restricted to visible (non-hidden) flags.
func distinctRealNames(candidates []realFlag, visibleOnly bool) []string {
seen := make(map[string]bool, len(candidates))
out := make([]string, 0, len(candidates))
for _, c := range candidates {
if visibleOnly && c.hidden {
continue
}
if seen[c.name] {
continue
}
seen[c.name] = true
out = append(out, c.name)
}
sort.Strings(out)
return out
}
// canonicalRealName chooses the canonical target among real flags sharing a
// morph key, preferring a visible flag over a hidden legacy alias.
func canonicalRealName(reals []realFlag) string {
for _, r := range reals {
if !r.hidden {
return r.name
}
}
if len(reals) > 0 {
return reals[0].name
}
return ""
}
func sortedUnique(values []string) []string {
if len(values) == 0 {
return nil
}
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, v := range values {
if seen[v] {
continue
}
seen[v] = true
out = append(out, v)
}
sort.Strings(out)
return out
}
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
// 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 "sync"
// paramAliasIndex is the lazily built per-command view of the generated
// parameter-alias table, keyed by the same normalized CLI path the generator
// used. It is populated once; the generated slice never changes at runtime.
var (
paramAliasIndexOnce sync.Once
paramAliasIndex map[string]ParamAliasEntry
)
func buildParamAliasIndex() {
entries := loadGeneratedParamAliases()
paramAliasIndex = make(map[string]ParamAliasEntry, len(entries))
for _, e := range entries {
paramAliasIndex[e.CLIPath] = e
}
}
// LookupParamAlias resolves the reduced parameter-alias entry for a command.
//
// rawCommandPath is Cobra's CommandPath() (it still carries the "dws" prefix).
// It is normalized through normalizeSchemaCLIPath — the exact function the
// build-time generator used to key each entry — so the runtime lookup key is
// byte-identical to the generation key and there is zero mapping drift.
func LookupParamAlias(rawCommandPath string) (ParamAliasEntry, bool) {
paramAliasIndexOnce.Do(buildParamAliasIndex)
e, ok := paramAliasIndex[normalizeSchemaCLIPath(rawCommandPath)]
return e, ok
}
// ResolveAlias returns the canonical real flag a morphed emitted name reduces
// to, if this command aliases it. The caller is expected to pass an
// already-morphed name (cmdutil.Morph), matching how the table is keyed.
func (e ParamAliasEntry) ResolveAlias(morphed string) (string, bool) {
canon, ok := e.Aliases[morphed]
return canon, ok
}
// IsBlocked reports whether a morphed emitted name is on this command's block
// list: it must never be auto-rewritten and instead routes to did-you-mean.
func (e ParamAliasEntry) IsBlocked(morphed string) bool {
return containsParamAlias(e.Blocked, morphed)
}
// IsAmbiguous reports whether a morphed emitted name is on this command's
// reviewed co-occurrence whitelist: it is intentionally left unresolved so the
// runtime asks instead of guessing between two real flags.
func (e ParamAliasEntry) IsAmbiguous(morphed string) bool {
return containsParamAlias(e.Ambiguous, morphed)
}
func containsParamAlias(list []string, target string) bool {
for _, v := range list {
if v == target {
return true
}
}
return false
}
+518
View File
@@ -0,0 +1,518 @@
// 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 (
"errors"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
// realMap builds a per-leaf real-flag table keyed by the shared Morph so the
// tests exercise exactly the same intersection the generator performs.
func realMap(flags ...realFlag) map[string][]realFlag {
m := make(map[string][]realFlag)
for _, f := range flags {
k := cmdutil.Morph(f.name)
m[k] = appendRealFlag(m[k], f)
}
return m
}
// conceptFixture is a small synthetic concept set; reduceLeafParamAliases is
// deliberately pure so it can be tested without the whole Cobra tree.
func conceptFixture() []Concept {
return []Concept{
{ID: "pagination_size", CanonicalHint: "limit", Members: []string{"limit", "size", "page-size", "max-results"}},
{ID: "base_id", CanonicalHint: "base-id", Members: []string{"base", "base-id", "base-token"}},
{ID: "user_id", CanonicalHint: "user-id", Members: []string{"user", "users", "user-id", "uid"}},
}
}
func useParamConceptLoader(t *testing.T, concepts ParamConcepts, err error) {
t.Helper()
previous := loadReviewedParamConcepts
loadReviewedParamConcepts = func() (ParamConcepts, error) { return concepts, err }
t.Cleanup(func() { loadReviewedParamConcepts = previous })
}
func TestReduceParamAliasesLoadsAndValidatesSourceTree(t *testing.T) {
t.Run("load failure", func(t *testing.T) {
useParamConceptLoader(t, ParamConcepts{}, errors.New("fixture load"))
if _, err := ReduceParamAliases(&cobra.Command{Use: "dws"}); err == nil || !strings.Contains(err.Error(), "fixture load") {
t.Fatalf("ReduceParamAliases() error = %v", err)
}
})
t.Run("nil root", func(t *testing.T) {
useParamConceptLoader(t, ParamConcepts{Version: 1}, nil)
if _, err := ReduceParamAliases(nil); err == nil || !strings.Contains(err.Error(), "root is nil") {
t.Fatalf("ReduceParamAliases(nil) error = %v", err)
}
})
t.Run("real tree", func(t *testing.T) {
concepts := ParamConcepts{
Version: 1,
Concepts: []Concept{
{ID: "query", Members: []string{"query", "keyword"}, Commands: []string{"demo run"}},
{ID: "user_id", Members: []string{"user-id", "uid"}, Commands: []string{"demo run"}},
},
Overrides: []CommandOverride{
{CommandPath: "alpha", Block: []string{"unsafe"}},
{CommandPath: "demo run", Bind: map[string]string{"id": "user_id"}},
},
}
useParamConceptLoader(t, concepts, nil)
root := &cobra.Command{Use: "dws"}
alpha := &cobra.Command{Use: "alpha", Run: func(*cobra.Command, []string) {}}
alpha.Flags().String("name", "", "name")
demo := &cobra.Command{Use: "demo", Run: func(*cobra.Command, []string) {}}
run := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}}
run.Flags().String("query", "", "query")
run.Flags().String("id", "", "id")
demo.AddCommand(run)
root.AddCommand(alpha, demo)
root.AddCommand(&cobra.Command{Use: "help", Run: func(*cobra.Command, []string) {}})
root.AddCommand(&cobra.Command{Use: "hidden", Hidden: true, Run: func(*cobra.Command, []string) {}})
entries, err := ReduceParamAliases(root)
if err != nil {
t.Fatalf("ReduceParamAliases() error = %v", err)
}
if len(entries) != 2 || entries[0].CLIPath != "alpha" || entries[1].CLIPath != "demo run" {
t.Fatalf("entries = %#v", entries)
}
if entries[1].Aliases["keyword"] != "query" || entries[1].Aliases["uid"] != "id" {
t.Fatalf("demo aliases = %#v", entries[1].Aliases)
}
})
t.Run("stale and unbound review inputs", func(t *testing.T) {
concepts := ParamConcepts{
Version: 1,
Concepts: []Concept{
{ID: "missing", Members: []string{"missing"}, Commands: []string{"demo run"}},
{ID: "stale", Members: []string{"stale"}, Commands: []string{"ghost run"}},
},
Overrides: []CommandOverride{{CommandPath: "ghost run", Block: []string{"unsafe"}}},
}
useParamConceptLoader(t, concepts, nil)
root := &cobra.Command{Use: "dws"}
demo := &cobra.Command{Use: "demo"}
run := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}}
run.Flags().String("query", "", "query")
demo.AddCommand(run)
root.AddCommand(demo)
_, err := ReduceParamAliases(root)
for _, want := range []string{"has no matching real flag", "does not match any runnable Cobra leaf", "does not match any runnable Cobra command"} {
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("ReduceParamAliases() error = %v, want %q", err, want)
}
}
})
}
func TestParamAliasHelperEdges(t *testing.T) {
walkRunnableParamCommands(nil, func(*cobra.Command) { t.Fatal("nil root was visited") })
helpOnly := &cobra.Command{Use: "demo"}
helpOnly.Flags().String("help", "", "help")
if got := realFlagsByMorph(helpOnly); len(got) != 0 {
t.Fatalf("help flag entered the real parameter table: %#v", got)
}
flags := []realFlag{{name: "same", hidden: true}}
flags = appendRealFlag(flags, realFlag{name: "same"})
if len(flags) != 1 || flags[0].hidden {
t.Fatalf("visible duplicate did not replace hidden registration: %#v", flags)
}
flags = appendRealFlag(flags, realFlag{name: "same", hidden: true})
if len(flags) != 1 || flags[0].hidden {
t.Fatalf("hidden duplicate replaced visible registration: %#v", flags)
}
flags = appendRealFlag(flags, realFlag{name: "alpha"})
if !reflect.DeepEqual([]string{flags[0].name, flags[1].name}, []string{"alpha", "same"}) {
t.Fatalf("new real flags are not sorted: %#v", flags)
}
candidates := []realFlag{{name: "hidden", hidden: true}, {name: "visible"}, {name: "visible"}}
if got := distinctRealNames(candidates, true); !reflect.DeepEqual(got, []string{"visible"}) {
t.Fatalf("visible names = %v", got)
}
if got := canonicalRealName([]realFlag{{name: "hidden", hidden: true}}); got != "hidden" {
t.Fatalf("hidden-only canonical = %q", got)
}
if got := canonicalRealName(nil); got != "" {
t.Fatalf("empty canonical = %q", got)
}
if got := sortedUnique(nil); got != nil {
t.Fatalf("sortedUnique(nil) = %#v", got)
}
if got := sortedUnique([]string{"b", "a", "b"}); !reflect.DeepEqual(got, []string{"a", "b"}) {
t.Fatalf("sortedUnique() = %v", got)
}
real := realMap(realFlag{name: "query"}, realFlag{name: "id"})
if !conceptHasRealFlag(Concept{ID: "query", Members: []string{"query"}}, CommandOverride{}, real) {
t.Fatal("concept member did not match a real flag")
}
if !conceptHasRealFlag(Concept{ID: "user", Members: []string{"user-id"}}, CommandOverride{Bind: map[string]string{"id": "user"}}, real) {
t.Fatal("reviewed bind did not match a real flag")
}
if conceptHasRealFlag(Concept{ID: "user", Members: []string{"user-id"}}, CommandOverride{Bind: map[string]string{"missing": "user"}}, real) {
t.Fatal("missing reviewed bind matched a real flag")
}
}
func TestReduceLeafParamAliasesRemainingEdges(t *testing.T) {
t.Run("pending reviewed bind blocks non-real members", func(t *testing.T) {
entry, problems := reduceLeafParamAliases(
"demo cmd",
realMap(realFlag{name: "id"}),
[]Concept{{ID: "user_id", Members: []string{"user-id", "uid"}}},
CommandOverride{Bind: map[string]string{"id": "user_id"}, Investigate: true},
)
if len(problems) != 0 || entry == nil ||
!containsParamAlias(entry.Blocked, "user-id") || !containsParamAlias(entry.Blocked, "uid") {
t.Fatalf("pending bind entry = %#v, problems = %v", entry, problems)
}
})
t.Run("hidden-only canonical", func(t *testing.T) {
entry, problems := reduceLeafParamAliases(
"demo cmd",
realMap(realFlag{name: "query", hidden: true}),
[]Concept{{ID: "query", Members: []string{"query", "keyword"}}},
CommandOverride{},
)
if len(problems) != 0 || entry == nil || entry.Aliases["keyword"] != "query" {
t.Fatalf("hidden-only entry = %#v, problems = %v", entry, problems)
}
})
t.Run("multiple hidden candidates stay unresolved", func(t *testing.T) {
entry, problems := reduceLeafParamAliases(
"demo cmd",
realMap(realFlag{name: "first", hidden: true}, realFlag{name: "second", hidden: true}),
[]Concept{{ID: "choice", Members: []string{"first", "second", "choice"}}},
CommandOverride{},
)
if len(problems) != 0 || entry != nil {
t.Fatalf("multiple hidden candidates entry = %#v, problems = %v", entry, problems)
}
})
t.Run("two concepts cannot claim one emitted spelling", func(t *testing.T) {
_, problems := reduceLeafParamAliases(
"demo cmd",
realMap(realFlag{name: "first"}, realFlag{name: "second"}),
[]Concept{
{ID: "first", Members: []string{"first", "shared"}},
{ID: "second", Members: []string{"second", "shared"}},
},
CommandOverride{},
)
if len(problems) == 0 || !strings.Contains(strings.Join(problems, "\n"), "reduces to both") {
t.Fatalf("alias collision problems = %v", problems)
}
})
t.Run("unclaimed exclude becomes blocked", func(t *testing.T) {
entry, problems := reduceLeafParamAliases(
"demo cmd",
realMap(realFlag{name: "query"}),
[]Concept{{ID: "query", Members: []string{"query", "keyword"}, Excludes: []string{"name"}}},
CommandOverride{},
)
if len(problems) != 0 || entry == nil || !containsParamAlias(entry.Blocked, "name") {
t.Fatalf("exclude entry = %#v, problems = %v", entry, problems)
}
})
}
func TestParamAliasEntryLookupMethods(t *testing.T) {
entry := ParamAliasEntry{
Aliases: map[string]string{"uid": "user"},
Blocked: []string{"count"},
Ambiguous: []string{"user-id"},
}
if got, ok := entry.ResolveAlias("uid"); !ok || got != "user" {
t.Fatalf("ResolveAlias(uid) = %q, %v", got, ok)
}
if _, ok := entry.ResolveAlias("missing"); ok {
t.Fatal("ResolveAlias(missing) unexpectedly matched")
}
if !entry.IsBlocked("count") || entry.IsBlocked("missing") {
t.Fatalf("blocked lookup mismatch: %#v", entry.Blocked)
}
if !entry.IsAmbiguous("user-id") || entry.IsAmbiguous("missing") {
t.Fatalf("ambiguous lookup mismatch: %#v", entry.Ambiguous)
}
}
func TestReduceLeafParamAliasesAutoReduction(t *testing.T) {
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "limit"}), conceptFixture(), CommandOverride{})
if len(problems) != 0 {
t.Fatalf("unexpected problems: %v", problems)
}
if entry == nil {
t.Fatal("expected a reduced entry")
}
for _, emitted := range []string{"size", "page-size", "max-results"} {
if entry.Aliases[emitted] != "limit" {
t.Fatalf("alias %q = %q, want limit", emitted, entry.Aliases[emitted])
}
}
if _, ok := entry.Aliases["limit"]; ok {
t.Fatal("the real flag limit must never be an alias key")
}
}
func TestReduceLeafParamAliasesCoOccurrenceRequiresReview(t *testing.T) {
_, problems := reduceLeafParamAliases("demo cmd",
realMap(realFlag{name: "user"}, realFlag{name: "users"}), conceptFixture(), CommandOverride{})
if len(problems) == 0 {
t.Fatal("two visible real flags for one concept must fail without a reviewed ambiguous whitelist")
}
}
func TestReduceLeafParamAliasesAmbiguousWhitelist(t *testing.T) {
entry, problems := reduceLeafParamAliases("demo cmd",
realMap(realFlag{name: "user"}, realFlag{name: "users"}), conceptFixture(),
CommandOverride{CommandPath: "demo cmd", Ambiguous: []string{"user-id", "uid"}})
if len(problems) != 0 {
t.Fatalf("unexpected problems: %v", problems)
}
if entry == nil {
t.Fatal("expected a reduced entry")
}
if _, ok := entry.Aliases["user-id"]; ok {
t.Fatal("a reviewed co-occurrence must not auto-reduce its concept members")
}
if len(entry.Ambiguous) != 2 || entry.Ambiguous[0] != "uid" || entry.Ambiguous[1] != "user-id" {
t.Fatalf("ambiguous = %v, want sorted [uid user-id]", entry.Ambiguous)
}
}
// TestReduceLeafParamAliasesCoOccurrencePerConcept locks the per-concept guard:
// reviewing one concept's co-occurrence must not silently vouch for a second,
// unreviewed co-occurring concept on the same command. Here user_id (user +
// users) is whitelisted while base_id (base + base-id) is not, so base_id's
// unreviewed emittable member base-token must still fail generation.
func TestReduceLeafParamAliasesCoOccurrencePerConcept(t *testing.T) {
real := realMap(
realFlag{name: "user"}, realFlag{name: "users"},
realFlag{name: "base"}, realFlag{name: "base-id"},
)
_, problems := reduceLeafParamAliases("demo cmd", real, conceptFixture(),
CommandOverride{CommandPath: "demo cmd", Ambiguous: []string{"user-id", "uid"}})
if len(problems) == 0 {
t.Fatal("an unreviewed second co-occurring concept must fail even when another concept is whitelisted")
}
found := false
for _, p := range problems {
if strings.Contains(p, `concept "base_id"`) {
found = true
}
}
if !found {
t.Fatalf("expected a problem naming the unreviewed base_id concept, got: %v", problems)
}
}
// TestReduceLeafParamAliasesCoOccurrenceBothReviewed confirms the per-concept
// guard passes once every co-occurring concept's emittable members are listed.
func TestReduceLeafParamAliasesCoOccurrenceBothReviewed(t *testing.T) {
real := realMap(
realFlag{name: "user"}, realFlag{name: "users"},
realFlag{name: "base"}, realFlag{name: "base-id"},
)
_, problems := reduceLeafParamAliases("demo cmd", real, conceptFixture(),
CommandOverride{CommandPath: "demo cmd", Ambiguous: []string{"user-id", "uid", "base-token"}})
if len(problems) != 0 {
t.Fatalf("both concepts reviewed should pass: %v", problems)
}
}
// TestReduceLeafParamAliasesRejectsAliasAmbiguousOverlap locks the guard that a
// single name cannot be both auto-reduced and marked ambiguous. Here only
// --users is real, so user_id auto-reduces user-id to users; hand-listing
// user-id as ambiguous would produce a self-contradictory entry.
func TestReduceLeafParamAliasesRejectsAliasAmbiguousOverlap(t *testing.T) {
_, problems := reduceLeafParamAliases("demo cmd",
realMap(realFlag{name: "users"}), conceptFixture(),
CommandOverride{CommandPath: "demo cmd", Ambiguous: []string{"user-id"}})
if len(problems) == 0 {
t.Fatal("a name that both auto-reduces and is listed ambiguous must fail")
}
}
func TestReduceLeafParamAliasesAbsorbsHiddenLegacyAlias(t *testing.T) {
entry, problems := reduceLeafParamAliases("demo cmd",
realMap(realFlag{name: "base-id"}, realFlag{name: "base", hidden: true}), conceptFixture(), CommandOverride{})
if len(problems) != 0 {
t.Fatalf("a hidden legacy alias flag must not be a co-occurrence: %v", problems)
}
if entry == nil {
t.Fatal("expected a reduced entry")
}
if entry.Aliases["base-token"] != "base-id" {
t.Fatalf("base-token = %q, want base-id", entry.Aliases["base-token"])
}
if _, ok := entry.Aliases["base"]; ok {
t.Fatal("a real (hidden) flag must never be an alias key")
}
}
func TestReduceLeafParamAliasesBindGenericFlag(t *testing.T) {
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}), conceptFixture(),
CommandOverride{CommandPath: "demo cmd", Bind: map[string]string{"id": "base_id"}})
if len(problems) != 0 {
t.Fatalf("unexpected problems: %v", problems)
}
if entry.Aliases["base"] != "id" || entry.Aliases["base-id"] != "id" || entry.Aliases["base-token"] != "id" {
t.Fatalf("bind reduction wrong: %#v", entry.Aliases)
}
}
func TestReduceLeafParamAliasesBindRejectsNonRealFlag(t *testing.T) {
_, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}), conceptFixture(),
CommandOverride{CommandPath: "demo cmd", Bind: map[string]string{"missing": "base_id"}})
if len(problems) == 0 {
t.Fatal("binding a non-real flag must fail")
}
}
func TestReduceLeafParamAliasesScopedAlias(t *testing.T) {
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}), conceptFixture(),
CommandOverride{CommandPath: "demo cmd", ScopedAliases: map[string]string{"ding-id": "id"}})
if len(problems) != 0 {
t.Fatalf("unexpected problems: %v", problems)
}
if entry == nil || entry.Aliases[cmdutil.Morph("ding-id")] != "id" {
t.Fatalf("scoped alias not applied: %#v", entry)
}
}
func TestReduceLeafParamAliasesScopedAliasRejectsNonRealTarget(t *testing.T) {
_, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}), conceptFixture(),
CommandOverride{CommandPath: "demo cmd", ScopedAliases: map[string]string{"foo": "nonexistent"}})
if len(problems) == 0 {
t.Fatal("a scoped alias onto a non-real flag must fail")
}
}
func TestReduceLeafParamAliasesBlockRemovesAndRecords(t *testing.T) {
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "limit"}), conceptFixture(),
CommandOverride{CommandPath: "demo cmd", Block: []string{"size"}})
if len(problems) != 0 {
t.Fatalf("unexpected problems: %v", problems)
}
if entry == nil {
t.Fatal("expected a reduced entry")
}
if _, ok := entry.Aliases["size"]; ok {
t.Fatal("a blocked emitted name must be removed from the alias map")
}
found := false
for _, b := range entry.Blocked {
if b == "size" {
found = true
}
}
if !found {
t.Fatalf("size not recorded in blocked: %v", entry.Blocked)
}
}
func TestReduceLeafParamAliasesPendingReviewDoesNotEmit(t *testing.T) {
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "query"}), nil,
CommandOverride{CommandPath: "demo cmd", ScopedAliases: map[string]string{"keyword": "query"}, Confirm: true})
if len(problems) != 0 {
t.Fatalf("unexpected problems: %v", problems)
}
if entry == nil || entry.Aliases["keyword"] != "" || !containsParamAlias(entry.Blocked, "keyword") {
t.Fatalf("pending mapping entered automatic aliases: %#v", entry)
}
}
func TestReduceLeafParamAliasesExcludesProtectFuzzyButDoNotOverrideAnotherConcept(t *testing.T) {
concepts := []Concept{
{ID: "page_number", Members: []string{"page", "page-no"}, Excludes: []string{"page-size"}},
{ID: "page_size", Members: []string{"limit", "page-size"}},
}
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "page"}, realFlag{name: "limit"}), concepts, CommandOverride{})
if len(problems) != 0 {
t.Fatalf("unexpected problems: %v", problems)
}
if entry.Aliases["page-size"] != "limit" || containsParamAlias(entry.Blocked, "page-size") {
t.Fatalf("another reviewed concept alias was overridden by an exclude: %#v", entry)
}
}
func TestReduceLeafParamAliasesRejectsProtectionOrScopedAliasOnRealFlag(t *testing.T) {
real := realMap(realFlag{name: "user-id"}, realFlag{name: "user"})
for name, override := range map[string]CommandOverride{
"block": {CommandPath: "demo cmd", Block: []string{"user-id"}},
"ambiguous": {CommandPath: "demo cmd", Ambiguous: []string{"user-id"}},
"scoped": {CommandPath: "demo cmd", ScopedAliases: map[string]string{"user-id": "user"}},
} {
t.Run(name, func(t *testing.T) {
if _, problems := reduceLeafParamAliases("demo cmd", real, nil, override); len(problems) == 0 {
t.Fatal("real native flag was allowed to be reclassified")
}
})
}
}
// TestGeneratedParamAliasesAreWellFormed guards the committed generated table
// at the Go level, complementing the byte-identity drift gate.
func TestGeneratedParamAliasesAreWellFormed(t *testing.T) {
if len(generatedParamAliases) == 0 {
t.Fatal("generated parameter alias table is empty")
}
seen := make(map[string]bool, len(generatedParamAliases))
for _, e := range generatedParamAliases {
if e.CLIPath == "" {
t.Fatal("generated entry has an empty CLIPath")
}
if seen[e.CLIPath] {
t.Fatalf("duplicate CLIPath %q in generated table", e.CLIPath)
}
seen[e.CLIPath] = true
for emitted, canon := range e.Aliases {
if emitted != cmdutil.Morph(emitted) {
t.Fatalf("%s: alias key %q is not morph-normalized", e.CLIPath, emitted)
}
if canon == "" {
t.Fatalf("%s: alias %q has an empty target", e.CLIPath, emitted)
}
if emitted == canon {
t.Fatalf("%s: alias %q maps to itself", e.CLIPath, emitted)
}
}
classified := make(map[string]string, len(e.Aliases)+len(e.Blocked)+len(e.Ambiguous))
for emitted := range e.Aliases {
classified[emitted] = "alias"
}
for kind, values := range map[string][]string{"blocked": e.Blocked, "ambiguous": e.Ambiguous} {
for _, name := range values {
if name != cmdutil.Morph(name) {
t.Fatalf("%s: %s name %q is not morph-normalized", e.CLIPath, kind, name)
}
if previous := classified[name]; previous != "" {
t.Fatalf("%s: %q is classified as both %s and %s", e.CLIPath, name, previous, kind)
}
classified[name] = kind
}
}
}
}
+488
View File
@@ -0,0 +1,488 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package cli
import (
"bytes"
_ "embed"
"encoding/json"
"fmt"
"io"
"regexp"
"sort"
"strings"
"sync"
)
const paramConceptsSchemaRef = "./param_concepts.schema.json"
// param_concepts.json is the reviewed, typed parameter concept dictionary and
// the sole source of equivalent flag spellings ("concepts") plus per-command
// overrides. Build-time generators reduce these concepts against each command's
// real Cobra flags; generated alias tables are downstream views and must never
// be read back here.
//go:embed param_concepts.json
var embeddedParamConceptsJSON []byte
//go:embed param_concepts.schema.json
var embeddedParamConceptsSchemaJSON []byte
var (
paramConceptIDPattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
paramFlagTokenPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
paramCommandPathPattern = regexp.MustCompile(`^[A-Za-z0-9+][A-Za-z0-9._:+-]*$`)
)
// didYouMean sentinels are the only non-flag values a fixture case may expect.
const (
paramDidYouMeanAmbiguous = "did-you-mean:ambiguous"
paramDidYouMeanBlocked = "did-you-mean:blocked"
)
type paramConceptsSnapshot struct {
Schema string `json:"$schema"`
Version int `json:"version"`
MorphRules map[string]ParamMorphRule `json:"morphological_rules,omitempty"`
Concepts map[string]paramConceptSpec `json:"concepts"`
Overrides map[string]paramCommandOverride `json:"command_overrides,omitempty"`
Fixture *paramValidationFixtureSpec `json:"validation_fixture,omitempty"`
}
type paramConceptSpec struct {
Denotes string `json:"denotes"`
CanonicalHint string `json:"canonical_hint"`
Members []string `json:"members"`
Excludes []string `json:"excludes,omitempty"`
Commands []string `json:"commands"`
Risk string `json:"risk"`
}
type paramCommandOverride struct {
Bind map[string]string `json:"bind,omitempty"`
ScopedAliases map[string]string `json:"scoped_aliases,omitempty"`
Block []string `json:"block,omitempty"`
Ambiguous []string `json:"ambiguous,omitempty"`
Confirm bool `json:"confirm,omitempty"`
ScopeStrict bool `json:"scope_strict,omitempty"`
Investigate bool `json:"investigate,omitempty"`
Note string `json:"note,omitempty"`
}
type paramValidationFixtureSpec struct {
Cases []paramFixtureCaseSpec `json:"cases"`
}
type paramFixtureCaseSpec struct {
Command string `json:"command"`
Emitted string `json:"emitted"`
Expect string `json:"expect"`
Via string `json:"via,omitempty"`
Occ int `json:"occ,omitempty"`
}
// ParamMorphRule documents one table-free name normalization behavior. It is
// evidence for the shared Morph function; it is not a per-command alias.
type ParamMorphRule struct {
Desc string `json:"desc"`
Enabled bool `json:"enabled"`
Guard string `json:"guard,omitempty"`
Reason string `json:"reason,omitempty"`
}
// Concept is one reviewed set of equivalent flag spellings that all denote a
// single entity. Members reduce onto the command's real flag; Excludes lists
// spellings that denote a different entity and must never be reduced in.
type Concept struct {
ID string
Denotes string
CanonicalHint string
Members []string
Excludes []string
Commands []string
Risk string
}
// CommandOverride is one reviewed per-command adjustment: binding a generic
// real flag to a concept, command-scoped aliases, blocks, and the reviewed
// co-occurrence whitelist.
type CommandOverride struct {
CommandPath string
Bind map[string]string
ScopedAliases map[string]string
Block []string
Ambiguous []string
Confirm bool
ScopeStrict bool
Investigate bool
Note string
}
// ParamFixtureCase is one reviewed regression assertion derived from evaluation
// bad cases: the emitted name on Command must reduce to Expect (a real flag) or
// route to a did-you-mean sentinel.
type ParamFixtureCase struct {
Command string
Emitted string
Expect string
Via string
Occ int
}
// ParamConcepts is the decoded, validated reviewed concept dictionary.
type ParamConcepts struct {
Version int
Morph map[string]ParamMorphRule
Concepts []Concept
ByConcept map[string]Concept
Overrides []CommandOverride
Fixture []ParamFixtureCase
}
var (
embeddedParamConceptsOnce sync.Once
embeddedParamConceptsData ParamConcepts
embeddedParamConceptsErr error
loadReviewedParamConcepts = loadEmbeddedParamConcepts
)
// LoadParamConcepts decodes and validates the embedded reviewed concept
// dictionary exactly once.
func LoadParamConcepts() (ParamConcepts, error) {
return loadReviewedParamConcepts()
}
func loadEmbeddedParamConcepts() (ParamConcepts, error) {
embeddedParamConceptsOnce.Do(func() {
embeddedParamConceptsData, embeddedParamConceptsErr = decodeParamConcepts(embeddedParamConceptsJSON)
})
return cloneParamConcepts(embeddedParamConceptsData), embeddedParamConceptsErr
}
func decodeParamConcepts(data []byte) (ParamConcepts, error) {
var snapshot paramConceptsSnapshot
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&snapshot); err != nil {
return ParamConcepts{}, fmt.Errorf("decode reviewed parameter concepts: %w", err)
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
err = fmt.Errorf("multiple JSON values")
}
return ParamConcepts{}, fmt.Errorf("decode reviewed parameter concepts: %w", err)
}
if snapshot.Version != 1 {
return ParamConcepts{}, fmt.Errorf("unsupported parameter concepts version %d", snapshot.Version)
}
if strings.TrimSpace(snapshot.Schema) != paramConceptsSchemaRef {
return ParamConcepts{}, fmt.Errorf("parameter concepts must declare $schema=%q", paramConceptsSchemaRef)
}
if len(snapshot.Concepts) == 0 {
return ParamConcepts{}, fmt.Errorf("parameter concepts declares no concepts")
}
concepts, byConcept, err := decodeParamConceptSpecs(snapshot.Concepts)
if err != nil {
return ParamConcepts{}, err
}
overrides, err := decodeParamCommandOverrides(snapshot.Overrides, byConcept)
if err != nil {
return ParamConcepts{}, err
}
fixture, err := decodeParamFixtureCases(snapshot.Fixture)
if err != nil {
return ParamConcepts{}, err
}
morph := make(map[string]ParamMorphRule, len(snapshot.MorphRules))
for name, rule := range snapshot.MorphRules {
if strings.TrimSpace(rule.Desc) == "" {
return ParamConcepts{}, fmt.Errorf("parameter concepts morph rule %q has empty desc", name)
}
morph[name] = rule
}
return ParamConcepts{
Version: snapshot.Version,
Morph: morph,
Concepts: concepts,
ByConcept: byConcept,
Overrides: overrides,
Fixture: fixture,
}, nil
}
// decodeParamConceptSpecs validates every concept and enforces two purity
// invariants: members are unique across all concepts, and no member appears in
// its own excludes list.
func decodeParamConceptSpecs(specs map[string]paramConceptSpec) ([]Concept, map[string]Concept, error) {
ids := make([]string, 0, len(specs))
for id := range specs {
ids = append(ids, id)
}
sort.Strings(ids)
concepts := make([]Concept, 0, len(ids))
byConcept := make(map[string]Concept, len(ids))
memberOwner := make(map[string]string)
for _, id := range ids {
if !paramConceptIDPattern.MatchString(id) {
return nil, nil, fmt.Errorf("parameter concepts contains invalid concept id %q", id)
}
spec := specs[id]
if strings.TrimSpace(spec.Denotes) == "" {
return nil, nil, fmt.Errorf("concept %s has empty denotes", id)
}
if !paramFlagTokenPattern.MatchString(spec.CanonicalHint) {
return nil, nil, fmt.Errorf("concept %s has invalid canonical_hint %q", id, spec.CanonicalHint)
}
switch spec.Risk {
case "green", "yellow":
default:
return nil, nil, fmt.Errorf("concept %s has invalid risk %q", id, spec.Risk)
}
if len(spec.Members) == 0 {
return nil, nil, fmt.Errorf("concept %s has no members", id)
}
if len(spec.Commands) == 0 {
return nil, nil, fmt.Errorf("concept %s has no reviewed command scope", id)
}
members := make([]string, 0, len(spec.Members))
memberSet := make(map[string]bool, len(spec.Members))
for _, member := range spec.Members {
if !paramFlagTokenPattern.MatchString(member) {
return nil, nil, fmt.Errorf("concept %s has invalid member %q", id, member)
}
if memberSet[member] {
return nil, nil, fmt.Errorf("concept %s repeats member %q", id, member)
}
memberSet[member] = true
if owner, exists := memberOwner[member]; exists {
return nil, nil, fmt.Errorf("member %q belongs to both concept %s and %s", member, owner, id)
}
memberOwner[member] = id
members = append(members, member)
}
excludes := make([]string, 0, len(spec.Excludes))
excludeSet := make(map[string]bool, len(spec.Excludes))
for _, exclude := range spec.Excludes {
if !paramFlagTokenPattern.MatchString(exclude) {
return nil, nil, fmt.Errorf("concept %s has invalid exclude %q", id, exclude)
}
if excludeSet[exclude] {
return nil, nil, fmt.Errorf("concept %s repeats exclude %q", id, exclude)
}
excludeSet[exclude] = true
if memberSet[exclude] {
return nil, nil, fmt.Errorf("concept %s lists %q as both member and exclude", id, exclude)
}
excludes = append(excludes, exclude)
}
commands := make([]string, 0, len(spec.Commands))
commandSet := make(map[string]bool, len(spec.Commands))
for _, command := range spec.Commands {
if !validParamCommandPath(command) {
return nil, nil, fmt.Errorf("concept %s has invalid command scope %q", id, command)
}
if commandSet[command] {
return nil, nil, fmt.Errorf("concept %s repeats command scope %q", id, command)
}
commandSet[command] = true
commands = append(commands, command)
}
sort.Strings(commands)
concept := Concept{
ID: id,
Denotes: strings.TrimSpace(spec.Denotes),
CanonicalHint: spec.CanonicalHint,
Members: members,
Excludes: excludes,
Commands: commands,
Risk: spec.Risk,
}
concepts = append(concepts, concept)
byConcept[id] = concept
}
return concepts, byConcept, nil
}
func decodeParamCommandOverrides(specs map[string]paramCommandOverride, byConcept map[string]Concept) ([]CommandOverride, error) {
paths := make([]string, 0, len(specs))
for path := range specs {
paths = append(paths, path)
}
sort.Strings(paths)
overrides := make([]CommandOverride, 0, len(paths))
for _, path := range paths {
if !validParamCommandPath(path) {
return nil, fmt.Errorf("command_overrides contains invalid command path %q", path)
}
spec := specs[path]
if len(spec.Bind) == 0 && len(spec.ScopedAliases) == 0 && len(spec.Block) == 0 && len(spec.Ambiguous) == 0 {
return nil, fmt.Errorf("command_override %q declares no bind/scoped_aliases/block/ambiguous", path)
}
for flag, conceptID := range spec.Bind {
if !paramFlagTokenPattern.MatchString(flag) {
return nil, fmt.Errorf("command_override %q bind has invalid flag %q", path, flag)
}
if _, ok := byConcept[conceptID]; !ok {
return nil, fmt.Errorf("command_override %q binds %q to undeclared concept %q", path, flag, conceptID)
}
}
for emitted, realFlag := range spec.ScopedAliases {
if !paramFlagTokenPattern.MatchString(emitted) {
return nil, fmt.Errorf("command_override %q scoped_aliases has invalid emitted %q", path, emitted)
}
if !paramFlagTokenPattern.MatchString(realFlag) {
return nil, fmt.Errorf("command_override %q scoped_aliases has invalid target %q", path, realFlag)
}
}
if err := validParamTokenList(path, "block", spec.Block); err != nil {
return nil, err
}
if err := validParamTokenList(path, "ambiguous", spec.Ambiguous); err != nil {
return nil, err
}
overrides = append(overrides, CommandOverride{
CommandPath: path,
Bind: cloneStringMap(spec.Bind),
ScopedAliases: cloneStringMap(spec.ScopedAliases),
Block: append([]string(nil), spec.Block...),
Ambiguous: append([]string(nil), spec.Ambiguous...),
Confirm: spec.Confirm,
ScopeStrict: spec.ScopeStrict,
Investigate: spec.Investigate,
Note: strings.TrimSpace(spec.Note),
})
}
return overrides, nil
}
func decodeParamFixtureCases(spec *paramValidationFixtureSpec) ([]ParamFixtureCase, error) {
if spec == nil {
return nil, nil
}
if len(spec.Cases) == 0 {
return nil, fmt.Errorf("validation_fixture declares no cases")
}
cases := make([]ParamFixtureCase, 0, len(spec.Cases))
for i, c := range spec.Cases {
if !validParamCommandPath(c.Command) {
return nil, fmt.Errorf("validation_fixture case %d has invalid command %q", i, c.Command)
}
if !paramFlagTokenPattern.MatchString(c.Emitted) {
return nil, fmt.Errorf("validation_fixture case %d has invalid emitted %q", i, c.Emitted)
}
expect := strings.TrimSpace(c.Expect)
if expect == "" {
return nil, fmt.Errorf("validation_fixture case %d has empty expect", i)
}
if strings.HasPrefix(expect, "did-you-mean:") {
if expect != paramDidYouMeanAmbiguous && expect != paramDidYouMeanBlocked {
return nil, fmt.Errorf("validation_fixture case %d has unknown did-you-mean sentinel %q", i, expect)
}
} else if !paramFlagTokenPattern.MatchString(expect) {
return nil, fmt.Errorf("validation_fixture case %d has invalid expect %q", i, expect)
}
if c.Occ < 0 {
return nil, fmt.Errorf("validation_fixture case %d has negative occ %d", i, c.Occ)
}
cases = append(cases, ParamFixtureCase{
Command: c.Command,
Emitted: c.Emitted,
Expect: expect,
Via: strings.TrimSpace(c.Via),
Occ: c.Occ,
})
}
return cases, nil
}
func validParamCommandPath(path string) bool {
if strings.TrimSpace(path) != path || path == "" {
return false
}
for _, token := range strings.Split(path, " ") {
if !paramCommandPathPattern.MatchString(token) {
return false
}
}
return true
}
func validParamTokenList(path, field string, tokens []string) error {
seen := make(map[string]bool, len(tokens))
for _, token := range tokens {
if !paramFlagTokenPattern.MatchString(token) {
return fmt.Errorf("command_override %q %s has invalid token %q", path, field, token)
}
if seen[token] {
return fmt.Errorf("command_override %q %s repeats token %q", path, field, token)
}
seen[token] = true
}
return nil
}
func cloneParamConcepts(src ParamConcepts) ParamConcepts {
dst := ParamConcepts{Version: src.Version}
if src.Morph != nil {
dst.Morph = make(map[string]ParamMorphRule, len(src.Morph))
for k, v := range src.Morph {
dst.Morph[k] = v
}
}
if src.Concepts != nil {
dst.Concepts = make([]Concept, 0, len(src.Concepts))
for _, c := range src.Concepts {
dst.Concepts = append(dst.Concepts, cloneConcept(c))
}
}
if src.ByConcept != nil {
dst.ByConcept = make(map[string]Concept, len(src.ByConcept))
for k, v := range src.ByConcept {
dst.ByConcept[k] = cloneConcept(v)
}
}
if src.Overrides != nil {
dst.Overrides = make([]CommandOverride, 0, len(src.Overrides))
for _, o := range src.Overrides {
dst.Overrides = append(dst.Overrides, cloneCommandOverride(o))
}
}
if src.Fixture != nil {
dst.Fixture = append([]ParamFixtureCase(nil), src.Fixture...)
}
return dst
}
func cloneConcept(c Concept) Concept {
c.Members = append([]string(nil), c.Members...)
c.Excludes = append([]string(nil), c.Excludes...)
c.Commands = append([]string(nil), c.Commands...)
return c
}
func cloneCommandOverride(o CommandOverride) CommandOverride {
o.Bind = cloneStringMap(o.Bind)
o.ScopedAliases = cloneStringMap(o.ScopedAliases)
o.Block = append([]string(nil), o.Block...)
o.Ambiguous = append([]string(nil), o.Ambiguous...)
return o
}
func cloneStringMap(src map[string]string) map[string]string {
if src == nil {
return nil
}
dst := make(map[string]string, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
+261
View File
@@ -0,0 +1,261 @@
{
"$schema": "./param_concepts.schema.json",
"version": 1,
"morphological_rules": {
"kebab_camel_equivalence": {"desc": "--page-size == --pageSize", "enabled": true},
"separator_normalization": {"desc": "-, _, . are equivalent separators", "enabled": true},
"trailing_id_tolerance": {"desc": "--base tolerates --base-id when only one is a real flag on the command", "enabled": true, "guard": "the two must not both be real flags with different semantics"},
"pluralization": {"desc": "--id<->--ids, --user<->--users", "enabled": false, "reason": "singular/list semantics can differ; handled by concept+intersection or command override instead"}
},
"concepts": {
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +template-search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "mail thread list", "oa +list-executed"], "risk": "green"},
"page_number": {"denotes": "one-based page number", "canonical_hint": "page", "members": ["page", "page-no", "current-page", "page-num"], "excludes": ["cursor", "page-index", "page-size", "page-token"], "commands": ["devdoc article search"], "risk": "green"},
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list"], "risk": "green"},
"content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc block insert", "doc block update"], "risk": "green"},
"time_start": {"denotes": "start time point with unchanged value format and unit", "canonical_hint": "start", "members": ["start", "start-time", "start-date", "from", "from-date", "begin", "since", "time-min", "min-time"], "excludes": ["date", "time", "end"], "commands": ["calendar event list", "chat message list-all", "report list"], "risk": "yellow"},
"time_end": {"denotes": "end time point with unchanged value format and unit", "canonical_hint": "end", "members": ["end", "end-time", "end-date", "time-max", "max-time"], "excludes": ["date", "time", "start"], "commands": ["calendar event list"], "risk": "yellow"},
"base_id": {"denotes": "multi-dimensional table Base id", "canonical_hint": "base-id", "members": ["base", "base-id", "base-token"], "excludes": [], "commands": ["aitable +field-get", "aitable +list-tables", "aitable +record-query", "aitable +record-share-url", "aitable +table-get"], "risk": "green"},
"dept_id": {"denotes": "single department id", "canonical_hint": "dept", "members": ["dept", "dept-id", "department", "department-id", "parent", "parent-id"], "excludes": ["depts", "dept-ids", "department-ids", "name", "query"], "commands": ["contact +list-sub-depts", "contact dept list-children"], "risk": "yellow"},
"dept_ids": {"denotes": "department id list", "canonical_hint": "dept-ids", "members": ["depts", "dept-ids", "department-ids"], "excludes": ["dept", "dept-id", "department-id", "name", "query"], "commands": ["contact +list-dept-members"], "risk": "yellow"},
"group_id": {"denotes": "single DingTalk numeric groupId", "canonical_hint": "group-id", "members": ["group-id"], "excludes": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "commands": ["chat group get-by-group-id"], "risk": "yellow"},
"open_conversation_id": {"denotes": "single DingTalk openConversationId with unchanged value", "canonical_hint": "conversation-id", "members": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id"], "excludes": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids", "group-name", "name", "id", "source", "target", "src-conversation-id", "dest-conversation-id"], "commands": ["chat +chat-bots", "chat +chat-dismiss", "chat +chat-invite-url", "chat +chat-mute", "chat +chat-role-add", "chat +chat-role-list", "chat +chat-role-query-user", "chat +chat-role-set-user", "chat +chat-role-update", "chat +chat-set-admin", "chat +chat-set-history", "chat +chat-update-alias", "chat +chat-update-nick", "chat +conversation-info", "chat +messages-list-pin", "chat +messages-read-status", "chat category add-conv", "chat category remove-conv", "chat chmod", "chat clear-messages", "chat clear-red-point", "chat conversation-info", "chat group audit-join-validation", "chat group bots", "chat group dismiss", "chat group invite-url", "chat group members", "chat group members add", "chat group members add-bot", "chat group members list-by-ids", "chat group members remove", "chat group members remove-bot", "chat group notice create", "chat group notice edit", "chat group notice get", "chat group notice list", "chat group quit", "chat group rename", "chat group set-admin", "chat group set-history", "chat group transfer-owner", "chat group update-alias", "chat group update-icon", "chat group update-nick", "chat group update-settings", "chat group-mute", "chat group-mute-member", "chat group-role add", "chat group-role list", "chat group-role query-user", "chat group-role remove", "chat group-role remove-user", "chat group-role set-user", "chat group-role update", "chat hide", "chat mark-read", "chat mark-unread", "chat message add-favorite", "chat message download-media", "chat message list", "chat message list-mentions", "chat message list-pin-msg", "chat message list-topic-replies", "chat message read-status", "chat message recall", "chat message recall-by-bot", "chat message remove-favorite", "chat message reply", "chat message search", "chat message send", "chat message send-by-bot", "chat message send-card", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg", "chat mute-at-all", "chat mute-red-envelope", "chat set-top"], "risk": "yellow"},
"open_conversation_ids": {"denotes": "DingTalk openConversationId list with unchanged element values", "canonical_hint": "conversation-ids", "members": ["conversation-ids", "open-conversation-ids", "groups"], "excludes": ["group-id", "group-ids", "conversation-id", "open-conversation-id", "chat-id"], "commands": ["chat message search-advanced"], "risk": "yellow"},
"group_name": {"denotes": "group-name search keyword, not a group identifier", "canonical_hint": "group-name", "members": ["group-name"], "excludes": ["group-id", "conversation-id", "open-conversation-id", "chat-id", "id"], "commands": ["chat +group-members", "chat +send-to-group"], "risk": "yellow"},
"open_message_id": {"denotes": "single DingTalk openMessageId with unchanged value", "canonical_hint": "open-message-id", "members": ["msg-id", "message-id", "open-message-id"], "excludes": ["msg-ids", "message-ids", "open-message-ids", "ref-msg-id", "src-msg-id", "open-task-id", "topic-id", "resource-id"], "commands": ["chat +messages-read-status", "chat mark-read", "chat message add-emoji", "chat message add-favorite", "chat message add-text-emotion", "chat message download-media", "chat message forward", "chat message read-status", "chat message recall", "chat message remove-emoji", "chat message remove-favorite", "chat message remove-text-emotion", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg"], "risk": "yellow"},
"open_message_ids": {"denotes": "DingTalk openMessageId list with unchanged element values", "canonical_hint": "msg-ids", "members": ["msg-ids", "message-ids", "open-message-ids"], "excludes": ["msg-id", "message-id", "open-message-id", "ref-msg-id", "src-msg-id"], "commands": ["chat +messages-mget", "chat message combine-forward", "chat message list-by-ids", "chat message list-emotion-replies"], "risk": "yellow"},
"referenced_open_message_id": {"denotes": "referenced DingTalk openMessageId in a reply", "canonical_hint": "ref-msg-id", "members": ["ref-msg-id", "ref-message-id"], "excludes": ["msg-id", "message-id", "open-message-id", "msg-ids", "src-msg-id"], "commands": ["chat message reply"], "risk": "yellow"},
"user_id": {"denotes": "single user id", "canonical_hint": "user-id", "members": ["user", "user-id", "userid", "uid", "staff-id"], "excludes": ["at-user-ids", "to-user", "users", "user-ids", "name"], "commands": ["chat +chat-role-query-user", "chat +chat-role-set-user", "chat +messages-list-direct", "chat chmod", "chat conversation-info", "chat group transfer-owner", "chat group-role query-user", "chat group-role remove-user", "chat group-role set-user", "chat message list", "chat message send", "contact user profile get"], "risk": "yellow"},
"user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"},
"open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"},
"ding_id": {"denotes": "DING id", "canonical_hint": "ding-id", "members": ["ding-id", "open-ding-id"], "excludes": ["id"], "commands": ["ding message receiver-status"], "risk": "yellow"},
"folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive list", "mail folder update"], "risk": "green"},
"space_id": {"denotes": "drive/wiki space id", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["drive info"], "risk": "yellow"},
"app_id": {"denotes": "application id", "canonical_hint": "unified-app-id", "members": ["app-id", "unified-app-id", "application-id"], "excludes": ["app-key", "app-secret", "agent-id"], "commands": ["dev app get"], "risk": "yellow"},
"robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id"], "commands": ["chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"},
"open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat group members remove-bot"], "risk": "yellow"}
},
"command_overrides": {
"chat group rename": {"bind": {"id": "open_conversation_id"}, "note": "This command's real --id carries one openConversationId; aliases reduce to --id without changing the value."},
"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."},
"drive list": {"ambiguous": ["space"], "note": "both --space-id and native compatibility --workspace-id/--workspace exist; bare --space cannot choose one"},
"drive upload": {"ambiguous": ["space"], "note": "both --space-id and native compatibility --workspace-id/--workspace exist; bare --space cannot choose one"},
"ding +receiver-status": {"scoped_aliases": {"id": "ding-id"}, "note": "generic id reduces to ding-id"},
"ding message receiver-status": {"scoped_aliases": {"id": "ding-id"}},
"contact user profile get": {"scoped_aliases": {"id": "staff-id", "ids": "staff-id"}, "note": "user-id is reduced by the user_id concept; generic id/ids bound explicitly"},
"mail folder update": {"bind": {"id": "folder_id"}, "note": "this command's --id is the folder id; --folder-id reduces to --id"},
"mail message search": {"scoped_aliases": {"subject": "query"}, "scope_strict": true, "note": "never globalize: mail template create has a real and different --subject"},
"calendar event list": {"scoped_aliases": {"date": "start"}, "note": "reviewed against ParseISOTimeToMillis and final list_calendar_events payload; --date is normalized centrally while the command's existing hidden compatibility flags remain native fallbacks"},
"chat +bot-find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search shortcut, --name and --query denote the same search keyword; --name must not become a global search alias."},
"chat bot find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search command, --name and --query denote the same search keyword; --name must not become a global search alias."},
"chat +bot-search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."},
"chat bot search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."},
"chat message list-favorites": {"scoped_aliases": {"limit": "size"}, "scope_strict": true, "note": "On this exact command, both names denote the same bounded result count; the numeric value is unchanged."},
"chat +messages-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 +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-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 +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."},
"chat +category-rename": {"scoped_aliases": {"name": "title"}, "block": ["category-ids"], "scope_strict": true, "note": "The display-name alias is exact; a category-id list is not accepted where one category is required."},
"chat category rename": {"scoped_aliases": {"name": "title"}, "block": ["category-ids"], "scope_strict": true, "note": "The display-name alias is exact; a category-id list is not accepted where one category is required."},
"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 +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."},
"chat +chat-role-set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
"chat group-role remove-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
"chat group-role set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
"chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."},
"chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."},
"doc block insert": {"block": ["before-block-id"], "note": "requires a two-parameter conversion to --ref-block plus --where before; name-only normalization would silently default to after"},
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."},
"doc +export-get": {"block": ["node"], "note": "node is a document node id, a different entity from job-id"},
"doc block delete": {"block": ["index"], "note": "index (position) vs node (node id) are different"},
"report outbox list": {"block": ["template-type"], "note": "type vs name are different fields"},
"chat group members add-bot": {"bind": {"id": "open_conversation_id"}},
"chat group members list-by-ids": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["user-id", "user-ids"], "note": "This command's --id carries openConversationId, while --users carries an openDingTalkId list."},
"chat group members remove-bot": {"bind": {"id": "open_conversation_id"}},
"chat +send-to-group": {"bind": {"group": "group_name"}, "note": "The real --group is a group-name search keyword on this shortcut, not an identifier."},
"chat group share-invite": {"scoped_aliases": {"source-conversation-id": "source", "target-conversation-id": "target"}, "block": ["group-id", "group-ids", "user", "user-id", "userid", "uid", "staff-id"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "note": "A role-free conversation identifier cannot choose between source and target; --receiver requires openDingTalkId and must not accept userId spellings."},
"chat message combine-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "source-conversation-id": "src-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "note": "Source and destination conversation roles are preserved; a role-free identifier is ambiguous."},
"chat message forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "source-conversation-id": "src-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "note": "Source and destination conversation roles are preserved; a role-free identifier is ambiguous."},
"chat message forward-topic": {"scoped_aliases": {"src-open-conversation-id": "src-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "source-conversation-id": "src-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id", "src-open-message-id": "src-msg-id", "source-message-id": "src-msg-id"}, "block": ["group-id", "group-ids", "msg-id", "message-id", "open-message-id"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "note": "Conversation and message source/destination roles are preserved; role-free identifiers are rejected."},
"chat +conversation-info": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "This shortcut accepts --open-dingtalk-id, not userId; use stable chat conversation-info when userId resolution is needed."},
"chat group create": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed identifier domains."},
"chat +chat-set-admin": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a mixed userId/openDingTalkId list."},
"chat +messages-read-status": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a mixed userId/openDingTalkId list."},
"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."}
},
"validation_fixture": {
"cases": [
{"command": "oa +search-forms", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 28},
{"command": "aitable +list-tables", "emitted": "base-id", "expect": "base", "via": "concept:base_id+morph", "occ": 26},
{"command": "mail +find-mail-user", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 18},
{"command": "aitable +field-get", "emitted": "base", "expect": "base-id", "via": "concept:base_id+morph", "occ": 8},
{"command": "aitable +record-query", "emitted": "base", "expect": "base-id", "via": "concept:base_id+morph", "occ": 8},
{"command": "aitable +table-get", "emitted": "base", "expect": "base-id", "via": "concept:base_id+morph", "occ": 8},
{"command": "doc block update", "emitted": "content", "expect": "text", "via": "concept:content_text", "occ": 6},
{"command": "contact +resolve-dept", "emitted": "query", "expect": "name", "via": "concept:search_query+bind", "occ": 4},
{"command": "devdoc article search", "emitted": "limit", "expect": "size", "via": "concept:pagination_size", "occ": 2},
{"command": "devdoc article search", "emitted": "page-size", "expect": "size", "via": "concept:pagination_size", "occ": 2},
{"command": "devdoc article search", "emitted": "current-page", "expect": "page", "via": "concept:page_number", "occ": 2},
{"command": "mail message search", "emitted": "subject", "expect": "query", "via": "override:scoped_strict", "occ": 4},
{"command": "aitable +record-share-url", "emitted": "base", "expect": "base-id", "via": "concept:base_id+morph", "occ": 3},
{"command": "aitable record query", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size", "occ": 2},
{"command": "calendar event list", "emitted": "date", "expect": "start", "via": "override:scoped(reviewed+payload)", "occ": 2},
{"command": "calendar event list", "emitted": "start-time", "expect": "start", "via": "native:reviewed-compatibility-fallback", "occ": 2},
{"command": "calendar event list", "emitted": "min-time", "expect": "start", "via": "native:reviewed-compatibility-fallback", "occ": 2},
{"command": "calendar event list", "emitted": "time-min", "expect": "start", "via": "native:reviewed-compatibility-fallback", "occ": 2},
{"command": "calendar event list", "emitted": "end-time", "expect": "end", "via": "native:reviewed-compatibility-fallback", "occ": 2},
{"command": "calendar event list", "emitted": "time-max", "expect": "end", "via": "native:reviewed-compatibility-fallback", "occ": 2},
{"command": "calendar event list", "emitted": "max-results", "expect": "limit", "via": "native:reviewed-compatibility-fallback", "occ": 2},
{"command": "calendar event list", "emitted": "next-cursor", "expect": "cursor", "via": "native:reviewed-compatibility-fallback", "occ": 2},
{"command": "calendar event list", "emitted": "calendar", "expect": "calendar-id", "via": "native:reviewed-compatibility-fallback", "occ": 2},
{"command": "chat message list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size", "occ": 2},
{"command": "chat message list-by-sender", "emitted": "time", "expect": "did-you-mean:blocked", "via": "guard:time-format-boundary", "occ": 2},
{"command": "doc +template-search", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 2},
{"command": "doc block insert", "emitted": "content", "expect": "text", "via": "concept:content_text", "occ": 2},
{"command": "drive list", "emitted": "folder-id", "expect": "folder", "via": "concept:folder_id+morph", "occ": 2},
{"command": "mail thread list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size", "occ": 2},
{"command": "mail user search", "emitted": "query", "expect": "keyword", "via": "concept:search_query", "occ": 2},
{"command": "oa +list-executed", "emitted": "take", "expect": "limit", "via": "concept:pagination_size", "occ": 2},
{"command": "oa approval search-forms", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 2},
{"command": "report list", "emitted": "from-date", "expect": "start", "via": "concept:time_start", "occ": 2},
{"command": "aitable +base-search", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1},
{"command": "contact +search-user", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1},
{"command": "chat group rename", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 31},
{"command": "ding +receiver-status", "emitted": "id", "expect": "ding-id", "via": "override:scoped(ding_id)", "occ": 24},
{"command": "chat group members", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 8},
{"command": "contact +list-sub-depts", "emitted": "dept-id", "expect": "dept", "via": "concept:dept_id+morph", "occ": 4},
{"command": "contact +list-sub-depts", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:name-vs-id", "occ": 2},
{"command": "contact +list-sub-depts", "emitted": "query", "expect": "did-you-mean:blocked", "via": "guard:query-vs-id", "occ": 2},
{"command": "contact user profile get", "emitted": "user-id", "expect": "staff-id", "via": "concept:user_id", "occ": 2},
{"command": "contact user profile get", "emitted": "id", "expect": "staff-id", "via": "override:scoped", "occ": 2},
{"command": "contact user profile get", "emitted": "ids", "expect": "staff-id", "via": "override:scoped", "occ": 2},
{"command": "chat message send-by-bot", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list", "occ": 4},
{"command": "chat message send-by-bot", "emitted": "to-user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list", "occ": 1},
{"command": "dev app get", "emitted": "app-id", "expect": "unified-app-id", "via": "concept:app_id", "occ": 5},
{"command": "chat message list-all", "emitted": "from", "expect": "start", "via": "concept:time_start", "occ": 2},
{"command": "chat message list-all", "emitted": "start-time", "expect": "start", "via": "concept:time_start", "occ": 2},
{"command": "chat message search-advanced", "emitted": "group", "expect": "conversation-ids", "via": "native:reviewed-single-to-list", "occ": 4},
{"command": "chat message send", "emitted": "to-user", "expect": "user", "via": "override:scoped(reviewed+payload)", "occ": 4},
{"command": "contact +dept-members", "emitted": "name", "expect": "dept", "via": "override:scoped(reviewed)", "occ": 2},
{"command": "contact +dept-members", "emitted": "query", "expect": "dept", "via": "concept:search_query+bind", "occ": 2},
{"command": "contact dept list-children", "emitted": "parent-id", "expect": "dept", "via": "concept:dept_id", "occ": 2},
{"command": "contact dept list-children", "emitted": "parent", "expect": "dept", "via": "concept:dept_id", "occ": 2},
{"command": "ding message receiver-status", "emitted": "id", "expect": "ding-id", "via": "override:scoped(ding_id)", "occ": 2},
{"command": "ding message receiver-status", "emitted": "open-ding-id", "expect": "ding-id", "via": "concept:ding_id", "occ": 2},
{"command": "chat group members add", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 3},
{"command": "attendance +check-result", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "concept:user_ids+exclude", "occ": 2},
{"command": "attendance check result", "emitted": "user-ids", "expect": "users", "via": "concept:user_ids", "occ": 2},
{"command": "chat group members remove", "emitted": "group", "expect": "id", "via": "override:bind(open_conversation_id)", "occ": 2},
{"command": "chat group set-admin", "emitted": "user-id", "expect": "user", "via": "native:reviewed-compatibility-alias", "occ": 2},
{"command": "ding message send", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code", "occ": 2},
{"command": "doc +export-get", "emitted": "node", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
{"command": "doc block delete", "emitted": "index", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
{"command": "doc block insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-multi-parameter-transform", "occ": 2},
{"command": "drive info", "emitted": "workspace", "expect": "space-id", "via": "concept:space_id", "occ": 2},
{"command": "mail folder update", "emitted": "folder-id", "expect": "id", "via": "override:bind(folder_id)", "occ": 2},
{"command": "report outbox list", "emitted": "template-type", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
{"command": "chat +group-members", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"},
{"command": "chat group get-by-group-id", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:open-conversation-id-vs-group-id"},
{"command": "chat group get-by-group-id", "emitted": "id", "expect": "did-you-mean:blocked", "via": "guard:generic-id-vs-group-id"},
{"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": "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"},
{"command": "chat message search-advanced", "emitted": "open-conversation-ids", "expect": "conversation-ids", "via": "concept:open_conversation_ids"},
{"command": "chat message search-advanced", "emitted": "group-ids", "expect": "did-you-mean:blocked", "via": "guard:group-id-list-vs-open-conversation-id-list"},
{"command": "chat message recall", "emitted": "message-id", "expect": "msg-id", "via": "concept:open_message_id"},
{"command": "chat message add-favorite", "emitted": "msg-id", "expect": "open-message-id", "via": "concept:open_message_id"},
{"command": "chat message list-by-ids", "emitted": "message-ids", "expect": "msg-ids", "via": "concept:open_message_ids"},
{"command": "chat message list-by-ids", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
{"command": "chat message reply", "emitted": "ref-message-id", "expect": "ref-msg-id", "via": "concept:referenced_open_message_id"},
{"command": "chat message reply", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:message-role"},
{"command": "chat message forward-topic", "emitted": "src-open-message-id", "expect": "src-msg-id", "via": "override:scoped-role"},
{"command": "chat message forward-topic", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:message-role"},
{"command": "chat message combine-forward", "emitted": "src-open-cid", "expect": "src-conversation-id", "via": "override:scoped-role"},
{"command": "chat message forward-topic", "emitted": "dest-open-conversation-id", "expect": "dest-conversation-id", "via": "override:scoped-role"},
{"command": "chat message forward", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-destination-role"},
{"command": "chat group share-invite", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-target-role"},
{"command": "chat message send", "emitted": "user-id", "expect": "user", "via": "concept:user_id"},
{"command": "chat message list", "emitted": "user-id", "expect": "user", "via": "concept:user_id"},
{"command": "chat +messages-list-direct", "emitted": "user-id", "expect": "user", "via": "concept:user_id"},
{"command": "attendance +check-result", "emitted": "user-ids", "expect": "users", "via": "concept:user_ids"},
{"command": "contact +list-sub-depts", "emitted": "parent-id", "expect": "dept", "via": "concept:dept_id"},
{"command": "chat +conversation-info", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:user-id-vs-open-dingtalk-id"},
{"command": "chat group members remove", "emitted": "user-ids", "expect": "users", "via": "concept:user_ids"},
{"command": "chat group members remove", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
{"command": "chat group create", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
{"command": "chat +chat-set-admin", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
{"command": "chat +messages-read-status", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
{"command": "chat group members list-by-ids", "emitted": "open-dingtalk-ids", "expect": "users", "via": "concept:open_dingtalk_ids+bind"},
{"command": "chat group members remove-bot", "emitted": "open-bot-id", "expect": "bot-id", "via": "concept:open_bot_id"},
{"command": "chat group members remove-bot", "emitted": "robot-code", "expect": "did-you-mean:blocked", "via": "guard:robot-code-vs-open-bot-id"},
{"command": "chat group members add-bot", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code"},
{"command": "chat +bot-find", "emitted": "name", "expect": "query", "via": "override:scoped"},
{"command": "chat bot find", "emitted": "name", "expect": "query", "via": "override:scoped"},
{"command": "chat +bot-search", "emitted": "query", "expect": "name", "via": "override:scoped"},
{"command": "chat bot search", "emitted": "query", "expect": "name", "via": "override:scoped"},
{"command": "chat message list-favorites", "emitted": "limit", "expect": "size", "via": "override:scoped"},
{"command": "chat bot search", "emitted": "current-page", "expect": "page", "via": "override:scoped"},
{"command": "chat bot search", "emitted": "cursor", "expect": "did-you-mean:blocked", "via": "guard:page-number-vs-cursor"},
{"command": "chat message list-unread-conversations", "emitted": "limit", "expect": "count", "via": "override:scoped"},
{"command": "chat +messages-list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"},
{"command": "chat message list", "emitted": "start", "expect": "time", "via": "override:scoped"},
{"command": "chat message list", "emitted": "end", "expect": "did-you-mean:blocked", "via": "guard:single-time-vs-range"},
{"command": "chat message list-all", "emitted": "time", "expect": "did-you-mean:blocked", "via": "guard:time-range-required"},
{"command": "chat message list-by-sender", "emitted": "user-id", "expect": "sender-user-id", "via": "override:scoped-role"},
{"command": "chat message list-by-sender", "emitted": "open-dingtalk-id", "expect": "sender-open-dingtalk-id", "via": "override:scoped-role"},
{"command": "chat category create-smart", "emitted": "title", "expect": "name", "via": "override:scoped"},
{"command": "chat category create", "emitted": "name", "expect": "title", "via": "override:scoped"},
{"command": "chat message send", "emitted": "file", "expect": "file-path", "via": "override:scoped"},
{"command": "chat category add-conv", "emitted": "category-id", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
{"command": "chat category rename", "emitted": "category-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
{"command": "chat group-role set-user", "emitted": "role-id", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
{"command": "chat group-role update", "emitted": "role-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
{"command": "chat message send-by-webhook", "emitted": "at-user-ids", "expect": "at-users", "via": "override:scoped-role"},
{"command": "chat message send-by-bot", "emitted": "at-users", "expect": "at-user-ids", "via": "override:scoped-role"},
{"command": "chat message send-by-bot", "emitted": "at-ids", "expect": "did-you-mean:ambiguous", "via": "guard:user-id-vs-open-dingtalk-id"},
{"command": "chat +bot-search", "emitted": "current-page", "expect": "page", "via": "override:scoped"},
{"command": "chat +category-create", "emitted": "name", "expect": "title", "via": "override:scoped"},
{"command": "chat +category-rename", "emitted": "name", "expect": "title", "via": "override:scoped"},
{"command": "chat +messages-list-direct", "emitted": "start", "expect": "time", "via": "override:scoped"},
{"command": "chat +messages-list-unread-conversations", "emitted": "limit", "expect": "count", "via": "override:scoped"},
{"command": "chat +messages-send-by-webhook", "emitted": "at-user-ids", "expect": "at-users", "via": "override:scoped-role"},
{"command": "chat +unread-chats", "emitted": "limit", "expect": "count", "via": "override:scoped"},
{"command": "chat +unread-chats", "emitted": "size", "expect": "count", "via": "override:scoped"},
{"command": "chat category rename", "emitted": "name", "expect": "title", "via": "override:scoped"},
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"}
]
}
}
+269
View File
@@ -0,0 +1,269 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/internal/cli/param_concepts.schema.json",
"title": "DWS Reviewed Parameter Concept Dictionary",
"description": "Human-reviewed source of parameter concepts (equivalent flag spellings for the same entity) and per-command overrides. Build-time generators reduce these concepts against each command's real Cobra flags; they must never rewrite this file.",
"type": "object",
"additionalProperties": false,
"required": [
"$schema",
"version",
"concepts"
],
"properties": {
"$schema": {
"const": "./param_concepts.schema.json",
"description": "Relative editor contract. Keep this value unchanged so agents can validate the dictionary without network access."
},
"version": {
"const": 1,
"description": "ParamConcepts source format version."
},
"morphological_rules": {
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/morphRule"
},
"description": "Table-free, global name normalization behaviors realized by pflag SetNormalizeFunc. Documentation of morph behavior, not per-command aliases."
},
"concepts": {
"type": "object",
"minProperties": 1,
"propertyNames": {
"$ref": "#/$defs/conceptId"
},
"additionalProperties": {
"$ref": "#/$defs/concept"
},
"description": "Global concepts keyed by stable concept id. Go validation additionally rejects members that overlap across concepts and members that intersect their own excludes."
},
"command_overrides": {
"type": "object",
"propertyNames": {
"$ref": "#/$defs/commandPath"
},
"additionalProperties": {
"$ref": "#/$defs/commandOverride"
},
"description": "Per-command overrides keyed by the command path (without leading 'dws'). Only needed for generic-name binding, command-scoped aliases, co-occurrence whitelisting, or reject."
},
"validation_fixture": {
"$ref": "#/$defs/validationFixture"
}
},
"$defs": {
"conceptId": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Stable concept id in lower snake_case."
},
"flagToken": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$",
"description": "A flag spelling token without leading dashes."
},
"commandPath": {
"type": "string",
"pattern": "^[A-Za-z0-9+][A-Za-z0-9._:+-]*(?: [A-Za-z0-9+][A-Za-z0-9._:+-]*)*$",
"description": "Exact command path without the leading 'dws' or flags. A leading '+' segment marks a shortcut subcommand. Existence against the real Cobra tree is validated by the generator, not this schema."
},
"risk": {
"type": "string",
"enum": [
"green",
"yellow"
],
"description": "Reviewed risk band. green concepts reduce freely; yellow concepts need extra reviewer attention on excludes boundaries."
},
"morphRule": {
"type": "object",
"additionalProperties": false,
"required": [
"desc",
"enabled"
],
"properties": {
"desc": {
"type": "string",
"minLength": 1
},
"enabled": {
"type": "boolean"
},
"guard": {
"type": "string",
"minLength": 1
},
"reason": {
"type": "string",
"minLength": 1
}
}
},
"concept": {
"type": "object",
"additionalProperties": false,
"required": [
"denotes",
"canonical_hint",
"members",
"commands",
"risk"
],
"properties": {
"denotes": {
"type": "string",
"minLength": 1,
"description": "Human description of the single entity this concept denotes."
},
"canonical_hint": {
"$ref": "#/$defs/flagToken",
"description": "Governance hint only; the runtime reduces to whichever real flag the command exposes, not to this value."
},
"members": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"$ref": "#/$defs/flagToken"
},
"description": "Equivalent spellings that all denote the concept's entity."
},
"excludes": {
"type": "array",
"default": [],
"uniqueItems": true,
"items": {
"$ref": "#/$defs/flagToken"
},
"description": "Spellings that denote a DIFFERENT entity and must never be reduced into this concept."
},
"commands": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"$ref": "#/$defs/commandPath"
},
"description": "Exact reviewed runnable command paths on which this concept may participate in reduction. A concept never activates on an unlisted command merely because a real flag has the same spelling."
},
"risk": {
"$ref": "#/$defs/risk"
}
}
},
"commandOverride": {
"type": "object",
"additionalProperties": false,
"minProperties": 1,
"properties": {
"bind": {
"type": "object",
"minProperties": 1,
"propertyNames": {
"$ref": "#/$defs/flagToken"
},
"additionalProperties": {
"$ref": "#/$defs/conceptId"
},
"description": "Maps a generic real flag (e.g. id) to a concept id so concept members can reduce onto it. Go validation requires every value to be a declared concept id."
},
"scoped_aliases": {
"type": "object",
"minProperties": 1,
"propertyNames": {
"$ref": "#/$defs/flagToken"
},
"additionalProperties": {
"$ref": "#/$defs/flagToken"
},
"description": "Command-scoped emitted->realFlag aliases. Never promoted to a global concept member."
},
"block": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"$ref": "#/$defs/flagToken"
},
"description": "Emitted names that must never be reduced on this command; they route to did-you-mean instead."
},
"ambiguous": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"$ref": "#/$defs/flagToken"
},
"description": "Reviewed co-occurrence whitelist: when a concept intersects two or more real flags on this command, the emitted names listed here are expected to route to did-you-mean rather than fail generation."
},
"confirm": {
"type": "boolean",
"description": "Reviewer flagged this override as needing user confirmation of the mapping semantics."
},
"scope_strict": {
"type": "boolean",
"description": "This alias must stay strictly command-local; another command has a real and different flag with the same spelling."
},
"investigate": {
"type": "boolean",
"description": "Reviewer flagged this override as needing source-case investigation before landing."
},
"note": {
"type": "string",
"minLength": 1,
"description": "Reviewer note explaining the override."
}
}
},
"validationFixture": {
"type": "object",
"additionalProperties": false,
"required": [
"cases"
],
"properties": {
"cases": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/fixtureCase"
}
}
}
},
"fixtureCase": {
"type": "object",
"additionalProperties": false,
"required": [
"command",
"emitted",
"expect"
],
"properties": {
"command": {
"$ref": "#/$defs/commandPath"
},
"emitted": {
"$ref": "#/$defs/flagToken",
"description": "The name the model produced."
},
"expect": {
"type": "string",
"minLength": 1,
"description": "Either the real flag the emitted name must reduce to, or one of the did-you-mean sentinels 'did-you-mean:ambiguous' / 'did-you-mean:blocked'."
},
"via": {
"type": "string",
"minLength": 1,
"description": "Reviewer annotation of the reduction path; documentation only."
},
"occ": {
"type": "integer",
"minimum": 0,
"description": "Occurrence count in the evaluation batch; frequency evidence only."
}
}
}
}
}
@@ -0,0 +1,312 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package cli
import (
"encoding/json"
"strings"
"testing"
)
func TestParamConceptsJSONSchemaDocumentsClosedShape(t *testing.T) {
var schema map[string]any
if err := json.Unmarshal(embeddedParamConceptsSchemaJSON, &schema); err != nil {
t.Fatalf("decode param_concepts.schema.json: %v", err)
}
if schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" || schema["additionalProperties"] != false {
t.Fatalf("param concepts root schema is not closed: %#v", schema)
}
definitions := schema["$defs"].(map[string]any)
concept := definitions["concept"].(map[string]any)
if concept["additionalProperties"] != false {
t.Fatalf("concept schema allows unknown fields: %#v", concept)
}
properties := concept["properties"].(map[string]any)
for _, field := range []string{"denotes", "canonical_hint", "members", "excludes", "commands", "risk"} {
if _, ok := properties[field]; !ok {
t.Fatalf("concept schema is missing %s", field)
}
}
override := definitions["commandOverride"].(map[string]any)
if override["additionalProperties"] != false {
t.Fatalf("commandOverride schema allows unknown fields: %#v", override)
}
var source map[string]any
if err := json.Unmarshal(embeddedParamConceptsJSON, &source); err != nil {
t.Fatalf("decode param_concepts.json: %v", err)
}
if source["$schema"] != paramConceptsSchemaRef {
t.Fatalf("param concepts source $schema = %#v, want %q", source["$schema"], paramConceptsSchemaRef)
}
}
func TestEmbeddedParamConceptsLoadsAndSatisfiesInvariants(t *testing.T) {
concepts, err := LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
}
if len(concepts.Concepts) == 0 {
t.Fatal("embedded param concepts declares no concepts")
}
// Members must be globally unique and disjoint from their own excludes.
memberOwner := make(map[string]string)
for _, concept := range concepts.Concepts {
if len(concept.Commands) == 0 {
t.Fatalf("concept %s has no reviewed command scope", concept.ID)
}
excludeSet := make(map[string]bool, len(concept.Excludes))
for _, exclude := range concept.Excludes {
excludeSet[exclude] = true
}
for _, member := range concept.Members {
if owner, exists := memberOwner[member]; exists {
t.Fatalf("member %q belongs to both concept %s and %s", member, owner, concept.ID)
}
memberOwner[member] = concept.ID
if excludeSet[member] {
t.Fatalf("concept %s lists %q as both member and exclude", concept.ID, member)
}
}
}
// Every bind target must reference a declared concept.
for _, override := range concepts.Overrides {
if override.Confirm || override.Investigate {
t.Fatalf("current override %q remains unresolved (confirm=%v investigate=%v)", override.CommandPath, override.Confirm, override.Investigate)
}
for flag, conceptID := range override.Bind {
if _, ok := concepts.ByConcept[conceptID]; !ok {
t.Fatalf("command_override %q binds %q to undeclared concept %q", override.CommandPath, flag, conceptID)
}
}
}
// Fixture sentinels are limited to the two known did-you-mean forms.
for _, c := range concepts.Fixture {
if strings.HasPrefix(c.Expect, "did-you-mean:") &&
c.Expect != paramDidYouMeanAmbiguous && c.Expect != paramDidYouMeanBlocked {
t.Fatalf("fixture %q/%q has unknown sentinel %q", c.Command, c.Emitted, c.Expect)
}
}
}
func TestParamConceptRiskAuditBoundaries(t *testing.T) {
concepts, err := LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
}
assertMembers := func(id string, forbidden ...string) {
t.Helper()
concept, ok := concepts.ByConcept[id]
if !ok {
t.Fatalf("missing audited concept %q", id)
}
members := make(map[string]bool, len(concept.Members))
for _, member := range concept.Members {
members[member] = true
}
for _, name := range forbidden {
if members[name] {
t.Fatalf("audited concept %s still contains forbidden cross-semantics member %q", id, name)
}
}
}
assertMembers("user_id", "users", "user-ids")
assertMembers("user_ids", "user", "user-id")
assertMembers("dept_id", "depts", "dept-ids")
assertMembers("dept_ids", "dept", "dept-id")
assertMembers("group_id", "conversation-ids", "group-ids")
assertMembers("page_number", "page-index")
assertMembers("robot_code", "robot-id")
}
func TestDecodeParamConceptsRejectsUnknownFieldsAtEveryLevel(t *testing.T) {
valid := `{"$schema":"./param_concepts.schema.json","version":1,` +
`"concepts":{"search_query":{"denotes":"d","canonical_hint":"query","members":["query"],"commands":["demo cmd"],"risk":"green"}},` +
`"command_overrides":{"chat group rename":{"bind":{"id":"search_query"}}}}`
for name, input := range map[string]string{
"root": strings.Replace(valid, `"version":1`, `"version":1,"unknown":true`, 1),
"concept": strings.Replace(valid, `"risk":"green"`, `"risk":"green","unknown":true`, 1),
"override": strings.Replace(valid, `"bind":{"id":"search_query"}`, `"bind":{"id":"search_query"},"unknown":true`, 1),
} {
t.Run(name, func(t *testing.T) {
if _, err := decodeParamConcepts([]byte(input)); err == nil || !strings.Contains(err.Error(), "unknown field") {
t.Fatalf("decodeParamConcepts() error = %v, want unknown field", err)
}
})
}
}
func TestDecodeParamConceptsEnforcesReviewedConstraints(t *testing.T) {
wrap := func(body string) string {
return `{"$schema":"./param_concepts.schema.json","version":1,` + body + `}`
}
concept := func(members, excludes, risk string) string {
return `"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":` + members + `,"excludes":` + excludes + `,"commands":["demo cmd"],"risk":"` + risk + `"}}`
}
tests := map[string]string{
"missing schema ref": `{"version":1,"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":["query"],"risk":"green"}}}`,
"wrong version": `{"$schema":"./param_concepts.schema.json","version":2,"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":["query"],"risk":"green"}}}`,
"no concepts": wrap(`"concepts":{}`),
"invalid concept id": wrap(`"concepts":{"BadID":{"denotes":"d","canonical_hint":"query","members":["query"],"risk":"green"}}`),
"empty denotes": wrap(`"concepts":{"c_one":{"denotes":"","canonical_hint":"query","members":["query"],"risk":"green"}}`),
"invalid risk": wrap(concept(`["query"]`, `[]`, "red")),
"no members": wrap(`"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":[],"risk":"green"}}`),
"no command scope": wrap(`"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":["query"],"commands":[],"risk":"green"}}`),
"member equals exclude": wrap(concept(`["query"]`, `["query"]`, "green")),
"member overlaps concepts": wrap(`"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":["query"],"risk":"green"},"c_two":{"denotes":"d","canonical_hint":"query","members":["query"],"risk":"green"}}`),
"bind undeclared concept": wrap(concept(`["query"]`, `[]`, "green") + `,"command_overrides":{"chat group rename":{"bind":{"id":"missing"}}}`),
"empty override": wrap(concept(`["query"]`, `[]`, "green") + `,"command_overrides":{"chat group rename":{}}`),
"bad fixture sentinel": wrap(concept(`["query"]`, `[]`, "green") + `,"validation_fixture":{"cases":[{"command":"chat group rename","emitted":"group","expect":"did-you-mean:oops"}]}`),
"empty fixture cases": wrap(concept(`["query"]`, `[]`, "green") + `,"validation_fixture":{"cases":[]}`),
}
for name, input := range tests {
t.Run(name, func(t *testing.T) {
if _, err := decodeParamConcepts([]byte(input)); err == nil {
t.Fatal("decodeParamConcepts() unexpectedly accepted invalid reviewed source")
}
})
}
got, err := decodeParamConcepts([]byte(wrap(concept(`["query","keyword"]`, `["name"]`, "green"))))
if err != nil {
t.Fatalf("decodeParamConcepts() valid source error = %v", err)
}
if len(got.Concepts) != 1 || got.Concepts[0].ID != "c_one" {
t.Fatalf("decodeParamConcepts() concepts = %#v", got.Concepts)
}
}
func validParamConceptSpecFixture() paramConceptSpec {
return paramConceptSpec{
Denotes: "a reviewed value",
CanonicalHint: "query",
Members: []string{"query"},
Excludes: []string{"name"},
Commands: []string{"demo run"},
Risk: "green",
}
}
func TestDecodeParamConceptsRemainingSyntaxEdges(t *testing.T) {
valid := `{"$schema":"./param_concepts.schema.json","version":1,` +
`"concepts":{"c_one":{"denotes":"d","canonical_hint":"query","members":["query"],"commands":["demo run"],"risk":"green"}}}`
if _, err := decodeParamConcepts([]byte(valid + ` {}`)); err == nil || !strings.Contains(err.Error(), "multiple JSON values") {
t.Fatalf("multiple JSON values error = %v", err)
}
withEmptyMorphDescription := strings.Replace(
valid,
`"concepts":`,
`"morphological_rules":{"camel":{"desc":""}},"concepts":`,
1,
)
if _, err := decodeParamConcepts([]byte(withEmptyMorphDescription)); err == nil || !strings.Contains(err.Error(), "empty desc") {
t.Fatalf("empty morph description error = %v", err)
}
}
func TestDecodeParamConceptSpecsRemainingValidationEdges(t *testing.T) {
base := validParamConceptSpecFixture()
invalidCanonical := base
invalidCanonical.CanonicalHint = "bad token"
invalidMember := base
invalidMember.Members = []string{"bad token"}
repeatedMember := base
repeatedMember.Members = []string{"query", "query"}
invalidExclude := base
invalidExclude.Excludes = []string{"bad token"}
repeatedExclude := base
repeatedExclude.Excludes = []string{"name", "name"}
invalidCommand := base
invalidCommand.Commands = []string{"demo /bad"}
repeatedCommand := base
repeatedCommand.Commands = []string{"demo run", "demo run"}
tests := map[string]map[string]paramConceptSpec{
"invalid canonical hint": {"c_one": invalidCanonical},
"invalid member": {"c_one": invalidMember},
"repeated member": {"c_one": repeatedMember},
"invalid exclude": {"c_one": invalidExclude},
"repeated exclude": {"c_one": repeatedExclude},
"invalid command": {"c_one": invalidCommand},
"repeated command": {"c_one": repeatedCommand},
"cross-concept member": {
"c_one": base,
"c_two": base,
},
}
for name, specs := range tests {
t.Run(name, func(t *testing.T) {
if _, _, err := decodeParamConceptSpecs(specs); err == nil {
t.Fatal("decodeParamConceptSpecs() unexpectedly accepted invalid input")
}
})
}
}
func TestDecodeParamCommandOverridesRemainingValidationEdges(t *testing.T) {
byConcept := map[string]Concept{"c_one": {ID: "c_one"}}
tests := map[string]map[string]paramCommandOverride{
"invalid path": {
" demo run": {Block: []string{"name"}},
},
"invalid bind flag": {
"demo run": {Bind: map[string]string{"bad token": "c_one"}},
},
"invalid scoped emitted": {
"demo run": {ScopedAliases: map[string]string{"bad token": "query"}},
},
"invalid scoped target": {
"demo run": {ScopedAliases: map[string]string{"keyword": "bad token"}},
},
"invalid block token": {
"demo run": {Block: []string{"bad token"}},
},
"repeated ambiguous token": {
"demo run": {Ambiguous: []string{"uid", "uid"}},
},
}
for name, specs := range tests {
t.Run(name, func(t *testing.T) {
if _, err := decodeParamCommandOverrides(specs, byConcept); err == nil {
t.Fatal("decodeParamCommandOverrides() unexpectedly accepted invalid input")
}
})
}
}
func TestDecodeParamFixtureCasesRemainingValidationEdges(t *testing.T) {
tests := map[string]paramFixtureCaseSpec{
"invalid command": {Command: " demo run", Emitted: "query", Expect: "query"},
"invalid emitted": {Command: "demo run", Emitted: "bad token", Expect: "query"},
"empty expect": {Command: "demo run", Emitted: "query", Expect: " "},
"invalid expect": {Command: "demo run", Emitted: "query", Expect: "bad token"},
"negative occ": {Command: "demo run", Emitted: "query", Expect: "query", Occ: -1},
}
for name, fixture := range tests {
t.Run(name, func(t *testing.T) {
if _, err := decodeParamFixtureCases(&paramValidationFixtureSpec{Cases: []paramFixtureCaseSpec{fixture}}); err == nil {
t.Fatal("decodeParamFixtureCases() unexpectedly accepted invalid input")
}
})
}
}
func TestParamConceptPathAndTokenValidationEdges(t *testing.T) {
if validParamCommandPath(" demo run") {
t.Fatal("command path with surrounding whitespace was accepted")
}
if validParamCommandPath("demo /bad") {
t.Fatal("command path with an invalid token was accepted")
}
if err := validParamTokenList("demo run", "block", []string{"bad token"}); err == nil {
t.Fatal("invalid parameter token was accepted")
}
if err := validParamTokenList("demo run", "block", []string{"uid", "uid"}); err == nil {
t.Fatal("repeated parameter token was accepted")
}
}
@@ -37075,6 +37075,265 @@
"用户明确要求停止某自动化工作流时"
]
},
"aitable workflow edit-example": {
"agent_summary": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。",
"agent_summary_source": "dws-agent-selection/aitable",
"availability": "available",
"avoid_when": [
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
],
"confirmation": "not_required",
"effect": "read",
"effect_source": "agent-hint",
"examples": [
"dws aitable workflow edit-example"
],
"field_provenance": {
"agent_summary": {
"value": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。",
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"candidates": [
{
"value": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。",
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。"
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
}
]
},
"avoid_when": {
"value": [
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"candidates": [
{
"value": [
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。"
}
]
},
"confirmation": {
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
}
]
},
"effect": {
"value": "read",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": "read",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
}
]
},
"examples": {
"value": [
"dws aitable workflow edit-example"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"candidates": [
{
"value": [
"dws aitable workflow edit-example"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。"
}
]
},
"idempotency": {
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
}
]
},
"interface_mode": {
"value": "composite",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": "composite",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
}
]
},
"interface_reason": {
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
}
]
},
"interface_ref": {
"value": null,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "interface_disposition_matrix",
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
"candidates": [
{
"value": null,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
}
]
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。"
}
]
},
"risk": {
"value": "low",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": "low",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
}
]
},
"use_when": {
"value": [
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"candidates": [
{
"value": [
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。"
}
]
}
},
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"risk": "low",
"source_refs": [
"cobra-help:dws aitable workflow edit-example --help",
"internal/cli/schema_command_registry.json#aitable.workflow_edit_example",
"internal/cli/schema_hints/metadata/aitable.json",
"internal/cli/schema_hints/selection/aitable.json",
"mcp-contract:aitable/edit_workflow_example",
"skills/mono/references/products/aitable/aitable-workflow.md"
],
"use_when": [
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
]
},
"aitable workflow enable": {
"agent_summary": "启用工作流。",
"agent_summary_source": "dws-agent-selection/aitable",
File diff suppressed because it is too large Load Diff
@@ -1,17 +1,17 @@
{
"version": 1,
"source_hash": "sha256:eb6e44775a6e85f92ca31c90c876f67543ab928d8a8bda04f07a33dc802f7028",
"surface_hash": "sha256:29d4f5f43688cc01bc2277a4528b597de4fbab89a7b2f13fe2e56df85d1c66c4",
"source_hash": "sha256:b513a679eb51b64afa3f31364b45c29bc4590f0568908650f426ac8f4b041b2b",
"surface_hash": "sha256:41044fe1b6723564c40d684381ac6d6f23c4dfd58f9769350f02f223f02fa894",
"coverage": {
"surface_products": 26,
"products_with_metadata": 26,
"surface_tools": 840,
"tools_with_metadata": 840,
"tools_with_agent_summary": 840,
"tools_with_use_when": 840,
"tools_with_avoid_when": 840,
"tools_with_examples": 840,
"tools_with_interface_mode": 840,
"surface_tools": 846,
"tools_with_metadata": 846,
"tools_with_agent_summary": 846,
"tools_with_use_when": 846,
"tools_with_avoid_when": 846,
"tools_with_examples": 846,
"tools_with_interface_mode": 846,
"unmatched_skill_tools": 122,
"unreviewed_skill_tools": 11
},
File diff suppressed because it is too large Load Diff
+171 -14
View File
@@ -1,17 +1,17 @@
{
"version": 1,
"surface_hash": "sha256:29d4f5f43688cc01bc2277a4528b597de4fbab89a7b2f13fe2e56df85d1c66c4",
"source_hash": "sha256:c71b9bacfe2d73f5ad45151fada6fdc18bac69b266058a8b05647b6847c99835",
"surface_hash": "sha256:41044fe1b6723564c40d684381ac6d6f23c4dfd58f9769350f02f223f02fa894",
"source_hash": "sha256:1b4f0e6e6fe49115137ff81717462374b2fdd05ad4ca1bfb1b2dd8272aa6bad8",
"catalog": {
"agent_metadata": {
"products_with_metadata": 26,
"source": "embedded-skill-metadata",
"source_hash": "sha256:eb6e44775a6e85f92ca31c90c876f67543ab928d8a8bda04f07a33dc802f7028",
"surface_hash": "sha256:29d4f5f43688cc01bc2277a4528b597de4fbab89a7b2f13fe2e56df85d1c66c4",
"source_hash": "sha256:b513a679eb51b64afa3f31364b45c29bc4590f0568908650f426ac8f4b041b2b",
"surface_hash": "sha256:41044fe1b6723564c40d684381ac6d6f23c4dfd58f9769350f02f223f02fa894",
"surface_products": 26,
"surface_tools": 840,
"tools_with_agent_summary": 840,
"tools_with_metadata": 840,
"surface_tools": 846,
"tools_with_agent_summary": 846,
"tools_with_metadata": 846,
"unmatched_skill_tools": 122,
"version": 1
},
@@ -288,7 +288,7 @@
"id": "aitable",
"name": "AI 表格操作",
"runtime": true,
"tool_count": 145,
"tool_count": 146,
"tools": [
{
"agent_metadata_source": "embedded-skill-metadata",
@@ -4449,6 +4449,33 @@
"用户明确要求停止某自动化工作流时"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。",
"agent_summary_source": "dws-agent-selection/aitable",
"availability": "available",
"avoid_when": [
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
],
"canonical_path": "aitable.workflow_edit_example",
"cli_name": "edit-example",
"cli_path": "aitable workflow edit-example",
"confirmation": "not_required",
"description": "返回服务端提供的 AI 表格工作流编辑文档与示例。\n可作为 workflow create / workflow update 的 workflow-dsl/v1 结构参考;此命令不需要 Base ID 或其他参数。",
"effect": "read",
"group": "workflow",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"name": "workflow_edit_example",
"primary_cli_path": "aitable workflow edit-example",
"reviewed": true,
"risk": "low",
"title": "获取工作流编辑文档与示例",
"use_when": [
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "启用工作流。",
@@ -7722,7 +7749,7 @@
"id": "chat",
"name": "群聊 / 消息 / 机器人",
"runtime": true,
"tool_count": 124,
"tool_count": 129,
"tools": [
{
"agent_metadata_source": "embedded-skill-metadata",
@@ -9800,7 +9827,7 @@
"cli_name": "+at-me",
"cli_path": "chat +at-me",
"confirmation": "not_required",
"description": "当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息,再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。这是纯只读操作,只做搜索与本地投影,不会发送、撤回或标记任何消息。",
"description": "当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息,再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。默认只读且不会发送、撤回或标记任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。\n\n参数约束:\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
"effect": "read",
"idempotency": "idempotent",
"interface_mode": "composite",
@@ -9811,7 +9838,7 @@
"risk": "low",
"title": "查最近 @我 的消息(自动算时间窗,投影发送人/时间/内容/会话)",
"use_when": [
"当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息,再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。这是纯只读操作,只做搜索与本地投影,不会发送、撤回或标记任何消息。"
"当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息,再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。默认只读且不会发送、撤回或标记任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
]
},
{
@@ -10152,6 +10179,32 @@
"当你想查看自己作为群主或管理员在管理哪些群时使用;只读分页返回,可用 --role OWNER/ADMIN 按角色过滤。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间",
"agent_summary_source": "dws-agent-selection/chat",
"availability": "available",
"avoid_when": [
"要跨多个会话按关键词、发送者或消息类型检索时使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget"
],
"canonical_path": "chat.shortcut_chat_messages",
"cli_name": "+chat-messages",
"cli_path": "chat +chat-messages",
"confirmation": "not_required",
"description": "当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。\n\n参数约束:\n - --group、--conversation-id、--id、--user、--open-dingtalk-id 必须且只能指定一个\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
"effect": "read",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed built-in Shortcut adapter: it routes group or direct-message history reads, projects a stable message shape, and optionally orchestrates safe resource downloads with a failure ledger.",
"name": "shortcut_chat_messages",
"primary_cli_path": "chat +chat-messages",
"reviewed": true,
"risk": "low",
"title": "拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间",
"use_when": [
"当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "全员禁言 / 取消全员禁言",
@@ -10684,7 +10737,7 @@
"cli_name": "+messages-mget",
"cli_path": "chat +messages-mget",
"confirmation": "not_required",
"description": "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。\n\n参数约束:\n - --msg-ids 去重后必须包含 1-50 条消息 ID\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
"description": "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId/fileId 安全下载到工作目录内,并逐资源返回成功/失败 ledger;本地下载路径受限于工作目录、默认不覆盖同名文件,按既有安全下载约定无需交互确认。\n\n参数约束:\n - --msg-ids 去重后必须包含 1-50 条消息 ID\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
"effect": "read",
"idempotency": "idempotent",
"interface_mode": "composite",
@@ -10695,7 +10748,7 @@
"risk": "low",
"title": "根据消息 ID 批量查询消息(最多 50 条)",
"use_when": [
"当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。"
"当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId/fileId 安全下载到工作目录内,并逐资源返回成功/失败 ledger;本地下载路径受限于工作目录、默认不覆盖同名文件,按既有安全下载约定无需交互确认。"
]
},
{
@@ -10750,6 +10803,32 @@
"当你想知道自己发出的某条消息有哪些人已读/未读时使用;只读,需传会话 openConversationId 和该消息 openMessageId,可指定目标成员列表。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "统一发送文本、Markdown、当前用户文件或已有 mediaId 图片",
"agent_summary_source": "dws-agent-selection/chat",
"availability": "available",
"avoid_when": [
"需要 bot/webhook 发送媒体、卡片或 thread 回复时不要假设等价支持;改用真实存在的专用下层命令,缺少下层能力时停止"
],
"canonical_path": "chat.shortcut_messages_send",
"cli_name": "+messages-send",
"cli_path": "chat +messages-send",
"confirmation": "user_required",
"description": "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;--user 传 userId 时包括在 --dry-run 中也会先通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId。bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。\n\n参数约束:\n - --text、--markdown、--media-id、--file、--file-path 至少指定一个\n - --text、--markdown、--media-id、--file、--file-path 互斥,最多指定一个\n - --identity、--as 互斥,最多指定一个\n - --group、--chat-id 互斥,最多指定一个\n - --user、--open-dingtalk-id 互斥,最多指定一个\n - --uuid、--idempotency-key 互斥,最多指定一个\n - 目标、凭据和幂等参数受发送身份能力矩阵约束:user 必须指定一个群聊或单聊目标;bot 必须指定 robot-code 和一类目标;webhook 必须指定 webhook-token;幂等键仅 user 支持",
"effect": "write",
"idempotency": "unknown",
"interface_mode": "composite",
"interface_reason": "Reviewed composite send adapter: it selects current-user, bot, or webhook transport; current-user additionally supports live-compatible contact search with exact userId matching, mediaId images, and the native init/upload/commit local-file flow.",
"name": "shortcut_messages_send",
"primary_cli_path": "chat +messages-send",
"reviewed": true,
"risk": "medium",
"title": "统一发送文本、Markdown、当前用户文件或已有 mediaId 图片",
"use_when": [
"当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;--user 传 userId 时包括在 --dry-run 中也会先通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId。bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "自定义机器人 Webhook 发送群消息",
@@ -10776,6 +10855,32 @@
"当你只有自定义机器人的 Webhook token、想往其所在群推送消息时使用;会实际通过 Webhook 发群消息,需传 token、标题、正文,可 @手机号/userId 或 @所有人。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "创建流式卡片,可在同一次调用中写入内容并结束",
"agent_summary_source": "dws-agent-selection/chat",
"availability": "available",
"avoid_when": [
"已有 bizId、只需要追加或更新现有卡片内容时使用 +messages-update-card"
],
"canonical_path": "chat.shortcut_messages_send_card",
"cli_name": "+messages-send-card",
"cli_path": "chat +messages-send-card",
"confirmation": "user_required",
"description": "当你要发送一张流式卡片消息时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成,避免卡片停留在加载中。\n\n参数约束:\n - --group、--receiver、--receiver-open-dingtalk-id 必须且只能指定一个\n - --flow-status 必须在 1-5 之间,且显式指定时必须同时提供 --content",
"effect": "write",
"idempotency": "unknown",
"interface_mode": "composite",
"interface_reason": "Reviewed card lifecycle adapter: it can resolve a userId through contact search with exact matching, call create_and_send_card alone, or compose creation with update_streaming_card after extracting the returned bizId.",
"name": "shortcut_messages_send_card",
"primary_cli_path": "chat +messages-send-card",
"reviewed": true,
"risk": "medium",
"title": "创建流式卡片,可在同一次调用中写入内容并结束",
"use_when": [
"当你要发送一张流式卡片消息时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成,避免卡片停留在加载中。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "流式更新卡片内容(最后一次 --flow-status 应为 3)",
@@ -10828,6 +10933,32 @@
"当你想快速看一眼自己都加入了哪些群、以及每个群的会话ID、名称、群主和人数,而不想翻分页或盯着原始返回时使用;内部分页拉取你加入的群列表,把每个群防御式地投影成 会话id / 名称 / 群主 / 人数 / 类型 等关键字段,输出成干净的结果。可选 --type 在本地按群类型过滤(底层接口本身不带类型参数,故为客户端过滤)。这是只读操作,不会改动任何群或成员关系。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "多维搜索消息,可全量翻页并批量富化详情",
"agent_summary_source": "dws-agent-selection/chat",
"availability": "available",
"avoid_when": [
"只想读取一个已知会话的连续历史时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"
],
"canonical_path": "chat.shortcut_search_msg",
"cli_name": "+search-msg",
"cli_path": "chat +search-msg",
"confirmation": "not_required",
"description": "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。\n\n参数约束:\n - 至少指定一个内容、身份、会话或消息类型过滤条件\n - 需与 --end 一起传\n - 需与 --start 一起传\n - --groups、--chat-id 互斥,最多指定一个\n - --senders、--sender 互斥,最多指定一个\n - --at-me、--is-at-me 互斥,最多指定一个\n - --conversation-type、--chat-type 互斥,最多指定一个\n - --limit、--page-size 互斥,最多指定一个\n - --cursor、--page-token 互斥,最多指定一个\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
"effect": "read",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed search adapter: it combines filters, cursor pagination, batched mget enrichment, stable projection, completeness accounting, and optional safe resource downloads.",
"name": "shortcut_search_msg",
"primary_cli_path": "chat +search-msg",
"reviewed": true,
"risk": "low",
"title": "多维搜索消息,可全量翻页并批量富化详情",
"use_when": [
"当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "按群名直接给群发消息(自动搜群解析 openConversationId)",
@@ -10854,6 +10985,32 @@
"当你只知道群的名字、想直接往这个群里发一条消息而不想先手动查群 ID 时使用;内部先按群名搜索群聊解析出唯一 openConversationId 再发送,群名匹配到多个群时会列出候选让你区分、绝不自行假定。会真实发出群消息。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "拉取某条话题消息的全部回复并投影出发言人/文本/时间",
"agent_summary_source": "dws-agent-selection/chat",
"availability": "available",
"avoid_when": [
"要回复 Thread 或发送新回复时不要使用此读取入口;当前没有经过验证的 thread writer Shortcut"
],
"canonical_path": "chat.shortcut_thread_replies",
"cli_name": "+thread-replies",
"cli_path": "chat +thread-replies",
"confirmation": "not_required",
"description": "当你已经拿到某个群里一条「话题消息」的 threadId/topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么),而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --thread-id(兼容 --topic-id)拉取该话题的回复列表,可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。\n\n参数约束:\n - --thread-id 与兼容参数 --topic-id 必须且只能指定一个\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
"effect": "read",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed thread reader adapter: it accepts threadId/topicId, reads lower topic replies, projects stable message fields, and optionally downloads reply resources safely.",
"name": "shortcut_thread_replies",
"primary_cli_path": "chat +thread-replies",
"reviewed": true,
"risk": "low",
"title": "拉取某条话题消息的全部回复并投影出发言人/文本/时间",
"use_when": [
"当你已经拿到某个群里一条「话题消息」的 threadId/topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么),而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --thread-id(兼容 --topic-id)拉取该话题的回复列表,可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "列出我有未读消息的会话(投影会话名/未读数/会话ID)",
@@ -26514,6 +26671,6 @@
}
],
"source": "embedded-command-catalog",
"tool_count": 840
"tool_count": 846
}
}
@@ -97777,6 +97777,326 @@
"用户明确要求停止某自动化工作流时"
]
},
"aitable.workflow_edit_example": {
"agent_metadata_source": "embedded-skill-metadata",
"agent_source_refs": [
"cobra-help:dws aitable workflow edit-example --help",
"internal/cli/schema_command_registry.json#aitable.workflow_edit_example",
"internal/cli/schema_hints/metadata/aitable.json",
"internal/cli/schema_hints/selection/aitable.json",
"mcp-contract:aitable/edit_workflow_example",
"skills/mono/references/products/aitable/aitable-workflow.md"
],
"agent_summary": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。",
"agent_summary_source": "dws-agent-selection/aitable",
"availability": "available",
"avoid_when": [
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
],
"canonical_path": "aitable.workflow_edit_example",
"cli_name": "edit-example",
"cli_path": "aitable workflow edit-example",
"confirmation": "not_required",
"description": "返回服务端提供的 AI 表格工作流编辑文档与示例。\n可作为 workflow create / workflow update 的 workflow-dsl/v1 结构参考;此命令不需要 Base ID 或其他参数。",
"display": "AI 表格操作",
"effect": "read",
"effect_source": "agent-hint",
"examples": [
"dws aitable workflow edit-example"
],
"field_provenance": {
"agent_summary": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"selected": true,
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。"
},
"availability": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "available"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "available"
},
"avoid_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"selected": true,
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": [
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": [
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
]
},
"canonical_path": {
"candidates": [
{
"precedence": "command_registry",
"selected": true,
"source": "reviewed_command_registry",
"source_ref": "aitable workflow edit-example",
"value": "aitable.workflow_edit_example"
}
],
"precedence": "command_registry",
"resolution": "registry_identity",
"source": "reviewed_command_registry",
"source_ref": "aitable workflow edit-example",
"value": "aitable.workflow_edit_example"
},
"confirmation": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "not_required"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "not_required"
},
"description": {
"candidates": [
{
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "返回服务端提供的 AI 表格工作流编辑文档与示例。\n可作为 workflow create / workflow update 的 workflow-dsl/v1 结构参考;此命令不需要 Base ID 或其他参数。"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "返回服务端提供的 AI 表格工作流编辑文档与示例。\n可作为 workflow create / workflow update 的 workflow-dsl/v1 结构参考;此命令不需要 Base ID 或其他参数。"
},
"effect": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "read"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "read"
},
"examples": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"selected": true,
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": [
"dws aitable workflow edit-example"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": [
"dws aitable workflow edit-example"
]
},
"idempotency": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "idempotent"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "idempotent"
},
"interface_mode": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "composite"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "composite"
},
"interface_reason": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command."
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command."
},
"interface_ref": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": null
}
],
"precedence": "reviewed_explicit",
"resolution": "interface_disposition_matrix",
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": null
},
"reviewed": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": true
},
{
"precedence": "reviewed_explicit",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"selected": false,
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": true
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": true
},
"risk": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "low"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "low"
},
"title": {
"candidates": [
{
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "获取工作流编辑文档与示例"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "获取工作流编辑文档与示例"
},
"use_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"selected": true,
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": [
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": [
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
]
}
},
"group": "workflow",
"has_parameters": false,
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"is_alias": false,
"name": "workflow_edit_example",
"parameter_count": 0,
"parameters": {},
"path": "aitable.workflow_edit_example",
"primary_cli_path": "aitable workflow edit-example",
"product_id": "aitable",
"reviewed": true,
"risk": "low",
"source": "reviewed_command_registry",
"title": "获取工作流编辑文档与示例",
"use_when": [
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
]
},
"aitable.workflow_enable": {
"agent_metadata_source": "embedded-skill-metadata",
"agent_source_refs": [
File diff suppressed because it is too large Load Diff
+1 -6
View File
@@ -168,7 +168,6 @@
"chat +chat-get-by-id",
"chat +chat-members-get",
"chat +chat-members-list",
"chat +chat-messages",
"chat +chat-mute-member",
"chat +chat-quit",
"chat +chat-remove-bot",
@@ -205,15 +204,11 @@
"chat +messages-reply",
"chat +messages-resource-download",
"chat +messages-resource-url",
"chat +messages-send",
"chat +messages-send-by-bot",
"chat +messages-send-card",
"chat +messages-set-pin",
"chat +messages-set-top",
"chat +messages-unset-pin",
"chat +messages-unset-top",
"chat +search-msg",
"chat +thread-replies"
"chat +messages-unset-top"
]
}
]
@@ -452,6 +452,10 @@
"canonical_path": "aitable.workflow_disable",
"cli_path": "aitable workflow disable"
},
{
"canonical_path": "aitable.workflow_edit_example",
"cli_path": "aitable workflow edit-example"
},
{
"canonical_path": "aitable.workflow_enable",
"cli_path": "aitable workflow enable"
@@ -496,6 +496,26 @@
{
"canonical_path": "chat.set_top_conversation",
"cli_path": "chat set-top"
},
{
"canonical_path": "chat.shortcut_chat_messages",
"cli_path": "chat +chat-messages"
},
{
"canonical_path": "chat.shortcut_messages_send",
"cli_path": "chat +messages-send"
},
{
"canonical_path": "chat.shortcut_messages_send_card",
"cli_path": "chat +messages-send-card"
},
{
"canonical_path": "chat.shortcut_search_msg",
"cli_path": "chat +search-msg"
},
{
"canonical_path": "chat.shortcut_thread_replies",
"cli_path": "chat +thread-replies"
}
]
}
@@ -999,6 +999,19 @@
"reviewed": true,
"runtime_gate": "confirm_delete"
},
"aitable.workflow_edit_example": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"cli_path": "aitable workflow edit-example",
"runtime_gate": "none"
},
"aitable.workflow_enable": {
"interface_ref": {
"product_id": "aitable-helper",
+73 -2
View File
@@ -1152,6 +1152,32 @@
"cli_path": "chat +chat-role-query-user",
"runtime_gate": "none"
},
"chat.shortcut_chat_messages": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed built-in Shortcut adapter: it routes group or direct-message history reads, projects a stable message shape, and optionally orchestrates safe resource downloads with a failure ledger.",
"reviewed": true,
"review_reason": "Reviewed against the executable Shortcut and group/direct lower read tools. Optional downloads stay read/not_required under the existing safe local-download convention: workspace-relative paths, no overwrite by default, and atomic publication.",
"cli_path": "chat +chat-messages",
"runtime_gate": "none"
},
"chat.shortcut_messages_send": {
"effect": "write",
"risk": "medium",
"confirmation": "user_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed composite send adapter: it selects current-user, bot, or webhook transport; current-user additionally supports live-compatible contact search with exact userId matching, mediaId images, and the native init/upload/commit local-file flow.",
"reviewed": true,
"review_reason": "Reviewed against every executable identity/content/target validation branch, authenticated contact-search response shape, and the real lower tool mappings; bot and webhook media are intentionally rejected because no equivalent lower capability exists.",
"cli_path": "chat +messages-send",
"runtime_gate": "typed_yes"
},
"chat.shortcut_messages_send_by_webhook": {
"effect": "write",
"risk": "medium",
@@ -1165,6 +1191,25 @@
"cli_path": "chat +messages-send-by-webhook",
"runtime_gate": "typed_yes"
},
"chat.shortcut_messages_send_card": {
"effect": "write",
"risk": "medium",
"confirmation": "user_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed card lifecycle adapter: it can resolve a userId through contact search with exact matching, call create_and_send_card alone, or compose creation with update_streaming_card after extracting the returned bizId.",
"parameters": {
"receiver-open-dingtalk-id": {
"property": "receiverOpenDingTalkId",
"required": false
}
},
"reviewed": true,
"review_reason": "Reviewed against authenticated contact-search resolution, create-only, two-action dry-run, create/update success, missing bizId, and partial-failure behavior that preserves the created bizId; the explicit openDingTalkId flag is bound to the lower interface's exact receiverOpenDingTalkId property.",
"cli_path": "chat +messages-send-card",
"runtime_gate": "typed_yes"
},
"chat.shortcut_messages_list_direct": {
"effect": "read",
"risk": "low",
@@ -1200,7 +1245,7 @@
"availability": "available",
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
"reviewed": true,
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command. Optional downloads stay read/not_required under the existing safe local-download convention and therefore do not create a non-interactive confirmation no-op.",
"cli_path": "chat +messages-mget",
"runtime_gate": "none"
},
@@ -1265,7 +1310,7 @@
"availability": "available",
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
"reviewed": true,
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command. Optional downloads stay read/not_required under the existing safe local-download convention and therefore do not create a non-interactive confirmation no-op.",
"cli_path": "chat +at-me",
"runtime_gate": "none"
},
@@ -1321,6 +1366,19 @@
"cli_path": "chat +my-groups",
"runtime_gate": "none"
},
"chat.shortcut_search_msg": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed search adapter: it combines filters, cursor pagination, batched mget enrichment, stable projection, completeness accounting, and optional safe resource downloads.",
"reviewed": true,
"review_reason": "Reviewed against the executable advanced-search flags plus page and enrichment ledgers. Optional downloads stay read/not_required under the existing safe local-download convention and therefore do not create a non-interactive confirmation no-op.",
"cli_path": "chat +search-msg",
"runtime_gate": "none"
},
"chat.shortcut_send_to_group": {
"effect": "write",
"risk": "medium",
@@ -1334,6 +1392,19 @@
"cli_path": "chat +send-to-group",
"runtime_gate": "typed_yes"
},
"chat.shortcut_thread_replies": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed thread reader adapter: it accepts threadId/topicId, reads lower topic replies, projects stable message fields, and optionally downloads reply resources safely.",
"reviewed": true,
"review_reason": "Reviewed against the executable thread/topic alias contract and real list_topic_replies mapping. Optional downloads stay read/not_required under the existing safe local-download convention and therefore do not create a non-interactive confirmation no-op.",
"cli_path": "chat +thread-replies",
"runtime_gate": "none"
},
"chat.shortcut_unread_chats": {
"effect": "read",
"risk": "low",
@@ -7,7 +7,7 @@
"channel": "open-source"
},
"coverage": {
"source_tools": 840,
"source_tools": 846,
"matched_tools": 71
},
"tools": {
@@ -2250,6 +2250,26 @@
"dws-schema-live:none (helper/composite; Skill+Cobra)"
]
},
"aitable.workflow_edit_example": {
"agent_summary": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。",
"use_when": [
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
],
"avoid_when": [
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
],
"examples": [
"dws aitable workflow edit-example"
],
"reviewed": true,
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"source_refs": [
"internal/cli/schema_command_registry.json#aitable.workflow_edit_example",
"cobra-help:dws aitable workflow edit-example --help",
"skills/mono/references/products/aitable/aitable-workflow.md",
"mcp-contract:aitable/edit_workflow_example"
]
},
"aitable.workflow_enable": {
"agent_summary": "启用工作流。",
"use_when": [
+102 -2
View File
@@ -2110,6 +2110,46 @@
"ShortcutRegistry:chat +chat-role-query-user"
]
},
"chat.shortcut_chat_messages": {
"agent_summary": "拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间",
"use_when": [
"当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
],
"avoid_when": [
"要跨多个会话按关键词、发送者或消息类型检索时使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget"
],
"examples": [
"dws chat +chat-messages --group <openConversationId> --direction older",
"dws chat +chat-messages --open-dingtalk-id <openDingTalkId> --download-resources --output-dir ./downloads"
],
"reviewed": true,
"review_reason": "Agent-authored from the verified group/direct routing, stable projection, pagination, and optional safe resource workflow; it distinguishes conversation history from cross-conversation search and exact-ID mget.",
"source_refs": [
"internal/cli/schema_command_registry.json#chat.shortcut_chat_messages",
"cobra-help:dws chat +chat-messages",
"ShortcutRegistry:chat +chat-messages"
]
},
"chat.shortcut_messages_send": {
"agent_summary": "统一发送文本、Markdown、当前用户文件或已有 mediaId 图片",
"use_when": [
"当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;--user 传 userId 时包括在 --dry-run 中也会先通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId。bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。"
],
"avoid_when": [
"需要 bot/webhook 发送媒体、卡片或 thread 回复时不要假设等价支持;改用真实存在的专用下层命令,缺少下层能力时停止"
],
"examples": [
"dws chat +messages-send --as user --chat-id <openConversationId> --markdown \"## 周报\" --idempotency-key <key>",
"dws chat +messages-send --as user --user <userId> --msg-type file --file ./report.pdf"
],
"reviewed": true,
"review_reason": "Agent-authored from the verified identity/content capability matrix, live-compatible contact search with exact userId matching, native local-file upload flow, and fail-closed rejection of unsupported bot/webhook media.",
"source_refs": [
"internal/cli/schema_command_registry.json#chat.shortcut_messages_send",
"cobra-help:dws chat +messages-send",
"ShortcutRegistry:chat +messages-send"
]
},
"chat.shortcut_messages_send_by_webhook": {
"agent_summary": "自定义机器人 Webhook 发送群消息",
"use_when": [
@@ -2129,6 +2169,26 @@
"ShortcutRegistry:chat +messages-send-by-webhook"
]
},
"chat.shortcut_messages_send_card": {
"agent_summary": "创建流式卡片,可在同一次调用中写入内容并结束",
"use_when": [
"当你要发送一张流式卡片消息时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成,避免卡片停留在加载中。"
],
"avoid_when": [
"已有 bizId、只需要追加或更新现有卡片内容时使用 +messages-update-card"
],
"examples": [
"dws chat +messages-send-card --group <openConversationId> --content \"任务已完成\"",
"dws chat +messages-send-card --receiver <userId>"
],
"reviewed": true,
"review_reason": "Agent-authored from the verified create-only and create-then-update lifecycle, live-compatible contact search with exact userId matching, explicit userId/openDingTalkId routing, two-action dry-run, bizId extraction, and partial-failure reporting.",
"source_refs": [
"internal/cli/schema_command_registry.json#chat.shortcut_messages_send_card",
"cobra-help:dws chat +messages-send-card",
"ShortcutRegistry:chat +messages-send-card"
]
},
"chat.shortcut_messages_list_direct": {
"agent_summary": "拉取单聊会话消息",
"use_when": [
@@ -2170,7 +2230,7 @@
"chat.shortcut_messages_mget": {
"agent_summary": "根据消息 ID 批量查询消息(最多 50 条)",
"use_when": [
"当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。"
"当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId/fileId 安全下载到工作目录内,并逐资源返回成功/失败 ledger;本地下载路径受限于工作目录、默认不覆盖同名文件,按既有安全下载约定无需交互确认。"
],
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
@@ -2265,7 +2325,7 @@
"chat.shortcut_at_me": {
"agent_summary": "查最近 @我 的消息(自动算时间窗,投影发送人/时间/内容/会话)",
"use_when": [
"当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息,再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。这是纯只读操作,只做搜索与本地投影,不会发送、撤回或标记任何消息。"
"当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息,再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。默认只读且不会发送、撤回或标记任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
],
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
@@ -2359,6 +2419,26 @@
"ShortcutRegistry:chat +my-groups"
]
},
"chat.shortcut_search_msg": {
"agent_summary": "多维搜索消息,可全量翻页并批量富化详情",
"use_when": [
"当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
],
"avoid_when": [
"只想读取一个已知会话的连续历史时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"
],
"examples": [
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all",
"dws chat +search-msg --group <openConversationId> --message-type file --download-resources --output-dir ./downloads"
],
"reviewed": true,
"review_reason": "Agent-authored from the verified filter mapping, cursor pagination, batched mget enrichment, completeness ledger, and shared safe resource workflow.",
"source_refs": [
"internal/cli/schema_command_registry.json#chat.shortcut_search_msg",
"cobra-help:dws chat +search-msg",
"ShortcutRegistry:chat +search-msg"
]
},
"chat.shortcut_send_to_group": {
"agent_summary": "按群名直接给群发消息(自动搜群解析 openConversationId)",
"use_when": [
@@ -2378,6 +2458,26 @@
"ShortcutRegistry:chat +send-to-group"
]
},
"chat.shortcut_thread_replies": {
"agent_summary": "拉取某条话题消息的全部回复并投影出发言人/文本/时间",
"use_when": [
"当你已经拿到某个群里一条「话题消息」的 threadId/topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么),而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --thread-id(兼容 --topic-id)拉取该话题的回复列表,可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
],
"avoid_when": [
"要回复 Thread 或发送新回复时不要使用此读取入口;当前没有经过验证的 thread writer Shortcut"
],
"examples": [
"dws chat +thread-replies --group <openConversationId> --thread-id <threadId>",
"dws chat +thread-replies --group <openConversationId> --topic-id <topicId> --download-resources --output-dir ./downloads"
],
"reviewed": true,
"review_reason": "Agent-authored from the verified thread/topic alias, lower list_topic_replies mapping, stable projection, group-context fallback, and optional safe resource workflow.",
"source_refs": [
"internal/cli/schema_command_registry.json#chat.shortcut_thread_replies",
"cobra-help:dws chat +thread-replies",
"ShortcutRegistry:chat +thread-replies"
]
},
"chat.shortcut_unread_chats": {
"agent_summary": "列出我有未读消息的会话(投影会话名/未读数/会话ID)",
"use_when": [
+1 -1
View File
@@ -25,7 +25,7 @@ func TestCrossPlatformCoverageDiagnosticsAndErrorRenderingEdges(t *testing.T) {
}),
)
typed := err.(*Error)
if typed.Retryable || typed.ServerDiag.TraceID != "trace" {
if typed.Retryable || !typed.RetryableSet || typed.ServerDiag.TraceID != "trace" {
t.Fatalf("diagnostics options = %#v", typed)
}
if (ServerDiagnostics{TraceID: "trace"}).IsEmpty() {
+1
View File
@@ -44,6 +44,7 @@ func WithServerDiag(diag ServerDiagnostics) Option {
// Override retryable if server explicitly specified.
if diag.ServerRetryable != nil {
e.Retryable = *diag.ServerRetryable
e.RetryableSet = true
}
}
}
+59 -18
View File
@@ -20,6 +20,7 @@ import (
"fmt"
"io"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
@@ -41,20 +42,23 @@ const (
// Error is the structured repository-local error model for the Go rewrite.
type Error struct {
Category Category
Message string
Operation string
ServerKey string
Retryable bool
Reason string
Hint string
Actions []string
AvailableFlags []string
Snapshot string
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
ServerDiag ServerDiagnostics `json:"-"`
Cause error `json:"-"`
Category Category
Message string
Operation string
ServerKey string
Retryable bool
RetryableSet bool
RetryAfterSeconds *int64
NextRetryAt *time.Time
Reason string
Hint string
Actions []string
AvailableFlags []string
Snapshot string
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
ServerDiag ServerDiagnostics `json:"-"`
Cause error `json:"-"`
}
func (e *Error) Error() string {
@@ -107,6 +111,31 @@ func WithServerKey(serverKey string) Option {
func WithRetryable(retryable bool) Option {
return func(err *Error) {
err.Retryable = retryable
err.RetryableSet = true
}
}
// WithRetryAfterSeconds records the server-recommended delay before a retry.
// A zero delay is meaningful and is therefore preserved; negative values are
// ignored as invalid server guidance.
func WithRetryAfterSeconds(seconds int64) Option {
return func(err *Error) {
if seconds < 0 {
return
}
value := seconds
err.RetryAfterSeconds = &value
}
}
// WithNextRetryAt records the absolute time at which a retry may be attempted.
func WithNextRetryAt(next time.Time) Option {
return func(err *Error) {
if next.IsZero() {
return
}
value := next.UTC()
err.NextRetryAt = &value
}
}
@@ -266,8 +295,14 @@ func PrintJSON(w io.Writer, err error) error {
if typed.ServerKey != "" {
errorPayload["server_key"] = typed.ServerKey
}
if typed.Retryable {
errorPayload["retryable"] = true
if typed.RetryableSet {
errorPayload["retryable"] = typed.Retryable
}
if typed.RetryAfterSeconds != nil {
errorPayload["retry_after_seconds"] = *typed.RetryAfterSeconds
}
if typed.NextRetryAt != nil {
errorPayload["next_retry_at"] = typed.NextRetryAt.UTC().Format(time.RFC3339)
}
if typed.Hint != "" {
errorPayload["hint"] = typed.Hint
@@ -383,8 +418,14 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
if line := formatAvailableFlagsHumanLine(typed.AvailableFlags); line != "" {
lines = append(lines, tui.Dim(line))
}
if typed.Retryable {
lines = append(lines, tui.Warning("Retryable: true"))
if typed.RetryableSet {
lines = append(lines, tui.Warning(fmt.Sprintf("Retryable: %t", typed.Retryable)))
}
if typed.RetryAfterSeconds != nil {
lines = append(lines, tui.Warning(fmt.Sprintf("Retry After: %ds", *typed.RetryAfterSeconds)))
}
if typed.NextRetryAt != nil {
lines = append(lines, tui.Warning("Next Retry At: "+typed.NextRetryAt.UTC().Format(time.RFC3339)))
}
// Always shown when present: Trace ID, Server Code
+94
View File
@@ -17,6 +17,7 @@ import (
stderrors "errors"
"strings"
"testing"
"time"
)
func TestExitCodeByCategory(t *testing.T) {
@@ -77,6 +78,99 @@ func TestPrintJSON(t *testing.T) {
}
}
func TestCrossPlatformCoverageRetryabilityTriStateAndRetryTiming(t *testing.T) {
t.Parallel()
next := time.Date(2026, time.July, 30, 4, 5, 6, 0, time.FixedZone("CST", 8*60*60))
tests := []struct {
name string
err error
wantRetryable string
wantRetryAfter bool
wantNextRetryAt bool
}{
{
name: "unknown is omitted",
err: NewAPI("unknown"),
},
{
name: "explicit false is preserved",
err: NewValidation("terminal", WithRetryable(false)),
wantRetryable: `"retryable": false`,
},
{
name: "explicit true with timing",
err: NewAPI("transient", WithRetryable(true), WithRetryAfterSeconds(30), WithNextRetryAt(next)),
wantRetryable: `"retryable": true`,
wantRetryAfter: true,
wantNextRetryAt: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var jsonOut strings.Builder
if err := PrintJSON(&jsonOut, tt.err); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
gotJSON := jsonOut.String()
if tt.wantRetryable == "" {
if strings.Contains(gotJSON, `"retryable"`) {
t.Fatalf("unknown retryability must be omitted: %s", gotJSON)
}
} else if !strings.Contains(gotJSON, tt.wantRetryable) {
t.Fatalf("missing %s in %s", tt.wantRetryable, gotJSON)
}
if got := strings.Contains(gotJSON, `"retry_after_seconds": 30`); got != tt.wantRetryAfter {
t.Fatalf("retry_after_seconds presence = %v, want %v: %s", got, tt.wantRetryAfter, gotJSON)
}
if got := strings.Contains(gotJSON, `"next_retry_at": "2026-07-29T20:05:06Z"`); got != tt.wantNextRetryAt {
t.Fatalf("next_retry_at presence = %v, want %v: %s", got, tt.wantNextRetryAt, gotJSON)
}
var humanOut strings.Builder
if err := PrintHuman(&humanOut, tt.err); err != nil {
t.Fatalf("PrintHuman() error = %v", err)
}
gotHuman := humanOut.String()
if tt.wantRetryable == "" {
if strings.Contains(gotHuman, "Retryable:") {
t.Fatalf("unknown retryability must be omitted: %s", gotHuman)
}
} else {
want := "Retryable: true"
if strings.Contains(tt.wantRetryable, "false") {
want = "Retryable: false"
}
if !strings.Contains(gotHuman, want) {
t.Fatalf("missing %q in %s", want, gotHuman)
}
}
if tt.wantRetryAfter && !strings.Contains(gotHuman, "Retry After: 30s") {
t.Fatalf("missing retry delay in %s", gotHuman)
}
if tt.wantNextRetryAt && !strings.Contains(gotHuman, "Next Retry At: 2026-07-29T20:05:06Z") {
t.Fatalf("missing next retry time in %s", gotHuman)
}
})
}
}
func TestCrossPlatformCoverageRetryTimingOptionsIgnoreInvalidValues(t *testing.T) {
t.Parallel()
err := NewAPI(
"invalid timing",
WithRetryAfterSeconds(-1),
WithNextRetryAt(time.Time{}),
).(*Error)
if err.RetryAfterSeconds != nil || err.NextRetryAt != nil {
t.Fatalf("invalid retry timing was retained: %#v", err)
}
}
func TestPrintJSON_AvailableFlags(t *testing.T) {
t.Parallel()
+3 -1
View File
@@ -25,7 +25,9 @@ import (
)
// MetaFileName is the on-disk name of the bus metadata file. It lives
// alongside bus.lock and bus.sock inside the bus working directory.
// alongside bus.lock inside the bus working directory. Unix bus sockets live
// in a private per-user runtime directory so shared config filesystems do not
// need socket support.
const MetaFileName = "bus.meta"
// Meta is the JSON document written once at bus startup. Its primary
+3 -3
View File
@@ -30,9 +30,9 @@ import (
type SpawnFunc func(SpawnConfig) (pid int, err error)
// DiscoverConfig describes one discover attempt. WorkDir holds bus.lock and
// usually (on Unix) bus.sock — see dwsevent.IPCEndpoint for the short-path
// fallback when WorkDir is too deep; the caller must mkdir it with
// pkg/config.DirPerm beforehand.
// persistent bus metadata; Unix sockets live in a private per-user runtime
// directory so WorkDir may reside on a shared filesystem without socket
// support. The caller must mkdir WorkDir with pkg/config.DirPerm beforehand.
type DiscoverConfig struct {
WorkDir string
IPCEndpoint string
+2 -2
View File
@@ -69,8 +69,8 @@ type BusEntry struct {
// IPCEndpoint returns the IPC endpoint for this entry. Delegates to
// dwsevent.IPCEndpoint so status/stop dial exactly where consume and the
// bus daemon bound (including the short-path fallback when WorkDir is too
// deep for sun_path).
// bus daemon bound (a private per-user runtime path on Unix and a named pipe
// on Windows).
func (e BusEntry) IPCEndpoint() string {
hash := e.ClientIDHash
if e.IdentityHash != "" {
+36 -14
View File
@@ -17,8 +17,16 @@ import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
)
const eventRuntimeDirPrefix = "dws-event-"
func currentUserID() string {
return strconv.Itoa(os.Geteuid())
}
// MaxUnixSocketPath returns the longest Unix socket path accepted by
// bind/connect on this OS (Go rejects longer names with EINVAL before
// the syscall). sockaddr_un.sun_path is 104 bytes on darwin and the
@@ -35,17 +43,19 @@ func maxUnixSocketPath(goos string) int {
}
// IPCEndpoint returns the bus IPC endpoint for one identity: a Named Pipe
// name on Windows, otherwise bus.sock inside workDir.
// name on Windows, otherwise a deterministic Unix socket under a private
// per-user runtime directory.
//
// The canonical Unix location is <workDir>/bus.sock, but workDir derives
// from the config dir, which can be arbitrarily deep (e.g. dwssb sandboxes
// use ~/.dwssb/sandboxes/<name>/config/...). When the canonical path would
// exceed the OS sun_path limit, the socket falls back to a short
// deterministic path under os.TempDir keyed by a hash of workDir, so every
// process (consume parent, forked _bus child, status/stop tooling) that
// derives the endpoint from the same workDir agrees on the location.
// bus.lock / bus.meta / bus.log always stay in workDir — only the socket
// moves.
// Unix sockets must live on a local filesystem that supports bind(2).
// Config directories may reside on NFS, CSI, FUSE, or other shared mounts
// that reject Unix socket creation with ENOTSUPP. On Unix, the endpoint uses
// XDG_RUNTIME_DIR when it is absolute and short enough; otherwise it falls
// back to a per-UID directory under os.TempDir. The transport creates and
// validates that directory as owner-only before listening or dialing. The
// socket name is keyed by a hash of workDir so every process (consume parent,
// forked _bus child, status/stop tooling) that derives the endpoint from the
// same workDir agrees on the location. bus.lock / bus.meta / bus.log always
// stay in workDir.
//
// This is the single source of truth for endpoint derivation; the cobra
// layer and busctl must not re-implement the shape.
@@ -60,9 +70,21 @@ func ipcEndpointForOS(goos, workDir, editionName string, sourceKind SourceKind,
if goos == "windows" {
return `\\.\pipe\dws-event-` + editionName + "-" + string(sourceKind) + "-" + identityHash
}
sock := filepath.Join(workDir, "bus.sock")
if len(sock) <= maxUnixSocketPath(goos) {
return sock
return unixSocketEndpoint(goos, workDir, strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")), os.TempDir())
}
func unixSocketEndpoint(goos, workDir, runtimeDir, tempDir string) string {
socketName := "dws-evt-" + IdentityHash(workDir) + ".sock"
userDirName := eventRuntimeDirPrefix + currentUserID()
if filepath.IsAbs(runtimeDir) {
candidate := filepath.Join(runtimeDir, userDirName, socketName)
if len(candidate) <= maxUnixSocketPath(goos) {
return candidate
}
}
return filepath.Join(os.TempDir(), "dws-evt-"+IdentityHash(workDir)+".sock")
fallback := filepath.Join(tempDir, userDirName, socketName)
if len(fallback) <= maxUnixSocketPath(goos) {
return fallback
}
return filepath.Join("/tmp", userDirName, socketName)
}
+19 -1
View File
@@ -14,6 +14,7 @@
package event
import (
"os"
"path/filepath"
"strings"
"testing"
@@ -33,10 +34,27 @@ func TestCrossPlatformCoverageEndpointPortableCoverageEdges(t *testing.T) {
if got := ipcEndpointForOS("windows", "ignored", "open", "", "hash"); got != `\\.\pipe\dws-event-open-app_stream-hash` {
t.Fatalf("Windows endpoint = %q", got)
}
if got := ipcEndpointForOS("darwin", "short", "open", SourceKindPersonalStream, "hash"); got != filepath.Join("short", "bus.sock") {
runtimeRoot := filepath.VolumeName(os.TempDir()) + string(filepath.Separator)
runtimeDir := filepath.Join(runtimeRoot, "dws-xdg-runtime")
t.Setenv("XDG_RUNTIME_DIR", runtimeDir)
workDir := "portable-xdg-workdir"
wantXDG := filepath.Join(runtimeDir, eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock")
if got := ipcEndpointForOS("linux", workDir, "open", SourceKindPersonalStream, "hash"); got != wantXDG {
t.Fatalf("XDG Unix endpoint = %q, want %q", got, wantXDG)
}
t.Setenv("XDG_RUNTIME_DIR", "")
if got := ipcEndpointForOS("darwin", "short", "open", SourceKindPersonalStream, "hash"); got != filepath.Join(os.TempDir(), eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash("short")+".sock") {
t.Fatalf("short Unix endpoint = %q", got)
}
if got := ipcEndpointForOS("darwin", strings.Repeat("x", 200), "open", SourceKindAppStream, "hash"); !strings.Contains(got, "dws-evt-") {
t.Fatalf("long Unix endpoint = %q", got)
}
longTempDir := filepath.Join(string(filepath.Separator), strings.Repeat("long-temp-root", 20))
wantShortFallback := filepath.Join("/tmp", eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock")
if got := unixSocketEndpoint("darwin", workDir, "", longTempDir); got != wantShortFallback {
t.Fatalf("overlong temp endpoint = %q, want %q", got, wantShortFallback)
}
}
+57 -5
View File
@@ -23,6 +23,7 @@ import (
)
func TestEndpointPlatformVariants(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", "")
if maxUnixSocketPath("linux") != 107 || maxUnixSocketPath("darwin") != 103 {
t.Fatal("Unix socket limits changed")
}
@@ -31,7 +32,7 @@ func TestEndpointPlatformVariants(t *testing.T) {
t.Fatalf("Windows pipe = %q", pipe)
}
short := ipcEndpointForOS("darwin", "/tmp/events", "open", SourceKindPersonalStream, "hash")
if short != filepath.Join("/tmp/events", "bus.sock") {
if short != filepath.Join(os.TempDir(), eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash("/tmp/events")+".sock") {
t.Fatalf("short Unix endpoint = %q", short)
}
long := ipcEndpointForOS("darwin", "/"+strings.Repeat("deep/", 40), "open", SourceKindAppStream, "hash")
@@ -40,16 +41,40 @@ func TestEndpointPlatformVariants(t *testing.T) {
}
}
func TestIPCEndpointShortWorkDirUsesCanonicalPath(t *testing.T) {
workDir := "/tmp/dws/events/open/app_stream/aabbccdd00112233"
func TestIPCEndpointUsesXDGUserRuntimeDir(t *testing.T) {
tempRoot, err := filepath.EvalSymlinks("/tmp")
if err != nil {
t.Fatalf("EvalSymlinks: %v", err)
}
runtimeDir, err := os.MkdirTemp(tempRoot, "dws-xdg-")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(runtimeDir) })
t.Setenv("XDG_RUNTIME_DIR", runtimeDir)
workDir := "/shared/events/open/app_stream/aabbccdd00112233"
got := IPCEndpoint(workDir, "open", SourceKindAppStream, "aabbccdd00112233")
want := filepath.Join(workDir, "bus.sock")
want := filepath.Join(runtimeDir, eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock")
if got != want {
t.Fatalf("IPCEndpoint = %q, want %q", got, want)
}
}
func TestIPCEndpointLongWorkDirFallsBackUnderTempDir(t *testing.T) {
func TestIPCEndpointWithoutXDGUsesPerUserLocalTempDir(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", "")
workDir := "/tmp/dws/events/open/app_stream/aabbccdd00112233"
got := IPCEndpoint(workDir, "open", SourceKindAppStream, "aabbccdd00112233")
want := filepath.Join(os.TempDir(), eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock")
if got != want {
t.Fatalf("IPCEndpoint = %q, want %q", got, want)
}
if strings.HasPrefix(got, workDir) {
t.Fatalf("IPCEndpoint = %q, want endpoint outside workDir", got)
}
}
func TestIPCEndpointLongWorkDirUsesLocalTempDir(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", "")
// Mirrors the dwssb sandbox layout that produced a 111-byte socket
// path — over macOS's 103-byte usable sun_path budget.
workDir := "/Users/zhengyubai/.dwssb/sandboxes/event-subscribe/config/events/open/personal_stream/3928ce0fb4860a52"
@@ -66,6 +91,7 @@ func TestIPCEndpointLongWorkDirFallsBackUnderTempDir(t *testing.T) {
}
func TestIPCEndpointFallbackIsDeterministicPerWorkDir(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", "")
long := strings.Repeat("x", 120)
a := IPCEndpoint("/base/"+long+"/one", "open", SourceKindPersonalStream, "hash")
b := IPCEndpoint("/base/"+long+"/one", "open", SourceKindPersonalStream, "hash")
@@ -77,3 +103,29 @@ func TestIPCEndpointFallbackIsDeterministicPerWorkDir(t *testing.T) {
t.Fatalf("different workDirs collided on endpoint %q", a)
}
}
func TestIPCEndpointLongXDGPathFallsBackToTempDir(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", "/"+strings.Repeat("runtime/", 30))
workDir := "/shared/events/open/personal_stream/aabbccdd00112233"
got := ipcEndpointForOS("linux", workDir, "open", SourceKindPersonalStream, "hash")
wantPrefix := filepath.Join(os.TempDir(), eventRuntimeDirPrefix+currentUserID()) + string(filepath.Separator)
if !strings.HasPrefix(got, wantPrefix) {
t.Fatalf("IPCEndpoint = %q, want fallback under %q", got, wantPrefix)
}
if len(got) > maxUnixSocketPath("linux") {
t.Fatalf("fallback path still too long: %d > %d (%q)", len(got), maxUnixSocketPath("linux"), got)
}
}
func TestIPCEndpointLongTempDirUsesShortSystemFallback(t *testing.T) {
workDir := "/shared/events/open/personal_stream/aabbccdd00112233"
longTempDir := "/" + strings.Repeat("long-temp-root/", 20)
got := unixSocketEndpoint("linux", workDir, "", longTempDir)
want := filepath.Join("/tmp", eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock")
if got != want {
t.Fatalf("IPCEndpoint = %q, want short fallback %q", got, want)
}
if len(got) > maxUnixSocketPath("linux") {
t.Fatalf("short fallback path too long: %d > %d (%q)", len(got), maxUnixSocketPath("linux"), got)
}
}
+866
View File
@@ -0,0 +1,866 @@
// 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 personal
import (
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
eventlock "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/lock"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
const (
// AttemptStateFileName stores retry suppression state for personal
// subscription creation. It intentionally does not share the successful
// subscription run-state file.
AttemptStateFileName = "personal_subscription_attempts.json"
// AttemptStateLockFileName serializes cross-process attempt-state changes.
AttemptStateLockFileName = "personal_subscription_attempts.lock"
attemptStateVersion = 1
attemptLockWaitTimeout = 5 * time.Second
attemptLockRetryDelay = 25 * time.Millisecond
attemptFailureReset = 24 * time.Hour
attemptTerminalHold = time.Hour
attemptMaxFieldLength = 256
)
var (
// ErrAttemptClaimStale means another process replaced at least one claimed
// fingerprint. Completion is rejected as a unit so an old process cannot
// overwrite newer state.
ErrAttemptClaimStale = errors.New("personal event: subscription attempt claim is stale")
)
// AttemptState is the persisted lifecycle state of one subscription attempt.
type AttemptState string
const (
AttemptStateInFlight AttemptState = "in_flight"
AttemptStateCooldown AttemptState = "cooldown"
AttemptStateTerminalHold AttemptState = "terminal_hold"
)
// Retryability preserves the distinction between an explicit server decision
// and an error for which the server did not provide retry guidance.
type Retryability string
const (
RetryabilityUnknown Retryability = "unknown"
RetryabilityRetryable Retryability = "retryable"
RetryabilityNonRetryable Retryability = "non_retryable"
)
// Value converts retryability to a bool while preserving whether it is known.
func (r Retryability) Value() (value bool, known bool) {
switch r {
case RetryabilityRetryable:
return true, true
case RetryabilityNonRetryable:
return false, true
default:
return false, false
}
}
// AttemptSpec identifies one normalized subscription request. Fingerprint must
// come from Fingerprint; only the digest and the non-sensitive event key are
// persisted.
type AttemptSpec struct {
Fingerprint string
EventKey string
}
// AttemptClaim is the ownership token returned by Claim. Callers must finish
// it with CompleteSuccess, CompleteFailure, or Release.
type AttemptClaim struct {
AttemptID string
Fingerprints []string
previous map[string]attemptPrevious
}
type attemptPrevious struct {
record attemptRecord
present bool
}
// AttemptBlockedError reports a local suppression decision. It never includes
// the raw endpoint, idempotency key, profile selector, rule parameter, or
// filter.
type AttemptBlockedError struct {
Fingerprint string
EventKey string
State AttemptState
Retryability Retryability
RetryAfter time.Duration
NextAllowedAt time.Time
FailureCount int
ErrorCode string
TraceID string
}
func (e *AttemptBlockedError) Error() string {
if e == nil {
return ""
}
return fmt.Sprintf(
"personal event: subscription attempt %s until %s",
e.State,
e.NextAllowedAt.UTC().Format(time.RFC3339),
)
}
// AttemptFailure describes the one failed item in a claimed batch.
type AttemptFailure struct {
Fingerprint string
Retryability Retryability
RetryAfter time.Duration
ErrorCode string
TraceID string
}
// AttemptHold is the persisted suppression decision after CompleteFailure.
type AttemptHold struct {
Fingerprint string
State AttemptState
Retryability Retryability
RetryAfter time.Duration
NextAllowedAt time.Time
FailureCount int
}
// AttemptStoreOption customizes an AttemptStore.
type AttemptStoreOption func(*AttemptStore)
// WithAttemptClock installs a clock, primarily for deterministic tests.
func WithAttemptClock(now func() time.Time) AttemptStoreOption {
return func(store *AttemptStore) {
if now != nil {
store.now = now
}
}
}
// WithAttemptIDGenerator installs an attempt-ID generator. IDs are persisted
// only as CAS tokens and must not contain request data.
func WithAttemptIDGenerator(generate func() (string, error)) AttemptStoreOption {
return func(store *AttemptStore) {
if generate != nil {
store.newID = generate
}
}
}
// AttemptStore coordinates personal subscription creation across CLI
// processes sharing one identity work directory.
type AttemptStore struct {
workDir string
now func() time.Time
newID func() (string, error)
readFile func(string) ([]byte, error)
mkdirAll func(string, os.FileMode) error
tryAcquire func(string) (*eventlock.File, error)
remove func(string) error
marshal func(any, string, string) ([]byte, error)
writeFile func(string, []byte, os.FileMode) error
chmod func(string, os.FileMode) error
rename func(string, string) error
sleep func(time.Duration)
}
// NewAttemptStore creates a fail-closed persistent attempt guard rooted in the
// identity-specific personal event work directory.
func NewAttemptStore(workDir string, options ...AttemptStoreOption) *AttemptStore {
store := &AttemptStore{
workDir: strings.TrimSpace(workDir),
now: time.Now,
newID: newAttemptID,
readFile: os.ReadFile,
mkdirAll: os.MkdirAll,
tryAcquire: eventlock.TryAcquire,
remove: os.Remove,
marshal: json.MarshalIndent,
writeFile: os.WriteFile,
chmod: os.Chmod,
rename: os.Rename,
sleep: time.Sleep,
}
for _, option := range options {
if option != nil {
option(store)
}
}
return store
}
// Fingerprint hashes an endpoint, the existing subscription idempotency key,
// and optional profile selectors into a stable non-reversible store key.
// Passing the active selector ensures two profiles resolving to the same
// corp/user can recover independently after a profile change.
func Fingerprint(endpoint, idempotencyKey string, profileSelector ...string) string {
endpoint = normalizeAttemptEndpoint(endpoint)
idempotencyKey = strings.TrimSpace(idempotencyKey)
if endpoint == "" || idempotencyKey == "" {
return ""
}
h := sha256.New()
writeFingerprintPart(h, "dws-personal-subscription-attempt-v1")
writeFingerprintPart(h, endpoint)
writeFingerprintPart(h, idempotencyKey)
for _, selector := range profileSelector {
if selector = strings.TrimSpace(selector); selector != "" {
writeFingerprintPart(h, selector)
}
}
return hex.EncodeToString(h.Sum(nil))
}
type fingerprintWriter interface {
Write([]byte) (int, error)
}
func writeFingerprintPart(w fingerprintWriter, value string) {
var size [8]byte
binary.BigEndian.PutUint64(size[:], uint64(len(value)))
_, _ = w.Write(size[:])
_, _ = w.Write([]byte(value))
}
func normalizeAttemptEndpoint(raw string) string {
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return raw
}
parsed.Scheme = strings.ToLower(parsed.Scheme)
parsed.Host = strings.ToLower(parsed.Host)
parsed.Fragment = ""
parsed.Path = strings.TrimRight(parsed.Path, "/")
return parsed.String()
}
// Claim atomically reserves every fingerprint in specs. If any fingerprint is
// still suppressed, none are reserved and an AttemptBlockedError is returned.
func (s *AttemptStore) Claim(specs []AttemptSpec, lease time.Duration) (*AttemptClaim, error) {
normalized, err := normalizeAttemptSpecs(specs)
if err != nil {
return nil, err
}
if lease <= 0 {
return nil, errors.New("personal event: subscription attempt lease must be positive")
}
if err := s.validate(); err != nil {
return nil, err
}
attemptID, err := s.newID()
if err != nil {
return nil, fmt.Errorf("personal event: generate subscription attempt id: %w", err)
}
attemptID = strings.TrimSpace(attemptID)
if attemptID == "" || len(attemptID) > attemptMaxFieldLength {
return nil, errors.New("personal event: generated subscription attempt id is invalid")
}
now := s.now().UTC()
claim := &AttemptClaim{
AttemptID: attemptID,
Fingerprints: make([]string, 0, len(normalized)),
previous: make(map[string]attemptPrevious, len(normalized)),
}
var blocked *AttemptBlockedError
err = s.withLock(func() error {
state, err := s.load()
if err != nil {
return err
}
pruneAttemptRecords(state, now)
for _, spec := range normalized {
record, ok := state.Records[spec.Fingerprint]
if !ok {
continue
}
if candidate := blockedAttempt(record, now); candidate != nil {
blocked = candidate
return nil
}
}
for _, spec := range normalized {
previous, present := state.Records[spec.Fingerprint]
claim.previous[spec.Fingerprint] = attemptPrevious{
record: previous,
present: present,
}
claim.Fingerprints = append(claim.Fingerprints, spec.Fingerprint)
record := previous
record.Fingerprint = spec.Fingerprint
record.EventKey = spec.EventKey
record.State = AttemptStateInFlight
record.AttemptID = attemptID
record.LeaseUntil = now.Add(lease)
record.NextAllowedAt = time.Time{}
record.Retryability = ""
record.ErrorCode = ""
record.TraceID = ""
state.Records[spec.Fingerprint] = record
}
return s.write(state)
})
if err != nil {
return nil, err
}
if blocked != nil {
return nil, blocked
}
return claim, nil
}
// CompleteSuccess clears failure history for every item in a successful
// claimed batch.
func (s *AttemptStore) CompleteSuccess(claim *AttemptClaim) error {
if err := validateAttemptClaim(claim); err != nil {
return err
}
if err := s.validate(); err != nil {
return err
}
return s.withLock(func() error {
state, err := s.load()
if err != nil {
return err
}
if !claimOwnsAll(state, claim) {
return ErrAttemptClaimStale
}
for _, fingerprint := range claim.Fingerprints {
delete(state.Records, fingerprint)
}
return s.write(state)
})
}
// CompleteFailure records the failed item, clears successfully completed
// items, and restores every unexecuted item to its exact pre-claim state.
func (s *AttemptStore) CompleteFailure(
claim *AttemptClaim,
succeeded []string,
failure AttemptFailure,
) (AttemptHold, error) {
var zero AttemptHold
if err := validateAttemptClaim(claim); err != nil {
return zero, err
}
failure.Fingerprint = strings.TrimSpace(failure.Fingerprint)
if !containsFingerprint(claim.Fingerprints, failure.Fingerprint) {
return zero, errors.New("personal event: failed fingerprint is not part of the attempt claim")
}
if !validRetryability(failure.Retryability) {
return zero, fmt.Errorf("personal event: invalid retryability %q", failure.Retryability)
}
succeededSet, err := normalizeSucceededFingerprints(claim, succeeded, failure.Fingerprint)
if err != nil {
return zero, err
}
if err := s.validate(); err != nil {
return zero, err
}
now := s.now().UTC()
var hold AttemptHold
err = s.withLock(func() error {
state, err := s.load()
if err != nil {
return err
}
if !claimOwnsAll(state, claim) {
return ErrAttemptClaimStale
}
for _, fingerprint := range claim.Fingerprints {
switch {
case fingerprint == failure.Fingerprint:
previous := claim.previous[fingerprint]
record := previous.record
count := record.FailureCount
if record.LastFailureAt.IsZero() || now.Sub(record.LastFailureAt) >= attemptFailureReset {
count = 0
}
count++
delay, stateName := attemptFailureDelay(fingerprint, count, failure)
record.Fingerprint = fingerprint
record.EventKey = state.Records[fingerprint].EventKey
record.State = stateName
record.AttemptID = ""
record.LeaseUntil = time.Time{}
record.FailureCount = count
record.LastFailureAt = now
record.NextAllowedAt = now.Add(delay)
record.Retryability = failure.Retryability
record.ErrorCode = boundedAttemptField(failure.ErrorCode)
record.TraceID = boundedAttemptField(failure.TraceID)
state.Records[fingerprint] = record
hold = AttemptHold{
Fingerprint: fingerprint,
State: stateName,
Retryability: failure.Retryability,
RetryAfter: delay,
NextAllowedAt: record.NextAllowedAt,
FailureCount: count,
}
case succeededSet[fingerprint]:
delete(state.Records, fingerprint)
default:
restoreAttemptPrevious(state, fingerprint, claim.previous[fingerprint])
}
}
return s.write(state)
})
if err != nil {
return zero, err
}
return hold, nil
}
// Release restores every claimed item without recording a remote failure. It
// is intended for local failures before a subscription request is sent.
func (s *AttemptStore) Release(claim *AttemptClaim) error {
if err := validateAttemptClaim(claim); err != nil {
return err
}
if err := s.validate(); err != nil {
return err
}
return s.withLock(func() error {
state, err := s.load()
if err != nil {
return err
}
if !claimOwnsAll(state, claim) {
return ErrAttemptClaimStale
}
for _, fingerprint := range claim.Fingerprints {
restoreAttemptPrevious(state, fingerprint, claim.previous[fingerprint])
}
return s.write(state)
})
}
func (s *AttemptStore) validate() error {
if s == nil {
return errors.New("personal event: nil subscription attempt store")
}
if s.workDir == "" {
return errors.New("personal event: subscription attempt work directory is required")
}
if s.now == nil || s.newID == nil || s.readFile == nil || s.mkdirAll == nil ||
s.tryAcquire == nil || s.remove == nil || s.marshal == nil ||
s.writeFile == nil || s.chmod == nil || s.rename == nil || s.sleep == nil {
return errors.New("personal event: subscription attempt store is not initialized")
}
return nil
}
func normalizeAttemptSpecs(specs []AttemptSpec) ([]AttemptSpec, error) {
if len(specs) == 0 {
return nil, errors.New("personal event: at least one subscription attempt is required")
}
out := make([]AttemptSpec, 0, len(specs))
seen := make(map[string]struct{}, len(specs))
for _, spec := range specs {
spec.Fingerprint = strings.TrimSpace(spec.Fingerprint)
spec.EventKey = strings.TrimSpace(spec.EventKey)
if !validAttemptFingerprint(spec.Fingerprint) {
return nil, errors.New("personal event: subscription attempt fingerprint is invalid")
}
if len(spec.EventKey) > attemptMaxFieldLength {
return nil, errors.New("personal event: subscription attempt event key is too long")
}
if _, ok := seen[spec.Fingerprint]; ok {
continue
}
seen[spec.Fingerprint] = struct{}{}
out = append(out, spec)
}
return out, nil
}
func normalizeSucceededFingerprints(
claim *AttemptClaim,
succeeded []string,
failed string,
) (map[string]bool, error) {
out := make(map[string]bool, len(succeeded))
for _, fingerprint := range succeeded {
fingerprint = strings.TrimSpace(fingerprint)
if fingerprint == failed {
return nil, errors.New("personal event: failed fingerprint cannot also be successful")
}
if !containsFingerprint(claim.Fingerprints, fingerprint) {
return nil, errors.New("personal event: successful fingerprint is not part of the attempt claim")
}
out[fingerprint] = true
}
return out, nil
}
func validateAttemptClaim(claim *AttemptClaim) error {
if claim == nil || strings.TrimSpace(claim.AttemptID) == "" ||
len(claim.Fingerprints) == 0 || claim.previous == nil {
return errors.New("personal event: invalid subscription attempt claim")
}
for _, fingerprint := range claim.Fingerprints {
if !validAttemptFingerprint(fingerprint) {
return errors.New("personal event: subscription attempt claim contains an invalid fingerprint")
}
if _, ok := claim.previous[fingerprint]; !ok {
return errors.New("personal event: subscription attempt claim is incomplete")
}
}
return nil
}
func containsFingerprint(fingerprints []string, target string) bool {
for _, fingerprint := range fingerprints {
if fingerprint == target {
return true
}
}
return false
}
func validAttemptFingerprint(value string) bool {
if len(value) != sha256.Size*2 {
return false
}
decoded, err := hex.DecodeString(value)
return err == nil && len(decoded) == sha256.Size
}
func validRetryability(value Retryability) bool {
switch value {
case RetryabilityUnknown, RetryabilityRetryable, RetryabilityNonRetryable:
return true
default:
return false
}
}
func attemptFailureDelay(
fingerprint string,
count int,
failure AttemptFailure,
) (time.Duration, AttemptState) {
if failure.Retryability == RetryabilityNonRetryable {
return attemptTerminalHold, AttemptStateTerminalHold
}
base := attemptBackoff(count)
delay := base + deterministicAttemptJitter(fingerprint, count, base)
if failure.RetryAfter > delay {
delay = failure.RetryAfter
}
return delay, AttemptStateCooldown
}
func attemptBackoff(count int) time.Duration {
switch count {
case 1:
return 30 * time.Second
case 2:
return 2 * time.Minute
case 3:
return 10 * time.Minute
default:
return 30 * time.Minute
}
}
func deterministicAttemptJitter(fingerprint string, count int, base time.Duration) time.Duration {
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", fingerprint, count)))
// 0..2000 basis points, inclusive (0%..20%).
basisPoints := int(binary.BigEndian.Uint16(sum[:2])) % 2001
return time.Duration(int64(base) * int64(basisPoints) / 10000)
}
func boundedAttemptField(value string) string {
value = strings.TrimSpace(value)
if len(value) > attemptMaxFieldLength {
return value[:attemptMaxFieldLength]
}
return value
}
func restoreAttemptPrevious(state *attemptStateFile, fingerprint string, previous attemptPrevious) {
if previous.present {
state.Records[fingerprint] = previous.record
return
}
delete(state.Records, fingerprint)
}
func claimOwnsAll(state *attemptStateFile, claim *AttemptClaim) bool {
for _, fingerprint := range claim.Fingerprints {
record, ok := state.Records[fingerprint]
if !ok || record.State != AttemptStateInFlight || record.AttemptID != claim.AttemptID {
return false
}
}
return true
}
func blockedAttempt(record attemptRecord, now time.Time) *AttemptBlockedError {
var next time.Time
retryability := record.Retryability
switch record.State {
case AttemptStateInFlight:
if !record.LeaseUntil.After(now) {
return nil
}
next = record.LeaseUntil
retryability = RetryabilityUnknown
case AttemptStateCooldown, AttemptStateTerminalHold:
if !record.NextAllowedAt.After(now) {
return nil
}
next = record.NextAllowedAt
default:
return nil
}
return &AttemptBlockedError{
Fingerprint: record.Fingerprint,
EventKey: record.EventKey,
State: record.State,
Retryability: retryability,
RetryAfter: next.Sub(now),
NextAllowedAt: next,
FailureCount: record.FailureCount,
ErrorCode: record.ErrorCode,
TraceID: record.TraceID,
}
}
type attemptStateFile struct {
Version int `json:"version"`
Records map[string]attemptRecord `json:"records"`
}
type attemptRecord struct {
Fingerprint string `json:"fingerprint"`
EventKey string `json:"event_key,omitempty"`
State AttemptState `json:"state"`
AttemptID string `json:"attempt_id,omitempty"`
LeaseUntil time.Time `json:"lease_until,omitempty"`
FailureCount int `json:"failure_count,omitempty"`
LastFailureAt time.Time `json:"last_failure_at,omitempty"`
NextAllowedAt time.Time `json:"next_allowed_at,omitempty"`
Retryability Retryability `json:"retryability,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
TraceID string `json:"trace_id,omitempty"`
}
func (s *AttemptStore) load() (*attemptStateFile, error) {
path := filepath.Join(s.workDir, AttemptStateFileName)
data, err := s.readFile(path)
if os.IsNotExist(err) {
return newAttemptStateFile(), nil
}
if err != nil {
return nil, fmt.Errorf("personal event: read subscription attempt state: %w", err)
}
var state attemptStateFile
if err := json.Unmarshal(data, &state); err != nil {
return nil, fmt.Errorf("personal event: decode subscription attempt state: %w", err)
}
if state.Version != attemptStateVersion {
return nil, fmt.Errorf(
"personal event: unsupported subscription attempt state version %d",
state.Version,
)
}
if state.Records == nil {
return nil, errors.New("personal event: subscription attempt state has no records map")
}
for fingerprint, record := range state.Records {
if err := validateAttemptRecord(fingerprint, record); err != nil {
return nil, err
}
}
return &state, nil
}
func newAttemptStateFile() *attemptStateFile {
return &attemptStateFile{
Version: attemptStateVersion,
Records: make(map[string]attemptRecord),
}
}
func validateAttemptRecord(fingerprint string, record attemptRecord) error {
if !validAttemptFingerprint(fingerprint) || fingerprint != record.Fingerprint {
return errors.New("personal event: subscription attempt state contains an invalid fingerprint")
}
if len(record.EventKey) > attemptMaxFieldLength ||
len(record.AttemptID) > attemptMaxFieldLength ||
len(record.ErrorCode) > attemptMaxFieldLength ||
len(record.TraceID) > attemptMaxFieldLength {
return errors.New("personal event: subscription attempt state contains an oversized field")
}
if record.FailureCount < 0 {
return errors.New("personal event: subscription attempt state contains a negative failure count")
}
if record.Retryability != "" && !validRetryability(record.Retryability) {
return errors.New("personal event: subscription attempt state contains invalid retryability")
}
switch record.State {
case AttemptStateInFlight:
if record.AttemptID == "" || record.LeaseUntil.IsZero() {
return errors.New("personal event: in-flight subscription attempt state is incomplete")
}
case AttemptStateCooldown:
if record.NextAllowedAt.IsZero() ||
(record.Retryability != RetryabilityUnknown &&
record.Retryability != RetryabilityRetryable) {
return errors.New("personal event: cooldown subscription attempt state is invalid")
}
case AttemptStateTerminalHold:
if record.NextAllowedAt.IsZero() ||
record.Retryability != RetryabilityNonRetryable {
return errors.New("personal event: terminal subscription attempt state is invalid")
}
default:
return errors.New("personal event: subscription attempt state contains an invalid state")
}
return nil
}
func pruneAttemptRecords(state *attemptStateFile, now time.Time) {
for fingerprint, record := range state.Records {
switch {
case !record.LastFailureAt.IsZero() &&
!now.Before(record.LastFailureAt) &&
now.Sub(record.LastFailureAt) >= attemptFailureReset &&
((record.State == AttemptStateInFlight && !record.LeaseUntil.After(now)) ||
((record.State == AttemptStateCooldown ||
record.State == AttemptStateTerminalHold) &&
!record.NextAllowedAt.After(now))):
delete(state.Records, fingerprint)
case record.State == AttemptStateInFlight &&
record.LastFailureAt.IsZero() &&
!record.LeaseUntil.IsZero() &&
!now.Before(record.LeaseUntil) &&
now.Sub(record.LeaseUntil) >= attemptFailureReset:
delete(state.Records, fingerprint)
}
}
}
func (s *AttemptStore) write(state *attemptStateFile) error {
if state == nil {
return errors.New("personal event: nil subscription attempt state")
}
if err := s.mkdirAll(s.workDir, config.DirPerm); err != nil {
return fmt.Errorf("personal event: create subscription attempt directory: %w", err)
}
path := filepath.Join(s.workDir, AttemptStateFileName)
if len(state.Records) == 0 {
if err := s.remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("personal event: remove empty subscription attempt state: %w", err)
}
return nil
}
keys := make([]string, 0, len(state.Records))
for fingerprint := range state.Records {
keys = append(keys, fingerprint)
}
sort.Strings(keys)
ordered := make(map[string]attemptRecord, len(keys))
for _, fingerprint := range keys {
ordered[fingerprint] = state.Records[fingerprint]
}
payload := attemptStateFile{Version: attemptStateVersion, Records: ordered}
data, err := s.marshal(payload, "", " ")
if err != nil {
return fmt.Errorf("personal event: encode subscription attempt state: %w", err)
}
data = append(data, '\n')
tmp := path + ".tmp"
if err := s.writeFile(tmp, data, config.FilePerm); err != nil {
return fmt.Errorf("personal event: write subscription attempt state: %w", err)
}
if err := s.chmod(tmp, config.FilePerm); err != nil {
_ = s.remove(tmp)
return fmt.Errorf("personal event: secure subscription attempt state: %w", err)
}
if err := s.rename(tmp, path); err != nil {
_ = s.remove(tmp)
return fmt.Errorf("personal event: replace subscription attempt state: %w", err)
}
return nil
}
func (s *AttemptStore) withLock(fn func() error) error {
if err := s.mkdirAll(s.workDir, config.DirPerm); err != nil {
return fmt.Errorf("personal event: create subscription attempt directory: %w", err)
}
lockPath := filepath.Join(s.workDir, AttemptStateLockFileName)
deadline := s.now().Add(attemptLockWaitTimeout)
for {
held, err := s.tryAcquire(lockPath)
if err == nil {
if err := s.chmod(lockPath, config.FilePerm); err != nil {
_ = held.Close()
return fmt.Errorf("personal event: secure subscription attempt lock: %w", err)
}
defer held.Close()
return fn()
}
if !errors.Is(err, eventlock.ErrBusy) {
return fmt.Errorf("personal event: acquire subscription attempt lock: %w", err)
}
remaining := deadline.Sub(s.now())
if remaining <= 0 {
return fmt.Errorf(
"personal event: timed out waiting for subscription attempt lock after %s",
attemptLockWaitTimeout,
)
}
delay := attemptLockRetryDelay
if remaining < delay {
delay = remaining
}
s.sleep(delay)
}
}
func newAttemptID() (string, error) {
return rand.Text(), nil
}
File diff suppressed because it is too large Load Diff
+179 -42
View File
@@ -23,6 +23,7 @@ import (
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -67,6 +68,8 @@ type Client struct {
HTTPClient *http.Client
Identity Identity
AccessTokenProvider func(context.Context) (string, error)
ClientVersion string
UserAgent string
}
type CreateSubscriptionRequest struct {
@@ -141,10 +144,14 @@ func (s dwsSubscription) toSubscription() Subscription {
}
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable,omitempty"`
Details map[string]any `json:"details,omitempty"`
Code string `json:"code"`
Message string `json:"message"`
Retryable *bool `json:"retryable,omitempty"`
RetryAfterSeconds *int64 `json:"retry_after_seconds,omitempty"`
NextRetryAt *time.Time `json:"next_retry_at,omitempty"`
TraceID string `json:"trace_id,omitempty"`
HTTPStatus int `json:"http_status,omitempty"`
Details map[string]any `json:"details,omitempty"`
}
func (e *APIError) Error() string {
@@ -178,18 +185,6 @@ func (c *Client) CreateSubscription(ctx context.Context, req CreateSubscriptionR
}
var sub Subscription
if err := c.do(ctx, http.MethodPost, "/subscription/user", nil, c.buildCreateRequest(req), &sub); err != nil {
var apiErr *APIError
if errors.As(err, &apiErr) {
if subID, ok := apiErr.Details["subscribe_id"].(string); ok && subID != "" {
return &Subscription{
SubscribeID: subID,
EventKey: req.EventKey,
RuleType: req.RuleType,
Status: "active",
SourceID: c.Identity.SourceID,
}, nil
}
}
return nil, err
}
if sub.EventKey == "" {
@@ -378,14 +373,20 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
return fmt.Errorf("personal event: read response: %w", err)
}
responseLog := sanitizeLogPayload(data)
responseID := firstNonEmpty(responseRequestID(data), responseHeaderRequestID(resp.Header))
responseTrace := firstNonEmpty(responseBodyTraceID(data), responseTraceID(resp.Header))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if apiErr := decodeAPIError(data); apiErr != nil {
apiErr = withRequestDetails(apiErr, method, path, resp.StatusCode, responseRequestID(data))
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), apiErr)
apiErr = withHTTPResponseDetails(apiErr, method, path, resp, responseID, responseTrace)
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, responseID, apiErr)
return apiErr
}
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), nil)
return fmt.Errorf("personal event: HTTP %d", resp.StatusCode)
apiErr := withHTTPResponseDetails(&APIError{
Code: fmt.Sprintf("HTTP_%d", resp.StatusCode),
Message: firstNonEmpty(http.StatusText(resp.StatusCode), "HTTP request failed"),
}, method, path, resp, responseID, responseTrace)
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, responseID, apiErr)
return apiErr
}
if len(bytes.TrimSpace(data)) == 0 {
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, "", nil)
@@ -393,23 +394,25 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
}
var env responseEnvelope
if err := json.Unmarshal(data, &env); err == nil && (env.Success != nil || env.Error != nil || env.Result != nil || env.ErrorCode != "" || env.ErrorMsg != "") {
envID := firstNonEmpty(env.requestID(), responseID)
envTrace := firstNonEmpty(env.traceID(), responseTrace)
if env.Success == nil {
if apiErr := env.apiError(); apiErr != nil {
apiErr = withRequestDetails(apiErr, method, path, resp.StatusCode, env.requestID())
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), apiErr)
apiErr = withHTTPResponseDetails(apiErr, method, path, resp, envID, envTrace)
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, envID, apiErr)
return apiErr
}
}
if env.Success != nil && !*env.Success {
if apiErr := env.apiError(); apiErr != nil {
apiErr = withRequestDetails(apiErr, method, path, resp.StatusCode, env.requestID())
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), apiErr)
apiErr = withHTTPResponseDetails(apiErr, method, path, resp, envID, envTrace)
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, envID, apiErr)
return apiErr
}
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), nil)
logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, envID, nil)
return errors.New("personal event: request failed")
}
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), nil)
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, envID, nil)
if env.Result == nil {
return nil
}
@@ -419,10 +422,10 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
return decodeResult(env.Result, out)
}
if out == nil {
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), nil)
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseID, nil)
return nil
}
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), nil)
logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseID, nil)
return json.Unmarshal(data, out)
}
@@ -451,25 +454,58 @@ func (c *Client) decorate(req *http.Request, accessToken string) {
if c.Identity.CorpID != "" {
req.Header.Set("X-DWS-Corp-Id", c.Identity.CorpID)
}
if version := strings.TrimSpace(c.ClientVersion); version != "" {
req.Header.Set("X-Cli-Version", version)
}
if userAgent := strings.TrimSpace(c.UserAgent); userAgent != "" {
req.Header.Set("User-Agent", userAgent)
}
req.Header.Set("Accept", "application/json")
}
type responseEnvelope struct {
Success *bool `json:"success"`
RequestID string `json:"request_id,omitempty"`
RequestID2 string `json:"requestId,omitempty"`
Result json.RawMessage `json:"result"`
Error *APIError `json:"error"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
Success *bool `json:"success"`
RequestID string `json:"request_id,omitempty"`
RequestID2 string `json:"requestId,omitempty"`
TraceID string `json:"trace_id,omitempty"`
TraceID2 string `json:"traceId,omitempty"`
Arguments []json.RawMessage `json:"arguments,omitempty"`
Result json.RawMessage `json:"result"`
Error *APIError `json:"error"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
Retryable *bool `json:"retryable,omitempty"`
RetryAfterSeconds *int64 `json:"retry_after_seconds,omitempty"`
RetryAfterSecondsCamel *int64 `json:"retryAfterSeconds,omitempty"`
NextRetryAt *time.Time `json:"next_retry_at,omitempty"`
NextRetryAtCamel *time.Time `json:"nextRetryAt,omitempty"`
}
func (e responseEnvelope) apiError() *APIError {
if e.Error != nil {
if e.Error.Retryable == nil {
e.Error.Retryable = e.Retryable
}
if e.Error.RetryAfterSeconds == nil {
e.Error.RetryAfterSeconds = firstInt64Pointer(e.RetryAfterSeconds, e.RetryAfterSecondsCamel)
}
if e.Error.NextRetryAt == nil {
e.Error.NextRetryAt = firstTimePointer(e.NextRetryAt, e.NextRetryAtCamel)
}
if e.Error.TraceID == "" {
e.Error.TraceID = e.traceID()
}
return e.Error
}
if e.ErrorCode != "" || e.ErrorMsg != "" {
return &APIError{Code: e.ErrorCode, Message: e.ErrorMsg}
return &APIError{
Code: e.ErrorCode,
Message: e.ErrorMsg,
Retryable: e.Retryable,
RetryAfterSeconds: firstInt64Pointer(e.RetryAfterSeconds, e.RetryAfterSecondsCamel),
NextRetryAt: firstTimePointer(e.NextRetryAt, e.NextRetryAtCamel),
TraceID: e.traceID(),
}
}
return nil
}
@@ -478,6 +514,10 @@ func (e responseEnvelope) requestID() string {
return firstNonEmpty(e.RequestID, e.RequestID2)
}
func (e responseEnvelope) traceID() string {
return firstNonEmpty(e.TraceID, e.TraceID2, firstArgumentString(e.Arguments))
}
func decodeResult(raw json.RawMessage, out any) error {
if len(raw) == 0 || string(raw) == "null" {
return nil
@@ -511,11 +551,8 @@ func decodeSubscriptionResult(raw json.RawMessage) (Subscription, bool) {
func decodeAPIError(data []byte) *APIError {
var env responseEnvelope
if err := json.Unmarshal(data, &env); err == nil {
if env.Error != nil {
return env.Error
}
if env.ErrorCode != "" || env.ErrorMsg != "" {
return &APIError{Code: env.ErrorCode, Message: env.ErrorMsg}
if apiErr := env.apiError(); apiErr != nil {
return apiErr
}
}
var apiErr APIError
@@ -533,12 +570,24 @@ func responseRequestID(data []byte) string {
return ""
}
func responseBodyTraceID(data []byte) string {
var env responseEnvelope
if err := json.Unmarshal(data, &env); err == nil {
if env.Error != nil && strings.TrimSpace(env.Error.TraceID) != "" {
return strings.TrimSpace(env.Error.TraceID)
}
return env.traceID()
}
return ""
}
func withRequestDetails(apiErr *APIError, method, path string, status int, requestID string) *APIError {
if apiErr == nil {
return nil
}
apiErr.HTTPStatus = status
if apiErr.Details == nil {
apiErr.Details = make(map[string]any, 4)
apiErr.Details = make(map[string]any, 8)
}
apiErr.Details["method"] = method
apiErr.Details["path"] = path
@@ -549,6 +598,91 @@ func withRequestDetails(apiErr *APIError, method, path string, status int, reque
return apiErr
}
func withHTTPResponseDetails(apiErr *APIError, method, path string, resp *http.Response, requestID, traceID string) *APIError {
if apiErr == nil {
return nil
}
status := 0
if resp != nil {
status = resp.StatusCode
traceID = firstNonEmpty(apiErr.TraceID, traceID, responseTraceID(resp.Header))
}
apiErr = withRequestDetails(apiErr, method, path, status, requestID)
if apiErr.TraceID == "" {
apiErr.TraceID = strings.TrimSpace(traceID)
}
if resp == nil {
return apiErr
}
retryAfter := strings.TrimSpace(resp.Header.Get("Retry-After"))
if retryAfter == "" {
return apiErr
}
apiErr.Details["retry_after"] = retryAfter
if apiErr.RetryAfterSeconds == nil && apiErr.NextRetryAt == nil {
if seconds, err := strconv.ParseInt(retryAfter, 10, 64); err == nil && seconds >= 0 {
apiErr.RetryAfterSeconds = &seconds
} else if next, err := http.ParseTime(retryAfter); err == nil {
next = next.UTC()
apiErr.NextRetryAt = &next
}
}
if apiErr.RetryAfterSeconds != nil {
apiErr.Details["retry_after_seconds"] = *apiErr.RetryAfterSeconds
}
if apiErr.NextRetryAt != nil {
apiErr.Details["next_retry_at"] = apiErr.NextRetryAt.UTC().Format(time.RFC3339)
}
return apiErr
}
func responseTraceID(headers http.Header) string {
for _, key := range []string{"X-Trace-Id", "X-Dingtalk-Trace-Id"} {
if value := strings.TrimSpace(headers.Get(key)); value != "" {
return value
}
}
return ""
}
func responseHeaderRequestID(headers http.Header) string {
return strings.TrimSpace(headers.Get("X-Request-Id"))
}
func firstArgumentString(arguments []json.RawMessage) string {
if len(arguments) == 0 {
return ""
}
var value string
if err := json.Unmarshal(arguments[0], &value); err != nil {
return ""
}
return strings.TrimSpace(value)
}
func firstInt64Pointer(values ...*int64) *int64 {
for _, value := range values {
if value == nil {
continue
}
copy := *value
return &copy
}
return nil
}
func firstTimePointer(values ...*time.Time) *time.Time {
for _, value := range values {
if value == nil || value.IsZero() {
continue
}
copy := value.UTC()
return &copy
}
return nil
}
func logControlRequest(message, method, path string, q url.Values, status int, requestPayload, responsePayload, requestID string, apiErr *APIError) {
attrs := []any{
"method", method,
@@ -568,6 +702,9 @@ func logControlRequest(message, method, path string, q url.Values, status int, r
attrs = append(attrs, "request_id", requestID)
}
if apiErr != nil {
if apiErr.TraceID != "" {
attrs = append(attrs, "trace_id", apiErr.TraceID)
}
if apiErr.Code != "" {
attrs = append(attrs, "error_code", apiErr.Code)
}
+279
View File
@@ -25,6 +25,7 @@ import (
"strconv"
"strings"
"testing"
"time"
)
func TestClientCreateSubscriptionDWSRequestAndArrayResponse(t *testing.T) {
@@ -282,6 +283,12 @@ func TestClientBusinessErrorHTTP200(t *testing.T) {
apiErr.Details["http_status"] != http.StatusOK || apiErr.Details["request_id"] != "req-1" {
t.Fatalf("details = %#v", apiErr.Details)
}
if apiErr.Retryable != nil {
t.Fatalf("retryable = %v, want unknown", *apiErr.Retryable)
}
if apiErr.HTTPStatus != http.StatusOK || apiErr.TraceID != "" {
t.Fatalf("HTTP diagnostics = status %d trace %q", apiErr.HTTPStatus, apiErr.TraceID)
}
out := logs.String()
for _, want := range []string{"/subscription/user", "INVALID_PARAM", "clientId is empty", "req-1", "request", "response"} {
if !strings.Contains(out, want) {
@@ -290,6 +297,278 @@ func TestClientBusinessErrorHTTP200(t *testing.T) {
}
}
func TestCrossPlatformCoverageClientBusinessErrorPreservesRetryContractAndClientHeaders(t *testing.T) {
nextRetryAt := "2026-07-30T04:05:06Z"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Cli-Version"); got != "1.2.3" {
t.Fatalf("X-Cli-Version = %q", got)
}
if got := r.Header.Get("User-Agent"); got != "dws-cli/1.2.3" {
t.Fatalf("User-Agent = %q", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": false,
"errorCode": "RATE_LIMITED",
"errorMsg": "slow down",
"retryable": false,
"retryAfterSeconds": 30,
"nextRetryAt": nextRetryAt,
"arguments": []string{"portal-trace-1"},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
c.ClientVersion = "1.2.3"
c.UserAgent = "dws-cli/1.2.3"
_, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventMention,
RuleType: "at",
RuleParam: map[string]any{},
})
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error = %v, want *APIError", err)
}
if apiErr.Retryable == nil || *apiErr.Retryable {
t.Fatalf("retryable = %v, want explicit false", apiErr.Retryable)
}
if apiErr.RetryAfterSeconds == nil || *apiErr.RetryAfterSeconds != 30 {
t.Fatalf("retry_after_seconds = %v", apiErr.RetryAfterSeconds)
}
if apiErr.NextRetryAt == nil || apiErr.NextRetryAt.Format(time.RFC3339) != nextRetryAt {
t.Fatalf("next_retry_at = %v", apiErr.NextRetryAt)
}
if apiErr.TraceID != "portal-trace-1" || apiErr.HTTPStatus != http.StatusOK {
t.Fatalf("HTTP diagnostics = trace %q status %d", apiErr.TraceID, apiErr.HTTPStatus)
}
if _, exists := apiErr.Details["request_id"]; exists {
t.Fatalf("portal trace was mislabeled as request_id: %#v", apiErr.Details)
}
}
func TestCrossPlatformCoverageClientErrorDiagnosticIdentityPrecedence(t *testing.T) {
tests := []struct {
name string
body map[string]any
headers http.Header
wantTraceID string
wantRequestID string
}{
{
name: "nested trace wins",
body: map[string]any{
"success": false,
"requestId": "body-request",
"traceId": "top-trace",
"arguments": []string{"portal-trace"},
"error": map[string]any{
"code": "SYSTEM_ERROR",
"message": "failed",
"trace_id": "nested-trace",
},
},
headers: http.Header{
"X-Trace-Id": []string{"header-trace"},
"X-Request-Id": []string{"header-request"},
},
wantTraceID: "nested-trace",
wantRequestID: "body-request",
},
{
name: "top-level trace wins over arguments and header",
body: map[string]any{
"success": false,
"requestId": "body-request",
"traceId": "top-trace",
"arguments": []string{"portal-trace"},
"error": map[string]any{"code": "SYSTEM_ERROR", "message": "failed"},
},
headers: http.Header{"X-Trace-Id": []string{"header-trace"}},
wantTraceID: "top-trace",
wantRequestID: "body-request",
},
{
name: "portal argument wins over header",
body: map[string]any{
"success": false,
"requestId": "body-request",
"arguments": []string{"portal-trace"},
"error": map[string]any{"code": "SYSTEM_ERROR", "message": "failed"},
},
headers: http.Header{"X-Trace-Id": []string{"header-trace"}},
wantTraceID: "portal-trace",
wantRequestID: "body-request",
},
{
name: "headers are independent fallbacks",
body: map[string]any{
"success": false,
"error": map[string]any{"code": "SYSTEM_ERROR", "message": "failed"},
},
headers: http.Header{
"X-Trace-Id": []string{"header-trace"},
"X-Request-Id": []string{"header-request"},
},
wantTraceID: "header-trace",
wantRequestID: "header-request",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
for key, values := range test.headers {
for _, value := range values {
w.Header().Add(key, value)
}
}
_ = json.NewEncoder(w).Encode(test.body)
}))
defer srv.Close()
client := NewClient(srv.URL, Identity{
AccessToken: "token",
ClientID: "client",
SourceID: "open",
})
_, err := client.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventMention,
RuleType: "at",
RuleParam: map[string]any{},
})
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error = %v, want *APIError", err)
}
if apiErr.TraceID != test.wantTraceID {
t.Fatalf("trace_id = %q, want %q", apiErr.TraceID, test.wantTraceID)
}
if got, _ := apiErr.Details["request_id"].(string); got != test.wantRequestID {
t.Fatalf("request_id = %q, want %q; details=%#v", got, test.wantRequestID, apiErr.Details)
}
})
}
}
func TestCrossPlatformCoverageClientHTTPErrorPreservesRetryAfterAndHeaderTrace(t *testing.T) {
tests := []struct {
name string
retryAfter string
wantSeconds int64
wantNextRetryAt string
}{
{name: "delta seconds", retryAfter: "45", wantSeconds: 45},
{name: "http date", retryAfter: "Thu, 30 Jul 2026 04:05:06 GMT", wantNextRetryAt: "2026-07-30T04:05:06Z"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls++
w.Header().Set("Retry-After", tt.retryAfter)
w.Header().Set("X-Trace-Id", "header-trace")
w.WriteHeader(http.StatusTooManyRequests)
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
_, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventMention,
RuleType: "at",
RuleParam: map[string]any{},
})
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error = %v, want *APIError", err)
}
if calls != 1 {
t.Fatalf("HTTP calls = %d, want one-shot client", calls)
}
if apiErr.Code != "HTTP_429" || apiErr.HTTPStatus != http.StatusTooManyRequests || apiErr.TraceID != "header-trace" {
t.Fatalf("API error = %#v", apiErr)
}
if apiErr.Retryable != nil {
t.Fatalf("retryable = %v, want unknown", apiErr.Retryable)
}
if tt.wantSeconds > 0 {
if apiErr.RetryAfterSeconds == nil || *apiErr.RetryAfterSeconds != tt.wantSeconds {
t.Fatalf("retry_after_seconds = %v", apiErr.RetryAfterSeconds)
}
}
if tt.wantNextRetryAt != "" {
if apiErr.NextRetryAt == nil || apiErr.NextRetryAt.Format(time.RFC3339) != tt.wantNextRetryAt {
t.Fatalf("next_retry_at = %v", apiErr.NextRetryAt)
}
}
})
}
}
func TestCrossPlatformCoverageClientErrorDiagnosticHelpersHandleNilAndMalformedInputs(t *testing.T) {
if got := withHTTPResponseDetails(nil, http.MethodPost, "/subscription/user", nil, "request-1", "trace-1"); got != nil {
t.Fatalf("withHTTPResponseDetails(nil) = %#v, want nil", got)
}
apiErr := &APIError{Code: "SYSTEM_ERROR", Message: "failed"}
got := withHTTPResponseDetails(apiErr, http.MethodPost, "/subscription/user", nil, "request-1", " trace-1 ")
if got != apiErr {
t.Fatalf("withHTTPResponseDetails() returned a different error: got %#v, want %#v", got, apiErr)
}
if got.HTTPStatus != 0 || got.TraceID != "trace-1" {
t.Fatalf("diagnostics = status %d trace %q", got.HTTPStatus, got.TraceID)
}
if got.Details["request_id"] != "request-1" {
t.Fatalf("details = %#v", got.Details)
}
if got := firstArgumentString([]json.RawMessage{json.RawMessage(`{`)}); got != "" {
t.Fatalf("firstArgumentString(malformed) = %q, want empty", got)
}
}
func TestCrossPlatformCoverageClientCreateSubscriptionRejectsErrorsWithSubscribeID(t *testing.T) {
for _, code := range []string{
"SYSTEM_ERROR",
"DUP",
"DUPLICATE_SUBSCRIPTION",
"SUBSCRIPTION_ALREADY_EXISTS",
"SUBSCRIPTION_ALREADY_EXIST",
"ALREADY_SUBSCRIBED",
"DUPLICATE",
} {
t.Run(code, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{
"code": code,
"message": "registration failed",
"details": map[string]any{"subscribe_id": "pending-sub"},
},
})
}))
defer srv.Close()
c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"})
sub, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{
EventKey: EventMention,
RuleType: "at",
RuleParam: map[string]any{},
})
if err == nil || sub != nil {
t.Fatalf("subscription = %#v, error = %v; API errors must stay failures", sub, err)
}
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.Code != code {
t.Fatalf("error = %#v", err)
}
})
}
}
func TestClientOmitsCorpHeaderWhenUnknown(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-DWS-Corp-Id"); got != "" {
@@ -219,8 +219,8 @@ func TestCrossPlatformCoveragePersonalClientHelpersAndOperations(t *testing.T) {
}
base.HTTPClient = personalHTTPClient(400, `{"error":{"code":"DUP","details":{"subscribe_id":"existing"}}}`)
created, err := base.CreateSubscription(t.Context(), CreateSubscriptionRequest{EventKey: "e", RuleType: "r"})
if err != nil || created.SubscribeID != "existing" {
t.Fatalf("duplicate create = %#v, %v", created, err)
if err == nil || created != nil {
t.Fatalf("duplicate error create = %#v, %v", created, err)
}
request := base.buildCreateRequest(CreateSubscriptionRequest{
EventKey: "e", RuleType: "r", Name: "n", RuleParam: map[string]any{"bad": make(chan int)},
@@ -73,6 +73,83 @@ func TestCrossPlatformCoverageUnixListenErrorCoverage(t *testing.T) {
}
}
func TestCrossPlatformCoverageUnixSocketDirectoryErrorCoverage(t *testing.T) {
oldLstat, oldRuntimeStat, oldMkdir := lstatSocketPath, statSocketRuntimeRoot, mkdirSocketDir
t.Cleanup(func() {
lstatSocketPath, statSocketRuntimeRoot, mkdirSocketDir = oldLstat, oldRuntimeStat, oldMkdir
})
wantErr := errors.New("synthetic socket directory failure")
if err := ensureSocketDir("relative/bus.sock", true); err == nil || !strings.Contains(err.Error(), "must be absolute") {
t.Fatalf("relative socket path error = %v", err)
}
root := shortSecureTempDir(t)
missingRootPath := filepath.Join(root, "missing-root", "dws-event-test", "bus.sock")
if err := ensureSocketDir(missingRootPath, false); err == nil || !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing runtime root error = %v", err)
}
rootInfo, err := oldLstat(root)
if err != nil {
t.Fatalf("lstat secure root: %v", err)
}
lstatSocketPath = func(path string) (os.FileInfo, error) {
if path == "/tmp" {
return fileInfoWithMode{FileInfo: rootInfo, mode: os.ModeSymlink | 0o777}, nil
}
return oldLstat(path)
}
statSocketRuntimeRoot = func(path string) (os.FileInfo, error) {
if path == "/tmp" {
return nil, wantErr
}
return oldRuntimeStat(path)
}
if err := ensureSocketDir("/tmp/dws-event-coverage/bus.sock", false); !errors.Is(err, wantErr) {
t.Fatalf("runtime root resolution error = %v", err)
}
lstatSocketPath, statSocketRuntimeRoot = oldLstat, oldRuntimeStat
rootFile := filepath.Join(root, "runtime-root-file")
if err := os.WriteFile(rootFile, []byte("not a directory"), 0o600); err != nil {
t.Fatalf("write runtime root file: %v", err)
}
if err := ensureSocketDir(filepath.Join(rootFile, "dws-event-test", "bus.sock"), false); err == nil || !strings.Contains(err.Error(), "runtime root is not a directory") {
t.Fatalf("non-directory runtime root error = %v", err)
}
mkdirSocketDir = func(string, os.FileMode) error { return wantErr }
if err := ensureSocketDir(filepath.Join(root, "mkdir-failure", "bus.sock"), true); !errors.Is(err, wantErr) {
t.Fatalf("socket directory creation error = %v", err)
}
mkdirSocketDir = oldMkdir
if err := ensureSocketDir(filepath.Join(root, "missing-socket-dir", "bus.sock"), false); err == nil || !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing socket directory error = %v", err)
}
withoutOwner := fileInfoWithoutOwner{FileInfo: rootInfo}
if err := validateSocketRuntimeRoot(root, withoutOwner, uint32(os.Geteuid())); err == nil || !strings.Contains(err.Error(), "owner") {
t.Fatalf("runtime root owner error = %v", err)
}
if err := validatePrivateSocketDir(root, withoutOwner, uint32(os.Geteuid())); err == nil || !strings.Contains(err.Error(), "owner") {
t.Fatalf("socket directory owner error = %v", err)
}
}
type fileInfoWithMode struct {
os.FileInfo
mode os.FileMode
}
func (f fileInfoWithMode) Mode() os.FileMode { return f.mode }
func (f fileInfoWithMode) IsDir() bool { return f.mode.IsDir() }
type fileInfoWithoutOwner struct{ os.FileInfo }
func (fileInfoWithoutOwner) Sys() any { return struct{}{} }
type stubNetListener struct {
close func() error
}
+98 -5
View File
@@ -16,9 +16,12 @@
package transport
import (
"errors"
"fmt"
"net"
"os"
"path/filepath"
"syscall"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
@@ -30,10 +33,13 @@ type unixListener struct {
}
var (
statSocket = os.Stat
removeSocket = os.Remove
listenUnix = net.Listen
chmodSocket = os.Chmod
statSocket = os.Stat
removeSocket = os.Remove
listenUnix = net.Listen
chmodSocket = os.Chmod
lstatSocketPath = os.Lstat
statSocketRuntimeRoot = os.Stat
mkdirSocketDir = os.Mkdir
)
func (u *unixListener) Accept() (net.Conn, error) { return u.l.Accept() }
@@ -56,11 +62,95 @@ func checkSocketPath(path string) error {
return nil
}
// ensureSocketDir makes the socket's immediate parent an owner-only
// directory and rejects unsafe pre-existing paths. The parent of that
// directory must itself either be private to the effective user (for
// XDG_RUNTIME_DIR and macOS temporary roots) or sticky (for Linux /tmp), so
// another user cannot rename the private directory out from under us.
func ensureSocketDir(path string, create bool) error {
if !filepath.IsAbs(path) {
return fmt.Errorf("transport: unix socket path must be absolute: %s", path)
}
dir := filepath.Dir(path)
root := filepath.Dir(dir)
rootInfo, err := lstatSocketPath(root)
if err != nil {
return fmt.Errorf("transport: inspect socket runtime root %s: %w", root, err)
}
// macOS exposes the system /tmp as a root-owned symlink to /private/tmp.
// Follow only that well-known alias, then apply the same ownership/sticky
// validation to its target. Arbitrary runtime-root symlinks remain rejected.
if rootInfo.Mode()&os.ModeSymlink != 0 && filepath.Clean(root) == "/tmp" {
rootInfo, err = statSocketRuntimeRoot(root)
if err != nil {
return fmt.Errorf("transport: resolve socket runtime root %s: %w", root, err)
}
}
if err := validateSocketRuntimeRoot(root, rootInfo, uint32(os.Geteuid())); err != nil {
return err
}
if create {
if err := mkdirSocketDir(dir, config.DirPerm); err != nil && !errors.Is(err, os.ErrExist) {
return fmt.Errorf("transport: create socket directory %s: %w", dir, err)
}
}
dirInfo, err := lstatSocketPath(dir)
if err != nil {
return fmt.Errorf("transport: inspect socket directory %s: %w", dir, err)
}
return validatePrivateSocketDir(dir, dirInfo, uint32(os.Geteuid()))
}
func validateSocketRuntimeRoot(path string, info os.FileInfo, effectiveUID uint32) error {
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("transport: socket runtime root is not a directory: %s", path)
}
owner, err := fileOwnerUID(info)
if err != nil {
return fmt.Errorf("transport: inspect socket runtime root owner %s: %w", path, err)
}
privateOwnerRoot := owner == effectiveUID && info.Mode().Perm()&0o022 == 0
stickyRoot := info.Mode()&os.ModeSticky != 0
if !privateOwnerRoot && !stickyRoot {
return fmt.Errorf("transport: socket runtime root is neither private nor sticky: %s", path)
}
return nil
}
func validatePrivateSocketDir(path string, info os.FileInfo, effectiveUID uint32) error {
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("transport: socket directory is not a directory: %s", path)
}
owner, err := fileOwnerUID(info)
if err != nil {
return fmt.Errorf("transport: inspect socket directory owner %s: %w", path, err)
}
if owner != effectiveUID {
return fmt.Errorf("transport: socket directory %s is owned by uid %d, want %d", path, owner, effectiveUID)
}
if perm := info.Mode().Perm(); perm != config.DirPerm {
return fmt.Errorf("transport: socket directory %s has permissions %04o, want %04o", path, perm, config.DirPerm)
}
return nil
}
func fileOwnerUID(info os.FileInfo) (uint32, error) {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return 0, errors.New("stat result does not expose an owner uid")
}
return stat.Uid, nil
}
func listen(path string) (Listener, error) {
if err := checkSocketPath(path); err != nil {
return nil, err
}
// Stale socket cleanup. Caller holds bus.lock so this is race-safe.
if err := ensureSocketDir(path, true); err != nil {
return nil, err
}
// The private per-user parent excludes other users. The caller's bus.lock
// serializes stale-socket cleanup for processes using the same WorkDir.
if _, err := statSocket(path); err == nil {
if err := removeSocket(path); err != nil {
return nil, fmt.Errorf("transport: remove stale socket %s: %w", path, err)
@@ -82,5 +172,8 @@ func dial(path string) (net.Conn, error) {
if err := checkSocketPath(path); err != nil {
return nil, err
}
if err := ensureSocketDir(path, false); err != nil {
return nil, err
}
return net.Dial("unix", path)
}
+138 -5
View File
@@ -21,12 +21,32 @@ import (
"net"
"os"
"path/filepath"
"strings"
"sync"
"testing"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
)
func shortSecureTempDir(t *testing.T) string {
t.Helper()
tempRoot, err := filepath.EvalSymlinks("/tmp")
if err != nil {
t.Fatalf("EvalSymlinks: %v", err)
}
dir, err := os.MkdirTemp(tempRoot, "dws-et-")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
if err := os.Chmod(dir, 0o700); err != nil {
t.Fatalf("chmod temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
return dir
}
func TestListen_DialRoundtrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "bus.sock")
path := filepath.Join(shortSecureTempDir(t), "bus.sock")
l, err := Listen(path)
if err != nil {
t.Fatalf("Listen: %v", err)
@@ -87,7 +107,7 @@ func TestListen_DialRoundtrip(t *testing.T) {
}
func TestListen_StaleSocketCleanup(t *testing.T) {
path := filepath.Join(t.TempDir(), "bus.sock")
path := filepath.Join(shortSecureTempDir(t), "bus.sock")
// Pre-create a stale file at path (not a valid socket).
if err := os.WriteFile(path, []byte("stale"), 0o600); err != nil {
t.Fatalf("pre-create: %v", err)
@@ -99,8 +119,121 @@ func TestListen_StaleSocketCleanup(t *testing.T) {
defer l.Close()
}
func TestCrossPlatformCoverageListenCreatesPrivateSocketDirectory(t *testing.T) {
dir := filepath.Join(shortSecureTempDir(t), "dws-event-test")
path := filepath.Join(dir, "bus.sock")
l, err := Listen(path)
if err != nil {
t.Fatalf("Listen: %v", err)
}
defer l.Close()
st, err := os.Stat(dir)
if err != nil {
t.Fatalf("stat socket directory: %v", err)
}
if mode := st.Mode().Perm(); mode != 0o700 {
t.Fatalf("socket directory mode = %04o, want 0700", mode)
}
}
func TestCrossPlatformCoverageListenRejectsWorldAccessibleSocketDirectory(t *testing.T) {
dir := filepath.Join(shortSecureTempDir(t), "dws-event-test")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.Chmod(dir, 0o777); err != nil {
t.Fatalf("chmod: %v", err)
}
if _, err := Listen(filepath.Join(dir, "bus.sock")); err == nil || !strings.Contains(err.Error(), "want 0700") {
t.Fatalf("Listen error = %v, want 0700 directory rejection", err)
}
}
func TestCrossPlatformCoverageDialRejectsWorldAccessibleSocketDirectory(t *testing.T) {
dir := filepath.Join(shortSecureTempDir(t), "dws-event-test")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.Chmod(dir, 0o777); err != nil {
t.Fatalf("chmod: %v", err)
}
if _, err := Dial(filepath.Join(dir, "bus.sock")); err == nil || !strings.Contains(err.Error(), "want 0700") {
t.Fatalf("Dial error = %v, want 0700 directory rejection", err)
}
}
func TestCrossPlatformCoverageListenRejectsSymlinkSocketDirectory(t *testing.T) {
root := shortSecureTempDir(t)
target := filepath.Join(root, "target")
if err := os.Mkdir(target, 0o700); err != nil {
t.Fatalf("mkdir target: %v", err)
}
link := filepath.Join(root, "dws-event-test")
if err := os.Symlink(target, link); err != nil {
t.Fatalf("symlink: %v", err)
}
if _, err := Listen(filepath.Join(link, "bus.sock")); err == nil || !strings.Contains(err.Error(), "not a directory") {
t.Fatalf("Listen error = %v, want symlink directory rejection", err)
}
}
func TestCrossPlatformCoverageValidatePrivateSocketDirRejectsDifferentOwner(t *testing.T) {
dir := shortSecureTempDir(t)
st, err := os.Lstat(dir)
if err != nil {
t.Fatalf("lstat: %v", err)
}
otherUID := uint32(os.Geteuid() + 1)
if err := validatePrivateSocketDir(dir, st, otherUID); err == nil || !strings.Contains(err.Error(), "is owned by uid") {
t.Fatalf("validatePrivateSocketDir error = %v, want owner mismatch", err)
}
}
func TestCrossPlatformCoverageListenRejectsUntrustedRuntimeRoot(t *testing.T) {
root := filepath.Join(shortSecureTempDir(t), "untrusted")
if err := os.Mkdir(root, 0o700); err != nil {
t.Fatalf("mkdir root: %v", err)
}
if err := os.Chmod(root, 0o777); err != nil {
t.Fatalf("chmod root: %v", err)
}
dir := filepath.Join(root, "dws-event-test")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("mkdir socket dir: %v", err)
}
if _, err := Listen(filepath.Join(dir, "bus.sock")); err == nil || !strings.Contains(err.Error(), "neither private nor sticky") {
t.Fatalf("Listen error = %v, want untrusted runtime root rejection", err)
}
}
func TestCrossPlatformCoverageListenSharedWorkDirUsesLocalSecureRuntimeEndpoint(t *testing.T) {
root := shortSecureTempDir(t)
runtimeDir := filepath.Join(root, "runtime")
if err := os.Mkdir(runtimeDir, 0o700); err != nil {
t.Fatalf("mkdir runtime: %v", err)
}
t.Setenv("XDG_RUNTIME_DIR", runtimeDir)
sharedWorkDir := filepath.Join(root, "simulated-nfs", "events", "open", "personal_stream", "identity")
endpoint := dwsevent.IPCEndpoint(sharedWorkDir, "open", dwsevent.SourceKindPersonalStream, "identity")
if strings.HasPrefix(endpoint, sharedWorkDir) {
t.Fatalf("endpoint = %q, want socket outside shared WorkDir %q", endpoint, sharedWorkDir)
}
l, err := Listen(endpoint)
if err != nil {
t.Fatalf("Listen on local runtime endpoint: %v", err)
}
defer l.Close()
if mode, err := os.Stat(filepath.Dir(endpoint)); err != nil {
t.Fatalf("stat runtime socket directory: %v", err)
} else if mode.Mode().Perm() != 0o700 {
t.Fatalf("runtime socket directory mode = %04o, want 0700", mode.Mode().Perm())
}
}
func TestListen_CloseUnlinksSocket(t *testing.T) {
path := filepath.Join(t.TempDir(), "bus.sock")
path := filepath.Join(shortSecureTempDir(t), "bus.sock")
l, err := Listen(path)
if err != nil {
t.Fatalf("Listen: %v", err)
@@ -114,7 +247,7 @@ func TestListen_CloseUnlinksSocket(t *testing.T) {
}
func TestDial_NoServerReturnsError(t *testing.T) {
path := filepath.Join(t.TempDir(), "nonexistent.sock")
path := filepath.Join(shortSecureTempDir(t), "nonexistent.sock")
if _, err := Dial(path); err == nil {
t.Fatal("Dial to nonexistent socket should error")
}
@@ -124,7 +257,7 @@ func TestDial_NoServerReturnsError(t *testing.T) {
// surfaces as io.EOF to the server's Reader — the EOF signal is what bus
// uses to unregister dead consumers (plan invariant #5).
func TestReader_HandlesPeerCloseEOF(t *testing.T) {
path := filepath.Join(t.TempDir(), "bus.sock")
path := filepath.Join(shortSecureTempDir(t), "bus.sock")
l, err := Listen(path)
if err != nil {
t.Fatalf("Listen: %v", err)
@@ -0,0 +1,161 @@
// 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.
// Command cmd_param_aliases reduces the reviewed parameter concept dictionary
// (internal/cli/param_concepts.json) against the live Cobra command tree and
// writes the deterministic per-command alias table to
// internal/cli/param_aliases_generated.go.
//
// The reduction algorithm lives in package cli (cli.ReduceParamAliases) so the
// build-time intersection and the runtime normalizer share exactly one
// implementation. This command is a thin, deterministic serializer: it never
// invents identity, and it fails closed on any un-whitelisted co-occurrence,
// missing override path, or non-real alias target.
package main
import (
"bytes"
"flag"
"fmt"
"go/format"
"os"
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/generator/outputguard"
)
const defaultOutput = "internal/cli/param_aliases_generated.go"
var (
newParamAliasRoot = app.NewSchemaSourceRootCommand
reduceParamAliasEntries = cli.ReduceParamAliases
formatParamAliasSource = format.Source
writeParamAliasFile = os.WriteFile
exitParamAliasProcess = os.Exit
)
func main() {
var rootPath string
var outputPath string
flag.StringVar(&rootPath, "root", ".", "Repository root used to protect generator inputs")
flag.StringVar(&outputPath, "output", defaultOutput, "Output generated parameter-alias table")
flag.Parse()
if err := generateParamAliases(rootPath, outputPath); err != nil {
fail(err)
}
}
func generateParamAliases(rootPath, outputPath string) error {
if err := validateOutputIsolation(rootPath, outputPath); err != nil {
return err
}
entries, err := reduceParamAliasEntries(newParamAliasRoot())
if err != nil {
return err
}
source, err := renderParamAliases(entries)
if err != nil {
return err
}
if err := writeParamAliasFile(outputPath, source, 0o644); err != nil {
return fmt.Errorf("write generated parameter aliases: %w", err)
}
_, _ = fmt.Fprintf(os.Stderr, "generated parameter aliases: output=%s commands=%d\n", outputPath, len(entries))
return nil
}
func validateOutputIsolation(rootPath, outputPath string) error {
inputs := []outputguard.Input{
{Name: "reviewed parameter concept dictionary", Path: "internal/cli/param_concepts.json"},
{Name: "reviewed parameter concept schema", Path: "internal/cli/param_concepts.schema.json"},
}
target := outputguard.Target{Name: "--output", Path: outputPath}
if err := outputguard.Validate(rootPath, inputs, []outputguard.Target{target}); err != nil {
return err
}
return outputguard.ValidateRepoTargetAllowlist(rootPath, target, defaultOutput)
}
// renderParamAliases serializes the reduced entries into gofmt-stable Go
// source. Entries and every map/slice are emitted in sorted order so
// consecutive generations are byte-identical.
func renderParamAliases(entries []cli.ParamAliasEntry) ([]byte, error) {
var b bytes.Buffer
b.WriteString("// Copyright 2026 Alibaba Group\n")
b.WriteString("// Licensed under the Apache License, Version 2.0 (the \"License\");\n\n")
b.WriteString("// Code generated by cmd_param_aliases; DO NOT EDIT.\n")
b.WriteString("// Source: internal/cli/param_concepts.json reduced against the live Cobra tree.\n")
b.WriteString("// Regenerate with `make generate-schema` (or `go generate ./internal/cli`).\n\n")
b.WriteString("package cli\n\n")
b.WriteString("// generatedParamAliases is the per-command parameter-alias table reduced from\n")
b.WriteString("// the reviewed concept dictionary. Each entry binds one runnable Cobra leaf.\n")
b.WriteString("var generatedParamAliases = []ParamAliasEntry{\n")
for _, entry := range entries {
b.WriteString("\t{\n")
fmt.Fprintf(&b, "\t\tCLIPath: %q,\n", entry.CLIPath)
if len(entry.Aliases) > 0 {
b.WriteString("\t\tAliases: map[string]string{\n")
for _, key := range sortedKeys(entry.Aliases) {
fmt.Fprintf(&b, "\t\t\t%q: %q,\n", key, entry.Aliases[key])
}
b.WriteString("\t\t},\n")
}
if len(entry.Blocked) > 0 {
fmt.Fprintf(&b, "\t\tBlocked: %s,\n", renderStringSlice(entry.Blocked))
}
if len(entry.Ambiguous) > 0 {
fmt.Fprintf(&b, "\t\tAmbiguous: %s,\n", renderStringSlice(entry.Ambiguous))
}
b.WriteString("\t},\n")
}
b.WriteString("}\n")
b.WriteString("\nfunc loadGeneratedParamAliases() []ParamAliasEntry {\n")
b.WriteString("\treturn generatedParamAliases\n")
b.WriteString("}\n")
formatted, err := formatParamAliasSource(b.Bytes())
if err != nil {
return nil, fmt.Errorf("format generated parameter aliases: %w", err)
}
return formatted, nil
}
func renderStringSlice(values []string) string {
quoted := make([]string, len(values))
for i, v := range values {
quoted[i] = fmt.Sprintf("%q", v)
}
return "[]string{" + strings.Join(quoted, ", ") + "}"
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func fail(err error) {
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
exitParamAliasProcess(1)
}
@@ -0,0 +1,184 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package main
import (
"context"
"errors"
"flag"
"go/format"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/spf13/cobra"
)
func TestParamAliasGeneratorUsesDistributionOwnedCommandTree(t *testing.T) {
got := reflect.ValueOf(newParamAliasRoot).Pointer()
want := reflect.ValueOf(app.NewSchemaSourceRootCommand).Pointer()
if got != want {
t.Fatal("parameter alias generator must use the distribution-owned Schema source command tree")
}
}
func preserveParamAliasGeneratorGlobals(t *testing.T) {
t.Helper()
oldArgs, oldFlags := os.Args, flag.CommandLine
oldRoot := newParamAliasRoot
oldReduce := reduceParamAliasEntries
oldFormat := formatParamAliasSource
oldWrite := writeParamAliasFile
oldExit := exitParamAliasProcess
t.Cleanup(func() {
os.Args, flag.CommandLine = oldArgs, oldFlags
newParamAliasRoot = oldRoot
reduceParamAliasEntries = oldReduce
formatParamAliasSource = oldFormat
writeParamAliasFile = oldWrite
exitParamAliasProcess = oldExit
})
}
func repositoryRoot(t *testing.T) string {
t.Helper()
root, err := filepath.Abs(filepath.Join("..", "..", ".."))
if err != nil {
t.Fatal(err)
}
return root
}
func TestCrossPlatformCoverageParamAliasMainWritesGeneratedFile(t *testing.T) {
preserveParamAliasGeneratorGlobals(t)
output := filepath.Join(t.TempDir(), "param_aliases_generated.go")
flag.CommandLine = flag.NewFlagSet("param-alias-success", flag.ContinueOnError)
os.Args = []string{"cmd_param_aliases", "-root", repositoryRoot(t), "-output", output}
newParamAliasRoot = func(...context.Context) *cobra.Command { return &cobra.Command{Use: "dws"} }
reduceParamAliasEntries = func(*cobra.Command) ([]cli.ParamAliasEntry, error) {
return []cli.ParamAliasEntry{{CLIPath: "demo get", Aliases: map[string]string{"uid": "user"}}}, nil
}
main()
data, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "func loadGeneratedParamAliases() []ParamAliasEntry") ||
!strings.Contains(string(data), `"uid": "user"`) {
t.Fatalf("generated source missing runtime table:\n%s", data)
}
}
func TestCrossPlatformCoverageParamAliasMainReportsFailure(t *testing.T) {
preserveParamAliasGeneratorGlobals(t)
flag.CommandLine = flag.NewFlagSet("param-alias-failure", flag.ContinueOnError)
os.Args = []string{
"cmd_param_aliases",
"-root", repositoryRoot(t),
"-output", filepath.Join(repositoryRoot(t), "internal", "cli", "param_concepts.json"),
}
exitParamAliasProcess = func(code int) { panic(code) }
defer func() {
if recovered := recover(); recovered != 1 {
t.Fatalf("main exit = %#v, want 1", recovered)
}
}()
main()
}
func TestGenerateParamAliasesFailurePaths(t *testing.T) {
preserveParamAliasGeneratorGlobals(t)
root := repositoryRoot(t)
output := filepath.Join(t.TempDir(), "param_aliases_generated.go")
newParamAliasRoot = func(...context.Context) *cobra.Command { return &cobra.Command{Use: "dws"} }
if err := generateParamAliases(root, filepath.Join(root, "internal", "cli", "param_concepts.json")); err == nil {
t.Fatal("generateParamAliases accepted an output that overlaps a reviewed input")
}
reduceParamAliasEntries = func(*cobra.Command) ([]cli.ParamAliasEntry, error) {
return nil, errors.New("reduce")
}
if err := generateParamAliases(root, output); err == nil || !strings.Contains(err.Error(), "reduce") {
t.Fatalf("reduction error = %v", err)
}
reduceParamAliasEntries = func(*cobra.Command) ([]cli.ParamAliasEntry, error) { return nil, nil }
formatParamAliasSource = func([]byte) ([]byte, error) { return nil, errors.New("format") }
if err := generateParamAliases(root, output); err == nil || !strings.Contains(err.Error(), "format generated") {
t.Fatalf("format error = %v", err)
}
formatParamAliasSource = format.Source
writeParamAliasFile = func(string, []byte, os.FileMode) error { return errors.New("write") }
if err := generateParamAliases(root, output); err == nil || !strings.Contains(err.Error(), "write generated") {
t.Fatalf("write error = %v", err)
}
}
func TestValidateParamAliasOutputIsolation(t *testing.T) {
root := repositoryRoot(t)
if err := validateOutputIsolation(root, filepath.Join(t.TempDir(), "aliases.go")); err != nil {
t.Fatalf("temporary output rejected: %v", err)
}
if err := validateOutputIsolation(root, filepath.Join(root, "internal", "cli", "not_a_delivery_target.go")); err == nil ||
!strings.Contains(err.Error(), "not a canonical generated delivery target") {
t.Fatalf("non-canonical repository output error = %v", err)
}
}
func TestRenderParamAliasesDeterministicShape(t *testing.T) {
preserveParamAliasGeneratorGlobals(t)
entries := []cli.ParamAliasEntry{
{
CLIPath: "demo get",
Aliases: map[string]string{"z-name": "name", "a-name": "name"},
Blocked: []string{"page", "count"},
Ambiguous: []string{"user-id"},
},
{CLIPath: "demo empty"},
}
data, err := renderParamAliases(entries)
if err != nil {
t.Fatal(err)
}
text := string(data)
if strings.Index(text, `"a-name": "name"`) > strings.Index(text, `"z-name": "name"`) {
t.Fatalf("alias keys are not sorted:\n%s", text)
}
for _, want := range []string{
"func loadGeneratedParamAliases() []ParamAliasEntry",
`Blocked: []string{"page", "count"}`,
`Ambiguous: []string{"user-id"}`,
`CLIPath: "demo empty"`,
} {
if !strings.Contains(text, want) {
t.Fatalf("generated source missing %q:\n%s", want, text)
}
}
empty, err := renderParamAliases(nil)
if err != nil || !strings.Contains(string(empty), "var generatedParamAliases = []ParamAliasEntry{") {
t.Fatalf("empty render = %q, error = %v", empty, err)
}
if got := renderStringSlice(nil); got != "[]string{}" {
t.Fatalf("renderStringSlice(nil) = %q", got)
}
if got := sortedKeys(nil); len(got) != 0 {
t.Fatalf("sortedKeys(nil) = %v", got)
}
if got := sortedKeys(map[string]string{"b": "2", "a": "1"}); !reflect.DeepEqual(got, []string{"a", "b"}) {
t.Fatalf("sortedKeys() = %v", got)
}
formatParamAliasSource = func([]byte) ([]byte, error) { return nil, errors.New("broken formatter") }
if _, err := renderParamAliases(entries); err == nil || !strings.Contains(err.Error(), "broken formatter") {
t.Fatalf("formatter error = %v", err)
}
}
+13 -1
View File
@@ -853,6 +853,7 @@ func newAitableCommand() *cobra.Command {
dws aitable form [list|delete|update] 表单管理
dws aitable form field [list|update|hide] 表单字段管理
dws aitable form share [get|update|notify] 表单分享管理
dws aitable workflow [edit-example|create|update|enable|disable|get|list] 自动化工作流管理
dws aitable dashboard [get|create|update|delete|config-example] 仪表盘管理
dws aitable chart [get|create|update|delete|widgets-example] 图表管理
dws aitable export data 数据导出
@@ -3260,6 +3261,17 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
},
}
workflowEditExampleCmd := &cobra.Command{
Use: "edit-example",
Short: "获取工作流编辑文档与示例",
Long: `返回服务端提供的 AI 表格工作流编辑文档与示例。
可作为 workflow create / workflow update 的 workflow-dsl/v1 结构参考;此命令不需要 Base ID 或其他参数。`,
Example: ` dws aitable workflow edit-example`,
RunE: func(cmd *cobra.Command, args []string) error {
return callAitableTool("edit_workflow_example", map[string]any{})
},
}
workflowUpdateCmd := &cobra.Command{
Use: "update",
Short: "更新并发布已有自动化工作流",
@@ -4856,7 +4868,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
workflowListCmd.Flags().Int("limit", 0, "分页大小 [1, 100],不传走服务端默认 20")
workflowListCmd.Flags().Int("offset", 0, "分页偏移量,>= 0,不传走服务端默认 0")
workflowCmd.AddCommand(
workflowCreateCmd, workflowUpdateCmd,
workflowEditExampleCmd, workflowCreateCmd, workflowUpdateCmd,
workflowEnableCmd, workflowDisableCmd,
workflowGetCmd, workflowListCmd,
)
@@ -94,6 +94,23 @@ func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
}
}
func TestAitableWorkflowEditExampleMapsEmptyArguments(t *testing.T) {
caller, err := runAitableWorkflowCommand(t, nil, "edit-example")
if err != nil {
t.Fatalf("workflow edit-example returned error: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
}
call := caller.calls[0]
if call.productID != "aitable" || call.toolName != "edit_workflow_example" {
t.Fatalf("tool call = %s/%s, want aitable/edit_workflow_example", call.productID, call.toolName)
}
if len(call.args) != 0 {
t.Fatalf("tool args = %#v, want empty arguments", call.args)
}
}
func TestAitableWorkflowUpdateReadsDSLFile(t *testing.T) {
path := t.TempDir() + "/workflow.json"
if err := os.WriteFile(path, []byte(`{"version":"workflow-dsl/v1","name":"updated"}`), 0o600); err != nil {
+42
View File
@@ -13,6 +13,7 @@ import (
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
@@ -289,6 +290,47 @@ func normalizeAtPlaceholders(text string, ids []string, wrapAngle bool) string {
return text
}
// NormalizeMessageMentions applies the placeholder convention required by the
// selected sender identity and ensures a declared @all is present in the body.
// Current-user messages use <@id>/<@all>; bot and webhook messages use
// @id/@mobile/@all.
func NormalizeMessageMentions(text string, ids []string, atAll, wrapAngle bool) string {
text = normalizeAtPlaceholders(text, ids, wrapAngle)
allPlaceholder := "@all"
if wrapAngle {
text = normalizeAtPlaceholders(text, []string{"all"}, true)
allPlaceholder = "<@all>"
} else {
text = strings.ReplaceAll(text, "<@all>", "@all")
}
if atAll && !containsMessageMention(text, allPlaceholder) {
text = allPlaceholder + " " + text
}
return text
}
func containsMessageMention(text, placeholder string) bool {
if strings.HasPrefix(placeholder, "<") {
return strings.Contains(text, placeholder)
}
for searchFrom := 0; ; {
offset := strings.Index(text[searchFrom:], placeholder)
if offset < 0 {
return false
}
end := searchFrom + offset + len(placeholder)
if end == len(text) {
return true
}
next, _ := utf8.DecodeRuneInString(text[end:])
if !unicode.IsLetter(next) && !unicode.IsDigit(next) &&
next != '_' && next != '-' {
return true
}
searchFrom = end
}
}
func resolveOpenDingTalkID(ctx context.Context, value string) (string, error) {
ids, err := resolveOpenDingTalkIDs(ctx, []string{value})
if err != nil {
@@ -114,6 +114,18 @@ func TestCrossPlatformCoverageChatDirectionAndScalarCoverage(t *testing.T) {
for _, wrap := range []bool{true, false} {
_ = normalizeAtPlaceholders("hello @u1 <@u2>", []string{"", "u1", "u2"}, wrap)
}
if got := NormalizeMessageMentions("hello @u1", []string{"u1"}, true, true); got != "<@all> hello <@u1>" {
t.Fatalf("current-user mention normalization = %q", got)
}
if got := NormalizeMessageMentions("<@all> <@u1>", []string{"u1"}, true, false); got != "@all @u1" {
t.Fatalf("bot mention normalization = %q", got)
}
if got := NormalizeMessageMentions("@alliance hello", nil, true, false); got != "@all @alliance hello" {
t.Fatalf("bot @all token detection = %q", got)
}
if got := NormalizeMessageMentions("hello @all", nil, true, false); got != "hello @all" {
t.Fatalf("trailing bot @all detection = %q", got)
}
}
func TestCrossPlatformCoverageChatContactMappingCoverage(t *testing.T) {
+43
View File
@@ -0,0 +1,43 @@
// 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 helpers
import "context"
// ConversationLocalFileMeta exposes the already-reviewed native chat upload
// metadata to built-in semantic Shortcuts. It remains an alias so the native
// Cobra leaf and Shortcut use exactly the same upload implementation.
type ConversationLocalFileMeta = conversationLocalFileMeta
// BuildConversationLocalFileMeta validates a local file and computes the
// metadata required by DingTalk's conversation-file upload flow.
func BuildConversationLocalFileMeta(filePath, fileName, md5Value string) (ConversationLocalFileMeta, error) {
return buildConversationLocalFileMeta(filePath, fileName, md5Value)
}
// UploadConversationLocalFile executes the existing init -> HTTP upload ->
// commit flow and returns the commit response for message-content assembly.
func UploadConversationLocalFile(
ctx context.Context,
targetArgs map[string]any,
meta ConversationLocalFileMeta,
uuid string,
) (string, error) {
return uploadConversationLocalFile(ctx, targetArgs, meta, uuid)
}
// ParseConversationFileSendIDs extracts the committed dentry and space IDs.
func ParseConversationFileSendIDs(text string) (int64, int64, error) {
return parseConversationFileSendIDs(text)
}
// BuildConversationFileContent renders the exact file-message content accepted
// by send_personal_message.
func BuildConversationFileContent(
dentryID, spaceID int64,
meta ConversationLocalFileMeta,
) (string, error) {
return buildConversationFileContent(dentryID, spaceID, meta)
}
@@ -22,6 +22,7 @@ import (
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -217,6 +218,73 @@ func TestCrossPlatformCoverageChatSendResolvesUserBeforeDispatch(t *testing.T) {
}
}
func TestChatSendAndReplyDefaultToAgentProductForIMClawType(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwenwork")
tests := []struct {
name string
args []string
}{
{
name: "send",
args: []string{"message", "send", "--open-dingtalk-id", "D1", "--text", "hello"},
},
{
name: "reply",
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", "D1", "--text", "hello"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &chatChangedContractCaller{}
if err := executeChatChangedContract(t, caller, tc.args...); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "send_personal_message" {
t.Fatalf("calls = %#v", caller.calls)
}
if got := caller.calls[0].args["clawType"]; got != "qwenwork" {
t.Fatalf("clawType = %#v, want qwenwork", got)
}
})
}
}
func TestChatSendAndReplyDisableAITagWithEmptyClawType(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwenwork")
tests := []struct {
name string
args []string
}{
{
name: "send",
args: []string{"message", "send", "--open-dingtalk-id", "D1", "--text", "hello", "--ai-tag=false"},
},
{
name: "reply",
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", "D1", "--text", "hello", "--ai-tag=false"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &chatChangedContractCaller{}
if err := executeChatChangedContract(t, caller, tc.args...); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "send_personal_message" {
t.Fatalf("calls = %#v", caller.calls)
}
got, present := caller.calls[0].args["clawType"]
if !present || got != "" {
t.Fatalf("clawType = %#v, present = %v; want present empty string", got, present)
}
})
}
}
func TestCrossPlatformCoverageChatSendFailsClosedWhenUserCannotResolve(t *testing.T) {
caller := &chatChangedContractCaller{}
err := executeChatChangedContract(t, caller, "message", "send", "--user", "123", "--text", "hello")
+32
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
stderrors "errors"
"io"
"reflect"
"strings"
"testing"
@@ -651,6 +652,37 @@ func TestDevAppEventSubscribeUsesEventCodes(t *testing.T) {
}
}
type devAppFailingCountRunner struct {
calls int
err error
}
func (r *devAppFailingCountRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.calls++
return executor.Result{Invocation: invocation}, r.err
}
func TestCrossPlatformCoverageDevAppEventSubscribeRunnerFailureIsNotRetried(t *testing.T) {
wantErr := stderrors.New("event subscription failed")
runner := &devAppFailingCountRunner{err: wantErr}
root := newDevAppTestRoot(runner)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{
"dev", "app", "event", "subscribe",
"--unified-app-id", "u-1",
"--event-codes", "a,b",
"--yes",
})
if err := root.Execute(); !stderrors.Is(err, wantErr) {
t.Fatalf("Execute() error = %v, want %v", err, wantErr)
}
if runner.calls != 1 {
t.Fatalf("runner calls = %d, want 1", runner.calls)
}
}
func TestDevAppEventSubscribeRequiresEventCodes(t *testing.T) {
for _, tc := range []struct {
name string
+87
View File
@@ -0,0 +1,87 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCrossPlatformCoverageConversationFileShortcutWrappersReuseNativeFlow(t *testing.T) {
file := filepath.Join(t.TempDir(), "report.txt")
if err := os.WriteFile(file, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
meta, err := BuildConversationLocalFileMeta(file, "", "")
if err != nil {
t.Fatal(err)
}
if meta.FileName != "report.txt" || meta.FileSize != 7 {
t.Fatalf("meta = %#v", meta)
}
content, err := BuildConversationFileContent(11, 22, meta)
if err != nil || !strings.Contains(content, `"dentryId":11`) {
t.Fatalf("content = %q, %v", content, err)
}
dentryID, spaceID, err := ParseConversationFileSendIDs(`{"result":{"dentryId":11,"spaceId":22}}`)
if err != nil || dentryID != 11 || spaceID != 22 {
t.Fatalf("ids = %d/%d, %v", dentryID, spaceID, err)
}
oldPut := httpPutFile
t.Cleanup(func() { httpPutFile = oldPut })
var putPath string
httpPutFile = func(_ context.Context, _ string, _ map[string]string, localPath string, size int64) error {
putPath = localPath
if size != 7 {
t.Errorf("upload size = %d", size)
}
return nil
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://upload.example.test/file","uploadKey":"key"}`},
{text: `{"result":{"dentryId":11,"spaceId":22}}`},
}}
installScriptedCaller(t, caller)
commit, err := UploadConversationLocalFile(
context.Background(),
map[string]any{"openConversationId": "cid"},
meta,
"uuid",
)
if err != nil || !strings.Contains(commit, `"dentryId":11`) || putPath != file || caller.calls != 2 {
t.Fatalf("upload = %q, path=%q calls=%d err=%v", commit, putPath, caller.calls, err)
}
}
func TestCrossPlatformCoverageReadToolNameContractAndHelperBoundary(t *testing.T) {
for tool, want := range map[string]bool{
" get_conversation ": true,
"LIST_MESSAGES": true,
"query_send_status": true,
"search_messages": true,
"unread_message_conversation_list": true,
"send_personal_message": false,
"": false,
} {
if got := IsReadToolName(tool); got != want {
t.Errorf("IsReadToolName(%q) = %v, want %v", tool, got, want)
}
}
caller := &helpersReadCaller{
helpersCoreCaller: &helpersCoreCaller{format: "json", dry: true},
readResult: textToolResult(`{"success":true}`),
}
installHelpersCoreDeps(t, caller)
if _, err := CallMCPReadToolTextOnServer("chat", "send_personal_message", nil); err == nil {
t.Fatal("write tool was accepted by the read helper boundary")
}
if caller.readCalls != 0 || caller.calls != 0 {
t.Fatalf("rejected write reached caller: read=%d regular=%d", caller.readCalls, caller.calls)
}
}
+22
View File
@@ -156,6 +156,12 @@ func callMCPToolReturnTextOnServer(ctx context.Context, serverID, toolName strin
// returning a synthetic dry-run envelope that looks like business data.
func CallMCPReadToolTextOnServer(serverID, toolName string, args map[string]any) (string, error) {
ctx := context.Background()
if !IsReadToolName(toolName) {
return "", &CLIError{
Code: CodeMCPToolError,
Message: fmt.Sprintf("tool %q is not allowed on the dry-run read channel", toolName),
}
}
if deps == nil || deps.Caller == nil {
return "", &CLIError{
Code: CodeMCPToolError,
@@ -176,6 +182,22 @@ func CallMCPReadToolTextOnServer(serverID, toolName string, args map[string]any)
return parseMCPToolTextResult(serverID, toolName, result, err)
}
// IsReadToolName is the fail-closed naming contract for the dry-run read
// channel. Both the Shortcut runtime and the helper boundary enforce it so a
// future direct helper caller cannot accidentally route a write tool through
// ReadToolCaller.
func IsReadToolName(toolName string) bool {
toolName = strings.TrimSpace(strings.ToLower(toolName))
for _, prefix := range []string{
"get_", "list_", "query_", "search_", "unread_",
} {
if strings.HasPrefix(toolName, prefix) {
return true
}
}
return false
}
func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResult, err error) (string, error) {
if err != nil {
if patErr := reclassifyPATFromError(err); patErr != nil {
+9 -6
View File
@@ -55,12 +55,15 @@ Host-owned PAT 开关:
由宿主处理全部 UI / 交互 / 回调节奏 / 重试逻辑,
CLI 侧不再拉起任何本地浏览器 / 轮询。
服务端 Agent 产品标签 claw-type:
开源构建默认在出站 MCP 请求中注入 claw-type: openClaw。
如设置 DWS_AGENT_PRODUCT,则使用经校验的环境变量值覆盖该默认值。
hostControl.clawType 会回填请求实际使用的值,避免 PAT 与请求标识漂移。
该值由调用方声明,不是认证凭据;服务端不得仅凭它放权或跳过授权。
它也不会修改 IM 消息展示使用的 clawType 参数或 --ai-tag 行为。
服务端 PAT / 路由标签 claw-type:
开源构建固定在出站 MCP 请求中注入 claw-type: openClaw,
hostControl.clawType 会回填相同值。DWS_AGENT_PRODUCT 不会修改它。
Agent 产品标识 DWS_AGENT_PRODUCT:
合法非空值作为 x-dws-agent-product 请求头发送,供下游日志 / BI 使用;
本客户端不使用该值派生或改变 PAT、鉴权或路由,下游使用契约由对应
服务自行定义。同时该值作为启用 --ai-tag 时 IM 消息小尾巴的 clawType
参数。未设置或为空时请求头省略,小尾巴回退发行版默认值。
DINGTALK_AGENT(可选,仅供 x-dingtalk-agent 使用):
如设置,将原样注入 HTTP 请求头 x-dingtalk-agent,
+245 -10
View File
@@ -14,9 +14,14 @@
package pipeline
import (
"errors"
"fmt"
"io"
"log/slog"
"os"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
@@ -30,36 +35,61 @@ import (
// If the target command cannot be resolved (e.g. the user typed a
// non-existent command), PreParse is skipped silently and Cobra will
// handle the error.
func RunPreParse(root *cobra.Command, engine *Engine) {
func RunPreParse(root *cobra.Command, engine *Engine) error {
_, err := RunPreParseArgs(root, engine, os.Args[1:])
return err
}
// RunPreParseArgs is the testable form of RunPreParse. Production passes
// os.Args[1:]; end-to-end tests can pass an isolated argv while exercising the
// exact same command traversal, FlagInfo extraction, handler chain, and
// root.SetArgs delivery path.
func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Context, error) {
if engine == nil || !engine.HasHandlers(PreParse) {
return
return nil, nil
}
rawArgs := os.Args[1:]
if len(rawArgs) == 0 {
return
return nil, nil
}
// Traverse the command tree to find the target command.
target, _, err := root.Traverse(rawArgs)
// Cobra's Traverse does not merge root persistent flags before deciding
// whether a leading flag consumes the next token. For example, it can treat
// the command name in `--dry-run calendar event list` as a value and resolve
// the unrelated root command `event list`. Remove only known root-persistent
// flags from the traversal copy; the original argv remains intact for the
// handlers and Cobra's real parse.
target, _, err := root.Traverse(argsForCommandTraversal(root, rawArgs))
if err != nil {
return
return nil, nil
}
// Build FlagInfo from the target command's registered flags.
flagInfos := FlagInfoFromCommand(target)
if len(flagInfos) == 0 {
return
return nil, nil
}
ctx := &Context{
Args: append([]string{}, rawArgs...),
Args: append([]string{}, rawArgs...),
// target.CommandPath() still carries the "dws" prefix; PreParse
// handlers that key off a command (e.g. the semantic-alias table)
// normalize it themselves, so the runtime key matches the build key.
Command: target.CommandPath(),
FlagSpecs: flagInfos,
}
if err := engine.RunPhase(PreParse, ctx); err != nil {
// Cobra has not parsed persistent flags yet, but this error is rendered
// immediately by app.Execute. Prime only the presentation controls so
// --format/--debug/--verbose affect this early error exactly as they do
// errors returned after Cobra parsing. No credentials, profiles, output
// paths, or execution controls are applied here.
if presentationErr := primeEarlyErrorPresentation(root, target, ctx.Args); presentationErr != nil {
slog.Debug("pipeline pre-parse presentation flags", "error", presentationErr)
}
slog.Debug("pipeline pre-parse", "error", err)
return
return ctx, err
}
// Only set corrected args if PreParse actually changed something.
@@ -75,6 +105,210 @@ func RunPreParse(root *cobra.Command, engine *Engine) {
)
}
}
return ctx, nil
}
func argsForCommandTraversal(root *cobra.Command, rawArgs []string) []string {
if root == nil || len(rawArgs) == 0 {
return rawArgs
}
flags := root.PersistentFlags()
if flags == nil || !flags.HasFlags() {
return rawArgs
}
matcher := newFlagTokenMatcher(flags)
out := make([]string, 0, len(rawArgs))
for index := 0; index < len(rawArgs); index++ {
argument := rawArgs[index]
if argument == "--" {
out = append(out, rawArgs[index:]...)
break
}
flag, inlineValue, matched := matcher.matchTraversalToken(argument)
if !matched {
out = append(out, argument)
continue
}
if index+1 < len(rawArgs) {
if _, ok := separatedBoolValue(argument, rawArgs[index+1], flag, inlineValue); ok {
index++
continue
}
}
if !inlineValue && flag.NoOptDefVal == "" && index+1 < len(rawArgs) {
index++
}
}
return out
}
// separatedBoolValue recognises model-friendly `--flag false` and exact
// shorthand `-f false` spellings without mistaking an already attached value
// or a shorthand cluster for a detached value. pflag otherwise treats a bare
// bool flag as true and leaves the following token positional.
func separatedBoolValue(argument, following string, flag *pflag.Flag, inlineValue bool) (string, bool) {
if flag == nil || (flag.Value.Type() != "bool" && flag.Value.Type() != "boolean") {
return "", false
}
if strings.HasPrefix(argument, "--") {
if inlineValue || strings.Contains(argument, "=") {
return "", false
}
} else if flag.Shorthand == "" || argument != "-"+flag.Shorthand {
return "", false
}
return cmdutil.NormalizeBoolLiteral(following)
}
type longFlagMatch struct {
flag *pflag.Flag
value string
hasValue bool
recognized bool
}
type flagTokenMatcher struct {
byName map[string]*pflag.Flag
byShorthand map[string]*pflag.Flag
known map[string]bool
candidates []string
specByName map[string]FlagInfo
}
func newFlagTokenMatcher(flagSets ...*pflag.FlagSet) *flagTokenMatcher {
matcher := &flagTokenMatcher{
byName: make(map[string]*pflag.Flag),
byShorthand: make(map[string]*pflag.Flag),
known: make(map[string]bool),
specByName: make(map[string]FlagInfo),
}
for _, flags := range flagSets {
if flags == nil {
continue
}
flags.VisitAll(func(flag *pflag.Flag) {
if _, exists := matcher.byName[flag.Name]; exists {
return
}
matcher.byName[flag.Name] = flag
matcher.known[flag.Name] = true
matcher.candidates = append(matcher.candidates, flag.Name)
matcher.specByName[flag.Name] = flagInfoFromPflag(flag)
if flag.Shorthand != "" {
matcher.byShorthand[flag.Shorthand] = flag
}
})
}
return matcher
}
func (m *flagTokenMatcher) matchLongToken(argument string) longFlagMatch {
if m == nil || !strings.HasPrefix(argument, "--") || argument == "--" {
return longFlagMatch{}
}
canonical := argument
body := strings.TrimPrefix(canonical, "--")
name, value, hasValue := strings.Cut(body, "=")
if flag := m.byName[name]; flag != nil {
return longFlagMatch{flag: flag, value: value, hasValue: hasValue, recognized: true}
}
if normalized, ok := NormalizeFlagToken(argument, m.known); ok {
canonical = normalized
} else if split, ok := SplitStickyFlag(argument, m.specByName); ok {
name = strings.TrimPrefix(split.Flag, "--")
return longFlagMatch{flag: m.byName[name], value: split.Value, hasValue: true, recognized: true}
} else if fuzzy, ok := FuzzyMatchFlag(argument, m.known, m.candidates); ok {
canonical = fuzzy
} else {
return longFlagMatch{}
}
body = strings.TrimPrefix(canonical, "--")
name, value, hasValue = strings.Cut(body, "=")
flag := m.byName[name]
return longFlagMatch{flag: flag, value: value, hasValue: hasValue, recognized: flag != nil}
}
func (m *flagTokenMatcher) matchTraversalToken(argument string) (*pflag.Flag, bool, bool) {
if m == nil || argument == "" || argument == "-" || argument == "--" {
return nil, false, false
}
if strings.HasPrefix(argument, "--") {
match := m.matchLongToken(argument)
return match.flag, match.hasValue, match.recognized
}
if !strings.HasPrefix(argument, "-") {
return nil, false, false
}
body := strings.TrimPrefix(argument, "-")
shorthands := []rune(body)
for index, shorthand := range shorthands {
flag := m.byShorthand[string(shorthand)]
if flag == nil {
return nil, false, false
}
if flag.NoOptDefVal == "" {
// A value-taking shorthand consumes the next token only when it is
// last; otherwise the remainder is its attached value (`-vfjson`).
return flag, index < len(shorthands)-1, true
}
}
return m.byShorthand[string(shorthands[0])], true, true
}
func primeEarlyErrorPresentation(root, target *cobra.Command, rawArgs []string) error {
if root == nil || target == nil || len(rawArgs) == 0 {
return nil
}
rootFlags := root.PersistentFlags()
presentationFlags := pflag.NewFlagSet("early-error-presentation", pflag.ContinueOnError)
presentationFlags.SetOutput(io.Discard)
presentationFlags.ParseErrorsWhitelist.UnknownFlags = true
presentationFlags.SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
return pflag.NormalizedName(cmdutil.Morph(name))
})
names := make([]string, 0, 3)
if source := rootFlags.Lookup("format"); source != nil {
value, err := rootFlags.GetString("format")
if err != nil {
return fmt.Errorf("read presentation flag --format: %w", err)
}
presentationFlags.StringP("format", source.Shorthand, value, source.Usage)
names = append(names, "format")
}
for _, name := range []string{"debug", "verbose"} {
source := rootFlags.Lookup(name)
if source == nil {
continue
}
value, err := rootFlags.GetBool(name)
if err != nil {
return fmt.Errorf("read presentation flag --%s: %w", name, err)
}
presentationFlags.BoolP(name, source.Shorthand, value, source.Usage)
names = append(names, name)
}
// Keep the existing contract in which a PreParse conflict wins over help;
// registering help prevents pflag's special unknown-help early return.
presentationFlags.BoolP("help", "h", false, "")
parseErr := presentationFlags.Parse(rawArgs)
errs := []error{parseErr}
for _, name := range names {
if !presentationFlags.Changed(name) {
continue
}
value := presentationFlags.Lookup(name).Value.String()
if err := rootFlags.Set(name, value); err != nil {
errs = append(errs, fmt.Errorf("apply presentation flag --%s: %w", name, err))
}
}
return errors.Join(errs...)
}
// FlagInfoFromCommand extracts FlagInfo entries from a Cobra
@@ -117,6 +351,7 @@ func appendFlagInfo(infos *[]FlagInfo, seen map[string]bool, flag *pflag.Flag) {
func flagInfoFromPflag(f *pflag.Flag) FlagInfo {
fi := FlagInfo{
Name: f.Name,
Shorthand: f.Shorthand,
PropertyName: f.Name,
Type: f.Value.Type(),
}
+345 -3
View File
@@ -4,9 +4,11 @@ import (
"errors"
"os"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
func TestCrossPlatformCoverageFlagInfoFromCommandIncludesLocalInheritedAndAnnotations(t *testing.T) {
@@ -16,7 +18,7 @@ func TestCrossPlatformCoverageFlagInfoFromCommandIncludesLocalInheritedAndAnnota
root := &cobra.Command{Use: "root"}
root.PersistentFlags().String("profile", "", "")
child := &cobra.Command{Use: "child"}
child.Flags().String("start-time", "", "")
child.Flags().StringP("start-time", "s", "", "")
child.Flags().Lookup("start-time").Annotations = map[string][]string{
"x-cli-format": {"date-time"},
"x-cli-enum": {"one", "two"},
@@ -31,7 +33,7 @@ func TestCrossPlatformCoverageFlagInfoFromCommandIncludesLocalInheritedAndAnnota
for _, info := range infos {
byName[info.Name] = info
}
if byName["profile"].Type != "string" || byName["start-time"].Format != "date-time" ||
if byName["profile"].Type != "string" || byName["start-time"].Shorthand != "s" || byName["start-time"].Format != "date-time" ||
!reflect.DeepEqual(byName["start-time"].Enum, []string{"one", "two"}) {
t.Fatalf("flag infos = %#v", infos)
}
@@ -106,8 +108,348 @@ func TestCrossPlatformCoverageRunPreParseAppliesCorrectionsOnlyOnSuccess(t *test
failing := NewEngine()
failing.Register(newStub("fail", PreParse, func(*Context) error { return errors.New("boom") }))
os.Args = []string{"root", "child", "--name", "original"}
RunPreParse(root, failing)
if err := RunPreParse(root, failing); err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("failed preparse error = %v, want boom", err)
}
if err := root.Execute(); err != nil || *value != "original" {
t.Fatalf("failed preparse execute = %q, %v", *value, err)
}
}
func TestRunPreParseResolvesCommandPastLeadingPersistentFlags(t *testing.T) {
tests := []struct {
name string
args []string
executable bool
}{
{name: "boolean long flag", args: []string{"--dry-run", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true},
{name: "boolean long flag with detached false", args: []string{"--dry-run", "false", "calendar", "event", "list", "--date", "2026-03-10"}},
{name: "camel-case boolean long flag", args: []string{"--dryRun", "calendar", "event", "list", "--date", "2026-03-10"}},
{name: "camel-case boolean with detached false", args: []string{"--dryRun", "false", "calendar", "event", "list", "--date", "2026-03-10"}},
{name: "fuzzy boolean long flag", args: []string{"--dry-rnu", "calendar", "event", "list", "--date", "2026-03-10"}},
{name: "fuzzy boolean with detached false", args: []string{"--dry-rnu", "false", "calendar", "event", "list", "--date", "2026-03-10"}},
{name: "valued long flag", args: []string{"--profile", "corp:user", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true},
{name: "fuzzy valued long flag", args: []string{"--profle", "corp:user", "calendar", "event", "list", "--date", "2026-03-10"}},
{name: "sticky valued long flag", args: []string{"--timeout30", "calendar", "event", "list", "--date", "2026-03-10"}},
{name: "valued shorthand", args: []string{"-f", "json", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true},
{name: "attached shorthand", args: []string{"-fjson", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true},
{name: "clustered attached shorthand", args: []string{"-vfjson", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true},
{name: "boolean shorthand with detached false", args: []string{"-v", "false", "calendar", "event", "list", "--date", "2026-03-10"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("profile", "", "")
root.PersistentFlags().Int("timeout", 0, "")
root.PersistentFlags().StringP("format", "f", "json", "")
root.PersistentFlags().BoolP("verbose", "v", false, "")
// This similarly named root path makes the old traversal failure
// deterministic: `--dry-run` consumed "calendar" as a value and
// incorrectly selected `dws event list`.
misleadingEvent := &cobra.Command{Use: "event"}
misleadingEvent.AddCommand(&cobra.Command{Use: "list"})
root.AddCommand(misleadingEvent)
calendar := &cobra.Command{Use: "calendar"}
event := &cobra.Command{Use: "event"}
value := ""
list := &cobra.Command{Use: "list"}
list.Flags().StringVar(&value, "start", "", "")
event.AddCommand(list)
calendar.AddCommand(event)
root.AddCommand(calendar)
engine := NewEngine()
engine.Register(newStub("calendar-date-alias", PreParse, func(ctx *Context) error {
if ctx.Command != "dws calendar event list" {
t.Fatalf("resolved command = %q, want dws calendar event list", ctx.Command)
}
for index, argument := range ctx.Args {
if argument == "--date" {
ctx.Args[index] = "--start"
ctx.AddCorrection("calendar-date-alias", PreParse, "start", "--date", "--start", "test")
}
}
return nil
}))
root.SetArgs(test.args)
ctx, err := RunPreParseArgs(root, engine, test.args)
if err != nil {
t.Fatalf("RunPreParseArgs() error = %v", err)
}
if ctx == nil || len(ctx.Corrections) != 1 {
t.Fatalf("RunPreParseArgs() context = %#v", ctx)
}
if test.executable {
if err := root.Execute(); err != nil {
t.Fatalf("corrected command failed: %v", err)
}
if value != "2026-03-10" {
t.Fatalf("canonical --start value = %q", value)
}
}
})
}
}
func TestRunPreParsePrimesPresentationFlagsForEarlyErrors(t *testing.T) {
tests := []struct {
name string
args []string
wantFormat string
wantDebug bool
wantVerbose bool
}{
{
name: "canonical flags after command",
args: []string{"child", "--name", "demo", "--format", "table", "--debug"},
wantFormat: "table",
wantDebug: true,
},
{
name: "normalized presentation names",
args: []string{"--dryRun", "--FORMAT=pretty", "--Verbose", "child", "--name", "demo"},
wantFormat: "pretty",
wantVerbose: true,
},
{
name: "clustered shorthands",
args: []string{"-vfraw", "child", "--name", "demo"},
wantFormat: "raw",
wantVerbose: true,
},
{
name: "explicit boolean presentation values",
args: []string{"--debug=true", "-v=false", "child", "--name", "demo"},
wantFormat: "json",
wantDebug: true,
wantVerbose: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().StringP("format", "f", "json", "")
root.PersistentFlags().Bool("debug", false, "")
root.PersistentFlags().BoolP("verbose", "v", false, "")
child := &cobra.Command{Use: "child"}
child.Flags().String("name", "", "")
root.AddCommand(child)
engine := NewEngine()
engine.Register(newStub("fail", PreParse, func(*Context) error { return errors.New("early") }))
ctx, err := RunPreParseArgs(root, engine, test.args)
if err == nil || ctx == nil {
t.Fatalf("RunPreParseArgs() = %#v, %v; want early error with context", ctx, err)
}
format, _ := root.PersistentFlags().GetString("format")
debug, _ := root.PersistentFlags().GetBool("debug")
verbose, _ := root.PersistentFlags().GetBool("verbose")
if format != test.wantFormat || debug != test.wantDebug || verbose != test.wantVerbose {
t.Fatalf("presentation flags = format:%q debug:%v verbose:%v; want %q/%v/%v", format, debug, verbose, test.wantFormat, test.wantDebug, test.wantVerbose)
}
})
}
}
func TestRunPreParseKeepsPrimaryErrorWhenPresentationParsingFails(t *testing.T) {
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().StringP("format", "f", "json", "")
root.PersistentFlags().Bool("debug", false, "")
root.PersistentFlags().BoolP("verbose", "v", false, "")
child := &cobra.Command{Use: "child"}
child.Flags().String("name", "", "")
root.AddCommand(child)
primaryErr := errors.New("primary pre-parse failure")
engine := NewEngine()
engine.Register(newStub("fail", PreParse, func(*Context) error { return primaryErr }))
ctx, err := RunPreParseArgs(root, engine, []string{
"child", "--name", "demo", "--format", "table", "--debug=maybe",
})
if ctx == nil || !errors.Is(err, primaryErr) {
t.Fatalf("RunPreParseArgs() = %#v, %v; want primary error", ctx, err)
}
format, _ := root.PersistentFlags().GetString("format")
debug, _ := root.PersistentFlags().GetBool("debug")
if format != "table" || debug {
t.Fatalf("partially valid presentation values = format:%q debug:%v", format, debug)
}
}
func TestCommandTraversalFlagTokenEdges(t *testing.T) {
raw := []string{"child"}
if got := argsForCommandTraversal(nil, raw); !reflect.DeepEqual(got, raw) {
t.Fatalf("nil-root traversal args = %v", got)
}
root := &cobra.Command{Use: "root"}
if got := argsForCommandTraversal(root, nil); got != nil {
t.Fatalf("empty traversal args = %v", got)
}
root.PersistentFlags().BoolP("verbose", "v", false, "")
root.PersistentFlags().StringP("format", "f", "", "")
if got := argsForCommandTraversal(root, []string{"--", "--verbose", "child"}); !reflect.DeepEqual(got, []string{"--", "--verbose", "child"}) {
t.Fatalf("double-dash traversal args = %v", got)
}
if flag, inline, matched := newFlagTokenMatcher(nil).matchTraversalToken("--verbose"); flag != nil || inline || matched {
t.Fatalf("nil flag set matched: %#v, %v, %v", flag, inline, matched)
}
if flag, inline, matched := (*flagTokenMatcher)(nil).matchTraversalToken(""); flag != nil || inline || matched {
t.Fatalf("nil matcher matched: %#v, %v, %v", flag, inline, matched)
}
if match := (*flagTokenMatcher)(nil).matchLongToken("--verbose"); match.recognized {
t.Fatalf("nil long matcher matched: %#v", match)
}
if flag, inline, matched := newFlagTokenMatcher(root.PersistentFlags()).matchTraversalToken("-x"); flag != nil || inline || matched {
t.Fatalf("unknown shorthand matched: %#v, %v, %v", flag, inline, matched)
}
flag, inline, matched := newFlagTokenMatcher(root.PersistentFlags()).matchTraversalToken("-vv")
if !matched || !inline || flag == nil || flag.Name != "verbose" {
t.Fatalf("boolean shorthand cluster = %#v, %v, %v", flag, inline, matched)
}
duplicate := pflag.NewFlagSet("duplicate", pflag.ContinueOnError)
duplicate.Bool("verbose", false, "")
matcher := newFlagTokenMatcher(root.PersistentFlags(), duplicate)
if len(matcher.byName) != 2 || matcher.byName["verbose"] != root.PersistentFlags().Lookup("verbose") {
t.Fatalf("duplicate flag precedence = %#v", matcher.byName)
}
}
func TestSeparatedBoolValueRecognition(t *testing.T) {
flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
flags.BoolP("verbose", "v", false, "")
flags.String("format", "", "")
verbose := flags.Lookup("verbose")
format := flags.Lookup("format")
tests := []struct {
name string
argument string
following string
flag *pflag.Flag
inline bool
want string
ok bool
}{
{name: "nil flag", argument: "--verbose", following: "false"},
{name: "non bool", argument: "--format", following: "false", flag: format},
{name: "long false", argument: "--verbose", following: "false", flag: verbose, want: "false", ok: true},
{name: "long synonym", argument: "--verbose", following: "on", flag: verbose, want: "true", ok: true},
{name: "inline long", argument: "--verbosefalse", following: "false", flag: verbose, inline: true},
{name: "equals long", argument: "--verbose=false", following: "true", flag: verbose},
{name: "exact shorthand", argument: "-v", following: "0", flag: verbose, want: "false", ok: true},
{name: "shorthand cluster", argument: "-vv", following: "false", flag: verbose},
{name: "invalid literal", argument: "--verbose", following: "maybe", flag: verbose},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, ok := separatedBoolValue(test.argument, test.following, test.flag, test.inline)
if got != test.want || ok != test.ok {
t.Fatalf("separatedBoolValue() = %q, %v; want %q, %v", got, ok, test.want, test.ok)
}
})
}
}
func TestPrimeEarlyErrorPresentationEdges(t *testing.T) {
if err := primeEarlyErrorPresentation(nil, nil, nil); err != nil {
t.Fatalf("nil presentation priming error = %v", err)
}
root := &cobra.Command{Use: "root"}
root.PersistentFlags().StringP("format", "f", "json", "")
root.PersistentFlags().Bool("debug", false, "")
root.PersistentFlags().BoolP("verbose", "v", false, "")
child := &cobra.Command{Use: "child"}
child.Flags().String("name", "", "")
root.AddCommand(child)
if err := primeEarlyErrorPresentation(root, child, []string{
"child", "--unknown", "value", "-x", "--name", "demo",
"-f", "table", "-v", "maybe", "--", "--debug",
}); err != nil {
t.Fatalf("presentation priming error = %v", err)
}
format, _ := root.PersistentFlags().GetString("format")
debug, _ := root.PersistentFlags().GetBool("debug")
verbose, _ := root.PersistentFlags().GetBool("verbose")
if format != "table" || debug || !verbose {
t.Fatalf("presentation after edge argv = format:%q debug:%v verbose:%v", format, debug, verbose)
}
}
func TestPrimeEarlyErrorPresentationReportsParseAndContractErrors(t *testing.T) {
t.Run("invalid presentation value is reported after applying valid values", func(t *testing.T) {
root := &cobra.Command{Use: "root"}
root.PersistentFlags().StringP("format", "f", "json", "")
root.PersistentFlags().Bool("debug", false, "")
root.PersistentFlags().BoolP("verbose", "v", false, "")
child := &cobra.Command{Use: "child"}
root.AddCommand(child)
err := primeEarlyErrorPresentation(root, child, []string{"child", "--format", "table", "--debug=maybe"})
if err == nil || !strings.Contains(err.Error(), "invalid argument") {
t.Fatalf("invalid presentation error = %v", err)
}
format, _ := root.PersistentFlags().GetString("format")
debug, _ := root.PersistentFlags().GetBool("debug")
if format != "table" || debug {
t.Fatalf("partially valid presentation values = format:%q debug:%v", format, debug)
}
})
for _, test := range []struct {
name string
add func(*pflag.FlagSet)
want string
}{
{name: "format type drift", add: func(flags *pflag.FlagSet) { flags.Bool("format", false, "") }, want: "read presentation flag --format"},
{name: "debug type drift", add: func(flags *pflag.FlagSet) {
flags.String("format", "json", "")
flags.String("debug", "", "")
}, want: "read presentation flag --debug"},
} {
t.Run(test.name, func(t *testing.T) {
root := &cobra.Command{Use: "root"}
test.add(root.PersistentFlags())
err := primeEarlyErrorPresentation(root, root, []string{"--format", "table"})
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("presentation contract error = %v, want %q", err, test.want)
}
})
}
t.Run("apply error is returned", func(t *testing.T) {
root := &cobra.Command{Use: "root"}
value := &rejectingPresentationString{value: "json"}
root.PersistentFlags().VarP(value, "format", "f", "")
err := primeEarlyErrorPresentation(root, root, []string{"--format", "table"})
if err == nil || !strings.Contains(err.Error(), "apply presentation flag --format") {
t.Fatalf("presentation apply error = %v", err)
}
})
t.Run("missing presentation flags are optional", func(t *testing.T) {
root := &cobra.Command{Use: "root"}
if err := primeEarlyErrorPresentation(root, root, []string{"--unknown", "value"}); err != nil {
t.Fatalf("optional presentation flags error = %v", err)
}
})
}
type rejectingPresentationString struct {
value string
}
func (v *rejectingPresentationString) Set(string) error { return errors.New("rejected") }
func (v *rejectingPresentationString) String() string { return v.value }
func (*rejectingPresentationString) Type() string { return "string" }
+18 -1
View File
@@ -24,6 +24,23 @@ type Engine struct {
handlers map[Phase][]Handler
}
// HandlerError preserves the pipeline location of a handler failure for logs
// and diagnostics while keeping the underlying domain error available to
// user-facing adapters through Unwrap.
type HandlerError struct {
Phase Phase
Handler string
Cause error
}
func (e *HandlerError) Error() string {
return fmt.Sprintf("pipeline %s handler %q: %v", e.Phase, e.Handler, e.Cause)
}
func (e *HandlerError) Unwrap() error {
return e.Cause
}
// NewEngine creates a pipeline engine with no registered handlers.
func NewEngine() *Engine {
return &Engine{
@@ -64,7 +81,7 @@ func (e *Engine) HasHandlers(phase Phase) bool {
func (e *Engine) RunPhase(phase Phase, ctx *Context) error {
for _, h := range e.handlers[phase] {
if err := h.Handle(ctx); err != nil {
return fmt.Errorf("pipeline %s handler %q: %w", phase, h.Name(), err)
return &HandlerError{Phase: phase, Handler: h.Name(), Cause: err}
}
}
return nil
+40
View File
@@ -168,6 +168,10 @@ func TestRunPhaseErrorAbortsChain(t *testing.T) {
if !strings.Contains(err.Error(), "fail") {
t.Errorf("error should contain handler name, got %q", err.Error())
}
var handlerErr *HandlerError
if !errors.As(err, &handlerErr) || handlerErr.Phase != PreParse || handlerErr.Handler != "fail" || handlerErr.Unwrap() != boom {
t.Fatalf("handler error = %#v, want pre-parse/fail wrapping boom", handlerErr)
}
if !h1.called {
t.Error("h1 should have been called")
}
@@ -240,6 +244,42 @@ func TestContextAddCorrection(t *testing.T) {
}
}
func TestContextFlagProtectionAndConflictError(t *testing.T) {
var nilContext *Context
nilContext.ProtectFlag("uid", FlagProtectionBlocked)
if nilContext.IsFlagProtected("uid") {
t.Fatal("nil context reported a protected flag")
}
ctx := &Context{}
ctx.ProtectFlag("", FlagProtectionBlocked)
if ctx.ProtectedFlags != nil {
t.Fatalf("empty flag initialized protection map: %#v", ctx.ProtectedFlags)
}
ctx.ProtectFlag("uid", FlagProtectionAmbiguous)
if !ctx.IsFlagProtected("uid") || ctx.IsFlagProtected("missing") {
t.Fatalf("protection lookup mismatch: %#v", ctx.ProtectedFlags)
}
err := (&FlagConflictError{
Command: "dws demo run",
Canonical: "user",
Spellings: []string{"--user-id", "uid"},
}).Error()
if !strings.Contains(err, `for --user on "dws demo run": --user-id, --uid`) {
t.Fatalf("FlagConflictError.Error() = %q", err)
}
boolErr := (&BoolValueConflictError{
Command: "dws demo run",
Flag: "--yes",
Values: []string{"true", "false"},
}).Error()
if !strings.Contains(boolErr, `for --yes on "dws demo run": false, true`) {
t.Fatalf("BoolValueConflictError.Error() = %q", boolErr)
}
}
func TestPhaseString(t *testing.T) {
tests := []struct {
phase Phase
+153
View File
@@ -0,0 +1,153 @@
// 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 pipeline
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
)
const maxFlagEditDistance = 2
// NormalizeFlagToken folds a long flag's morphological spelling and returns a
// canonical token only when the folded name is a real flag. Both the normal
// PreParse handler chain and Cobra command traversal use this primitive so a
// spelling accepted after a command is also recognised before it.
func NormalizeFlagToken(argument string, known map[string]bool) (string, bool) {
if !strings.HasPrefix(argument, "--") {
return "", false
}
bare := argument[2:]
if bare == "" {
return "", false
}
var suffix string
if index := strings.IndexByte(bare, '='); index >= 0 {
suffix = bare[index:]
bare = bare[:index]
}
if known[bare] {
return "", false
}
normalized := cmdutil.Morph(bare)
if normalized == bare || !known[normalized] {
return "", false
}
return "--" + normalized + suffix, true
}
// StickyFlagPair is the canonical flag and value resolved from one glued
// long-flag token. Inline is required for boolean flags because pflag treats a
// bare boolean as true without consuming the following argv token.
type StickyFlagPair struct {
Flag string
Value string
Inline bool
}
// SplitStickyFlag splits a safely recognisable glued flag/value token. The
// suffix must satisfy the real flag's type/format/enum contract, preventing a
// typo from being reinterpreted as data.
func SplitStickyFlag(argument string, specByName map[string]FlagInfo) (StickyFlagPair, bool) {
if !strings.HasPrefix(argument, "--") || strings.Contains(argument, "=") {
return StickyFlagPair{}, false
}
bare := argument[2:]
if bare == "" {
return StickyFlagPair{}, false
}
if _, ok := specByName[bare]; ok {
return StickyFlagPair{}, false
}
if _, ok := specByName[cmdutil.Morph(bare)]; ok {
return StickyFlagPair{}, false
}
for index := len(bare) - 1; index >= 1; index-- {
prefix := bare[:index]
matchedFlag := ""
if _, ok := specByName[prefix]; ok {
matchedFlag = prefix
} else if normalized := cmdutil.Morph(prefix); normalized != "" {
if _, ok := specByName[normalized]; ok {
matchedFlag = normalized
}
}
if matchedFlag == "" {
continue
}
suffix := bare[index:]
spec := specByName[matchedFlag]
if !cmdutil.SuffixLooksLikeValue(suffix, spec.Type, spec.Format, spec.Enum) {
return StickyFlagPair{}, false
}
inline := false
if spec.Type == "bool" || spec.Type == "boolean" {
suffix, _ = cmdutil.NormalizeBoolLiteral(suffix)
inline = true
}
return StickyFlagPair{Flag: "--" + matchedFlag, Value: suffix, Inline: inline}, true
}
return StickyFlagPair{}, false
}
// FuzzyMatchFlag returns the unique closest real long flag within the
// conservative edit-distance threshold used by ParamNameHandler.
func FuzzyMatchFlag(argument string, known map[string]bool, candidates []string) (string, bool) {
if !strings.HasPrefix(argument, "--") {
return "", false
}
bare := argument[2:]
if bare == "" {
return "", false
}
var suffix string
if index := strings.IndexByte(bare, '='); index >= 0 {
suffix = bare[index:]
bare = bare[:index]
}
if known[bare] {
return "", false
}
threshold := maxFlagEditDistance
if len(bare) <= 3 {
threshold = 1
}
bestDistance := threshold + 1
bestMatch := ""
ambiguous := false
for _, candidate := range candidates {
distance := cmdutil.LevenshteinDist(bare, candidate)
if distance < bestDistance {
bestDistance = distance
bestMatch = candidate
ambiguous = false
} else if distance == bestDistance && candidate != bestMatch {
ambiguous = true
}
}
if bestDistance > threshold || ambiguous || bestMatch == "" {
return "", false
}
return "--" + bestMatch + suffix, true
}
+101
View File
@@ -0,0 +1,101 @@
// 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 pipeline
import "testing"
func TestNormalizeFlagTokenDirectContract(t *testing.T) {
known := map[string]bool{"user-id": true}
tests := []struct {
argument string
want string
ok bool
}{
{argument: "-u"},
{argument: "--"},
{argument: "--user-id"},
{argument: "--unknown"},
{argument: "--unknownFlag"},
{argument: "--userId=42", want: "--user-id=42", ok: true},
}
for _, test := range tests {
got, ok := NormalizeFlagToken(test.argument, known)
if got != test.want || ok != test.ok {
t.Errorf("NormalizeFlagToken(%q) = %q, %v; want %q, %v", test.argument, got, ok, test.want, test.ok)
}
}
}
func TestSplitStickyFlagDirectContract(t *testing.T) {
specs := map[string]FlagInfo{
"limit": {Name: "limit", Type: "int"},
"page-size": {Name: "page-size", Type: "int"},
"yes": {Name: "yes", Type: "bool"},
}
tests := []struct {
argument string
want StickyFlagPair
ok bool
}{
{argument: "-limit100"},
{argument: "--limit=100"},
{argument: "--"},
{argument: "--limit"},
{argument: "--pageSize"},
{argument: "--unknown100"},
{argument: "--limitabc"},
{argument: "--yesfalse", want: StickyFlagPair{Flag: "--yes", Value: "false", Inline: true}, ok: true},
{argument: "--yestrue", want: StickyFlagPair{Flag: "--yes", Value: "true", Inline: true}, ok: true},
{argument: "--yesno", want: StickyFlagPair{Flag: "--yes", Value: "false", Inline: true}, ok: true},
{argument: "--yesmaybe"},
{argument: "--limit100", want: StickyFlagPair{Flag: "--limit", Value: "100"}, ok: true},
{argument: "--pageSize50", want: StickyFlagPair{Flag: "--page-size", Value: "50"}, ok: true},
}
for _, test := range tests {
got, ok := SplitStickyFlag(test.argument, specs)
if got != test.want || ok != test.ok {
t.Errorf("SplitStickyFlag(%q) = %#v, %v; want %#v, %v", test.argument, got, ok, test.want, test.ok)
}
}
}
func TestFuzzyMatchFlagDirectContract(t *testing.T) {
known := map[string]bool{"limit": true, "name": true, "nave": true, "id": true}
candidates := []string{"limit", "name", "nave", "id"}
tests := []struct {
argument string
candidates []string
useCandidates bool
want string
ok bool
}{
{argument: "-limt"},
{argument: "--"},
{argument: "--limit"},
{argument: "--xy"},
{argument: "--nae"},
{argument: "--nothing", useCandidates: true},
{argument: "--limt=10", want: "--limit=10", ok: true},
}
for _, test := range tests {
caseCandidates := candidates
if test.useCandidates {
caseCandidates = test.candidates
}
got, ok := FuzzyMatchFlag(test.argument, known, caseCandidates)
if got != test.want || ok != test.ok {
t.Errorf("FuzzyMatchFlag(%q) = %q, %v; want %q, %v", test.argument, got, ok, test.want, test.ok)
}
}
}
+12 -74
View File
@@ -14,10 +14,8 @@
package handlers
import (
"strings"
"unicode"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
)
// AliasHandler normalises flag names in raw argv so that common
@@ -46,6 +44,10 @@ func (AliasHandler) Handle(ctx *pipeline.Context) error {
result := make([]string, 0, len(ctx.Args))
for i, arg := range ctx.Args {
if arg == "--" {
result = append(result, ctx.Args[i:]...)
break
}
rewritten, ok := tryNormaliseFlag(arg, known)
if ok {
ctx.AddCorrection("alias", pipeline.PreParse, rewritten, arg, rewritten, "alias")
@@ -63,40 +65,14 @@ func (AliasHandler) Handle(ctx *pipeline.Context) error {
// normalised to a known flag name. It handles both bare flags and
// "--flag=value" syntax.
func tryNormaliseFlag(arg string, known map[string]bool) (string, bool) {
if !strings.HasPrefix(arg, "--") {
return "", false
}
bare := arg[2:]
if bare == "" {
return "", false
}
// Handle --flag=value syntax: split, normalise the key, reassemble.
var suffix string
if idx := strings.IndexByte(bare, '='); idx >= 0 {
suffix = bare[idx:] // includes "="
bare = bare[:idx]
}
// Already a known flag in its current form — no change needed.
if known[bare] {
return "", false
}
normalised := toKebabCase(bare)
if normalised == bare {
return "", false
}
if !known[normalised] {
return "", false
}
return "--" + normalised + suffix, true
return pipeline.NormalizeFlagToken(arg, known)
}
// toKebabCase converts a string from camelCase, PascalCase, or
// snake_case to kebab-case. Examples:
// toKebabCase converts a string from camelCase, PascalCase, or snake_case to
// kebab-case. It is a thin compatibility shim over the single shared
// normaliser cmdutil.Morph so the pipeline handlers and the build-time
// parameter-alias generator can never diverge on how a flag spelling is
// folded. Examples:
//
// "userId" → "user-id"
// "UserName" → "user-name"
@@ -104,43 +80,5 @@ func tryNormaliseFlag(arg string, known map[string]bool) (string, bool) {
// "USER_ID" → "user-id"
// "pageSize" → "page-size"
func toKebabCase(s string) string {
if s == "" {
return ""
}
var b strings.Builder
b.Grow(len(s) + 4) // small extra for hyphens
runes := []rune(s)
for i, r := range runes {
if r == '_' || r == ' ' {
if b.Len() > 0 {
b.WriteByte('-')
}
continue
}
if unicode.IsUpper(r) {
// Insert hyphen before an uppercase letter when:
// 1. Not at start, AND
// 2. Previous char was lowercase, OR
// 3. Next char is lowercase (handles "userID" → "user-id"
// at the boundary between "I" and "D" in "ID" we don't
// split, but "IDs" → we split before "s" which is
// handled by the lowercase check at the next iteration).
if i > 0 {
prev := runes[i-1]
if unicode.IsLower(prev) {
b.WriteByte('-')
} else if unicode.IsUpper(prev) && i+1 < len(runes) && unicode.IsLower(runes[i+1]) {
b.WriteByte('-')
}
}
b.WriteRune(unicode.ToLower(r))
} else {
b.WriteRune(unicode.ToLower(r))
}
}
return strings.Trim(b.String(), "-")
return cmdutil.Morph(s)
}
+6
View File
@@ -184,3 +184,9 @@ func TestAliasHandlerNameAndPhase(t *testing.T) {
t.Errorf("Phase() = %v, want PreParse", h.Phase())
}
}
func TestTryNormaliseFlagRejectsBareDoubleDash(t *testing.T) {
if got, ok := tryNormaliseFlag("--", map[string]bool{"limit": true}); ok || got != "" {
t.Fatalf("tryNormaliseFlag(--) = %q, %v", got, ok)
}
}
+162
View File
@@ -0,0 +1,162 @@
// 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 handlers
import (
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
)
// BoolValueHandler gives every real Cobra boolean flag one consistent input
// grammar before pflag parsing. pflag interprets `--yes false` as a bare
// `--yes` (true) plus a positional `false`; rewriting the pair to
// `--yes=false` preserves the value the caller actually supplied. The same
// rule applies to default-true, required, local, and inherited boolean flags:
// the handler preserves an explicit value and never substitutes a default.
//
// This handler runs after all flag-name handlers so camelCase, semantic
// aliases, and conservative fuzzy corrections have already reached their
// canonical names. It consumes only recognised boolean literals, stops at
// `--`, and rejects contradictory values for the same canonical flag so the
// result cannot depend on argv order.
type BoolValueHandler struct{}
func (BoolValueHandler) Name() string { return "boolvalue" }
func (BoolValueHandler) Phase() pipeline.Phase { return pipeline.PreParse }
func (BoolValueHandler) Handle(ctx *pipeline.Context) error {
if len(ctx.Args) == 0 || len(ctx.FlagSpecs) == 0 {
return nil
}
longNames := make(map[string]pipeline.FlagInfo)
shortNames := make(map[string]pipeline.FlagInfo)
for _, spec := range ctx.FlagSpecs {
if spec.Name == "" || (spec.Type != "bool" && spec.Type != "boolean") {
continue
}
longNames[spec.Name] = spec
if spec.Shorthand != "" {
shortNames[spec.Shorthand] = spec
}
}
result := make([]string, 0, len(ctx.Args))
valuesByFlag := make(map[string]map[string]bool)
for index := 0; index < len(ctx.Args); index++ {
argument := ctx.Args[index]
if argument == "--" {
result = append(result, ctx.Args[index:]...)
break
}
spec, inlineValue, hasInlineValue, matched := matchBooleanFlagToken(argument, longNames, shortNames)
if !matched || ctx.IsFlagProtected(cmdutil.Morph(spec.Name)) {
result = append(result, argument)
continue
}
normalized := "true"
corrected := argument
original := argument
changed := false
if hasInlineValue {
var ok bool
normalized, ok = cmdutil.NormalizeBoolLiteral(inlineValue)
if !ok {
result = append(result, argument)
continue
}
corrected = "--" + spec.Name + "=" + normalized
changed = corrected != argument
} else if index+1 < len(ctx.Args) {
if value, ok := cmdutil.NormalizeBoolLiteral(ctx.Args[index+1]); ok {
normalized = value
corrected = "--" + spec.Name + "=" + normalized
original = strings.Join(ctx.Args[index:index+2], " ")
changed = true
index++
}
}
if valuesByFlag[spec.Name] == nil {
valuesByFlag[spec.Name] = make(map[string]bool)
}
valuesByFlag[spec.Name][normalized] = true
if changed {
ctx.AddCorrection("boolvalue", pipeline.PreParse, "--"+spec.Name, original, corrected, "explicit-bool")
}
result = append(result, corrected)
}
ctx.Args = result
names := make([]string, 0, len(valuesByFlag))
for name := range valuesByFlag {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
values := valuesByFlag[name]
if len(values) < 2 {
continue
}
list := make([]string, 0, len(values))
for value := range values {
list = append(list, value)
}
sort.Strings(list)
return &pipeline.BoolValueConflictError{
Command: ctx.Command,
Flag: name,
Values: list,
}
}
return nil
}
// matchBooleanFlagToken recognises canonical long flags, exact shorthands,
// and their explicit =value forms. Shorthand clusters remain native pflag
// syntax and are intentionally not reinterpreted here.
func matchBooleanFlagToken(argument string, longNames, shortNames map[string]pipeline.FlagInfo) (pipeline.FlagInfo, string, bool, bool) {
if strings.HasPrefix(argument, "--") {
bare, suffix, isFlag := splitFlagToken(argument)
if !isFlag {
return pipeline.FlagInfo{}, "", false, false
}
spec, ok := longNames[bare]
if !ok {
return pipeline.FlagInfo{}, "", false, false
}
if suffix == "" {
return spec, "", false, true
}
return spec, strings.TrimPrefix(suffix, "="), true, true
}
if !strings.HasPrefix(argument, "-") || strings.HasPrefix(argument, "--") {
return pipeline.FlagInfo{}, "", false, false
}
body := strings.TrimPrefix(argument, "-")
name, value, hasValue := body, "", false
if index := strings.IndexByte(body, '='); index >= 0 {
name, value, hasValue = body[:index], body[index+1:], true
}
spec, ok := shortNames[name]
if !ok {
return pipeline.FlagInfo{}, "", false, false
}
return spec, value, hasValue, true
}
@@ -0,0 +1,132 @@
// 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 handlers
import (
"errors"
"reflect"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
func TestBoolValueHandler(t *testing.T) {
boolFlags := []pipeline.FlagInfo{{Name: "yes", Shorthand: "y", Type: "bool"}}
tests := []struct {
name string
args []string
flags []pipeline.FlagInfo
protected []string
want []string
corrections int
conflict bool
}{
{name: "long false", args: []string{"--yes", "false"}, flags: boolFlags, want: []string{"--yes=false"}, corrections: 1},
{name: "long mixed-case false", args: []string{"--yes", "FaLsE"}, flags: boolFlags, want: []string{"--yes=false"}, corrections: 1},
{name: "long true synonym", args: []string{"--yes", "yes"}, flags: boolFlags, want: []string{"--yes=true"}, corrections: 1},
{name: "shorthand zero", args: []string{"-y", "0"}, flags: boolFlags, want: []string{"--yes=false"}, corrections: 1},
{name: "shorthand equals synonym", args: []string{"-y=off"}, flags: boolFlags, want: []string{"--yes=false"}, corrections: 1},
{name: "preserves following flags", args: []string{"--yes", "off", "--format", "json"}, flags: boolFlags, want: []string{"--yes=false", "--format", "json"}, corrections: 1},
{name: "explicit equals stays native", args: []string{"--yes=false"}, flags: boolFlags, want: []string{"--yes=false"}},
{name: "explicit equals synonym normalizes", args: []string{"--yes=No"}, flags: boolFlags, want: []string{"--yes=false"}, corrections: 1},
{name: "bare bool stays native", args: []string{"--yes"}, flags: boolFlags, want: []string{"--yes"}},
{name: "invalid literal is positional", args: []string{"--yes", "maybe"}, flags: boolFlags, want: []string{"--yes", "maybe"}},
{name: "invalid inline literal stays native", args: []string{"--yes=maybe"}, flags: boolFlags, want: []string{"--yes=maybe"}},
{name: "non bool flag is unchanged", args: []string{"--name", "false"}, flags: []pipeline.FlagInfo{{Name: "name", Type: "string"}}, want: []string{"--name", "false"}},
{name: "unknown flag is unchanged", args: []string{"--confirm", "false"}, flags: boolFlags, want: []string{"--confirm", "false"}},
{name: "shorthand cluster is unchanged", args: []string{"-vy", "false"}, flags: boolFlags, want: []string{"-vy", "false"}},
{name: "protected bool is unchanged", args: []string{"--yes", "false"}, flags: boolFlags, protected: []string{"yes"}, want: []string{"--yes", "false"}},
{name: "protected noncanonical bool uses morphed key", args: []string{"--dry_run", "false"}, flags: []pipeline.FlagInfo{{Name: "dry_run", Type: "bool"}}, protected: []string{"dry-run"}, want: []string{"--dry_run", "false"}},
{name: "stops at double dash", args: []string{"--", "--yes", "false"}, flags: boolFlags, want: []string{"--", "--yes", "false"}},
{name: "no specs", args: []string{"--yes", "false"}, want: []string{"--yes", "false"}},
{name: "identical repeated values remain valid", args: []string{"--yes", "--yes=true", "--yes", "yes"}, flags: boolFlags, want: []string{"--yes", "--yes=true", "--yes=true"}, corrections: 1},
{name: "contradictory detached values fail", args: []string{"--yes", "true", "--yes", "false"}, flags: boolFlags, want: []string{"--yes=true", "--yes=false"}, corrections: 2, conflict: true},
{name: "bare and explicit false fail", args: []string{"--yes", "--yes=false"}, flags: boolFlags, want: []string{"--yes", "--yes=false"}, conflict: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctx := &pipeline.Context{
Args: append([]string(nil), test.args...),
FlagSpecs: test.flags,
}
for _, protected := range test.protected {
ctx.ProtectFlag(protected, pipeline.FlagProtectionBlocked)
}
handler := BoolValueHandler{}
err := handler.Handle(ctx)
var conflict *pipeline.BoolValueConflictError
if test.conflict {
if !errors.As(err, &conflict) {
t.Fatalf("Handle() error = %v, want BoolValueConflictError", err)
}
if conflict.Flag != "yes" || !reflect.DeepEqual(conflict.Values, []string{"false", "true"}) {
t.Fatalf("conflict = %#v", conflict)
}
} else if err != nil {
t.Fatalf("Handle() error = %v", err)
}
if !reflect.DeepEqual(ctx.Args, test.want) {
t.Fatalf("Args = %#v, want %#v", ctx.Args, test.want)
}
if len(ctx.Corrections) != test.corrections {
t.Fatalf("Corrections = %#v, want %d", ctx.Corrections, test.corrections)
}
if test.corrections > 0 {
correction := ctx.Corrections[0]
if correction.Handler != "boolvalue" || correction.Kind != "explicit-bool" || correction.Field != "--yes" {
t.Fatalf("correction metadata = %#v", correction)
}
}
})
}
}
func TestMatchBooleanFlagToken(t *testing.T) {
longNames := map[string]pipeline.FlagInfo{"yes": {Name: "yes", Shorthand: "y", Type: "bool"}}
shortNames := map[string]pipeline.FlagInfo{"y": longNames["yes"]}
tests := []struct {
argument string
value string
hasValue bool
matched bool
}{
{argument: "--yes", matched: true},
{argument: "--yes=false", value: "false", hasValue: true, matched: true},
{argument: "-y", matched: true},
{argument: "-y=true", value: "true", hasValue: true, matched: true},
{argument: "-vy"},
{argument: "--unknown=false"},
{argument: "yes"},
{argument: "--"},
}
for _, test := range tests {
t.Run(test.argument, func(t *testing.T) {
spec, value, hasValue, matched := matchBooleanFlagToken(test.argument, longNames, shortNames)
if value != test.value || hasValue != test.hasValue || matched != test.matched {
t.Fatalf("matchBooleanFlagToken(%q) = %#v, %q, %v, %v", test.argument, spec, value, hasValue, matched)
}
if matched && spec.Name != "yes" {
t.Fatalf("matched spec = %#v", spec)
}
})
}
}
func TestBoolValueHandlerMeta(t *testing.T) {
handler := BoolValueHandler{}
if handler.Name() != "boolvalue" || handler.Phase() != pipeline.PreParse {
t.Fatalf("handler metadata = %q/%v", handler.Name(), handler.Phase())
}
}

Some files were not shown because too many files have changed in this diff Show More