Compare commits

...
Author SHA1 Message Date
chichuan 3519965285 ci: increase integration test timeouts 2026-07-30 20:52:18 +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
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 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
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
chichuan 82d02096f7 Merge pull request #833 from DingTalk-Real-AI/codex/changelog-v1.0.55-promote-beta.8
docs(release): promote v1.0.55-beta.8 baseline
2026-07-30 11:00:51 +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
chichuan ad08b6b499 docs(release): promote v1.0.55-beta.8 2026-07-30 10:32:20 +08:00
Dennis cffc48406c fix(im): resolve direct recipients via contact search 2026-07-30 10:29:39 +08:00
github-actions[bot] 250aab3ef1 chore: update beta formula for v1.0.55-beta.8 [skip ci] 2026-07-30 02:28:33 +00:00
chichuan e36b6dc049 Merge pull request #832 from DingTalk-Real-AI/codex/changelog-v1.0.55-beta.8
docs(release): add v1.0.55-beta.8 notes
2026-07-30 10:19:15 +08:00
Dennis f9e3476d42 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-gap-fill 2026-07-30 10:02:34 +08:00
chichuan d9d62fb2f7 docs(release): add v1.0.55-beta.8 notes 2026-07-30 09:55:16 +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
github-actions[bot] f9f61a4cc6 Merge pull request #825 from DingTalk-Real-AI/codex/changelog-v1.0.55
docs(release): add v1.0.55 stable notes
2026-07-30 09:39:02 +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
chichuan 8d22cd553a docs(release): add v1.0.55 stable notes 2026-07-29 20:42:19 +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
github-actions[bot] 4f31863aae chore: update beta formula for v1.0.55-beta.7 [skip ci] 2026-07-29 10:19:40 +00:00
chichuan 6de2bf1518 docs(release): 合入 beta.7 发布说明(风险等级:低)
发布模块:CHANGELOG。补充 v1.0.55-beta.7 的完整变更说明,并保留失败 beta.6 的审计记录。风险等级:低。
2026-07-29 18:09:24 +08:00
Dennis 9c297d0520 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-gap-fill 2026-07-29 18:02:49 +08:00
chichuan 78b724480e docs(release): 补充 beta.7 完整发布说明(风险等级:低) 2026-07-29 18:02:40 +08:00
Dennis 37230d2d4d chore(schema): refresh shortcut skill source hashes 2026-07-29 18:01:54 +08:00
github-actions[bot] 4724c30f4b Merge pull request #821 from typefield/agent/restore-shared-account-rule
fix(skills): restore multi-account safety rule in dws-shared SKILL.md
2026-07-29 17:56:16 +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
玉澜 76b9f1536a test(app): pin multi-account safety rule in embedded dws-shared skill
Replace the CI classifier change with a real PR-level regression
contract: materialize the embedded multi skill source and assert
dws-shared/SKILL.md keeps the 禁止选择第一项、最近登录或最近使用账号 rule
that the MultiSkill e2e release gate requires. The new test file also
makes the revision full-suite so all quality gates run on this PR.
2026-07-29 17:42:04 +08:00
玉澜 809b9b3570 ci: classify skills/ changes as docs-only for fast path
Skill markdown files are agent documentation embedded at build time;
they carry no Go code changes. Without this classification a one-line
SKILL.md edit triggers the full -race test suite on internal/app and
reverse dependencies, which exceeds the 8m job timeout and fails CI
deterministically.
2026-07-29 17:36:56 +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
玉澜 0be5b73518 fix(skills): restore multi-account safety rule in dws-shared SKILL.md
Commit dc20ddec dropped the 禁止选择第一项、最近登录或最近使用账号 rule
from dws-shared/SKILL.md during the multi-skill refactor while the
MultiSkill e2e contract still asserts it there, blocking the
v1.0.55-beta.6 release run. Restore the rule as a mandatory-contract
bullet pointing at dingtalk-profile/SKILL.md for the full selection and
cross-org rules.
2026-07-29 17:19:31 +08:00
chichuan a637a44b7a docs(release): 恢复 beta.6 main admission(风险等级:低)
明确 beta.6 五个 PR 审计范围,并由真实用户合入以触发 main CHANGELOG fast-path CI。
2026-07-29 16:52:33 +08:00
github-actions[bot] 579eed81d9 Merge pull request #818 from DingTalk-Real-AI/codex/changelog-v1.0.55-beta.6
docs(release): prepare v1.0.55-beta.6 changelog
2026-07-29 16:38:54 +08:00
chichuan c68d9facb2 docs(release): 补充 CHANGELOG beta.6 五项合入说明(风险等级:低) 2026-07-29 16:33:48 +08:00
github-actions[bot] 1f9138e99a Merge pull request #621 from typefield/agent/sync-wukong-multi-skill
feat(skills): add  multi-skill framework to DWS
2026-07-29 16:24:53 +08:00
玉澜 c3fd814630 Merge remote-tracking branch 'origin/main' into pr621-wukong-sync
# Conflicts:
#	CHANGELOG.md
2026-07-29 16:08:16 +08:00
github-actions[bot] 5922a0717a Merge pull request #816 from wxianfeng/feature/aone82250541-agent-product
feat: support configurable Agent Product identity
2026-07-29 16:04:24 +08:00
玉澜 c5bc1fdad4 Merge remote-tracking branch 'typefield/agent/sync-wukong-multi-skill' into pr621-wukong-sync
# Conflicts:
#	CHANGELOG.md
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
#	internal/cli/schema_parameter_bindings.json
2026-07-29 15:51:04 +08:00
玉澜 9567cfd3d8 fix(review): align wukong port with upstream behavior and PR #621 review findings
Must-fix: drive permission apply now gates on confirmDangerousAction and
declares confirmation=user_required, matching its help-text promise.

Wukong parity restored: formula-verify --exit-on-error (payload-parsing
exit path) and --targets conflict error, sheet info --include, chat
location/profile message types, search-advanced wukong flag aliases,
and a dedicated drive download-version leaf replacing the removed
polymorphic download --version.

Consistency fixes: transfer-owner --node/--workspace XOR and JSON-aware
dry-run after --yes validation; drive list --versions rejects
--depth/--pattern instead of misleading depth errors; depth BFS resumes
rate-limited folders from the failed page cursor to avoid duplicates;
doc style cover upload honors cmd.Context() and a 20 MiB size cap; chat
user-settings set validates per-item openConversationId and is
risk=medium.

Hardened the skill static audit to scan fenced code blocks and reject
unknown subcommands on group commands, fixing the stale aitable/drive
doc examples it exposed. Added CHANGELOG entry and coverage tests for
all changed statements plus previously untested ported commands.
2026-07-29 15:34:59 +08:00
wxianfeng 4567dd1cd6 fix(im): gate optional resource downloads at runtime 2026-07-29 15:33:01 +08:00
chichuan 1180510f40 merge(agent-product): 同步 main 并解决 CHANGELOG 冲突(风险等级:高)
保留 #816 的 Agent Product 身份说明与 main 中已合入的 Shortcut 修复条目,并完成全仓测试、构建及 Schema 生成漂移校验。
2026-07-29 15:19:28 +08:00
Dennis 75bb01bb64 docs(skill): align IM shortcut routing 2026-07-29 14:45:28 +08:00
chichuan 2456660780 test(chat): 补齐文字表情跨平台覆盖(风险:低)
让 update-text-emotion 映射与缺参测试进入 Darwin/Windows coverage 矩阵,并移除已由 Cobra 必填门禁覆盖的不可达重复校验。
2026-07-29 14:36:41 +08:00
Dennis 11a7ab8b7c fix(im): harden shortcut downloads and message context 2026-07-29 14:20:39 +08:00
chichuan c3dbe866c4 feat(chat): 补齐文字表情原地更新契约(风险:低)
基于 PR #621 现有 update-text-emotion 实现,补齐七参数 RPC 映射、Cobra/Schema 必填约束、mono Skill、CHANGELOG 与别名/缺参回归测试。
2026-07-29 14:17:03 +08:00
wxianfeng 81f130c483 fix: address agent product review feedback 2026-07-29 13:56:25 +08:00
玉澜 6b99685594 fix(schema): bump runtime-surface completeness source_tools to 839
The 26 newly registered commands raised the registry count to 839, but
runtime-surface-completeness.json still declared source_tools=813, so
check-schema-catalog.sh failed the Policy job ("runtime-surface
completeness source must remain unreviewed and interface-free"). The 26
tools are all reviewed in metadata/selection sources, so the unreviewed
71-tool list is unchanged; regenerate dependent schema artifacts.
2026-07-29 13:51:25 +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
玉澜 9d59550890 test(helpers): cover new drive/doc-style/sheet/chat commands to 100% changed-code coverage
The CI platform coverage gate enforces 100% coverage of changed
statements via tests named TestCrossPlatformCoverage*/TestAllShortcuts.
Add unit tests for drive list --depth BFS (pagination, rate-limit retry,
dedup, truncation, SIGINT, anomalies), drive list --versions/transfer-
owner/cover/revert paths, doc style cover upload flow, sheet
formula-verify target parsing, and chat group user-settings validation.
Also drop an unreachable resourceID guard in uploadDocStyleImage.
2026-07-29 13:29:22 +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
玉澜 83f13d8e11 Merge remote-tracking branch 'origin/main' into pr621-wukong-sync
# Conflicts:
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
2026-07-29 12:25:45 +08:00
wxianfeng 86bce64cc8 test: cover agent product header branches 2026-07-29 12:06:43 +08:00
玉澜 68e1a78810 feat(cli): port drive list --depth and doc style, register all new commands in Schema
- Port drive list --depth N BFS recursive listing (pan + workspace routes,
  rate-limit requeue, SIGINT partial emit, --pattern/--quiet)
- Port doc style cover set/clear, background set/clear, get with local
  image validation and attachment-upload subflow
- Register all 26 newly ported commands in schema_command_registry with
  reviewed metadata/selection hints instead of exclusions (813->839 tools)
- Review fixes: drive list --node usage text no longer implies required
  in agent schema; remove broken formula-verify --exit-on-error; error on
  --range without --sheet-id; portable stdin read; drop local --yes
  shadowing root -y on drive revert/transfer-owner; use
  confirmDangerousAction for non-delete confirms; explicit
  recursiveChange=false now transmitted; sheet version revert and
  comment delete moved into sheet confirmationGuards registry
2026-07-29 11:57:57 +08:00
玉澜 e63a4bdf47 feat(cli): add chat group get-mute-config command from wukong develop 2026-07-29 11:18:49 +08:00
github-actions[bot] 6ab01a365e Merge pull request #815 from DingTalk-Real-AI/codex/im-shortcut-optimization
feat(chat): harden and publish IM shortcuts
2026-07-29 11:05:56 +08:00
玉澜 d855edaad2 feat(cli): add chat message update-text-emotion command 2026-07-29 11:03:04 +08:00
玉澜 75fce5c4ff Merge remote-tracking branch 'origin/main' into pr621-wukong-sync
# Conflicts:
#	internal/cli/schema_catalog.json
2026-07-29 10:53:12 +08:00
玉澜 d28a50c0f4 fix(cli): resolve schema parameter mapping for drive download
Remove --version flag from drive download (polymorphic tool dispatch
incompatible with schema validation). Regenerate schema catalog and
add new commands to schema_command_exclusions.json.
2026-07-29 10:12:15 +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
Dennis ecbd2e3009 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-optimization
# Conflicts:
#	internal/cli/schema_catalog.json
2026-07-29 09:57:46 +08:00
Dennis 33df6ee794 test(chat): close IM shortcut coverage gaps 2026-07-29 09:46:00 +08:00
github-actions[bot] 18e1c7870e Merge pull request #676 from typefield/feat/command-surface-naming
feat(helpers): declarative LeafSpec command framework + devapp migration
2026-07-29 09:43:34 +08:00
玉澜 bbecd2f3a6 style: gofmt chat.go 2026-07-29 09:23:27 +08:00
玉澜 a705c9de0b feat(cli): implement wukong-internal commands in open-source CLI
Port 19 command leaves from wukong internal CLI:
- drive star add/remove/list (文档收藏)
- drive cover (节点封面)
- drive revert (文件版本回滚)
- drive list --versions / download --version (文件历史版本)
- drive permission transfer-owner/apply-info/apply
- sheet version save/list/revert
- sheet formula-verify
- sheet comment list/create/reply/update/delete
- chat group user-settings query/set

Restore corresponding skill docs and register commands in schema
exclusions pending Schema review.
2026-07-29 01:06:36 +08:00
玉澜 1ff4941082 Merge remote-tracking branch 'upstream/main' into feat/command-surface-naming 2026-07-29 00:53:26 +08:00
玉澜 f5d57c2e07 Merge remote-tracking branch 'origin/main' into pr621-wukong-sync 2026-07-29 00:32:22 +08:00
Dennis f3231ed2a8 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-optimization
# Conflicts:
#	internal/shortcut/chat/compatibility_coverage_test.go
#	internal/shortcut/smart/compatibility_coverage_test.go
2026-07-29 00:29:53 +08:00
玉澜 7762abc1ae refactor(helpers): drop unused LeafInt64 kind (CR C1)
No production LeafSpec uses LeafInt64; devapp only needs LeafInt
(non-zero-only putInt semantics). The default MCP dispatch and Server
routing stay — they are the framework's documented main path for
future MCP-direct products.
2026-07-29 00:29:21 +08:00
Dennis 8d1b76caa7 feat(chat): align and harden IM shortcuts 2026-07-29 00:21:26 +08:00
玉澜 9d0995a61d test(helpers): cover parse-error path in required validation 2026-07-28 23:02:06 +08:00
玉澜 967cf26d44 fix(skills): remove commands absent from open-source CLI
Remove references to wukong-internal-only commands that fail CI
Interface Integrity: drive permission transfer-owner/apply/apply-info,
drive star/cover/revert/list --versions, sheet comment/formula-verify/
version, chat group user-settings. Delete sheet-comment.md and
sheet-version.md entirely.
2026-07-28 22:52:46 +08:00
玉澜 571eb20457 refactor: align required/args semantics, trim-aware fallback, helper dedupe
Post-review cleanup round:
- leaf.go: required validation now matches leafArgs inclusion rules
  (LeafInt explicit 0 / LeafInt64 <= 0 count as missing) via
  leafHasEffectiveValue; fallback-chain candidates are judged after
  TrimSpace when Trim is set so pure-whitespace values fall through.
- command_meta.go: drop catalogStringVal/catalogStringSliceVal in favor
  of existing schemaString/schemaStringSlice.
- fetch_mcp_metadata: cross-owned canonicals skip name-coincidence
  direct merges; the reviewed cross-server identity is the sole source.
2026-07-28 22:52:33 +08:00
github-actions[bot] 7937d09eed Merge pull request #757 from DingTalk-Real-AI/fix/shortcut-audit-batch
fix(shortcut): 修复按姓名解析漏掉外部联系人 + resource-url 补 --msg-id 别名
2026-07-28 22:32:59 +08:00
玉澜 bc39d24559 fix(fetch-mcp-metadata): refresh cross-server tools via reviewed interface_refs
Live matching only recognized srv.ID+"."+name == registry canonical, so
the 101 canonicals whose reviewed interface_ref routes to a differently
named server/tool were silently skipped and stayed frozen at the
previous snapshot (or degraded to stubs). Build a reverse index from the
previous snapshot's reviewed interface_refs (live key → canonicals) and
fan the live descriptor out to every owning canonical, preserving the
reviewed ref through the existing merge semantics.
2026-07-28 22:04:32 +08:00
玉澜 d31cae2b0c Revert "docs(skills): add create→transfer-owner bridge for group owner scenario"
This reverts commit 4e71f56f97.
2026-07-28 22:04:21 +08:00
wxianfeng e998e2609d feat: support agent product identity to #82250541 2026-07-28 21:46:20 +08:00
玉澜 4e71f56f97 docs(skills): add create→transfer-owner bridge for group owner scenario
group create does not support --owner; agents need an explicit pointer
to transfer-owner when users ask to specify a group owner at creation.
2026-07-28 21:44:59 +08:00
玉澜 3717053d24 chore(helpers): drop dead devapp flag-registration helpers
addDevAppVersionLocatorFlags and registerDevAppMemberMutationFlags lost
their last callers when the dev app command surface was reworked; the
uncovered dead code regressed overall coverage below the merge base.
2026-07-28 21:33:26 +08:00
玉澜 2a3df50d0f refactor(cli): deterministic alias collision resolution and helper cleanup
alias-vs-alias collisions in the command meta lookup now resolve to the
owner with the lexicographically smallest primary path instead of map
iteration order. Move catalogStringVal next to its sibling helpers in
command_meta.go and drop the redundant captureBaseHelpFunc alias in the
calendar help wrapper. Unify the Safety help annotation to English
"(requires --yes)".
2026-07-28 21:13:49 +08:00
玉澜 03b3cf68e4 feat(coverage-gate): log files exempted for having no executable statements
Silently dropping non-executable changed files made the exemption
invisible in CI logs; each exempted path is now reported to stderr in
sorted order.
2026-07-28 21:13:40 +08:00
玉澜 bbdf843ef3 fix(fetch-mcp-metadata): count registry stubs as unmatched in coverage
matched_tools claimed every surface tool matched even when entries were
registry stubs with no live MCP metadata, and unmatched_tools was
hardcoded to 0. Coverage now excludes stubs from matched_tools, reports
them as unmatched, and a registry JSON parse failure warns instead of
silently producing a stub-only snapshot. The schema catalog policy
invariant is relaxed to match the honest accounting.
2026-07-28 21:13:40 +08:00
玉澜 eb2658ca68 fix(helpers): honor alias/env/default fallback for integer leaf flags
The leaf fallback chain read only string flags, so LeafInt/LeafInt64
flags could never satisfy Required via alias or env, alias values for
integer flags were silently dropped, and a registered Default shadowed
alias/env values. Resolution order is now explicit flag > alias > env >
Default > ArgDefault, aliases register with the primary flag's Kind, and
unparsable integer env values fail loudly.
2026-07-28 21:13:29 +08:00
玉澜 69b8df3e40 fix(skills): reconcile wukong sync with latest main CLI surface
Restore capabilities now supported on main (doc read --scope/--tags,
drive upload --node overwrite, chat category, dingtalk-markdown routing),
remove commands still absent from the open-source CLI (calendar event
instances, sheet info --include, chat group create --owner), remap
folded services (attendance/ding/oa/report/sheet) to dingtalk-misc in
the shortcut generator, and regenerate shortcut sections and schema
metadata.
2026-07-28 20:56:37 +08:00
Dennis a5ac09218b fix(chat): close IM shortcut validation gaps 2026-07-28 20:55:25 +08:00
玉澜 8d988bc350 Merge remote-tracking branch 'origin/main' into pr621-wukong-sync
# Conflicts:
#	skills/multi/dingtalk-aitable/SKILL.md
#	skills/multi/dingtalk-attendance/SKILL.md
#	skills/multi/dingtalk-calendar/SKILL.md
#	skills/multi/dingtalk-chat/SKILL.md
#	skills/multi/dingtalk-chat/references/chat.md
#	skills/multi/dingtalk-contact/SKILL.md
#	skills/multi/dingtalk-contact/references/contact.md
#	skills/multi/dingtalk-ding/SKILL.md
#	skills/multi/dingtalk-doc/SKILL.md
#	skills/multi/dingtalk-doc/references/doc.md
#	skills/multi/dingtalk-doc/references/doc/doc-comment.md
#	skills/multi/dingtalk-doc/references/doc/doc-read.md
#	skills/multi/dingtalk-drive/SKILL.md
#	skills/multi/dingtalk-drive/references/drive.md
#	skills/multi/dingtalk-mail/SKILL.md
#	skills/multi/dingtalk-minutes/SKILL.md
#	skills/multi/dingtalk-oa/SKILL.md
#	skills/multi/dingtalk-report/SKILL.md
#	skills/multi/dingtalk-sheet/SKILL.md
#	skills/multi/dingtalk-todo/SKILL.md
#	skills/multi/dingtalk-todo/references/todo.md
#	skills/multi/dingtalk-wiki/SKILL.md
#	skills/multi/dws-shared/SKILL.md
2026-07-28 20:25:16 +08:00
玉澜 dc20ddecf6 feat(skills): sync wukong 13-sub-skill multi layout with open-source cleanup
Replace skills/multi with wukong's consolidated structure (long-tail
products folded into dingtalk-misc), keeping GitHub-only skills
(dingtalk-dev/event/pat/profile/skill). Prune MCP-only product refs and
align all documented commands/flags with the open-source Cobra tree:
remove markdown/*, drive task get, drive version flags, doc read
--scope, --async modes, retired conference/chat-file-upload mentions.
2026-07-28 20:20:12 +08:00
DennisandClaude Opus 4.8 d41214988a fix(shortcut): keep external contacts in name resolution; alias resource-url msg-id
Two independent shortcut correctness fixes surfaced by the audit:

- Name→ID resolution (chat +dm / +broadcast / … via the shared resolver) dropped
  every search_contact_by_key_word row with an empty userId. External /
  cross-org contacts arrive with only an openDingTalkId, so they were silently
  discarded — making resolution report a real person as missing, or collapse to
  the wrong single match when an in-org namesake existed. Keep any row with at
  least one usable identity (userId or openDingTalkId) and fall the display name
  back through nick/showName/flowerName/staffName/userName.

- chat +messages-resource-url required --message-id with no alias, so an agent
  copying the message list's openMessageId/msgId output field hit "unknown
  flag". Accept --msg-id / --open-message-id as aliases (declared via an
  at-least-one constraint since a shortcut's Required check only sees the
  primary flag name), mirroring the earlier chat message download-media fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 20:11:26 +08:00
Dennis bfb812bfa0 feat(chat): publish and harden all IM shortcuts 2026-07-28 18:59:04 +08:00
玉澜 a09790fc3f Merge remote-tracking branch 'origin/main' into pr621-wukong-sync
# Conflicts:
#	test/skill_static/skill_static_test.go
2026-07-28 18:10:50 +08:00
玉澜 4b0f71eedc test: close changed-code coverage gaps to satisfy the coverage gate
- fetch_mcp_metadata: extract run()/resolveToken()/writeMetadata with
  injectable deps (keychain, servers, lister, registry, exit); full-path
  tests reach 100% file coverage.
- internal/cli: drop dead initSafetyByCLIPath (superseded by ResolveMeta),
  split buildMetaByCLIPath / assembleSchemaCatalogSnapshot /
  assembleCommandRegistryFrom / mergedCommandRegistryJSON so shard and
  malformed-snapshot failure modes are testable; cover catalog structure
  violation formatting (sort/truncate) and RenderSafetyAnnotation.
- generators: cover registry shard merge and catalog shard write failure
  modes.
- helpers/cmdutil: cover LeafSpec default/server dispatch, transform error
  propagation, default env hint, devapp member remove validate chain, and
  the required-flags error helpers.

Local gate: overall 90.17% vs merge-base 89.96%, changed-code 100%
(861 statements); make policy and go test ./... green.
2026-07-28 18:05:20 +08:00
玉澜 5c30d01522 fix(coverage-gate): exempt files without executable statements
A changed production Go file with no function bodies (pragma carriers such
as internal/cli/gen.go, doc-only files) can never appear in a coverage
profile, so the missing-profile check failed every PR touching one. Parse
changed files and exempt those without executable statements; unreadable
or unparsable files stay conservative.
2026-07-28 18:05:19 +08:00
玉澜 c94190f90e fix: honor alias/env fallback for plain required LeafSpec flags
Plain Required now validates the effective value (primary flag -> aliases
-> env) instead of only the primary flag, matching the declared fallback
semantics; whitespace-only values under Trim count as missing. Extracted
cmdutil.MissingRequiredFlagsError to keep the unified error format.
2026-07-28 16:48:42 +08:00
玉澜 6763ddd154 fix: resolve command metadata via compat aliases
ResolveMeta copies Catalog aliases into CommandIdentity and registers each
alias path against the same metadata (primary cli_path wins on collision),
so compat paths like 'report list' resolve instead of returning ok=false.
2026-07-28 16:48:42 +08:00
玉澜 235cad4cc7 fix: report honest MCP snapshot coverage
snapshot_services now counts only services whose tools/list succeeded and
missing_services names the failures, so a partially failed refresh can no
longer write a snapshot that claims full coverage.
2026-07-28 16:48:42 +08:00
玉澜 96d0d430e6 Merge upstream main into feat/command-surface-naming 2026-07-28 16:12:38 +08:00
github-actions[bot] 5783c4e82a chore: update beta formula for v1.0.55-beta.5 [skip ci] 2026-07-28 07:10:13 +00:00
chichuanandchichuan baafd6fe7d docs(CHANGELOG): 补充 v1.0.55-beta.5 精确发布说明(风险等级:文档级) (#812)
Co-authored-by: chichuan <haofeng.hf@alibaba-inc.com>
2026-07-28 15:02:24 +08:00
github-actions[bot] 23c3b74979 Merge pull request #803 from DingTalk-Real-AI/codex/fix-contract-defects
fix: harden dws contract edge cases
2026-07-28 14:46:06 +08:00
玉澜 0d866911e0 fix: refresh existing MCP metadata 2026-07-23 14:20:23 +08:00
玉澜 2bf5401f55 fix: load split registry for MCP metadata refresh 2026-07-23 14:16:27 +08:00
玉澜 c76c30a0b7 Merge upstream main into feat/command-surface-naming 2026-07-23 14:12:18 +08:00
玉澜 51dc237d8a feat: declarative LeafSpec command framework + schema generation/consumption separation
== LeafSpec command framework (internal/helpers/leaf.go) ==
Declarative command construction: LeafSpec/LeafFlag/NewLeafCommand with
Call (pluggable dispatch), LeafInt, PostMount, Trim, Validate. Collapses
per-command hand-written required validation, alias/env fallback, value
transform, and toolArgs assembly into one declarative path.

== devapp migration (28/31 commands) ==
All MCP-direct devapp leaf commands migrated to LeafSpec. Factories
(devAppCall/devAppCallCursor/devAppMeta) fold 33 repeated closures.
fakeDevAppRunner asserts toolArgs for every migrated command. 4 complex
commands (delete/robot submit/result/config) kept hand-written.

== Schema generation/consumption separation ==
- gen.go: isolated //go:generate pragmas from business code.
- command_meta.go: ResolveMeta(cliPath) -> CommandMeta{Identity,Safety,Selection}.
- command_safety.go: SafetyForCLIPath + RenderSafetyAnnotation; safety metadata
  flows from embedded catalog into --help output.
- calendar.go HelpFunc fix: delegates to root HelpFunc at help-time.
- schema_catalog_structure.go: closed catalog structure validation gate.

== Registry + catalog per-product sharding ==
schema_command_registry and schema_catalog split into per-product shards,
eliminating concurrent-PR merge conflicts on these files.

== MCP metadata refresh tool ==
cmd/fetch_mcp_metadata: iterates 26 MCP server endpoints, merges with previous
data for cross-server interface_ref. make fetch-mcp-metadata target.

== AGENTS.md ==
Documents the generation/consumption split.

Verified: make policy exit 0, drift zero, all tests pass.
2026-07-19 09:38:43 +08:00
玉澜 52045fb290 Merge upstream/main into agent/sync-wukong-multi-skill 2026-07-16 18:17:17 +08:00
玉澜 275c3430b8 fix(skills): reconcile multi-skill runtime contracts 2026-07-15 10:26:51 +08:00
玉澜 56116bf99e feat(skills): align Wukong multi-skill docs 2026-07-15 01:17:45 +08:00
535 changed files with 691118 additions and 600826 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
+42 -5
View File
@@ -7,7 +7,8 @@ unrelated work, and use `gofmt` for every modified Go file.
- Build: `go build ./cmd`
- Full test suite: `DWS_PACKAGE_VERSION=0.0.0-test go test ./...`
- Generate Schema assets: `go generate ./internal/cli`
- Generate Schema assets: `go generate ./internal/cli` (entry point: `internal/cli/gen.go`)
- Refresh pinned MCP metadata: `make fetch-mcp-metadata` (requires `dws auth login`)
- Check generated drift: `./scripts/policy/check-generated-drift.sh`
- Check the Schema contract: `./scripts/policy/check-schema-catalog.sh`
@@ -56,8 +57,16 @@ The Schema data flow is one way:
6. One-way publication
SchemaRegistry
└─ internal/cli/schema_catalog.json
└─ internal/cli/schema_catalog/
(catalog.json + tools/<product>.json; split per product so
concurrent feature PRs only rewrite their own shard)
└─ dws schema list/product/group/leaf/--all
7. Runtime consumption (unified API)
ResolveMeta(cliPath) → CommandMeta{Identity, Safety, Selection}
└─ internal/cli/command_meta.go
└─ all consumers (help, schema, agent, skill-gen) call this one function
└─ backed by embedded catalog (sync.Once lazy map, O(1) lookup)
```
Parameter overlays from metadata are merged into `EffectiveCommandRegistry`
@@ -73,11 +82,38 @@ Build-time gates and the snapshot serializer consume that source-resolved typed
registry/index. Runtime projections and delivery gates consume the typed
registry/index returned by the production snapshot loader. Neither path may
reopen annotations, merge source records, or use a previous Catalog or other
generated JSON as a source. `schema_catalog.json` is output-only in the
generated JSON as a source. `schema_catalog/` (catalog.json + per-product
tools/<product>.json shards) is output-only in the
generation graph. The production loader decoding the embedded published
snapshot is a delivery boundary, not source resolution; it must never create or
repair a Cobra command, flag, registry entry, or later Catalog generation.
### Generation vs consumption separation
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` +
`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`,
`check-command-surface.sh` (catalog structure).
**Consumption** (runtime, fast, read-only, unified API):
- Entry point: `ResolveMeta(cliPath) → CommandMeta{Identity, Safety, Selection}`
in `internal/cli/command_meta.go`.
- Backed by embedded catalog (`embeddedSchemaCatalog()`, sync.Once, O(1) map).
- Consumers: `--help` (Safety annotation via `RenderSafetyAnnotation`),
`dws schema`, agent selection, future skill generation.
- `SafetyForCLIPath` delegates to `ResolveMeta` (backward compatible).
This split is architecturally isomorphic to Lark's typed metadata registry,
navigation catalog, and schema renderer. DWS intentionally preserves its
existing flat JSON wire contract for compatibility; do not treat architectural
@@ -117,7 +153,8 @@ When adding or changing an Agent-visible command, review all relevant inputs:
must never materialize, infer, or override registry identity.
- Flag-to-interface property mappings and required/default semantics.
- Generated files under `internal/cli/schema_agent_metadata/` and
`internal/cli/schema_catalog.json` after running generation.
`internal/cli/schema_catalog/` (catalog.json + tools/<product>.json) after
running generation.
Run the reverse-completeness tests whenever the Cobra tree changes. A command
that works through `dws <path>` but cannot be found through the matching
@@ -177,7 +214,7 @@ For every curated tool:
2. Edit `selection/<product>.json` for selection prose (`reviewed: true`,
`review_reason`, `source_refs`).
3. Run `make generate-schema`. Do not hand-edit generated
`schema_agent_metadata/` or `schema_catalog.json`.
`schema_agent_metadata/` or `schema_catalog/`.
### Pull live MCP descriptions (personal token)
+99 -1
View File
@@ -6,10 +6,108 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.55-beta.8] - 2026-07-30
This beta revalidates the `v1.0.55-beta.7` product baseline through a complete
guarded release delivery. It carries no new product-facing command behavior;
the new version is required because the published beta.7 artifacts succeeded
on GitHub, npm, and Homebrew, but its enabled optional Gitee mirror failed and
left that Release run ineligible for stable promotion.
### Changed
- **Complete promotion evidence** — republishes the validated v1.0.55 command, Runtime Schema, Skill, authentication, and projection changes with the optional Gitee upload fallback disabled, so the release can produce one successful auditable delivery proof before stable promotion.
## [1.0.55] - 2026-07-30
This release promotes the validated `v1.0.55-beta.8` baseline to stable. It
expands the public Workspace command surface and personal event consumption,
makes the full built-in shortcut catalog available to Agents, and hardens
multi-account routing, authentication compatibility, command safety, and
response projection across the CLI.
### Added
- **Broader Workspace command surface** (#621, #676) — adds roughly 30 reviewed Drive, Doc, Sheet, and Chat leaf commands synchronized from Wukong, including Drive version and permission operations, document styling, Sheet comment/version/formula verification, and in-place text-emotion updates. A reusable declarative `LeafSpec` framework now delivers command identity, safety, selection, and guarded Help metadata consistently.
- **Complete Agent-visible shortcut delivery** (#802, #815) — publishes all 210 built-in shortcuts as reviewed Runtime Schema leaves across 16 products, including 88 validated Chat shortcuts, with executable paths, parameters, constraints, selection guidance, dry-run capabilities, and runtime-aligned confirmation semantics.
- **Expanded enterprise and event capabilities** (#790) — adds the HR Brain talent-pool, employee-profile, and structured-search command families; `dws mcp url get` resolves MCP Market endpoints; personal event consumption supports eight additional IM event keys, multi-key consumers, and targeted shutdown.
- **Agent integration identity** (#804, #816) — adds validated `DWS_AGENT_HOST` and `DWS_AGENT_PRODUCT` labels for observability and product attribution while keeping them separate from authentication and authorization.
### Changed
- **Progressive multi-Skill guidance and account safety** (#621, #821) — reorganizes bundled product guidance for progressive discovery and restores the mandatory rule that Agents must not guess an account when a multi-account organization has no unique current default.
- **Supported Chat file delivery** — retires the legacy AppKey/AppSecret-backed `chat media upload` command from discovery and routes local files through `chat message send --msg-type file --file-path`, while callers with an existing media ID can continue sending images directly.
- **Guarded release delivery** (#791) — strengthens immutable GitHub, npm, Homebrew, optional mirror, recovery, and version-allocation checks while keeping beta and stable publication role-gated and auditable.
### Fixed
- **Shortcut and message projection correctness** (#706, #783, #795) — prevents successful read shortcuts from silently projecting non-empty backend responses to empty results, renders rich, forwarded, and encrypted message forms safely, and fixes group-bot, bot-search, mail-thread, media-ID alias, and Todo paging response handling.
- **Command contract edge cases** (#803) — makes approval revocation and document rollback honor dry-run before confirmation or preflight, fixes Drive and Doc rename semantics, restores Drive-specific metadata, and validates Todo reminder rules.
- **Authentication and external-contact compatibility** (#756, #757) — migrates legacy global and organization-scoped credentials without cross-account token borrowing, preserves contacts that expose only `openDingTalkId`, and aligns message-resource flags with message-list output fields.
## [1.0.55-beta.7] - 2026-07-29
This beta supersedes the unpublished `v1.0.55-beta.6` candidate and packages
PRs #621, #676, #757, #815, #816, and #821. It restores the mandatory
multi-account safety rule caught by the sealed-release E2E gate while retaining
the reviewed Wukong capability and multi-Skill synchronization, declarative
command and Schema delivery, hardened Chat shortcuts, external contact
resolution, and Agent product identity on top of the `v1.0.55-beta.5` baseline.
### Added
- **Wukong capability and multi-Skill synchronization** (#621) — ports roughly 30 reviewed leaf commands into the open-source CLI across Drive, Doc, Sheet, and Chat, including in-place text-emotion updates, Drive version and permission operations, document styling, and Sheet comment/version/formula verification. The bundled multi-Skill framework is reorganized into progressive product references and routing guidance while retaining current open-source command, response, safety, and Runtime Schema contracts.
- **Declarative leaf commands and unified metadata delivery** (#676) — adds the reusable `LeafSpec` command framework and migrates 27 DevApp commands without changing their paths or flags. Runtime consumers now resolve identity, safety, and selection through one embedded Catalog-backed API, and guarded Help output publishes the command's safety/confirmation annotation.
- **Agent product identity** (#816) — adds the optional `DWS_AGENT_PRODUCT` override for the existing HTTP `claw-type` header while preserving each edition's default when unset. Product and runtime labels are caller-declared signals, not authentication credentials; services must validate supported values and must not grant access solely from them. The override does not change the separate IM message-display `clawType` parameter controlled by the edition and `--ai-tag`.
### Changed
- **Reviewed Chat shortcut delivery** (#815) — publishes 88 currently available Chat shortcuts after real-business validation, keeps three confirmed lower-service failures unavailable, strengthens semantic availability and dry-run contracts, and adds safe message-resource download plus group-member listing. Conversation filtering, IM routing/reporting, and member mute resolution are aligned with the validated backend identities.
- **Agent identity label hardening** (#816) — limits `DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` to 64 ASCII bytes, trims only surrounding ASCII spaces and tabs, and rejects other control or Unicode whitespace. QwenWork integrations should report the two dimensions separately as `DWS_AGENT_PRODUCT=qwenwork` plus `DWS_AGENT_HOST=cloud` or `desktop`; previously used combined Host labels such as `qwenwork_cloud` remain syntactically valid for compatibility.
### Fixed
- **External-contact and message-resource chaining** (#757) — the shared name-to-ID resolver keeps external or cross-organization contacts that expose only `openDingTalkId`, applies reviewed display-name fallbacks, and preserves organization-only filtering for commands that require `userId`. `chat +messages-resource-url` now accepts `--msg-id` and `--open-message-id` as aliases for `--message-id`, matching message-list response fields.
- **Multi-account Skill safety contract** (#821) — restores the mandatory rule that an Agent must never choose the first, most recently logged-in, or most recently used account when an organization has multiple accounts without one unique `isOrgCurrent=true` default. A PR-level embedded-Skill regression test now catches removal before the full sealed-release E2E gate.
## [1.0.55-beta.6] - 2026-07-29
This beta packages PRs #621, #676, #757, #815, and #816, validating the Wukong
capability and multi-Skill synchronization, declarative command and Schema
delivery, hardened Chat shortcuts, external contact resolution, and Agent
product identity on top of the `v1.0.55-beta.5` baseline.
### Added
- **Wukong capability and multi-Skill synchronization** (#621) — ports roughly 30 reviewed leaf commands into the open-source CLI across Drive, Doc, Sheet, and Chat, including in-place text-emotion updates, Drive version and permission operations, document styling, and Sheet comment/version/formula verification. The bundled multi-Skill framework is reorganized into progressive product references and routing guidance while retaining current open-source command, response, safety, and Runtime Schema contracts.
- **Declarative leaf commands and unified metadata delivery** (#676) — adds the reusable `LeafSpec` command framework and migrates 27 DevApp commands without changing their paths or flags. Runtime consumers now resolve identity, safety, and selection through one embedded Catalog-backed API, and guarded Help output publishes the command's safety/confirmation annotation.
- **Agent product identity** (#816) — adds the optional `DWS_AGENT_PRODUCT` override for the existing HTTP `claw-type` header while preserving each edition's default when unset. Product and runtime labels are caller-declared signals, not authentication credentials; services must validate supported values and must not grant access solely from them. The override does not change the separate IM message-display `clawType` parameter controlled by the edition and `--ai-tag`.
### Changed
- **Reviewed Chat shortcut delivery** (#815) — publishes 88 currently available Chat shortcuts after real-business validation, keeps three confirmed lower-service failures unavailable, strengthens semantic availability and dry-run contracts, and adds safe message-resource download plus group-member listing. Conversation filtering, IM routing/reporting, and member mute resolution are aligned with the validated backend identities.
- **Agent identity label hardening** (#816) — limits `DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` to 64 ASCII bytes, trims only surrounding ASCII spaces and tabs, and rejects other control or Unicode whitespace. QwenWork integrations should report the two dimensions separately as `DWS_AGENT_PRODUCT=qwenwork` plus `DWS_AGENT_HOST=cloud` or `desktop`; previously used combined Host labels such as `qwenwork_cloud` remain syntactically valid for compatibility.
### Fixed
- **External-contact and message-resource chaining** (#757) — the shared name-to-ID resolver keeps external or cross-organization contacts that expose only `openDingTalkId`, applies reviewed display-name fallbacks, and preserves organization-only filtering for commands that require `userId`. `chat +messages-resource-url` now accepts `--msg-id` and `--open-message-id` as aliases for `--message-id`, matching message-list response fields.
## [1.0.55-beta.5] - 2026-07-28
This beta validates expanded personal event consumption, complete Agent-visible
Runtime Schema coverage for all 210 built-in shortcuts, Agent host
observability, and hardened document, Drive, approval, and Todo command
contracts on top of the `v1.0.55-beta.4` baseline.
### Added
- **Expanded personal event consumption** (#790) — adds eight IM personal event keys, supports subscribing to and consuming multiple event keys in one `dws event consume` invocation, and adds targeted local-consumer shutdown when a subscription is stopped so other consumers can continue on the shared event bus.
- **Shortcut Runtime Schema delivery** — publishes all 210 public built-in shortcuts as reviewed Agent-visible leaf tools across 16 product groups, with stable canonical identities, executable `+shortcut` CLI paths, parameter and cross-parameter constraints, selection guidance, interface metadata, and runtime-aligned safety/confirmation semantics. `dws shortcut list` remains the lightweight batch-discovery view, while leaf Schema now carries the complete Agent contract; declared string-slice defaults are also preserved consistently in Cobra and Schema.
- **Shortcut Runtime Schema delivery** (#802) — publishes all 210 public built-in shortcuts as reviewed Agent-visible leaf tools across 16 product groups, with stable canonical identities, executable `+shortcut` CLI paths, parameter and cross-parameter constraints, selection guidance, interface metadata, and runtime-aligned safety/confirmation semantics. `dws shortcut list` remains the lightweight batch-discovery view, while leaf Schema now carries the complete Agent contract; declared string-slice defaults are also preserved consistently in Cobra and Schema.
- **Agent host observability** (#804) — accepts an optional, validated `DWS_AGENT_HOST` label and sends it as `x-dws-agent-host` for logs and BI only; invalid values fail before CLI network activity, and the label never participates in authentication or routing.
### Fixed
- **Command contract edge cases** (#803) — approval revocation and document-version rollback now honor `--dry-run` before confirmation or remote preflight; `drive rename` removes only a suffix matching the node's current extension to avoid duplicate extensions while `doc rename` preserves the caller's exact display name; `doc info` keeps its stable MCP contract while `drive info` restores Drive-only metadata such as a non-null `fileSize`; and Todo reminder writes now reject invalid rule JSON while Help, Schema, and Skills distinguish a due time from an independently unreadable reminder rule.
## [1.0.55-beta.4] - 2026-07-27
+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.4"
version "1.0.55-beta.8"
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.4/dws-darwin-arm64.tar.gz"
sha256 "05b269fe44a125ee8b368d6228c5229950b8216872fb569741d1e30a83ce952a"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-darwin-arm64.tar.gz"
sha256 "07fabf720fa98f82c56027df703a3ad3f0aec16c957ea684047928f9f74fa00f"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.4/dws-darwin-amd64.tar.gz"
sha256 "b0d7604299336c83b7805d3b1a47a90668f2e2f3fc54702b0e846bb2907b6170"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-darwin-amd64.tar.gz"
sha256 "fbec64dc5c3463a04de9720ffb1fd247196e619ea81b976b47ecbf751a17fb72"
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.4/dws-linux-arm64.tar.gz"
sha256 "a9c1dd5c6171091a84fc18e5081c9f75d037826cd3966726545d715ce832ae31"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-linux-arm64.tar.gz"
sha256 "f111cdffef0188ddf954d2174fa0d318069bcec80104775483f13f645aa8089b"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.4/dws-linux-amd64.tar.gz"
sha256 "5e97ba398f5a3e15b9d235bc53f596d31a4d6b7af7bb2185b7b48beeda6a2ebb"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-linux-amd64.tar.gz"
sha256 "d69475b7f3cec4bad4c051834df22fbfadfa7075b82347e6ca7490f67d71d0b1"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.4/dws-skills.zip"
sha256 "4ebc0294b65d90adb5c5d639a548b528af240e4117ccca3d388e2efb6030170a"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-skills.zip"
sha256 "be8c9267704cfef1319fc9cd2fcba5aebe45b670b4dc37d3dae15f65523356f8"
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
+35 -8
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 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 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
@@ -87,6 +87,9 @@ policy: test-auth-legacy-compat
@$(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
@@ -129,16 +132,36 @@ test-schema-agent-examples:
generate-schema:
@set -e; \
registry_guard=$$(mktemp); \
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; \
cp internal/cli/schema_command_registry.json "$$registry_guard"; \
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; \
cmp -s internal/cli/schema_command_registry.json "$$registry_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/schema_command_registry.json' >&2; \
diff -qr internal/cli/schema_command_registry "$$registry_guard" >/dev/null || { \
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 || { \
@@ -153,14 +176,18 @@ generate-schema:
generate-schema-agent-metadata:
$(GO) run ./internal/generator/cmd_schema_agent_metadata \
-root . \
-registry internal/cli/schema_command_registry.json \
-registry internal/cli/schema_command_registry \
-output-dir internal/cli/schema_agent_metadata \
-audit-output internal/cli/schema_agent_metadata_audit.json
generate-schema-catalog:
$(GO) run -a ./internal/generator/cmd_schema_catalog \
-root . \
-output internal/cli/schema_catalog.json
-output internal/cli/schema_catalog
fetch-mcp-metadata:
@printf ' %sRefreshing MCP metadata from live server%s\n' "$(COLOR_RUN)" "$(COLOR_RESET)"
@./scripts/dev/fetch_mcp_metadata.sh
package:
@version="$(if $(VERSION),$(VERSION),v0.0.0-SNAPSHOT)"; VERSION="$${version#v}" ./scripts/dev/build-all.sh
+403
View File
@@ -0,0 +1,403 @@
// Command fetch_mcp_metadata pulls tools/list from ALL live MCP server endpoints
// and writes a refreshed schema_mcp_metadata.json. This is DWS's equivalent of
// lark-cli's scripts/fetch_meta.py.
//
// Usage:
//
// dws auth login # ensure valid auth
// make fetch-mcp-metadata # runs this tool
//
// The tool loads auth from the DWS keychain, iterates all 26 static server
// endpoints (internal/syncdata.StaticServers), calls tools/list on each,
// merges results, and writes schema_mcp_metadata.json.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"sort"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/syncdata"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
// toolLister is the tools/list capability consumed by run; production code
// uses transport.Client, tests inject fakes.
type toolLister interface {
ListTools(ctx context.Context, endpoint string) (transport.ToolsListResult, error)
}
// Injection points so run() is fully testable without network/keychain/exit.
var (
osExit = os.Exit
getenv = os.Getenv
loadTokenData = auth.LoadTokenDataKeychain
staticServers = syncdata.StaticServers
registrySource = cli.EmbeddedCommandRegistryMergedJSON
listToolsTimeout = 30 * time.Second
gitHeadPath = ".git/HEAD"
newToolLister = func(token string) toolLister {
return transport.NewClient(&http.Client{Timeout: 60 * time.Second}).WithAuth(token, nil)
}
)
func main() {
osExit(run(os.Args[1:], os.Stderr))
}
func run(args []string, stderr io.Writer) int {
flags := flag.NewFlagSet("fetch_mcp_metadata", flag.ContinueOnError)
flags.SetOutput(stderr)
output := flags.String("output", "internal/cli/schema_mcp_metadata.json", "output file path")
if err := flags.Parse(args); err != nil {
return 2
}
token := resolveToken(stderr)
if token == "" {
fmt.Fprintln(stderr, "fetch_mcp_metadata: no auth token. Run 'dws auth login' first.")
return 1
}
client := newToolLister(token)
// Iterate ALL static server endpoints (26 servers covering all products).
servers := staticServers()
fmt.Fprintf(stderr, "fetch_mcp_metadata: querying %d server endpoints\n", len(servers))
// Load CLI registry to build tool_name → interface_ref mapping.
registryMap := loadRegistryInterfaceRefs(stderr)
fmt.Fprintf(stderr, "fetch_mcp_metadata: registry mapping: %d entries\n", len(registryMap))
// Load the previous schema_mcp_metadata.json to preserve hand-curated
// cross-server interface_ref mappings that automated matching can't derive.
prevData, prevErr := os.ReadFile(*output)
prevTools := map[string]map[string]any{}
if prevErr == nil {
var prev struct {
Tools map[string]map[string]any `json:"tools"`
}
if json.Unmarshal(prevData, &prev) == nil {
prevTools = prev.Tools
}
}
// Start from previous data (preserves cross-server refs), then overwrite
// with fresh MCP data where available.
allTools := make(map[string]map[string]any)
for k, v := range prevTools {
allTools[k] = v
}
// Reviewed cross-server interface_refs live only in the previous snapshot
// (the registry stores canonical paths, not MCP identities). Build a
// live-key → canonicals index so those tools get refreshed instead of
// being skipped and frozen at the previous snapshot forever.
crossRefs := buildCrossServerRefs(prevTools, registryMap)
if len(crossRefs) > 0 {
fmt.Fprintf(stderr, "fetch_mcp_metadata: cross-server ref index: %d live keys\n", len(crossRefs))
}
// Canonicals with a reviewed cross-server identity must only be fed by
// that identity; a same-named tool on another server is a coincidence,
// not a data source.
crossOwned := map[string]bool{}
for _, canonicals := range crossRefs {
for _, canonical := range canonicals {
crossOwned[canonical] = true
}
}
totalRaw := 0
failedServices := []string{}
for _, srv := range servers {
endpoint := strings.TrimSpace(srv.Endpoint)
if endpoint == "" {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), listToolsTimeout)
result, err := client.ListTools(ctx, endpoint)
cancel()
if err != nil {
fmt.Fprintf(stderr, " [skip] %s: %v\n", srv.ID, err)
failedServices = append(failedServices, srv.ID)
continue
}
fmt.Fprintf(stderr, " [ok] %s: %d tools\n", srv.ID, len(result.Tools))
totalRaw += len(result.Tools)
for _, tool := range result.Tools {
name := strings.TrimSpace(tool.Name)
if name == "" {
continue
}
// Direct match: CLI canonical equals server-prefixed tool name
// (e.g., "doc.copy_document"). Cross-owned canonicals are skipped
// here — their reviewed identity feeds them below.
canonicalKey := srv.ID + "." + name
if ref, hasRef := registryMap[canonicalKey]; hasRef && !crossOwned[canonicalKey] {
mergeLiveMCPTool(allTools, canonicalKey, tool, ref)
}
// Cross-server match: registry canonicals whose reviewed
// interface_ref points at this live tool (one live tool may feed
// several canonicals, e.g. advperm_enable/disable → set_advanced_permission).
for _, canonical := range crossRefs[canonicalKey] {
mergeLiveMCPTool(allTools, canonical, tool, registryMap[canonical])
}
}
}
matched := 0
for _, t := range allTools {
if _, ok := t["interface_ref"]; ok {
matched++
}
}
fmt.Fprintf(stderr, "fetch_mcp_metadata: MCP matched=%d, with interface_ref=%d\n", len(allTools), matched)
// Fill gaps: for registry canonicals not covered by MCP tools/list OR
// previous data, add stub entries (interface_ref only).
stubs := 0
for canonicalKey, ref := range registryMap {
if _, exists := allTools[canonicalKey]; exists {
continue
}
allTools[canonicalKey] = map[string]any{
"interface_ref": ref,
}
stubs++
}
if stubs > 0 {
fmt.Fprintf(stderr, "fetch_mcp_metadata: added %d registry stubs (no MCP data, interface_ref only)\n", stubs)
}
// Compute coverage fields required by check-schema-catalog.sh. Failed
// services must be reported honestly so policy can spot snapshot gaps.
if len(failedServices) > 0 {
fmt.Fprintf(stderr, "fetch_mcp_metadata: %d/%d services unreachable: %s\n",
len(failedServices), len(servers), strings.Join(failedServices, ", "))
}
metadata := map[string]any{
"version": 1,
"source": "mcp-tools-list+cli-registry",
"coverage": buildCoverage(len(servers), failedServices, totalRaw, len(allTools), stubs),
"tools": allTools,
}
// source_revision: git commit hash (proves provenance).
if rev, err := os.ReadFile(gitHeadPath); err == nil {
metadata["source_revision"] = strings.TrimSpace(string(rev))
}
if err := writeMetadata(*output, metadata); err != nil {
fmt.Fprintf(stderr, "fetch_mcp_metadata: %v\n", err)
return 1
}
fmt.Fprintf(stderr, "fetch_mcp_metadata: wrote %d tools to %s\n", len(allTools), *output)
return 0
}
// resolveToken returns the access token from DWS_ACCESS_TOKEN or, as a
// fallback, the DWS keychain.
func resolveToken(stderr io.Writer) string {
token := strings.TrimSpace(getenv("DWS_ACCESS_TOKEN"))
if token != "" {
return token
}
td, err := loadTokenData()
if err != nil || td == nil || td.AccessToken == "" {
return ""
}
fmt.Fprintf(stderr, "fetch_mcp_metadata: loaded token from keychain (%d chars)\n", len(td.AccessToken))
return td.AccessToken
}
// writeMetadata marshals the snapshot and writes it to the output path.
func writeMetadata(path string, metadata map[string]any) error {
data, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return fmt.Errorf("marshal failed: %w", err)
}
data = append(data, '\n')
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("write %s failed: %w", path, err)
}
return nil
}
// buildCoverage reports snapshot coverage honestly: snapshot_services only
// counts services whose tools/list succeeded, missing_services names the
// failures, and matched_tools excludes registry stubs (entries carrying no
// live MCP metadata) so a stub-heavy snapshot cannot claim full matching.
func buildCoverage(sourceServices int, failedServices []string, sourceTools, surfaceTools, stubs int) map[string]any {
missing := failedServices
if missing == nil {
missing = []string{}
}
return map[string]any{
"surface_scope": "source_revision",
"source_services": sourceServices,
"snapshot_services": sourceServices - len(missing),
"missing_services": missing,
"source_tools": sourceTools,
"surface_tools": surfaceTools,
"matched_tools": surfaceTools - stubs,
"aliased_tools": 0,
"unmatched_tools": stubs,
}
}
// mergeLiveMCPTool replaces stale live-derived fields while retaining an
// existing reviewed interface_ref. Some CLI canonicals intentionally route to
// a differently named product/RPC, so the previous cross-server mapping must
// survive even though title, description, and parameters are refreshed.
func mergeLiveMCPTool(allTools map[string]map[string]any, canonicalKey string, tool transport.ToolDescriptor, fallbackRef map[string]string) {
interfaceRef := any(fallbackRef)
if previous := allTools[canonicalKey]; previous != nil {
if reviewedRef, ok := previous["interface_ref"]; ok && reviewedRef != nil {
interfaceRef = reviewedRef
}
}
entry := map[string]any{
"title": tool.Title,
"description": tool.Description,
"interface_ref": interfaceRef,
}
if tool.InputSchema != nil {
entry["parameters"] = extractParams(tool.InputSchema)
}
allTools[canonicalKey] = entry
}
// buildCrossServerRefs indexes reviewed cross-server mappings from the
// previous snapshot: for every registry canonical whose interface_ref names a
// different MCP identity (product_id.rpc_name != canonical), the live key is
// mapped back to that canonical. One live tool may serve several canonicals,
// so values are slices, sorted for deterministic merge order.
func buildCrossServerRefs(prevTools map[string]map[string]any, registryMap map[string]map[string]string) map[string][]string {
index := map[string][]string{}
for canonical, entry := range prevTools {
if _, inRegistry := registryMap[canonical]; !inRegistry {
continue
}
ref, ok := entry["interface_ref"].(map[string]any)
if !ok {
continue
}
productID, _ := ref["product_id"].(string)
rpcName, _ := ref["rpc_name"].(string)
if productID == "" || rpcName == "" {
continue
}
liveKey := productID + "." + rpcName
if liveKey == canonical {
continue
}
index[liveKey] = append(index[liveKey], canonical)
}
for _, canonicals := range index {
sort.Strings(canonicals)
}
return index
}
// loadRegistryInterfaceRefs loads the reviewed split CommandRegistry through
// the cli package's reassembly API and builds a canonical_path →
// {product_id, rpc_name} mapping for interface_ref injection.
func loadRegistryInterfaceRefs(stderr io.Writer) map[string]map[string]string {
data, err := registrySource()
if err != nil {
fmt.Fprintf(stderr, "fetch_mcp_metadata: warning: cannot load registry: %v\n", err)
return map[string]map[string]string{}
}
var reg struct {
Products []struct {
ID string `json:"id"`
Tools []struct {
CanonicalPath string `json:"canonical_path"`
} `json:"tools"`
} `json:"products"`
}
if err := json.Unmarshal(data, &reg); err != nil {
// 与读文件失败同等告警:静默返回空映射会让所有 live tool 被丢弃、
// 产出 stub-only 快照且零提示(P1#1 的故障模式)。
fmt.Fprintf(stderr, "fetch_mcp_metadata: warning: cannot parse registry: %v\n", err)
return map[string]map[string]string{}
}
out := make(map[string]map[string]string)
for _, prod := range reg.Products {
for _, tool := range prod.Tools {
cp := strings.TrimSpace(tool.CanonicalPath)
if cp == "" || !strings.Contains(cp, ".") {
continue
}
parts := strings.SplitN(cp, ".", 2)
out[cp] = map[string]string{
"product_id": parts[0],
"rpc_name": parts[1],
}
}
}
return out
}
// extractParams converts a JSON Schema inputSchema (from MCP tools/list) into
// the flat param-name → metadata map used by schema_mcp_metadata.json.
func extractParams(inputSchema map[string]any) map[string]map[string]any {
if inputSchema == nil {
return nil
}
properties, ok := inputSchema["properties"].(map[string]any)
if !ok {
return nil
}
requiredSet := map[string]bool{}
if req, ok := inputSchema["required"].([]any); ok {
for _, r := range req {
if s, ok := r.(string); ok {
requiredSet[s] = true
}
}
}
params := make(map[string]map[string]any, len(properties))
for name, raw := range properties {
prop, ok := raw.(map[string]any)
if !ok {
continue
}
meta := map[string]any{}
if t, ok := prop["type"].(string); ok {
meta["type"] = t
}
if d, ok := prop["description"].(string); ok {
meta["description"] = d
}
if d, ok := prop["default"].(string); ok {
meta["default"] = d
}
if e, ok := prop["enum"].([]any); ok {
enums := make([]string, 0, len(e))
for _, v := range e {
if s, ok := v.(string); ok {
enums = append(enums, s)
}
}
if len(enums) > 0 {
meta["enum"] = enums
}
}
meta["required"] = requiredSet[name]
params[name] = meta
}
return params
}
+557
View File
@@ -0,0 +1,557 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"math"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/syncdata"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func TestLoadRegistryInterfaceRefsUsesSplitRegistry(t *testing.T) {
var stderr bytes.Buffer
refs := loadRegistryInterfaceRefs(&stderr)
if len(refs) == 0 {
t.Fatal("loadRegistryInterfaceRefs() returned no reviewed commands")
}
got, ok := refs["calendar.list_calendars"]
if !ok {
t.Fatal("calendar.list_calendars missing from reassembled split registry")
}
if got["product_id"] != "calendar" || got["rpc_name"] != "list_calendars" {
t.Fatalf("calendar.list_calendars ref = %#v", got)
}
}
func TestBuildCrossServerRefs(t *testing.T) {
registryMap := map[string]map[string]string{
"aitable.advperm_enable": {"product_id": "aitable", "rpc_name": "advperm_enable"},
"aitable.advperm_disable": {"product_id": "aitable", "rpc_name": "advperm_disable"},
"doc.copy_document": {"product_id": "doc", "rpc_name": "copy_document"},
}
prevTools := map[string]map[string]any{
// Fan-out: two canonicals share one live tool; insertion order must
// not affect the sorted result.
"aitable.advperm_enable": {
"interface_ref": map[string]any{"product_id": "aitable-helper", "rpc_name": "set_advanced_permission"},
},
"aitable.advperm_disable": {
"interface_ref": map[string]any{"product_id": "aitable-helper", "rpc_name": "set_advanced_permission"},
},
// Identity ref (live key == canonical) needs no cross entry.
"doc.copy_document": {
"interface_ref": map[string]any{"product_id": "doc", "rpc_name": "copy_document"},
},
// Not in the registry: must be ignored.
"ghost.tool": {
"interface_ref": map[string]any{"product_id": "ghost-helper", "rpc_name": "haunt"},
},
}
got := buildCrossServerRefs(prevTools, registryMap)
want := map[string][]string{
"aitable-helper.set_advanced_permission": {"aitable.advperm_disable", "aitable.advperm_enable"},
}
if len(got) != len(want) {
t.Fatalf("index = %#v, want %#v", got, want)
}
for k, v := range want {
if gv := got[k]; len(gv) != len(v) || gv[0] != v[0] || gv[1] != v[1] {
t.Fatalf("index[%q] = %v, want %v", k, gv, v)
}
}
}
func TestBuildCrossServerRefsSkipsMalformedRefs(t *testing.T) {
registryMap := map[string]map[string]string{
"a.x": {"product_id": "a", "rpc_name": "x"},
"a.y": {"product_id": "a", "rpc_name": "y"},
"a.z": {"product_id": "a", "rpc_name": "z"},
}
prevTools := map[string]map[string]any{
"a.x": {"interface_ref": "not-a-map"},
"a.y": {"interface_ref": map[string]any{"product_id": "", "rpc_name": "r"}},
"a.z": {"title": "no ref at all"},
}
if got := buildCrossServerRefs(prevTools, registryMap); len(got) != 0 {
t.Fatalf("index = %#v, want empty", got)
}
}
func TestRunRefreshesCrossServerTools(t *testing.T) {
registry := func() ([]byte, error) {
return []byte(`{"version":1,"products":[{"id":"aitable","tools":[{"canonical_path":"aitable.advperm_enable"},{"canonical_path":"aitable.advperm_disable"}]}]}`), nil
}
servers := []syncdata.ServerInfo{{ID: "aitable-helper", Endpoint: "https://helper.example"}}
lister := &fakeLister{
results: map[string]transport.ToolsListResult{
"https://helper.example": {Tools: []transport.ToolDescriptor{
{Name: "set_advanced_permission", Title: "live title", Description: "live desc"},
}},
},
}
stubDeps(t, "env-token", nil, servers, lister, registry)
output := filepath.Join(t.TempDir(), "snapshot.json")
prev := `{"tools":{
"aitable.advperm_enable":{"title":"stale","interface_ref":{"product_id":"aitable-helper","rpc_name":"set_advanced_permission"}},
"aitable.advperm_disable":{"title":"stale","interface_ref":{"product_id":"aitable-helper","rpc_name":"set_advanced_permission"}}
}}`
if err := os.WriteFile(output, []byte(prev), 0o600); err != nil {
t.Fatal(err)
}
var stderr bytes.Buffer
if code := run([]string{"--output", output}, &stderr); code != 0 {
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
}
if !strings.Contains(stderr.String(), "cross-server ref index: 1 live keys") {
t.Fatalf("stderr = %q, want cross-server index log", stderr.String())
}
data, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
var snapshot struct {
Tools map[string]map[string]any `json:"tools"`
}
if err := json.Unmarshal(data, &snapshot); err != nil {
t.Fatal(err)
}
for _, canonical := range []string{"aitable.advperm_enable", "aitable.advperm_disable"} {
entry := snapshot.Tools[canonical]
if entry["title"] != "live title" || entry["description"] != "live desc" {
t.Fatalf("%s = %#v, want live refresh", canonical, entry)
}
ref := entry["interface_ref"].(map[string]any)
if ref["product_id"] != "aitable-helper" || ref["rpc_name"] != "set_advanced_permission" {
t.Fatalf("%s reviewed ref lost: %#v", canonical, ref)
}
}
}
// TestRunCrossOwnedCanonicalIgnoresNameCoincidence:canonical 拥有评审过的
// 跨 server 身份时,另一 server 上恰好同名的工具不得直连覆盖其元数据——
// 数据源只能是评审身份指向的 live 工具。
func TestRunCrossOwnedCanonicalIgnoresNameCoincidence(t *testing.T) {
registry := func() ([]byte, error) {
return []byte(`{"version":1,"products":[{"id":"aitable","tools":[{"canonical_path":"aitable.advperm_enable"}]}]}`), nil
}
servers := []syncdata.ServerInfo{
{ID: "aitable", Endpoint: "https://aitable.example"},
{ID: "aitable-helper", Endpoint: "https://helper.example"},
}
lister := &fakeLister{
results: map[string]transport.ToolsListResult{
// 同名巧合:aitable server 上恰好也有 advperm_enable。
"https://aitable.example": {Tools: []transport.ToolDescriptor{
{Name: "advperm_enable", Title: "coincidence title", Description: "coincidence desc"},
}},
"https://helper.example": {Tools: []transport.ToolDescriptor{
{Name: "set_advanced_permission", Title: "owner title", Description: "owner desc"},
}},
},
}
stubDeps(t, "env-token", nil, servers, lister, registry)
output := filepath.Join(t.TempDir(), "snapshot.json")
prev := `{"tools":{"aitable.advperm_enable":{"title":"stale","interface_ref":{"product_id":"aitable-helper","rpc_name":"set_advanced_permission"}}}}`
if err := os.WriteFile(output, []byte(prev), 0o600); err != nil {
t.Fatal(err)
}
var stderr bytes.Buffer
if code := run([]string{"--output", output}, &stderr); code != 0 {
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
}
data, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
var snapshot struct {
Tools map[string]map[string]any `json:"tools"`
}
if err := json.Unmarshal(data, &snapshot); err != nil {
t.Fatal(err)
}
entry := snapshot.Tools["aitable.advperm_enable"]
if entry["title"] != "owner title" || entry["description"] != "owner desc" {
t.Fatalf("entry = %#v, want reviewed-identity source to win over name coincidence", entry)
}
}
func TestMergeLiveMCPToolRefreshesExistingMetadata(t *testing.T) {
const canonical = "calendar.list_calendars"
reviewedRef := map[string]any{
"product_id": "calendar-helper",
"rpc_name": "list_user_calendars",
}
allTools := map[string]map[string]any{
canonical: {
"title": "old title",
"description": "old description",
"interface_ref": reviewedRef,
"parameters": map[string]any{
"stale": map[string]any{"type": "string"},
},
},
}
live := transport.ToolDescriptor{
Name: "list_calendars",
Title: "new title",
Description: "new description",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"cursor": map[string]any{
"type": "string",
"description": "next page cursor",
},
},
"required": []any{"cursor"},
},
}
fallbackRef := map[string]string{
"product_id": "calendar",
"rpc_name": "list_calendars",
}
mergeLiveMCPTool(allTools, canonical, live, fallbackRef)
got := allTools[canonical]
if got["title"] != "new title" || got["description"] != "new description" {
t.Fatalf("live metadata was not refreshed: %#v", got)
}
if !reflect.DeepEqual(got["interface_ref"], reviewedRef) {
t.Fatalf("interface_ref = %#v, want reviewed mapping %#v", got["interface_ref"], reviewedRef)
}
params, ok := got["parameters"].(map[string]map[string]any)
if !ok {
t.Fatalf("parameters type = %T, want refreshed parameter map", got["parameters"])
}
if _, stale := params["stale"]; stale {
t.Fatalf("stale parameter survived refresh: %#v", params)
}
if cursor := params["cursor"]; cursor["type"] != "string" || cursor["description"] != "next page cursor" || cursor["required"] != true {
t.Fatalf("cursor parameter = %#v", cursor)
}
}
func TestBuildCoverageReportsFailedServices(t *testing.T) {
got := buildCoverage(26, []string{"doc", "sheet"}, 800, 813, 40)
if got["source_services"] != 26 {
t.Fatalf("source_services = %v, want 26", got["source_services"])
}
if got["snapshot_services"] != 24 {
t.Fatalf("snapshot_services = %v, want 24 (26 sources - 2 failures)", got["snapshot_services"])
}
if !reflect.DeepEqual(got["missing_services"], []string{"doc", "sheet"}) {
t.Fatalf("missing_services = %#v, want failed service IDs", got["missing_services"])
}
// matched 必须剔除 stub 占位,unmatched 据实等于 stub 数。
if got["matched_tools"] != 773 || got["unmatched_tools"] != 40 {
t.Fatalf("matched/unmatched = %v/%v, want 773/40 (813 surface - 40 stubs)", got["matched_tools"], got["unmatched_tools"])
}
if got["source_tools"] != 800 || got["surface_tools"] != 813 {
t.Fatalf("tool counts = %#v", got)
}
}
func TestBuildCoverageFullSnapshotHasNoMissingServices(t *testing.T) {
got := buildCoverage(26, nil, 813, 813, 0)
if got["snapshot_services"] != 26 {
t.Fatalf("snapshot_services = %v, want 26", got["snapshot_services"])
}
if !reflect.DeepEqual(got["missing_services"], []string{}) {
t.Fatalf("missing_services = %#v, want empty non-nil slice", got["missing_services"])
}
if got["matched_tools"] != 813 || got["unmatched_tools"] != 0 {
t.Fatalf("matched/unmatched = %v/%v, want 813/0 for stub-free snapshot", got["matched_tools"], got["unmatched_tools"])
}
}
// fakeLister returns canned tools/list results per endpoint.
type fakeLister struct {
results map[string]transport.ToolsListResult
errs map[string]error
}
func (f *fakeLister) ListTools(_ context.Context, endpoint string) (transport.ToolsListResult, error) {
if err := f.errs[endpoint]; err != nil {
return transport.ToolsListResult{}, err
}
return f.results[endpoint], nil
}
// stubDeps swaps every injection point for the duration of one test.
func stubDeps(t *testing.T, token string, keychain func() (*auth.TokenData, error), servers []syncdata.ServerInfo, lister toolLister, registry func() ([]byte, error)) {
t.Helper()
origGetenv, origLoad, origServers, origNew, origRegistry := getenv, loadTokenData, staticServers, newToolLister, registrySource
t.Cleanup(func() {
getenv, loadTokenData, staticServers, newToolLister, registrySource = origGetenv, origLoad, origServers, origNew, origRegistry
})
getenv = func(key string) string {
if key == "DWS_ACCESS_TOKEN" {
return token
}
return ""
}
loadTokenData = keychain
staticServers = func() []syncdata.ServerInfo { return servers }
newToolLister = func(string) toolLister { return lister }
registrySource = registry
}
func testRegistryJSON() ([]byte, error) {
return []byte(`{"version":1,"products":[{"id":"doc","tools":[{"canonical_path":"doc.copy_document"},{"canonical_path":"doc.get_document"},{"canonical_path":"bad-entry"}]}]}`), nil
}
func TestRunNoTokenFails(t *testing.T) {
stubDeps(t, "", func() (*auth.TokenData, error) { return nil, errors.New("no keychain") }, nil, &fakeLister{}, testRegistryJSON)
var stderr bytes.Buffer
if code := run(nil, &stderr); code != 1 {
t.Fatalf("run() = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "no auth token") {
t.Fatalf("stderr = %q, want no-auth-token hint", stderr.String())
}
}
func TestRunInvalidFlagFails(t *testing.T) {
stubDeps(t, "tok", nil, nil, &fakeLister{}, testRegistryJSON)
var stderr bytes.Buffer
if code := run([]string{"--nonexistent"}, &stderr); code != 2 {
t.Fatalf("run() = %d, want 2", code)
}
}
func TestResolveTokenKeychainFallback(t *testing.T) {
stubDeps(t, "", func() (*auth.TokenData, error) {
return &auth.TokenData{AccessToken: "kc-token"}, nil
}, nil, &fakeLister{}, testRegistryJSON)
var stderr bytes.Buffer
if got := resolveToken(&stderr); got != "kc-token" {
t.Fatalf("resolveToken() = %q, want kc-token", got)
}
if !strings.Contains(stderr.String(), "loaded token from keychain") {
t.Fatalf("stderr = %q, want keychain log", stderr.String())
}
}
func TestResolveTokenEmptyKeychainToken(t *testing.T) {
stubDeps(t, "", func() (*auth.TokenData, error) { return &auth.TokenData{}, nil }, nil, &fakeLister{}, testRegistryJSON)
var stderr bytes.Buffer
if got := resolveToken(&stderr); got != "" {
t.Fatalf("resolveToken() = %q, want empty", got)
}
}
func TestRunWritesSnapshotWithHonestCoverage(t *testing.T) {
servers := []syncdata.ServerInfo{
{ID: "doc", Endpoint: "https://doc.example"},
{ID: "sheet", Endpoint: "https://sheet.example"},
{ID: "blank", Endpoint: " "},
}
lister := &fakeLister{
results: map[string]transport.ToolsListResult{
"https://doc.example": {Tools: []transport.ToolDescriptor{
{Name: "copy_document", Title: "复制文档", Description: "copy", InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"doc_id": map[string]any{"type": "string", "description": "文档 ID", "default": "d", "enum": []any{"a", "b", 3}},
"bogus": "not-a-map",
},
"required": []any{"doc_id", 42},
}},
{Name: " "},
{Name: "not_in_registry"},
}},
},
errs: map[string]error{"https://sheet.example": errors.New("boom")},
}
stubDeps(t, "env-token", nil, servers, lister, testRegistryJSON)
dir := t.TempDir()
output := filepath.Join(dir, "snapshot.json")
prev := `{"tools":{"doc.get_document":{"interface_ref":{"product_id":"doc-helper","rpc_name":"fetch_document"}}}}`
if err := os.WriteFile(output, []byte(prev), 0o600); err != nil {
t.Fatal(err)
}
var stderr bytes.Buffer
if code := run([]string{"--output", output}, &stderr); code != 0 {
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
}
data, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
var snapshot struct {
Version int `json:"version"`
Coverage map[string]any `json:"coverage"`
Tools map[string]map[string]any
}
if err := json.Unmarshal(data, &snapshot); err != nil {
t.Fatal(err)
}
if snapshot.Version != 1 {
t.Fatalf("version = %d", snapshot.Version)
}
if got := snapshot.Coverage["snapshot_services"].(float64); got != 2 {
t.Fatalf("snapshot_services = %v, want 2 (3 servers - 1 failed; blank endpoint not counted as failed)", got)
}
if got := snapshot.Coverage["missing_services"].([]any); len(got) != 1 || got[0] != "sheet" {
t.Fatalf("missing_services = %v, want [sheet]", got)
}
live := snapshot.Tools["doc.copy_document"]
if live == nil || live["title"] != "复制文档" {
t.Fatalf("doc.copy_document = %#v, want live metadata", live)
}
params := live["parameters"].(map[string]any)
docID := params["doc_id"].(map[string]any)
if docID["type"] != "string" || docID["required"] != true || docID["default"] != "d" {
t.Fatalf("doc_id = %#v", docID)
}
if enum := docID["enum"].([]any); len(enum) != 2 {
t.Fatalf("enum = %v, want the 2 string members only", enum)
}
if _, ok := params["bogus"]; ok {
t.Fatal("non-map property should be skipped")
}
prevRef := snapshot.Tools["doc.get_document"]["interface_ref"].(map[string]any)
if prevRef["product_id"] != "doc-helper" {
t.Fatalf("previous reviewed ref lost: %#v", prevRef)
}
if _, ok := snapshot.Tools["not_in_registry"]; ok {
t.Fatal("tools outside the registry must be dropped")
}
if !strings.Contains(stderr.String(), "services unreachable: sheet") {
t.Fatalf("stderr = %q, want unreachable log", stderr.String())
}
}
func TestRunIgnoresCorruptPreviousSnapshot(t *testing.T) {
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
output := filepath.Join(t.TempDir(), "snapshot.json")
if err := os.WriteFile(output, []byte("{corrupt"), 0o600); err != nil {
t.Fatal(err)
}
var stderr bytes.Buffer
if code := run([]string{"--output", output}, &stderr); code != 0 {
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
}
}
func TestRunRegistryLoadFailureStillWritesStublessSnapshot(t *testing.T) {
stubDeps(t, "env-token", nil, nil, &fakeLister{}, func() ([]byte, error) { return nil, errors.New("no registry") })
output := filepath.Join(t.TempDir(), "snapshot.json")
var stderr bytes.Buffer
if code := run([]string{"--output", output}, &stderr); code != 0 {
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
}
if !strings.Contains(stderr.String(), "cannot load registry") {
t.Fatalf("stderr = %q, want registry warning", stderr.String())
}
}
func TestRunUnparsableRegistryYieldsNoRefs(t *testing.T) {
var stderr bytes.Buffer
stubDeps(t, "env-token", nil, nil, &fakeLister{}, func() ([]byte, error) { return []byte("{bad"), nil })
if refs := loadRegistryInterfaceRefs(&stderr); len(refs) != 0 {
t.Fatalf("refs = %v, want empty for unparsable registry", refs)
}
// 解析失败必须有告警,不得静默产出空映射。
if !strings.Contains(stderr.String(), "cannot parse registry") {
t.Fatalf("stderr = %q, want parse warning", stderr.String())
}
}
func TestRunWriteFailure(t *testing.T) {
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
var stderr bytes.Buffer
badPath := filepath.Join(t.TempDir(), "missing-dir", "snapshot.json")
if code := run([]string{"--output", badPath}, &stderr); code != 1 {
t.Fatalf("run() = %d, want 1 on write failure", code)
}
}
func TestWriteMetadataMarshalFailure(t *testing.T) {
err := writeMetadata(filepath.Join(t.TempDir(), "out.json"), map[string]any{"bad": math.NaN()})
if err == nil || !strings.Contains(err.Error(), "marshal failed") {
t.Fatalf("err = %v, want marshal failure", err)
}
}
func TestMainDelegatesToRun(t *testing.T) {
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
origExit, origArgs := osExit, os.Args
t.Cleanup(func() { osExit, os.Args = origExit, origArgs })
exitCode := -1
osExit = func(code int) { exitCode = code }
os.Args = []string{"fetch_mcp_metadata", "--output", filepath.Join(t.TempDir(), "snapshot.json")}
main()
if exitCode != 0 {
t.Fatalf("main() exited with %d, want 0", exitCode)
}
}
func TestExtractParamsNilAndNonObjectSchemas(t *testing.T) {
if got := extractParams(nil); got != nil {
t.Fatalf("extractParams(nil) = %v, want nil", got)
}
if got := extractParams(map[string]any{"type": "object"}); got != nil {
t.Fatalf("extractParams(no properties) = %v, want nil", got)
}
}
func TestNewToolListerBuildsAuthedClient(t *testing.T) {
if lister := newToolLister("tok"); lister == nil {
t.Fatal("newToolLister returned nil")
}
}
func TestRunRecordsSourceRevision(t *testing.T) {
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
dir := t.TempDir()
head := filepath.Join(dir, "HEAD")
if err := os.WriteFile(head, []byte("ref: refs/heads/feature\n"), 0o600); err != nil {
t.Fatal(err)
}
origHead := gitHeadPath
t.Cleanup(func() { gitHeadPath = origHead })
gitHeadPath = head
output := filepath.Join(dir, "snapshot.json")
var stderr bytes.Buffer
if code := run([]string{"--output", output}, &stderr); code != 0 {
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
}
data, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
var snapshot struct {
SourceRevision string `json:"source_revision"`
}
if err := json.Unmarshal(data, &snapshot); err != nil {
t.Fatal(err)
}
if snapshot.SourceRevision != "ref: refs/heads/feature" {
t.Fatalf("source_revision = %q", snapshot.SourceRevision)
}
}
+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
+38 -1
View File
@@ -5,7 +5,8 @@
| Variable | Purpose / 用途 |
|---------|---------|
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
| `DWS_AGENT_HOST` | Optional Agent host observation label sent as `x-dws-agent-host` (for example `qwenwork_cloud`). Values are trimmed and must match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. Used only for logs and BI, never for authentication or routing. / 可选 Agent 宿主观测标识,经裁剪并校验后作为 `x-dws-agent-host` 发送;仅用于日志与 BI,不参与鉴权或路由 |
| `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_<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) |
@@ -13,6 +14,42 @@
| `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 产品与运行形态的信任模型
`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` 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.
For QwenWork, report the dimensions separately:
```bash
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.
`DWS_AGENT_PRODUCT` 和 `DWS_AGENT_HOST` 均由调用方声明,不是认证凭据,也不能证明
真实宿主身份。服务端可以在独立认证上下文中将受支持值用于日志/BI、PAT 兼容策略、
PAT 身份/来源派生或 Discovery 准入,但不得仅凭这两个 Header 放权、绕过认证或跳过
授权。HTTP `claw-type` 与 IM 消息发送参数 `clawType` 是两个独立维度;后者仅控制
“Send from AI”展示,仍由发行版 `ClawTypeValue` 和 `--ai-tag` 决定。
## Exit Codes / 退出码
| Code | Category | Description / 描述 |
+8 -6
View File
@@ -8,7 +8,7 @@ DWS Schema 是当前二进制公开 CLI 的版本化 Agent 执行契约。它描
1. **Schema 描述 CLI,不制造 CLI。** `CommandRegistry`、manual hint、metadata 和 Catalog 都不能凭空创建 Cobra 命令或 flag;registry 中的每个路径都必须精确绑定真实 runnable Cobra leaf。
2. **所有来源只解析一次。** 来源经过统一 resolver 进入 typed `SchemaRegistry`,所有查询、导出和门禁都消费同一个 `SchemaRegistry/SchemaIndex`。
3. **Registry-first,Catalog 只出不进。** reviewed `CommandRegistry` 是稳定 command identity/navigation 的唯一事实源;`schema_catalog.json` 和其他生成 JSON 只是下游发布物,不能成为命令、metadata 或下一轮 Catalog 的来源。运行时 production loader 解码 embedded snapshot 只是交付边界,不是 source resolution。
3. **Registry-first,Catalog 只出不进。** reviewed `CommandRegistry` 是稳定 command identity/navigation 的唯一事实源;`schema_catalog/`(`catalog.json` + 每产品 `tools/<product>.json`)和其他生成 JSON 只是下游发布物,不能成为命令、metadata 或下一轮 Catalog 的来源。运行时 production loader 解码 embedded snapshot 只是交付边界,不是 source resolution。
Schema 不调用 MCP `tools/list`,不访问网络,也不读取用户本地 discovery cache。
@@ -63,8 +63,9 @@ live Cobra flag facts / typed parameter metadata
build-time typed gates snapshot serializer
|
v
schema_catalog.json
(release output only)
schema_catalog/
(catalog.json + tools/<product>.json,
release output only)
|
v
go:embed -> typed loader
@@ -130,7 +131,7 @@ DWS 当前对外仍保留兼容 wire:leaf 使用 flat `parameters`,安全和
| `schema_mcp_metadata.json` | pinned RPC identity、接口描述和脱敏参数事实 | CLI identity、运行时路由、risk 推断 |
| `schema_hints/selection/*.json` | reviewed selection prose(summary / use_when / avoid_when / examples) | 创建 Cobra 命令或参数、改写 safety |
| Skills/Markdown | 产品路由、工作流和使用建议 | 命令存在性和 flag 事实 |
| `schema_catalog.json` 及其他 generated JSON | resolved registry 的兼容发布序列化;运行时由 production loader 解回 typed registry/index | generation/source resolution 输入、identity fallback、手工修复源 |
| `schema_catalog/`(catalog.json + tools/<product>.json)及其他 generated JSON | resolved registry 的兼容发布序列化;运行时由 production loader 解回 typed registry/index | generation/source resolution 输入、identity fallback、手工修复源 |
`schema_command_registry.json` 承载 reviewed `CommandRegistry`。Manual command addition 先以确定性规则合并进 effective registry;从 binder 开始,下游只看到一个稳定 identity/navigation 模型。旧 wire 中的 `surface_hash` / `surface_tools` 字段仅为兼容名称,语义已经是 effective Registry hash/coverage,不构成第二事实源。
@@ -272,7 +273,8 @@ dws schema --all # 所有工具的完整 leaf 导
- `internal/cli/schema_agent_metadata/index.json`
- `internal/cli/schema_agent_metadata/<product>.json`
- `internal/cli/schema_agent_metadata_audit.json`
- `internal/cli/schema_catalog.json`
- `internal/cli/schema_catalog/catalog.json`(全局信封 + Catalog)
- `internal/cli/schema_catalog/tools/<product>.json`(每产品 leaf ToolSpecs,按产品分片以免并发 PR 冲突)
只编辑来源;不要手工编辑 Agent metadata 或 Catalog 输出。
@@ -303,7 +305,7 @@ go test ./internal/cli ./internal/app ./internal/generator/... -count=1
## 10. 明确禁止
- 运行时调用 MCP `tools/list` 或访问网络生成 Schema。
- 从旧 `schema_catalog.json` 或其他 generated JSON 反向创建/补齐 Cobra leaf、flag、CommandRegistry 或下一轮 Catalog。
- 从 `schema_catalog/` 等生成 JSON 反向创建/补齐 Cobra leaf、flag、CommandRegistry 或下一轮 Catalog。
- 把 native annotation、legacy registry 或 Catalog 当作 identity fallback;或在 `EffectiveCommandRegistry` 之后再次选择 identity winner。
- renderer、query 或 gate 在 `SchemaRegistry` 之后重新读取 source 并做第二次 merge。
- 用 prefix/wildcard exclusion 隐藏未来命令。
File diff suppressed because it is too large Load Diff
+12 -7
View File
@@ -24,6 +24,7 @@ import (
const (
envDWSAgentHost = "DWS_AGENT_HOST"
headerDWSAgentHost = "x-dws-agent-host"
maxAgentHostBytes = 64
)
var agentHostPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
@@ -32,23 +33,27 @@ func init() {
configmeta.Register(configmeta.ConfigItem{
Name: envDWSAgentHost,
Category: configmeta.CategoryExternal,
Description: "调用 DWS 的 Agent 宿主标识;仅用于日志和 BI 观测",
Example: "qwenwork_cloud",
Description: "调用 DWS 的 Agent 运行形态标识;服务端可结合产品用于观测和 PAT 兼容策略",
Example: "cloud",
})
}
// parseAgentHost normalizes and validates the caller-provided observation
// label. CR/LF is rejected before trimming so it can never be hidden at the
// edge of a value. An unset or whitespace-only value means "do not emit".
// parseAgentHost normalizes and validates the caller-declared runtime-form
// signal. Only surrounding ASCII spaces and tabs are trimmed; other control
// or Unicode whitespace remains visible to validation and is rejected. An
// unset or ASCII-whitespace-only value means "do not emit".
func parseAgentHost(raw string) (string, error) {
if strings.ContainsAny(raw, "\r\n") {
return "", invalidAgentHostError()
}
value := strings.TrimSpace(raw)
value := strings.Trim(raw, " \t")
if value == "" {
return "", nil
}
if len(value) > maxAgentHostBytes {
return "", invalidAgentHostError()
}
if !agentHostPattern.MatchString(value) {
return "", invalidAgentHostError()
}
@@ -59,7 +64,7 @@ func invalidAgentHostError() error {
// Do not include the raw environment value in the error: it is an
// untrusted caller-controlled string and may contain sensitive data.
return apperrors.NewValidation(
"DWS_AGENT_HOST must match ^[a-z0-9][a-z0-9_-]*$",
"DWS_AGENT_HOST must be at most 64 bytes and match ^[a-z0-9][a-z0-9_-]*$",
apperrors.WithReason("invalid_agent_host"),
)
}
+14 -4
View File
@@ -21,6 +21,7 @@ import (
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -32,12 +33,14 @@ func TestParseAgentHost(t *testing.T) {
want string
}{
{name: "unset", raw: "", want: ""},
{name: "whitespace only", raw: " \t\u3000", want: ""},
{name: "cloud", raw: "qwenwork_cloud", want: "qwenwork_cloud"},
{name: "desktop", raw: "qwenwork_desktop", want: "qwenwork_desktop"},
{name: "trim", raw: " \tqwenwork_cloud\t ", want: "qwenwork_cloud"},
{name: "ASCII whitespace only", raw: " \t ", want: ""},
{name: "cloud", raw: "cloud", want: "cloud"},
{name: "desktop", raw: "desktop", want: "desktop"},
{name: "legacy combined label remains valid", raw: "qwenwork_cloud", want: "qwenwork_cloud"},
{name: "trim", raw: " \tcloud\t ", want: "cloud"},
{name: "generic", raw: "host-2_alpha", want: "host-2_alpha"},
{name: "leading digit", raw: "2nd_host", want: "2nd_host"},
{name: "maximum length", raw: strings.Repeat("a", maxAgentHostBytes), want: strings.Repeat("a", maxAgentHostBytes)},
}
for _, tc := range valid {
t.Run(tc.name, func(t *testing.T) {
@@ -64,6 +67,12 @@ func TestParseAgentHost(t *testing.T) {
{name: "leading dash", raw: "-qwenwork"},
{name: "leading underscore", raw: "_qwenwork"},
{name: "control character", raw: "qwenwork\x00cloud"},
{name: "vertical tab", raw: "\vcloud"},
{name: "form feed", raw: "cloud\f"},
{name: "next line", raw: "cloud\u0085"},
{name: "non-breaking space", raw: "\u00a0cloud"},
{name: "ideographic space", raw: "cloud\u3000"},
{name: "too long", raw: strings.Repeat("a", maxAgentHostBytes+1)},
}
for _, tc := range invalid {
t.Run(tc.name, func(t *testing.T) {
@@ -91,6 +100,7 @@ func TestParseAgentHost(t *testing.T) {
func TestResolveIdentityHeadersAddsAgentHostBeforeEditionMerge(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(envDWSAgentHost, " qwenwork_desktop ")
t.Setenv(agentproduct.EnvName, "")
t.Setenv(envDWSChannel, "channel-test")
t.Setenv(envDingtalkAgent, "agent-test")
t.Setenv(authpkg.AgentCodeEnv, "agent-code-test")
+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 app
import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func init() {
configmeta.Register(configmeta.ConfigItem{
Name: agentproduct.EnvName,
Category: configmeta.CategoryExternal,
Description: "调用方声明的 Agent 产品标识;覆盖 HTTP claw-type,但不是认证凭据",
DefaultValue: "由当前发行版决定",
Example: "qwenwork",
})
}
// parseAgentProduct converts the reusable package error into the CLI's stable
// structured validation error without exposing the untrusted raw value.
func parseAgentProduct(raw string) (string, error) {
value, err := agentproduct.Parse(raw)
if err != nil {
return "", invalidAgentProductError()
}
return value, nil
}
func invalidAgentProductError() error {
return apperrors.NewValidation(
"DWS_AGENT_PRODUCT must be at most 64 bytes and match ^[A-Za-z0-9][A-Za-z0-9_-]*$",
apperrors.WithReason("invalid_agent_product"),
)
}
// 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
}
value, err := agentproduct.ResolveFromEnv(fallback)
if err != nil {
return fallback
}
return value
}
func applyAgentProductOverride(headers map[string]string) map[string]string {
value := resolveEffectiveAgentProduct(headers)
if headers == nil {
headers = make(map[string]string)
}
headers[agentproduct.HeaderName] = value
return headers
}
+267
View File
@@ -0,0 +1,267 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"errors"
"io"
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func TestUnsetAgentProductKeepsOpenSourceDefault(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)
}
}
func TestParseAgentProductReturnsStableValidationError(t *testing.T) {
const invalidValue = "DO_NOT ECHO"
got, err := parseAgentProduct(invalidValue)
if got != "" {
t.Fatalf("parseAgentProduct() = %q, want empty", got)
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) {
t.Fatalf("parseAgentProduct() error type = %T, want *errors.Error", err)
}
if appErr.Category != apperrors.CategoryValidation {
t.Fatalf("category = %q, want validation", appErr.Category)
}
if appErr.Reason != "invalid_agent_product" {
t.Fatalf("reason = %q, want invalid_agent_product", appErr.Reason)
}
if strings.Contains(err.Error(), invalidValue) {
t.Fatalf("error must not echo invalid value: %v", err)
}
}
func TestResolveIdentityHeadersAgentProductPrecedence(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["x-edition-header"] = "preserved"
return headers
},
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "enterprise-default"
headers["x-enterprise-header"] = "preserved"
return headers
},
})
t.Run("unset keeps edition default", 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)
}
})
t.Run("valid override is final", 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["x-edition-header"]; got != "preserved" {
t.Fatalf("edition header = %q, want preserved", got)
}
if got := headers["x-enterprise-header"]; got != "preserved" {
t.Fatalf("enterprise header = %q, want preserved", got)
}
if got := headers["x-dingtalk-source"]; got != "github" {
t.Fatalf("x-dingtalk-source = %q, want github", got)
}
})
t.Run("invalid library input falls back to edition", 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)
}
})
}
func TestApplyAgentProductOverrideAllocatesHeaders(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)
}
}
func TestRootRejectsInvalidAgentProductBeforeEditionHook(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
const invalidValue = "DO_NOT ECHO"
t.Setenv(agentproduct.EnvName, invalidValue)
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
hookCalled := false
edition.Override(&edition.Hooks{
AfterPersistentPreRun: func(_ *cobra.Command, _ []string) error {
hookCalled = true
return nil
},
})
root := NewRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{"version"})
err := root.Execute()
if err == nil {
t.Fatal("root command accepted invalid DWS_AGENT_PRODUCT")
}
if hookCalled {
t.Fatal("edition AfterPersistentPreRun ran before DWS_AGENT_PRODUCT validation")
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) {
t.Fatalf("root error type = %T, want *errors.Error", err)
}
if appErr.Category != apperrors.CategoryValidation || appErr.Reason != "invalid_agent_product" {
t.Fatalf("root error = category %q reason %q", appErr.Category, appErr.Reason)
}
if strings.Contains(err.Error(), invalidValue) {
t.Fatalf("root error must not echo invalid value: %v", err)
}
}
func TestEffectiveClawTypeDoesNotInvokeEnterpriseCredentialHeaders(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
hookCalled := false
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
return headers
},
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
hookCalled = true
headers[agentproduct.HeaderName] = "enterprise-default"
return headers
},
})
t.Setenv(agentproduct.EnvName, "")
if got := effectiveClawType(); got != "wukong" {
t.Fatalf("effectiveClawType() = %q, want wukong", got)
}
if hookCalled {
t.Fatal("EnterpriseCredentialHeaders hook ran during PAT error serialization")
}
}
func TestAgentProductHeaderIsSeparateFromMessageClawType(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "qwenwork")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
ClawTypeValue: "message-brand",
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
return headers
},
})
if got := resolveIdentityHeaders()[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)
}
}
func TestResolveIdentityHeadersRestoresAgentProductAfterNilCredentialHeaders(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "qwenwork")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
credentialHookCalled := false
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
return headers
},
EnterpriseCredentialHeaders: func(map[string]string) map[string]string {
credentialHookCalled = true
return nil
},
})
headers := resolveIdentityHeaders()
if !credentialHookCalled {
t.Fatal("EnterpriseCredentialHeaders hook was not called")
}
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
}
}
func TestEffectiveClawTypeUsesAgentProductOverride(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"
return headers
},
})
t.Setenv(agentproduct.EnvName, "qwenwork")
if got := effectiveClawType(); got != "qwenwork" {
t.Fatalf("effectiveClawType() = %q, want qwenwork", got)
}
t.Setenv(authpkg.AgentCodeEnv, "agent-code")
if got := apperrors.HostControlBlock()["clawType"]; got != "qwenwork" {
t.Fatalf("hostControl.clawType = %q, want qwenwork", got)
}
t.Setenv(agentproduct.EnvName, "")
if got := effectiveClawType(); got != "wukong" {
t.Fatalf("effectiveClawType() = %q, want wukong", got)
}
t.Setenv(agentproduct.EnvName, "invalid product")
if got := effectiveClawType(); got != "wukong" {
t.Fatalf("effectiveClawType() with invalid env = %q, want wukong", got)
}
}
+2
View File
@@ -17,6 +17,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -346,6 +347,7 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
t.Fatal("host control enabled without agent code")
}
t.Setenv(authpkg.AgentCodeEnv, "agent")
t.Setenv(agentproduct.EnvName, "")
edition.Override(&edition.Hooks{MergeHeaders: func(headers map[string]string) map[string]string { return headers }})
if got := hostControlProviderFromEnv(); got != edition.DefaultOSSClawType {
t.Fatalf("default claw type = %q", got)
+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 {
+137
View File
@@ -11,6 +11,7 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestToolCallerAdapterDryRunNeverInvokesRunner(t *testing.T) {
@@ -36,6 +37,66 @@ func TestToolCallerAdapterDryRunNeverInvokesRunner(t *testing.T) {
}
}
func TestToolCallerAdapterDryRunAllowsOnlyExplicitReadCapability(t *testing.T) {
runner := &readOnlyDryRunRunner{}
caller := newToolCallerAdapter(runner, &GlobalFlags{DryRun: true, Format: "json"})
result, err := caller.(edition.ReadToolCaller).CallReadTool(
context.Background(),
"im",
"search_groups",
map[string]any{"keyword": "project"},
)
if err != nil {
t.Fatalf("CallReadTool() error = %v", err)
}
if got := runner.readCalls.Load(); got != 1 {
t.Fatalf("read calls = %d, want 1", got)
}
if got := runner.regularCalls.Load(); got != 0 {
t.Fatalf("regular calls = %d, want 0", got)
}
if runner.invocation.DryRun {
t.Fatal("read-only invocation was left in dry-run mode")
}
if result == nil || len(result.Content) != 1 || !strings.Contains(result.Content[0].Text, `"read":true`) {
t.Fatalf("read result = %#v", result)
}
failClosed := newToolCallerAdapter(&countingErrorRunner{}, &GlobalFlags{DryRun: true})
if _, err := failClosed.(edition.ReadToolCaller).CallReadTool(
context.Background(), "im", "search_groups", nil,
); err == nil {
t.Fatal("runner without read-only capability was accepted")
}
}
func TestCrossPlatformCoverageReadOnlyGuardErrorPaths(t *testing.T) {
var nilAdapter *toolCallerAdapter
if _, err := nilAdapter.CallReadTool(context.Background(), "im", "search_groups", nil); err == nil {
t.Fatal("nil adapter accepted a read-only call")
}
regularRunner := &capturingSuccessRunner{}
regular := newToolCallerAdapter(regularRunner, &GlobalFlags{DryRun: false, Format: "json"})
if _, err := regular.(edition.ReadToolCaller).CallReadTool(context.Background(), "im", "search_groups", nil); err != nil {
t.Fatalf("non-dry read should use the regular runner: %v", err)
}
if got := regularRunner.calls.Load(); got != 1 {
t.Fatalf("regular runner calls = %d, want 1", got)
}
readFailure := newToolCallerAdapter(&failingReadOnlyRunner{}, &GlobalFlags{DryRun: true, Format: "json"})
if _, err := readFailure.(edition.ReadToolCaller).CallReadTool(context.Background(), "im", "search_groups", nil); err == nil {
t.Fatal("read-only runner error was swallowed")
}
var nilRuntime *runtimeRunner
if _, err := nilRuntime.RunReadOnly(context.Background(), executor.Invocation{}); err == nil {
t.Fatal("nil runtime runner accepted a read-only call")
}
}
func TestRuntimeRunnerGlobalDryRunStopsBeforeInjectedFallback(t *testing.T) {
fallback := &countingErrorRunner{}
runner := &runtimeRunner{globalFlags: &GlobalFlags{DryRun: true}, fallback: fallback}
@@ -56,6 +117,38 @@ func TestRuntimeRunnerGlobalDryRunStopsBeforeInjectedFallback(t *testing.T) {
}
}
func TestRuntimeRunnerReadOnlyClonePreservesGlobalDryRunBarrier(t *testing.T) {
fallback := &capturingSuccessRunner{}
flags := &GlobalFlags{DryRun: true}
runner := &runtimeRunner{globalFlags: flags, fallback: fallback}
invocation := executor.NewHelperInvocation(
"test",
"im",
"search_groups",
map[string]any{"keyword": "project"},
)
if _, err := runner.RunReadOnly(context.Background(), invocation); err != nil {
t.Fatalf("RunReadOnly() error = %v", err)
}
if got := fallback.calls.Load(); got != 1 {
t.Fatalf("fallback calls = %d, want 1", got)
}
if fallback.invocation.DryRun {
t.Fatal("read-only fallback invocation was left in dry-run mode")
}
if !flags.DryRun {
t.Fatal("RunReadOnly mutated the process-wide dry-run flag")
}
if _, err := runner.Run(context.Background(), invocation); err != nil {
t.Fatalf("ordinary Run() error = %v", err)
}
if got := fallback.calls.Load(); got != 1 {
t.Fatalf("ordinary dry-run reached fallback; calls = %d", got)
}
}
type countingErrorRunner struct {
calls atomic.Int64
}
@@ -64,3 +157,47 @@ func (r *countingErrorRunner) Run(context.Context, executor.Invocation) (executo
r.calls.Add(1)
return executor.Result{}, errors.New("runner must not be called")
}
type readOnlyDryRunRunner struct {
regularCalls atomic.Int64
readCalls atomic.Int64
invocation executor.Invocation
}
func (r *readOnlyDryRunRunner) Run(context.Context, executor.Invocation) (executor.Result, error) {
r.regularCalls.Add(1)
return executor.Result{}, errors.New("regular runner must not be called")
}
func (r *readOnlyDryRunRunner) RunReadOnly(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.readCalls.Add(1)
r.invocation = invocation
return executor.Result{
Invocation: invocation,
Response: map[string]any{"read": true},
}, nil
}
type capturingSuccessRunner struct {
calls atomic.Int64
invocation executor.Invocation
}
func (r *capturingSuccessRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.calls.Add(1)
r.invocation = invocation
return executor.Result{
Invocation: invocation,
Response: map[string]any{"read": true},
}, nil
}
type failingReadOnlyRunner struct{}
func (*failingReadOnlyRunner) Run(context.Context, executor.Invocation) (executor.Result, error) {
return executor.Result{}, errors.New("regular runner must not be called")
}
func (*failingReadOnlyRunner) RunReadOnly(context.Context, executor.Invocation) (executor.Result, error) {
return executor.Result{}, errors.New("read failed")
}
+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)
}
}
@@ -0,0 +1,30 @@
package app
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestMultiSkillSharedContractKeepsAccountSafetyRule pins the multi-account
// safety rule that release run 30437390088 found missing: the MultiSkill e2e
// contract asserts the exact phrase below inside the installed
// dws-shared/SKILL.md, so removing it from the embedded skill source must
// fail at PR time instead of at release time.
func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
if err != nil {
t.Fatalf("materialize embedded multi skill source: %v", err)
}
t.Cleanup(cleanup)
data, err := os.ReadFile(filepath.Join(dir, "dws-shared", "SKILL.md"))
if err != nil {
t.Fatalf("read embedded dws-shared/SKILL.md: %v", err)
}
const rule = "禁止选择第一项、最近登录或最近使用账号"
if !strings.Contains(string(data), rule) {
t.Fatalf("embedded dws-shared/SKILL.md lost the mandatory account safety rule %q", rule)
}
}
+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)
}
}
+2 -1
View File
@@ -570,7 +570,8 @@ func handlePatAuthCheck(
// 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
// edition.MergeHeaders and surfaced in hostControl for traceability.
// the edition default / DWS_AGENT_PRODUCT override and surfaced in
// hostControl for traceability.
if hostOwnedPAT || patData.Data.FlowID == "" {
if hostOwnedPAT {
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorForHostControl(patErr.RawJSON)}
+5 -3
View File
@@ -30,6 +30,7 @@ import (
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/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
)
func TestIsPatScopeError_MissingScope(t *testing.T) {
@@ -1006,10 +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 — the open-source build pins that to the
// literal edition.DefaultOSSClawType value ("openClaw").
// hostControl.clawType. With no DWS_AGENT_PRODUCT override the
// open-source edition default remains "openClaw".
t.Setenv(authpkg.AgentCodeEnv, "agt-sales")
t.Setenv("DINGTALK_AGENT", "sales-copilot")
t.Setenv(agentproduct.EnvName, "")
mock := &mockRunner{
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
@@ -1053,7 +1055,7 @@ func TestHandlePatAuthCheck_HostControlledFlowIDPassthrough(t *testing.T) {
}
hostControl, _ := data["hostControl"].(map[string]any)
if got, _ := hostControl["clawType"].(string); got != "openClaw" {
t.Fatalf("hostControl.clawType = %q, want openClaw (hard-wired by open-source edition)", got)
t.Fatalf("hostControl.clawType = %q, want openClaw (open-source edition default)", got)
}
if got, _ := hostControl["callbackOwner"].(string); got != "host" {
t.Fatalf("hostControl.callbackOwner = %q, want host", got)
+14 -13
View File
@@ -27,11 +27,11 @@ import (
//
// Decision rule:
// - 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 into the
// `claw-type` HTTP header. The open-source build pins that to
// edition.DefaultOSSClawType ("openClaw") unconditionally — there
// is no per-spawn env override.
// - 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.
// - When DINGTALK_DWS_AGENTCODE is empty the provider returns "" so
// HostControlBlock yields nil and no hostControl block is emitted.
func init() {
@@ -48,15 +48,16 @@ func hostControlProviderFromEnv() string {
return effectiveClawType()
}
// effectiveClawType returns the literal value that MergeHeaders will
// inject into outbound `claw-type` headers. Going through the edition
// hook (instead of a hard-coded constant) keeps this site correct for
// downstream editions that override MergeHeaders.
// effectiveClawType resolves the literal value injected into outbound
// `claw-type` headers without invoking credential hooks from PAT error
// serialization. MergeHeaders implementations that set claw-type must satisfy
// the edition contract that this value is independent of the base map.
func effectiveClawType() string {
if h := edition.Get(); h != nil && h.MergeHeaders != nil {
if v, ok := h.MergeHeaders(map[string]string{})["claw-type"]; ok && v != "" {
return v
headers := make(map[string]string)
if h := edition.Get(); h != nil {
if h.MergeHeaders != nil {
headers = h.MergeHeaders(headers)
}
}
return edition.DefaultOSSClawType
return resolveEffectiveAgentProduct(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)
}
}
+116 -7
View File
@@ -41,6 +41,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
@@ -75,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.
@@ -113,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 {
@@ -135,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")
}
@@ -182,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{
@@ -230,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) {
@@ -338,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",
@@ -353,12 +418,15 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
return cmd.Help()
},
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Validate the optional observation label before any edition hook
// Validate caller-provided identity labels before any edition hook
// or command network activity can run. Header-only library callers
// use the best-effort path in resolveIdentityHeaders instead.
if _, err := parseAgentHost(os.Getenv(envDWSAgentHost)); err != nil {
return err
}
if _, err := parseAgentProduct(os.Getenv(agentproduct.EnvName)); err != nil {
return err
}
authpkg.SetRuntimeProfile(flags.Profile)
// Apply OAuth credential overrides from CLI flags (highest priority).
@@ -449,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++ {
@@ -1248,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 }
+2
View File
@@ -5,6 +5,7 @@ import (
"strings"
"text/tabwriter"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -41,6 +42,7 @@ func configureRootHelp(root *cobra.Command) {
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
if cmd != root {
defaultHelpFunc(cmd, args)
cli.RenderSafetyAnnotation(cmd)
return
}
renderRootHelp(root)
+1 -1
View File
@@ -380,7 +380,7 @@ func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
}
searchAdvanced := mustFindCommand(t, root, "chat", "message", "search-advanced")
for _, flag := range []string{"sender", "senders", "sender-ids"} {
for _, flag := range []string{"sender", "senders", "sender-ids", "message-type", "only-robot", "conversation-type"} {
if searchAdvanced.Flags().Lookup(flag) == nil {
t.Fatalf("chat message search-advanced missing --%s", flag)
}
+37 -5
View File
@@ -36,6 +36,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -224,6 +225,24 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
return r.runSingle(ctx, invocation, true)
}
// RunReadOnly executes one already-classified read lookup for a semantic
// Shortcut that is building a dry-run plan. It clones the runtime flags and
// clears DryRun only on that clone: the process-wide caller and every ordinary
// ToolCaller invocation retain the global execution barrier.
func (r *runtimeRunner) RunReadOnly(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
if r == nil {
return executor.Result{}, fmt.Errorf("runtime runner is not configured")
}
clone := *r
if r.globalFlags != nil {
flags := *r.globalFlags
flags.DryRun = false
clone.globalFlags = &flags
}
invocation.DryRun = false
return clone.Run(ctx, invocation)
}
func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invocation, prefetchToken bool) (executor.Result, error) {
if r.loader == nil || r.transport == nil {
return r.fallback.Run(ctx, invocation)
@@ -986,10 +1005,10 @@ 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 the
// open-source edition pins to edition.DefaultOSSClawType via the
// MergeHeaders hook below) and it does NOT influence the host-owned
// PAT decision (driven solely by DINGTALK_DWS_AGENTCODE).
// 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
// DINGTALK_DWS_AGENTCODE).
sessionID := os.Getenv(envDingtalkSessionID)
if sessionID == "" {
sessionID = os.Getenv(envDWSSessionID)
@@ -1038,7 +1057,7 @@ func resolveIdentityHeaders() map[string]string {
headers["x-dws-channel"] = v
}
// DWS_AGENT_HOST is a caller-provided observation label only. Root command
// DWS_AGENT_HOST is a caller-declared runtime-form signal. Root command
// execution validates it strictly in PersistentPreRunE. Library callers
// that bypass the root command keep this best-effort API contract: invalid
// values are omitted rather than changing the public function signature.
@@ -1049,9 +1068,22 @@ 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 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
return headers
}
+67 -14
View File
@@ -11,22 +11,26 @@ import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const publicShortcutSchemaCount = 210
const (
publicShortcutCount = 265
schemaPublishedShortcutCount = 215
)
func TestEmbeddedSchemaPublishesEveryPublicShortcutContract(t *testing.T) {
func TestEmbeddedSchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
tools := embeddedSchemaAllToolsForHelpFlagTest(t, NewRootCommand())
public := make([]shortcut.Shortcut, 0, publicShortcutSchemaCount)
public := make([]shortcut.Shortcut, 0, publicShortcutCount)
for _, candidate := range shortcut.All() {
if candidate.UserDefined || !shortcut.InPublicCatalog(candidate.Service, candidate.Command) {
continue
}
public = append(public, candidate)
}
if got := len(public); got != publicShortcutSchemaCount {
t.Fatalf("public built-in shortcuts = %d, want %d", got, publicShortcutSchemaCount)
if got := len(public); got != publicShortcutCount {
t.Fatalf("public built-in shortcuts = %d, want %d", got, publicShortcutCount)
}
deliveredShortcuts := 0
@@ -35,17 +39,35 @@ func TestEmbeddedSchemaPublishesEveryPublicShortcutContract(t *testing.T) {
deliveredShortcuts++
}
}
if deliveredShortcuts != publicShortcutSchemaCount {
t.Fatalf("embedded schema --all shortcut tools = %d, want %d", deliveredShortcuts, publicShortcutSchemaCount)
if deliveredShortcuts != schemaPublishedShortcutCount {
t.Fatalf("embedded schema --all shortcut tools = %d, want %d", deliveredShortcuts, schemaPublishedShortcutCount)
}
exclusions, err := cli.EmbeddedRuntimeSchemaExclusions()
if err != nil {
t.Fatal(err)
}
excludedPaths := make(map[string]bool, len(exclusions))
for _, exclusion := range exclusions {
if !exclusion.Reviewed || strings.TrimSpace(exclusion.Reason) == "" {
t.Fatalf("unreviewed public command exclusion: %#v", exclusion)
}
excludedPaths[exclusion.CLIPath] = true
}
excludedShortcuts := 0
for _, declared := range public {
declared := declared
t.Run(declared.Service+"/"+strings.TrimPrefix(declared.Command, "+"), func(t *testing.T) {
canonical := shortcutSchemaCanonical(declared)
tool := tools[canonical]
if tool == nil {
t.Fatalf("embedded schema --all is missing %s (%s %s)", canonical, declared.Service, declared.Command)
cliPath := declared.Service + " " + declared.Command
if !excludedPaths[cliPath] {
t.Fatalf("embedded schema --all is missing %s (%s) without an exact reviewed exclusion", canonical, cliPath)
}
excludedShortcuts++
return
}
assertEmbeddedShortcutIdentityAndSelection(t, tool, declared, canonical)
assertEmbeddedShortcutSafetyAndInterface(t, tool, declared, canonical)
@@ -53,6 +75,9 @@ func TestEmbeddedSchemaPublishesEveryPublicShortcutContract(t *testing.T) {
assertEmbeddedShortcutConstraints(t, tool, declared, canonical)
})
}
if got, want := excludedShortcuts, publicShortcutCount-schemaPublishedShortcutCount; got != want {
t.Fatalf("exactly excluded public shortcuts = %d, want %d", got, want)
}
}
func TestEmbeddedShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T) {
@@ -81,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)), 120; 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"])
@@ -91,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)
}
}
@@ -328,9 +353,9 @@ func assertEmbeddedShortcutConstraints(
case shortcut.ConstraintCustom:
for _, flagName := range flags {
description := schemaContractString(schemaContractMap(tool["parameters"])[flagName]["description"])
for _, requiredText := range []string{"原文不能为空", "不能重复"} {
if !strings.Contains(description, requiredText) {
t.Errorf("%s --%s description does not publish custom constraint %q: %q", canonical, flagName, requiredText, description)
for _, evidence := range shortcutCustomConstraintEvidence(constraint.Description) {
if !strings.Contains(description, evidence) {
t.Errorf("%s --%s description does not publish custom constraint evidence %q: %q", canonical, flagName, evidence, description)
}
}
}
@@ -349,6 +374,34 @@ func assertEmbeddedShortcutConstraints(
}
}
func shortcutCustomConstraintEvidence(description string) []string {
// Custom constraints are prose rather than a typed wire contract. Require
// their decision-relevant facts to survive in the delivered parameter
// description while allowing the renderer to reorder connective wording.
probes := []string{
"原文=>替换",
"不能为空",
"不能重复",
"大于 0",
"工作目录",
"相对路径",
"绝对路径",
"..",
"最多 15 个字符",
"能力矩阵",
}
evidence := make([]string, 0, len(probes))
for _, probe := range probes {
if strings.Contains(description, probe) {
evidence = append(evidence, probe)
}
}
if len(evidence) > 0 {
return evidence
}
return []string{strings.TrimSpace(description)}
}
func mustShortcutJSON(value any) string {
encoded, err := json.Marshal(value)
if err != nil {
+27
View File
@@ -66,6 +66,33 @@ func (a *toolCallerAdapter) CallTool(ctx context.Context, productID, toolName st
return convertResult(result), nil
}
type dryRunReadRunner interface {
RunReadOnly(context.Context, executor.Invocation) (executor.Result, error)
}
// CallReadTool executes a Shortcut's explicitly classified read lookup while
// the outer command is in dry-run mode. Ordinary CallTool remains protected by
// the global execution barrier. The runner capability is optional and fails
// closed so an injected runner cannot accidentally receive a real call.
func (a *toolCallerAdapter) CallReadTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
if a == nil || a.runner == nil {
return nil, fmt.Errorf("ToolCaller runner is not configured")
}
if !a.DryRun() {
return a.CallTool(ctx, productID, toolName, args)
}
readRunner, ok := a.runner.(dryRunReadRunner)
if !ok {
return nil, fmt.Errorf("ToolCaller runner does not support read-only dry-run lookups")
}
inv := executor.NewHelperInvocation("overlay."+productID+"."+toolName, productID, toolName, args)
result, err := readRunner.RunReadOnly(ctx, inv)
if err != nil {
return nil, err
}
return convertResult(result), nil
}
// CallToolWithToken invokes a helper with an in-memory token override. It is
// used during login before the new token has been persisted to any profile
// slot.
+13
View File
@@ -39,6 +39,19 @@ func (r recordingToolCaller) CallTool(ctx context.Context, product, tool string,
return res, err
}
func (r recordingToolCaller) CallReadTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
inner, ok := r.inner.(edition.ReadToolCaller)
if !ok {
return nil, fmt.Errorf("ToolCaller read-only dry-run lookup is not configured")
}
recordedArgs := cloneToolArgs(args)
res, err := inner.CallReadTool(ctx, product, tool, args)
// This is a real read even though the outer command is a dry-run. Record it
// as such so usage evidence does not misclassify the lookup as skipped.
usage.Append(product, tool, recordedArgs, err == nil, false)
return res, err
}
func (r recordingToolCaller) CallToolWithToken(
ctx context.Context,
token, product, tool string,
+56
View File
@@ -27,6 +27,7 @@ type crossPlatformCoverageCaller struct {
args map[string]any
token string
dryRun bool
reads int
}
func (c *crossPlatformCoverageCaller) CallTool(_ context.Context, _, _ string, args map[string]any) (*edition.ToolResult, error) {
@@ -44,6 +45,16 @@ func (c *crossPlatformCoverageCaller) CallToolWithToken(
return &edition.ToolResult{}, nil
}
func (c *crossPlatformCoverageCaller) CallReadTool(
_ context.Context,
_, _ string,
args map[string]any,
) (*edition.ToolResult, error) {
c.reads++
c.args = args
return &edition.ToolResult{}, nil
}
func (*crossPlatformCoverageCaller) Format() string { return "json" }
func (c *crossPlatformCoverageCaller) DryRun() bool { return c.dryRun }
func (*crossPlatformCoverageCaller) Fields() string { return "id,name" }
@@ -129,6 +140,51 @@ func TestCrossPlatformCoverageRecordingToolCaller(t *testing.T) {
}
}
func TestCrossPlatformCoverageRecordingReadToolCaller(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv("DWS_USAGE_TRACKING", "1")
inner := &crossPlatformCoverageCaller{dryRun: true}
caller := newRecordingToolCaller(inner)
readCaller, ok := caller.(edition.ReadToolCaller)
if !ok {
t.Fatal("recording caller dropped read-only capability")
}
if _, err := readCaller.CallReadTool(
context.Background(),
"im",
"search_groups",
map[string]any{"open_conversation_id": "cid_x"},
); err != nil {
t.Fatal(err)
}
if inner.reads != 1 || inner.args["open_conversation_id"] != "cid_x" {
t.Fatalf("read forwarding = reads %d args %#v", inner.reads, inner.args)
}
records, err := usage.Read()
if err != nil {
t.Fatal(err)
}
if len(records) != 1 || !records[0].OK {
t.Fatalf("real read must be recorded as successful: %#v", records)
}
withoutRead := recordingToolCaller{inner: nonReadToolCaller{}}
if _, err := withoutRead.CallReadTool(context.Background(), "im", "search_groups", nil); err == nil {
t.Fatal("recording caller accepted an inner caller without read support")
}
}
type nonReadToolCaller struct{}
func (nonReadToolCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return &edition.ToolResult{}, nil
}
func (nonReadToolCaller) Format() string { return "json" }
func (nonReadToolCaller) DryRun() bool { return false }
func (nonReadToolCaller) Fields() string { return "" }
func (nonReadToolCaller) JQ() string { return "" }
func TestCrossPlatformCoverageRootPublishesShortcutCommands(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
+135
View File
@@ -0,0 +1,135 @@
// 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_meta.go provides the unified metadata consumption API. All runtime
// consumers (help, schema, agent selection, skill generation) call ResolveMeta
// to get a CommandMeta struct — one function, one struct, no need to know which
// of the 6 generation layers a field comes from.
//
// This is the "simple consumption" half of the generation/consumption split:
// - Generation (gen.go + internal/generator/): 6 inputs → catalog snapshot.
// - Consumption (this file): catalog snapshot → ResolveMeta → CommandMeta.
package cli
import (
"sort"
"strings"
"sync"
)
// CommandMeta is the complete runtime metadata view for a single command.
// Consumers read this struct; they never touch the raw catalog maps.
type CommandMeta struct {
Identity CommandIdentity
Safety CommandSafety
Selection CommandSelection
}
// CommandIdentity is the stable identity of a command.
type CommandIdentity struct {
CLIPath string // "dev app delete"
Canonical string // "dev.delete_dev_app"
Aliases []string // ["search", ...]
ProductID string // "devapp"
Title string // one-line description
}
// CommandSelection is the agent-facing selection metadata.
type CommandSelection struct {
AgentSummary string
UseWhen []string
AvoidWhen []string
Examples []string
}
var (
metaByCLIPathOnce sync.Once
metaByCLIPath map[string]CommandMeta
)
// initMetaByCLIPath builds the cli_path → CommandMeta lookup from the embedded
// catalog. Runs once (sync.Once); the catalog is already decoded at package init.
func initMetaByCLIPath() {
metaByCLIPath = buildMetaByCLIPath(embeddedSchemaCatalog())
}
// buildMetaByCLIPath constructs the lookup from a loaded catalog snapshot.
// Split from initMetaByCLIPath so malformed-snapshot guards stay testable.
func buildMetaByCLIPath(loaded loadedSchemaCatalog) map[string]CommandMeta {
lookup := make(map[string]CommandMeta)
if loaded.Snapshot.Tools == nil {
return lookup
}
metas := make([]CommandMeta, 0, len(loaded.Snapshot.Tools))
for _, tool := range loaded.Snapshot.Tools {
cliPath := schemaString(tool["cli_path"])
if cliPath == "" {
continue
}
meta := CommandMeta{
Identity: CommandIdentity{
CLIPath: cliPath,
Canonical: schemaString(tool["canonical_path"]),
Aliases: schemaStringSlice(tool["aliases"]),
ProductID: schemaString(tool["product_id"]),
Title: schemaString(tool["title"]),
},
Safety: CommandSafety{
Effect: schemaString(tool["effect"]),
Risk: schemaString(tool["risk"]),
Confirmation: schemaString(tool["confirmation"]),
Idempotency: schemaString(tool["idempotency"]),
},
Selection: CommandSelection{
AgentSummary: schemaString(tool["agent_summary"]),
UseWhen: schemaStringSlice(tool["use_when"]),
AvoidWhen: schemaStringSlice(tool["avoid_when"]),
Examples: schemaStringSlice(tool["examples"]),
},
}
lookup[cliPath] = meta
metas = append(metas, meta)
}
// Register compat alias paths (e.g. "report list") against the same
// metadata in a second pass, sorted by primary cli_path: primary paths
// always win (registered above, aliases only fill vacancies), and an
// alias-vs-alias collision resolves deterministically to the owner with
// the lexicographically smallest primary path — Snapshot.Tools is a map,
// so relying on iteration order would make the winner vary per process.
sort.Slice(metas, func(i, j int) bool {
return metas[i].Identity.CLIPath < metas[j].Identity.CLIPath
})
for _, meta := range metas {
for _, alias := range meta.Identity.Aliases {
alias = strings.TrimSpace(alias)
if alias == "" || alias == meta.Identity.CLIPath {
continue
}
if _, exists := lookup[alias]; !exists {
lookup[alias] = meta
}
}
}
return lookup
}
// ResolveMeta returns the complete metadata for a command identified by its CLI
// path (e.g. "dev app delete") or one of its compat aliases (e.g. "report list"
// for "report inbox list"). Returns ok=false for commands not in the embedded
// catalog (utility commands, hidden commands, shortcuts).
func ResolveMeta(cliPath string) (CommandMeta, bool) {
metaByCLIPathOnce.Do(initMetaByCLIPath)
m, ok := metaByCLIPath[strings.TrimSpace(cliPath)]
return m, ok
}
+77
View File
@@ -0,0 +1,77 @@
// 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"
"strings"
"github.com/spf13/cobra"
)
// CommandSafety holds the safety metadata for a CLI command, resolved at
// runtime from the embedded schema catalog. This is a read-only view over the
// catalog — NOT a second safety source. The catalog remains the single
// authoritative reviewed source; this struct merely provides typed access for
// consumers (help rendering, skill generation).
type CommandSafety struct {
Effect string // read / write / destructive
Risk string // low / medium / high
Confirmation string // not_required / user_required
Idempotency string // idempotent / non_idempotent
}
// ShouldRender returns true when the safety metadata warrants a visible
// annotation in --help. Only commands that need confirmation or carry
// above-low risk are annotated; read-only low-risk commands stay clean.
func (s CommandSafety) ShouldRender() bool {
return s.Confirmation == "user_required" ||
(s.Risk != "" && s.Risk != "low")
}
// SafetyForCLIPath returns the safety metadata for a command identified by its
// CLI path (e.g. "dev app delete"). Returns ok=false when the command is not
// in the embedded catalog (utility commands, hidden commands, shortcuts).
//
// Deprecated: use ResolveMeta(cliPath).Safety for the complete metadata view.
// Kept for backward compatibility with existing callers.
func SafetyForCLIPath(cliPath string) (CommandSafety, bool) {
meta, ok := ResolveMeta(cliPath)
if !ok {
return CommandSafety{}, false
}
return meta.Safety, true
}
// RenderSafetyAnnotation writes a "Safety:" line to the command's stdout when
// the command carries above-low risk or requires user confirmation. This is
// the shared entry point for ALL help rendering paths (root HelpFunc, product
// group custom HelpFuncs like calendar's). It avoids the timing issue where a
// group captures origHelp before configureRootHelp sets the root's custom func.
func RenderSafetyAnnotation(cmd *cobra.Command) {
cliPath := strings.TrimSpace(strings.TrimPrefix(cmd.CommandPath(), cmd.Root().Name()+" "))
safety, ok := SafetyForCLIPath(cliPath)
if !ok || !safety.ShouldRender() {
return
}
w := cmd.OutOrStdout()
fmt.Fprintf(w, "\nSafety: effect=%s risk=%s confirmation=%s", safety.Effect, safety.Risk, safety.Confirmation)
if safety.Idempotency != "" {
fmt.Fprintf(w, " idempotency=%s", safety.Idempotency)
}
if safety.Confirmation == "user_required" {
fmt.Fprint(w, " (requires --yes)")
}
fmt.Fprintln(w)
}
+132
View File
@@ -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 cli
import (
"testing"
)
func TestSafetyForCLIPathKnownCommand(t *testing.T) {
// dev app delete 是 destructive + high risk + user_required。
s, ok := SafetyForCLIPath("dev app delete")
if !ok {
t.Fatal("SafetyForCLIPath(\"dev app delete\") returned ok=false; want true")
}
if s.Effect != "destructive" {
t.Errorf("effect = %q, want destructive", s.Effect)
}
if s.Risk != "high" {
t.Errorf("risk = %q, want high", s.Risk)
}
if s.Confirmation != "user_required" {
t.Errorf("confirmation = %q, want user_required", s.Confirmation)
}
if !s.ShouldRender() {
t.Error("ShouldRender() = false for destructive/high/user_required; want true")
}
}
func TestSafetyForCLIPathUnknownSkips(t *testing.T) {
// 不存在的命令 → ok=false。
_, ok := SafetyForCLIPath("nonexistent fake command")
if ok {
t.Fatal("SafetyForCLIPath for nonexistent command returned ok=true; want false")
}
}
func TestSafetyForCLIPathReadOnlyLowRiskSkips(t *testing.T) {
// 找一个 read/low 风险命令,ShouldRender 应 false。
// dev app list 是 read,默认 low risk。
s, ok := SafetyForCLIPath("dev app list")
if !ok {
t.Skip("dev app list not in catalog; skipping")
}
if s.ShouldRender() {
t.Errorf("ShouldRender() = true for effect=%s risk=%s; want false (read/low)", s.Effect, s.Risk)
}
}
func TestSafetyForCLIPathTrimsWhitespace(t *testing.T) {
// 带首尾空格的 cli_path 也能查到。
s1, _ := SafetyForCLIPath("dev app delete")
s2, ok2 := SafetyForCLIPath(" dev app delete ")
if !ok2 {
t.Fatal("trimmed lookup failed; whitespace not trimmed")
}
if s1 != s2 {
t.Errorf("whitespace-trimmed result differs: %+v vs %+v", s1, s2)
}
}
func TestResolveMetaComplete(t *testing.T) {
// dev app delete: destructive + high + user_required + 有 selection。
m, ok := ResolveMeta("dev app delete")
if !ok {
t.Fatal("ResolveMeta(\"dev app delete\") returned ok=false")
}
if m.Identity.CLIPath != "dev app delete" {
t.Errorf("CLIPath = %q", m.Identity.CLIPath)
}
if m.Safety.Effect != "destructive" {
t.Errorf("Effect = %q, want destructive", m.Safety.Effect)
}
if len(m.Selection.UseWhen) == 0 && m.Selection.AgentSummary == "" {
t.Error("Selection is empty; expected use_when or agent_summary")
}
t.Logf("dev app delete: canonical=%s safety=%+v selection(use_when=%d avoid_when=%d examples=%d)",
m.Identity.Canonical, m.Safety, len(m.Selection.UseWhen), len(m.Selection.AvoidWhen), len(m.Selection.Examples))
}
func TestResolveMetaUnknownSkips(t *testing.T) {
_, ok := ResolveMeta("nonexistent fake command")
if ok {
t.Fatal("ResolveMeta for unknown command returned ok=true")
}
}
func TestResolveMetaCopiesAliases(t *testing.T) {
// report inbox list 在 Catalog 中声明了兼容别名 "report list"。
m, ok := ResolveMeta("report inbox list")
if !ok {
t.Fatal("ResolveMeta(\"report inbox list\") returned ok=false")
}
found := false
for _, alias := range m.Identity.Aliases {
if alias == "report list" {
found = true
break
}
}
if !found {
t.Fatalf("Identity.Aliases = %v, want to contain \"report list\"", m.Identity.Aliases)
}
}
func TestResolveMetaAliasLookup(t *testing.T) {
// 兼容别名路径必须解析到与主 cli_path 同一份元数据。
primary, ok := ResolveMeta("report inbox list")
if !ok {
t.Fatal("primary path lookup failed")
}
aliased, ok := ResolveMeta("report list")
if !ok {
t.Fatal("ResolveMeta(\"report list\") alias lookup returned ok=false")
}
if aliased.Identity.CLIPath != primary.Identity.CLIPath || aliased.Identity.Canonical != primary.Identity.Canonical {
t.Fatalf("alias metadata = %+v, want same identity as primary %+v", aliased.Identity, primary.Identity)
}
if aliased.Safety != primary.Safety {
t.Fatalf("alias safety = %+v, want %+v", aliased.Safety, primary.Safety)
}
}
+41
View File
@@ -0,0 +1,41 @@
// 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.
// 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 authored-input → generated-output contract is documented in one place.
//
// Generation inputs (authored, reviewed):
// 1. schema_command_registry/ identity (canonical/aliases/navigation)
// 2. schema_hints/metadata/*.json safety (effect/risk/confirmation)
// 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. 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
//go:generate go run ../generator/cmd_schema_agent_metadata -root ../.. -registry internal/cli/schema_command_registry -output-dir schema_agent_metadata -audit-output schema_agent_metadata_audit.json
// Rebuild all dependencies so the Catalog compiler cannot reuse the 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")
}
}
-6
View File
@@ -22,12 +22,6 @@ import (
"sync/atomic"
)
//go:generate go run ../generator/cmd_schema_agent_metadata -root ../.. -registry internal/cli/schema_command_registry.json -output-dir schema_agent_metadata -audit-output schema_agent_metadata_audit.json
// Rebuild all dependencies so the Catalog compiler cannot reuse the 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.json
//go:embed schema_agent_metadata/*.json
var embeddedAgentMetadataFS embed.FS
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,17 +1,17 @@
{
"version": 1,
"source_hash": "sha256:f71480e58eda8f2be041e07d7faf6fb20f5c21f861995d3c4dacc311b46c3e81",
"surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed",
"source_hash": "sha256:7484b82d1ca793a6129acfaa192ff08fc0864d47a6e75ecd16d8e7fa32857e16",
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
"coverage": {
"surface_products": 26,
"products_with_metadata": 26,
"surface_tools": 813,
"tools_with_metadata": 813,
"tools_with_agent_summary": 813,
"tools_with_use_when": 813,
"tools_with_avoid_when": 813,
"tools_with_examples": 813,
"tools_with_interface_mode": 813,
"surface_tools": 845,
"tools_with_metadata": 845,
"tools_with_agent_summary": 845,
"tools_with_use_when": 845,
"tools_with_avoid_when": 845,
"tools_with_examples": 845,
"tools_with_interface_mode": 845,
"unmatched_skill_tools": 122,
"unreviewed_skill_tools": 11
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+86 -4
View File
@@ -15,10 +15,11 @@ package cli
import (
"crypto/sha256"
_ "embed"
"embed"
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"strings"
"sync"
"sync/atomic"
@@ -29,8 +30,20 @@ import (
const SchemaCatalogSnapshotVersion = 1
//go:embed schema_catalog.json
var embeddedSchemaCatalogJSON []byte
// The release Catalog is committed as a per-product split so concurrent
// feature PRs only rewrite the shard for the product they touch:
//
// schema_catalog/catalog.json global envelope + Catalog map
// schema_catalog/tools/<product>.json that product's leaf ToolSpecs
//
// The loader reassembles the exact same SchemaCatalogSnapshot, so the
// source_hash integrity check is identical to the single-file layout.
//
//go:embed schema_catalog/catalog.json
var embeddedSchemaCatalogEnvelopeJSON []byte
//go:embed schema_catalog/tools
var embeddedSchemaCatalogTools embed.FS
// SchemaCatalogSnapshot is the release-stable Agent contract. Catalog holds
// the progressive product/tool index; Tools holds full leaf parameter schemas.
@@ -68,11 +81,80 @@ var runtimeEmbeddedSchemaCatalogLazyLoadCount atomic.Uint64
func embeddedSchemaCatalog() loadedSchemaCatalog {
runtimeEmbeddedSchemaCatalogOnce.Do(func() {
runtimeEmbeddedSchemaCatalogLazyLoadCount.Add(1)
runtimeEmbeddedSchemaCatalog, runtimeEmbeddedSchemaCatalogErr = decodeSchemaCatalogSnapshot(embeddedSchemaCatalogJSON)
runtimeEmbeddedSchemaCatalog, runtimeEmbeddedSchemaCatalogErr = assembleEmbeddedSchemaCatalog()
})
return runtimeEmbeddedSchemaCatalog
}
// schemaCatalogEnvelope is the global half of the split release Catalog. It
// mirrors the generator's envelope struct; the Catalog map and release hashes
// do not partition by product and stay in one file.
type schemaCatalogEnvelope struct {
Version int `json:"version"`
SurfaceHash string `json:"surface_hash,omitempty"`
SourceHash string `json:"source_hash"`
Catalog map[string]any `json:"catalog"`
}
// schemaCatalogToolShard mirrors the per-product shard written by the
// generator. Only the product and its leaf ToolSpecs are stored here.
type schemaCatalogToolShard struct {
Product string `json:"product"`
Tools map[string]map[string]any `json:"tools"`
}
// assembleEmbeddedSchemaCatalog reassembles the split release Catalog shards
// into the same SchemaCatalogSnapshot the single-file layout produced, then
// validates it through the production loader. source_hash still covers the
// whole payload: if any shard is missing, stale, or tampered, the content hash
// check in loadSchemaCatalogSnapshot fails exactly as before.
func assembleEmbeddedSchemaCatalog() (loadedSchemaCatalog, error) {
snapshot, err := assembleSchemaCatalogSnapshot(embeddedSchemaCatalogEnvelopeJSON, embeddedSchemaCatalogTools, "schema_catalog/tools")
if err != nil {
return loadedSchemaCatalog{}, err
}
return loadSchemaCatalogSnapshot(snapshot)
}
// assembleSchemaCatalogSnapshot merges an envelope document and a directory of
// per-product tool shards back into the single-document snapshot shape. Split
// from assembleEmbeddedSchemaCatalog so shard failure modes stay testable
// against fake filesystems.
func assembleSchemaCatalogSnapshot(envelopeJSON []byte, shards fs.FS, dir string) (SchemaCatalogSnapshot, error) {
var envelope schemaCatalogEnvelope
if err := decodeStrictSchemaJSON(envelopeJSON, &envelope); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("decode embedded schema catalog.json: %w", err)
}
entries, err := fs.ReadDir(shards, dir)
if err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("read embedded schema catalog tools directory: %w", err)
}
tools := make(map[string]map[string]any, len(entries)*8)
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
data, readErr := fs.ReadFile(shards, dir+"/"+entry.Name())
if readErr != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("read embedded schema catalog shard %s: %w", entry.Name(), readErr)
}
var shard schemaCatalogToolShard
if err := decodeStrictSchemaJSON(data, &shard); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("decode embedded schema catalog shard %s: %w", entry.Name(), err)
}
for canonical, spec := range shard.Tools {
tools[canonical] = spec
}
}
return SchemaCatalogSnapshot{
Version: envelope.Version,
SurfaceHash: envelope.SurfaceHash,
SourceHash: envelope.SourceHash,
Catalog: envelope.Catalog,
Tools: tools,
}, nil
}
func embeddedSchemaCatalogError() error {
_ = embeddedSchemaCatalog()
return runtimeEmbeddedSchemaCatalogErr
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,762 @@
{
"product": "devdoc",
"tools": {
"devdoc.search_open_platform_docs_rag": {
"agent_metadata_source": "embedded-skill-metadata",
"agent_source_refs": [
"CommandRegistry:canonical_path=devdoc.search_open_platform_docs_rag",
"Skill:skills/mono/references/products/devdoc.md",
"cobra-help:dws devdoc article search",
"dws-wukong-envelope@4574f7022c32:dws-wukong-discovery.devdoc.prod.json#devdoc.search_open_platform_docs",
"internal/cli/schema_command_registry.json#devdoc.search_open_platform_docs_rag",
"internal/cli/schema_hints/imported/wukong.json",
"internal/cli/schema_hints/metadata/devdoc.json",
"internal/cli/schema_hints/selection/devdoc.json",
"internal/cli/schema_mcp_metadata.json#tools.devdoc.search_open_platform_docs_rag",
"live-dws-schema:devdoc.search_open_platform_docs_rag",
"skills/mono/references/products/devdoc.md",
"skills/mono/references/products/simple.md",
"structured-hint:internal/cli/schema_hints/imported/wukong.json#devdoc.search_open_platform_docs_rag",
"structured-hint:internal/cli/schema_hints/selection-review.json#devdoc.search_open_platform_docs_rag"
],
"agent_summary": "搜索钉钉开放平台开发文档,返回资料与链接(不生成分析答案)",
"agent_summary_source": "dws-agent-selection/devdoc",
"availability": "available",
"avoid_when": [
"搜索用户业务文档用 drive/wiki/doc,不要用 devdoc",
"要执行开放平台应用配置变更时用 dev"
],
"canonical_path": "devdoc.search_open_platform_docs_rag",
"cli_name": "search",
"cli_path": "devdoc article search",
"confirmation": "not_required",
"constraints": {
"require_one_of": [
[
"query",
"keyword"
]
]
},
"description": "按关键词搜索 open.dingtalk.com 文档,支持分页。默认表格输出,可用 -f json 获取完整响应。",
"display": "开放平台文档搜索",
"effect": "read",
"effect_source": "command-verb",
"examples": [
"dws devdoc article search --query \"OAuth2 接入\" --format json",
"dws devdoc article search --query \"errcode 40078\" --format json"
],
"field_provenance": {
"agent_summary": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": true,
"source": "internal/cli/schema_hints/selection/devdoc.json",
"value": "搜索钉钉开放平台开发文档,返回资料与链接(不生成分析答案)"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/devdoc.json",
"value": "搜索钉钉开放平台开发文档,返回资料与链接(不生成分析答案)"
},
"availability": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"value": "available"
},
{
"precedence": "mcp_fallback",
"selected": false,
"source": "internal/cli/schema_mcp_metadata.json#tools.devdoc.search_open_platform_docs_rag.interface_ref",
"value": "available"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"value": "available"
},
"avoid_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": true,
"source": "internal/cli/schema_hints/selection/devdoc.json",
"value": [
"搜索用户业务文档用 drive/wiki/doc,不要用 devdoc",
"要执行开放平台应用配置变更时用 dev"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/devdoc.json",
"value": [
"搜索用户业务文档用 drive/wiki/doc,不要用 devdoc",
"要执行开放平台应用配置变更时用 dev"
]
},
"canonical_path": {
"candidates": [
{
"precedence": "command_registry",
"selected": true,
"source": "reviewed_command_registry",
"source_ref": "devdoc article search",
"value": "devdoc.search_open_platform_docs_rag"
},
{
"precedence": "reviewed_manual",
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
"selected": false,
"source": "reviewed_manual_hint",
"source_ref": "devdoc article search",
"value": "devdoc.search_open_platform_docs_rag"
}
],
"precedence": "command_registry",
"resolution": "registry_identity",
"source": "reviewed_command_registry",
"source_ref": "devdoc article search",
"value": "devdoc.search_open_platform_docs_rag"
},
"confirmation": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"value": "not_required"
},
{
"precedence": "inference_or_default",
"selected": false,
"source": "risk-default",
"value": "not_required"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"value": "not_required"
},
"description": {
"candidates": [
{
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "按关键词搜索 open.dingtalk.com 文档,支持分页。默认表格输出,可用 -f json 获取完整响应。"
},
{
"precedence": "mcp_metadata",
"selected": false,
"source": "mcp_metadata",
"value": "根据关键词搜索钉钉开放平台的开发文档,返回匹配的文档条目列表,包含标题、摘要、文档链接和相关标签。搜索结果按相关性排序。适用于开发者在集成或调试过程中快速查找 API 说明、接入指南、错误码解释等技术资料。"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "按关键词搜索 open.dingtalk.com 文档,支持分页。默认表格输出,可用 -f json 获取完整响应。"
},
"effect": {
"candidates": [
{
"precedence": "inference_or_default",
"selected": true,
"source": "command-verb",
"value": "read"
}
],
"precedence": "inference_or_default",
"resolution": "highest_precedence",
"source": "command-verb",
"value": "read"
},
"examples": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": true,
"source": "internal/cli/schema_hints/selection/devdoc.json",
"value": [
"dws devdoc article search --query \"OAuth2 接入\" --format json",
"dws devdoc article search --query \"errcode 40078\" --format json"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/devdoc.json",
"value": [
"dws devdoc article search --query \"OAuth2 接入\" --format json",
"dws devdoc article search --query \"errcode 40078\" --format json"
]
},
"idempotency": {
"candidates": [
{
"precedence": "inference_or_default",
"selected": true,
"source": "effect-default",
"value": "idempotent"
}
],
"precedence": "inference_or_default",
"resolution": "highest_precedence",
"source": "effect-default",
"value": "idempotent"
},
"interface_mode": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"value": "mcp"
},
{
"precedence": "mcp_fallback",
"selected": false,
"source": "internal/cli/schema_mcp_metadata.json#tools.devdoc.search_open_platform_docs_rag.interface_ref",
"value": "mcp"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"value": "mcp"
},
"interface_ref": {
"candidates": [
{
"precedence": "imported",
"selected": true,
"source": "internal/cli/schema_hints/imported/wukong.json",
"value": {
"product_id": "devdoc",
"rpc_name": "search_open_platform_docs"
}
},
{
"precedence": "mcp_fallback",
"selected": false,
"source": "internal/cli/schema_mcp_metadata.json#tools.devdoc.search_open_platform_docs_rag.interface_ref",
"value": "devdoc.search_open_platform_docs"
}
],
"precedence": "imported",
"resolution": "highest_precedence",
"source": "internal/cli/schema_hints/imported/wukong.json",
"value": {
"product_id": "devdoc",
"rpc_name": "search_open_platform_docs"
}
},
"metadata_source": {
"candidates": [
{
"precedence": "derived_resolution",
"selected": true,
"source": "metadata_source_resolution",
"value": "embedded-mcp-metadata"
}
],
"precedence": "derived_resolution",
"resolution": "highest_precedence",
"source": "metadata_source_resolution",
"value": "embedded-mcp-metadata"
},
"reviewed": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"value": true
},
{
"precedence": "reviewed_explicit",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": false,
"source": "internal/cli/schema_hints/selection/devdoc.json",
"value": true
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"value": true
},
"risk": {
"candidates": [
{
"precedence": "inference_or_default",
"selected": true,
"source": "effect-default",
"value": "low"
}
],
"precedence": "inference_or_default",
"resolution": "highest_precedence",
"source": "effect-default",
"value": "low"
},
"title": {
"candidates": [
{
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "搜索开放平台文档"
},
{
"precedence": "mcp_metadata",
"selected": false,
"source": "mcp_metadata",
"value": "search_open_platform_docs"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "搜索开放平台文档"
},
"use_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": true,
"source": "internal/cli/schema_hints/selection/devdoc.json",
"value": [
"查 OpenAPI、字段、错误码、OAuth2、接入指南等开放平台开发问题"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/devdoc.json",
"value": [
"查 OpenAPI、字段、错误码、OAuth2、接入指南等开放平台开发问题"
]
}
},
"group": "article",
"has_parameters": true,
"idempotency": "idempotent",
"interface_mode": "mcp",
"interface_ref": {
"product_id": "devdoc",
"rpc_name": "search_open_platform_docs"
},
"is_alias": false,
"metadata_source": "embedded-mcp-metadata",
"name": "search_open_platform_docs_rag",
"parameter_count": 3,
"parameters": {
"page": {
"default": "1",
"description": "页码,默认 1",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "页码,默认 1"
},
{
"precedence": "mcp_metadata",
"selected": false,
"source": "mcp_metadata",
"value": "分页页码"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "页码,默认 1"
},
"interface_type": {
"candidates": [
{
"precedence": "mcp_metadata",
"selected": true,
"source": "mcp_metadata",
"value": "number"
},
{
"precedence": "fallback",
"selected": false,
"source": "cobra_flag_type",
"value": "string"
}
],
"precedence": "mcp_metadata",
"resolution": "highest_precedence",
"source": "mcp_metadata",
"value": "number"
},
"property": {
"candidates": [
{
"precedence": "inference",
"selected": true,
"source": "flag_name_inference",
"value": "page"
}
],
"precedence": "inference",
"resolution": "highest_precedence",
"source": "flag_name_inference",
"value": "page"
},
"required": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_nonzero_default",
"value": false
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": false
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_nonzero_default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "string"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "string"
}
},
"interface_description": "分页页码",
"interface_type": "number",
"property": "page",
"required": false,
"type": "string"
},
"query": {
"description": "搜索关键词 (必填)",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "搜索关键词 (必填)"
},
{
"precedence": "mcp_metadata",
"selected": false,
"source": "mcp_metadata",
"value": "搜索关键词"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "搜索关键词 (必填)"
},
"property": {
"candidates": [
{
"precedence": "versioned_binding",
"selected": true,
"source": "versioned_parameter_binding",
"value": "keyword"
},
{
"precedence": "inference",
"selected": false,
"source": "flag_name_inference",
"value": "query"
}
],
"precedence": "versioned_binding",
"resolution": "highest_precedence",
"source": "versioned_parameter_binding",
"value": "keyword"
},
"required": {
"candidates": [
{
"precedence": "reviewed_manual",
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
"selected": true,
"source": "reviewed_manual_hint",
"value": false
},
{
"precedence": "command_constraint",
"selected": false,
"source": "require_one_of_constraint",
"value": false
},
{
"precedence": "mcp_metadata",
"selected": false,
"source": "mcp_metadata",
"value": true
},
{
"precedence": "inference",
"selected": false,
"source": "usage_required_inference",
"value": true
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": false
}
],
"precedence": "reviewed_manual",
"resolution": "highest_precedence",
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
"source": "reviewed_manual_hint",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "string"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "string"
}
},
"interface_description": "搜索关键词",
"property": "keyword",
"required": false,
"type": "string"
},
"size": {
"default": "10",
"description": "每页数量,默认 10",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "每页数量,默认 10"
},
{
"precedence": "mcp_metadata",
"selected": false,
"source": "mcp_metadata",
"value": "分页大小"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "每页数量,默认 10"
},
"interface_type": {
"candidates": [
{
"precedence": "mcp_metadata",
"selected": true,
"source": "mcp_metadata",
"value": "number"
},
{
"precedence": "fallback",
"selected": false,
"source": "cobra_flag_type",
"value": "string"
}
],
"precedence": "mcp_metadata",
"resolution": "highest_precedence",
"source": "mcp_metadata",
"value": "number"
},
"property": {
"candidates": [
{
"precedence": "inference",
"selected": true,
"source": "flag_name_inference",
"value": "size"
}
],
"precedence": "inference",
"resolution": "highest_precedence",
"source": "flag_name_inference",
"value": "size"
},
"required": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_nonzero_default",
"value": false
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": false
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_nonzero_default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "string"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "string"
}
},
"interface_description": "分页大小",
"interface_type": "number",
"property": "size",
"required": false,
"type": "string"
}
},
"path": "devdoc.search_open_platform_docs_rag",
"positionals": [
{
"description": "搜索关键词;也可通过 --query 传入",
"index": 0,
"name": "keyword",
"required": false,
"type": "string"
}
],
"primary_cli_path": "devdoc article search",
"product_id": "devdoc",
"reviewed": true,
"risk": "low",
"source": "reviewed_command_registry",
"title": "搜索开放平台文档",
"use_when": [
"查 OpenAPI、字段、错误码、OAuth2、接入指南等开放平台开发问题"
]
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+360
View File
@@ -0,0 +1,360 @@
{
"product": "live",
"tools": {
"live.get_my_lives": {
"agent_metadata_source": "embedded-skill-metadata",
"agent_source_refs": [
"CommandRegistry:canonical_path=live.get_my_lives",
"Skill:skills/multi/dingtalk-live/SKILL.md",
"cobra-help:dws live stream list",
"dws-wukong-envelope@4574f7022c32:dws-wukong-discovery.live.prod.json#live.get_my_lives",
"internal/cli/schema_command_registry.json#live.get_my_lives",
"internal/cli/schema_hints/imported/wukong.json",
"internal/cli/schema_hints/metadata/live.json",
"internal/cli/schema_hints/selection/live.json",
"internal/cli/schema_mcp_metadata.json#tools.live.get_my_lives",
"live-dws-schema:live.get_my_lives",
"skills/mono/references/products/live.md",
"structured-hint:internal/cli/schema_hints/imported/wukong.json#live.get_my_lives",
"structured-hint:internal/cli/schema_hints/selection-review.json#live.get_my_lives"
],
"agent_summary": "查看当前用户发起的直播列表与基础统计",
"agent_summary_source": "dws-agent-selection/live",
"availability": "available",
"avoid_when": [
"需要创建/开播/结束直播时不要使用;当前公开面仅列表查询"
],
"canonical_path": "live.get_my_lives",
"cli_name": "list",
"cli_path": "live stream list",
"confirmation": "not_required",
"description": "查看我的直播列表",
"display": "直播列表 / 信息",
"effect": "read",
"effect_source": "command-verb",
"examples": [
"dws live stream list"
],
"field_provenance": {
"agent_summary": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": true,
"source": "internal/cli/schema_hints/selection/live.json",
"value": "查看当前用户发起的直播列表与基础统计"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/live.json",
"value": "查看当前用户发起的直播列表与基础统计"
},
"availability": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "dws-tool-metadata/live marks this tool as reviewed",
"selected": true,
"source": "internal/cli/schema_hints/metadata/live.json",
"value": "available"
},
{
"precedence": "mcp_fallback",
"selected": false,
"source": "internal/cli/schema_mcp_metadata.json#tools.live.get_my_lives.interface_ref",
"value": "available"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/live marks this tool as reviewed",
"source": "internal/cli/schema_hints/metadata/live.json",
"value": "available"
},
"avoid_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": true,
"source": "internal/cli/schema_hints/selection/live.json",
"value": [
"需要创建/开播/结束直播时不要使用;当前公开面仅列表查询"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/live.json",
"value": [
"需要创建/开播/结束直播时不要使用;当前公开面仅列表查询"
]
},
"canonical_path": {
"candidates": [
{
"precedence": "command_registry",
"selected": true,
"source": "reviewed_command_registry",
"source_ref": "live stream list",
"value": "live.get_my_lives"
}
],
"precedence": "command_registry",
"resolution": "registry_identity",
"source": "reviewed_command_registry",
"source_ref": "live stream list",
"value": "live.get_my_lives"
},
"confirmation": {
"candidates": [
{
"precedence": "inference_or_default",
"selected": true,
"source": "risk-default",
"value": "not_required"
}
],
"precedence": "inference_or_default",
"resolution": "highest_precedence",
"source": "risk-default",
"value": "not_required"
},
"description": {
"candidates": [
{
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "查看我的直播列表"
},
{
"precedence": "mcp_metadata",
"selected": false,
"source": "mcp_metadata",
"value": "获取当前用户发起的直播列表,包含每场直播的基础信息与统计数据。返回字段包括:直播标题、封面图 URL、直播 ID、主播昵称、开始/结束时间戳、播放时长(毫秒)、观看量(UV)及直播状态(如已结束)。数据按分页返回,支持判断是否还有更多内容。适用于个人直播管理、历史记录查看等场景,仅展示当前用户有权限访问的直播内容。"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "查看我的直播列表"
},
"effect": {
"candidates": [
{
"precedence": "inference_or_default",
"selected": true,
"source": "command-verb",
"value": "read"
}
],
"precedence": "inference_or_default",
"resolution": "highest_precedence",
"source": "command-verb",
"value": "read"
},
"examples": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": true,
"source": "internal/cli/schema_hints/selection/live.json",
"value": [
"dws live stream list"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/live.json",
"value": [
"dws live stream list"
]
},
"idempotency": {
"candidates": [
{
"precedence": "inference_or_default",
"selected": true,
"source": "effect-default",
"value": "idempotent"
}
],
"precedence": "inference_or_default",
"resolution": "highest_precedence",
"source": "effect-default",
"value": "idempotent"
},
"interface_mode": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "dws-tool-metadata/live marks this tool as reviewed",
"selected": true,
"source": "internal/cli/schema_hints/metadata/live.json",
"value": "mcp"
},
{
"precedence": "mcp_fallback",
"selected": false,
"source": "internal/cli/schema_mcp_metadata.json#tools.live.get_my_lives.interface_ref",
"value": "mcp"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/live marks this tool as reviewed",
"source": "internal/cli/schema_hints/metadata/live.json",
"value": "mcp"
},
"interface_ref": {
"candidates": [
{
"precedence": "imported",
"selected": true,
"source": "internal/cli/schema_hints/imported/wukong.json",
"value": {
"product_id": "live",
"rpc_name": "get_my_lives"
}
},
{
"precedence": "mcp_fallback",
"selected": false,
"source": "internal/cli/schema_mcp_metadata.json#tools.live.get_my_lives.interface_ref",
"value": "live.get_my_lives"
}
],
"precedence": "imported",
"resolution": "highest_precedence",
"source": "internal/cli/schema_hints/imported/wukong.json",
"value": {
"product_id": "live",
"rpc_name": "get_my_lives"
}
},
"metadata_source": {
"candidates": [
{
"precedence": "derived_resolution",
"selected": true,
"source": "metadata_source_resolution",
"value": "embedded-mcp-metadata"
}
],
"precedence": "derived_resolution",
"resolution": "highest_precedence",
"source": "metadata_source_resolution",
"value": "embedded-mcp-metadata"
},
"reviewed": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "dws-tool-metadata/live marks this tool as reviewed",
"selected": true,
"source": "internal/cli/schema_hints/metadata/live.json",
"value": true
},
{
"precedence": "reviewed_explicit",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": false,
"source": "internal/cli/schema_hints/selection/live.json",
"value": true
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/live marks this tool as reviewed",
"source": "internal/cli/schema_hints/metadata/live.json",
"value": true
},
"risk": {
"candidates": [
{
"precedence": "inference_or_default",
"selected": true,
"source": "effect-default",
"value": "low"
}
],
"precedence": "inference_or_default",
"resolution": "highest_precedence",
"source": "effect-default",
"value": "low"
},
"title": {
"candidates": [
{
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "查看我的直播列表"
},
{
"precedence": "mcp_metadata",
"selected": false,
"source": "mcp_metadata",
"value": "获取当前用户发起的直播列表"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "查看我的直播列表"
},
"use_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": true,
"source": "internal/cli/schema_hints/selection/live.json",
"value": [
"用户要看自己发起过的直播、状态或观看量等列表信息"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/live.json",
"value": [
"用户要看自己发起过的直播、状态或观看量等列表信息"
]
}
},
"group": "stream",
"has_parameters": false,
"idempotency": "idempotent",
"interface_mode": "mcp",
"interface_ref": {
"product_id": "live",
"rpc_name": "get_my_lives"
},
"is_alias": false,
"metadata_source": "embedded-mcp-metadata",
"name": "get_my_lives",
"parameter_count": 0,
"parameters": {},
"path": "live.get_my_lives",
"primary_cli_path": "live stream list",
"product_id": "live",
"reviewed": true,
"risk": "low",
"source": "reviewed_command_registry",
"title": "查看我的直播列表",
"use_when": [
"用户要看自己发起过的直播、状态或观看量等列表信息"
]
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+337
View File
@@ -0,0 +1,337 @@
{
"product": "mcp",
"tools": {
"mcp.url_get": {
"agent_metadata_source": "embedded-skill-metadata",
"agent_source_refs": [
"cobra-help:dws mcp url get --help",
"internal/app/mcp_url_command.go",
"internal/cli/schema_command_registry.json#mcp.url_get",
"internal/cli/schema_hints/metadata/mcp.json",
"internal/cli/schema_hints/selection/mcp.json",
"pkg/edition/default.go#openSupplementServers"
],
"agent_summary": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址",
"agent_summary_source": "dws-agent-selection/mcp",
"availability": "available",
"avoid_when": [
"只是查询 DWS 已公开命令或参数时使用 dws schema",
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播"
],
"canonical_path": "mcp.url_get",
"cli_name": "get",
"cli_path": "mcp url get",
"confirmation": "not_required",
"description": "输入 MCP 市场 mcpId,返回以当前用户和组织身份访问该 MCP 的 Streamable HTTP 服务地址。\n\n安全提示:返回的 mcpURL 和 mcpJSON 可能包含身份凭据,仅限个人使用,请勿分享到群聊、文档、邮件、代码仓库或日志。",
"display": "管理 MCP 服务连接信息",
"effect": "read",
"effect_source": "agent-hint",
"examples": [
"dws mcp url get 10043 --format json"
],
"field_provenance": {
"agent_summary": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"selected": true,
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址"
},
"availability": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "available"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "available"
},
"avoid_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"selected": true,
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": [
"只是查询 DWS 已公开命令或参数时使用 dws schema",
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": [
"只是查询 DWS 已公开命令或参数时使用 dws schema",
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播"
]
},
"canonical_path": {
"candidates": [
{
"precedence": "command_registry",
"selected": true,
"source": "reviewed_command_registry",
"source_ref": "mcp url get",
"value": "mcp.url_get"
}
],
"precedence": "command_registry",
"resolution": "registry_identity",
"source": "reviewed_command_registry",
"source_ref": "mcp url get",
"value": "mcp.url_get"
},
"confirmation": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "not_required"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "not_required"
},
"description": {
"candidates": [
{
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "输入 MCP 市场 mcpId,返回以当前用户和组织身份访问该 MCP 的 Streamable HTTP 服务地址。\n\n安全提示:返回的 mcpURL 和 mcpJSON 可能包含身份凭据,仅限个人使用,请勿分享到群聊、文档、邮件、代码仓库或日志。"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "输入 MCP 市场 mcpId,返回以当前用户和组织身份访问该 MCP 的 Streamable HTTP 服务地址。\n\n安全提示:返回的 mcpURL 和 mcpJSON 可能包含身份凭据,仅限个人使用,请勿分享到群聊、文档、邮件、代码仓库或日志。"
},
"effect": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "read"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "read"
},
"examples": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"selected": true,
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": [
"dws mcp url get 10043 --format json"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": [
"dws mcp url get 10043 --format json"
]
},
"idempotency": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "idempotent"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "idempotent"
},
"interface_mode": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "composite"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "composite"
},
"interface_reason": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata."
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata."
},
"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/mcp.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/mcp.json",
"value": null
},
"reviewed": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": true
},
{
"precedence": "reviewed_explicit",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"selected": false,
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": true
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": true
},
"risk": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "medium"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"value": "medium"
},
"title": {
"candidates": [
{
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "按 mcpId 获取 MCP 的 Streamable HTTP 服务地址"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "按 mcpId 获取 MCP 的 Streamable HTTP 服务地址"
},
"use_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"selected": true,
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": [
"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"source": "internal/cli/schema_hints/selection/mcp.json",
"value": [
"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"
]
}
},
"group": "url",
"has_parameters": false,
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata.",
"is_alias": false,
"name": "url_get",
"parameter_count": 0,
"parameters": {},
"path": "mcp.url_get",
"positionals": [
{
"description": "钉钉 MCP 市场中的 mcpId",
"index": 0,
"name": "mcp_id",
"required": true,
"type": "string"
}
],
"primary_cli_path": "mcp url get",
"product_id": "mcp",
"reviewed": true,
"risk": "medium",
"source": "reviewed_command_registry",
"title": "按 mcpId 获取 MCP 的 Streamable HTTP 服务地址",
"use_when": [
"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"
]
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+373
View File
@@ -0,0 +1,373 @@
// 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 (
"encoding/json"
"fmt"
"sort"
"strings"
)
// schema_catalog.json is generated, but every command entry it delivers to
// Agents must follow one unified closed structure: a fixed required core plus
// a whitelisted optional set. Any new field must be registered here first,
// which keeps the delivered command data structure uniform across products.
// Command identity (names/paths) is owned by schema_command_registry.json and
// is deliberately out of scope for this validator.
const schemaCatalogStructureMaxViolations = 25
// schemaCatalogToolRequiredKeys is the required core present on every tool
// entry. Keep sorted; ValidateCatalogStructure rejects entries missing any of
// these.
var schemaCatalogToolRequiredKeys = []string{
"agent_metadata_source",
"agent_source_refs",
"agent_summary",
"agent_summary_source",
"availability",
"avoid_when",
"canonical_path",
"cli_name",
"cli_path",
"confirmation",
"description",
"display",
"effect",
"effect_source",
"examples",
"field_provenance",
"has_parameters",
"idempotency",
"interface_mode",
"is_alias",
"name",
"parameter_count",
"parameters",
"path",
"primary_cli_path",
"product_id",
"reviewed",
"risk",
"source",
"title",
"use_when",
}
// schemaCatalogToolOptionalKeys is the optional whitelist. interface_ref and
// interface_reason are mutually exclusive and gated by interface_mode.
var schemaCatalogToolOptionalKeys = []string{
"aliases",
"constraints",
"dry_run",
"group",
"interface_reason",
"interface_ref",
"metadata_source",
"positionals",
}
var schemaCatalogToolEnums = map[string][]string{
"effect": {"read", "write", "destructive"},
"risk": {"low", "medium", "high"},
"confirmation": {"not_required", "user_required"},
"interface_mode": {InterfaceModeMCP, InterfaceModeComposite, InterfaceModeLocal},
"availability": {InterfaceAvailable, InterfaceUnavailable},
}
// schemaCatalogParamRequiredKeys is the required core of every parameter.
var schemaCatalogParamRequiredKeys = []string{
"description",
"field_provenance",
"required",
"type",
}
// schemaCatalogParamOptionalKeys is the parameter optional whitelist.
var schemaCatalogParamOptionalKeys = []string{
"cli_required",
"default",
"enum",
"example",
"format",
"interface_description",
"interface_type",
"property",
"required_when",
}
var schemaCatalogParamTypes = []string{"string", "integer", "number", "boolean", "array", "object"}
type schemaCatalogStructureViolation struct {
tool string
message string
}
// ValidateCatalogStructure checks that every tool entry in a schema_catalog
// snapshot conforms to the unified command data structure. It returns an
// error aggregating up to schemaCatalogStructureMaxViolations violations.
func ValidateCatalogStructure(data []byte) error {
var snapshot struct {
Version int `json:"version"`
Tools map[string]map[string]any `json:"tools"`
}
if err := json.Unmarshal(data, &snapshot); err != nil {
return fmt.Errorf("decode schema catalog: %w", err)
}
if snapshot.Version != SchemaCatalogSnapshotVersion {
return fmt.Errorf("schema catalog version = %d, want %d", snapshot.Version, SchemaCatalogSnapshotVersion)
}
if len(snapshot.Tools) == 0 {
return fmt.Errorf("schema catalog has no tools")
}
violations := []schemaCatalogStructureViolation{}
for toolID, entry := range snapshot.Tools {
validateCatalogToolEntry(toolID, entry, &violations)
}
if len(violations) == 0 {
return nil
}
sort.Slice(violations, func(i, j int) bool {
if violations[i].tool != violations[j].tool {
return violations[i].tool < violations[j].tool
}
return violations[i].message < violations[j].message
})
var b strings.Builder
shown := violations
truncated := 0
if len(shown) > schemaCatalogStructureMaxViolations {
truncated = len(shown) - schemaCatalogStructureMaxViolations
shown = shown[:schemaCatalogStructureMaxViolations]
}
for _, v := range shown {
fmt.Fprintf(&b, "\n %s: %s", v.tool, v.message)
}
if truncated > 0 {
fmt.Fprintf(&b, "\n ... and %d more violations", truncated)
}
return fmt.Errorf("schema catalog entries violate the unified command structure (%d total):%s", len(violations), b.String())
}
func validateCatalogToolEntry(toolID string, entry map[string]any, violations *[]schemaCatalogStructureViolation) {
report := func(format string, args ...any) {
*violations = append(*violations, schemaCatalogStructureViolation{
tool: toolID,
message: fmt.Sprintf(format, args...),
})
}
allowed := make(map[string]bool, len(schemaCatalogToolRequiredKeys)+len(schemaCatalogToolOptionalKeys))
for _, k := range schemaCatalogToolRequiredKeys {
allowed[k] = true
}
for _, k := range schemaCatalogToolOptionalKeys {
allowed[k] = true
}
for key := range entry {
if !allowed[key] {
report("unknown field %q (register it in schema_catalog_structure.go first)", key)
}
}
stringKeys := []string{
"agent_metadata_source", "agent_summary", "agent_summary_source",
"availability", "canonical_path", "cli_name", "cli_path", "confirmation",
"description", "display", "effect", "effect_source", "idempotency",
"interface_mode", "name", "path", "primary_cli_path", "product_id",
"risk", "source", "title",
}
for _, key := range stringKeys {
if !requireNonEmptyString(entry, key) {
report("field %q must be a non-empty string", key)
}
}
stringArrayKeys := []string{"agent_source_refs", "avoid_when", "examples", "use_when"}
for _, key := range stringArrayKeys {
if !requireStringArray(entry, key) {
report("field %q must be an array of strings", key)
}
}
for _, key := range []string{"has_parameters", "is_alias", "reviewed"} {
if _, ok := entry[key].(bool); !ok {
report("field %q must be a boolean", key)
}
}
if _, ok := entry["field_provenance"].(map[string]any); !ok {
report("field %q must be an object", "field_provenance")
}
for field, values := range schemaCatalogToolEnums {
raw, ok := entry[field].(string)
if !ok {
continue // already reported as missing/typed above
}
if !stringSliceContains(values, raw) {
report("field %q = %q, want one of {%s}", field, raw, strings.Join(values, ", "))
}
}
parameters, paramsOK := entry["parameters"].(map[string]any)
if !paramsOK {
report("field %q must be an object", "parameters")
parameters = nil
}
count, countOK := entry["parameter_count"].(float64)
if !countOK {
report("field %q must be a number", "parameter_count")
} else if paramsOK && int(count) != len(parameters) {
report("parameter_count = %d, want %d (len(parameters))", int(count), len(parameters))
}
if has, ok := entry["has_parameters"].(bool); ok && paramsOK && has != (len(parameters) > 0) {
report("has_parameters = %v, want %v (len(parameters) > 0)", has, len(parameters) > 0)
}
validateCatalogInterface(toolID, entry, violations)
for paramName, raw := range parameters {
param, ok := raw.(map[string]any)
if !ok {
report("parameter %q must be an object", paramName)
continue
}
validateCatalogParam(toolID, paramName, param, violations)
}
}
func validateCatalogInterface(toolID string, entry map[string]any, violations *[]schemaCatalogStructureViolation) {
report := func(format string, args ...any) {
*violations = append(*violations, schemaCatalogStructureViolation{
tool: toolID,
message: fmt.Sprintf(format, args...),
})
}
mode, _ := entry["interface_mode"].(string)
ref, hasRef := entry["interface_ref"]
reason, _ := entry["interface_reason"].(string)
switch mode {
case InterfaceModeMCP:
if !hasRef {
report("interface_mode=mcp requires interface_ref")
return
}
refObj, ok := ref.(map[string]any)
if !ok {
report("interface_ref must be an object")
return
}
for _, key := range []string{"product_id", "rpc_name"} {
if s, ok := refObj[key].(string); !ok || strings.TrimSpace(s) == "" {
report("interface_ref.%s must be a non-empty string", key)
}
}
if strings.TrimSpace(reason) != "" {
report("interface_mode=mcp must not set interface_reason")
}
case InterfaceModeComposite, InterfaceModeLocal:
if hasRef {
report("interface_mode=%s must not set interface_ref", mode)
}
if strings.TrimSpace(reason) == "" {
report("interface_mode=%s requires a non-empty interface_reason", mode)
}
}
}
func validateCatalogParam(toolID, paramName string, param map[string]any, violations *[]schemaCatalogStructureViolation) {
report := func(format string, args ...any) {
*violations = append(*violations, schemaCatalogStructureViolation{
tool: toolID,
message: fmt.Sprintf(format, args...),
})
}
allowed := make(map[string]bool, len(schemaCatalogParamRequiredKeys)+len(schemaCatalogParamOptionalKeys))
for _, k := range schemaCatalogParamRequiredKeys {
allowed[k] = true
}
for _, k := range schemaCatalogParamOptionalKeys {
allowed[k] = true
}
for key := range param {
if !allowed[key] {
report("parameter %q: unknown field %q", paramName, key)
}
}
typ, _ := param["type"].(string)
if !stringSliceContains(schemaCatalogParamTypes, typ) {
report("parameter %q: type = %q, want one of {%s}", paramName, typ, strings.Join(schemaCatalogParamTypes, ", "))
}
if s, ok := param["description"].(string); !ok || strings.TrimSpace(s) == "" {
report("parameter %q: description must be a non-empty string", paramName)
}
if _, ok := param["required"].(bool); !ok {
report("parameter %q: required must be a boolean", paramName)
}
if _, ok := param["field_provenance"].(map[string]any); !ok {
report("parameter %q: field_provenance must be an object", paramName)
}
for _, key := range []string{"property", "interface_description", "interface_type", "format", "default", "example", "required_when"} {
if raw, present := param[key]; present {
if _, ok := raw.(string); !ok {
report("parameter %q: %s must be a string", paramName, key)
}
}
}
if raw, present := param["cli_required"]; present {
if _, ok := raw.(bool); !ok {
report("parameter %q: cli_required must be a boolean", paramName)
}
}
if raw, present := param["enum"]; present {
items, ok := raw.([]any)
if !ok {
report("parameter %q: enum must be an array", paramName)
} else {
for i, item := range items {
if _, ok := item.(string); !ok {
report("parameter %q: enum[%d] must be a string", paramName, i)
}
}
}
}
}
func requireNonEmptyString(entry map[string]any, key string) bool {
s, ok := entry[key].(string)
return ok && strings.TrimSpace(s) != ""
}
func requireStringArray(entry map[string]any, key string) bool {
raw, ok := entry[key].([]any)
if !ok {
return false
}
for _, item := range raw {
if _, ok := item.(string); !ok {
return false
}
}
return true
}
func stringSliceContains(values []string, target string) bool {
for _, v := range values {
if v == target {
return true
}
}
return false
}
@@ -0,0 +1,358 @@
// 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 (
"encoding/json"
"fmt"
"strings"
"testing"
)
// TestEmbeddedSchemaCatalogStructure gates the delivered catalog: every tool
// entry must conform to the unified closed structure. If this fails after
// regeneration, either fix the generator inputs or deliberately extend the
// whitelist in schema_catalog_structure.go.
func TestEmbeddedSchemaCatalogStructure(t *testing.T) {
// After PR #656 the catalog is embedded as per-product shards, not a single
// JSON file. The loaded snapshot is the reassembled result — serialize it
// back to JSON and validate the same closed structure.
loaded := embeddedSchemaCatalog()
data, err := json.Marshal(loaded.Snapshot)
if err != nil {
t.Fatalf("marshal embedded catalog snapshot: %v", err)
}
if err := ValidateCatalogStructure(data); err != nil {
t.Fatalf("embedded schema catalog violates the unified command structure: %v", err)
}
}
func validCatalogToolEntry() map[string]any {
return map[string]any{
"agent_metadata_source": "embedded-skill-metadata",
"agent_source_refs": []any{"skills/mono/references/products/aitable.md"},
"agent_summary": "创建多维表格记录",
"agent_summary_source": "dws-agent-selection/aitable",
"availability": "available",
"avoid_when": []any{"批量更新时请用 record update"},
"canonical_path": "aitable.record_create",
"cli_name": "create",
"cli_path": "aitable record create",
"confirmation": "not_required",
"description": "在数据表中创建一条或多条记录。",
"display": "多维表格",
"effect": "write",
"effect_source": "command-verb",
"examples": []any{"dws aitable record create --base-id x"},
"field_provenance": map[string]any{"effect": map[string]any{"precedence": "reviewed_explicit"}},
"has_parameters": true,
"idempotency": "non_idempotent",
"interface_mode": "mcp",
"interface_ref": map[string]any{"product_id": "aitable", "rpc_name": "create_records"},
"is_alias": false,
"name": "record create",
"parameter_count": float64(1),
"parameters": map[string]any{
"base-id": map[string]any{
"type": "string",
"description": "目标多维表格 ID。",
"required": true,
"field_provenance": map[string]any{"description": map[string]any{"precedence": "reviewed_explicit"}},
},
},
"path": "aitable.record_create",
"primary_cli_path": "aitable record create",
"product_id": "aitable",
"reviewed": true,
"risk": "medium",
"source": "cobra+registry",
"title": "创建记录",
"use_when": []any{"需要向多维表格写入新记录时"},
}
}
func catalogPayload(t *testing.T, entry map[string]any) []byte {
t.Helper()
payload, err := json.Marshal(map[string]any{
"version": SchemaCatalogSnapshotVersion,
"tools": map[string]any{"aitable.record_create": entry},
})
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
return payload
}
func TestValidateCatalogStructureAcceptsValidEntry(t *testing.T) {
if err := ValidateCatalogStructure(catalogPayload(t, validCatalogToolEntry())); err != nil {
t.Fatalf("ValidateCatalogStructure() error = %v", err)
}
}
func TestValidateCatalogStructureRejectsViolations(t *testing.T) {
cases := []struct {
name string
mutate func(entry map[string]any)
want string
}{
{
name: "unknown tool field",
mutate: func(e map[string]any) { e["surprise"] = "x" },
want: `unknown field "surprise"`,
},
{
name: "missing required core field",
mutate: func(e map[string]any) { delete(e, "agent_summary") },
want: `"agent_summary" must be a non-empty string`,
},
{
name: "bad effect enum",
mutate: func(e map[string]any) { e["effect"] = "mutate" },
want: `"effect" = "mutate"`,
},
{
// 非字符串枚举字段:类型错误由字符串校验报告,枚举循环跳过。
name: "non-string enum field skips enum check",
mutate: func(e map[string]any) { e["effect"] = float64(1) },
want: `"effect" must be a non-empty string`,
},
{
name: "mcp without interface_ref",
mutate: func(e map[string]any) { delete(e, "interface_ref") },
want: "interface_mode=mcp requires interface_ref",
},
{
name: "composite without interface_reason",
mutate: func(e map[string]any) {
e["interface_mode"] = "composite"
delete(e, "interface_ref")
},
want: "interface_mode=composite requires a non-empty interface_reason",
},
{
name: "composite must not keep interface_ref",
mutate: func(e map[string]any) {
e["interface_mode"] = "composite"
e["interface_reason"] = "本地组合多个 MCP 调用"
},
want: "interface_mode=composite must not set interface_ref",
},
{
name: "parameter_count mismatch",
mutate: func(e map[string]any) { e["parameter_count"] = float64(2) },
want: "parameter_count = 2, want 1",
},
{
name: "has_parameters mismatch",
mutate: func(e map[string]any) { e["has_parameters"] = false },
want: "has_parameters = false, want true",
},
{
name: "param missing required",
mutate: func(e map[string]any) {
params := e["parameters"].(map[string]any)
delete(params["base-id"].(map[string]any), "required")
},
want: `parameter "base-id": required must be a boolean`,
},
{
name: "param unknown field",
mutate: func(e map[string]any) {
params := e["parameters"].(map[string]any)
params["base-id"].(map[string]any)["mystery"] = true
},
want: `parameter "base-id": unknown field "mystery"`,
},
{
name: "param bad type enum",
mutate: func(e map[string]any) {
params := e["parameters"].(map[string]any)
params["base-id"].(map[string]any)["type"] = "text"
},
want: `parameter "base-id": type = "text"`,
},
{
name: "param default must be string",
mutate: func(e map[string]any) {
params := e["parameters"].(map[string]any)
params["base-id"].(map[string]any)["default"] = float64(3)
},
want: `parameter "base-id": default must be a string`,
},
{
name: "reviewed must be bool",
mutate: func(e map[string]any) { e["reviewed"] = "yes" },
want: `"reviewed" must be a boolean`,
},
{
name: "examples must be string array",
mutate: func(e map[string]any) { e["examples"] = []any{"ok", float64(1)} },
want: `"examples" must be an array of strings`,
},
{
name: "use_when must be an array at all",
mutate: func(e map[string]any) { e["use_when"] = "单个字符串" },
want: `"use_when" must be an array of strings`,
},
{
name: "field_provenance must be object",
mutate: func(e map[string]any) { e["field_provenance"] = "not-an-object" },
want: `"field_provenance" must be an object`,
},
{
name: "parameters must be object",
mutate: func(e map[string]any) { e["parameters"] = []any{"x"} },
want: `"parameters" must be an object`,
},
{
name: "parameter_count must be number",
mutate: func(e map[string]any) { e["parameter_count"] = "1" },
want: `"parameter_count" must be a number`,
},
{
name: "parameter entry must be object",
mutate: func(e map[string]any) {
e["parameters"] = map[string]any{"base-id": "not-an-object"}
e["parameter_count"] = float64(1)
},
want: `parameter "base-id" must be an object`,
},
{
name: "interface_ref must be object",
mutate: func(e map[string]any) { e["interface_ref"] = "aitable.create_records" },
want: "interface_ref must be an object",
},
{
name: "interface_ref keys must be non-empty",
mutate: func(e map[string]any) {
e["interface_ref"] = map[string]any{"product_id": " ", "rpc_name": float64(1)}
},
want: "interface_ref.product_id must be a non-empty string",
},
{
name: "mcp must not set interface_reason",
mutate: func(e map[string]any) { e["interface_reason"] = "多余理由" },
want: "interface_mode=mcp must not set interface_reason",
},
{
name: "param description must be non-empty",
mutate: func(e map[string]any) {
params := e["parameters"].(map[string]any)
params["base-id"].(map[string]any)["description"] = " "
},
want: `parameter "base-id": description must be a non-empty string`,
},
{
name: "param field_provenance must be object",
mutate: func(e map[string]any) {
params := e["parameters"].(map[string]any)
params["base-id"].(map[string]any)["field_provenance"] = "x"
},
want: `parameter "base-id": field_provenance must be an object`,
},
{
name: "param cli_required must be bool",
mutate: func(e map[string]any) {
params := e["parameters"].(map[string]any)
params["base-id"].(map[string]any)["cli_required"] = "yes"
},
want: `parameter "base-id": cli_required must be a boolean`,
},
{
name: "param enum must be array",
mutate: func(e map[string]any) {
params := e["parameters"].(map[string]any)
params["base-id"].(map[string]any)["enum"] = "A,B"
},
want: `parameter "base-id": enum must be an array`,
},
{
name: "param enum items must be strings",
mutate: func(e map[string]any) {
params := e["parameters"].(map[string]any)
params["base-id"].(map[string]any)["enum"] = []any{"A", float64(2)}
},
want: `parameter "base-id": enum[1] must be a string`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
entry := validCatalogToolEntry()
tc.mutate(entry)
err := ValidateCatalogStructure(catalogPayload(t, entry))
if err == nil {
t.Fatalf("ValidateCatalogStructure() = nil, want violation containing %q", tc.want)
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("ValidateCatalogStructure() error = %v, want substring %q", err, tc.want)
}
})
}
}
func TestValidateCatalogStructureRejectsBadEnvelope(t *testing.T) {
if err := ValidateCatalogStructure([]byte(`{"version":99,"tools":{}}`)); err == nil {
t.Fatal("ValidateCatalogStructure() = nil, want version error")
}
if err := ValidateCatalogStructure([]byte(`{"version":1,"tools":{}}`)); err == nil {
t.Fatal("ValidateCatalogStructure() = nil, want empty-tools error")
}
if err := ValidateCatalogStructure([]byte(`not json`)); err == nil {
t.Fatal("ValidateCatalogStructure() = nil, want decode error")
}
}
func TestValidateCatalogStructureSortsViolationsAcrossTools(t *testing.T) {
// 两个工具各制造两条违规,驱动排序比较器的 tool 与 message 两个分支。
bad1 := validCatalogToolEntry()
bad1["effect"] = "mutate"
bad1["risk"] = "extreme"
bad2 := validCatalogToolEntry()
bad2["canonical_path"] = "zz.last_tool"
bad2["effect"] = "mutate"
bad2["risk"] = "extreme"
payload, err := json.Marshal(map[string]any{
"version": SchemaCatalogSnapshotVersion,
"tools": map[string]any{"zz.last_tool": bad2, "aitable.record_create": bad1},
})
if err != nil {
t.Fatal(err)
}
verr := ValidateCatalogStructure(payload)
if verr == nil {
t.Fatal("ValidateCatalogStructure() = nil, want sorted violations")
}
msg := verr.Error()
if !strings.Contains(msg, "(4 total)") {
t.Fatalf("error = %v, want 4 total violations", verr)
}
if first, second := strings.Index(msg, "aitable.record_create"), strings.Index(msg, "zz.last_tool"); first < 0 || second < 0 || first > second {
t.Fatalf("violations not sorted by tool: %v", verr)
}
}
func TestValidateCatalogStructureTruncatesViolationList(t *testing.T) {
// 单工具制造超过 25 条违规,验证截断提示。
entry := validCatalogToolEntry()
for i := 0; i < 30; i++ {
entry[fmt.Sprintf("unknown_field_%02d", i)] = true
}
err := ValidateCatalogStructure(catalogPayload(t, entry))
if err == nil {
t.Fatal("ValidateCatalogStructure() = nil, want truncated violation list")
}
if !strings.Contains(err.Error(), "more violations") {
t.Fatalf("error = %v, want truncation marker", err)
}
}
+16 -4
View File
@@ -849,7 +849,13 @@ func TestAssemblerUsesReviewedCommandVisibility(t *testing.T) {
}
func TestCommandRegistrySourceValidationIsFailClosed(t *testing.T) {
registry, err := ValidateCommandRegistrySource(embeddedSchemaCommandRegistryJSON)
// After splitting registry into per-product shards, tests use the merged
// JSON (reassembled from shards) for byte-level operations.
merged, err := EmbeddedCommandRegistryMergedJSON()
if err != nil {
t.Fatalf("EmbeddedCommandRegistryMergedJSON() error = %v", err)
}
registry, err := ValidateCommandRegistrySource(merged)
if err != nil {
t.Fatalf("ValidateCommandRegistrySource(embedded) error = %v", err)
}
@@ -861,8 +867,9 @@ func TestCommandRegistrySourceValidationIsFailClosed(t *testing.T) {
t.Fatalf("embedded hash = %q, decoded hash = %q", hash, registry.SourceHash())
}
drifted := strings.Replace(string(embeddedSchemaCommandRegistryJSON), `"cli_path": "aisearch person"`, `"cli_path": "aisearch people"`, 1)
if drifted == string(embeddedSchemaCommandRegistryJSON) {
// Match both pretty-printed ("cli_path": "...") and compact ("cli_path":"...") JSON.
drifted := strings.Replace(string(merged), `"aisearch person"`, `"aisearch people"`, 1)
if drifted == string(merged) {
t.Fatal("test fixture did not mutate embedded registry")
}
_, err = ValidateCommandRegistrySource([]byte(drifted))
@@ -870,7 +877,12 @@ func TestCommandRegistrySourceValidationIsFailClosed(t *testing.T) {
t.Fatalf("drift error = %v", err)
}
unknownField := strings.Replace(string(embeddedSchemaCommandRegistryJSON), `"version": 1`, `"version": 1, "unreviewed": true`, 1)
// Test unknown-field rejection: inject an unrecognized top-level key.
unknownField := strings.Replace(string(merged), `"version":1`, `"version":1,"unreviewed":true`, 1)
if unknownField == string(merged) {
// Try with space (pretty-printed format).
unknownField = strings.Replace(string(merged), `"version": 1`, `"version": 1, "unreviewed": true`, 1)
}
_, err = ValidateCommandRegistrySource([]byte(unknownField))
if err == nil || !strings.Contains(err.Error(), "unknown field") {
t.Fatalf("unknown-field error = %v", err)

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