Compare commits

..
Author SHA1 Message Date
玉澜 41136b6d41 chore: nudge PR synchronize (CI did not pick up 746b7e40) 2026-07-31 22:57:37 +08:00
玉澜 746b7e403c fix(schema): restore merge-base contract parity for the corecmd migration
The ContractFinal assembly path dropped every non-declared parameter fact,
breaking the published Schema against the reviewed merge-base contract:

- merge pinned MCP parameter metadata and the reviewed in-code runtime hints
  back into contract_final parameter resolution (318 interface_type losses,
  calendar recurrence required/required_when regressions)
- restore devapp write risk to high and publish back to write/high; the
  migration silently downgraded 14 dev write tools and re-classified publish
  as destructive
- keep dev at-least-one checks as Validate hooks with the shipped wording
  instead of publishing new typed constraints; constraint publication is a
  contract change that belongs to its own reviewed PR (aitable annotations
  reverted for the same reason)
- align RunE escape hatch, BoolFlag shadowing, guard-first ConfirmFirst
  declaration, and Sheet target preflight with behavioral tests; drop the
  retired gen_schema_decls.py helper and fix corecmd naming in docs/CHANGELOG

check-authoritative-schema-compatibility vs origin/main: ok.
2026-07-31 22:15:45 +08:00
玉澜andCursor 86e34b5489 fix: gofmt aitable_schema_test.go so CI lint can proceed
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 19:28:57 +08:00
玉澜andCursor b64438d01c fix(corecmd): keep Validate and ConfirmSafety on the same RunE layer
PreRunE Validate was skipped by direct RunE / proxy calls. Run both hooks
in one wrapper (Validate first), add pat chmod Validate, let Sheet outer
guards call ContractValidate first, and assert declare user_required
leaves expose Validate, required flags, or CallTool-defer confirm.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 16:58:11 +08:00
玉澜andCursor 4943c6ff49 fix(corecmd): defer metadata ConfirmSafety until CallTool
Without Validate, DeclareLeafMetadata no longer confirms before RunE-local
required checks. Wrap deps.Caller so the first MCP CallTool runs
ConfirmSafety; Validate-backed leaves keep confirm-after-PreRunE.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 16:12:24 +08:00
玉澜andCursor f0fef85905 fix(corecmd): run metadata Validate before ConfirmSafety
DeclareLeafMetadata user_required wraps were confirming before RunE-local
checks, so illegal calls got confirmation_required instead of real errors.
Allow Validate on PreRunE, migrate event stop and drive publish checks, and
lock the Sheet dual-gate transitional state.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 15:46:48 +08:00
玉澜andCursor 7773eb27f1 refactor(corecmd): rename package and finish ContractFinal leaf migration
Move cmdcore to corecmd, declare attendance ContractFinal in helpers, keep
Sheet destructive commands --yes-only, and align catalog/policy provenance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 14:13:46 +08:00
玉澜 18e32ad09d fix(cmdcore): unify runtime and schema safety 2026-07-31 11:56:57 +08:00
玉澜andCursor a9857d94d7 refactor(schema): migrate selection/metadata into ContractFinal decls
Compile reviewed Agent Schema into bind-time Go declarations so catalog
tools stamp contract_final without changing execution bodies.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 08:30:53 +08:00
玉澜 461455b4fa fix: harden destructive dry-run validation 2026-07-31 01:19:51 +08:00
玉澜 f0d558a0d1 refactor(shortcut): route live commands through cmdcore 2026-07-31 00:21:33 +08:00
玉澜 3cf690e779 Merge remote-tracking branch 'origin/main' into agent/cmdcore-phase2
# Conflicts:
#	CHANGELOG.md
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
2026-07-30 23:43:21 +08:00
玉澜 340f01e95c refactor(cmdcore): align leaf safety with schema contract 2026-07-30 23:03:32 +08:00
github-actions[bot] 187787040b chore: update beta formula for v1.0.56-beta.2 [skip ci] 2026-07-30 15:00:34 +00:00
chichuan cd6e854bf1 Merge pull request #843 from DingTalk-Real-AI/codex/changelog-v1.0.56-beta.2-admission-final
docs(release): clarify v1.0.56-beta.2 skill routing
2026-07-30 22:48:59 +08:00
chichuan 61124f8768 docs(release): clarify v1.0.56-beta.2 skill routing 2026-07-30 22:44:52 +08:00
github-actions[bot] 6cfeac3179 Merge pull request #842 from DingTalk-Real-AI/codex/changelog-v1.0.56-beta.2-admission
docs(release): complete v1.0.56-beta.2 notes
2026-07-30 22:43:08 +08:00
chichuan acd293cc83 docs(release): complete v1.0.56-beta.2 notes 2026-07-30 22:40:59 +08:00
github-actions[bot] d2045c3441 Merge pull request #841 from DingTalk-Real-AI/codex/changelog-v1.0.56-beta.2
docs(release): seal v1.0.56-beta.2 changelog
2026-07-30 22:38:42 +08:00
chichuan 4de41c27c7 docs(release): add v1.0.56-beta.2 notes 2026-07-30 22:36:34 +08:00
github-actions[bot] 5df2860e66 Merge pull request #831 from wxianfeng/fix/agent-product-header-separation
fix: separate Agent Product from claw-type
2026-07-30 14:35:55 +00:00
chichuan f3390b6875 Merge branch 'main' into fix/agent-product-header-separation 2026-07-30 22:16:13 +08:00
玉澜andCursor 621229fca2 test(app): align example dry-run classifier and devapp safety gate with declared contracts
- manualAgentExampleDryRunEvidence now recognizes executor invocation
  envelopes ("kind": "*_invocation" / connect_preview with dry_run) as
  invocation previews before the generic request branch, so the 32 devapp
  declared tools match their declared preview_kind; pinned with a unit test
  covering all invocation kinds plus request/plan precedence.
- TestDevAppWriteGuardRequiresFinalSchemaConfirmation updates devapp wants
  to the declared risk grading (reversible writes medium; create/version
  create/robot submit high-write; delete/publish destructive) and accepts
  contract_final provenance for declared tools while hints-fed tools keep
  reviewed_explicit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 22:08:48 +08:00
github-actions[bot] 55f25d8d48 Merge pull request #835 from DingTalk-Real-AI/codex/skill-token-shallow-water
perf(skills): reduce common-path context loading
2026-07-30 14:04:51 +00:00
johnandClaude Opus 4.6 acfbd35aa2 docs: add command framework comparison (DWS vs lark-cli vs GWS)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-30 21:56:52 +08:00
chichuan 67fbf65916 Merge branch 'main' into fix/agent-product-header-separation 2026-07-30 21:54:11 +08:00
chichuan 7f82e46adf Merge branch 'main' into codex/skill-token-shallow-water 2026-07-30 21:52:17 +08:00
chichuan 1fb1ae3e23 Merge remote-tracking branch 'origin/main' into codex/pr-831-conflict-fix
# Conflicts:
#	CHANGELOG.md
2026-07-30 21:47:14 +08:00
github-actions[bot] fd0ab16c7f chore: update beta formula for v1.0.56-beta.1 [skip ci] 2026-07-30 13:46:57 +00:00
johnandClaude Opus 4.6 95a5fd069a docs: add command framework architecture and domain model
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-30 21:42:52 +08:00
d6292d92d3 feat(cmdcore): suppress interactive prompt off-terminal, document agent protocol
Align the write-confirmation UX with lark-cli: ConfirmRisk (and the
shortcut confirmRisk) now print the yes/no prompt only when stdin is a
real terminal (ioctl-level check via go-isatty; a char-device stat would
misclassify `< /dev/null`). Non-interactive callers get a clean
structured confirmation_required error on stderr. Piped answers are
still honored for humans/scripts; --yes/--dry-run remain the sanctioned
non-interactive paths.

skills/mono: add the recognition + retry protocol for agents —
identify confirmation_required via error.reason, show action and params,
retry the original command with --yes only after explicit user consent,
never silently append --yes or treat it as a transient error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 21:42:19 +08:00
chichuan daaad35f5b Merge pull request #840 from DingTalk-Real-AI/codex/changelog-v1.0.56-beta.1-followup
docs(release): complete v1.0.56-beta.1 notes
2026-07-30 21:33:31 +08:00
chichuan 953a36f4c9 docs(release): complete v1.0.56-beta.1 notes 2026-07-30 21:30:31 +08:00
github-actions[bot] e015f40ae2 Merge pull request #836 from DingTalk-Real-AI/codex/changelog-v1.0.56-beta.1
docs(release): add v1.0.56-beta.1 notes
2026-07-30 21:27:07 +08:00
afb9c27560 style: gofmt the three PR files failing the lint format gate
Alignment-only changes in runtime_schema.go, schema_contract_model.go,
and devapp_safety_homology_test.go. Remaining make lint findings are in
upstream-owned keychain/transport files untouched by this PR.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 21:22:48 +08:00
chichuan 84226b963c Merge branch 'main' into codex/changelog-v1.0.56-beta.1 2026-07-30 21:22:16 +08:00
chichuan 1d1c06aaae Merge branch 'main' into fix/agent-product-header-separation 2026-07-30 21:17:23 +08:00
github-actions[bot] f34f9e8223 Merge pull request #839 from DingTalk-Real-AI/codex/fix-multi-profile-e2e-timeout
ci: increase integration test timeouts
2026-07-30 21:14:44 +08:00
08c9c8a923 fix(cmdcore): close review findings on safety tier inference
- schemaSafetyFromDecl: drop the now-unreachable nil return; the tier
  fill always produces a complete block for a declared Schema
- validateDispatchDecl: panic when ConfirmFirst is set without Risk —
  it orders a confirmation that does not exist, and for declared-Schema
  writes an empty Risk would silently publish the read safety tier
- RFC: document the boundary that write commands must declare Risk

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 21:13:29 +08:00
johnandClaude Opus 4.6 f805c966d6 docs: add command framework architecture overview
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-30 21:02:55 +08:00
chichuan 3519965285 ci: increase integration test timeouts 2026-07-30 20:52:18 +08:00
eae7955448 chore(schema): regenerate artifacts after rebase onto latest main
Upstream added five reviewed tools (845 total); hashes and counts
refresh. Content of existing tools is unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 20:38:53 +08:00
ce0501b93e refactor(cmdcore): split Safety into its own enum tier with Risk default
Risk (runtime confirmation) and Safety (schema metadata) are two
independent enums composed at embed time: explicit SafetyDecl fields >
CommandSpec.Safety tier > Risk.SafetyDefault(). The tier fill now also
covers idempotency, so an enum-only declaration is self-sufficient and
validateSchemaDecl no longer needs safety completeness checks.

devapp reclassifies its write leaves by reversibility: reversible
mutations declare LeafSafetyWrite (risk high->medium), create/robot
submit/version create declare LeafSafetyHighWrite, and delete/version
publish declare LeafSafetyDestructive (publish effect
write->destructive). Shared hand-written safety constants are deleted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 20:37:40 +08:00
玉澜andCursor da93fbcc47 fix(cmdcore): close review findings on decl completeness and dry-run indexing
- validateSchemaDecl now also requires Safety (effect/risk/confirmation
  or the Risk shorthand; Idempotency is declaration-only) and Interface
  (mode/availability, plus reason for composite/unavailable), so every
  unconditional catalog required key is guaranteed at construction time
- declared dry_run capabilities are indexed by BindEffectiveCommandRegistry
  instead of Schema assembly: every process resolving the command tree
  gets the reviewed set, removing the hidden "must assemble in-process
  first" precondition of the delivery gate
- agent-metadata contract merge now errors when a declared tool has no
  canonical CLI projection instead of silently dropping the declaration

Artifacts are byte-identical; full cli/cmdcore/helpers/generator suites pass.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 20:37:38 +08:00
玉澜andCursor 92f1daa95f feat(cmdcore): single-source dry-run review and authoring-time decl checks
- dry_run capabilities declared via cmdcore.SchemaDecl are reviewed by
  construction: the Schema pass-through indexes them into the reviewed
  capability set, so declared tools no longer need manual entries in
  reviewedDryRunCapabilityGroups (31 devapp paths deleted). A conflicting
  manual entry for the same canonical is a hard error.
- NewCommand now enforces authoring-time homology for declared commands:
  a non-empty Schema without Description/AgentSummary/UseWhen/AvoidWhen/
  Examples panics at construction instead of failing later in generated
  artifacts or silently drifting from cobra prose.
- --help Example inherits Schema.Selection.Examples when not authored
  separately, keeping one authored source for examples.

Catalog and agent metadata artifacts are byte-identical.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 20:37:36 +08:00
玉澜andCursor 9911d8f9c9 feat(schema): consume Contract declarations in agent-metadata, drop devapp hints
The agent-metadata generator now merges each registered Contract final
overlay (cmdcore.SchemaDecl) as the top-precedence contract_final
candidate, so declared tools no longer need hint-file rows for
agent_summary/use_when/avoid_when/examples/safety/interface. Selection
eval fixtures and example execution plans synthesize the same assertions
from the declaration, keeping semantic-eval and example coverage intact.

- devapp hint rows deleted from schema_hints/{metadata,selection}/dev.json
  (connect_status/connect_stop/search_open_platform_docs_rag kept);
  artifact content for all 31 declared leaves is byte-identical, only
  provenance now reads contract_final / cmdcore.SchemaDecl
- exact-coverage gates exempt declared tools (hints remain required for
  every non-declared command); reviewed-delivery gate accepts
  contract_final as the stronger reviewed source
- cmdcore derives effect_source=cmdcore.contract for SchemaDecl-only
  safety (read leaves), matching the Risk-shorthand path

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 20:37:34 +08:00
玉澜andCursor 52324e9bc2 feat(devapp): declare complete Schema for all 31 published leaves
Every devapp leaf now declares its full final Schema in LeafSpec
(description/safety/interface/selection/dry_run plus Risk, Required
flags and at-least-one Constraints); declaration is the sole final
source, hints no longer shape the published catalog for these tools.

- Hand-written delete/robot submit/robot config migrate to
  LeafSpec+RunE with manual cmdcore.ConfirmRisk; robot result becomes
  a plain declared leaf. Legacy write guard, runtime_gate annotation
  and now-dead helpers are removed; the homology gate is strengthened
  to declare-only for the devapp tree.
- New CommandSpec/LeafSpec ConfirmFirst knob reproduces the devapp
  guard-first semantics (confirmation_required before parameter
  validation) without changing shortcut ordering.
- dry_run is published for all 31 leaves via the reviewed capability
  registry (invocation preview, no remote reads).
- Catalog regenerated: dry_run blocks added, unified-app-id/
  version-id/member-type/user-ids correctly marked required,
  require_one_of constraints published for get/webapp config/security
  config, and robot config name corrected to optional (CLI upsert
  runtime truth; the remote schema's required was not CLI-accurate).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 20:37:06 +08:00
玉澜andCursor a80ac662f5 chore(schema): regenerate catalog for contract attribution fixes
Rebaseline after the declare-or-annotate framework work: confirmation and
parameter description provenance now cite cmdcore.contract (runtime_gate /
native_annotation) instead of hints; delivered values unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 20:37:02 +08:00
玉澜andCursor a5cefd67f3 feat(cmdcore): typed SchemaDecl final source with assembly guards
- SchemaDecl on CommandSpec/LeafSpec declares the final ToolSpec payload;
  framework converts in-process (no JSON bridge) and Schema assembly
  pass-throughs it.
- Assembly fails closed on declared identity mismatched with the bound
  entry and on reviewed fields in the declaration payload.
- RFC/homology/AGENTS docs pin declare=final-source, safety precedence
  Final > Risk > gate, and light runtime write semantics.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 20:36:33 +08:00
玉澜andCursor 8c9c22f4b0 feat(cmdcore): declare-or-annotate homology with full ToolSpec authority
Pin path A: Contract fields declare CLI surface; write-guard uses runtime_gate;
RFC §5.0/§5.0.4 covers every Schema ToolSpec field group so none are ownerless.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 20:36:31 +08:00
玉澜andCursor 37a05db6e9 fix(shortcut): fail closed when write confirm has no stdin
Treat EOF/closed stdin as confirmation_required instead of an
interactive decline so agent/CI no longer get exit 0 for writes that
never ran. Align cmdcore.ConfirmRisk the same way.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 20:36:28 +08:00
玉澜andCursor 41fdf79aea refactor(leaf): declare params on LeafSpec; keep Call as execution
Lift business flags/const params out of Call/PostMount, add typed
flag defaults and policy gates, and realign the RFC acceptance bar to
"no Execute/Call body exists only to assemble params".

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 20:32:08 +08:00
玉澜 ceca98c573 refactor(cmdcore): layer dispatch into Invoke/Orchestrate behind a shared Ctx
The single Dispatch hook assumed every command is one MCP call, so the
Shortcut projection could only ever describe a command, never run it. Split
dispatch into Invoke (assembled toolArgs) and Orchestrate (multi-step), keep
RunE as the escape hatch, and reject specs that declare anything other than
exactly one at construction time. Ctx gives both hooks the same typed flag
accessors so orchestration no longer reaches for framework-specific plumbing.

Catalog output stays byte-identical.
2026-07-30 20:32:04 +08:00
玉澜 c84a42b0d5 fix(cmdcore): address review findings in the Phase 2 additions
Adversarial review confirmed the Phase 1 extraction is a verbatim move (no BLOCKERs) and that cmdcore.BoolFlag is equivalent to both original readers. All fixes below are in the Phase 2 additions.

Correctness: a CommandSpec declaring neither RunE nor Dispatch no longer runs the whole pipeline (write-confirmation prompt included) and silently exits 0 — it now fails with a typed internal error, which also defuses the FromShortcut trap. FromShortcut no longer double-renders the 参数约束 section (shortcutLongHelp already appends it and NewCommand appends ConstraintHelp again): it now maps intent prose only. Flag usage keeps mount()'s flagHelp decoration (必填/可选值) so projected help matches the live shortcut, and the constraint Flags slice is copied instead of aliasing the shortcut registry.

Honesty: the FromShortcut doc block now lists every dropped semantic (Required Changed-vs-effective-value divergence, typed bool/int/slice defaults, Enum, Hidden, Tips to Example, custom constraint, required/enum runtime-schema annotations). cmdcore's package doc no longer claims catalog drift proves runtime behavior — drift covers the build-time projection, unit tests cover the runtime pipeline. leaf.go's stale Phase 1 header updated. Panic messages say command not leaf; doc comments lead with the exported names.

Tests: new mount-equivalence test compares the projected command's flag set/types/usage and rendered Long against live mount(s) — the test that would have caught both bugs above; new root-to-child test exercises the inherited/root-persistent --yes/--dry-run lookup that the leaf-local helper never reached; the nil-dispatch test is inverted to assert the error. docs/architecture.md documents internal/cmdcore and how it differs from internal/cobracmd.

Verified: drift ok (840 tools, identical hashes), make policy pass, skill-command-integrity ok (1033 paths), cmdcore self-coverage 100%, CI-style changed-code coverage 100% (278 statements), full helpers/cmdcore/shortcut suites green.
2026-07-30 20:32:00 +08:00
玉澜 fb7696293d test(cmdcore): give the shared base its own exhaustive test suite
CI coverage jobs run go test -coverprofile WITHOUT -coverpkg, so each package is measured only by its own tests. cmdcore's logic was exercised only indirectly from internal/helpers, leaving cmdcore self-coverage at 31.5% — failing the CI coverage gate (changed-code 40.5%, overall regression 90.79% to 90.55%) even though the cross-package platform gate reported 100%.

Add direct tests for every cmdcore primitive: flag registration for all four kinds plus hidden aliases/MarkRequired, the explicit-alias-env-default fallback chain incl. Trim/empty skips, integer and slice resolution, required validation, toolArgs assembly incl. Bind/ArgDefault/OmitEmpty/Transform (value, nil-skip, error), constraint declaration panics, constraintProvided (default-not-counted, alias, env, bool, slice main+alias), all three constraint kinds with exact error wording, Risk confirmation (read/--yes/--dry-run/accept/decline), BoolFlag (nil/missing/local/root), schema projection, constraint help, and NewCommand orchestration (order, RunE escape, per-stage abort, decline-cancels, nil dispatch).

cmdcore self-coverage 31.5% to 100%; CI-style changed-code coverage 100%.
2026-07-30 20:31:57 +08:00
玉澜 93d6e1a001 feat(cmdcore): unified CommandSpec + FromLeafSpec/FromShortcut adapters (Phase 2)
Introduce cmdcore.CommandSpec as the single typed leaf definition and
cmdcore.NewCommand as the one orchestration path (flags → constraint decl
checks → Runtime Schema projection → constraint help → PostMount → RunE
escape / generated RunE{required → constraints → Validate → BuildArgs →
ConfirmRisk → Dispatch}). Dispatch becomes a spec property, not a
separate framework.

helpers.NewLeafCommand now delegates to cmdcore.NewCommand(FromLeafSpec),
so every LeafSpec command — including all 27 devapp leaves — flows through
the unified spec. The MCP dispatch (Call / callMCPToolOnServer /
callMCPTool) is captured in the FromLeafSpec closure.

internal/shortcut/adapter.go adds FromShortcut, the typed seam mapping a
Shortcut's shared base (flags of every kind, known constraints, risk,
help identity) into a CommandSpec. It is intentionally NOT wired into the
live mount() path: Shortcut's multi-step Execute, decline-returns-nil
semantics, and Flag.Enum/Hidden/custom-constraint extras are not modeled
by cmdcore yet, so the 376 shipped shortcuts stay byte-identical. Live
wiring is deferred to Phase 3, gated by shortcut-list + schema equivalence.

Commands are provably unaffected: check-generated-drift ok (840 tools,
identical hashes), `dws schema --all` and `shortcut list` unchanged, full
helpers/cmdcore/shortcut suites green, changed-code coverage 100%.
2026-07-30 20:31:54 +08:00
玉澜 4d4817d74f refactor(cmdcore): extract shared leaf base, LeafSpec delegates to it
Phase 1 of converging the command frameworks onto one typed base. Extract
LeafSpec's flag registration, alias/env/default effective-value fallback,
required validation, cross-flag constraint declaration checks + runtime
enforcement, Risk-driven write confirmation (--dry-run/--yes/global-flag
aware via a 3-level bool lookup), toolArgs assembly, and Agent Runtime
Schema projection into a new dispatch-agnostic internal/cmdcore package.

internal/helpers/leaf.go now keeps only the LeafSpec shell (with MCP
dispatch fields) and NewLeafCommand orchestration; LeafFlag/LeafFlagKind/
LeafConstraint/LeafConstraintKind/LeafRisk and their constants become
aliases to cmdcore types, so all 27 devapp call sites compile unchanged.
Dispatch (callMCPTool/OnServer/Call) stays in helpers.

Pure extraction, zero behavior change: catalog is byte-identical
(check-generated-drift ok), the leaf unit + risk/constraint tests pass,
and changed-code coverage is 100% (224 statements across both packages).
Only the leaf framework code is touched; Shortcut delegation is deferred
to Phase 2/3.
2026-07-30 20:31:50 +08:00
玉澜 110780bf74 feat(leaf): add Risk-driven write confirmation to LeafSpec
Close the last capability gap versus the shortcut framework: LeafSpec now
carries a Risk field (read / write / high-risk-write) and enforces the
same pre-dispatch write confirmation as shortcut's confirmRisk. Read (and
empty) risk never prompts; write/high-risk-write prompt unless --yes or
--dry-run, cancelling without dispatch on decline. Prompt wording matches
the shortcut runner verbatim (command path substitutes Service+Command)
so atomic commands and smart shortcuts confirm identically. --yes is read
robustly across local/inherited/root-persistent flags.
2026-07-30 20:31:47 +08:00
玉澜 26b100c6bb feat(leaf): unify LeafSpec with declarative constraints and bool/slice kinds
Converge the atomic LeafSpec framework toward the shortcut framework's
constraint system so both share one flag-registration + validation base,
differing only in dispatch path (single-step MCP vs multi-step
orchestration).

- Add LeafBool / LeafStringSlice flag kinds (registration, effective-value
  detection, required semantics, toolArgs assembly: bool delivers on
  Changed incl. explicit false; slice trims and drops empty elements).
- Add LeafConstraint (at_least_one / exactly_one / mutually_exclusive) on
  LeafSpec. The framework validates them between required checks and the
  Validate hook, with error wording identical to the shortcut runner's
  RuntimeContext validators; "provided" reuses LeafSpec's alias/env
  fallback chain (registration defaults do not count), which the
  shortcut framework's bare Changed check lacks.
- Project constraints to the Agent Runtime Schema (exactly_one =
  require_one_of + mutually_exclusive) and render a 参数约束 help section,
  matching shortcut leaf help. Declaration errors panic at build time.
2026-07-30 20:31:44 +08:00
chichuan a54ee24acb Merge branch 'main' into fix/agent-product-header-separation 2026-07-30 20:21:34 +08:00
chichuan a7074bf53f Merge pull request #838 from DingTalk-Real-AI/codex/fix-scoped-coverage-timeout
fix: align scoped coverage and test timeout
2026-07-30 20:20:01 +08:00
chichuan 21144af79b fix: align scoped coverage and test timeout 2026-07-30 20:06:24 +08:00
chichuan 320582f98c Merge branch 'main' into fix/agent-product-header-separation 2026-07-30 19:04:13 +08:00
Dennis e04ff5a12b Merge remote-tracking branch 'origin/main' into codex/skill-token-shallow-water
# Conflicts:
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
2026-07-30 17:39:06 +08:00
github-actions[bot] 3bdc30badb Merge pull request #834 from wxianfeng/fix/event-subscription-retry-storm
fix(event): prevent subscription retry storms
2026-07-30 17:18:27 +08:00
wxianfeng c569def067 docs: clarify disabled AI tag argument shape 2026-07-30 16:55:46 +08:00
wxianfeng b82e975429 test(app): preserve audit sink ownership in coverage gate 2026-07-30 16:25:56 +08:00
wxianfeng 2808e71cb6 fix(event): address retry storm review 2026-07-30 16:08:19 +08:00
chichuan 584b1bd9d4 docs(release): add v1.0.56-beta.1 notes 2026-07-30 15:42:05 +08:00
Dennis c350311048 chore: keep analysis report out of PR 2026-07-30 15:20:51 +08:00
Dennis 158e7ec701 perf(skills): reduce common-path context loading 2026-07-30 15:17:48 +08:00
wxianfeng 125a101487 fix: separate Agent Product from claw-type 2026-07-30 14:34:22 +08:00
wxianfeng ec99654854 fix(event): prevent subscription retry storms 2026-07-30 13:49:08 +08:00
github-actions[bot] 9aa76ea748 Merge pull request #806 from DingTalk-Real-AI/fix/param-hallucination
feat(param): 参数概念归一化治理与 IM 场景完善
2026-07-30 04:00:22 +00:00
克谨 885c3fe021 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-30 11:46:37 +08:00
github-actions[bot] d0d56cbaf5 Merge pull request #817 from DingTalk-Real-AI/codex/im-shortcut-gap-fill
feat(im): close shortcut capability gaps
2026-07-30 11:42:23 +08:00
克谨 dfba9546f4 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-30 11:06:02 +08:00
克谨 b3ba9fee97 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-30 10:43:51 +08:00
克谨 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
克谨 8936c20ef0 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-29 20:07:03 +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
克谨 2e1cce8501 test(param): align category alias fixtures with title limits 2026-07-29 13:34:59 +08:00
克谨 870fba823b Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-29 13:18:47 +08:00
克谨 d083de5f84 fix(cli): normalize explicit boolean flag values safely 2026-07-29 13:18:27 +08:00
克谨 1fb966dbff fix(cli): centralize parameter alias generation entrypoint 2026-07-29 13:17:55 +08:00
克谨 7987fb3a35 chore(ci): retrigger pull request checks 2026-07-29 10:02:28 +08:00
克谨 3d6a9c6232 test(pipeline): cover shared flag matchers 2026-07-29 10:02:28 +08:00
克谨 195569ddfa fix(cli): harden parameter preparse integration 2026-07-29 10:02:28 +08:00
克谨 7827856876 fix(param): align aliases and bound exhaustive tests 2026-07-29 10:02:28 +08:00
克谨 f79f41e930 chore(param): exclude normalization specs from review 2026-07-29 10:02:27 +08:00
克谨 bfd53df9b2 feat(param): expand reviewed IM parameter normalization 2026-07-29 10:02:27 +08:00
克谨 4db117893e fix(param): freeze reviewed normalization baseline
Restore calendar helper behavior to main, finalize reviewed alias/guard decisions, cover payload and dry-run paths, and record the local migration freeze checkpoint.
2026-07-29 10:02:27 +08:00
克谨 b314749ef7 test(param): cover final alias payloads and guard errors 2026-07-29 10:02:27 +08:00
克谨 5133103a54 fix(param): harden command-scoped normalization safety 2026-07-29 10:02:27 +08:00
克谨 9faa332306 chore: ignore stray compiled param-aliases generator binary 2026-07-29 10:02:27 +08:00
克谨 17fa1e1b34 refactor(calendar): read canonical flags in event list after normalization
Now that alias spellings are normalized to canonical flags in the PreParse
pipeline, drop the redundant flagOrFallback tails in the event-list handler and
read --start/--end/--calendar-id/--cursor/--limit directly (keeping --count as a
deliberately separate flag). Behaviour is unchanged; the pilot test guards it.
2026-07-29 10:01:53 +08:00
克谨 af1f8ccd05 test(param): fixture regression through delivery path + co-occurrence gate
Add the ⑥ regression gate that replays every reviewed validation_fixture bad case
through the real embedded PreParse pipeline and asserts the canonical outcome
(accepting either semantic rewrite or native real-flag acceptance, failing only
on a genuine unknown-flag hallucination). Add check-param-concepts.sh (dictionary
schema/loader invariants) and check-param-alias-cooccurrence.sh (full-tree
co-occurrence scan), and wire all three into make policy.
2026-07-29 10:01:53 +08:00
克谨 c26cbbbbb8 feat(param): wire semantic alias table into PreParse; pilot calendar event list
Unify runtime morphology on pkg/cmdutil.Morph (same function the generator uses),
add a SemanticAliasHandler that looks up the embedded generated table after
morphological normalization and rewrites synonyms to the command's canonical flag
(leaving blocked/ambiguous synonyms untouched for the did-you-mean path), and
thread the command CLIPath through the pipeline Context. Pilot the mechanism on
'calendar event list' by removing its hand-written hidden spelling variants; a
behaviour-preservation test locks the outcome.
2026-07-29 10:01:53 +08:00
克谨 2733f510af feat(param): generate per-command alias table from concepts
Add internal/generator/cmd_param_aliases: reads the reviewed dictionary plus the
live Cobra tree, reduces each concept against a command's real flags (>=2 visible
real flags without a reviewed ambiguous entry fails generation), and emits the
committed internal/cli/param_aliases_generated.go table with lookup helpers.
Extend generate-schema and check-generated-drift.sh to treat the dictionary as a
reviewed input and byte-guard the generated table.
2026-07-29 10:01:53 +08:00
克谨 abc62622fb feat(param): add reviewed param-concept dictionary, closed schema, and loader
Introduce internal/cli/param_concepts.json as the single reviewed source of
parameter-normalization concepts and per-command overrides, guarded by a closed
JSON schema and a go:embed loader with contract tests. Add the design spec.
2026-07-29 10:00:41 +08:00
424 changed files with 247175 additions and 229083 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
+23 -5
View File
@@ -21,6 +21,23 @@ primary paths, aliases, and navigation. Edit it only when reviewed exposure,
identity, primary path, or aliases change; parameter, Skill, and metadata-only
changes must not rewrite it mechanically.
## Command framework declaration
- Framework definition: `docs/rfc-command-framework-convergence.md` **§5.0**
- Today: `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` (+ optional `Schema`) → `corecmd.New`
- **Declare = final Schema source**: `Flags` / `Constraints` / `Safety` / `ConstParams` / `Schema` (ToolSpec groups)
- `Safety` uses the existing `cli.SafetySpec` directly. Its `confirmation` drives the runtime gate; `effect` / `risk` / `idempotency` are published unchanged. When `Schema` is set, convert once → `RegisterRuntimeContractFinal` (map store, no JSON/deep-clone); assembly **pass-throughs** Final.
- **Execute** = hooks (`Validate` / `Call` / `RunE` / `PostMount`) — not a second surface authority
- Declaration path has **no reviewed parallel fields**; migration-only `runtime_gate` annotate until `Safety` is declared
## flag / help / schema homology
- Decision (path A — Contract/LeafSpec is CLI-surface authority **and must embed into Schema**): `docs/flag-help-schema-homology.md`
- Hard rule: every help/Schema fact is **declared** **or** **annotated**; never inference-only (§1.1–§1.3; framework §5.0).
- Embed path: `corecmd.New` → `dws.schema.*` annotations → Schema catalog assembly
- MCP metadata must not create CLI flags; optional 1:1 passthrough is a gated subset only.
- Planned gate IDs: `HOM-P*`, `HOM-S*`, `HOM-I1`, `HOM-D1` (see that doc §4).
## Agent Schema contract
The Schema data flow is one way:
@@ -95,11 +112,12 @@ The Schema system has two physically separated processes:
**Generation** (build-time, slow, reviewed, one-way):
- Entry point: `internal/cli/gen.go` (all `//go:generate` pragmas isolated here,
not in business code).
- Tools: `internal/generator/cmd_schema_agent_metadata` + `cmd_schema_catalog`
(standalone Go mains).
- Inputs: 6 authored sources (registry + hints metadata + hints selection +
MCP metadata + parameter bindings + cobra tree).
- Output: `schema_catalog/` (per-product shards) + `schema_agent_metadata/`.
- Tools: `internal/generator/cmd_schema_agent_metadata` + `cmd_schema_catalog` +
`cmd_param_aliases` (standalone Go mains).
- Inputs: 7 authored source groups (registry + hints metadata + hints selection +
MCP metadata + parameter bindings + reviewed parameter concepts + cobra tree).
- Output: `schema_catalog/` (per-product shards) + `schema_agent_metadata/` +
`param_aliases_generated.go`.
- Refresh MCP metadata: `make fetch-mcp-metadata` (iterates 26 MCP server
endpoints, merges with previous data for cross-server interface_ref).
- Gates: `make generate-schema` (byte guards on inputs), `check-generated-drift.sh`,
+43
View File
@@ -6,6 +6,49 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### Fixed
- **Unified command safety and Shortcut runtime (H0)** — Shortcut leaves now execute through `corecmd.New`, sharing the same typed Safety confirmation gate as Leaf commands. EOF / closed stdin returns `confirmation_required`, and interactive `no` returns the existing non-zero cancellation validation error instead of reporting success for an operation that did not run. Pass `--yes` or `--dry-run` to skip the prompt.
## [1.0.56-beta.2] - 2026-07-30
This beta adds PRs #831 and #835 on top of v1.0.56-beta.1. It separates
Agent Product observability and IM display identity from the stable
edition-owned PAT and routing identity, and reduces common-path Skill context
loading without changing the public command or Runtime Schema surface.
### Changed
- **Agent Product identity separation** (#831) — sends `DWS_AGENT_PRODUCT` through the new `x-dws-agent-product` observability Header and uses a valid non-empty value for the IM `clawType` display label whenever `--ai-tag` is enabled. Because `--ai-tag` defaults to `true`, callers that set `DWS_AGENT_PRODUCT` change the displayed label by default. With `--ai-tag=false`, native `chat message send` / `reply` calls preserve their existing wire shape by sending an empty IM `clawType`, while shortcut calls omit the argument. Unset or empty Product values omit the Header and preserve the active edition's IM display default.
- **Agent Host dimension convention** (#831) — new integrations should send the runtime form (`cloud` or `desktop`) through `DWS_AGENT_HOST` and report the product separately through `DWS_AGENT_PRODUCT`. Legacy combined labels such as `qwenwork_cloud` remain syntactically valid for compatibility.
- **Reduced common-path Skill context** (#835) — keeps the complete 97-command Chat Shortcut inventory in Runtime Catalog and leaf Schema while routing common intents through compact Skill tables and references. When an exact command path is already known, the mono Skill no longer requires eager loading of a complete product reference. The generated Skill policy now detects drift, forced full-reference loading, and context-budget regressions; the common Chat plus shared activation estimate drops from 7,301 to 4,771 `o200k_base` tokens without changing the 845-tool Schema surface.
### Fixed
- **Stable PAT/routing identity** (#831) — restores the CLI-emitted open-source HTTP `claw-type` and PAT `hostControl.clawType` to the edition-fixed `openClaw` value. `DWS_AGENT_PRODUCT` no longer changes those wire values, and the client continues to derive PAT, authentication, routing, and Discovery behaviour from the existing independent signals.
- **Portable generated Skill validation** (#835) — resolves the mono Skill name by scanning upward from the generated target, keeping `--check` independent of the repository checkout path and preventing false drift failures when an ancestor directory resembles a Skill name.
## [1.0.56-beta.1] - 2026-07-30
This beta starts the v1.0.56 line on top of v1.0.55 and packages PRs #817,
#806, and #834, together with release-validation fixes #838 and #839. It closes
the remaining Agent-visible IM shortcut gaps, introduces reviewed
command-scoped parameter normalization without guessing business identifiers
or values, and prevents deterministic personal-event subscription failures
from becoming unbounded retry storms.
### Added
- **Complete IM shortcut workflows** (#817) — publishes the previously excluded `+chat-messages`, `+messages-send`, `+messages-send-card`, `+search-msg`, and `+thread-replies` shortcuts in Runtime Schema. Unified send, streaming-card delivery, advanced search, thread replies, and opt-in resource downloads now share reviewed parameters, selection guidance, and runtime-aligned safety semantics.
- **Reviewed parameter concept normalization** (#806) — adds a closed parameter-concept dictionary and generated command-level alias table, covering reviewed IM synonyms while preserving the boundaries between group, conversation, user, open-user, cursor, and paging identifiers.
### Fixed
- **Message delivery and resource handling** (#817) — resolves direct recipients through exact contact search, preserves rich and nested message resources, avoids same-name download overwrites, and prevents read shortcuts from silently returning empty results on non-interactive input.
- **Parameter parsing safety** (#806) — rejects ambiguous, blocked, or conflicting aliases before dispatch, normalizes explicit boolean values such as `--dry-run false`, and keeps internal pre-parse handler details out of user-visible errors.
- **Personal-event subscription retry safety** (#834) — adds cross-process attempt claims, deterministic backoff and jitter, `Retry-After` handling, terminal holds, compare-and-swap completion, and fail-closed state handling across all public personal-event subscriptions, preventing deterministic failures from causing unbounded callback retries.
- **Scoped CI and release validation reliability** (#838, #839) — keeps scoped coverage aligned with intentionally skipped supporting profiles, gives focused race and Multi-profile E2E suites enough time for the current `internal/app` workload, and preserves hidden E2E diagnostics on failure.
## [1.0.55-beta.8] - 2026-07-30
This beta revalidates the `v1.0.55-beta.7` product baseline through a complete
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.55-beta.8"
version "1.0.56-beta.2"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-darwin-arm64.tar.gz"
sha256 "07fabf720fa98f82c56027df703a3ad3f0aec16c957ea684047928f9f74fa00f"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-darwin-arm64.tar.gz"
sha256 "19b52b5427dbf24acfeb1da16a93e6edfd0443389a778abbbfd381ff8f656139"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-darwin-amd64.tar.gz"
sha256 "fbec64dc5c3463a04de9720ffb1fd247196e619ea81b976b47ecbf751a17fb72"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-darwin-amd64.tar.gz"
sha256 "621da52d04f391234d160a0522d70c1fb2e36da59102ef201a9a3a11b706b3e8"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-linux-arm64.tar.gz"
sha256 "f111cdffef0188ddf954d2174fa0d318069bcec80104775483f13f645aa8089b"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-linux-arm64.tar.gz"
sha256 "4ba956463f4b583c1727f58026a6bc1fb23d27537083e3bafd541a05ab15b48b"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-linux-amd64.tar.gz"
sha256 "d69475b7f3cec4bad4c051834df22fbfadfa7075b82347e6ca7490f67d71d0b1"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-linux-amd64.tar.gz"
sha256 "a8156ec5b89355faf8c08a65d9c088f89a7b411a418fb4b488f3d472efc79670"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-skills.zip"
sha256 "be8c9267704cfef1319fc9cd2fcba5aebe45b670b4dc37d3dae15f65523356f8"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-skills.zip"
sha256 "92c71fdeade88b3b76a00cb74ebd3223cc4110c7ee151d36d782537613129967"
end
def install
+30 -2
View File
@@ -8,7 +8,7 @@ POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
all: setup-hooks fmt lint build test rebuild
@@ -30,6 +30,7 @@ help:
@printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n"
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
@printf " make skill-context-budget - Check generated Skill drift and common-path context budgets\n"
@printf " make cli-smoke - Verify help for every public top-level command\n"
@printf " make mock-mcp-smoke - Verify HTTP and stdio MCP request/response transport\n"
@printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n"
@@ -84,9 +85,13 @@ fmt:
policy: test-auth-legacy-compat
@mkdir -p "$(POLICY_GOTMPDIR)"
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
@$(POLICY_ENV) ./scripts/policy/check-schema-command-registry.sh
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
@$(POLICY_ENV) ./scripts/policy/check-param-concepts.sh
@$(POLICY_ENV) ./scripts/policy/check-param-alias-cooccurrence.sh
@$(POLICY_ENV) $(GO) test -count=1 ./internal/app -run '^(TestParamAlias(FixtureThroughEmbeddedDeliveryPath|ReadCommandFinalPayload|WriteCommandFinalPayload|CanonicalConflictFailsBeforeRunE|BlockedFlagReachesReviewedFinalError)|TestFlagConflictErrorFormattingIsDeterministic)$$'
@$(POLICY_ENV) ./scripts/policy/check-schema-catalog.sh
@$(POLICY_ENV) ./scripts/policy/check-schema-binary.sh
@$(POLICY_ENV) $(MAKE) test-schema-agent-examples
@@ -118,6 +123,9 @@ schema-compatibility:
skill-command-integrity:
@./scripts/policy/check-skill-commands.sh
skill-context-budget:
@./scripts/policy/check-skill-context-budget.sh
cli-smoke:
@./scripts/policy/check-cli-smoke.sh
@@ -130,10 +138,14 @@ test-schema-agent-examples:
generate-schema:
@set -e; \
registry_guard=$$(mktemp -d); \
concepts_guard=$$(mktemp); \
concepts_schema_guard=$$(mktemp); \
metadata_guard=$$(mktemp -d); \
selection_guard=$$(mktemp -d); \
trap 'rm -rf "$$registry_guard" "$$metadata_guard" "$$selection_guard"' EXIT HUP INT TERM; \
trap 'rm -rf "$$registry_guard" "$$concepts_guard" "$$concepts_schema_guard" "$$metadata_guard" "$$selection_guard"' EXIT HUP INT TERM; \
cp -R internal/cli/schema_command_registry/ "$$registry_guard/"; \
cp internal/cli/param_concepts.json "$$concepts_guard"; \
cp internal/cli/param_concepts.schema.json "$$concepts_schema_guard"; \
cp -R internal/cli/schema_hints/metadata/. "$$metadata_guard/"; \
cp -R internal/cli/schema_hints/selection/. "$$selection_guard/"; \
$(GO) generate ./internal/cli; \
@@ -141,6 +153,22 @@ generate-schema:
printf '%s\n' 'generation modified reviewed input internal/cli/schema_command_registry/' >&2; \
exit 1; \
}; \
cmp -s internal/cli/param_concepts.json "$$concepts_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.json' >&2; \
exit 1; \
}; \
cmp -s internal/cli/param_concepts.schema.json "$$concepts_schema_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \
exit 1; \
}; \
cmp -s internal/cli/param_concepts.json "$$concepts_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.json' >&2; \
exit 1; \
}; \
cmp -s internal/cli/param_concepts.schema.json "$$concepts_schema_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \
exit 1; \
}; \
diff -qr internal/cli/schema_hints/metadata "$$metadata_guard" >/dev/null || { \
printf '%s\n' 'generation modified reviewed input internal/cli/schema_hints/metadata' >&2; \
exit 1; \
+6
View File
@@ -30,6 +30,12 @@
- `internal/security`: endpoint allowlist and domain trust
- `internal/safety`: runtime safety checks (confirm prompts, dry-run guards)
- `internal/cobracmd`: shared Cobra command builders
- `internal/corecmd`: dispatch-agnostic leaf-command base — flag registration,
alias/env/default value resolution, required and cross-flag constraint
validation, Risk write confirmation, toolArgs assembly, Runtime Schema
projection. Distinct from `internal/cobracmd` (generic tree helpers): it owns
the declarative leaf contract (`corecmd.Spec`) that the LeafSpec framework is
built on and that the Shortcut adapter projects into.
- `internal/pat`: PAT (Personal Access Token) authorization flow
- `internal/output`: response formatting (json, table, raw, pretty)
- `internal/logging`: structured logging and argument sanitization
+207
View File
@@ -0,0 +1,207 @@
# command 领域模型
本文档描述 `internal/corecmd` 包的领域模型——类型、概念及其关系。
## 核心模型图
```
┌─────────────────────────────────────────────────────────────────────┐
│ corecmd.Spec │
│ (一个叶子命令的完整契约) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─── CLI 表面 ───┐ ┌─── 参数声明 ───────────────────────────┐ │
│ │ Use │ │ FlagSpec[] │ │
│ │ Short │ │ ├─ Name / Kind / Default │ │
│ │ Long │ │ ├─ Required / MarkRequired │ │
│ │ Example │ │ ├─ Aliases[] / EnvVar (回退链) │ │
│ └────────────────┘ │ ├─ Bind / Transform / OmitEmpty │ │
│ │ └─ Enum / Format / SchemaDescription │ │
│ │ │ │
│ │ Constraint[] │ │
│ │ ├─ at_least_one │ │
│ │ ├─ exactly_one │ │
│ │ └─ mutually_exclusive │ │
│ │ │ │
│ │ ConstParams map[string]any │ │
│ └────────────────────────────────────────┘ │
│ │
│ ┌─── 安全模型 ──────────────────────────────────────────────────┐ │
│ │ Safety cli.SafetySpec │ │
│ │ ├─ Effect (read / write / destructive) │ │
│ │ ├─ Risk (low / medium / high) │ │
│ │ ├─ Confirmation (not_required / user_required) ──▶ 运行时门 │ │
│ │ └─ Idempotency (idempotent / retryable / …) │ │
│ │ │ │
│ │ 四字段彼此独立;同一值同时供运行时与 Schema 使用 │ │
│ │ ConfirmFirst: bool (只控制确认门顺序) │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─── Schema 声明 (Agent 可见的元数据) ──────────────────────────┐ │
│ │ SchemaDecl │ │
│ │ ├─ Title / Description │ │
│ │ ├─ DryRunDecl {PreviewKind, RemoteReads} │ │
│ │ ├─ InterfaceDecl {Mode, Availability, Reason, Ref} │ │
│ │ ├─ SelectionDecl {AgentSummary, UseWhen, AvoidWhen, │ │
│ │ │ Prerequisites, Tips, Examples} │ │
│ │ ├─ IdentityDecl {ProductID, CanonicalPath, Aliases, ...} │ │
│ │ └─ Positionals[] {Name, Type, Required, Variadic} │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─── 执行体 (恰好一个) ─────────────────────────────────────────┐ │
│ │ Invoke(Ctx, toolArgs) ← 单步:框架装配好 args 后派发 │ │
│ │ Orchestrate(Ctx) ← 多步:自行组装多次调用 │ │
│ │ RunE(cmd, args) ← 逃生舱:完全自定义 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─── 钩子 ─────────────────────────────────────────────────────┐ │
│ │ Validate(cmd, args) ← 条件式业务校验(约束表达不了的) │ │
│ │ PostMount(cmd) ← 挂载收尾(设置 Args 等 cobra 属性) │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
## 构建与执行流
```
corecmd.Spec ──── corecmd.New() ────▶ cobra.Command ──── 用户执行 ────▶ Ctx
│ │ │
构建时检查: 注册产物: 执行上下文:
• validateDispatchDecl • Flags + Aliases • Str(name)
• validateSafetySpec • Annotations (Schema) • Int(name)
• validateSchemaDecl • Long (约束 help) • Bool(name)
• RegisterFlags • RunE (管线) • StrSlice(name)
• ValidateConstraintDecls • Changed(name)
• embedContractIntoSchema • DryRun() / Yes()
• AnnotateConstraints
• PostMount
```
## 领域概念
| 概念 | 类型 | 职责 |
|------|------|------|
| **corecmd.Spec** | struct | 一个命令的完整契约(声明 + 执行) |
| **FlagSpec** | struct | 一个参数的注册、回退链、绑定规则 |
| **Constraint** | struct | 参数间的关系约束 |
| **Safety** | cli.SafetySpec | 运行时与 Schema 共用的安全契约 |
| **SchemaDecl** | struct | Agent 可见的完整工具规格声明 |
| **SelectionDecl** | struct | Agent 选择该工具的语义指引 |
| **InterfaceDecl** | struct | 工具的接口模式与可用性 |
| **DryRunDecl** | struct | dry-run 能力声明 |
| **IdentityDecl** | struct | 工具在注册表中的身份标识 |
| **PositionalDecl** | struct | 有序位置参数声明 |
| **Ctx** | struct | 执行上下文(类型安全的 flag 读取) |
| **New** | func | 统一构建器(`corecmd.Spec` → `*cobra.Command`) |
## SafetySpec
`corecmd.Spec.Safety` 直接复用 `cli.SafetySpec`,没有 command 自定义 Risk/Safety 枚举,也没有 `SafetyDecl` 覆盖层:
```go
Safety: cli.SafetySpec{
Effect: "write",
Risk: "high",
Confirmation: "user_required",
Idempotency: "unknown",
}
```
- `Confirmation == "user_required"` 时执行确认;`--yes` 和 `--dry-run` 可跳过交互。
- `Effect`、`Risk`、`Idempotency` 不参与确认决策,也不会改写 `Confirmation`。
- 任意一个字段非空时,四个字段必须全部显式声明;构建时拒绝部分声明。
- 完全空值仅作为历史只读默认,最终发布为 `read/low/not_required/idempotent`。
## FlagSpec 有效值回退链
框架统一的 flag 值解析顺序:
```
显式主 flag (Changed)
│ 空?
▼
隐藏别名 (Changed, 按声明序)
│ 空?
▼
环境变量 (EnvVar)
│ 空?
▼
注册默认值 (Default)
│ 空?
▼
ArgDefault (兜底)
```
各 Kind 的特殊行为:
| Kind | 入参条件 | 回退链 |
|------|----------|--------|
| KindString | 有效值非空(或 !OmitEmpty) | 完整参与 |
| KindInt | 值 ≠ 0(putInt 语义) | 完整参与 |
| KindBool | Changed 时入参(显式 false 也下发) | 不参与别名/env 回退 |
| KindStringSlice | 存在非空元素 | 仅 Changed 的主 flag/alias |
## Constraint 约束
声明式跨 flag 关系,构建时校验合法性,运行时统一执行:
| Kind | 语义 | 错误文案示例 |
|------|------|-------------|
| `at_least_one` | 至少提供一个 | "请至少指定 --a、--b 之一" |
| `exactly_one` | 恰好提供一个 | "请指定 --a、--b 之一" / "只能指定其一" |
| `mutually_exclusive` | 最多提供一个 | "参数 --a、--b 互斥,只能指定其一" |
"是否提供"的判定复用有效值回退链(显式主 flag → 别名 → env),注册默认值不算作已提供。
## SchemaDecl 子结构
### SelectionDecl(Agent 选择指引)
```go
SelectionDecl{
AgentSummary: "一句话描述工具做什么",
UseWhen: []string{"在什么场景下应该选择这个工具"},
AvoidWhen: []string{"什么场景不应该用,应该用什么替代"},
Prerequisites: []string{"使用前提条件"},
Tips: []string{"使用技巧"},
Examples: []string{"dws dev app create --name Bot --dry-run"},
}
```
### InterfaceDecl(接口模式)
```go
InterfaceDecl{
Mode: "composite", // local / pinned / composite
Availability: "available", // available / unavailable
Reason: "...", // composite/unavailable 时的原因
ProductID: "...", // pinned 时的 ref
RPCName: "...", // pinned 时的 ref
}
```
### DryRunDecl(dry-run 能力)
```go
DryRunDecl{
PreviewKind: "invocation", // invocation / request / plan
RemoteReads: false, // dry-run 时是否发起远端读
}
```
## 执行体三选一
| 执行体 | 适用场景 | 框架做了什么 |
|--------|----------|-------------|
| **Invoke** | 单步 MCP/后端调用 | 框架完成 required→constraint→validate→buildArgs→confirm,传入装配好的 toolArgs |
| **Orchestrate** | 多步编排 | 框架完成 required→constraint→validate→confirm,传入 Ctx 自行组装调用 |
| **RunE** | 逃生舱 | 框架仍执行 Safety 确认,具体业务执行完全自定义 |
## 设计不变量
1. **一个 corecmd.Spec = 一个叶子命令的全部事实**
2. **声明面绝不调用后端**——command 是 dispatch-agnostic
3. **执行面绝不发明 CLI 表面**——业务 flag 必须在 Flags 声明
4. **构建时拦截 > 运行时报错**——声明错误 panic 在注册阶段
5. **SafetySpec 是单一事实源**——Confirmation 驱动运行时,其余字段原样进入 Schema
6. **声明即 review**——代码中的 Schema 经 code review 后直接投影,不依赖外部 hint 文件
+270
View File
@@ -0,0 +1,270 @@
# 命令框架架构
本文档描述 `internal/corecmd` 统一命令框架的当前架构,面向框架使用者和维护者。
## 概览
```
用户输入 → cobra 命令树 → corecmd.New() → 运行时管线 → 后端派发
```
命令框架将 CLI 命令的**声明**与**执行**分离:
- **声明面** — 数据字段描述命令是什么(flag、约束、SafetySpec、Schema 元数据)
- **执行面** — 钩子函数描述命令做什么(校验、派发、编排)
框架负责:flag 注册、有效值回退链、required/约束校验、SafetySpec 确认、toolArgs 装配、Agent Runtime Schema 投影。
## 核心类型
### corecmd.Spec
统一的类型化命令规格,是框架的核心数据结构:
```go
type Spec struct {
// 声明面
Use string
Short string
Long string
Example string
Flags []FlagSpec
Constraints []Constraint
Safety cli.SafetySpec // 运行时与 Schema 的单一安全来源
ConfirmFirst bool // 确认门先于参数校验
ConstParams map[string]any
Schema SchemaDecl // 完整 ToolSpec 载荷
// 执行面(恰好一个)
Invoke func(c *Ctx, toolArgs map[string]any) error // 单步
Orchestrate func(c *Ctx) error // 多步
RunE func(cmd *cobra.Command, args []string) error // 逃生舱
// 钩子
Validate func(cmd *cobra.Command, args []string) error
PostMount func(cmd *cobra.Command)
}
```
### SafetySpec(单一安全来源)
`Spec.Safety` 直接使用现有 Agent Runtime Schema 的 `cli.SafetySpec`:
| 字段 | 职责 |
|------|------|
| `Effect` | 操作影响:read / write / destructive |
| `Risk` | 风险等级:low / medium / high |
| `Confirmation` | 是否需要用户确认:not_required / user_required |
| `Idempotency` | 幂等性:idempotent / retryable / non_idempotent / unknown |
四个字段彼此独立。框架只读取 `Confirmation` 决定运行时确认,其余字段原样发布到 Schema,不从一个字段机械推导另一个。非空 SafetySpec 必须一次声明完整:
```go
Safety: cli.SafetySpec{
Effect: "write",
Risk: "high",
Confirmation: "user_required",
Idempotency: "unknown",
},
```
完全空值保留历史只读默认 `read/low/not_required/idempotent`;不存在 Risk/Safety 枚举或覆盖优先级链。
### FlagSpec
声明一个 flag 的注册方式、有效值回退链、到 toolArgs 的绑定:
```go
type FlagSpec struct {
Name string // flag 名(kebab-case)
Usage string // --help 文案
Kind FlagKind // String / Int / Bool / StringSlice
Default string // 注册默认值
Required bool // 框架校验非空
Aliases []string // 隐藏别名
EnvVar string // 环境变量回退
Bind string // toolArgs 键名(空则用 Name)
Transform func(string) (any, error) // 值转换
// ...更多字段见源码
}
```
### Constraint
跨 flag 关系约束:
```go
type Constraint struct {
Kind ConstraintKind // at_least_one / exactly_one / mutually_exclusive
Flags []string
}
```
## 有效值回退链
flag 解析按以下顺序取值(先命中先生效):
```
显式主 flag (Changed) → 隐藏别名 (Changed) → 环境变量 → 注册默认值
│
ArgDefault ←──┘ (兜底)
```
- KindBool:仅 Changed 时生效,不参与回退链
- KindStringSlice:仅主 flag / alias Changed 时生效,元素恒 TrimSpace
- KindInt:非零才入 toolArgs(putInt 语义)
## 构建时流程
`corecmd.New(spec)` 执行以下构建时检查(失败则 panic):
1. **validateDispatchDecl** — 恰好一个执行体(Invoke/Orchestrate/RunE)
2. **validateSafetySpec** — 非空 SafetySpec 的四个独立字段必须完整
3. **validateSchemaDecl** — Schema 声明完整性(Description、AgentSummary、UseWhen、AvoidWhen、Examples、Interface)
4. **RegisterFlags** — flag + alias 注册到 cobra
5. **ValidateConstraintDecls** — 约束引用的 flag 必须存在
6. **embedContractIntoSchema** — 投影到 dws.schema.* annotations
7. **AnnotateConstraints** — 约束渲染到 --help
8. **PostMount** — 调用方的挂载收尾钩子
## 运行时流程
生成的 `RunE` 按以下顺序执行:
```
[ConfirmFirst? → ConfirmSafety] ← 可选:先确认后校验
│
▼
ValidateRequired ← 有效值回退链校验
│
▼
ValidateConstraints ← 互斥/至少一个/恰好一个
│
▼
Validate hook ← 条件式业务校验(可选)
│
▼
BuildArgs ← flag → toolArgs 装配
│
▼
ConstParams 合并
│
▼
[!ConfirmFirst? → ConfirmSafety] ← 默认顺序:校验后确认
│
▼
Invoke(ctx, toolArgs) ← 单步派发
或 Orchestrate(ctx) ← 多步编排
```
## 消费方式
### LeafSpec(MCP 直连叶子命令)
```go
func newDevAppCreateCommand(runner executor.Runner) *cobra.Command {
return NewLeafCommand(LeafSpec{
Use: "create",
Short: "创建开放平台企业内部应用",
Tool: devAppCreateTool,
Safety: cli.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
ConfirmFirst: true,
Flags: []LeafFlag{
{Name: "name", Usage: "应用名称 (必填)", Bind: "name",
Trim: true, Required: true, RequiredHint: "--name 为必填"},
},
Schema: LeafSchema{
Description: "创建开放平台企业内部应用",
DryRun: &LeafDryRunDecl{PreviewKind: "invocation"},
Interface: &LeafInterfaceDecl{Mode: "composite", Availability: "available"},
Selection: LeafSelectionDecl{
AgentSummary: "创建钉钉开放平台应用",
UseWhen: []string{"需要新建企业内部应用"},
AvoidWhen: []string{"应用已存在时用 update"},
Examples: []string{`dws dev app create --name "Bot" --dry-run`},
},
},
Call: devAppCall(runner),
})
}
```
`NewLeafCommand` 经 `FromLeafSpec()` 归一为 `corecmd.Spec`,再交 `corecmd.New()` 构建。这是**完全托管模式**:声明 + 执行都归 command。
### 声明元数据模式(既有命令补 Schema)
执行体必须冻结时,用同一套 `LeafSpec` 词汇只声明元数据,写在命令字面量旁:
```go
baseListCmd := &cobra.Command{
Use: "list", Short: "获取 AI 表格列表",
RunE: func(cmd *cobra.Command, args []string) error { /* 原执行体不动 */ },
}
DeclareLeafMetadata(baseListCmd, LeafSpec{
Safety: aitableSafetyRead(),
Schema: LeafSchema{
Description: "列出最近访问的 AI 表格 Base。",
Interface: aitableMCPInterface("list_bases"),
Selection: LeafSelectionDecl{
AgentSummary: "列出最近访问的 AI 表格 Base。",
UseWhen: []string{"只需浏览最近打开过的 Base 时"},
AvoidWhen: []string{"按名称查找优先 base search"},
Examples: []string{"dws aitable base list"},
},
},
})
```
`DeclareLeafMetadata` 调用 `corecmd.AttachSchema` 挂 Safety+Schema;不注册 flag、不接管参数投影。当 `Safety.Confirmation=user_required` 时,用**同一份** SafetySpec 包一层 `ConfirmSafety`,保证执行门禁与 Catalog 同源。迁移态入口;新命令仍应走 `NewLeafCommand`。
### Shortcut(智能快捷方式,已接入 live mount)
```go
func mount(s Shortcut) *cobra.Command {
return corecmd.New(FromShortcut(s))
}
spec := FromShortcut(Shortcut{
Service: "chat",
Command: "+demo",
Risk: RiskHighWrite,
Flags: []Flag{...},
Execute: func(rt *RuntimeContext) error { ... },
})
```
Shortcut 当前仍保留自身的 `Risk`,adapter 只在边界将它展开成完整
`cli.SafetySpec`;command/Leaf 不再保留该枚举。Shortcut 的 Cobra
type/default/usage provenance 保持不变,command 统一补充 Required、Enum 和关系约束投影。
## 文件结构
| 文件 | 职责 |
|------|------|
| `internal/corecmd/corecmd.go` | 核心类型 + `New` 构建器 + 运行时管线 |
| `internal/corecmd/schema_decl.go` | SchemaDecl 载荷类型 + 声明完整性守卫 |
| `internal/helpers/leaf.go` | LeafSpec 门面:`NewLeafCommand`(完全托管)+ `DeclareLeafMetadata`(声明元数据) |
| `internal/shortcut/adapter.go` | FromShortcut 完整映射与 Risk 兼容边界 |
| `internal/shortcut/runner.go` | RuntimeContext;live mount 委托 `corecmd.New(FromShortcut(s))` |
## Schema 投影
声明即 review:代码中的 Schema 声明经过 code review 后直接投影为:
- **Agent Runtime Schema** (dws.schema.* cobra annotations)
- **Agent Metadata** (agent-metadata JSON, 消费方为 Agent 选择器)
- **Catalog** (catalog.json, 命令注册表)
- **Dry-run Capabilities** (声明自动索引为 reviewed 能力)
不再需要外部 hint 文件维护 selection/metadata/dry-run 信息。
## 设计原则
1. **声明 vs 执行分离** — Flags/Constraints/Safety/Schema 是声明;Invoke/Validate/PostMount 是执行
2. **单一数据源** — 一份声明驱动 --help、Schema、catalog、runtime 校验
3. **安全字段不互推** — Confirmation 单独驱动确认,Effect/Risk/Idempotency 原样发布
4. **构建时拦截 > 运行时报错** — 声明不完整在命令注册时 panic,不等到用户触发
5. **边界兼容** — Shortcut 暂由 adapter 转换,Leaf 直接声明 SafetySpec
+236
View File
@@ -0,0 +1,236 @@
# 命令框架对比:DWS command vs lark-cli vs GWS
本文档对比 DWS(钉钉工作区 CLI)、lark-cli(飞书 CLI)和 GWS(Google Workspace CLI / gcloud)三套命令框架的设计差异。
## 总览对比
| 维度 | DWS (command) | lark-cli | GWS (gcloud) |
|------|---------------|----------|--------------|
| 语言 | Go | Go | Python (gcloud) / Go (部分) |
| CLI 框架 | cobra | cobra | argparse + calliope |
| 调用底座 | MCP JSON-RPC | Lark REST SDK (`CallAPITyped`) | Google API Client |
| 命令层次 | 2 层:LeafSpec + Shortcut | 3 层:Shortcuts + API Commands + Raw API | 2 层:surface commands + raw |
| Schema 来源 | 代码声明投影 | 代码声明 + 运行时 introspection | API Discovery 文档自动生成 |
| Agent 适配 | 内建 (dws.schema.*) | 内建 (--print-schema) | 外挂 (MCP adapter) |
## 架构对比
### DWS command
```
corecmd.Spec (声明) → corecmd.New() → cobra.Command
│
├── cli.SafetySpec (运行时 + Schema 单一安全来源)
├── FlagSpec[] (参数 + 回退链 + 绑定)
├── Constraint[] (互斥/至少一个)
├── SchemaDecl (Agent Selection/DryRun/Interface)
│
└── Invoke / Orchestrate / RunE (执行)
```
**核心特点**:
- 声明与执行严格分离
- SafetySpec 四个独立字段直接对齐 Agent Runtime Schema
- 有效值回退链:flag → alias → env → default
- 框架统一校验、装配、确认、投影
- Schema 从代码声明直接投影,无外部 hint 文件
### lark-cli
```
Shortcut (声明) → runner.Mount() → cobra.Command
│
├── Risk string (确认行为)
├── Scopes / ConditionalScopes (OAuth 权限)
├── Flag[] (参数 + Enum + Input sources)
├── AuthTypes (user/bot)
│
├── DryRun hook → DryRunAPI
├── Validate hook
└── Execute hook → RuntimeContext → CallAPITyped
```
**核心特点**:
- Execute 内直接调 REST API (`CallAPITyped`)
- DryRun 是独立 hook(返回结构化 API 计划)
- 内建 OAuth scope 声明与预检
- `--print-schema --flag-name` 运行时 introspection
- 无 Schema 投影层,Agent 通过 introspection 动态发现
### GWS (gcloud 风格)
```
API Discovery → 代码生成 → surface command
│
├── arguments (从 JSON Schema 自动生成)
├── request/response 映射
└── 自定义 action hook (少量)
```
**核心特点**:
- Schema-first:从 API Discovery 文档自动生成命令
- 参数直接映射 API 字段(flat schema)
- 人工 surface command 是 thin wrapper
- Agent 适配通过 MCP 外部 adapter
## 核心设计差异
### 1. 声明粒度
| 能力 | DWS command | lark-cli | GWS |
|------|-------------|----------|-----|
| 参数别名 + 环境变量回退 | ✅ FlagSpec.Aliases + EnvVar | ❌ 无 | ❌ 无 |
| 声明式约束 (互斥/至少一个) | ✅ Constraint[] | ❌ 只有 Validate hook | ✅ argparse group |
| 安全契约 | ✅ SafetySpec(effect/risk/confirmation/idempotency) | Risk | 无 |
| Schema 投影 (Agent metadata) | ✅ SchemaDecl 内建 | ⚠️ 运行时 introspection | ❌ 外挂 |
| 参数绑定 (flag name → API key) | ✅ FlagSpec.Bind | ❌ 手写 | ✅ 自动映射 |
| ConstParams (固定载荷) | ✅ | ❌ 手写在 Execute | ✅ 隐式 |
| 确认门顺序可配 (ConfirmFirst) | ✅ | ❌ 固定顺序 | ❌ 无确认机制 |
### 2. 执行模型
| 维度 | DWS command | lark-cli | GWS |
|------|-------------|----------|-----|
| 参数装配 | 框架自动 (BuildArgs) | 手写 (`runtime.Str()/Bool()`) | 自动映射 |
| 派发方式 | Invoke(ctx, toolArgs) | Execute(ctx, runtime) | 自动调用 |
| 多步编排 | Orchestrate(ctx) | Execute 内链式 CallAPITyped | 不支持 |
| DryRun | 框架统一 (--dry-run flag) | 独立 DryRun hook 返回 API 计划 | 部分命令支持 |
| 错误分类 | apperrors 类型化 | errs.Problem 类型化 | HTTP status 映射 |
### 3. Agent 适配
| 维度 | DWS command | lark-cli | GWS |
|------|-------------|----------|-----|
| 工具发现 | `dws schema --all` (静态 catalog) | `--print-schema` (运行时) | API Discovery |
| 选择指引 | SelectionDecl (UseWhen/AvoidWhen) | Description + Tips | 无 |
| 安全声明 | cli.SafetySpec 直接声明 | Risk string | 无 |
| dry-run 能力声明 | DryRunDecl (reviewed) | DryRun hook 存在性 | 无 |
| 接口模式 | InterfaceDecl (local/pinned/composite) | 隐式 (全部 REST) | 隐式 (全部 REST) |
### 4. Schema 生命周期
```
DWS: 代码声明 → code review → cobra annotation → catalog/metadata JSON
(单一数据源,构建时验证完整性)
lark-cli: 代码声明 → 运行时 introspection → Agent 动态发现
(无离线 catalog,Agent 必须执行命令才能发现)
GWS: API Discovery JSON → 代码生成 → surface command
(Schema-first,但命令行体验受限于 API 形状)
```
## 设计哲学对比
### DWS command 的选择
| 选择 | 理由 | 对比 |
|------|------|------|
| 框架装配参数 | 消除 N 个命令各写一份 toolArgs 装配 | lark-cli 每个 Execute 手动取 flag 值 |
| SafetySpec 单一来源 | confirmation 驱动运行时,其余字段原样发布且互不推导 | lark-cli 只有 Risk 一个维度 |
| 声明式约束 | 构建时校验合法性 + 投影到 Schema + 渲染帮助 | lark-cli 约束隐藏在 Validate 逻辑里 |
| Schema 构建时投影 | 离线 catalog 支持 Agent 批量发现 | lark-cli 需要逐个命令 introspection |
| 有效值回退链 | flag → alias → env 统一语义 | lark-cli 别名是独立 Flag 手动关联 |
| ConfirmFirst | 精确建模遗留语义 | lark-cli 确认始终在 Execute 内 |
### lark-cli 的选择
| 选择 | 理由 | 对比 |
|------|------|------|
| 直连 REST API | 精确控制请求/响应,可处理分页/重试 | DWS 通过 MCP 间接调用 |
| DryRun 返回 API 计划 | Agent 可预览将要发出的真实 HTTP 请求 | DWS dry-run 只展示参数 |
| OAuth scope 声明 | 框架预检权限,失败提前 | DWS 依赖 MCP 层鉴权 |
| `--print-schema` introspection | 运行时发现,无需维护离线 catalog | DWS 需要 re-generate |
| Input sources (file/@path/stdin) | 丰富的输入方式声明 | DWS 无此抽象 |
| PrintFlagSchema | 单 flag 级别的 JSON Schema 暴露 | DWS 只在 catalog 级别 |
### GWS (gcloud) 的选择
| 选择 | 理由 | 对比 |
|------|------|------|
| API Discovery 驱动 | 一份 Schema 生成所有:SDK/CLI/文档 | DWS/lark 手写 |
| Flat parameter 映射 | API 字段 = CLI flag,零转换 | DWS 需要 Bind 映射 |
| 无 shortcut 层 | API 粒度即用户粒度 | DWS/lark 有精选层 |
## 代码量对比
| 框架 | 核心框架代码 | 单命令声明开销 | 备注 |
|------|-------------|---------------|------|
| DWS command | ~1400 行 (command.go + schema_decl.go) | ~20-30 行 (纯声明) | 框架重、单命令轻 |
| lark-cli | ~800 行 (runner.go + types.go + common.go) | ~50-150 行 (声明 + Execute 逻辑) | 框架轻、单命令重 |
| GWS gcloud | ~5000+ 行 (calliope 框架) | ~10 行 (多数自动生成) | 框架最重、单命令最轻 |
## DWS 命令声明示例 vs lark-cli
### DWS (command / LeafSpec)
```go
NewLeafCommand(LeafSpec{
Use: "create",
Short: "创建应用",
Tool: "create_dev_app",
Safety: cli.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
ConfirmFirst: true,
Flags: []LeafFlag{
{Name: "name", Usage: "应用名称", Bind: "name",
Trim: true, Required: true, RequiredHint: "--name 为必填"},
},
Schema: LeafSchema{
Description: "创建开放平台企业内部应用",
DryRun: &LeafDryRunDecl{PreviewKind: "invocation"},
Interface: &LeafInterfaceDecl{Mode: "composite", Availability: "available"},
Selection: LeafSelectionDecl{
AgentSummary: "创建钉钉开放平台应用",
UseWhen: []string{"需要新建企业内部应用"},
AvoidWhen: []string{"应用已存在时用 update"},
Examples: []string{`dws dev app create --name "Bot" --dry-run`},
},
},
Call: devAppCall(runner),
})
```
### lark-cli (Shortcut)
```go
var CalendarCreate = common.Shortcut{
Service: "calendar",
Command: "+create",
Description: "Create a new calendar event",
Risk: "write",
Scopes: []string{"calendar:calendar"},
Flags: []common.Flag{
{Name: "summary", Desc: "Event title", Required: true},
{Name: "start", Desc: "Start time (RFC3339)", Required: true},
{Name: "end", Desc: "End time (RFC3339)", Required: true},
{Name: "attendees", Type: "string_slice", Desc: "Attendee emails"},
},
DryRun: func(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
return &common.DryRunAPI{
Method: "POST",
Path: "/open-apis/calendar/v4/calendars/{id}/events",
Body: buildEventBody(rt),
}
},
Execute: func(ctx context.Context, rt *common.RuntimeContext) error {
body := buildEventBody(rt)
data, err := rt.CallAPITyped("POST",
"/open-apis/calendar/v4/calendars/{id}/events", nil, body)
if err != nil { return err }
return rt.Output(data)
},
}
```
## 适用场景总结
| 场景 | 最适合 | 原因 |
|------|--------|------|
| MCP 后端 + Agent Schema 投影 | **DWS command** | 内建 Schema 声明、SafetySpec 契约、离线 catalog |
| REST API 直连 + OAuth scope 管理 | **lark-cli** | CallAPITyped + scope 预检 + DryRun API 计划 |
| API-first 大规模 surface 生成 | **GWS gcloud** | Discovery 驱动,一份 Schema 生成一切 |
| 多步编排 (跨服务链式调用) | **lark-cli** / DWS Orchestrate | lark 的 CallAPITyped 链式 + DWS 的 Orchestrate |
| 遗留系统迁移 (保持行为等价) | **DWS command** | ConfirmFirst + 回退链 + catalog 漂移门禁 |
+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
+246
View File
@@ -0,0 +1,246 @@
# flag / help / schema 同源
- **状态**:已决策(路径 A + Contract 嵌入 Schema)
- **相关**:[`rfc-command-framework-convergence.md`](rfc-command-framework-convergence.md)、[`schema-dynamic-endpoint-design.md`](schema-dynamic-endpoint-design.md)
- **实现门面**:`LeafSpec` → `corecmd.Spec` → `corecmd.New`
## 1. 决策
采用 **路径 A:Contract / LeafSpec 为 CLI 表面权威**,并且 **Contract 必须嵌入进 Schema**。
「同源」的含义是:同一份 Contract(今日经 LeafSpec / `corecmd.Spec` 表达)同时决定——且门禁能证明——
1. cobra 实际注册的 flags / required / defaults;
2. `--help` 的 Flags 与「参数约束」段;
3. **嵌入后的** `dws schema` / Catalog(`schema_catalog` / agent metadata 生成物)中的 parameters、关系约束和 SafetySpec。
嵌入机制(已落地):
```text
corecmd.Spec
→ RegisterFlags + embedContractIntoSchema
→ cobra annotations:
dws.schema.contract=command
dws.schema.property / type / required (per flag)
dws.schema.constraints
→ RegisterRuntimeContractFinal(SafetySpec + SchemaDecl)
→ Schema 组装透传 Contract Final
→ go:embed schema_catalog / schema_agent_metadata
```
command/Leaf 不再写 `dws.schema.risk`;SafetySpec 走类型化 Final 载荷,不使用字符串枚举注解。
### 1.1 硬规则:声明 = 最终数据源(Schema 透传)
受管命令进入 Schema 的叶子数据由 **Contract 声明**定义最终值;框架(`corecmd.New`)做**类型转换**并注册,Schema 组装**透传**,不得:
- 把声明序列化成 JSON 注解再解析;
- 在声明体系里再挂「评审字段」并行权威;
- 用 hints/registry 盖写已声明字段。
迁移期未迁完的叶子可暂走旧组装路径;**新声明面不含 review_reason / reviewed 字段**。写命令未设 `Safety` 时,过渡期仍可用 `runtime_gate` annotate(`HOM-S2`)。
**不采用**路径 B(以 `schema_mcp_metadata` 生成全部 CLI flag/help/schema)作为主权威。钉钉 MCP meta 不是飞书 OAPI:粒度与 CLI 特有语义(二选一、OmitEmpty、ConstParams、write guard)无法从裸 meta 推出;强行生成会违反「Schema 描述 CLI,不制造 CLI」。
路径 B 仅允许作为 **可选的 1:1 MCP 透传叶子通道**(见 §5),不得覆盖 LeafSpec / Shortcut 主路径。
对外叙事:与飞书 **分层单权威** 同构——API/透传叶可用平台事实,产品 CLI / Shortcut 用手写契约 + 执行体——而不是「全家只有平台 meta」。
### 1.2 声明(declare):写什么、写在哪、投影到哪
**定义**:声明 = 在 Contract 结构体的**数据字段**上写出事实;`corecmd.New` 据此注册 cobra、渲染 help、写入 `dws.schema.*`。钩子闭包(`Validate` / `Call` / `PostMount` / `RunE`)里的逻辑**不算**声明——即使行为正确,也不能单靠钩子让 Schema/help「猜出」该事实。
命令框架边界与今日/目标对应见 RFC [`rfc-command-framework-convergence.md`](rfc-command-framework-convergence.md) **§5.0**(框架上的「声明」定义);本节给字段级表与示例。
**唯一写入面**(三选一,语义相同):
| 入口 | 类型 | 归一 |
|---|---|---|
| Leaf 命令 | `helpers.LeafSpec` | `FromLeafSpec` → `corecmd.Spec` |
| Shortcut | Shortcut 声明(经 `FromShortcut`) | → `corecmd.Spec` |
| 直接基座 | `corecmd.Spec` | `corecmd.New` |
今日产品 CLI 叶子以 **`LeafSpec` 为声明门面**;字段与 `corecmd.Spec` 契约面一一对应。
#### 1.2.1 契约字段(算声明)
| 字段 | 声明什么 | 运行时 | 嵌入 Schema / help |
|---|---|---|---|
| `Flags[]`(`FlagSpec` / `LeafFlag`) | 用户可见参数面:名、类型、默认、必填、usage | 注册 cobra flag;装配 toolArgs | `dws.schema.property` / `type` / `required`;`--help` Flags |
| `Constraints[]` | 跨 flag 关系:`at_least_one` / `exactly_one` / `mutually_exclusive`;`custom` 记录钩子校验 | 通用关系由 `ValidateConstraints` 执行;`custom` 由 `Validate` 执行 | `dws.schema.constraints`;`--help`「参数约束」 |
| `Safety`(`cli.SafetySpec`) | effect/risk/confirmation/idempotency 四个独立事实 | `confirmation=user_required` 时 `ConfirmSafety`;`--yes` / `--dry-run` 跳过 | 同一个 SafetySpec 原样进入 Contract Final(`HOM-S1`) |
| `ConstParams` | 固定载荷(不上 flag 表) | 并入 toolArgs;不满足 Required | **不**投影为用户 parameter |
| `Use` / `Short` / `Long` / `Example` | 命令身份文案与示例 | cobra 自身 | help;identity 仍以 registry 为准 |
`FlagSpec` 子字段(声明细节):
| 子字段 | 作用 | 是否进 Schema parameters |
|---|---|---|
| `Name`, `Usage`, `Kind`, `Default` | 注册名/说明/类型/DefValue | 是(name/type;default 与 cobra 对齐) |
| `Required` / `MarkRequired` | 非空校验 / cobra 硬必填 | 是(`required`) |
| `RequiredHint`, `Aliases`, `EnvVar` | 校验提示、隐藏别名、环境回退 | 否(执行细节;别名不上主 parameter 表) |
| `ArgDefault`, `Bind`, `OmitEmpty`, `Trim`, `Transform` | toolArgs 装配语义 | 否(载荷细节;`Bind` 可进 property 映射,但不另造 flag) |
#### 1.2.2 编排 / 执行字段(不算声明)
| 字段 | 角色 | 禁止用来「冒充」的声明 |
|---|---|---|
| `Validate` | 条件式/领域校验钩子 | 不得在此 `Flags().String(...)` 注册业务 flag;不得只靠钩子表达「必填/互斥」而不写 `Flags`/`Constraints` |
| `Call` / `Invoke` / `Orchestrate` | 执行体 | 不得 `params[k]=…` 装配业务参数(应在 `Flags`/`ConstParams`) |
| `PostMount` | 挂载后收尾(annotate、领域工具注入) | 不得注册业务 flag;分页等横切由领域工具注入并可走 annotate |
| `RunE` | 逃生舱(整段手写) | 表面事实仍须 Flags 声明;框架仍按 Safety 执行确认 |
| `Server` / `Tool` | MCP 路由 | 不构成 CLI parameter 声明 |
#### 1.2.3 最小声明示例
```go
// 读:Safety 四字段显式对齐 Schema
NewLeafCommand(LeafSpec{
Use: "get", Short: "…", Tool: "…",
Safety: cli.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Flags: []LeafFlag{
{Name: "unified-app-id", Usage: "…", Bind: "unifiedAppId", Trim: true, Required: true},
},
Call: devAppCall(runner), PostMount: devAppMeta(tool),
})
// 写:同一个 SafetySpec 同时驱动确认与 Schema
NewLeafCommand(LeafSpec{
Use: "publish", Short: "…", Tool: "…",
Flags: []LeafFlag{ /* … */ },
Safety: cli.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Call: devAppCall(runner), PostMount: devAppMeta(tool),
})
// 迁移期旧写命令:确认走 annotate,见 §1.3 —— 不是新声明面
NewLeafCommand(LeafSpec{
Use: "create", /* Flags… */,
Validate: func(cmd *cobra.Command, _ []string) error {
return devAppRequireWriteGuard(cmd, "create") // 执行守卫,不是 Contract 声明
},
Call: devAppCall(runner),
PostMount: devAppMetaWrite(tool), // 人工标注 runtime_gate
})
```
**空 `Safety` 的含义**:command 为兼容旧只读叶保留 `read/low/not_required/idempotent` 默认。因此「会改状态却留空 Safety」**不是**合法声明;新 Leaf 必须写完整 SafetySpec,未迁移旧路径则按 §1.3 标注 gate。
### 1.3 人工标注(annotate):声明的补充通道
当事实无法或不愿放进 Contract 字段时,必须**显式**落注解 / 评审源,禁止组装期推断:
| 标注手段 | 典型值 | 何时用 |
|---|---|---|
| `cli.AnnotateRuntimeGate` / `devAppMetaWrite` | `dws.schema.runtime_gate=devAppRequireWriteGuard` | 尚未迁移到 SafetySpec 的旧写命令(`HOM-S2`) |
| `cli.AnnotateRuntimeRisk` | `dws.schema.risk=…` | Shortcut 暂存的旧兼容路径;command/Leaf 禁止新增 |
| `cli.AnnotateRuntimeFlag` / Constraints | 与 embed 同形 | 手写 cobra 叶补齐表面(长期应迁入 Contract) |
| reviewed `schema_hints/metadata` Safety | effect/risk/confirmation | 迁移期遗留;受管命令收敛后让位于 Safety/gate |
标注与声明冲突时:**Contract 声明胜**(路径 A)。标注不得发明未注册的 CLI flag。
### 1.4 Schema 全覆盖(`ToolSpec` 无空洞)
`dws schema` 叶子模型是 `cli.ToolSpec`。命令框架必须为**每一个字段组**指定权威;完整矩阵在 RFC **§5.0.4**,摘要:
| ToolSpec 组 | 权威类 | 框架声明字段 / 其它源 |
|---|---|---|
| Identity | 评审源 | `schema_command_registry` |
| Parameters.`name/type/required/default/property` | **声明**(或同形 annotate) | `Flags` / `Bind` |
| Parameters.`description` | 声明 usage + 可选 hints overlay | `Usage`;hints 不得改 type/required/default |
| Parameters.`interface_*` | 评审源 | MCP meta / bindings;**不造 flag** |
| Constraints | **声明** | `Constraints` |
| Positionals | **声明** 或显式 annotate | 目标 `Args`;禁止推断 |
| Safety.`effect/risk/confirmation/idempotency` | **声明**完整 `Safety`,或迁移期 `runtime_gate` / reviewed Safety | 四字段独立;不得互相推导 |
| DryRun | 评审源 | dry-run capabilities registry |
| Interface | 评审源 | MCP + agent metadata |
| Selection | 评审源 | `schema_hints/selection` |
| FieldProvenance / Extensions | 组装派生或评审扩展 | 组装器;与 delivered value 一致 |
| (非 Schema parameter)ConstParams | **声明** | 载荷;不上 parameters 表 |
验收:新增 Schema 字段必须同步改 RFC §5.0.4 + 本表;受管写命令 Safety 不得无主。
## 2. 字段归属(每一类恰好一个写入者)
| 字段类 | 权威 | 投影到 |
|---|---|---|
| flags / defaults / required / enum / 关系约束 / 运行时 Risk | Contract(LeafSpec / `corecmd.Spec` 门面) | cobra、`--help`、Schema `parameters` / constraints / confirmation |
| ConstParams、Bind、OmitEmpty、Transform | 同上(载荷声明,不上 flag 表) | toolArgs;Schema 不把 ConstParams 伪装成用户 flag |
| canonical path / aliases / navigation / exposure | `schema_command_registry`(+ reviewed manual additions) | Schema identity |
| use_when / avoid_when / examples / agent_summary 文案 | `schema_hints/selection` | Schema selection |
| RPC tool 形状、`interface_ref`、interface 描述 | `schema_mcp_metadata` + `schema_parameter_bindings` | Schema `interface_*` 字段;**不得创建 flag** |
| 参数描述 overlay(可选) | `schema_hints/metadata` 仅补充 usage 文案 | 与 Contract/cobra 冲突时 **Contract/cobra 胜** |
| 遗留 Safety 文案(迁移期) | 今日仍可读 `schema_hints/metadata` safety | 受管命令收敛后以 Contract.Safety / runtime_gate 为准(见 §4) |
| dry-run 正能力 | reviewed dry-run registry | Schema `dry_run` |
| positionals | Contract Args / 显式 annotate | Schema `positionals` |
| FieldProvenance | 组装派生 | Schema provenance(与值一致) |
Identity 与 selection **刻意不**由 Contract 取代(RFC 决策 8 / schema 设计硬规则)。**完整无空洞表见 RFC §5.0.4。**
## 3. 当前缺口与目标闭环
已具备:
- Flags / ConstParams / Constraints → 注册、校验与 `ConstraintHelp`;SafetySpec → 运行时 `ConfirmSafety`(command);
- Call / Execute 作为执行体;业务参数不得在 Call 内装配(helpers 门禁);
- **Contract → Schema 嵌入**:参数/约束写原生 annotation,SafetySpec 与 SchemaDecl 注册为类型化 Contract Final 并由 Schema 组装透传。
仍缺:
1. `HOM-P*` / `HOM-D1` 门禁:禁止 hints 静默改写 type/required/default;help ≡ schema parameters;
2. 重新 `make generate-schema` 后把嵌入结果固化进签入 catalog(注意本机 OOM);identity/selection 仍走评审源。
已落地(写命令确认语义,`HOM-S2` 起点):
- `AnnotateRuntimeGate` / `dws.schema.runtime_gate`;Leaf `PostMount: devAppMetaWrite`;手写 delete/robot 等同路径显式标注;
- Schema 组装在无 Contract Safety 但有 gate 时 overlay `confirmation=user_required`(`applyContractGateToSafety`);
- AST/mount 测试:新 Leaf 须声明完整 SafetySpec;尚未迁移的旧路径须有 runtime_gate。
## 4. Schema 投影与 Safety 门禁规划
以下门禁在落地时应作为独立 policy / 测试交付(门禁 ID 稳定,便于 CI 认领)。
| Gate ID | 断言 | 范围 |
|---|---|---|
| `HOM-P1` | 受管 leaf 的 schema `parameters[].name` 集合 ≡ cobra 本地 flag 名集合(排除全局 persistent) | LeafSpec / 未来 Contract 编译命令 |
| `HOM-P2` | schema parameter `type` / `required` / `default` 与 cobra DefValue / MarkFlagRequired / FlagSpec 一致;hints overlay **不得**改写这三项,只可补 description | 同上 |
| `HOM-P3` | schema 关系约束(require_one_of / mutually_exclusive)≡ Contract/Leaf `Constraints` 投影(与 `AnnotateConstraints` 同构) | 声明了 Constraints 的命令 |
| `HOM-S1` | 若 Contract/Leaf `Risk` ∈ {write, high-risk-write},则 schema `confirmation=user_required`,且 help Safety 行与之同语义 | 使用 Risk 确认的受管命令 |
| `HOM-S2` | 若命令走显式 write guard(如 `devAppRequireWriteGuard`)而非 Risk,则必须人工标注 `dws.schema.runtime_gate`(或等价 reviewed Safety);Schema 不得呈 `confirmation=not_required`;符合 §1.1 declare OR annotate | 今日 devapp 写命令 |
| `HOM-S3` | `Risk=read`(或空→read)不得投影为 `user_required`,除非有 reviewed exclusion reason | 受管读命令 |
| `HOM-I1` | `interface_ref` 存在时,bindings 覆盖的 CLI flag ⊆ Contract flags;MCP meta **不**引入额外 CLI flag | 有 MCP 绑定的命令 |
| `HOM-D1` | `dws <path> --help` Flags 段与 schema leaf parameters 零未解释增量 | 抽样 + 受管全集逐步扩大 |
落地顺序建议:
1. 先对 LeafSpec 命令实现 `HOM-P1`/`HOM-P2`/`HOM-P3`(反射 cobra 即可起步,因 flag 已由 Contract 注册);
2. 再收 `HOM-S1`–`S3`(需产品确认 devapp 是否升格 Risk,或保持 guard + 诚实 provenance);
3. `HOM-I1`/`HOM-D1` 接入 `check-schema-*` / PR 门禁。
## 5. 路径 B 子通道:1:1 MCP 透传叶(可选,非主路径)
仅当同时满足以下条件时,才允许「从 MCP meta 生成 flag/help/schema 参数」:
1. CLI path ↔ 单一 MCP tool **严格 1:1**,无多步、无按名解析、无本地 effect;
2. 无不在 MCP input schema 中的 CLI 特有 flag(含 guard 专用语义 flag 除外的全局 `--yes`/`--dry-run`);
3. 无 ConstParams / Transform / 跨 flag 约束 / Call 内业务逻辑;
4. Risk/confirmation 在 meta 或并列 reviewed Safety 中有显式来源,不靠生成器猜测;
5. 在 registry 中标记 `surface_kind=mcp_passthrough`(名称可调整),且 **不得**与 LeafSpec/Shortcut 手写定义双注册同一 `cli_path`;
6. 文档与门禁写明:该通道是子集优化,失败时回退/禁止扩张到产品 CLI。
显式排除(永远走路径 A / Shortcut):
- 全部 `LeafSpec` 命令(含 `dws dev app …`);
- 全部 `+shortcut` 与 smart 编排;
- 任何需要 `devAppRequireWriteGuard`、cursor 工具注入、或响应投影的命令。
## 6. 非目标
- 不把 selection 文案或 canonical identity 塞进 Contract。
- 不要求删除 LeafSpec 门面。
- 不把「生成 catalog 字节一致」当作运行时同源的充分条件(仍需 `HOM-*` 与差分门禁)。
+47 -25
View File
@@ -5,8 +5,8 @@
| Variable | Purpose / 用途 |
|---------|---------|
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
| `DWS_AGENT_PRODUCT` | Optional, caller-declared Agent product sent through the existing HTTP `claw-type` header (for example `qwenwork`). Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9_-]*$`. Unset or empty values preserve the edition default (`openClaw` in the open-source build). / 可选、由调用方声明的 Agent 产品标识,经校验后覆盖 HTTP `claw-type` 请求头;未设置或为空时保持当前发行版默认值 |
| `DWS_AGENT_HOST` | Optional, caller-declared Agent runtime form sent as `x-dws-agent-host` (for example `cloud` or `desktop`). Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. / 可选、由调用方声明的 Agent 运行形态,经校验后作为 `x-dws-agent-host` 发送;未设置时省略 |
| `DWS_AGENT_PRODUCT` | Optional, caller-declared Agent product sent as `x-dws-agent-product` (for example `qwenwork`) for downstream logs/BI and used as the IM `clawType` display label when `--ai-tag` is enabled. `--ai-tag` defaults to `true`, so a configured Product changes the displayed label by default. With `--ai-tag=false`, native `chat message send` / `reply` calls send an empty `clawType`, while shortcut calls omit the argument. Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9_-]*$`. Unset or empty values omit the Header and use the edition's IM display default. This client never uses Product to change the separate HTTP `claw-type` PAT/routing label. / 可选、由调用方声明的 Agent 产品标识,经校验后作为 `x-dws-agent-product` 发送,并用于 IM 小尾巴;`--ai-tag` 默认为 `true`,因此配置 Product 后默认会改变展示标签。使用 `--ai-tag=false` 时,原生 `chat message send` / `reply` 发送空的 `clawType`,shortcut 调用则省略该参数。未设置时省略请求头且 IM 使用发行版默认值;本客户端不会用 Product 修改独立的 HTTP `claw-type` |
| `DWS_AGENT_HOST` | Optional, caller-declared Agent runtime form sent as `x-dws-agent-host` (for example `cloud` or `desktop`) for downstream logs/BI. Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. This client does not use Host for PAT, authentication, Discovery, or MCP endpoint selection. / 可选、由调用方声明的 Agent 运行形态,经校验后作为 `x-dws-agent-host` 发送给下游日志/BI;本客户端不使用该值进行 PAT、鉴权、Discovery 或 MCP 端点选择,未设置时省略 |
| `DWS_<PRODUCT>_MCP_URL` | Override a product MCP endpoint for local development / 本地开发时覆盖指定产品 MCP endpoint |
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
@@ -14,23 +14,36 @@
| `DWS_ALLOW_HTTP_ENDPOINTS` | Set `1` to allow HTTP for loopback during dev / 设为 `1` 允许回环地址 HTTP,仅用于开发调试 |
| `DWS_DISABLE_KEYCHAIN` | macOS only. Set `1` to skip system Keychain for the encryption key and use file-based storage (same scheme as Linux). For sandboxed runtimes (e.g. Codex App) that block Keychain APIs. Weakens at-rest protection — DEK and ciphertext live in the same directory. / 仅 macOS。设为 `1` 时跳过系统 Keychain,密钥以文件形式存储(与 Linux 一致)。用于 Keychain API 被拦截的沙盒环境(如 Codex App)。代价是 DEK 与密文同目录,保护强度低于默认方案 |
### Agent Product and Host trust model / Agent 产品与运行形态的信任模型
### Agent Product, Host, and `claw-type` / Agent 产品、运行形态与 `claw-type`
`DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` are caller-declared selection and
observation signals. They are not credentials, attestations, or proof of the
calling host's identity. DingTalk services may record them for logs/BI and may
combine supported values with separately authenticated context for PAT
compatibility, PAT identity/source derivation, or Discovery eligibility. A
service must allowlist supported values and must never grant access, bypass
authentication, or skip authorization solely because either Header claims a
particular product or runtime form. They are not used to select ordinary MCP
tool endpoints.
`DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` are caller-declared observation
signals. They are not credentials, attestations, or proof of the calling
host's identity. The CLI validates and emits `x-dws-agent-product` and
`x-dws-agent-host`, but does not use either value to derive its authentication,
PAT mode, Discovery behaviour, or ordinary MCP endpoint selection. Downstream
services own and must document their own contracts for these caller-declared
Headers.
`DWS_AGENT_PRODUCT` controls only the HTTP `claw-type` Header. The similarly
named `clawType` tool argument on IM send operations is an independent
message-display axis used for the “Send from AI” label. It remains controlled
by the active edition's `ClawTypeValue` and `--ai-tag`; changing
`DWS_AGENT_PRODUCT` does not change that message label.
Service integrators should treat both Headers as untrusted input, allowlist
expected values, and should not grant access, bypass authentication, or skip
authorization solely because a Header claims a particular Product or Host.
The HTTP `claw-type` Header is a separate, edition-fixed PAT/routing label:
`openClaw` in the open-source build. `DWS_AGENT_PRODUCT` never changes it or
PAT `hostControl.clawType`. On IM send/reply operations with `--ai-tag`,
however, a valid non-empty Product value is used as the `clawType` tool
argument so the delivered message carries the matching “Send from AI” label.
Because `--ai-tag` defaults to `true`, this display change is enabled by
default for callers that set Product. With `--ai-tag=false`, native
`chat message send` / `reply` calls serialize `clawType: ""`, while shortcut
calls omit the argument; this client does not assume downstream services treat
an empty value and an absent key as equivalent. The display-value precedence
when the tag is enabled is valid non-empty `DWS_AGENT_PRODUCT`, then the active
edition's `ClawTypeValue`, then `openClaw`.
Do not set arbitrary Product values that the target downstream and IM services
have not explicitly enabled; an unknown value may be ignored or may not render
the expected label.
For QwenWork, report the dimensions separately:
@@ -39,16 +52,25 @@ DWS_AGENT_PRODUCT=qwenwork
DWS_AGENT_HOST=cloud # or desktop
```
Do not set arbitrary product values that the target service has not explicitly
enabled. Older combined Host labels such as `qwenwork_cloud` still satisfy the
generic syntax for compatibility, but new integrations should use the
two-dimensional convention above.
Older combined Host labels such as `qwenwork_cloud` still satisfy the generic
syntax for compatibility, but new integrations should use the two-dimensional
convention above.
`DWS_AGENT_PRODUCT` 和 `DWS_AGENT_HOST` 均由调用方声明,不是认证凭据,也不能证明
真实宿主身份。服务端可以在独立认证上下文中将受支持值用于日志/BI、PAT 兼容策略、
PAT 身份/来源派生或 Discovery 准入,但不得仅凭这两个 Header 放权、绕过认证或跳过
授权。HTTP `claw-type` 与 IM 消息发送参数 `clawType` 是两个独立维度;后者仅控制
“Send from AI”展示,仍由发行版 `ClawTypeValue` 和 `--ai-tag` 决定。
真实宿主身份。CLI 只负责校验并发送 `x-dws-agent-product` 与 `x-dws-agent-host`,
不会用它们派生本客户端的鉴权、PAT 模式、Discovery 行为或 MCP 端点;下游服务的
使用契约由对应服务自行定义和说明。HTTP `claw-type` 是发行版固定的 PAT/路由标签,
开源版固定为 `openClaw`,不受 `DWS_AGENT_PRODUCT` 影响。
服务集成方应将这两个请求头视为不可信输入并对白名单值做校验,不应仅因请求头声明了
某个 Product 或 Host 就授予访问、绕过认证或跳过鉴权。
`--ai-tag` 默认为 `true`,因此配置合法非空 Product 后,默认发送的 IM 工具参数
`clawType` 及小尾巴会随之改变。传入 `--ai-tag=false` 时,原生
`chat message send` / `reply` 会发送 `clawType: ""`,shortcut 调用则省略该参数;
本客户端不假定下游会将空值与键缺失等价处理。启用小尾巴时,展示值优先级依次为
`DWS_AGENT_PRODUCT`、当前发行版的 `ClawTypeValue`、`openClaw`。不要传入目标下游及
IM 服务未明确支持的 Product 值,否则可能被忽略或无法展示预期标签。
## Exit Codes / 退出码
File diff suppressed because it is too large Load Diff
+29 -8
View File
@@ -6,12 +6,14 @@ DWS Schema 是当前二进制公开 CLI 的版本化 Agent 执行契约。它描
设计遵循三条硬规则:
1. **Schema 描述 CLI,不制造 CLI。** `CommandRegistry`、manual hint、metadata 和 Catalog 都不能凭空创建 Cobra 命令或 flag;registry 中的每个路径都必须精确绑定真实 runnable Cobra leaf。
1. **Schema 描述 CLI,不制造 CLI。** `CommandRegistry`、manual hint、metadata 和 Catalog 都不能凭空创建 Cobra 命令或 flag;registry 中的每个路径都必须精确绑定真实 runnable Cobra leaf。**`schema_mcp_metadata` 同样不得生成 CLI flag**——它只提供 interface 事实(见 §4.1 同源决策)。
2. **所有来源只解析一次。** 来源经过统一 resolver 进入 typed `SchemaRegistry`,所有查询、导出和门禁都消费同一个 `SchemaRegistry/SchemaIndex`。
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。
**flag / help / schema 参数面同源**(已决策):Contract / LeafSpec 为 CLI 表面权威;分层字段归属与门禁 ID 见 [`flag-help-schema-homology.md`](flag-help-schema-homology.md)。
## 2. 单向数据流
```text
@@ -95,8 +97,10 @@ DWS 与 Lark 保持**架构同构**,而不是强行复制字段:
| typed command/metadata registry | `EffectiveCommandRegistry`、`BoundCommandRegistry` 与最终 `SchemaRegistry` |
| navigation catalog/index | 从同一 `ToolSpec` 派生的 `SchemaIndex` |
| schema renderer/envelope | overview、product/group、leaf、`--all` projections |
| API Commands ← 平台 OAPI meta | **不**作为 DWS 主路径;可选 1:1 MCP 透传子集见同源文档 §5 |
| Shortcuts ← 手写声明 + Execute | LeafSpec / Shortcut + Contract 表面(路径 A) |
共同点是:强类型 registry 持有已审核、已绑定、已解析的事实,index 只负责确定性导航,renderer 只投影,不重新读取来源或做 precedence。DWS 的 base Registry 与 reviewed manual command additions 在绑定前合并为唯一的 `EffectiveCommandRegistry`,因此不存在 “native-first”、“legacy registry fallback” 或 Catalog fallback。
共同点是:强类型 registry 持有已审核、已绑定、已解析的事实,index 只负责确定性导航,renderer 只投影,不重新读取来源或做 precedence。DWS 的 base Registry 与 reviewed manual command additions 在绑定前合并为唯一的 `EffectiveCommandRegistry`,因此不存在 “native-first”、“legacy registry fallback” 或 Catalog fallback。飞书也是**分层单权威**(API 用平台 meta,Shortcut 用手写契约),不是全家只有 meta——DWS 对齐的是这一分层,而不是「用 MCP meta 生成全部 CLI」。
DWS 内部 resolved model 为:
@@ -121,20 +125,33 @@ DWS 当前对外仍保留兼容 wire:leaf 使用 flat `parameters`,安全和
| 来源 | 负责内容 | 明确不负责 |
|---|---|---|
| Contract / LeafSpec / `corecmd.Spec` | **CLI 表面权威**:flags、defaults、required、enum、关系约束、运行时 Risk;编译为 cobra 与 help | canonical identity、selection 文案、虚构 RPC |
| `schema_command_registry.json` | reviewed `CommandRegistry`:稳定 canonical identity、primary CLI path、alias、exposure 和导航 | 创建 Cobra 命令/flag、参数、安全、endpoint/token |
| reviewed manual command additions | 将一个精确存在的 runnable Cobra leaf 合并进 `EffectiveCommandRegistry`;必须 reviewed 且带 reason | 运行时 fallback、覆盖冲突 identity、创建命令 |
| Go/Cobra | 路径是否真实可执行、Cobra 接受的 flag、CLI 类型/默认值、执行校验、help 文本 | 稳定 canonical identity、Agent 场景选择、虚构 RPC |
| Go/Cobra | Contract 编译后的可执行投影:路径是否真实可执行、Cobra 接受的 flag、DefValue、help 文本 | 稳定 canonical identity、Agent 场景选择、虚构 RPC;**不得**成为与 Contract 平行的第二套 flag 权威 |
| native Schema identity annotations | implementation-side consistency evidence;存在时必须与 `EffectiveCommandRegistry` 精确一致 | 提供、补全、推断或覆盖 identity |
| `schema_hints/metadata/*.json` parameter overlays | 精确覆盖现有 flag 的描述、映射、类型和 required 语义;并承载 safety / `runtime_gate` / interface | 创建命令/flag、绕过 completeness、虚构 RPC |
| typed parameter metadata / constraints | `required_when`、one-of、互斥、联动、格式、枚举、位置参数 | 命令 identity |
| `schema_parameter_bindings.json` | 稳定 CLI flag 到 RPC property 的映射 | 命令发现、risk 推断 |
| `schema_mcp_metadata.json` | pinned RPC identity、接口描述和脱敏参数事实 | CLI identity、运行时路由、risk 推断 |
| `schema_hints/metadata/*.json` parameter overlays | 补充 flag **description** 等文案;迁移期可读 safety | 创建命令/flag;**不得**静默改写 type/required/default(与 Contract 冲突时 Contract/cobra 胜);不得绕过 completeness、虚构 RPC |
| typed parameter metadata / constraints | 由 Contract 约束投影而来的 `require_one_of` / 互斥等;以及仍需 reviewed 的 `required_when` 等 | 命令 identity |
| `schema_parameter_bindings.json` | 稳定 CLI flag 到 RPC property 的映射 | 命令发现、risk 推断、创建 CLI flag |
| `schema_mcp_metadata.json` | pinned RPC identity、接口描述和脱敏参数事实(interface 同源) | CLI identity、运行时路由、**创建 CLI flag**、risk 推断 |
| `schema_hints/selection/*.json` | reviewed selection prose(summary / use_when / avoid_when / examples) | 创建 Cobra 命令或参数、改写 safety |
| Skills/Markdown | 产品路由、工作流和使用建议 | 命令存在性和 flag 事实 |
| `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,不构成第二事实源。
### 4.1 flag / help / schema 同源(路径 A + 嵌入)
完整决策、字段归属表、`HOM-*` 门禁规划与可选 MCP 透传准入条件见 [`flag-help-schema-homology.md`](flag-help-schema-homology.md)。
摘要:
- **同源面**:Contract → cobra flags ≡ `--help` Flags ≡ **嵌入注解后的** schema `parameters` / 关系约束;显式 `Risk` 经 `dws.schema.risk` overlay 进 Schema Safety。
- **嵌入点**:`command.embedContractIntoSchema` 写入 `dws.schema.contract` / property / type / required;`AnnotateConstraints` 写入 constraints;Schema 组装(`runtimeToolSpecFromMetadata`)消费这些注解并进入 `go:embed` catalog。
- **硬规则**:CLI 表面事实 = **声明(Contract 数据字段)OR 人工标注**;禁止纯推断。非 CLI 表面字段(identity / selection / interface)必须有**评审源**。声明写法见同源文档 §1.2;标注见 §1.3;**`ToolSpec` 全字段权威见 RFC §5.0.4 / 同源 §1.4**。
- **非同源面(有意)**:identity(registry)、selection 文案(hints/selection)、RPC 形状(MCP meta 仅 `interface_*`)、dry-run 正能力 registry。
- **禁止**:以 MCP meta 为主通道生成 Leaf/Shortcut 的 flag;hints overlay 改写 type/required/default;Schema 字段无权威归属。
## 5. 统一解析与 precedence
### 5.1 Identity
@@ -158,7 +175,7 @@ CI 同时禁止重新加入 generated native contracts 或 materialization 入
`schema_command_registry.json` 是 reviewed source,不是生成快照。它必须保留
`$schema: ./schema_command_registry.schema.json`。该 JSON Schema 对 root、product
和 CommandSpec 全部使用 `additionalProperties: false`,并约束:
和 corecmd.Spec 全部使用 `additionalProperties: false`,并约束:
- canonical identity、`source_product_id` 和精确 CLI path 的格式;
- `aliases` 唯一且不能复用 primary path;
@@ -291,6 +308,7 @@ dws schema --all # 所有工具的完整 leaf 导
- 每个 MCP `interface_ref` 必须在 pinned interface registry 精确存在;local/composite/unavailable 必须满足同一 conflict matrix。
- `--all` 的 tool set 必须与最终 index 一对一,且每个工具包含完整参数契约。
- 连续两次生成必须字节稳定,提交的生成物不得漂移。
- **同源门禁(规划,见同源文档 §4)**:受管命令逐步满足 `HOM-P1`–`P3`(parameters ≡ cobra/Contract)、`HOM-S1`–`S3`(Safety/Risk 对齐)、`HOM-I1`(interface 不创建 flag)、`HOM-D1`(help ≡ schema parameters)。hints 不得作为 type/required/default 的 winner。
推荐本地验证:
@@ -306,8 +324,11 @@ go test ./internal/cli ./internal/app ./internal/generator/... -count=1
- 运行时调用 MCP `tools/list` 或访问网络生成 Schema。
- 从 `schema_catalog/` 等生成 JSON 反向创建/补齐 Cobra leaf、flag、CommandRegistry 或下一轮 Catalog。
- 从 `schema_mcp_metadata` **生成或补齐** LeafSpec/Shortcut 主路径的 CLI flag(interface overlay 除外);未满足同源文档 §5 准入条件时启用「MCP 透传生成通道」。
- 把 native annotation、legacy registry 或 Catalog 当作 identity fallback;或在 `EffectiveCommandRegistry` 之后再次选择 identity winner。
- renderer、query 或 gate 在 `SchemaRegistry` 之后重新读取 source 并做第二次 merge。
- 用 prefix/wildcard exclusion 隐藏未来命令。
- 让 manual hint、CommandRegistry 或 interface metadata 宣称一个不存在的命令、flag 或 RPC 可用。
- 让 `schema_hints/metadata` overlay 在与 Contract/cobra 冲突时赢得 type/required/default。
- 把 `schema --all` 当作普通业务数据查询,或把其完整结果无条件注入 Agent 上下文。
- 将 LeafSpec、`+shortcut` 或 write-guard/cursor/多步命令注册为 `mcp_passthrough` 表面。
+1 -1
View File
@@ -12,6 +12,7 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.0
github.com/itchyny/gojq v0.12.18
github.com/mattn/go-isatty v0.0.20
github.com/muesli/termenv v0.16.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad
github.com/spf13/cobra v1.10.2
@@ -40,7 +41,6 @@ require (
github.com/itchyny/timefmt-go v0.1.7 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
+1 -1
View File
@@ -33,7 +33,7 @@ func init() {
configmeta.Register(configmeta.ConfigItem{
Name: envDWSAgentHost,
Category: configmeta.CategoryExternal,
Description: "调用 DWS 的 Agent 运行形态标识;服务端可结合产品用于观测和 PAT 兼容策略",
Description: "调用 DWS 的 Agent 运行形态标识;作为 x-dws-agent-host 发送供下游观测,本客户端不使用该值改变 PAT、鉴权或路由",
Example: "cloud",
})
}
+19 -18
View File
@@ -24,8 +24,8 @@ func init() {
configmeta.Register(configmeta.ConfigItem{
Name: agentproduct.EnvName,
Category: configmeta.CategoryExternal,
Description: "调用方声明的 Agent 产品标识;覆盖 HTTP claw-type,但不是认证凭据",
DefaultValue: "由当前发行版决定",
Description: "调用方声明的 Agent 产品标识;作为 x-dws-agent-product 发送并用于 IM 小尾巴,本客户端不使用该值改变 HTTP claw-type/PAT",
DefaultValue: "未设置(请求头省略,IM 使用当前发行版默认值)",
Example: "qwenwork",
})
}
@@ -47,25 +47,26 @@ func invalidAgentProductError() error {
)
}
// resolveEffectiveAgentProduct resolves the request-header identity with one
// shared precedence rule: a valid non-empty runtime override wins, otherwise
// the edition's MergeHeaders value wins, otherwise the OSS default is used.
// Invalid runtime input falls back here for library callers that bypass root
// validation; normal CLI execution rejects it before network access.
func resolveEffectiveAgentProduct(headers map[string]string) string {
fallback := edition.DefaultOSSClawType
if value := headers[agentproduct.HeaderName]; value != "" {
fallback = value
// resolveEditionClawType resolves the fixed routing/PAT identity supplied by
// the active edition. DWS_AGENT_PRODUCT is deliberately not consulted.
func resolveEditionClawType(headers map[string]string) string {
if value := headers["claw-type"]; value != "" {
return value
}
value, err := agentproduct.ResolveFromEnv(fallback)
if err != nil {
return fallback
}
return value
return edition.DefaultOSSClawType
}
func applyAgentProductOverride(headers map[string]string) map[string]string {
value := resolveEffectiveAgentProduct(headers)
// applyAgentProductHeader injects only a valid, non-empty caller-declared
// product. Invalid values are omitted on library paths that bypass root
// validation; normal CLI execution rejects them before network access.
func applyAgentProductHeader(headers map[string]string) map[string]string {
value, err := agentproduct.ResolveFromEnv("")
if err != nil || value == "" {
if headers != nil {
delete(headers, agentproduct.HeaderName)
}
return headers
}
if headers == nil {
headers = make(map[string]string)
}
+100 -34
View File
@@ -26,13 +26,16 @@ import (
"github.com/spf13/cobra"
)
func TestUnsetAgentProductKeepsOpenSourceDefault(t *testing.T) {
func TestUnsetAgentProductOmitsHeaderAndKeepsOpenSourceClawType(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != edition.DefaultOSSClawType {
t.Fatalf("%s = %q, want %q", agentproduct.HeaderName, got, edition.DefaultOSSClawType)
if got := headers["claw-type"]; got != edition.DefaultOSSClawType {
t.Fatalf("claw-type = %q, want %q", got, edition.DefaultOSSClawType)
}
if _, ok := headers[agentproduct.HeaderName]; ok {
t.Fatalf("unset Product must omit %s", agentproduct.HeaderName)
}
}
@@ -59,38 +62,46 @@ func TestParseAgentProductReturnsStableValidationError(t *testing.T) {
}
}
func TestResolveIdentityHeadersAgentProductPrecedence(t *testing.T) {
func TestResolveIdentityHeadersSeparatesAgentProductFromClawType(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
headers["claw-type"] = "wukong"
headers[agentproduct.HeaderName] = "merge-product-must-not-win"
headers["x-edition-header"] = "preserved"
return headers
},
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "enterprise-default"
headers["claw-type"] = "credential-must-not-win"
headers[agentproduct.HeaderName] = "credential-product-must-not-win"
headers["x-enterprise-header"] = "preserved"
return headers
},
})
t.Run("unset keeps edition default", func(t *testing.T) {
t.Run("unset omits Product and keeps edition claw-type", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, "")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "wukong" {
t.Fatalf("%s = %q, want wukong", agentproduct.HeaderName, got)
if got := headers["claw-type"]; got != "wukong" {
t.Fatalf("claw-type = %q, want wukong", got)
}
if _, ok := headers[agentproduct.HeaderName]; ok {
t.Fatalf("unset Product must omit %s", agentproduct.HeaderName)
}
})
t.Run("valid override is final", func(t *testing.T) {
t.Run("valid Product is final without changing claw-type", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, " qwenwork ")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
}
if got := headers["claw-type"]; got != "wukong" {
t.Fatalf("claw-type = %q, want wukong", got)
}
if got := headers["x-edition-header"]; got != "preserved" {
t.Fatalf("edition header = %q, want preserved", got)
}
@@ -102,21 +113,48 @@ func TestResolveIdentityHeadersAgentProductPrecedence(t *testing.T) {
}
})
t.Run("invalid library input falls back to edition", func(t *testing.T) {
t.Run("invalid library input omits Product and keeps edition claw-type", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwen work")
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "wukong" {
t.Fatalf("%s = %q, want wukong", agentproduct.HeaderName, got)
if got := headers["claw-type"]; got != "wukong" {
t.Fatalf("claw-type = %q, want wukong", got)
}
if _, ok := headers[agentproduct.HeaderName]; ok {
t.Fatalf("invalid Product must omit %s", agentproduct.HeaderName)
}
})
}
func TestApplyAgentProductOverrideAllocatesHeaders(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwenwork")
func TestApplyAgentProductHeader(t *testing.T) {
t.Run("valid value allocates headers", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, "qwenwork")
headers := applyAgentProductOverride(nil)
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
headers := applyAgentProductHeader(nil)
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
}
})
for _, tc := range []struct {
name string
value string
}{
{name: "empty value", value: ""},
{name: "invalid value", value: "qwen work"},
} {
t.Run(tc.name+" removes inherited header", func(t *testing.T) {
t.Setenv(agentproduct.EnvName, tc.value)
headers := applyAgentProductHeader(map[string]string{
agentproduct.HeaderName: "must-not-leak",
"x-preserved": "yes",
})
if _, ok := headers[agentproduct.HeaderName]; ok {
t.Fatalf("%s must be omitted", agentproduct.HeaderName)
}
if got := headers["x-preserved"]; got != "yes" {
t.Fatalf("x-preserved = %q, want yes", got)
}
})
}
}
@@ -167,17 +205,17 @@ func TestEffectiveClawTypeDoesNotInvokeEnterpriseCredentialHeaders(t *testing.T)
hookCalled := false
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
headers["claw-type"] = "wukong"
return headers
},
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
hookCalled = true
headers[agentproduct.HeaderName] = "enterprise-default"
headers["claw-type"] = "enterprise-default"
return headers
},
})
t.Setenv(agentproduct.EnvName, "")
t.Setenv(agentproduct.EnvName, "qwenwork")
if got := effectiveClawType(); got != "wukong" {
t.Fatalf("effectiveClawType() = %q, want wukong", got)
}
@@ -186,7 +224,7 @@ func TestEffectiveClawTypeDoesNotInvokeEnterpriseCredentialHeaders(t *testing.T)
}
}
func TestAgentProductHeaderIsSeparateFromMessageClawType(t *testing.T) {
func TestAgentProductControlsObservabilityHeaderAndMessageClawTypeOnly(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "qwenwork")
@@ -195,20 +233,24 @@ func TestAgentProductHeaderIsSeparateFromMessageClawType(t *testing.T) {
edition.Override(&edition.Hooks{
ClawTypeValue: "message-brand",
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
headers["claw-type"] = "wukong"
return headers
},
})
if got := resolveIdentityHeaders()[agentproduct.HeaderName]; got != "qwenwork" {
headers := resolveIdentityHeaders()
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("HTTP %s = %q, want qwenwork", agentproduct.HeaderName, got)
}
if got := edition.ClawType(); got != "message-brand" {
t.Fatalf("message clawType = %q, want message-brand", got)
if got := headers["claw-type"]; got != "wukong" {
t.Fatalf("HTTP claw-type = %q, want wukong", got)
}
if got := edition.ClawType(); got != "qwenwork" {
t.Fatalf("message clawType = %q, want qwenwork", got)
}
}
func TestResolveIdentityHeadersRestoresAgentProductAfterNilCredentialHeaders(t *testing.T) {
func TestResolveIdentityHeadersRestoresIdentityAfterNilCredentialHeaders(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "qwenwork")
@@ -218,7 +260,7 @@ func TestResolveIdentityHeadersRestoresAgentProductAfterNilCredentialHeaders(t *
credentialHookCalled := false
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
headers["claw-type"] = "wukong"
return headers
},
EnterpriseCredentialHeaders: func(map[string]string) map[string]string {
@@ -234,25 +276,49 @@ func TestResolveIdentityHeadersRestoresAgentProductAfterNilCredentialHeaders(t *
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
}
if got := headers["claw-type"]; got != "wukong" {
t.Fatalf("claw-type = %q, want wukong", got)
}
}
func TestEffectiveClawTypeUsesAgentProductOverride(t *testing.T) {
func TestResolveIdentityHeadersRestoresDefaultsAfterNilMergeHeaders(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(agentproduct.EnvName, "")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(map[string]string) map[string]string {
return nil
},
})
headers := resolveIdentityHeaders()
if got := headers["claw-type"]; got != edition.DefaultOSSClawType {
t.Fatalf("claw-type = %q, want %q", got, edition.DefaultOSSClawType)
}
if _, ok := headers[agentproduct.HeaderName]; ok {
t.Fatalf("unset Product must omit %s", agentproduct.HeaderName)
}
}
func TestEffectiveClawTypeIgnoresAgentProduct(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
headers[agentproduct.HeaderName] = "wukong"
headers["claw-type"] = "wukong"
return headers
},
})
t.Setenv(agentproduct.EnvName, "qwenwork")
if got := effectiveClawType(); got != "qwenwork" {
t.Fatalf("effectiveClawType() = %q, want qwenwork", got)
if got := effectiveClawType(); got != "wukong" {
t.Fatalf("effectiveClawType() = %q, want wukong", got)
}
t.Setenv(authpkg.AgentCodeEnv, "agent-code")
if got := apperrors.HostControlBlock()["clawType"]; got != "qwenwork" {
t.Fatalf("hostControl.clawType = %q, want qwenwork", got)
if got := apperrors.HostControlBlock()["clawType"]; got != "wukong" {
t.Fatalf("hostControl.clawType = %q, want wukong", got)
}
t.Setenv(agentproduct.EnvName, "")
+74
View File
@@ -6,6 +6,8 @@ import (
"encoding/csv"
"encoding/json"
"fmt"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"os"
"path/filepath"
"strconv"
@@ -64,6 +66,31 @@ func newAuditTailCommand() *cobra.Command {
},
}
cmd.Flags().IntVarP(&n, "lines", "n", 20, "显示最近 N 条记录")
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
Safety: cli.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Schema: helpers.LeafSchema{
Description: "查看本地操作审计日志最近 N 条记录",
Interface: &helpers.LeafInterfaceDecl{
Mode: "local", Availability: "available",
Reason: "命令读取本地审计日志尾部,不绑定 pinned MCP RPC",
},
Selection: helpers.LeafSelectionDecl{
AgentSummary: "查看本地操作审计日志最近 N 条记录",
UseWhen: []string{"需要快速查看最近写入的审计记录(默认最近 20 条)"},
AvoidWhen: []string{
"需要按日期范围整段导出用 audit export",
"需要校验哈希链用 audit verify",
},
Examples: []string{
"dws audit tail",
"dws audit tail --lines 50",
},
},
},
})
return cmd
}
@@ -99,6 +126,31 @@ func newAuditExportCommand() *cobra.Command {
cmd.Flags().StringVar(&since, "since", "", "起始日期 (YYYY-MM-DD)")
cmd.Flags().StringVar(&until, "until", "", "截止日期 (YYYY-MM-DD)")
cmd.Flags().StringVar(&format, "format", "jsonl", "输出格式: jsonl 或 csv")
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
Safety: cli.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Schema: helpers.LeafSchema{
Description: "按日期范围导出本地操作审计日志(jsonl 或 csv)",
Interface: &helpers.LeafInterfaceDecl{
Mode: "local", Availability: "available",
Reason: "命令读取并导出本地审计日志文件,不绑定 pinned MCP RPC",
},
Selection: helpers.LeafSelectionDecl{
AgentSummary: "按日期范围导出本地操作审计日志(jsonl 或 csv)",
UseWhen: []string{"需要把本地审计日志导出为 jsonl/csv,或按 --since/--until 取一段时间"},
AvoidWhen: []string{
"只看最近几条用 audit tail",
"只校验哈希链完整性用 audit verify",
},
Examples: []string{
"dws audit export --format jsonl",
"dws audit export --since 2026-07-01 --until 2026-07-14 --format csv",
},
},
},
})
return cmd
}
@@ -152,6 +204,28 @@ func newAuditVerifyCommand() *cobra.Command {
},
}
cmd.Flags().StringVar(&file, "file", "", "指定审计文件路径(默认最新文件)")
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
Safety: cli.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Schema: helpers.LeafSchema{
Description: "校验本地审计日志文件的哈希链完整性",
Interface: &helpers.LeafInterfaceDecl{
Mode: "local", Availability: "available",
Reason: "命令校验本地审计日志哈希链,不绑定 pinned MCP RPC",
},
Selection: helpers.LeafSelectionDecl{
AgentSummary: "校验本地审计日志文件的哈希链完整性",
UseWhen: []string{"怀疑审计文件被篡改,或需要确认最新/指定文件哈希链是否完整"},
AvoidWhen: []string{"只浏览或导出日志内容时用 audit tail / audit export"},
Examples: []string{
"dws audit verify",
"dws audit verify --file /path/to/audit.jsonl",
},
},
},
})
return cmd
}
+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 {
+124 -35
View File
@@ -18,6 +18,7 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"io"
"log/slog"
"os"
@@ -31,7 +32,6 @@ import (
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
@@ -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{
@@ -361,6 +361,38 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
Variadic: true,
Index: 0,
})
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
Safety: cli.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Schema: helpers.LeafSchema{
Description: "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
Interface: &helpers.LeafInterfaceDecl{
Mode: "composite", Availability: "available",
Reason: "Reviewed composite workflow: the command creates or reuses a remote personal-event subscription and coordinates the local event bus and Stream consumer; no single pinned RPC represents the workflow.",
},
Selection: helpers.LeafSelectionDecl{
AgentSummary: "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
UseWhen: []string{
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"用户明确要求监听当前身份的所有单聊或所有群消息",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"需要监听指定群的标题变更、成员进退群或群解散事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标",
"同一目标、同一过滤条件需要同时监听多个兼容事件",
},
AvoidWhen: []string{
"只查历史聊天记录时用 chat 查询命令",
"只看事件目录/字段时用 event list / event schema",
},
Examples: []string{
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_receive_o2o user_im_message_read_o2o --user test-user-001 --flatten --max-events 2 --format ndjson",
},
},
},
})
return cmd
}
@@ -728,6 +760,28 @@ func newEventListCommand() *cobra.Command {
cmd.Flags().BoolVar(&enabledOnly, "enabled-only", false, "个人事件目录只显示 enabled")
cmd.Flags().BoolVar(&includePending, "include-pending", false, "个人事件目录包含 pending 项")
hideEventInternalFlags(cmd, "as", "all", "all-editions", "client-id")
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
Safety: cli.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Schema: helpers.LeafSchema{
Description: "列出支持的个人事件目录与状态说明",
Interface: &helpers.LeafInterfaceDecl{
Mode: "local", Availability: "available",
Reason: "命令读取 CLI 内置的个人事件目录,不绑定 pinned MCP RPC",
},
Selection: helpers.LeafSelectionDecl{
AgentSummary: "列出支持的个人事件目录与状态说明",
UseWhen: []string{"尚不知道可用 event_key,需要先盘点个人事件目录"},
AvoidWhen: []string{
"已知 event_key 要看 payload 字段时用 event schema",
"要开始监听时用 event consume",
},
Examples: []string{"dws event list --format json"},
},
},
})
return cmd
}
@@ -796,6 +850,25 @@ func newEventStatusCommand() *cobra.Command {
cmd.Flags().StringVar(&personalOpts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
"个人事件 sourceId;开源版默认 open,可由 edition 覆盖")
hideEventInternalFlags(cmd, "as", "all", "all-editions", "client-id", "fail-on-orphan")
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
Safety: cli.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Schema: helpers.LeafSchema{
Description: "查看个人事件订阅、本地 bus 与消费进程状态",
Interface: &helpers.LeafInterfaceDecl{
Mode: "composite", Availability: "available",
Reason: "Reviewed composite workflow: the command reads the remote personal-event subscription control plane and combines it with local bus and consumer state; no single pinned RPC represents the result.",
},
Selection: helpers.LeafSelectionDecl{
AgentSummary: "查看个人事件订阅、本地 bus 与消费进程状态",
UseWhen: []string{"需要确认订阅是否活跃、bus/consume 是否仍在运行"},
AvoidWhen: []string{"取消订阅用 event stop;列事件目录用 event list"},
Examples: []string{"dws event status --format json"},
},
},
})
return cmd
}
@@ -1037,39 +1110,22 @@ func newEventStopCommand() *cobra.Command {
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, args []string) error {
// Local validation runs in DeclareLeafMetadata Validate (PreRunE)
// before ConfirmSafety. Confirmation is the framework wrap.
as, err := eventNormalizeAs(asIdentity)
if err != nil {
return err
}
if as == "user" {
opts.SubscribeID = firstArg(args)
hasSubscribeID := strings.TrimSpace(opts.SubscribeID) != ""
if hasSubscribeID && opts.All {
return fmt.Errorf("event stop --as user: subscribe_id and --all are mutually exclusive")
}
if !hasSubscribeID && !opts.All {
return fmt.Errorf("event stop --as user: subscribe_id is required unless --all is set")
}
if eventStopDryRun(c) {
return writeEventStopDryRun(c, as, opts)
}
if !eventStopConfirmed(c) {
return eventStopConfirmationRequired("event stop 会取消个人事件订阅并停止本地消费")
}
return eventRunPersonalStop(c, opts)
}
if err := rejectChangedFlags(c, "user", "all", "personal-event-base-url", "stream-source-id"); err != nil {
return fmt.Errorf("event stop: %w", err)
}
if len(args) > 0 {
return fmt.Errorf("event stop: subscribe_id is only supported with --as user")
}
if eventStopDryRun(c) {
return writeEventStopDryRun(c, as, opts)
}
if !eventStopConfirmed(c) {
return eventStopConfirmationRequired("event stop 会停止事件消费")
}
configDir := defaultConfigDir()
clientID, _, _, _, err := eventResolveAppCredentials(configDir)
if err != nil {
@@ -1102,6 +1158,53 @@ func newEventStopCommand() *cobra.Command {
Required: false,
Index: 0,
})
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
Safety: cli.SafetySpec{
Effect: "destructive", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
Validate: func(c *cobra.Command, args []string) error {
as, err := eventNormalizeAs(asIdentity)
if err != nil {
return err
}
if as == "user" {
subscribeID := firstArg(args)
hasSubscribeID := strings.TrimSpace(subscribeID) != ""
if hasSubscribeID && opts.All {
return fmt.Errorf("event stop --as user: subscribe_id and --all are mutually exclusive")
}
if !hasSubscribeID && !opts.All {
return fmt.Errorf("event stop --as user: subscribe_id is required unless --all is set")
}
return nil
}
if err := rejectChangedFlags(c, "user", "all", "personal-event-base-url", "stream-source-id"); err != nil {
return fmt.Errorf("event stop: %w", err)
}
if len(args) > 0 {
return fmt.Errorf("event stop: subscribe_id is only supported with --as user")
}
return nil
},
Schema: helpers.LeafSchema{
Description: "取消个人事件订阅并停止对应本地消费",
DryRun: &helpers.LeafDryRunDecl{PreviewKind: "request", RemoteReads: false},
Interface: &helpers.LeafInterfaceDecl{
Mode: "composite", Availability: "available",
Reason: "Reviewed composite workflow: the command deletes remote personal-event subscriptions, interrupts local consumers, updates local state, and may stop the local bus; no single pinned RPC represents the workflow.",
},
Selection: helpers.LeafSelectionDecl{
AgentSummary: "取消个人事件订阅并停止对应本地消费",
UseWhen: []string{"用户明确要求取消已知 subscribe_id(或清理全部)并停止消费"},
AvoidWhen: []string{
"只需检查状态时用 event status",
"目标订阅不明确或用户未确认时不要停止",
},
Examples: []string{"dws event stop SUBSCRIBE_ID --dry-run"},
},
},
})
return cmd
}
@@ -1110,20 +1213,6 @@ func eventStopDryRun(cmd *cobra.Command) bool {
return value
}
func eventStopConfirmed(cmd *cobra.Command) bool {
value, _ := cmd.Flags().GetBool("yes")
return value
}
func eventStopConfirmationRequired(action string) error {
return apperrors.NewValidation(
action+";请先使用 --dry-run 预览,确认后加 --yes 执行",
apperrors.WithReason("confirmation_required"),
apperrors.WithHint("先以相同参数加 --dry-run 预览;获得用户确认后改用 --yes 执行"),
apperrors.WithActions("使用 --dry-run 生成预览", "获得用户确认后使用 --yes 执行"),
)
}
func writeEventStopDryRun(cmd *cobra.Command, identity string, opts personalStopOptions) error {
payload := map[string]any{
"dry_run": true,
+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
+252 -113
View File
@@ -20,6 +20,7 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"io"
"net/http"
"os"
@@ -177,6 +178,28 @@ func newEventSchemaCommand() *cobra.Command {
Required: true,
Index: 0,
})
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
Safety: cli.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Schema: helpers.LeafSchema{
Description: "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
Interface: &helpers.LeafInterfaceDecl{
Mode: "local", Availability: "available",
Reason: "命令读取 CLI 内置的个人事件 payload 定义,不绑定 pinned MCP RPC",
},
Selection: helpers.LeafSelectionDecl{
AgentSummary: "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
UseWhen: []string{"已知任一公开个人 IM event_key,消费前需要理解输出字段或保守 payload 契约"},
AvoidWhen: []string{
"查询 CLI 命令参数契约时用顶层 dws schema",
"要实际收事件时用 event consume",
},
Examples: []string{"dws event schema user_im_message_receive_at --flatten --format json"},
},
},
})
return cmd
}
@@ -227,7 +250,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 +261,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 +278,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 +307,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 +410,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 +434,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 +453,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 +478,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 +489,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 +503,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 +527,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 +540,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 +566,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 +617,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 +816,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 +891,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 +962,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 +1224,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)
}
}
+23
View File
@@ -16,6 +16,7 @@ package app
import (
"encoding/json"
"fmt"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
@@ -81,6 +82,28 @@ func newMCPURLGetCommand(caller edition.ToolCaller) *cobra.Command {
Required: true,
Index: 0,
})
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
Safety: cli.SafetySpec{
Effect: "read", Risk: "medium",
Confirmation: "not_required", Idempotency: "idempotent",
},
Schema: helpers.LeafSchema{
Description: "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址",
Interface: &helpers.LeafInterfaceDecl{
Mode: "composite", Availability: "available",
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.",
},
Selection: helpers.LeafSelectionDecl{
AgentSummary: "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址",
UseWhen: []string{"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"},
AvoidWhen: []string{
"只是查询 DWS 已公开命令或参数时使用 dws schema",
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播",
},
Examples: []string{"dws mcp url get 10043 --format json"},
},
},
})
return cmd
}
+826
View File
@@ -0,0 +1,826 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"context"
"encoding/json"
stderrors "errors"
"io"
"os"
"reflect"
"sort"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type paramAliasToolCall struct {
server string
tool string
args map[string]any
}
type paramAliasCaptureCaller struct {
calls []paramAliasToolCall
}
func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
copyArgs := make(map[string]any, len(args))
for key, value := range args {
copyArgs[key] = value
}
c.calls = append(c.calls, paramAliasToolCall{server: server, tool: tool, args: copyArgs})
text := paramAliasResponseForTool(tool)
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
// paramAliasResponseForTool supplies deterministic, business-shape-valid
// responses for the complete-command equivalence matrix. Most commands only
// print the transport result and need an empty object; smart shortcuts that
// inspect a read response receive the smallest shape that lets their full RunE
// complete without falling back to a validation error.
func paramAliasResponseForTool(tool string) string {
switch tool {
case "list_calendar_events":
return `{"result":{"events":[]}}`
case "search_mail_users":
return `{"users":[{"name":"Fixture User","email":"fixture@example.com","id":"fixture-user"}]}`
case "search_dept_by_keyword":
return `{"deptList":[{"deptId":1,"name":"Fixture Dept"}]}`
case "search_groups":
return `{"result":{"items":[{"openConversationId":"fixture-conversation","title":"Fixture Group"}]}}`
default:
return `{}`
}
}
func (*paramAliasCaptureCaller) Format() string { return "json" }
func (*paramAliasCaptureCaller) DryRun() bool { return false }
func (*paramAliasCaptureCaller) Fields() string { return "" }
func (*paramAliasCaptureCaller) JQ() string { return "" }
// paramAliasCaptureRunner covers helpers (currently dev app) that dispatch
// through executor.Runner instead of edition.ToolCaller. Keeping both capture
// boundaries in one call list lets the matrix compare the final request shape
// without knowing which transport adapter a command uses.
type paramAliasCaptureRunner struct {
caller *paramAliasCaptureCaller
}
func (r *paramAliasCaptureRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
copyArgs := make(map[string]any, len(invocation.Params))
for key, value := range invocation.Params {
copyArgs[key] = value
}
r.caller.calls = append(r.caller.calls, paramAliasToolCall{
server: invocation.CanonicalProduct,
tool: invocation.Tool,
args: copyArgs,
})
invocation.Implemented = true
return executor.Result{Invocation: invocation, Response: map[string]any{}}, nil
}
type paramAliasDryRunRejectRunner struct {
attempts []executor.Invocation
}
func (r *paramAliasDryRunRejectRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.attempts = append(r.attempts, invocation)
return executor.Result{}, stderrors.New("dry-run reached the injected command runner")
}
type paramAliasDryRunPreview struct {
DryRun bool `json:"dry_run"`
Executed bool `json:"executed"`
Tool string `json:"tool"`
Arguments map[string]any `json:"arguments"`
}
// executeParamAliasDryRunE2E uses the existing root --dry-run barrier as a
// parameter-normalization probe. These commands do not publish command-owned
// dry-run capabilities in Schema; the test deliberately makes no such claim.
// A reject runner proves the preview stops before endpoint resolution,
// authentication, or transport execution.
func executeParamAliasDryRunE2E(t *testing.T, args ...string) (*pipeline.Context, paramAliasDryRunPreview, []executor.Invocation, error) {
t.Helper()
originalArgs := os.Args
os.Args = append([]string{"dws"}, args...)
defer func() { os.Args = originalArgs }()
captureFile, err := os.CreateTemp(t.TempDir(), "param-alias-dry-run-*.json")
if err != nil {
t.Fatalf("create dry-run output capture: %v", err)
}
defer captureFile.Close()
originalStdout := os.Stdout
originalCaller := helpers.GetCaller()
os.Stdout = captureFile
defer func() {
os.Stdout = originalStdout
helpers.InitDeps(originalCaller)
}()
rejectRunner := &paramAliasDryRunRejectRunner{}
originalRunnerFactory := rootNewCommandRunnerWithFlags
rootNewCommandRunnerWithFlags = func(cli.CatalogLoader, *GlobalFlags) executor.Runner {
return rejectRunner
}
root := NewRootCommand()
rootNewCommandRunnerWithFlags = originalRunnerFactory
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
ctx, executeErr := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if executeErr == nil {
executeErr = root.Execute()
}
if err := captureFile.Sync(); err != nil {
t.Fatalf("sync dry-run output capture: %v", err)
}
if _, err := captureFile.Seek(0, io.SeekStart); err != nil {
t.Fatalf("rewind dry-run output capture: %v", err)
}
output, err := io.ReadAll(captureFile)
if err != nil {
t.Fatalf("read dry-run output capture: %v", err)
}
var preview paramAliasDryRunPreview
if executeErr == nil {
if err := json.Unmarshal(output, &preview); err != nil {
t.Fatalf("decode dry-run preview: %v\noutput=%s", err, output)
}
}
return ctx, preview, append([]executor.Invocation(nil), rejectRunner.attempts...), executeErr
}
func executeParamAliasE2E(t *testing.T, caller *paramAliasCaptureCaller, args ...string) (*pipeline.Context, error) {
t.Helper()
originalArgs := os.Args
os.Args = append([]string{"dws"}, args...)
defer func() { os.Args = originalArgs }()
originalRunnerFactory := rootNewCommandRunnerWithFlags
rootNewCommandRunnerWithFlags = func(cli.CatalogLoader, *GlobalFlags) executor.Runner {
return &paramAliasCaptureRunner{caller: caller}
}
root := NewRootCommand()
rootNewCommandRunnerWithFlags = originalRunnerFactory
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
originalCaller := helpers.GetCaller()
helpers.InitDeps(caller)
defer helpers.InitDeps(originalCaller)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
return ctx, err
}
return ctx, root.Execute()
}
func TestBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
tests := []struct {
name string
confirmation []string
wantError string
wantCalls int
wantOriginal string
wantCorrection string
}{
{name: "bare yes confirms", confirmation: []string{"--yes"}, wantCalls: 1},
{name: "glued false stays unconfirmed", confirmation: []string{"--yesfalse"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yesfalse", wantCorrection: "--yes=false"},
{name: "glued true confirms", confirmation: []string{"--yestrue"}, wantCalls: 1, wantOriginal: "--yestrue", wantCorrection: "--yes=true"},
{name: "detached false stays unconfirmed", confirmation: []string{"--yes", "false"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yes false", wantCorrection: "--yes=false"},
{name: "detached no stays unconfirmed", confirmation: []string{"--yes", "no"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yes no", wantCorrection: "--yes=false"},
{name: "detached zero stays unconfirmed", confirmation: []string{"--yes", "0"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yes 0", wantCorrection: "--yes=false"},
{name: "detached true confirms", confirmation: []string{"--yes", "true"}, wantCalls: 1, wantOriginal: "--yes true", wantCorrection: "--yes=true"},
{name: "detached yes confirms", confirmation: []string{"--yes", "yes"}, wantCalls: 1, wantOriginal: "--yes yes", wantCorrection: "--yes=true"},
{name: "detached one confirms", confirmation: []string{"--yes", "1"}, wantCalls: 1, wantOriginal: "--yes 1", wantCorrection: "--yes=true"},
{name: "explicit false remains unconfirmed", confirmation: []string{"--yes=false"}, wantError: "请添加 --yes 确认执行"},
{name: "explicit true confirms", confirmation: []string{"--yes=true"}, wantCalls: 1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &paramAliasCaptureCaller{}
args := []string{
"mail", "thread", "trash",
"--email", "user@example.com",
"--id", "conversation-1",
}
args = append(args, test.confirmation...)
ctx, err := executeParamAliasE2E(t, caller, args...)
if test.wantError == "" {
if err != nil {
t.Fatalf("confirmed command error = %v", err)
}
} else if err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("command error = %v, want substring %q", err, test.wantError)
}
if test.wantCorrection == "" {
if ctx != nil && len(ctx.Corrections) != 0 {
t.Fatalf("confirmation spelling received corrections: %#v", ctx.Corrections)
}
} else if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != test.wantOriginal || ctx.Corrections[0].Corrected != test.wantCorrection {
t.Fatalf("confirmation corrections = %#v, want %q -> %q", ctx, test.wantOriginal, test.wantCorrection)
}
if len(caller.calls) != test.wantCalls {
t.Fatalf("destructive calls = %#v, want %d", caller.calls, test.wantCalls)
}
})
}
}
func TestParamAliasReadCommandFinalPayload(t *testing.T) {
caller := &paramAliasCaptureCaller{}
start := "2026-03-10T14:00:00+08:00"
end := "2026-03-10T18:00:00+08:00"
ctx, err := executeParamAliasE2E(t, caller,
"calendar", "event", "list",
"--date", start,
"--end-time", end,
"--calendar", "primary",
"--max-results", "7",
"--next-cursor", "cursor-1",
)
if err != nil {
t.Fatalf("calendar alias E2E error = %v", err)
}
if len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--date" || ctx.Corrections[0].Corrected != "--start" {
t.Fatalf("calendar corrections = %#v, want only --date to be normalized centrally", ctx.Corrections)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "list_calendar_events" {
t.Fatalf("calendar calls = %#v", caller.calls)
}
startMS, _ := cmdutil.ParseISOTimeToMillis("start", start)
endMS, _ := cmdutil.ParseISOTimeToMillis("end", end)
want := map[string]any{
"startTime": startMS,
"endTime": endMS,
"calendarId": "primary",
"limit": 7,
"cursor": "cursor-1",
}
if !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("calendar payload = %#v, want %#v", caller.calls[0].args, want)
}
}
func TestParamAliasWriteCommandFinalPayload(t *testing.T) {
caller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasE2E(t, caller,
"chat", "message", "send",
"--to-user", "D-recipient",
"--text", "hello alias",
"--uuid", "alias-e2e",
)
if err != nil {
t.Fatalf("chat write alias E2E error = %v", err)
}
if len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--to-user" || ctx.Corrections[0].Corrected != "--user" {
t.Fatalf("chat corrections = %#v", ctx.Corrections)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "send_personal_message" {
t.Fatalf("chat calls = %#v", caller.calls)
}
payload := caller.calls[0].args
if payload["receiverOpenDingTalkId"] != "D-recipient" || payload["uuid"] != "alias-e2e" || payload["msgType"] != "markdown" {
t.Fatalf("chat payload identity fields = %#v", payload)
}
content, _ := payload["content"].(string)
if !strings.Contains(content, "hello alias") {
t.Fatalf("chat payload content = %q", content)
}
for _, forbidden := range []string{"user", "to-user", "userId"} {
if _, exists := payload[forbidden]; exists {
t.Fatalf("chat payload leaked pre-normalization field %q: %#v", forbidden, payload)
}
}
}
func TestChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
tests := []struct {
name string
command []string
tool string
required []string
}{
{
name: "add emoji",
command: []string{"chat", "message", "add-emoji"},
tool: "add_emoji_reaction",
required: []string{"--msg-id", "message-1", "--emoji", "like"},
},
{
name: "remove emoji",
command: []string{"chat", "message", "remove-emoji"},
tool: "remove_emoji_reaction",
required: []string{"--msg-id", "message-1", "--emoji", "like"},
},
{
name: "add text emotion",
command: []string{"chat", "message", "add-text-emotion"},
tool: "add_text_emotion",
required: []string{
"--msg-id", "message-1", "--emotion-id", "emotion-1",
"--emotion-name", "like", "--text", "nice", "--background-id", "background-1",
},
},
{
name: "remove text emotion",
command: []string{"chat", "message", "remove-text-emotion"},
tool: "remove_text_emotion",
required: []string{
"--msg-id", "message-1", "--emotion-id", "emotion-1",
"--emotion-name", "like", "--text", "nice", "--background-id", "background-1",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
canonicalArgs := append([]string(nil), test.command...)
canonicalArgs = append(canonicalArgs, "--conversation-id", "conversation-1")
canonicalArgs = append(canonicalArgs, test.required...)
canonicalCaller := &paramAliasCaptureCaller{}
if _, err := executeParamAliasE2E(t, canonicalCaller, canonicalArgs...); err != nil {
t.Fatalf("canonical execution failed: %v", err)
}
if len(canonicalCaller.calls) != 1 || canonicalCaller.calls[0].tool != test.tool {
t.Fatalf("canonical calls = %#v, want one %s call", canonicalCaller.calls, test.tool)
}
if canonicalCaller.calls[0].args["openConversationId"] != "conversation-1" {
t.Fatalf("canonical payload = %#v", canonicalCaller.calls[0].args)
}
// Numeric --group-id is a different identifier domain and is covered
// by TestAllReviewedParamAliasGuardsReachRuntimeContract.
for _, alias := range []string{"chat-id", "open-conversation-id"} {
t.Run(alias, func(t *testing.T) {
aliasArgs := append([]string(nil), test.command...)
aliasArgs = append(aliasArgs, "--"+alias, "conversation-1")
aliasArgs = append(aliasArgs, test.required...)
aliasCaller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasE2E(t, aliasCaller, aliasArgs...)
if err != nil {
t.Fatalf("alias execution failed: %v", err)
}
if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--"+alias || ctx.Corrections[0].Corrected != "--conversation-id" {
t.Fatalf("alias corrections = %#v", ctx)
}
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("final calls differ\ncanonical=%#v\nalias=%#v", canonicalCaller.calls, aliasCaller.calls)
}
})
}
})
}
}
func TestAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
root := NewRootCommand()
engine := newPipelineEngine()
entries, err := cli.ReduceParamAliases(root)
if err != nil {
t.Fatalf("ReduceParamAliases() error = %v", err)
}
chatEntries := 0
aliasCases := 0
guardCases := map[pipeline.FlagProtection]int{}
for _, entry := range entries {
if !strings.HasPrefix(entry.CLIPath, "chat ") {
continue
}
chatEntries++
leaf := resolveParamLeaf(root, entry.CLIPath)
if leaf == nil {
t.Fatalf("generated chat parameter path %q is not runnable", entry.CLIPath)
}
aliases := make([]string, 0, len(entry.Aliases))
for emitted := range entry.Aliases {
aliases = append(aliases, emitted)
}
sort.Strings(aliases)
for _, emitted := range aliases {
emitted := emitted
canonical := entry.Aliases[emitted]
aliasCases++
t.Run(entry.CLIPath+"/alias/"+emitted, func(t *testing.T) {
value := paramFixtureValue(leaf, emitted, canonical)
rawArgs := append(strings.Fields(entry.CLIPath), "--"+emitted, value)
ctx, runErr := pipeline.RunPreParseArgs(root, engine, rawArgs)
if runErr != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, runErr)
}
if ctx == nil {
t.Fatal("RunPreParseArgs returned nil context")
}
flagArgs := ctx.Args[len(strings.Fields(entry.CLIPath)):]
if len(flagArgs) < 2 || flagArgs[0] != "--"+canonical || flagArgs[1] != value {
t.Fatalf("runtime alias %q => %q produced args %v", emitted, canonical, ctx.Args)
}
if parseErr := leaf.ParseFlags(flagArgs); parseErr != nil {
t.Fatalf("canonical Cobra ParseFlags(%v) error = %v", flagArgs, parseErr)
}
})
}
for _, guard := range []struct {
protection pipeline.FlagProtection
emitted []string
}{
{protection: pipeline.FlagProtectionBlocked, emitted: entry.Blocked},
{protection: pipeline.FlagProtectionAmbiguous, emitted: entry.Ambiguous},
} {
for _, emitted := range guard.emitted {
emitted := emitted
protection := guard.protection
guardCases[protection]++
t.Run(entry.CLIPath+"/"+string(protection)+"/"+emitted, func(t *testing.T) {
value := paramFixtureValue(leaf, emitted, "did-you-mean:"+string(protection))
rawArgs := append(strings.Fields(entry.CLIPath), "--"+emitted, value)
ctx, runErr := pipeline.RunPreParseArgs(root, engine, rawArgs)
if runErr != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, runErr)
}
morphed := cmdutil.Morph(emitted)
if ctx == nil || ctx.ProtectedFlags[morphed] != protection {
t.Fatalf("runtime guard %q protection = %#v, want %s", emitted, ctx, protection)
}
assertLeftUnchanged(t, ctx, emitted, value)
flagArgs := ctx.Args[len(strings.Fields(entry.CLIPath)):]
if parseErr := leaf.ParseFlags(flagArgs); parseErr == nil || !strings.Contains(parseErr.Error(), "unknown flag") {
t.Fatalf("guarded Cobra ParseFlags(%v) error = %v, want unknown flag", flagArgs, parseErr)
}
})
}
}
}
if chatEntries == 0 || aliasCases == 0 || guardCases[pipeline.FlagProtectionBlocked] == 0 || guardCases[pipeline.FlagProtectionAmbiguous] == 0 {
t.Fatalf("chat parameter coverage is vacuous: entries=%d aliases=%d blocked=%d ambiguous=%d", chatEntries, aliasCases, guardCases[pipeline.FlagProtectionBlocked], guardCases[pipeline.FlagProtectionAmbiguous])
}
t.Logf("verified generated chat parameter routes: entries=%d aliases=%d blocked=%d ambiguous=%d", chatEntries, aliasCases, guardCases[pipeline.FlagProtectionBlocked], guardCases[pipeline.FlagProtectionAmbiguous])
}
func TestIMUserIDHallucinationRoutes(t *testing.T) {
tests := []struct {
command string
want string
}{
// These paths are reduced by the reviewed user_id concept.
{command: "chat +chat-role-query-user", want: "user"},
{command: "chat +chat-role-set-user", want: "user"},
{command: "chat +messages-list-direct", want: "user"},
{command: "chat chmod", want: "user"},
{command: "chat message list", want: "user"},
{command: "chat message send", want: "user"},
// These commands already own a hidden --userId compatibility flag.
// The format/spelling handler rewrites --user-id to that real flag, and
// the command's existing flagOrFallback wiring preserves its semantics.
{command: "chat conversation-info", want: "userId"},
{command: "chat group transfer-owner", want: "userId"},
{command: "chat group-role query-user", want: "userId"},
{command: "chat group-role remove-user", want: "userId"},
{command: "chat group-role set-user", want: "userId"},
{command: "chat group set-admin", want: "userId"},
{command: "chat group-mute-member", want: "userId"},
{command: "chat message read-status", want: "userId"},
{command: "chat message search-advanced", want: "userId"},
}
for _, test := range tests {
t.Run(test.command, func(t *testing.T) {
root := NewRootCommand()
leaf := resolveParamLeaf(root, test.command)
if leaf == nil {
t.Fatalf("IM command %q is not runnable", test.command)
}
rawArgs := append(strings.Fields(test.command), "--user-id", "fixture-user")
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), rawArgs)
if err != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, err)
}
if ctx == nil {
t.Fatal("RunPreParseArgs returned nil context")
}
flagArgs := ctx.Args[len(strings.Fields(test.command)):]
if len(flagArgs) != 2 || flagArgs[0] != "--"+test.want || flagArgs[1] != "fixture-user" {
t.Fatalf("--user-id route = %v, want --%s fixture-user", flagArgs, test.want)
}
if err := leaf.ParseFlags(flagArgs); err != nil {
t.Fatalf("Cobra ParseFlags(%v) error = %v", flagArgs, err)
}
})
}
}
func TestHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
const command = "chat message list-direct"
if _, ok := cli.LookupParamAlias(command); ok {
t.Fatalf("hidden command %q unexpectedly entered the public generated alias table", command)
}
root := NewRootCommand()
leaf := resolveParamLeaf(root, command)
if leaf == nil || !leaf.Hidden {
t.Fatalf("%q must remain a live hidden compatibility command", command)
}
rawArgs := append(strings.Fields(command), "--user-id", "fixture-user")
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), rawArgs)
if err != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, err)
}
if ctx == nil {
t.Fatal("RunPreParseArgs returned nil context")
}
flagArgs := ctx.Args[len(strings.Fields(command)):]
if err := leaf.ParseFlags(flagArgs); err == nil || !strings.Contains(err.Error(), "unknown flag") {
t.Fatalf("hidden command ParseFlags(%v) error = %v, want unknown flag", flagArgs, err)
}
}
func TestSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing.T) {
tests := []struct {
name string
tool string
canonicalArgs []string
aliasArgs []string
wantCorrections int
wantArgKeys []string
}{
{
name: "calendar read with multiple aliases",
tool: "list_calendar_events",
canonicalArgs: []string{
"--dry-run", "calendar", "event", "list",
"--start", "2026-03-10T14:00:00+08:00",
"--end", "2026-03-10T18:00:00+08:00",
"--calendar-id", "primary", "--limit", "7", "--cursor", "cursor-1",
},
aliasArgs: []string{
"--dry-run", "calendar", "event", "list",
"--date", "2026-03-10T14:00:00+08:00",
"--end-time", "2026-03-10T18:00:00+08:00",
"--calendar", "primary", "--max-results", "7", "--next-cursor", "cursor-1",
},
wantCorrections: 1,
wantArgKeys: []string{"calendarId", "cursor", "endTime", "limit", "startTime"},
},
{
name: "chat write scoped recipient alias",
tool: "send_personal_message",
canonicalArgs: []string{
"--dry-run", "chat", "message", "send",
"--user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
},
aliasArgs: []string{
"--dry-run", "chat", "message", "send",
"--to-user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
},
wantCorrections: 1,
wantArgKeys: []string{"clawType", "content", "msgType", "receiverOpenDingTalkId", "uuid"},
},
{
name: "mail write folder id concept alias",
tool: "update_mail_folder",
canonicalArgs: []string{
"--dry-run", "mail", "folder", "update",
"--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder",
},
aliasArgs: []string{
"--dry-run", "mail", "folder", "update",
"--email", "fixture@example.com", "--folder-id", "folder-1", "--name", "Fixture Folder",
},
wantCorrections: 1,
wantArgKeys: []string{"email", "id", "name"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, canonical, canonicalAttempts, canonicalErr := executeParamAliasDryRunE2E(t, test.canonicalArgs...)
if canonicalErr != nil {
t.Fatalf("canonical dry-run failed: %v", canonicalErr)
}
ctx, alias, aliasAttempts, aliasErr := executeParamAliasDryRunE2E(t, test.aliasArgs...)
if aliasErr != nil {
t.Fatalf("alias dry-run failed: %v\ncontext=%#v", aliasErr, ctx)
}
if ctx == nil || len(ctx.Corrections) != test.wantCorrections {
t.Fatalf("alias dry-run corrections = %#v, want %d", ctx, test.wantCorrections)
}
if len(canonicalAttempts) != 0 || len(aliasAttempts) != 0 {
t.Fatalf("dry-run reached command runner\ncanonical=%#v\nalias=%#v", canonicalAttempts, aliasAttempts)
}
for label, preview := range map[string]paramAliasDryRunPreview{"canonical": canonical, "alias": alias} {
if !preview.DryRun || preview.Executed {
t.Fatalf("%s preview execution state = %#v", label, preview)
}
if preview.Tool != test.tool {
t.Fatalf("%s preview tool = %q, want %q", label, preview.Tool, test.tool)
}
keys := make([]string, 0, len(preview.Arguments))
for key := range preview.Arguments {
keys = append(keys, key)
}
sort.Strings(keys)
if !reflect.DeepEqual(keys, test.wantArgKeys) {
t.Fatalf("%s preview argument keys = %v, want %v", label, keys, test.wantArgKeys)
}
}
if !reflect.DeepEqual(alias, canonical) {
t.Fatalf("dry-run previews differ\ncanonical=%#v\nalias=%#v", canonical, alias)
}
})
}
}
func TestParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
caller := &paramAliasCaptureCaller{}
for _, args := range [][]string{
{"calendar", "event", "list", "--date", "2026-03-10", "--start", "2026-03-11"},
{"calendar", "event", "list", "--start", "2026-03-11", "--date", "2026-03-10"},
} {
root := NewRootCommand()
root.SetArgs(args)
originalCaller := helpers.GetCaller()
helpers.InitDeps(caller)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
helpers.InitDeps(originalCaller)
var conflict *pipeline.FlagConflictError
if !stderrors.As(err, &conflict) {
t.Fatalf("RunPreParseArgs(%v) error = %v, want FlagConflictError (ctx=%#v)", args, err, ctx)
}
if conflict.Canonical != "start" || !reflect.DeepEqual(conflict.Spellings, []string{"date", "start"}) {
t.Fatalf("conflict = %#v", conflict)
}
}
if len(caller.calls) != 0 {
t.Fatalf("conflicting argv reached RunE/tool dispatch: %#v", caller.calls)
}
}
func TestAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
}
paths := make(map[string]bool)
for _, concept := range concepts.Concepts {
for _, path := range concept.Commands {
paths[path] = true
}
}
sourceGuards := make(map[string]pipeline.FlagProtection)
for _, override := range concepts.Overrides {
paths[override.CommandPath] = true
for _, emitted := range override.Block {
sourceGuards[override.CommandPath+"\x00"+cmdutil.Morph(emitted)] = pipeline.FlagProtectionBlocked
}
for _, emitted := range override.Ambiguous {
sourceGuards[override.CommandPath+"\x00"+cmdutil.Morph(emitted)] = pipeline.FlagProtectionAmbiguous
}
}
orderedPaths := make([]string, 0, len(paths))
for path := range paths {
orderedPaths = append(orderedPaths, path)
}
sort.Strings(orderedPaths)
root := NewRootCommand()
engine := newPipelineEngine()
guardCounts := map[pipeline.FlagProtection]int{}
testedGuards := make(map[string]pipeline.FlagProtection)
for _, path := range orderedPaths {
entry, ok := cli.LookupParamAlias(path)
if !ok {
continue
}
leaf := resolveParamLeaf(root, path)
if leaf == nil {
t.Fatalf("generated guard path %q is not runnable", path)
}
for _, protectionCase := range []struct {
protection pipeline.FlagProtection
emitted []string
}{
{protection: pipeline.FlagProtectionBlocked, emitted: entry.Blocked},
{protection: pipeline.FlagProtectionAmbiguous, emitted: entry.Ambiguous},
} {
for _, emitted := range protectionCase.emitted {
protectionCase := protectionCase
emitted := emitted
key := path + "\x00" + cmdutil.Morph(emitted)
if previous, duplicate := testedGuards[key]; duplicate {
t.Fatalf("generated guard %q/%q is classified twice: %s and %s", path, emitted, previous, protectionCase.protection)
}
testedGuards[key] = protectionCase.protection
guardCounts[protectionCase.protection]++
t.Run(path+"/"+emitted, func(t *testing.T) {
value := "FIXTURE_VALUE"
pathArgs := strings.Fields(path)
args := append(append([]string(nil), pathArgs...), "--"+emitted, value)
ctx, runErr := pipeline.RunPreParseArgs(root, engine, args)
if runErr != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", args, runErr)
}
morphed := cmdutil.Morph(emitted)
if ctx == nil || ctx.ProtectedFlags[morphed] != protectionCase.protection {
t.Fatalf("guard protection = %#v, want %s for %q", ctx, protectionCase.protection, morphed)
}
assertLeftUnchanged(t, ctx, emitted, value)
flagArgs := ctx.Args[len(pathArgs):]
if parseErr := leaf.ParseFlags(flagArgs); parseErr == nil || !strings.Contains(parseErr.Error(), "unknown flag") {
t.Fatalf("guarded Cobra ParseFlags(%v) error = %v, want unknown flag", flagArgs, parseErr)
}
})
}
}
}
for key, want := range sourceGuards {
if got, ok := testedGuards[key]; !ok || got != want {
t.Fatalf("reviewed source guard %q delivered as %s (present=%t), want %s", key, got, ok, want)
}
}
if guardCounts[pipeline.FlagProtectionBlocked] == 0 || guardCounts[pipeline.FlagProtectionAmbiguous] == 0 {
t.Fatalf("reviewed guard coverage is vacuous: blocked %d ambiguous %d", guardCounts[pipeline.FlagProtectionBlocked], guardCounts[pipeline.FlagProtectionAmbiguous])
}
}
func TestRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testing.T) {
for _, test := range []struct {
path string
emitted string
protection pipeline.FlagProtection
reason string
}{
{path: "chat message list-by-sender", emitted: "time", protection: pipeline.FlagProtectionBlocked, reason: "blocked_flag"},
{path: "drive list", emitted: "space", protection: pipeline.FlagProtectionAmbiguous, reason: "ambiguous_flag"},
} {
test := test
t.Run(test.path+"/"+test.emitted, func(t *testing.T) {
value := "FIXTURE_VALUE"
args := append(strings.Fields(test.path), "--"+test.emitted, value)
caller := &paramAliasCaptureCaller{}
ctx, executeErr := executeParamAliasE2E(t, caller, args...)
morphed := cmdutil.Morph(test.emitted)
if ctx == nil || ctx.ProtectedFlags[morphed] != test.protection {
t.Fatalf("guard protection = %#v, want %s for %q", ctx, test.protection, morphed)
}
assertLeftUnchanged(t, ctx, test.emitted, value)
var appErr *apperrors.Error
if !stderrors.As(executeErr, &appErr) {
t.Fatalf("final error = %T %v, want *errors.Error", executeErr, executeErr)
}
if appErr.Category != apperrors.CategoryValidation || appErr.Reason != test.reason || apperrors.ExitCode(executeErr) != 3 {
t.Fatalf("final error contract = category %q reason %q exit %d, want validation/%s/3", appErr.Category, appErr.Reason, apperrors.ExitCode(executeErr), test.reason)
}
if !strings.Contains(appErr.Message, "unknown flag: --"+test.emitted) || !strings.Contains(appErr.Message, "See 'dws "+test.path+" --help' for usage.") {
t.Fatalf("final error message = %q", appErr.Message)
}
if !strings.Contains(appErr.Hint, "--"+test.emitted) || !strings.Contains(appErr.Hint, "--help") {
t.Fatalf("final error hint = %q", appErr.Hint)
}
wantAction := "Run 'dws " + test.path + " --help' for valid flags"
if !reflect.DeepEqual(appErr.Actions, []string{wantAction}) || len(appErr.AvailableFlags) == 0 || appErr.Cause == nil {
t.Fatalf("final recovery fields = actions %v flags %v cause %v", appErr.Actions, appErr.AvailableFlags, appErr.Cause)
}
if len(caller.calls) != 0 {
t.Fatalf("guarded flag reached RunE/tool dispatch: %#v", caller.calls)
}
})
}
}
func TestFlagConflictErrorFormattingIsDeterministic(t *testing.T) {
err := (&pipeline.FlagConflictError{Command: "dws demo", Canonical: "start", Spellings: []string{"start", "date"}}).Error()
want := `conflicting parameter spellings for --start on "dws demo": --date, --start; pass exactly one spelling`
if err != want {
t.Fatalf("FlagConflictError = %q, want %q", err, want)
}
}
+220
View File
@@ -0,0 +1,220 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// TestParamAliasFixtureThroughEmbeddedDeliveryPath is the ⑥ regression gate.
// It reads the reviewed validation_fixture straight from the embedded concept
// dictionary and asserts every reviewed bad case through the REAL delivery
// path — not a generator unit test and not a reimplementation of the reduction
// logic:
//
// - the runtime PreParse engine built by newPipelineEngine() (the exact
// handler chain root.go installs, whose SemanticAliasHandler is wired to
// cli.LookupParamAlias over the embedded generated table),
// - one distribution-owned Cobra tree, reused because PreParse reads command
// and flag metadata but does not parse or mutate individual flag values,
// and
// - the embedded cli.LookupParamAlias query used to prove that a
// did-you-mean case is an intentional block/ambiguous guard rather than a
// name that merely happens to be absent from the table.
//
// Fixture expect semantics (see spec §⑥):
// - expect=<realFlag> : emitted must reduce to that canonical flag.
// - expect=did-you-mean:blocked : block guard hit; never auto-rewritten.
// - expect=did-you-mean:ambiguous: co-occurrence guard hit; never rewritten.
func TestParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
}
if len(concepts.Fixture) == 0 {
t.Fatal("validation_fixture declares no cases; ⑥ gate would be vacuous")
}
// The exact runtime handler chain (alias → semantic → sticky →
// paramname), with the semantic table sourced from the embedded generated
// snapshot. Build the distribution-owned tree once: constructing the full
// 800+ command tree for every fixture made the macOS race package exceed its
// 10-minute budget, while RunPreParseArgs itself only reads this tree.
engine := newPipelineEngine()
root := NewSchemaSourceRootCommand()
for _, c := range concepts.Fixture {
t.Run(c.Command+"/"+c.Emitted, func(t *testing.T) {
leaf := resolveParamLeaf(root, c.Command)
if leaf == nil {
t.Fatalf("fixture command %q is not a live Cobra command", c.Command)
}
// Fixture command paths carry no "dws" prefix; LookupParamAlias
// normalizes to the same key the generator used, so the runtime
// lookup is byte-identical to the build-time key.
entry, hasEntry := cli.LookupParamAlias(c.Command)
fixtureValue := paramFixtureValue(leaf, c.Emitted, c.Expect)
rawArgs := append(strings.Fields(c.Command), "--"+c.Emitted, fixtureValue)
root.SetArgs(rawArgs)
ctx, err := pipeline.RunPreParseArgs(root, engine, rawArgs)
if err != nil {
t.Fatalf("RunPreParseArgs error = %v", err)
}
if ctx == nil {
t.Fatal("RunPreParseArgs skipped a fixture command with real flags")
}
morphed := cmdutil.Morph(c.Emitted)
switch c.Expect {
case "did-you-mean:ambiguous":
if !hasEntry || !entry.IsAmbiguous(morphed) {
t.Fatalf("%q on %q: expected co-occurrence guard (ambiguous) but embedded entry does not classify it; ambiguous=%v", c.Emitted, c.Command, entry.Ambiguous)
}
if commandHasRealFlagByMorph(leaf, morphed) {
t.Fatalf("guarded --%s on %q is a real Cobra flag and would bypass the unknown-flag recovery path", c.Emitted, c.Command)
}
assertLeftUnchanged(t, ctx, c.Emitted, fixtureValue)
case "did-you-mean:blocked":
if !hasEntry || !entry.IsBlocked(morphed) {
t.Fatalf("%q on %q: expected block guard but embedded entry does not classify it; blocked=%v", c.Emitted, c.Command, entry.Blocked)
}
if commandHasRealFlagByMorph(leaf, morphed) {
t.Fatalf("guarded --%s on %q is a real Cobra flag and would bypass the unknown-flag recovery path", c.Emitted, c.Command)
}
assertLeftUnchanged(t, ctx, c.Emitted, fixtureValue)
default:
// Real-flag expect: the reviewed canonical outcome is delivered
// one of two equally valid ways, and the gate accepts either
// (failing only on a genuine unknown-flag hallucination):
// 1. semantic rewrite — the emitted synonym is not a real flag,
// so the embedded table rewrites it to the canonical flag; or
// 2. native acceptance — the emitted synonym is still a genuine
// (usually hidden) real flag the command accepts directly and
// maps to the same entity via its fallback wiring. Native
// compatibility flags intentionally remain command-owned.
if !commandHasRealFlagByMorph(leaf, cmdutil.Morph(c.Expect)) {
t.Fatalf("reviewed canonical --%s on %q is not a real Cobra flag", c.Expect, c.Command)
}
flagArgs := ctx.Args[len(strings.Fields(c.Command)):]
if len(flagArgs) < 2 || flagArgs[1] != fixtureValue {
t.Fatalf("%q on %q lost its value: args=%v", c.Emitted, c.Command, ctx.Args)
}
got := flagArgs[0]
gotBare := strings.SplitN(strings.TrimPrefix(got, "--"), "=", 2)[0]
switch {
case got == "--"+c.Expect:
// (1) rewritten; the embedded table must agree.
if !hasEntry {
t.Fatalf("%q on %q was rewritten without an embedded alias entry", c.Emitted, c.Command)
}
if canon, hit := entry.ResolveAlias(morphed); !hit || canon != c.Expect {
t.Fatalf("embedded table ResolveAlias(%q) on %q = %q (hit=%v), want %q", morphed, c.Command, canon, hit, c.Expect)
}
case cmdutil.Morph(gotBare) == morphed && commandHasRealFlagByMorph(leaf, morphed):
// (2) not rewritten — only valid if the command natively
// accepts the emitted synonym as a real flag.
default:
t.Fatalf("%q on %q reduced to unexpected %q, want --%s or native --%s (args=%v)", c.Emitted, c.Command, got, c.Expect, c.Emitted, ctx.Args)
}
}
})
}
}
// assertLeftUnchanged verifies a guarded (blocked/ambiguous) synonym is never
// silently rewritten: the flag token and its value survive verbatim so the
// unknown-flag did-you-mean path can surface the reviewed candidates.
func assertLeftUnchanged(t *testing.T, ctx *pipeline.Context, emitted, value string) {
t.Helper()
flagIndex := -1
for i, arg := range ctx.Args {
if arg == "--"+emitted || strings.HasPrefix(arg, "--"+emitted+"=") {
flagIndex = i
break
}
}
if flagIndex < 0 {
t.Fatalf("guarded synonym --%s disappeared: args=%v", emitted, ctx.Args)
}
if got := ctx.Args[flagIndex]; got != "--"+emitted {
t.Fatalf("guarded synonym --%s was rewritten to %q (must be left for did-you-mean): args=%v", emitted, got, ctx.Args)
}
if len(ctx.Args) <= flagIndex+1 || ctx.Args[flagIndex+1] != value {
t.Fatalf("guarded synonym --%s lost its value: args=%v", emitted, ctx.Args)
}
for _, corr := range ctx.Corrections {
if corr.Handler == "semantic-alias" && corr.Original == "--"+emitted {
t.Fatalf("guarded synonym --%s was corrected by %s (must not be): %+v", emitted, corr.Handler, corr)
}
}
}
func paramFixtureValue(cmd *cobra.Command, emitted, expect string) string {
if cmd == nil {
return "FIXTURE_VALUE"
}
wanted := []string{emitted}
if !strings.HasPrefix(expect, "did-you-mean:") {
wanted = append(wanted, expect)
}
for _, name := range wanted {
var found *pflag.Flag
cmd.Flags().VisitAll(func(flag *pflag.Flag) {
if found == nil && cmdutil.Morph(flag.Name) == cmdutil.Morph(name) {
found = flag
}
})
if found == nil {
continue
}
switch found.Value.Type() {
case "bool":
return "true"
case "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64", "float32", "float64":
return "1"
}
}
return "FIXTURE_VALUE"
}
// resolveParamLeaf resolves a fixture command path (no "dws" prefix, e.g.
// "chat message search-advanced") to its live Cobra command, or nil.
func resolveParamLeaf(root *cobra.Command, path string) *cobra.Command {
cmd, _, err := root.Find(strings.Fields(path))
if err != nil || cmd == nil || cmd == root {
return nil
}
return cmd
}
// commandHasRealFlagByMorph reports whether the command has any real flag
// (local or inherited, including hidden) whose Morph matches morphed — the same
// notion of "real flag" the build-time reducer uses to absorb legacy synonyms.
func commandHasRealFlagByMorph(cmd *cobra.Command, morphed string) bool {
found := false
check := func(f *pflag.Flag) {
if f.Name != "help" && cmdutil.Morph(f.Name) == morphed {
found = true
}
}
cmd.Flags().VisitAll(check)
cmd.InheritedFlags().VisitAll(check)
return found
}
@@ -0,0 +1,339 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
// paramAliasCompleteCommands is deliberately keyed by the exact reviewed
// fixture command path. Every argv is a complete, business-valid invocation:
// required companion flags are present, time and enum values are valid, and
// write commands use the capture caller rather than a real transport. The
// target canonical flag must occur exactly once so the test can replace only
// its spelling while holding every other input constant.
var paramAliasCompleteCommands = map[string][]string{
"aitable +base-search": {"aitable", "+base-search", "--query", "fixture"},
"aitable +field-get": {"aitable", "+field-get", "--base-id", "base-1", "--table-id", "table-1"},
"aitable +list-tables": {"aitable", "+list-tables", "--base", "base-1"},
"aitable +record-query": {"aitable", "+record-query", "--base-id", "base-1", "--table-id", "table-1", "--query", "fixture"},
"aitable +record-share-url": {"aitable", "+record-share-url", "--base-id", "base-1", "--table-id", "table-1", "--record-ids", "record-1"},
"aitable +table-get": {"aitable", "+table-get", "--base-id", "base-1"},
"aitable record query": {"aitable", "record", "query", "--base-id", "base-1", "--table-id", "table-1", "--limit", "7"},
"attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
"chat +category-create": {"chat", "+category-create", "--title", "Fixture Cat", "--yes"},
"chat +category-rename": {"chat", "+category-rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
"chat +group-members": {"chat", "+group-members", "--group", "Fixture Group"},
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"},
"chat +unread-chats": {"chat", "+unread-chats", "--count", "7", "--exclude-muted"},
"chat bot find": {"chat", "bot", "find", "--query", "fixture", "--limit", "7"},
"chat bot search": {"chat", "bot", "search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
"chat category create": {"chat", "category", "create", "--title", "Fixture Cat", "--yes"},
"chat category create-smart": {"chat", "category", "create-smart", "--name", "Fixture Smart Category", "--keywords", "fixture,priority", "--yes"},
"chat category rename": {"chat", "category", "rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
"chat group members": {"chat", "group", "members", "--id", "fixture-conversation"},
"chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", "D-user-1"},
"chat group members add-bot": {"chat", "group", "members", "add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
"chat group members list-by-ids": {"chat", "group", "members", "list-by-ids", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
"chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", "D-user-1", "--yes"},
"chat group members remove-bot": {"chat", "group", "members", "remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
"chat group rename": {"chat", "group", "rename", "--id", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
"chat group set-admin": {"chat", "group", "set-admin", "--group", "fixture-conversation", "--user", "user-1", "--yes"},
"chat message add-emoji": {"chat", "message", "add-emoji", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--emoji", "赞", "--yes"},
"chat message add-favorite": {"chat", "message", "add-favorite", "--open-message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--yes"},
"chat message combine-forward": {"chat", "message", "combine-forward", "--src-conversation-id", "fixture-source", "--msg-ids", "message-1,message-2", "--dest-conversation-id", "fixture-destination", "--yes"},
"chat message forward-topic": {"chat", "message", "forward-topic", "--src-msg-id", "message-1", "--src-conversation-id", "fixture-source", "--src-thread-id", "convThread-fixture", "--dest-conversation-id", "fixture-destination", "--yes"},
"chat message list": {"chat", "message", "list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat message list-all": {"chat", "message", "list-all", "--start", "2026-03-10 00:00:00", "--end", "2026-03-11 00:00:00"},
"chat message list-by-sender": {"chat", "message", "list-by-sender", "--sender-user-id", "user-1", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
"chat message list-favorites": {"chat", "message", "list-favorites", "--cursor", "2", "--size", "7"},
"chat message list-by-ids": {"chat", "message", "list-by-ids", "--msg-ids", "message-1,message-2"},
"chat message list-unread-conversations": {"chat", "message", "list-unread-conversations", "--count", "7", "--exclude-muted"},
"chat message recall": {"chat", "message", "recall", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
"chat message search-advanced": {"chat", "message", "search-advanced", "--conversation-ids", "fixture-conversation", "--query", "fixture"},
"chat message send": {"chat", "message", "send", "--user", "D-recipient", "--text", "hello fixture", "--uuid", "param-alias-equivalence", "--yes"},
"chat message send-by-bot": {"chat", "message", "send-by-bot", "--robot-code", "robot-1", "--group", "fixture-conversation", "--title", "Fixture Alert", "--text", "@user-1 @user-2 fixture", "--at-user-ids", "user-1,user-2", "--yes"},
"chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
"contact +dept-members": {"contact", "+dept-members", "--dept", "Fixture Dept"},
"contact +list-sub-depts": {"contact", "+list-sub-depts", "--dept", "1"},
"contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"},
"contact +search-user": {"contact", "+search-user", "--query", "Fixture User"},
"contact dept list-children": {"contact", "dept", "list-children", "--dept", "1"},
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1"},
"dev app get": {"dev", "app", "get", "--unified-app-id", "app-1"},
"devdoc article search": {"devdoc", "article", "search", "--query", "fixture", "--page", "2", "--size", "7"},
"ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"},
"ding message receiver-status": {"ding", "message", "receiver-status", "--ding-id", "ding-1"},
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1", "--yes"},
"doc +template-search": {"doc", "+template-search", "--query", "fixture", "--source", "MY", "--limit", "7"},
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
"drive info": {"drive", "info", "--node", "node-1", "--space-id", "space-1"},
"drive list": {"drive", "list", "--folder", "folder-1", "--limit", "7"},
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"},
"mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder", "--yes"},
"mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"},
"mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"},
"mail user search": {"mail", "user", "search", "--keyword", "fixture"},
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "1"},
"oa +search-forms": {"oa", "+search-forms", "--query", "fixture"},
"oa approval search-forms": {"oa", "approval", "search-forms", "--query", "fixture"},
"report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"},
}
// A command can expose more than one mutually exclusive canonical route. In
// that case the shared command template above cannot contain every canonical
// flag at once, so select a fixture-specific complete invocation here.
var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"chat message list": {
"user": {"chat", "message", "list", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
},
"chat message list-by-sender": {
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", "D-sender", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
},
"chat message send": {
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--uuid", "param-alias-equivalence-group", "--yes"},
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--uuid", "param-alias-equivalence-file", "--yes"},
},
}
// paramAliasNewIMCases is the exact set of aliases added by the reviewed IM
// optimization. The dedicated gate below requires every one to remain active
// in the embedded generated table and equivalent at the final transport.
var paramAliasNewIMCases = []struct {
command string
emitted string
canonical string
}{
{command: "chat +bot-find", emitted: "name", canonical: "query"},
{command: "chat bot find", emitted: "name", canonical: "query"},
{command: "chat +bot-search", emitted: "query", canonical: "name"},
{command: "chat +bot-search", emitted: "current-page", canonical: "page"},
{command: "chat +category-create", emitted: "name", canonical: "title"},
{command: "chat +category-rename", emitted: "name", canonical: "title"},
{command: "chat +messages-list-direct", emitted: "start", canonical: "time"},
{command: "chat +messages-list-unread-conversations", emitted: "limit", canonical: "count"},
{command: "chat +messages-list-unread-conversations", emitted: "size", canonical: "count"},
{command: "chat +messages-send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
{command: "chat +unread-chats", emitted: "limit", canonical: "count"},
{command: "chat +unread-chats", emitted: "size", canonical: "count"},
{command: "chat bot search", emitted: "query", canonical: "name"},
{command: "chat bot search", emitted: "current-page", canonical: "page"},
{command: "chat category create", emitted: "name", canonical: "title"},
{command: "chat category create-smart", emitted: "title", canonical: "name"},
{command: "chat category rename", emitted: "name", canonical: "title"},
{command: "chat message list", emitted: "start", canonical: "time"},
{command: "chat message list-by-sender", emitted: "user-id", canonical: "sender-user-id"},
{command: "chat message list-by-sender", emitted: "open-dingtalk-id", canonical: "sender-open-dingtalk-id"},
{command: "chat message list-favorites", emitted: "limit", canonical: "size"},
{command: "chat message list-unread-conversations", emitted: "limit", canonical: "count"},
{command: "chat message list-unread-conversations", emitted: "size", canonical: "count"},
{command: "chat message send", emitted: "file", canonical: "file-path"},
{command: "chat message send-by-bot", emitted: "at-users", canonical: "at-user-ids"},
{command: "chat message send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
}
// paramAliasRepresentativePayloadCases keeps final transport coverage across
// old concept aliases, command overrides, native compatibility flags, read and
// write commands, and different products. Every reviewed alias is still
// checked through the embedded PreParse delivery path and against a complete
// business-valid command template. The separate IM gate below continues to
// execute every alias introduced by the current IM optimization.
//
// Keeping the older 100+ aliases at the contract layer avoids rebuilding and
// executing the complete 800+ command Root twice per spelling under -race.
// That duplicated command construction was enough to push the pre-existing
// macOS app suite beyond its package-level 10-minute timeout.
var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
paramAliasPayloadCaseKey("contact user profile get", "user-id"): true, // native compatibility flag
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
}
func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
}
activeCommands := make(map[string]bool)
activeCases := 0
executedRepresentatives := make(map[string]bool)
for _, fixture := range concepts.Fixture {
if strings.HasPrefix(fixture.Expect, "did-you-mean:") {
continue
}
activeCommands[fixture.Command] = true
activeCases++
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
if !ok {
t.Errorf("reviewed active fixture %q/%q has no complete-command E2E template", fixture.Command, fixture.Emitted)
continue
}
canonicalArgs := append([]string(nil), complete...)
aliasArgs, replacements := replaceLongFlag(canonicalArgs, fixture.Expect, fixture.Emitted)
if replacements != 1 {
t.Errorf("complete command for %q/%q must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Command, fixture.Emitted, fixture.Expect, replacements, canonicalArgs)
continue
}
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
if !paramAliasRepresentativePayloadCases[caseKey] {
continue
}
executedRepresentatives[caseKey] = true
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
canonicalCaller := &paramAliasCaptureCaller{}
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
if canonicalErr != nil {
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
}
if len(canonicalCaller.calls) == 0 {
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
}
aliasCaller := &paramAliasCaptureCaller{}
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
if aliasErr != nil {
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
}
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
}
})
}
if activeCases == 0 {
t.Fatal("reviewed fixture contains no active alias cases")
}
for command := range paramAliasCompleteCommands {
if !activeCommands[command] {
t.Errorf("complete-command E2E template %q has no active reviewed fixture", command)
}
}
for command := range activeCommands {
if _, ok := paramAliasCompleteCommands[command]; !ok {
t.Errorf("active reviewed command %q has no complete-command E2E template", command)
}
}
if len(activeCommands) != len(paramAliasCompleteCommands) {
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(paramAliasCompleteCommands), len(activeCommands), activeCases)
}
for caseKey := range paramAliasRepresentativePayloadCases {
if !executedRepresentatives[caseKey] {
t.Errorf("representative final-payload case %q has no active reviewed fixture", caseKey)
}
}
if len(executedRepresentatives) != len(paramAliasRepresentativePayloadCases) {
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), len(paramAliasRepresentativePayloadCases))
}
}
func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
activeAliases := 0
for _, test := range paramAliasNewIMCases {
test := test
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
if !ok {
t.Fatal("reviewed IM alias has no complete-command E2E template")
}
canonicalArgs := append([]string(nil), complete...)
aliasArgs, replacements := replaceLongFlag(canonicalArgs, test.canonical, test.emitted)
if replacements != 1 {
t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, canonicalArgs)
}
canonicalCaller := &paramAliasCaptureCaller{}
if _, err := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...); err != nil {
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", err, canonicalArgs, canonicalCaller.calls)
}
if len(canonicalCaller.calls) == 0 {
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
}
entry, exists := cli.LookupParamAlias(test.command)
target, active := entry.ResolveAlias(test.emitted)
if !exists || !active {
return
}
if target != test.canonical {
t.Fatalf("active reviewed IM alias --%s resolves to --%s, want --%s", test.emitted, target, test.canonical)
}
activeAliases++
aliasCaller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
if err != nil {
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", err, aliasArgs, aliasCaller.calls)
}
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
}
})
}
if activeAliases != len(paramAliasNewIMCases) {
t.Fatalf("new IM aliases active in embedded table = %d, want %d", activeAliases, len(paramAliasNewIMCases))
}
}
func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
complete, ok := paramAliasCompleteCommands[command]
if variants := paramAliasCompleteCommandVariants[command]; variants != nil {
if variant, exists := variants[canonical]; exists {
return variant, true
}
}
return complete, ok
}
func paramAliasPayloadCaseKey(command, emitted string) string {
return command + "\x00" + emitted
}
func executeParamAliasPayloadE2E(t *testing.T, caller *paramAliasCaptureCaller, args ...string) (*pipeline.Context, error) {
t.Helper()
return executeParamAliasE2E(t, caller, args...)
}
func replaceLongFlag(args []string, canonical, emitted string) ([]string, int) {
out := append([]string(nil), args...)
replacements := 0
for index, arg := range out {
if arg == "--"+canonical {
out[index] = "--" + emitted
replacements++
continue
}
if strings.HasPrefix(arg, "--"+canonical+"=") {
out[index] = "--" + emitted + strings.TrimPrefix(arg, "--"+canonical)
replacements++
}
}
return out, replacements
}
@@ -0,0 +1,147 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
// TestCalendarEventListNativeFallbacksAndCentralAliasesCoexist locks the
// boundary between the command's original hidden compatibility flags and the
// new central semantic normalizer. Existing real flags stay untouched and are
// handled by calendar.go's flagOrFallback chain; only spellings that are not
// real flags (for example --date, --from, and --since) are rewritten centrally.
func TestCalendarEventListNativeFallbacksAndCentralAliasesCoexist(t *testing.T) {
engine := newPipelineEngine()
cases := []struct {
emitted string
value string
canonical string
isInt bool
native bool
}{
// Existing Calendar compatibility flags remain native.
{"start-time", "2026-03-10T14:00:00+08:00", "start", false, true},
{"startTime", "2026-03-10T14:00:00+08:00", "start", false, true},
{"start_time", "2026-03-10T14:00:00+08:00", "start", false, true},
{"start-date", "2026-03-10T14:00:00+08:00", "start", false, true},
{"min-time", "2026-03-10T14:00:00+08:00", "start", false, true},
{"time-min", "2026-03-10T14:00:00+08:00", "start", false, true},
{"end-time", "2026-03-10T18:00:00+08:00", "end", false, true},
{"endTime", "2026-03-10T18:00:00+08:00", "end", false, true},
{"end-date", "2026-03-10T18:00:00+08:00", "end", false, true},
{"max-time", "2026-03-10T18:00:00+08:00", "end", false, true},
{"time-max", "2026-03-10T18:00:00+08:00", "end", false, true},
{"max-results", "50", "limit", true, true},
{"maxResults", "50", "limit", true, true},
{"page-size", "50", "limit", true, true},
{"size", "50", "limit", true, true},
{"next-cursor", "TOKEN123", "cursor", false, true},
{"nextCursor", "TOKEN123", "cursor", false, true},
{"page-token", "TOKEN123", "cursor", false, true},
{"next-token", "TOKEN123", "cursor", false, true},
{"calendar", "primary", "calendar-id", false, true},
{"calendarId", "primary", "calendar-id", false, true},
// These spellings have no native Calendar flag and remain central aliases.
{"from", "2026-03-10T14:00:00+08:00", "start", false, false},
{"since", "2026-03-10T14:00:00+08:00", "start", false, false},
{"date", "2026-03-10T14:00:00+08:00", "start", false, false},
}
for _, tc := range cases {
t.Run(tc.emitted, func(t *testing.T) {
// Fresh command tree per case: ParseFlags mutates flag state.
root := NewRootCommand()
target := mustFindCommand(t, root, "calendar", "event", "list")
ctx := &pipeline.Context{
Args: []string{"calendar", "event", "list", "--" + tc.emitted, tc.value},
Command: target.CommandPath(),
FlagSpecs: pipeline.FlagInfoFromCommand(target),
}
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
t.Fatalf("PreParse error = %v", err)
}
parsedFlag := tc.canonical
if tc.native {
parsedFlag = tc.emitted
if len(ctx.Corrections) != 0 {
t.Fatalf("native --%s triggered central corrections: %#v", tc.emitted, ctx.Corrections)
}
if joined := strings.Join(ctx.Args, " "); !strings.Contains(joined, "--"+tc.emitted+" "+tc.value) {
t.Fatalf("native --%s did not survive unchanged: args = %v", tc.emitted, ctx.Args)
}
} else {
if joined := strings.Join(ctx.Args, " "); !strings.Contains(joined, "--"+tc.canonical+" "+tc.value) {
t.Fatalf("--%s not reduced to --%s: args = %v", tc.emitted, tc.canonical, ctx.Args)
}
if len(ctx.Corrections) != 1 {
t.Fatalf("central --%s corrections = %#v, want one", tc.emitted, ctx.Corrections)
}
}
flagArgs := ctx.Args[3:]
if err := target.ParseFlags(flagArgs); err != nil {
t.Fatalf("Cobra ParseFlags(%v) error = %v", flagArgs, err)
}
if tc.isInt {
got, err := target.Flags().GetInt(parsedFlag)
if err != nil || got != 50 {
t.Fatalf("flag --%s = %d (err %v), want 50", parsedFlag, got, err)
}
} else {
got, err := target.Flags().GetString(parsedFlag)
if err != nil || got != tc.value {
t.Fatalf("flag --%s = %q (err %v), want %q", parsedFlag, got, err, tc.value)
}
}
})
}
}
// TestCalendarEventListKeepsCountExclusion pins the reviewed decision that
// pagination_size deliberately excludes --count (count != limit). The kept
// hidden --count flag must be left untouched by the pipeline: it is a real
// flag, not a concept member, so it must not be rewritten to --limit.
func TestCalendarEventListKeepsCountExclusion(t *testing.T) {
engine := newPipelineEngine()
root := NewRootCommand()
target := mustFindCommand(t, root, "calendar", "event", "list")
ctx := &pipeline.Context{
Args: []string{"calendar", "event", "list", "--count", "5"},
Command: target.CommandPath(),
FlagSpecs: pipeline.FlagInfoFromCommand(target),
}
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
t.Fatalf("PreParse error = %v", err)
}
if joined := strings.Join(ctx.Args, " "); !strings.Contains(joined, "--count 5") {
t.Fatalf("--count must not be rewritten: args = %v", ctx.Args)
}
if len(ctx.Corrections) != 0 {
t.Fatalf("--count triggered corrections %#v, want none", ctx.Corrections)
}
if err := target.ParseFlags(ctx.Args[3:]); err != nil {
t.Fatalf("Cobra ParseFlags error = %v", err)
}
if got, _ := target.Flags().GetInt("count"); got != 5 {
t.Fatalf("flag --count = %d, want 5", got)
}
}
+3 -3
View File
@@ -569,9 +569,9 @@ func handlePatAuthCheck(
// In host-controlled PAT mode (driven solely by DINGTALK_DWS_AGENTCODE),
// or when flowId is absent, the CLI returns machine-readable JSON to
// stderr and leaves UI/polling/retry to the host. `claw-type` is NOT
// used for this decision — it is only forwarded on the wire via
// the edition default / DWS_AGENT_PRODUCT override and surfaced in
// hostControl for traceability.
// used for this decision — its edition-fixed value is forwarded on the
// wire and surfaced in hostControl for traceability. DWS_AGENT_PRODUCT
// does not affect this PAT contract.
if hostOwnedPAT || patData.Data.FlowID == "" {
if hostOwnedPAT {
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorForHostControl(patErr.RawJSON)}
+3 -3
View File
@@ -1007,11 +1007,11 @@ func TestHandlePatAuthCheck_HostControlledFlowIDPassthrough(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", tmpDir)
// Host-owned decision: driven ONLY by DINGTALK_DWS_AGENTCODE.
// DINGTALK_AGENT is set to demonstrate it does NOT leak into
// hostControl.clawType. With no DWS_AGENT_PRODUCT override the
// open-source edition default remains "openClaw".
// hostControl.clawType. DWS_AGENT_PRODUCT is also set to demonstrate
// that Product does not change the open-source fixed "openClaw" value.
t.Setenv(authpkg.AgentCodeEnv, "agt-sales")
t.Setenv("DINGTALK_AGENT", "sales-copilot")
t.Setenv(agentproduct.EnvName, "")
t.Setenv(agentproduct.EnvName, "qwenwork")
mock := &mockRunner{
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
+3 -4
View File
@@ -29,9 +29,8 @@ import (
// - Host-owned is triggered iff DINGTALK_DWS_AGENTCODE is non-empty.
// - When triggered, `clawType` in the emitted hostControl block MUST be the
// exact value the CLI actually injects on the wire. Each edition supplies
// its existing default and an optional valid DWS_AGENT_PRODUCT overrides
// it. Invalid input falls back here for library compatibility; root command
// execution rejects it before network access.
// its fixed value; DWS_AGENT_PRODUCT is a separate observability and IM
// message-display signal and never affects this PAT value.
// - When DINGTALK_DWS_AGENTCODE is empty the provider returns "" so
// HostControlBlock yields nil and no hostControl block is emitted.
func init() {
@@ -59,5 +58,5 @@ func effectiveClawType() string {
headers = h.MergeHeaders(headers)
}
}
return resolveEffectiveAgentProduct(headers)
return resolveEditionClawType(headers)
}
+100
View File
@@ -0,0 +1,100 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"bytes"
stderrors "errors"
"io"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
func TestLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
tests := []struct {
name string
args []string
}{
{name: "camel case", args: []string{"--dryRun", "chat", "bot", "find", "--help"}},
{name: "fuzzy boolean", args: []string{"--dry-rnu", "chat", "bot", "find", "--help"}},
{name: "fuzzy value", args: []string{"--profle", "corp:user", "chat", "bot", "find", "--help"}},
{name: "sticky value", args: []string{"--timeout30", "chat", "bot", "find", "--help"}},
{name: "sticky boolean value", args: []string{"--verbosefalse", "chat", "bot", "find", "--help"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
if err != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", test.args, err)
}
if ctx == nil || ctx.Command != "dws chat bot find" || len(ctx.Corrections) == 0 {
t.Fatalf("RunPreParseArgs(%v) context = %#v", test.args, ctx)
}
if err := root.Execute(); err != nil {
t.Fatalf("corrected leading persistent flag failed: %v", err)
}
})
}
}
func TestPreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
root := NewSchemaSourceRootCommand()
args := []string{
"chat", "message", "send",
"--user-id", "123", "--user", "456", "--text", "hi",
"--format", "table", "--debug",
}
_, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err == nil {
t.Fatal("alias/canonical conflict unexpectedly succeeded")
}
if wantsJSONErrors(root) {
t.Fatal("--format table was not applied before rendering the PreParse error")
}
if got := resolveVerbosity(root); got != apperrors.VerbosityDebug {
t.Fatalf("PreParse error verbosity = %v, want debug", got)
}
err = newPreParseValidationError(err)
var structured *apperrors.Error
if !stderrors.As(err, &structured) {
t.Fatalf("PreParse validation error = %T, want *errors.Error", err)
}
if strings.Contains(structured.Message, "pipeline") || strings.Contains(structured.Message, "semantic-alias") ||
strings.Contains(structured.Cause.Error(), "pipeline") || strings.Contains(structured.Cause.Error(), "semantic-alias") {
t.Fatalf("internal pipeline identity leaked to user error: message=%q cause=%q", structured.Message, structured.Cause)
}
var conflict *pipeline.FlagConflictError
if !stderrors.As(err, &conflict) {
t.Fatalf("PreParse validation error lost FlagConflictError: %v", err)
}
var output bytes.Buffer
if printErr := printExecutionError(root, &output, &output, err); printErr != nil {
t.Fatalf("printExecutionError() error = %v", printErr)
}
rendered := output.String()
if strings.HasPrefix(strings.TrimSpace(rendered), "{") {
t.Fatalf("--format table rendered JSON:\n%s", rendered)
}
if !strings.Contains(rendered, "Reason: parameter_conflict") || !strings.Contains(rendered, "Cause:") {
t.Fatalf("--debug details missing from early error:\n%s", rendered)
}
}
+111 -6
View File
@@ -76,6 +76,7 @@ var (
rootPluginLoadHooks = (*plugin.Plugin).LoadHooks
rootPluginSyncSkills = plugin.SyncSkills
rootAuthLoadTokenData = authpkg.LoadTokenData
rootNewCommandRunnerWithFlags = newCommandRunnerWithFlags
)
// Execute runs the root command and returns the process exit code.
@@ -114,7 +115,11 @@ func Execute() (exitCode int) {
// Run PreParse handlers on raw argv before Cobra parses flags.
// This corrects model-generated errors like --userId → --user-id
// and --limit100 → --limit 100.
rootRunPreParse(root, engine)
if err := rootRunPreParse(root, engine); err != nil {
err = newPreParseValidationError(err)
_ = printExecutionError(root, os.Stdout, os.Stderr, err)
return apperrors.ExitCode(err)
}
executed, err := rootExecuteCommand(root)
if err != nil {
@@ -136,6 +141,22 @@ func Execute() (exitCode int) {
return 0
}
// newPreParseValidationError keeps pipeline handler identity in internal logs
// while exposing only the underlying parameter-domain error to CLI users.
func newPreParseValidationError(err error) error {
userErr := err
var handlerErr *pipeline.HandlerError
if stderrors.As(err, &handlerErr) && handlerErr.Unwrap() != nil {
userErr = handlerErr.Unwrap()
}
return apperrors.NewValidation(
userErr.Error(),
apperrors.WithReason("parameter_conflict"),
apperrors.WithHint("Remove the duplicate alias/canonical spelling and pass the parameter exactly once."),
apperrors.WithCause(userErr),
)
}
func isUnknownCommandError(err error) bool {
return err != nil && strings.Contains(err.Error(), "unknown command")
}
@@ -183,6 +204,22 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
// 无论哪种格式,子串 "--help' for usage." 都可被检索到。
tail := fmt.Sprintf("\nSee '%s --help' for usage.", cmd.CommandPath())
msgWithTail := errMsg + tail
if flag, protection, ok := reviewedFlagProtection(cmd, errMsg); ok {
hint := fmt.Sprintf("Parameter --%s is blocked from automatic normalization on %q; choose an explicit flag from --help.", flag, cmd.CommandPath())
reason := "blocked_flag"
if protection == pipeline.FlagProtectionAmbiguous {
hint = fmt.Sprintf("Parameter --%s is ambiguous on %q and cannot be normalized safely; choose the intended explicit flag from --help.", flag, cmd.CommandPath())
reason = "ambiguous_flag"
}
return apperrors.NewValidation(
msgWithTail,
apperrors.WithHint(hint),
apperrors.WithReason(reason),
apperrors.WithCause(err),
apperrors.WithActions(fmt.Sprintf("Run '%s --help' for valid flags", cmd.CommandPath())),
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
)
}
// Common flag aliases and suggestions
suggestions := map[string]string{
@@ -231,6 +268,33 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
return fmt.Errorf("%s%s", errMsg, tail)
}
func reviewedFlagProtection(cmd *cobra.Command, errMsg string) (string, pipeline.FlagProtection, bool) {
if cmd == nil {
return "", "", false
}
const prefix = "unknown flag: --"
idx := strings.Index(errMsg, prefix)
if idx < 0 {
return "", "", false
}
flag := strings.TrimSpace(errMsg[idx+len(prefix):])
if i := strings.IndexAny(flag, " =\n\t"); i >= 0 {
flag = flag[:i]
}
entry, ok := cli.LookupParamAlias(cmd.CommandPath())
if !ok {
return "", "", false
}
morphed := cmdutil.Morph(flag)
if entry.IsBlocked(morphed) {
return flag, pipeline.FlagProtectionBlocked, true
}
if entry.IsAmbiguous(morphed) {
return flag, pipeline.FlagProtectionAmbiguous, true
}
return "", "", false
}
func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err error) error {
var raw apperrors.RawStderrError
if stderrors.As(err, &raw) {
@@ -339,7 +403,7 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
loader := cli.EnvironmentLoader{
LookupEnv: os.LookupEnv,
}
runner := newCommandRunnerWithFlags(loader, flags)
runner := rootNewCommandRunnerWithFlags(loader, flags)
root := &cobra.Command{
Use: "dws",
@@ -453,11 +517,38 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
configureRootHelp(root)
// Set custom flag error handler for better UX
root.SetFlagErrorFunc(flagErrorWithSuggestions)
installReviewedFlagProtectionHandlers(root)
root.SetContext(rootCtx)
return root
}
// installReviewedFlagProtectionHandlers makes reviewed blocked/ambiguous
// parameters authoritative even when an older command subtree has installed a
// local FlagErrorFunc. Commands without a reviewed guard keep their existing
// handler or inherit the root handler as before.
func installReviewedFlagProtectionHandlers(root *cobra.Command) {
if root == nil {
return
}
var visit func(*cobra.Command)
visit = func(cmd *cobra.Command) {
if entry, ok := cli.LookupParamAlias(cmd.CommandPath()); ok && (len(entry.Blocked) > 0 || len(entry.Ambiguous) > 0) {
previous := cmd.FlagErrorFunc()
cmd.SetFlagErrorFunc(func(current *cobra.Command, err error) error {
if _, _, guarded := reviewedFlagProtection(current, err.Error()); guarded {
return flagErrorWithSuggestions(current, err)
}
return previous(current, err)
})
}
for _, child := range cmd.Commands() {
visit(child)
}
}
visit(root)
}
func preparseProfileFlag(args []string) string {
args, _ = normalizeProfileFlagArgs(args)
for i := 0; i < len(args); i++ {
@@ -1252,13 +1343,27 @@ func newPipelineEngine() *pipeline.Engine {
// Register handler runs during command tree building.
handlers.RegisterHandler{},
// PreParse handlers run in order: alias → sticky → paramname.
// Alias normalises case first (--userId → --user-id), then
// sticky splits glued values (--limit100 → --limit 100), then
// paramname fixes near-miss typos (--limt → --limit).
// PreParse handlers run in order: alias → semantic → sticky → paramname
// → boolvalue.
// Alias normalises case first (--userId → --user-id), then semantic
// resolves reviewed synonyms to the real flag (--keyword → --query),
// then sticky splits glued values (--limit100 → --limit 100), then
// paramname fixes near-miss typos (--limt → --limit). Boolvalue runs
// last so detached values for every real boolean flag (for example
// `--dry-run false`) become explicit `--flag=false` tokens before pflag
// can interpret the bare flag as true.
handlers.AliasHandler{},
handlers.SemanticAliasHandler{
// Inject the build-time reduced alias table with native types so
// the handler package stays decoupled from cli.
Lookup: func(rawCommandPath string) (map[string]string, []string, []string, bool) {
e, ok := cli.LookupParamAlias(rawCommandPath)
return e.Aliases, e.Blocked, e.Ambiguous, ok
},
},
handlers.StickyHandler{},
handlers.ParamNameHandler{},
handlers.BoolValueHandler{},
// PostParse handlers normalise structured values.
handlers.ParamValueHandler{},
+7 -1
View File
@@ -40,7 +40,7 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) {}
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
rootResetRecoveryState = func() {}
rootStopAllStdioClients = func() {}
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
@@ -52,6 +52,12 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
t.Fatalf("successful Execute code = %d", code)
}
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return errors.New("alias/canonical conflict") }
if code := Execute(); code == 0 {
t.Fatal("pre-parse conflict returned zero")
}
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
wantErr := errors.New("unknown command missing")
rootLatestRecoveryCapture = func() *recovery.LastError { return &recovery.LastError{EventID: "evt-test"} }
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
+20 -13
View File
@@ -1005,9 +1005,8 @@ func resolveIdentityHeaders() map[string]string {
// Inject environment variable based headers for MCP gateway tracking.
// DINGTALK_AGENT, if set by the caller, is forwarded verbatim as the
// x-dingtalk-agent header. It does NOT influence claw-type (which comes
// from the edition default plus the explicit DWS_AGENT_PRODUCT override)
// and it does NOT influence the host-owned PAT decision (driven solely by
// x-dingtalk-agent header. It does NOT influence the edition-fixed
// claw-type or the host-owned PAT decision (driven solely by
// DINGTALK_DWS_AGENTCODE).
sessionID := os.Getenv(envDingtalkSessionID)
if sessionID == "" {
@@ -1068,22 +1067,30 @@ func resolveIdentityHeaders() map[string]string {
if fn := edition.Get().MergeHeaders; fn != nil {
headers = fn(headers)
}
// Resolve the Agent Product before credential injection. The credential
// hook has a separate contract and must not be able to replace the
// request identity used by PAT hostControl serialization.
headers = applyAgentProductOverride(headers)
agentProduct := headers[agentproduct.HeaderName]
if headers == nil {
headers = make(map[string]string)
}
// claw-type is the edition-fixed routing/PAT identity. Agent Product is a
// separate caller-declared observability and IM-display dimension.
clawType := resolveEditionClawType(headers)
headers["claw-type"] = clawType
headers = applyAgentProductHeader(headers)
agentProduct, hasAgentProduct := headers[agentproduct.HeaderName]
if fn := edition.Get().EnterpriseCredentialHeaders; fn != nil {
headers = fn(headers)
}
if headers == nil {
headers = make(map[string]string)
}
// DWS_AGENT_PRODUCT is the explicit caller override for the existing
// claw-type wire header. Reassert the resolved product after credential
// injection so that hook cannot alter identity. Invalid values are ignored
// on this best-effort library path; root execution rejects them earlier.
headers[agentproduct.HeaderName] = agentProduct
// Credential hooks cannot alter either identity dimension. Restore the
// fixed claw-type and the validated Product Header (or its absence).
headers["claw-type"] = clawType
if hasAgentProduct {
headers[agentproduct.HeaderName] = agentProduct
} else {
delete(headers, agentproduct.HeaderName)
}
return headers
}
@@ -36,6 +36,12 @@ var (
manualAgentExamplePlaceholderPattern = regexp.MustCompile(`<([^>]+)>`)
manualAgentExampleDryRunJSONPattern = regexp.MustCompile(`(?i)"dry_run"\s*:\s*true`)
manualAgentExampleDryRunPlanPattern = regexp.MustCompile(`(?i)"preview_kind"\s*:\s*"plan"`)
// manualAgentExampleDryRunInvocationPattern recognizes the executor
// envelope (and the dev connect preview, which mirrors its shape): the
// top-level contract is an invocation that merely embeds the would-be
// request inside response, so it must classify as invocation, not request.
manualAgentExampleDryRunInvocationPattern = regexp.MustCompile(
`"kind"\s*:\s*"(?:(?:helper|compat|workflow)_invocation|connect_preview)"`)
)
// TestManualAgentExamplesContract is the always-on gate. It validates every
@@ -395,6 +401,9 @@ func manualAgentExampleDryRunEvidence(capture manualAgentExampleCapture) (string
if manualAgentExampleDryRunJSONPattern.MatchString(capture.Output) && manualAgentExampleDryRunPlanPattern.MatchString(capture.Output) {
return cli.DryRunPreviewPlan, true
}
if manualAgentExampleDryRunJSONPattern.MatchString(capture.Output) && manualAgentExampleDryRunInvocationPattern.MatchString(capture.Output) {
return cli.DryRunPreviewInvocation, true
}
if manualAgentExampleDryRunJSONPattern.MatchString(capture.Output) {
return cli.DryRunPreviewRequest, true
}
@@ -448,6 +457,37 @@ func TestCrossPlatformCoverageManualAgentExampleDryRunEvidenceClassifiesStructur
}
}
func TestManualAgentExampleDryRunEvidenceClassifiesExecutorEnvelopeAsInvocation(t *testing.T) {
// Executor dry-run envelope: invocation at top level, the would-be request
// nested inside response — must classify as invocation, never request.
envelope := `{
"invocation": {"kind": "helper_invocation", "stage": "helper_override", "implemented": false,
"dry_run": true, "canonical_product": "devapp", "tool": "create_dev_app"},
"response": {"dry_run": true, "request": {"jsonrpc": "2.0", "method": "tools/call"},
"note": "execution skipped by --dry-run"}
}`
kind, observed := manualAgentExampleDryRunEvidence(manualAgentExampleCapture{Output: envelope})
if !observed || kind != cli.DryRunPreviewInvocation {
t.Fatalf("executor envelope classified as kind=%q observed=%v, want invocation", kind, observed)
}
for _, invKind := range []string{"compat_invocation", "workflow_invocation", "connect_preview"} {
out := strings.Replace(envelope, "helper_invocation", invKind, 1)
if kind, ok := manualAgentExampleDryRunEvidence(manualAgentExampleCapture{Output: out}); !ok || kind != cli.DryRunPreviewInvocation {
t.Fatalf("kind %q envelope classified as %q/%v, want invocation", invKind, kind, ok)
}
}
// A bare MCP dry-run document has no invocation kind and stays request.
mcpDoc := `{"dry_run": true, "executed": false, "tool": "get_dev_app", "arguments": {}}`
if kind, ok := manualAgentExampleDryRunEvidence(manualAgentExampleCapture{Output: mcpDoc}); !ok || kind != cli.DryRunPreviewRequest {
t.Fatalf("bare MCP dry-run classified as %q/%v, want request", kind, ok)
}
// Plan still wins over an invocation-shaped payload.
planDoc := `{"dry_run": true, "preview_kind": "plan", "kind": "helper_invocation"}`
if kind, ok := manualAgentExampleDryRunEvidence(manualAgentExampleCapture{Output: planDoc}); !ok || kind != cli.DryRunPreviewPlan {
t.Fatalf("plan precedence broken: %q/%v", kind, ok)
}
}
func manualAgentExamplePromptObserved(output string) bool {
normalized := strings.ToLower(output)
for _, marker := range []string{
+8 -8
View File
@@ -458,14 +458,14 @@ func TestPromptingWritesRequireUserConfirmation(t *testing.T) {
"sheet.delete_pivot_table": "medium",
}
wantSources := map[string]string{
"attendance.class_create": "internal/cli/schema_hints/metadata/attendance.json",
"attendance.class_update": "internal/cli/schema_hints/metadata/attendance.json",
"doc.delete_comment": "internal/cli/schema_hints/metadata/doc.json",
"doc.version_revert": "internal/cli/schema_hints/metadata/doc.json",
"drive.publish_set": "internal/cli/schema_hints/metadata/drive.json",
"drive.publish_unset": "internal/cli/schema_hints/metadata/drive.json",
"sheet.chart_delete": "internal/cli/schema_hints/metadata/sheet.json",
"sheet.delete_pivot_table": "internal/cli/schema_hints/metadata/sheet.json",
"attendance.class_create": "corecmd.contract",
"attendance.class_update": "corecmd.contract",
"doc.delete_comment": "corecmd.contract",
"doc.version_revert": "corecmd.contract",
"drive.publish_set": "corecmd.contract",
"drive.publish_unset": "corecmd.contract",
"sheet.chart_delete": "corecmd.contract",
"sheet.delete_pivot_table": "corecmd.contract",
}
canonicals := make([]string, 0, len(wantEffects))
for canonical := range wantEffects {
@@ -8,7 +8,6 @@ import (
"encoding/json"
"os"
"sort"
"strings"
"testing"
)
@@ -119,11 +118,11 @@ func TestReviewedRoutedInterfacesReachFinalSchema(t *testing.T) {
t.Errorf("missing %s provenance", field)
continue
}
if got := schemaContractString(entry["precedence"]); got != "reviewed_explicit" {
t.Errorf("%s provenance precedence = %q, want reviewed_explicit", field, got)
if got := schemaContractString(entry["precedence"]); got != "contract_final" {
t.Errorf("%s provenance precedence = %q, want contract_final", field, got)
}
if got := schemaContractString(entry["source"]); !strings.HasSuffix(got, test.sourceSuffix) {
t.Errorf("%s provenance source = %q, want suffix %q", field, got, test.sourceSuffix)
if got := schemaContractString(entry["source"]); got != "corecmd.contract" {
t.Errorf("%s provenance source = %q, want corecmd.contract", field, got)
}
}
if got := provenance["interface_ref"]["value"]; got != nil {
@@ -174,11 +173,11 @@ func TestViewGetWrappersUsePinnedGetViewsInterface(t *testing.T) {
provenance := schemaContractMap(tool["field_provenance"])
for _, field := range []string{"interface_mode", "availability", "interface_ref"} {
entry := provenance[field]
if got := schemaContractString(entry["precedence"]); got != "reviewed_explicit" {
t.Errorf("%s %s precedence = %q, want reviewed_explicit", canonical, field, got)
if got := schemaContractString(entry["precedence"]); got != "contract_final" {
t.Errorf("%s %s precedence = %q, want contract_final", canonical, field, got)
}
if got := schemaContractString(entry["source"]); !strings.Contains(got, "internal/cli/schema_hints/metadata/") {
t.Errorf("%s %s source = %q, want reviewed interface disposition source", canonical, field, got)
if got := schemaContractString(entry["source"]); got != "corecmd.contract" {
t.Errorf("%s %s source = %q, want corecmd.contract", canonical, field, got)
}
}
}
@@ -202,29 +201,10 @@ func TestReviewedInterfaceDispositionSourceOwnsRuntimeSurface(t *testing.T) {
return value
}
runtimeSurface := load("../cli/schema_hints/runtime-surface-completeness.json")
legacyDispositionKeys := load("../cli/schema_hints/zz-interface-disposition-review.json").Tools
dispositions := hintFile{Source: map[string]any{"reviewed": true}, Tools: map[string]map[string]any{}}
for _, product := range []string{
"attendance", "aitable", "chat", "drive", "event", "sheet", "wiki", "doc", "mail", "todo", "calendar", "conference", "contact", "dev", "devdoc", "ding", "live", "minutes", "oa", "pat", "report", "aisearch",
} {
path := "../cli/schema_hints/metadata/" + product + ".json"
if _, err := os.Stat(path); err != nil {
continue
}
file := load(path)
for canonical, hint := range file.Tools {
if _, ok := legacyDispositionKeys[canonical]; !ok {
continue
}
trimmed := map[string]any{}
for _, field := range []string{"interface_mode", "availability", "interface_ref", "interface_reason"} {
if value, exists := hint[field]; exists {
trimmed[field] = value
}
}
dispositions.Tools[canonical] = trimmed
}
}
// After hints→declarations migration, disposition facts live in compiled
// ContractFinal (corecmd.contract). zz-interface-disposition-review.json
// remains the audit key set + expected values for this gate.
dispositions := load("../cli/schema_hints/zz-interface-disposition-review.json")
if dispositions.Source["reviewed"] != true {
t.Fatalf("interface disposition source reviewed = %#v, want true", dispositions.Source["reviewed"])
}
@@ -314,11 +294,11 @@ func TestReviewedInterfaceDispositionSourceOwnsRuntimeSurface(t *testing.T) {
}
for _, field := range fields {
entry := provenance[field]
if got := schemaContractString(entry["precedence"]); got != "reviewed_explicit" {
t.Errorf("%s final %s precedence = %q, want reviewed_explicit", canonical, field, got)
if got := schemaContractString(entry["precedence"]); got != "contract_final" {
t.Errorf("%s final %s precedence = %q, want contract_final", canonical, field, got)
}
if got := schemaContractString(entry["source"]); !strings.Contains(got, "internal/cli/schema_hints/metadata/") {
t.Errorf("%s final %s source = %q, want reviewed disposition source", canonical, field, got)
if got := schemaContractString(entry["source"]); got != "corecmd.contract" {
t.Errorf("%s final %s source = %q, want corecmd.contract", canonical, field, got)
}
}
}
+43 -31
View File
@@ -15,45 +15,53 @@ type finalSchemaSafetyWant struct {
risk string
confirmation string
idempotency string
// provenance is the expected field_provenance precedence; empty defaults
// to "reviewed_explicit" (hints path). Contract-declared tools (devapp)
// deliver "contract_final".
provenance string
}
func TestReviewedMutationSafetyReachesFinalSchema(t *testing.T) {
const declared = "contract_final"
wants := []finalSchemaSafetyWant{
{canonical: "aitable.form_field_hide", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "idempotent"},
{canonical: "chat.dismiss_group", effect: "destructive", risk: "high", confirmation: "user_required", idempotency: "unknown"},
{canonical: "drive.recycle_restore", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
{canonical: "minutes.create_speaker_summary", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
{canonical: "sheet.clear_range", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown"},
{canonical: "sheet.batch_update", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown"},
{canonical: "sheet.range_batch_clear", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown"},
{canonical: "sheet.group_dimension", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
{canonical: "sheet.sort_filter", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
{canonical: "sheet.ungroup_dimension", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
{canonical: "aitable.form_field_hide", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "idempotent", provenance: declared},
{canonical: "chat.dismiss_group", effect: "destructive", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "drive.recycle_restore", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
{canonical: "minutes.create_speaker_summary", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
{canonical: "sheet.clear_range", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "sheet.batch_update", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "sheet.range_batch_clear", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "sheet.group_dimension", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
{canonical: "sheet.sort_filter", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
{canonical: "sheet.ungroup_dimension", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
}
assertFinalSchemaSafety(t, wants)
}
func TestDevAppWriteGuardRequiresFinalSchemaConfirmation(t *testing.T) {
// devapp 全树已声明化:provenance 为 contract_final;风险按可逆性分级
// (可逆写 medium / 难撤回 high-write / 删除与发布 destructive)。
const declared = "contract_final"
wants := []finalSchemaSafetyWant{
{canonical: "dev.add_dev_app_members", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
{canonical: "dev.apply_dev_app_permissions", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
{canonical: "dev.create_dev_app", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
{canonical: "dev.create_dev_app_version", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
{canonical: "dev.delete_dev_app", effect: "destructive", risk: "high", confirmation: "user_required", idempotency: "unknown"},
{canonical: "dev.disable_dev_app", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
{canonical: "dev.disable_dev_app_robot", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
{canonical: "dev.enable_dev_app", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
{canonical: "dev.enable_dev_app_robot", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
{canonical: "dev.publish_dev_app_version", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
{canonical: "dev.remove_dev_app_members", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
{canonical: "dev.remove_dev_app_permissions", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
{canonical: "dev.set_extension_robot_config", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
{canonical: "dev.set_extension_webapp_config", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
{canonical: "dev.submit_robot_create_task", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
{canonical: "dev.subscribe_dev_app_events", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
{canonical: "dev.unsubscribe_dev_app_events", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
{canonical: "dev.update_dev_app", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
{canonical: "dev.update_dev_app_security_config", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
{canonical: "dev.add_dev_app_members", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "dev.apply_dev_app_permissions", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "dev.create_dev_app", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "dev.create_dev_app_version", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "dev.delete_dev_app", effect: "destructive", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "dev.disable_dev_app", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "dev.disable_dev_app_robot", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "dev.enable_dev_app", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "dev.enable_dev_app_robot", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "dev.publish_dev_app_version", effect: "destructive", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "dev.remove_dev_app_members", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "dev.remove_dev_app_permissions", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "dev.set_extension_robot_config", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "dev.set_extension_webapp_config", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "dev.submit_robot_create_task", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "dev.subscribe_dev_app_events", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "dev.unsubscribe_dev_app_events", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "dev.update_dev_app", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "dev.update_dev_app_security_config", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
}
assertFinalSchemaSafety(t, wants)
}
@@ -77,13 +85,17 @@ func assertFinalSchemaSafety(t *testing.T, wants []finalSchemaSafetyWant) {
"confirmation": want.confirmation,
"idempotency": want.idempotency,
}
wantProvenance := want.provenance
if wantProvenance == "" {
wantProvenance = "reviewed_explicit"
}
provenance := schemaContractMap(tool["field_provenance"])
for field, expected := range values {
if got := schemaContractString(tool[field]); got != expected {
t.Errorf("%s = %q, want %q", field, got, expected)
}
if got := schemaContractString(provenance[field]["precedence"]); got != "reviewed_explicit" {
t.Errorf("%s provenance precedence = %q, want reviewed_explicit", field, got)
if got := schemaContractString(provenance[field]["precedence"]); got != wantProvenance {
t.Errorf("%s provenance precedence = %q, want %s", field, got, wantProvenance)
}
}
})
+79
View File
@@ -0,0 +1,79 @@
// 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"
"github.com/spf13/cobra"
)
// ContractFinalPayload is the Contract-authored final Schema leaf overlay.
// Registered in-process by the framework; Schema assembly reads it as
// pass-through. No JSON bridge. Treat as read-only after Register.
type ContractFinalPayload struct {
Title string
Description string
Positionals []RuntimeSchemaPositional
Safety *SafetySpec
DryRun *DryRunSpec
Interface *InterfaceSpec
Selection *SelectionSpec
Identity *ToolIdentitySpec
}
var contractFinalByCommand sync.Map // *cobra.Command → *ContractFinalPayload
// RegisterRuntimeContractFinal stores the typed final Schema overlay for a leaf.
// Light runtime write: one map store, no JSON, no deep clone.
func RegisterRuntimeContractFinal(cmd *cobra.Command, payload ContractFinalPayload) {
if cmd == nil {
return
}
AnnotateRuntimeContract(cmd)
p := payload
contractFinalByCommand.Store(cmd, &p)
}
// RuntimeContractFinal returns the registered final Schema overlay (read-only).
func RuntimeContractFinal(cmd *cobra.Command) (ContractFinalPayload, bool) {
if cmd == nil {
return ContractFinalPayload{}, false
}
raw, ok := contractFinalByCommand.Load(cmd)
if !ok {
return ContractFinalPayload{}, false
}
p, ok := raw.(*ContractFinalPayload)
if !ok || p == nil {
return ContractFinalPayload{}, false
}
return *p, true
}
// HasRuntimeContractFinal reports whether the leaf has a registered final overlay.
func HasRuntimeContractFinal(cmd *cobra.Command) bool {
if cmd == nil {
return false
}
_, ok := contractFinalByCommand.Load(cmd)
return ok
}
// ClearRuntimeContractFinalForTest removes a registration (tests only).
func ClearRuntimeContractFinalForTest(cmd *cobra.Command) {
if cmd != nil {
contractFinalByCommand.Delete(cmd)
}
}
@@ -0,0 +1,354 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package cli_test
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"runtime"
"sort"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
)
// TestAllCommandsContractFinalConsistentWithLiveAndEmbeddedCatalog walks every
// reviewed registry leaf and checks, one by one:
// 1. PrimaryCommand has runtime ContractFinal (command-side declaration)
// 2. Live assembly ToolSpec fields match that ContractFinal
// 3. Embedded full-leaf shard fields match live ToolSpec (incl. examples)
func TestAllCommandsContractFinalConsistentWithLiveAndEmbeddedCatalog(t *testing.T) {
root := app.NewSchemaSourceRootCommand()
liveReg, err := cli.AssembleSchemaRegistry(root)
if err != nil {
t.Fatalf("AssembleSchemaRegistry(live) error = %v", err)
}
embedByCanon, err := loadEmbeddedFullLeafTools()
if err != nil {
t.Fatalf("load embedded full leaf shards: %v", err)
}
effective, err := cli.BuildEffectiveCommandRegistry(root)
if err != nil {
t.Fatalf("BuildEffectiveCommandRegistry() error = %v", err)
}
bound, err := cli.BindEffectiveCommandRegistry(root, effective)
if err != nil {
t.Fatalf("BindEffectiveCommandRegistry() error = %v", err)
}
liveByCanon := indexLiveTools(liveReg)
type row struct {
canonical string
cliPath string
status string
detail string
}
rows := make([]row, 0, len(bound.Commands))
var failCount int
checked := 0
canonOrder := make([]string, 0, len(bound.Commands))
boundByCanon := make(map[string]cli.BoundCommandSpec, len(bound.Commands))
for _, cmd := range bound.Commands {
if cmd.CanonicalPath == "" {
continue
}
canonOrder = append(canonOrder, cmd.CanonicalPath)
boundByCanon[cmd.CanonicalPath] = cmd
}
sort.Strings(canonOrder)
for _, canonical := range canonOrder {
cmd := boundByCanon[canonical]
cliPath := cmd.PrimaryCLIPath
liveTool, ok := liveByCanon[canonical]
if !ok {
failCount++
rows = append(rows, row{canonical, cliPath, "FAIL", "absent from live registry"})
continue
}
embedTool, ok := embedByCanon[canonical]
if !ok {
failCount++
rows = append(rows, row{canonical, cliPath, "FAIL", "absent from embedded full leaf shard"})
continue
}
final, has := cli.RuntimeContractFinal(cmd.PrimaryCommand)
if !has {
failCount++
rows = append(rows, row{canonical, cliPath, "FAIL", "no RuntimeContractFinal on PrimaryCommand"})
continue
}
checked++
problems := compareFinalToLive(canonical, final, liveTool)
problems = append(problems, compareLiveToEmbedded(canonical, liveTool, embedTool)...)
if len(problems) > 0 {
failCount++
rows = append(rows, row{canonical, cliPath, "FAIL", strings.Join(problems, "; ")})
continue
}
if liveTool.MetadataSource != "corecmd.contract" {
failCount++
rows = append(rows, row{canonical, cliPath, "FAIL", "metadata_source=" + liveTool.MetadataSource})
continue
}
rows = append(rows, row{canonical, cliPath, "OK", "ContractFinal=live=catalog"})
}
reportPath := filepath.Join(t.TempDir(), "contract_final_per_command.txt")
var b strings.Builder
for i, r := range rows {
line := fmt.Sprintf("%04d %s %-48s %-60s %s\n", i+1, r.status, r.canonical, r.cliPath, r.detail)
b.WriteString(line)
if r.status != "OK" {
t.Errorf("%s", strings.TrimSpace(line))
}
}
summary := fmt.Sprintf("SUMMARY checked=%d fail=%d ok=%d live=%d embed=%d\n",
checked, failCount, checked-failCount, len(liveByCanon), len(embedByCanon))
b.WriteString(summary)
if err := os.WriteFile(reportPath, []byte(b.String()), 0o644); err != nil {
t.Fatalf("write report: %v", err)
}
t.Logf("per-command report: %s", reportPath)
t.Logf("%s", strings.TrimSpace(summary))
// Print every line so -v shows 逐个检查.
for _, r := range rows {
t.Logf("%s %-48s %s", r.status, r.canonical, r.cliPath)
}
if failCount > 0 {
t.Fatalf("per-command consistency failed: %d/%d", failCount, len(rows))
}
if checked != 845 {
t.Fatalf("checked = %d, want 845 reviewed tools", checked)
}
if err := cli.ValidateEmbeddedRuntimeSchemaCompleteness(app.NewSchemaSourceRootCommand()); err != nil {
t.Fatalf("ValidateEmbeddedRuntimeSchemaCompleteness() error = %v", err)
}
}
type liveToolView struct {
Description string
MetadataSource string
Effect string
Risk string
Confirmation string
Idempotency string
InterfaceMode string
Availability string
InterfacePID string
InterfaceRPC string
InterfaceReason string
AgentSummary string
UseWhen []string
AvoidWhen []string
Examples []string
}
type embedToolView struct {
Description string
MetadataSource string
Effect string
Risk string
Confirmation string
Idempotency string
InterfaceMode string
Availability string
InterfacePID string
InterfaceRPC string
AgentSummary string
UseWhen []string
AvoidWhen []string
Examples []string
}
func indexLiveTools(reg cli.SchemaRegistry) map[string]liveToolView {
out := make(map[string]liveToolView)
for _, product := range reg.Products {
for _, tool := range product.Tools {
view := liveToolView{
Description: tool.Description,
MetadataSource: tool.MetadataSource,
Effect: tool.Safety.Effect,
Risk: tool.Safety.Risk,
Confirmation: tool.Safety.Confirmation,
Idempotency: tool.Safety.Idempotency,
InterfaceMode: tool.Interface.Mode,
Availability: tool.Interface.Availability,
InterfaceReason: tool.Interface.Reason,
AgentSummary: tool.Selection.AgentSummary,
UseWhen: append([]string(nil), tool.Selection.UseWhen...),
AvoidWhen: append([]string(nil), tool.Selection.AvoidWhen...),
Examples: append([]string(nil), tool.Selection.Examples...),
}
if tool.Interface.Ref != nil {
view.InterfacePID = tool.Interface.Ref.ProductID
view.InterfaceRPC = tool.Interface.Ref.RPCName
}
out[tool.Identity.CanonicalPath] = view
}
}
return out
}
func repoCLIDir() string {
_, file, _, ok := runtime.Caller(0)
if !ok {
return "."
}
return filepath.Dir(file)
}
func loadEmbeddedFullLeafTools() (map[string]embedToolView, error) {
toolsDir := filepath.Join(repoCLIDir(), "schema_catalog", "tools")
entries, err := os.ReadDir(toolsDir)
if err != nil {
return nil, err
}
out := make(map[string]embedToolView)
for _, ent := range entries {
if ent.IsDir() || !strings.HasSuffix(ent.Name(), ".json") {
continue
}
data, err := os.ReadFile(filepath.Join(toolsDir, ent.Name()))
if err != nil {
return nil, err
}
var shard struct {
Tools map[string]map[string]any `json:"tools"`
}
if err := json.Unmarshal(data, &shard); err != nil {
return nil, fmt.Errorf("%s: %w", ent.Name(), err)
}
for key, raw := range shard.Tools {
canonical := asString(raw["canonical_path"])
if canonical == "" {
canonical = key
}
view := embedToolView{
Description: asString(raw["description"]),
MetadataSource: asString(raw["metadata_source"]),
Effect: asString(raw["effect"]),
Risk: asString(raw["risk"]),
Confirmation: asString(raw["confirmation"]),
Idempotency: asString(raw["idempotency"]),
InterfaceMode: asString(raw["interface_mode"]),
Availability: asString(raw["availability"]),
AgentSummary: asString(raw["agent_summary"]),
UseWhen: asStringSlice(raw["use_when"]),
AvoidWhen: asStringSlice(raw["avoid_when"]),
Examples: asStringSlice(raw["examples"]),
}
if ref, ok := raw["interface_ref"].(map[string]any); ok {
view.InterfacePID = asString(ref["product_id"])
view.InterfaceRPC = asString(ref["rpc_name"])
}
out[canonical] = view
}
}
return out, nil
}
func compareFinalToLive(canonical string, final cli.ContractFinalPayload, tool liveToolView) []string {
var problems []string
add := func(field, want, got string) {
if strings.TrimSpace(want) == "" {
return
}
if strings.TrimSpace(want) != strings.TrimSpace(got) {
problems = append(problems, fmt.Sprintf("%s: final=%q live=%q", field, want, got))
}
}
if final.Description != "" {
add("description", final.Description, tool.Description)
}
if final.Safety != nil {
add("effect", final.Safety.Effect, tool.Effect)
add("risk", final.Safety.Risk, tool.Risk)
add("confirmation", final.Safety.Confirmation, tool.Confirmation)
add("idempotency", final.Safety.Idempotency, tool.Idempotency)
}
if final.Interface != nil {
add("interface_mode", final.Interface.Mode, tool.InterfaceMode)
add("availability", final.Interface.Availability, tool.Availability)
add("interface_reason", final.Interface.Reason, tool.InterfaceReason)
if final.Interface.Ref != nil {
add("interface_product", final.Interface.Ref.ProductID, tool.InterfacePID)
add("interface_rpc", final.Interface.Ref.RPCName, tool.InterfaceRPC)
}
}
if final.Selection != nil {
add("agent_summary", final.Selection.AgentSummary, tool.AgentSummary)
if !reflect.DeepEqual(final.Selection.UseWhen, tool.UseWhen) {
problems = append(problems, "use_when mismatch")
}
if !reflect.DeepEqual(final.Selection.AvoidWhen, tool.AvoidWhen) {
problems = append(problems, "avoid_when mismatch")
}
if !reflect.DeepEqual(final.Selection.Examples, tool.Examples) {
problems = append(problems, "examples mismatch")
}
}
_ = canonical
return problems
}
func compareLiveToEmbedded(canonical string, live liveToolView, embed embedToolView) []string {
var problems []string
add := func(field, a, b string) {
if strings.TrimSpace(a) != strings.TrimSpace(b) {
problems = append(problems, fmt.Sprintf("%s: live=%q embed=%q", field, a, b))
}
}
add("description", live.Description, embed.Description)
add("effect", live.Effect, embed.Effect)
add("risk", live.Risk, embed.Risk)
add("confirmation", live.Confirmation, embed.Confirmation)
add("idempotency", live.Idempotency, embed.Idempotency)
add("interface_mode", live.InterfaceMode, embed.InterfaceMode)
add("availability", live.Availability, embed.Availability)
add("agent_summary", live.AgentSummary, embed.AgentSummary)
add("metadata_source", live.MetadataSource, embed.MetadataSource)
add("interface_product", live.InterfacePID, embed.InterfacePID)
add("interface_rpc", live.InterfaceRPC, embed.InterfaceRPC)
if !reflect.DeepEqual(live.UseWhen, embed.UseWhen) {
problems = append(problems, "use_when live!=embed")
}
if !reflect.DeepEqual(live.AvoidWhen, embed.AvoidWhen) {
problems = append(problems, "avoid_when live!=embed")
}
if !reflect.DeepEqual(live.Examples, embed.Examples) {
problems = append(problems, "examples live!=embed")
}
_ = canonical
return problems
}
func asString(v any) string {
s, _ := v.(string)
return s
}
func asStringSlice(v any) []string {
raw, ok := v.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(raw))
for _, item := range raw {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}
+158
View File
@@ -0,0 +1,158 @@
// 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"
"github.com/spf13/cobra"
)
func TestContractFinalTypedRegistryNoJSON(t *testing.T) {
cmd := &cobra.Command{Use: "x"}
t.Cleanup(func() { ClearRuntimeContractFinalForTest(cmd) })
RegisterRuntimeContractFinal(cmd, ContractFinalPayload{
Title: "T",
Safety: &SafetySpec{
Effect: "write", Confirmation: "user_required", Idempotency: "retryable",
},
Selection: &SelectionSpec{AgentSummary: "sum", UseWhen: []string{"u"}},
Identity: &ToolIdentitySpec{ProductID: "p", Name: "n"},
})
if cmd.Annotations != nil {
if _, ok := cmd.Annotations["dws.schema.final"]; ok {
t.Fatal("must not write JSON annotation dws.schema.final")
}
}
got, ok := RuntimeContractFinal(cmd)
if !ok || got.Title != "T" || got.Safety == nil || got.Safety.Idempotency != "retryable" {
t.Fatalf("got %#v ok=%v", got, ok)
}
if got.Selection == nil || got.Selection.Reviewed != nil {
t.Fatalf("selection must not carry reviewed fields: %#v", got.Selection)
}
}
func TestRuntimeToolSpecFromContractFinalPassThrough(t *testing.T) {
cmd := &cobra.Command{Use: "create", Short: "s", Long: "l"}
t.Cleanup(func() { ClearRuntimeContractFinalForTest(cmd) })
cmd.Flags().String("mode", "", "usage")
AnnotateRuntimeFlag(cmd, "mode", "mode", "string", false, "")
RegisterRuntimeContractFinal(cmd, ContractFinalPayload{
Title: "Final Title",
Safety: &SafetySpec{
Effect: "write", Confirmation: "user_required", Idempotency: "none",
},
DryRun: &DryRunSpec{PreviewKind: DryRunPreviewInvocation},
Selection: &SelectionSpec{
AgentSummary: "from contract",
UseWhen: []string{"create things"},
},
Identity: &ToolIdentitySpec{
ProductID: "dev", Name: "create_thing", CanonicalPath: "dev.create_thing",
},
})
entry := runtimeSchemaEntry{
ProductID: "dev",
ToolName: "create_thing",
CLIName: "create",
CLIPath: "dev create",
PrimaryCLIPath: "dev create",
ProductName: "Dev",
Command: cmd,
Source: "test",
}
spec, err := runtimeToolSpecFromContractFinal(entry, mustFinal(t, cmd), runtimeSchemaMetadataSources{})
if err != nil {
t.Fatal(err)
}
if spec.Title != "Final Title" {
t.Fatalf("title = %q", spec.Title)
}
if spec.Safety.Confirmation != "user_required" || spec.Safety.Idempotency != "none" {
t.Fatalf("safety = %#v", spec.Safety)
}
if spec.DryRun == nil || spec.DryRun.PreviewKind != DryRunPreviewInvocation {
t.Fatalf("dry_run = %#v", spec.DryRun)
}
if spec.Selection.AgentSummary != "from contract" {
t.Fatalf("selection = %#v", spec.Selection)
}
if spec.MetadataSource != "corecmd.contract" {
t.Fatalf("metadata_source = %q", spec.MetadataSource)
}
if len(spec.Parameters) != 1 || spec.Parameters[0].Name != "mode" {
t.Fatalf("parameters = %#v", spec.Parameters)
}
}
func TestRuntimeToolSpecFromContractFinalIdentityMismatchFails(t *testing.T) {
entry := runtimeSchemaEntry{
ProductID: "dev",
ToolName: "create_thing",
CLIName: "create",
CLIPath: "dev create",
PrimaryCLIPath: "dev create",
Command: &cobra.Command{Use: "create"},
Source: "test",
}
for name, id := range map[string]ToolIdentitySpec{
"wrong name": {Name: "delete_thing"},
"wrong canonical path": {CanonicalPath: "dev.delete_thing"},
"wrong product": {ProductID: "other"},
"wrong cli path": {CLIPath: "dev delete"},
"wrong aliases": {Aliases: []string{"dev rm"}},
} {
t.Run(name, func(t *testing.T) {
_, err := runtimeToolSpecFromContractFinal(entry, ContractFinalPayload{Identity: &id}, runtimeSchemaMetadataSources{})
if err == nil {
t.Fatal("declared identity conflicting with bound entry must fail assembly")
}
})
}
consistent := ContractFinalPayload{Identity: &ToolIdentitySpec{
ProductID: "dev", Name: "create_thing", CLIName: "create",
CanonicalPath: "dev.create_thing", CLIPath: "dev create",
PrimaryCLIPath: "dev create", Source: "test",
}}
if _, err := runtimeToolSpecFromContractFinal(entry, consistent, runtimeSchemaMetadataSources{}); err != nil {
t.Fatalf("declared identity matching bound entry must pass: %v", err)
}
}
func TestRuntimeToolSpecFromContractFinalRejectsReviewedSelection(t *testing.T) {
reviewed := true
entry := runtimeSchemaEntry{
ProductID: "dev",
ToolName: "create_thing",
Command: &cobra.Command{Use: "create"},
}
_, err := runtimeToolSpecFromContractFinal(entry, ContractFinalPayload{
Selection: &SelectionSpec{AgentSummary: "sum", Reviewed: &reviewed},
}, runtimeSchemaMetadataSources{})
if err == nil {
t.Fatal("declaration payload carrying Reviewed must fail assembly (reviewed is legacy-path only)")
}
}
func mustFinal(t *testing.T, cmd *cobra.Command) ContractFinalPayload {
t.Helper()
final, ok := RuntimeContractFinal(cmd)
if !ok {
t.Fatal("missing final")
}
return final
}
@@ -0,0 +1,249 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package cli_test
import (
"errors"
"fmt"
"io"
"sort"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
)
// TestUserRequiredSafetyHomologyWithRuntimeGate proves Catalog Safety and the
// executable confirmation gate share one source for every live user_required leaf:
//
// 1. ContractFinal.Safety.Confirmation == live ToolSpec.Confirmation
// 2. Runtime gate is installed: DeclareLeafMetadata (HasContractConfirmSafety),
// Sheet protect marker, or framework NewCommand/Shortcut RunE (verified by
// closed-stdin Execute → confirmation_required / 用户取消了操作 without --yes)
func TestUserRequiredSafetyHomologyWithRuntimeGate(t *testing.T) {
root := app.NewSchemaSourceRootCommand()
if root.PersistentFlags().Lookup("yes") == nil {
root.PersistentFlags().Bool("yes", false, "")
}
if root.PersistentFlags().Lookup("dry-run") == nil {
root.PersistentFlags().Bool("dry-run", false, "")
}
liveReg, err := cli.AssembleSchemaRegistry(root)
if err != nil {
t.Fatalf("AssembleSchemaRegistry: %v", err)
}
effective, err := cli.BuildEffectiveCommandRegistry(root)
if err != nil {
t.Fatalf("BuildEffectiveCommandRegistry: %v", err)
}
bound, err := cli.BindEffectiveCommandRegistry(root, effective)
if err != nil {
t.Fatalf("BindEffectiveCommandRegistry: %v", err)
}
liveByCanon := make(map[string]string) // canonical → confirmation
liveToolByCanon := make(map[string]cli.ToolSpec)
for _, product := range liveReg.Products {
for _, tool := range product.Tools {
liveByCanon[tool.Identity.CanonicalPath] = strings.TrimSpace(tool.Safety.Confirmation)
liveToolByCanon[tool.Identity.CanonicalPath] = tool
}
}
type row struct {
canonical string
cliPath string
gate string
detail string
}
var fails []row
var okRows []row
checked := 0
canonOrder := make([]string, 0, len(bound.Commands))
boundByCanon := make(map[string]cli.BoundCommandSpec, len(bound.Commands))
for _, cmd := range bound.Commands {
if cmd.CanonicalPath == "" {
continue
}
canonOrder = append(canonOrder, cmd.CanonicalPath)
boundByCanon[cmd.CanonicalPath] = cmd
}
sort.Strings(canonOrder)
for _, canonical := range canonOrder {
cmd := boundByCanon[canonical]
leaf := cmd.PrimaryCommand
if leaf == nil {
fails = append(fails, row{canonical, cmd.PrimaryCLIPath, "", "nil PrimaryCommand"})
continue
}
final, hasFinal := cli.RuntimeContractFinal(leaf)
if !hasFinal || final.Safety == nil {
continue
}
conf := strings.TrimSpace(final.Safety.Confirmation)
if conf != "user_required" {
continue
}
checked++
cliPath := cmd.PrimaryCLIPath
liveConf, ok := liveByCanon[canonical]
if !ok {
fails = append(fails, row{canonical, cliPath, "", "absent from live registry"})
continue
}
if liveConf != "user_required" {
fails = append(fails, row{canonical, cliPath, "", fmt.Sprintf(
"ContractFinal=user_required but live ToolSpec.Confirmation=%q", liveConf)})
continue
}
gate := ""
switch {
case helpers.HasContractConfirmSafety(leaf) && helpers.HasSheetMutationConfirmationGuard(leaf):
gate = "declare_leaf+sheet_marker"
case helpers.HasContractConfirmSafety(leaf):
gate = "declare_leaf_confirm"
case helpers.HasSheetMutationConfirmationGuard(leaf):
gate = "sheet_protect"
default:
// NewLeafCommand / Shortcut: NewCommand registers ContractFinal from the
// same SafetySpec it wires into ConfirmSafety. Prove that SafetySpec is
// still user_required-operable (closed stdin → confirmation gate).
leaf.SetIn(strings.NewReader(""))
leaf.SetOut(io.Discard)
leaf.SetErr(io.Discard)
if err := corecmd.ConfirmSafety(leaf, *final.Safety); !isConfirmationGateError(err) {
fails = append(fails, row{canonical, cliPath, "", fmt.Sprintf(
"framework leaf: ConfirmSafety(ContractFinal.Safety) = %v, want confirmation gate", err)})
continue
}
// Execute without --yes must not succeed (confirm-first or validate-then-confirm).
if err := probeConfirmationGate(leaf); err == nil {
fails = append(fails, row{canonical, cliPath, "",
"framework leaf: Execute without --yes succeeded"})
continue
}
gate = "framework_confirm_safety"
}
okRows = append(okRows, row{canonical, cliPath, gate, "ok"})
}
if checked == 0 {
t.Fatal("no user_required ContractFinal leaves found")
}
if len(fails) != 0 {
for _, f := range fails {
t.Errorf("%s (%s): %s", f.canonical, f.cliPath, f.detail)
}
t.Fatalf("Safety homology failed: %d/%d user_required leaves", len(fails), checked)
}
byGate := map[string]int{}
for _, r := range okRows {
byGate[r.gate]++
}
t.Logf("user_required Safety homology OK: %d leaves gates=%v", checked, byGate)
// Sheet transitional dual gate: every Sheet --yes-only protect marker must
// also carry DeclareLeafMetadata ConfirmSafety. A bare sheet_protect leaf
// would mean the outer guard was dropped from the homology pair (or the
// contract wrap never installed), silently widening authorization.
if bare := byGate["sheet_protect"]; bare != 0 {
t.Fatalf("transitional Sheet dual gate broken: %d leaf(ves) have sheet_protect without declare_leaf_confirm", bare)
}
if dual := byGate["declare_leaf+sheet_marker"]; dual == 0 {
t.Fatal("expected declare_leaf+sheet_marker leaves for Sheet transitional dual confirmation gate")
}
// Confirm/validate order is asserted by behavior, not by a claim of intent:
// a "deferred" annotation can still confirm first at runtime. For every
// user_required leaf that publishes a required parameter, an argument-less
// invocation must report that missing parameter — reaching confirmation
// instead is the RFC §5.1 inversion.
//
// Two forms declare guard-first ordering deliberately and are exempt: a Spec
// ConfirmFirst field (the devapp write contract asserted by
// TestEveryDevAppWriteCommandRequiresGuard) and the reviewed Sheet
// confirmationGuards table, whose wrapper is --yes-only by design. Both are
// declarations, not inferences, so the exemption cannot be reached by
// accident.
var orderFails []row
for _, r := range okRows {
leaf := boundByCanon[r.canonical].PrimaryCommand
if leaf == nil || !requiresParameter(liveToolByCanon[r.canonical]) {
continue
}
if corecmd.HasDeclaredConfirmFirst(leaf) || helpers.HasSheetMutationConfirmationGuard(leaf) {
continue
}
if err := probeConfirmationGate(leaf); isConfirmationGateError(err) {
orderFails = append(orderFails, row{r.canonical, r.cliPath, r.gate,
fmt.Sprintf("confirms before reporting a missing required parameter: %v", err)})
}
}
if len(orderFails) != 0 {
for _, f := range orderFails {
t.Errorf("%s (%s): %s", f.canonical, f.cliPath, f.detail)
}
t.Fatalf("confirm/validate order invariant failed: %d user_required leaf(ves)", len(orderFails))
}
}
// requiresParameter reports whether the delivered ToolSpec publishes at least
// one required parameter, i.e. whether an argument-less invocation is expected
// to fail validation before anything else.
func requiresParameter(tool cli.ToolSpec) bool {
for _, param := range tool.Parameters {
if param.Required || param.CLIRequired {
return true
}
}
return false
}
func probeConfirmationGate(leaf *cobra.Command) error {
root := leaf.Root()
if root == nil {
root = leaf
}
root.SilenceErrors = true
root.SilenceUsage = true
root.SetIn(strings.NewReader(""))
root.SetOut(io.Discard)
root.SetErr(io.Discard)
// Rebuild args from CommandPath relative to root.
path := strings.TrimSpace(strings.TrimPrefix(leaf.CommandPath(), root.CommandPath()))
fields := strings.Fields(path)
root.SetArgs(fields)
return root.Execute()
}
// isConfirmationGateError reports whether err is the confirmation gate itself.
//
// It classifies on the machine-readable reason rather than on prose: a Sheet
// destructive leaf's missing-flag error embeds the example
// "(获得用户确认后加 --yes)", so substring matching reported the confirmation gate
// for a correct validation error. The decline path is the one case that carries
// no reason, so it is matched by its exact message.
func isConfirmationGateError(err error) bool {
if err == nil {
return false
}
var typed *apperrors.Error
if errors.As(err, &typed) && strings.TrimSpace(typed.Reason) == "confirmation_required" {
return true
}
return strings.Contains(err.Error(), "用户取消了操作")
}
@@ -0,0 +1,92 @@
// 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"
"github.com/spf13/cobra"
)
func TestAnnotateRuntimeRiskEmbedsContractMarker(t *testing.T) {
cmd := &cobra.Command{Use: "x"}
AnnotateRuntimeRisk(cmd, "write")
got, ok := RuntimeContractRisk(cmd)
if !ok || got != "write" {
t.Fatalf("RuntimeContractRisk = %q %v", got, ok)
}
if cmd.Annotations[runtimeSchemaContractAnnotation] != "command" {
t.Fatalf("contract marker = %q", cmd.Annotations[runtimeSchemaContractAnnotation])
}
AnnotateRuntimeRisk(cmd, "")
if got, _ = RuntimeContractRisk(cmd); got != "write" {
t.Fatalf("empty AnnotateRuntimeRisk must be no-op, got %q", got)
}
}
func TestApplyContractRiskToSafety(t *testing.T) {
base := SafetySpec{Idempotency: "unknown", Risk: "low", Confirmation: "not_required", Effect: "read"}
got := applyContractRiskToSafety(base, "high-risk-write")
if got.Effect != "destructive" || got.Risk != "high" || got.Confirmation != "user_required" {
t.Fatalf("high-risk overlay = %#v", got)
}
if got.Idempotency != "unknown" {
t.Fatalf("idempotency should be preserved, got %q", got.Idempotency)
}
got = applyContractRiskToSafety(base, "write")
if got.Effect != "write" || got.Risk != "medium" || got.Confirmation != "user_required" {
t.Fatalf("write overlay = %#v", got)
}
got = applyContractRiskToSafety(base, "read")
if got.Effect != "read" || got.Risk != "low" || got.Confirmation != "not_required" {
t.Fatalf("read overlay = %#v", got)
}
}
func TestAnnotateRuntimeGateDeclareOrAnnotate(t *testing.T) {
cmd := &cobra.Command{Use: "x"}
if HasDeclaredOrAnnotatedConfirmation(cmd) {
t.Fatal("bare command must not claim confirmation coverage")
}
AnnotateRuntimeGate(cmd, "devAppRequireWriteGuard")
got, ok := RuntimeContractGate(cmd)
if !ok || got != "devAppRequireWriteGuard" {
t.Fatalf("RuntimeContractGate = %q %v", got, ok)
}
if !HasDeclaredOrAnnotatedConfirmation(cmd) {
t.Fatal("annotated gate must satisfy declare-OR-annotate")
}
if cmd.Annotations[runtimeSchemaContractAnnotation] != "command" {
t.Fatalf("contract marker = %q", cmd.Annotations[runtimeSchemaContractAnnotation])
}
}
func TestApplyContractGateToSafety(t *testing.T) {
base := SafetySpec{Confirmation: "not_required", Effect: "read", Risk: "low"}
got := applyContractGateToSafety(base, "devAppRequireWriteGuard")
if got.Confirmation != "user_required" {
t.Fatalf("gate must force user_required, got %#v", got)
}
if got.Effect != "write" || got.EffectSource != "corecmd.contract_gate" {
t.Fatalf("gate effect overlay = %#v", got)
}
if got.Risk != "medium" {
t.Fatalf("gate risk overlay = %#v", got)
}
reviewed := SafetySpec{Confirmation: "not_required", Effect: "destructive", Risk: "high", EffectSource: "reviewed"}
got = applyContractGateToSafety(reviewed, "devAppRequireWriteGuard")
if got.Effect != "destructive" || got.Risk != "high" || got.Confirmation != "user_required" {
t.Fatalf("gate must keep reviewed effect/risk: %#v", got)
}
}
+6 -3
View File
@@ -11,11 +11,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// gen.go is the single entry point for schema metadata generation. It isolates
// gen.go is the single entry point for reviewed CLI asset generation. It isolates
// all //go:generate pragmas from business code so that:
// - schema_agent_metadata.go / schema_catalog.go contain only types + embed.
// - Generation is a standalone process (make generate-schema triggers this).
// - The 6-input → 1-output contract is documented in one place.
// - The authored-input → generated-output contract is documented in one place.
//
// Generation inputs (authored, reviewed):
// 1. schema_command_registry/ identity (canonical/aliases/navigation)
@@ -23,11 +23,13 @@
// 3. schema_hints/selection/*.json selection (use_when/avoid_when)
// 4. schema_mcp_metadata.json MCP server tool definitions
// 5. schema_parameter_bindings.json parameter type/property mappings
// 6. cobra command tree (Go runtime) flags/usage/required (reflected)
// 6. param_concepts.json + schema reviewed parameter synonym policy
// 7. cobra command tree (Go runtime) flags/usage/required (reflected)
//
// Generation outputs (embedded at build):
// - schema_agent_metadata/*.json per-product agent metadata
// - schema_catalog/ per-product catalog shards
// - param_aliases_generated.go per-command parameter normalization
package cli
@@ -36,3 +38,4 @@ package cli
// package cached by the preceding metadata generator with the old embedded
// JSON files.
//go:generate go run -a ../generator/cmd_schema_catalog -root ../.. -output schema_catalog
//go:generate go run ../generator/cmd_param_aliases -root ../.. -output param_aliases_generated.go
@@ -0,0 +1,59 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cli
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
entries, err := os.ReadDir(".")
if err != nil {
t.Fatalf("read internal/cli: %v", err)
}
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || filepath.Ext(name) != ".go" || name == "gen.go" {
continue
}
content, err := os.ReadFile(name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
for _, line := range bytes.Split(content, []byte("\n")) {
if strings.HasPrefix(strings.TrimSpace(string(line)), "//go:generate") {
t.Errorf("%s contains //go:generate; all directives must stay in gen.go", name)
}
}
}
content, err := os.ReadFile("gen.go")
if err != nil {
t.Fatalf("read gen.go: %v", err)
}
for _, generator := range []string{
"cmd_schema_agent_metadata",
"cmd_schema_catalog",
"cmd_param_aliases",
} {
if !bytes.Contains(content, []byte("//go:generate go run")) || !bytes.Contains(content, []byte(generator)) {
t.Errorf("gen.go does not register %s", generator)
}
}
}
+98
View File
@@ -0,0 +1,98 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cli
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// Planned homology gates (docs/flag-help-schema-homology.md §4). Implementations
// land incrementally; this test pins the gate ID vocabulary so CI docs and
// future policy scripts share one checklist.
var plannedHomologyGateIDs = []string{
"HOM-P1", // schema parameters names ≡ cobra local flags
"HOM-P2", // type/required/default ≡ Contract/cobra; hints cannot win those
"HOM-P3", // schema constraints ≡ Contract Constraints projection
"HOM-S1", // Risk write/high-risk → confirmation=user_required + help Safety
"HOM-S2", // write-guard path must record honest provenance
"HOM-S3", // read Risk must not project user_required without exclusion
"HOM-I1", // interface_ref/bindings ⊆ Contract flags; MCP meta creates no flags
"HOM-D1", // --help Flags ≡ schema leaf parameters
}
func TestHomologyDecisionDocPinsPathAAndGateIDs(t *testing.T) {
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
doc := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", "docs", "flag-help-schema-homology.md"))
raw, err := os.ReadFile(doc)
if err != nil {
t.Fatalf("read homology doc: %v", err)
}
body := string(raw)
for _, needle := range []string{
"路径 A",
"Contract / LeafSpec 为 CLI 表面权威",
"必须嵌入进 Schema",
"embedContractIntoSchema",
"声明 = 最终数据源",
"不得:",
"把声明序列化成 JSON 注解再解析",
"声明体系里再挂「评审字段」并行权威",
"声明(declare):写什么、写在哪、投影到哪",
"契约字段(算声明)",
"编排 / 执行字段(不算声明)",
"Schema 全覆盖(`ToolSpec` 无空洞)",
"dws.schema.runtime_gate",
"mcp_passthrough",
"不得覆盖 LeafSpec / Shortcut 主路径",
} {
if !strings.Contains(body, needle) {
t.Fatalf("homology doc missing %q", needle)
}
}
for _, id := range plannedHomologyGateIDs {
if !strings.Contains(body, id) {
t.Fatalf("homology doc missing planned gate ID %s", id)
}
}
}
func TestMCPPassthroughAdmissionExcludesLeafAndShortcut(t *testing.T) {
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
doc := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", "docs", "flag-help-schema-homology.md"))
raw, err := os.ReadFile(doc)
if err != nil {
t.Fatalf("read homology doc: %v", err)
}
body := string(raw)
for _, needle := range []string{
"严格 1:1",
"全部 `LeafSpec` 命令",
"全部 `+shortcut`",
"surface_kind=mcp_passthrough",
} {
if !strings.Contains(body, needle) {
t.Fatalf("passthrough admission text missing %q", needle)
}
}
}
+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")
}
}
+139
View File
@@ -49,6 +49,13 @@ const (
runtimeSchemaFlagRequiredAnnotation = "dws.schema.required"
runtimeSchemaFlagRequiredWhenAnnotation = "dws.schema.required_when"
runtimeSchemaFlagExampleAnnotation = "dws.schema.example"
// Contract surface (command / LeafSpec) embedded onto the live Cobra leaf so
// Schema generation can project parameters/constraints/Risk without a second
// source of truth. See docs/flag-help-schema-homology.md.
runtimeSchemaContractAnnotation = "dws.schema.contract"
runtimeSchemaRiskAnnotation = "dws.schema.risk"
runtimeSchemaRuntimeGateAnnotation = "dws.schema.runtime_gate"
)
// RuntimeSchemaConstraints describes cross-parameter rules that cannot be
@@ -262,6 +269,16 @@ func AnnotateRuntimeRequiredFlags(cmd *cobra.Command, flagNames ...string) {
}
}
// AnnotateRuntimeFlagDescription records the Schema parameter description.
func AnnotateRuntimeFlagDescription(cmd *cobra.Command, flagName, description string) {
if cmd == nil {
return
}
if flag := runtimeCommandFlag(cmd, flagName); flag != nil {
setFlagAnnotation(flag, runtimeSchemaFlagDescriptionAnnotation, strings.TrimSpace(description))
}
}
// AnnotateRuntimeFlagRequiredWhen records a conditional CLI requirement. The
// expression is descriptive metadata and does not alter Cobra validation.
func AnnotateRuntimeFlagRequiredWhen(cmd *cobra.Command, flagName, expression string) {
@@ -306,6 +323,128 @@ func AnnotateRuntimeFlagExample(cmd *cobra.Command, flagName, example string) {
}
}
// AnnotateRuntimeContract marks a command as carrying a command/LeafSpec
// Contract surface. Schema assembly treats embedded flag annotations and, when
// set, AnnotateRuntimeRisk as Contract-authored facts (path A homology).
func AnnotateRuntimeContract(cmd *cobra.Command) {
if cmd == nil {
return
}
setRuntimeCommandAnnotation(cmd, runtimeSchemaContractAnnotation, "command")
}
// AnnotateRuntimeRisk records the Contract Risk string (read|write|high-risk-write)
// for Schema Safety projection. Empty risk is a no-op so write-guard leaves that
// leave Risk unset keep reviewed hint safety.
func AnnotateRuntimeRisk(cmd *cobra.Command, risk string) {
if cmd == nil {
return
}
risk = strings.TrimSpace(risk)
if risk == "" {
return
}
AnnotateRuntimeContract(cmd)
setRuntimeCommandAnnotation(cmd, runtimeSchemaRiskAnnotation, risk)
}
// AnnotateRuntimeGate records a non-Risk confirmation path (e.g.
// devAppRequireWriteGuard). Homology rule: every confirmation fact is either
// Contract-declared (Risk) or explicitly annotated (this gate / reviewed hints).
func AnnotateRuntimeGate(cmd *cobra.Command, gate string) {
if cmd == nil {
return
}
gate = strings.TrimSpace(gate)
if gate == "" {
return
}
AnnotateRuntimeContract(cmd)
setRuntimeCommandAnnotation(cmd, runtimeSchemaRuntimeGateAnnotation, gate)
}
// RuntimeContractRisk returns the Contract Risk annotation when present.
func RuntimeContractRisk(cmd *cobra.Command) (string, bool) {
if cmd == nil || cmd.Annotations == nil {
return "", false
}
risk := strings.TrimSpace(cmd.Annotations[runtimeSchemaRiskAnnotation])
if risk == "" {
return "", false
}
return risk, true
}
// RuntimeContractGate returns the annotated runtime confirmation gate when present.
func RuntimeContractGate(cmd *cobra.Command) (string, bool) {
if cmd == nil || cmd.Annotations == nil {
return "", false
}
gate := strings.TrimSpace(cmd.Annotations[runtimeSchemaRuntimeGateAnnotation])
if gate == "" {
return "", false
}
return gate, true
}
// HasDeclaredOrAnnotatedConfirmation reports whether confirmation semantics are
// covered by a typed Contract SafetySpec, the legacy Shortcut Risk bridge, or
// an explicit runtime_gate annotation.
func HasDeclaredOrAnnotatedConfirmation(cmd *cobra.Command) bool {
if final, ok := RuntimeContractFinal(cmd); ok && final.Safety != nil &&
strings.TrimSpace(final.Safety.Confirmation) != "" {
return true
}
if _, ok := RuntimeContractRisk(cmd); ok {
return true
}
_, ok := RuntimeContractGate(cmd)
return ok
}
// applyContractRiskToSafety overlays Schema Safety fields from an embedded
// Contract Risk value. Path A: Contract wins effect/risk/confirmation for the
// managed surface; other Safety fields (e.g. idempotency) are preserved.
func applyContractRiskToSafety(base SafetySpec, contractRisk string) SafetySpec {
out := base
switch strings.TrimSpace(contractRisk) {
case "write":
out.Effect = "write"
out.EffectSource = "corecmd.contract"
out.Risk = "medium"
out.Confirmation = "user_required"
case "high-risk-write":
out.Effect = "destructive"
out.EffectSource = "corecmd.contract"
out.Risk = "high"
out.Confirmation = "user_required"
case "read":
out.Effect = "read"
out.EffectSource = "corecmd.contract"
out.Risk = "low"
out.Confirmation = "not_required"
}
return out
}
// applyContractGateToSafety ensures a write-guard annotation cannot leave Schema
// claiming confirmation is not required. Reviewed effect/risk are kept when set.
func applyContractGateToSafety(base SafetySpec, gate string) SafetySpec {
out := base
if strings.TrimSpace(gate) == "" {
return out
}
out.Confirmation = "user_required"
if out.Effect == "" || out.Effect == "read" {
out.Effect = "write"
out.EffectSource = "corecmd.contract_gate"
}
if out.Risk == "" || out.Risk == "low" {
out.Risk = "medium"
}
return out
}
// AnnotateRuntimeConstraints records command-level parameter relationships.
func AnnotateRuntimeConstraints(cmd *cobra.Command, constraints RuntimeSchemaConstraints) {
if cmd == nil {
+257 -233
View File
@@ -3,7 +3,7 @@
"tools": {
"aisearch behavior": {
"agent_summary": "搜索发送/创建/分享/编辑/接收等明确行为记录",
"agent_summary_source": "dws-agent-selection/aisearch",
"agent_summary_source": "corecmd.SchemaDecl",
"availability": "available",
"avoid_when": [
"只按主题找内容本身时用 aisearch enterprise",
@@ -11,7 +11,7 @@
],
"confirmation": "not_required",
"effect": "read",
"effect_source": "command-verb",
"effect_source": "corecmd.contract",
"examples": [
"dws aisearch behavior --types mail --behavior-type send --direction \"我-\u003e汐峰\" --format json",
"dws aisearch behavior --queries \"智能化方案\" --types document --behavior-type create --format json"
@@ -19,33 +19,29 @@
"field_provenance": {
"agent_summary": {
"value": "搜索发送/创建/分享/编辑/接收等明确行为记录",
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": "搜索发送/创建/分享/编辑/接收等明确行为记录",
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/aisearch.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/aisearch.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "available",
@@ -60,48 +56,58 @@
"只按主题找内容本身时用 aisearch enterprise",
"没有行为动作词时不要选用本工具"
],
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": [
"只按主题找内容本身时用 aisearch enterprise",
"没有行为动作词时不要选用本工具"
],
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"confirmation": {
"value": "not_required",
"source": "risk-default",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "not_required",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "not_required",
"source": "risk-default",
"precedence": "inference_or_default",
"selected": true
"selected": false
}
]
},
"effect": {
"value": "read",
"source": "command-verb",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "read",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "read",
"source": "command-verb",
"precedence": "inference_or_default",
"selected": true
"selected": false
}
]
},
@@ -110,50 +116,52 @@
"dws aisearch behavior --types mail --behavior-type send --direction \"我-\u003e汐峰\" --format json",
"dws aisearch behavior --queries \"智能化方案\" --types document --behavior-type create --format json"
],
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": [
"dws aisearch behavior --types mail --behavior-type send --direction \"我-\u003e汐峰\" --format json",
"dws aisearch behavior --queries \"智能化方案\" --types document --behavior-type create --format json"
],
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"idempotency": {
"value": "idempotent",
"source": "effect-default",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "idempotent",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "idempotent",
"source": "effect-default",
"precedence": "inference_or_default",
"selected": true
"selected": false
}
]
},
"interface_mode": {
"value": "mcp",
"source": "internal/cli/schema_hints/metadata/aisearch.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed",
"candidates": [
{
"value": "mcp",
"source": "internal/cli/schema_hints/metadata/aisearch.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "mcp",
@@ -165,52 +173,61 @@
},
"interface_ref": {
"value": "aisearch.search_enterprise_behavior",
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise_behavior.interface_ref",
"precedence": "mcp_fallback",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "aisearch.search_enterprise_behavior",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "aisearch.search_enterprise_behavior",
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise_behavior.interface_ref",
"precedence": "mcp_fallback",
"selected": true
"selected": false
}
]
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/aisearch.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/aisearch.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"value": false,
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise_behavior",
"precedence": "mcp_fallback",
"selected": false
}
]
},
"risk": {
"value": "low",
"source": "effect-default",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "low",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "low",
"source": "effect-default",
"precedence": "inference_or_default",
"selected": true
"selected": false
}
]
},
@@ -218,19 +235,17 @@
"value": [
"用户明确问我/某人发过、发给、收到、创建、分享、编辑过什么"
],
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": [
"用户明确问我/某人发过、发给、收到、创建、分享、编辑过什么"
],
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
}
@@ -244,13 +259,8 @@
"reviewed": true,
"risk": "low",
"source_refs": [
"Cobra help: dws aisearch behavior --help",
"internal/cli/schema_command_registry.json",
"internal/cli/schema_command_registry.json#aisearch.search_enterprise_behavior",
"internal/cli/schema_hints/metadata/aisearch.json",
"internal/cli/schema_hints/selection/aisearch.json",
"corecmd.SchemaDecl",
"internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise_behavior",
"live-dws-schema:aisearch.search_enterprise_behavior",
"skills/mono/references/intent-guide.md",
"skills/mono/references/products/aisearch.md"
],
@@ -260,7 +270,7 @@
},
"aisearch enterprise": {
"agent_summary": "搜索企业内部知识与相关内容(文档/IM/日历/待办/纪要/日志/邮件等)",
"agent_summary_source": "dws-agent-selection/aisearch",
"agent_summary_source": "corecmd.SchemaDecl",
"availability": "available",
"avoid_when": [
"问“我发给谁/谁发给我/我创建过”等行为追溯时用 aisearch behavior",
@@ -269,7 +279,7 @@
],
"confirmation": "not_required",
"effect": "read",
"effect_source": "command-verb",
"effect_source": "corecmd.contract",
"examples": [
"dws aisearch enterprise --queries \"智能化方案\" --types document --format json",
"dws aisearch enterprise --queries \"OKR\" --types mail --time-range \"最近\" --format json"
@@ -277,33 +287,29 @@
"field_provenance": {
"agent_summary": {
"value": "搜索企业内部知识与相关内容(文档/IM/日历/待办/纪要/日志/邮件等)",
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": "搜索企业内部知识与相关内容(文档/IM/日历/待办/纪要/日志/邮件等)",
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/aisearch.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "The reviewed CLI supports an all-content search with default --types all, while --queries and --time-range are optional filters; imported MCP required markers must not turn them into mandatory CLI input.",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/aisearch.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The reviewed CLI supports an all-content search with default --types all, while --queries and --time-range are optional filters; imported MCP required markers must not turn them into mandatory CLI input."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "available",
@@ -319,10 +325,9 @@
"企业找人时用 aisearch person",
"已知具体资源 ID 要读写时改用对应产品命令"
],
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": [
@@ -330,26 +335,23 @@
"企业找人时用 aisearch person",
"已知具体资源 ID 要读写时改用对应产品命令"
],
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"confirmation": {
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/aisearch.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "The reviewed CLI supports an all-content search with default --types all, while --queries and --time-range are optional filters; imported MCP required markers must not turn them into mandatory CLI input.",
"candidates": [
{
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/aisearch.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The reviewed CLI supports an all-content search with default --types all, while --queries and --time-range are optional filters; imported MCP required markers must not turn them into mandatory CLI input."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "not_required",
@@ -361,15 +363,21 @@
},
"effect": {
"value": "read",
"source": "command-verb",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "read",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "read",
"source": "command-verb",
"precedence": "inference_or_default",
"selected": true
"selected": false
}
]
},
@@ -378,50 +386,52 @@
"dws aisearch enterprise --queries \"智能化方案\" --types document --format json",
"dws aisearch enterprise --queries \"OKR\" --types mail --time-range \"最近\" --format json"
],
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": [
"dws aisearch enterprise --queries \"智能化方案\" --types document --format json",
"dws aisearch enterprise --queries \"OKR\" --types mail --time-range \"最近\" --format json"
],
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"idempotency": {
"value": "idempotent",
"source": "effect-default",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "idempotent",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "idempotent",
"source": "effect-default",
"precedence": "inference_or_default",
"selected": true
"selected": false
}
]
},
"interface_mode": {
"value": "mcp",
"source": "internal/cli/schema_hints/metadata/aisearch.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "The reviewed CLI supports an all-content search with default --types all, while --queries and --time-range are optional filters; imported MCP required markers must not turn them into mandatory CLI input.",
"candidates": [
{
"value": "mcp",
"source": "internal/cli/schema_hints/metadata/aisearch.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The reviewed CLI supports an all-content search with default --types all, while --queries and --time-range are optional filters; imported MCP required markers must not turn them into mandatory CLI input."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "mcp",
@@ -433,52 +443,62 @@
},
"interface_ref": {
"value": "aisearch.search_enterprise",
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise.interface_ref",
"precedence": "mcp_fallback",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "aisearch.search_enterprise",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "aisearch.search_enterprise",
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise.interface_ref",
"precedence": "mcp_fallback",
"selected": true
"selected": false
}
]
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/aisearch.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "The reviewed CLI supports an all-content search with default --types all, while --queries and --time-range are optional filters; imported MCP required markers must not turn them into mandatory CLI input.",
"candidates": [
{
"value": true,
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": true,
"source": "internal/cli/schema_hints/metadata/aisearch.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The reviewed CLI supports an all-content search with default --types all, while --queries and --time-range are optional filters; imported MCP required markers must not turn them into mandatory CLI input."
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"review_reason": "The reviewed CLI supports an all-content search with default --types all, while --queries and --time-range are optional filters; imported MCP required markers must not turn them into mandatory CLI input."
}
]
},
"risk": {
"value": "low",
"source": "effect-default",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "low",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "low",
"source": "effect-default",
"precedence": "inference_or_default",
"selected": true
"selected": false
}
]
},
@@ -487,20 +507,18 @@
"按主题找资料、方案、文档、消息、邮件等内容,且关注“有什么内容”",
"用户显式给出时间词或类型词时分别映射到 --time-range / --types"
],
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": [
"按主题找资料、方案、文档、消息、邮件等内容,且关注“有什么内容”",
"用户显式给出时间词或类型词时分别映射到 --time-range / --types"
],
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
}
@@ -514,13 +532,9 @@
"reviewed": true,
"risk": "low",
"source_refs": [
"Cobra help: dws aisearch enterprise --help",
"internal/cli/schema_command_registry.json",
"internal/cli/schema_command_registry.json#aisearch.search_enterprise",
"corecmd.SchemaDecl",
"internal/cli/schema_hints/metadata/aisearch.json",
"internal/cli/schema_hints/selection/aisearch.json",
"internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise",
"live-dws-schema:aisearch.search_enterprise",
"skills/mono/references/intent-guide.md",
"skills/mono/references/products/aisearch.md"
],
@@ -531,7 +545,7 @@
},
"aisearch person": {
"agent_summary": "企业内找人:按姓名/部门/职位/职责/上下级/手机号/工号筛选",
"agent_summary_source": "dws-agent-selection/aisearch",
"agent_summary_source": "corecmd.SchemaDecl",
"availability": "available",
"avoid_when": [
"已有 userId 只需详情时用 contact user get",
@@ -540,7 +554,7 @@
],
"confirmation": "not_required",
"effect": "read",
"effect_source": "command-verb",
"effect_source": "corecmd.contract",
"examples": [
"dws aisearch person --keyword \"张三\" --dimension name --format json",
"dws aisearch person --keyword \"五道\" --dimension supervisor --format json"
@@ -548,33 +562,29 @@
"field_provenance": {
"agent_summary": {
"value": "企业内找人:按姓名/部门/职位/职责/上下级/手机号/工号筛选",
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": "企业内找人:按姓名/部门/职位/职责/上下级/手机号/工号筛选",
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/aisearch.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/aisearch.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "available",
@@ -590,10 +600,9 @@
"精确通讯录关键词搜同事/好友且不涉及职责语义时可用 contact user search",
"搜企业知识内容时用 aisearch enterprise;搜行为记录时用 aisearch behavior"
],
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": [
@@ -601,38 +610,49 @@
"精确通讯录关键词搜同事/好友且不涉及职责语义时可用 contact user search",
"搜企业知识内容时用 aisearch enterprise;搜行为记录时用 aisearch behavior"
],
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"confirmation": {
"value": "not_required",
"source": "risk-default",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "not_required",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "not_required",
"source": "risk-default",
"precedence": "inference_or_default",
"selected": true
"selected": false
}
]
},
"effect": {
"value": "read",
"source": "command-verb",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "read",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "read",
"source": "command-verb",
"precedence": "inference_or_default",
"selected": true
"selected": false
}
]
},
@@ -641,50 +661,52 @@
"dws aisearch person --keyword \"张三\" --dimension name --format json",
"dws aisearch person --keyword \"五道\" --dimension supervisor --format json"
],
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": [
"dws aisearch person --keyword \"张三\" --dimension name --format json",
"dws aisearch person --keyword \"五道\" --dimension supervisor --format json"
],
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"idempotency": {
"value": "idempotent",
"source": "effect-default",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "idempotent",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "idempotent",
"source": "effect-default",
"precedence": "inference_or_default",
"selected": true
"selected": false
}
]
},
"interface_mode": {
"value": "mcp",
"source": "internal/cli/schema_hints/metadata/aisearch.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed",
"candidates": [
{
"value": "mcp",
"source": "internal/cli/schema_hints/metadata/aisearch.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "mcp",
@@ -696,52 +718,61 @@
},
"interface_ref": {
"value": "aisearch.enterprise_person_search",
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.enterprise_person_search.interface_ref",
"precedence": "mcp_fallback",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "aisearch.enterprise_person_search",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "aisearch.enterprise_person_search",
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.enterprise_person_search.interface_ref",
"precedence": "mcp_fallback",
"selected": true
"selected": false
}
]
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/aisearch.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/aisearch.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"value": false,
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.enterprise_person_search",
"precedence": "mcp_fallback",
"selected": false
}
]
},
"risk": {
"value": "low",
"source": "effect-default",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "low",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "low",
"source": "effect-default",
"precedence": "inference_or_default",
"selected": true
"selected": false
}
]
},
@@ -750,20 +781,18 @@
"找人、谁负责某事、查上级/下级、按手机号或工号定位人员",
"需要把维度词映射到 --dimension,关键词只保留目标实体"
],
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": [
"找人、谁负责某事、查上级/下级、按手机号或工号定位人员",
"需要把维度词映射到 --dimension,关键词只保留目标实体"
],
"source": "internal/cli/schema_hints/selection/aisearch.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
}
@@ -777,13 +806,8 @@
"reviewed": true,
"risk": "low",
"source_refs": [
"Cobra help: dws aisearch person --help",
"internal/cli/schema_command_registry.json",
"internal/cli/schema_command_registry.json#aisearch.enterprise_person_search",
"internal/cli/schema_hints/metadata/aisearch.json",
"internal/cli/schema_hints/selection/aisearch.json",
"corecmd.SchemaDecl",
"internal/cli/schema_mcp_metadata.json#tools.aisearch.enterprise_person_search",
"live-dws-schema:aisearch.enterprise_person_search",
"skills/mono/references/intent-guide.md",
"skills/mono/references/products/aisearch.md",
"skills/mono/references/products/mail.md"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+201 -306
View File
@@ -3,7 +3,7 @@
"tools": {
"audit export": {
"agent_summary": "按日期范围导出本地操作审计日志(jsonl 或 csv)",
"agent_summary_source": "dws-agent-selection/audit",
"agent_summary_source": "corecmd.SchemaDecl",
"availability": "available",
"avoid_when": [
"只看最近几条用 audit tail",
@@ -11,7 +11,7 @@
],
"confirmation": "not_required",
"effect": "read",
"effect_source": "agent-hint",
"effect_source": "corecmd.contract",
"examples": [
"dws audit export --format jsonl",
"dws audit export --since 2026-07-01 --until 2026-07-14 --format csv"
@@ -19,33 +19,29 @@
"field_provenance": {
"agent_summary": {
"value": "按日期范围导出本地操作审计日志(jsonl 或 csv)",
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读导出,示例不含 --yes。",
"candidates": [
{
"value": "按日期范围导出本地操作审计日志(jsonl 或 csv)",
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读导出,示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit log export from #555; no remote RPC.",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit log export from #555; no remote RPC."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
@@ -54,52 +50,46 @@
"只看最近几条用 audit tail",
"只校验哈希链完整性用 audit verify"
],
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读导出,示例不含 --yes。",
"candidates": [
{
"value": [
"只看最近几条用 audit tail",
"只校验哈希链完整性用 audit verify"
],
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读导出,示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"confirmation": {
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit log export from #555; no remote RPC.",
"candidates": [
{
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit log export from #555; no remote RPC."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"effect": {
"value": "read",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit log export from #555; no remote RPC.",
"candidates": [
{
"value": "read",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit log export from #555; no remote RPC."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
@@ -108,82 +98,74 @@
"dws audit export --format jsonl",
"dws audit export --since 2026-07-01 --until 2026-07-14 --format csv"
],
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读导出,示例不含 --yes。",
"candidates": [
{
"value": [
"dws audit export --format jsonl",
"dws audit export --since 2026-07-01 --until 2026-07-14 --format csv"
],
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读导出,示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"idempotency": {
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit log export from #555; no remote RPC.",
"candidates": [
{
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit log export from #555; no remote RPC."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"interface_mode": {
"value": "local",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit log export from #555; no remote RPC.",
"candidates": [
{
"value": "local",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit log export from #555; no remote RPC."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"interface_reason": {
"value": "命令读取并导出本地审计日志文件,不绑定 pinned MCP RPC",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit log export from #555; no remote RPC.",
"candidates": [
{
"value": "命令读取并导出本地审计日志文件,不绑定 pinned MCP RPC",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit log export from #555; no remote RPC."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"interface_ref": {
"value": null,
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "interface_disposition_matrix",
"review_reason": "final interface mode local forbids a direct MCP interface_ref",
"candidates": [
{
"value": null,
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true,
"review_reason": "final interface mode local forbids a direct MCP interface_ref"
}
@@ -191,40 +173,29 @@
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit log export from #555; no remote RPC.",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit log export from #555; no remote RPC."
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读导出,示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"risk": {
"value": "low",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit log export from #555; no remote RPC.",
"candidates": [
{
"value": "low",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit log export from #555; no remote RPC."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
@@ -232,19 +203,17 @@
"value": [
"需要把本地审计日志导出为 jsonl/csv,或按 --since/--until 取一段时间"
],
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读导出,示例不含 --yes。",
"candidates": [
{
"value": [
"需要把本地审计日志导出为 jsonl/csv,或按 --since/--until 取一段时间"
],
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读导出,示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
}
@@ -255,11 +224,7 @@
"reviewed": true,
"risk": "low",
"source_refs": [
"cobra-help:dws audit export --help",
"internal/app/audit_command.go",
"internal/cli/schema_command_registry.json#audit.export",
"internal/cli/schema_hints/metadata/audit.json",
"internal/cli/schema_hints/selection/audit.json"
"corecmd.SchemaDecl"
],
"use_when": [
"需要把本地审计日志导出为 jsonl/csv,或按 --since/--until 取一段时间"
@@ -267,7 +232,7 @@
},
"audit tail": {
"agent_summary": "查看本地操作审计日志最近 N 条记录",
"agent_summary_source": "dws-agent-selection/audit",
"agent_summary_source": "corecmd.SchemaDecl",
"availability": "available",
"avoid_when": [
"需要按日期范围整段导出用 audit export",
@@ -275,7 +240,7 @@
],
"confirmation": "not_required",
"effect": "read",
"effect_source": "agent-hint",
"effect_source": "corecmd.contract",
"examples": [
"dws audit tail",
"dws audit tail --lines 50"
@@ -283,33 +248,29 @@
"field_provenance": {
"agent_summary": {
"value": "查看本地操作审计日志最近 N 条记录",
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读查看,示例不含 --yes。",
"candidates": [
{
"value": "查看本地操作审计日志最近 N 条记录",
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读查看,示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit log tail from #555; no remote RPC.",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit log tail from #555; no remote RPC."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
@@ -318,52 +279,46 @@
"需要按日期范围整段导出用 audit export",
"需要校验哈希链用 audit verify"
],
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读查看,示例不含 --yes。",
"candidates": [
{
"value": [
"需要按日期范围整段导出用 audit export",
"需要校验哈希链用 audit verify"
],
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读查看,示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"confirmation": {
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit log tail from #555; no remote RPC.",
"candidates": [
{
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit log tail from #555; no remote RPC."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"effect": {
"value": "read",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit log tail from #555; no remote RPC.",
"candidates": [
{
"value": "read",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit log tail from #555; no remote RPC."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
@@ -372,82 +327,74 @@
"dws audit tail",
"dws audit tail --lines 50"
],
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读查看,示例不含 --yes。",
"candidates": [
{
"value": [
"dws audit tail",
"dws audit tail --lines 50"
],
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读查看,示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"idempotency": {
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit log tail from #555; no remote RPC.",
"candidates": [
{
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit log tail from #555; no remote RPC."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"interface_mode": {
"value": "local",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit log tail from #555; no remote RPC.",
"candidates": [
{
"value": "local",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit log tail from #555; no remote RPC."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"interface_reason": {
"value": "命令读取本地审计日志尾部,不绑定 pinned MCP RPC",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit log tail from #555; no remote RPC.",
"candidates": [
{
"value": "命令读取本地审计日志尾部,不绑定 pinned MCP RPC",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit log tail from #555; no remote RPC."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"interface_ref": {
"value": null,
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "interface_disposition_matrix",
"review_reason": "final interface mode local forbids a direct MCP interface_ref",
"candidates": [
{
"value": null,
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true,
"review_reason": "final interface mode local forbids a direct MCP interface_ref"
}
@@ -455,40 +402,29 @@
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit log tail from #555; no remote RPC.",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit log tail from #555; no remote RPC."
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读查看,示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"risk": {
"value": "low",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit log tail from #555; no remote RPC.",
"candidates": [
{
"value": "low",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit log tail from #555; no remote RPC."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
@@ -496,19 +432,17 @@
"value": [
"需要快速查看最近写入的审计记录(默认最近 20 条)"
],
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读查看,示例不含 --yes。",
"candidates": [
{
"value": [
"需要快速查看最近写入的审计记录(默认最近 20 条)"
],
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读查看,示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
}
@@ -519,11 +453,7 @@
"reviewed": true,
"risk": "low",
"source_refs": [
"cobra-help:dws audit tail --help",
"internal/app/audit_command.go",
"internal/cli/schema_command_registry.json#audit.tail",
"internal/cli/schema_hints/metadata/audit.json",
"internal/cli/schema_hints/selection/audit.json"
"corecmd.SchemaDecl"
],
"use_when": [
"需要快速查看最近写入的审计记录(默认最近 20 条)"
@@ -531,14 +461,14 @@
},
"audit verify": {
"agent_summary": "校验本地审计日志文件的哈希链完整性",
"agent_summary_source": "dws-agent-selection/audit",
"agent_summary_source": "corecmd.SchemaDecl",
"availability": "available",
"avoid_when": [
"只浏览或导出日志内容时用 audit tail / audit export"
],
"confirmation": "not_required",
"effect": "read",
"effect_source": "agent-hint",
"effect_source": "corecmd.contract",
"examples": [
"dws audit verify",
"dws audit verify --file /path/to/audit.jsonl"
@@ -546,33 +476,29 @@
"field_provenance": {
"agent_summary": {
"value": "校验本地审计日志文件的哈希链完整性",
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读校验,示例不含 --yes。",
"candidates": [
{
"value": "校验本地审计日志文件的哈希链完整性",
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读校验,示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit hash-chain verify from #555; no remote RPC.",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit hash-chain verify from #555; no remote RPC."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
@@ -580,51 +506,45 @@
"value": [
"只浏览或导出日志内容时用 audit tail / audit export"
],
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读校验,示例不含 --yes。",
"candidates": [
{
"value": [
"只浏览或导出日志内容时用 audit tail / audit export"
],
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读校验,示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"confirmation": {
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit hash-chain verify from #555; no remote RPC.",
"candidates": [
{
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit hash-chain verify from #555; no remote RPC."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"effect": {
"value": "read",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit hash-chain verify from #555; no remote RPC.",
"candidates": [
{
"value": "read",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit hash-chain verify from #555; no remote RPC."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
@@ -633,82 +553,74 @@
"dws audit verify",
"dws audit verify --file /path/to/audit.jsonl"
],
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读校验,示例不含 --yes。",
"candidates": [
{
"value": [
"dws audit verify",
"dws audit verify --file /path/to/audit.jsonl"
],
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读校验,示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"idempotency": {
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit hash-chain verify from #555; no remote RPC.",
"candidates": [
{
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit hash-chain verify from #555; no remote RPC."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"interface_mode": {
"value": "local",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit hash-chain verify from #555; no remote RPC.",
"candidates": [
{
"value": "local",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit hash-chain verify from #555; no remote RPC."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"interface_reason": {
"value": "命令校验本地审计日志哈希链,不绑定 pinned MCP RPC",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit hash-chain verify from #555; no remote RPC.",
"candidates": [
{
"value": "命令校验本地审计日志哈希链,不绑定 pinned MCP RPC",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit hash-chain verify from #555; no remote RPC."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"interface_ref": {
"value": null,
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "interface_disposition_matrix",
"review_reason": "final interface mode local forbids a direct MCP interface_ref",
"candidates": [
{
"value": null,
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true,
"review_reason": "final interface mode local forbids a direct MCP interface_ref"
}
@@ -716,40 +628,29 @@
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit hash-chain verify from #555; no remote RPC.",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit hash-chain verify from #555; no remote RPC."
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读校验,示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"risk": {
"value": "low",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "Local audit hash-chain verify from #555; no remote RPC.",
"candidates": [
{
"value": "low",
"source": "internal/cli/schema_hints/metadata/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Local audit hash-chain verify from #555; no remote RPC."
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
@@ -757,19 +658,17 @@
"value": [
"怀疑审计文件被篡改,或需要确认最新/指定文件哈希链是否完整"
],
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读校验,示例不含 --yes。",
"candidates": [
{
"value": [
"怀疑审计文件被篡改,或需要确认最新/指定文件哈希链是否完整"
],
"source": "internal/cli/schema_hints/selection/audit.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读校验,示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
}
@@ -780,11 +679,7 @@
"reviewed": true,
"risk": "low",
"source_refs": [
"cobra-help:dws audit verify --help",
"internal/app/audit_command.go",
"internal/cli/schema_command_registry.json#audit.verify",
"internal/cli/schema_hints/metadata/audit.json",
"internal/cli/schema_hints/selection/audit.json"
"corecmd.SchemaDecl"
],
"use_when": [
"怀疑审计文件被篡改,或需要确认最新/指定文件哈希链是否完整"
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
+84 -83
View File
@@ -3,7 +3,7 @@
"tools": {
"devdoc article search": {
"agent_summary": "搜索钉钉开放平台开发文档,返回资料与链接(不生成分析答案)",
"agent_summary_source": "dws-agent-selection/devdoc",
"agent_summary_source": "corecmd.SchemaDecl",
"availability": "available",
"avoid_when": [
"搜索用户业务文档用 drive/wiki/doc,不要用 devdoc",
@@ -11,7 +11,7 @@
],
"confirmation": "not_required",
"effect": "read",
"effect_source": "command-verb",
"effect_source": "corecmd.contract",
"examples": [
"dws devdoc article search --query \"OAuth2 接入\" --format json",
"dws devdoc article search --query \"errcode 40078\" --format json"
@@ -19,33 +19,29 @@
"field_provenance": {
"agent_summary": {
"value": "搜索钉钉开放平台开发文档,返回资料与链接(不生成分析答案)",
"source": "internal/cli/schema_hints/selection/devdoc.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": "搜索钉钉开放平台开发文档,返回资料与链接(不生成分析答案)",
"source": "internal/cli/schema_hints/selection/devdoc.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"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.",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"precedence": "reviewed_explicit",
"selected": true,
"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": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "available",
@@ -60,36 +56,32 @@
"搜索用户业务文档用 drive/wiki/doc,不要用 devdoc",
"要执行开放平台应用配置变更时用 dev"
],
"source": "internal/cli/schema_hints/selection/devdoc.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": [
"搜索用户业务文档用 drive/wiki/doc,不要用 devdoc",
"要执行开放平台应用配置变更时用 dev"
],
"source": "internal/cli/schema_hints/selection/devdoc.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"confirmation": {
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"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.",
"candidates": [
{
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"precedence": "reviewed_explicit",
"selected": true,
"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": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "not_required",
@@ -101,15 +93,21 @@
},
"effect": {
"value": "read",
"source": "command-verb",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "read",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "read",
"source": "command-verb",
"precedence": "inference_or_default",
"selected": true
"selected": false
}
]
},
@@ -118,50 +116,52 @@
"dws devdoc article search --query \"OAuth2 接入\" --format json",
"dws devdoc article search --query \"errcode 40078\" --format json"
],
"source": "internal/cli/schema_hints/selection/devdoc.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": [
"dws devdoc article search --query \"OAuth2 接入\" --format json",
"dws devdoc article search --query \"errcode 40078\" --format json"
],
"source": "internal/cli/schema_hints/selection/devdoc.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"idempotency": {
"value": "idempotent",
"source": "effect-default",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "idempotent",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "idempotent",
"source": "effect-default",
"precedence": "inference_or_default",
"selected": true
"selected": false
}
]
},
"interface_mode": {
"value": "mcp",
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"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.",
"candidates": [
{
"value": "mcp",
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"precedence": "reviewed_explicit",
"selected": true,
"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": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "mcp",
@@ -173,15 +173,21 @@
},
"interface_ref": {
"value": "devdoc.search_open_platform_docs",
"source": "internal/cli/schema_hints/imported/wukong.json",
"precedence": "imported",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "devdoc.search_open_platform_docs",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "devdoc.search_open_platform_docs",
"source": "internal/cli/schema_hints/imported/wukong.json",
"precedence": "imported",
"selected": true
"selected": false
},
{
"value": "devdoc.search_open_platform_docs",
@@ -193,38 +199,42 @@
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"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.",
"candidates": [
{
"value": true,
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": true,
"source": "internal/cli/schema_hints/metadata/devdoc.json",
"precedence": "reviewed_explicit",
"selected": true,
"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."
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/devdoc.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"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."
}
]
},
"risk": {
"value": "low",
"source": "effect-default",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "low",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "low",
"source": "effect-default",
"precedence": "inference_or_default",
"selected": true
"selected": false
}
]
},
@@ -232,19 +242,17 @@
"value": [
"查 OpenAPI、字段、错误码、OAuth2、接入指南等开放平台开发问题"
],
"source": "internal/cli/schema_hints/selection/devdoc.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": [
"查 OpenAPI、字段、错误码、OAuth2、接入指南等开放平台开发问题"
],
"source": "internal/cli/schema_hints/selection/devdoc.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
}
@@ -258,20 +266,13 @@
"reviewed": true,
"risk": "low",
"source_refs": [
"CommandRegistry:canonical_path=devdoc.search_open_platform_docs_rag",
"Skill:skills/mono/references/products/devdoc.md",
"cobra-help:dws devdoc article search",
"corecmd.SchemaDecl",
"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"
"skills/mono/references/products/simple.md"
],
"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
@@ -1,6 +1,6 @@
{
"version": 1,
"source_hash": "sha256:f8cb29bdbeaceae6f04cbac8a93ea77f2a84f93f8304d25878c827b959bc0ba0",
"source_hash": "sha256:dc81f9a6bf1c4137d03f3b0aa7d2b17fcd72c5defdfc10ad26ee4911c4257098",
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
"coverage": {
"surface_products": 26,
@@ -12,8 +12,8 @@
"tools_with_avoid_when": 845,
"tools_with_examples": 845,
"tools_with_interface_mode": 845,
"unmatched_skill_tools": 122,
"unreviewed_skill_tools": 11
"unmatched_skill_tools": 118,
"unreviewed_skill_tools": 7
},
"products": {
"aisearch": {
+64 -88
View File
@@ -3,47 +3,43 @@
"tools": {
"live stream list": {
"agent_summary": "查看当前用户发起的直播列表与基础统计",
"agent_summary_source": "dws-agent-selection/live",
"agent_summary_source": "corecmd.SchemaDecl",
"availability": "available",
"avoid_when": [
"需要创建/开播/结束直播时不要使用;当前公开面仅列表查询"
],
"confirmation": "not_required",
"effect": "read",
"effect_source": "command-verb",
"effect_source": "corecmd.contract",
"examples": [
"dws live stream list"
],
"field_provenance": {
"agent_summary": {
"value": "查看当前用户发起的直播列表与基础统计",
"source": "internal/cli/schema_hints/selection/live.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": "查看当前用户发起的直播列表与基础统计",
"source": "internal/cli/schema_hints/selection/live.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/live.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/live marks this tool as reviewed",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/live.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/live marks this tool as reviewed"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "available",
@@ -57,46 +53,44 @@
"value": [
"需要创建/开播/结束直播时不要使用;当前公开面仅列表查询"
],
"source": "internal/cli/schema_hints/selection/live.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": [
"需要创建/开播/结束直播时不要使用;当前公开面仅列表查询"
],
"source": "internal/cli/schema_hints/selection/live.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"confirmation": {
"value": "not_required",
"source": "risk-default",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "not_required",
"source": "risk-default",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"effect": {
"value": "read",
"source": "command-verb",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "read",
"source": "command-verb",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
@@ -105,49 +99,45 @@
"value": [
"dws live stream list"
],
"source": "internal/cli/schema_hints/selection/live.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": [
"dws live stream list"
],
"source": "internal/cli/schema_hints/selection/live.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"idempotency": {
"value": "idempotent",
"source": "effect-default",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "idempotent",
"source": "effect-default",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"interface_mode": {
"value": "mcp",
"source": "internal/cli/schema_hints/metadata/live.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/live marks this tool as reviewed",
"candidates": [
{
"value": "mcp",
"source": "internal/cli/schema_hints/metadata/live.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/live marks this tool as reviewed"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "mcp",
@@ -159,15 +149,21 @@
},
"interface_ref": {
"value": "live.get_my_lives",
"source": "internal/cli/schema_hints/imported/wukong.json",
"precedence": "imported",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "live.get_my_lives",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "live.get_my_lives",
"source": "internal/cli/schema_hints/imported/wukong.json",
"precedence": "imported",
"selected": true
"selected": false
},
{
"value": "live.get_my_lives",
@@ -179,37 +175,28 @@
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/live.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/live marks this tool as reviewed",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/live.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/live marks this tool as reviewed"
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/live.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"risk": {
"value": "low",
"source": "effect-default",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "low",
"source": "effect-default",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
@@ -218,19 +205,17 @@
"value": [
"用户要看自己发起过的直播、状态或观看量等列表信息"
],
"source": "internal/cli/schema_hints/selection/live.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": [
"用户要看自己发起过的直播、状态或观看量等列表信息"
],
"source": "internal/cli/schema_hints/selection/live.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
}
@@ -244,19 +229,10 @@
"reviewed": true,
"risk": "low",
"source_refs": [
"CommandRegistry:canonical_path=live.get_my_lives",
"Skill:skills/multi/dingtalk-live/SKILL.md",
"cobra-help:dws live stream list",
"corecmd.SchemaDecl",
"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"
"internal/cli/schema_mcp_metadata.json#tools.live.get_my_lives"
],
"use_when": [
"用户要看自己发起过的直播、状态或观看量等列表信息"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+67 -103
View File
@@ -3,7 +3,7 @@
"tools": {
"mcp url get": {
"agent_summary": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址",
"agent_summary_source": "dws-agent-selection/mcp",
"agent_summary_source": "corecmd.SchemaDecl",
"availability": "available",
"avoid_when": [
"只是查询 DWS 已公开命令或参数时使用 dws schema",
@@ -11,40 +11,36 @@
],
"confirmation": "not_required",
"effect": "read",
"effect_source": "agent-hint",
"effect_source": "corecmd.contract",
"examples": [
"dws mcp url get 10043 --format json"
],
"field_provenance": {
"agent_summary": {
"value": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址",
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"candidates": [
{
"value": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址",
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"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.",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"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": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
@@ -53,52 +49,46 @@
"只是查询 DWS 已公开命令或参数时使用 dws schema",
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播"
],
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"candidates": [
{
"value": [
"只是查询 DWS 已公开命令或参数时使用 dws schema",
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播"
],
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"confirmation": {
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"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.",
"candidates": [
{
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"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": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"effect": {
"value": "read",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"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.",
"candidates": [
{
"value": "read",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"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": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
@@ -106,81 +96,73 @@
"value": [
"dws mcp url get 10043 --format json"
],
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"candidates": [
{
"value": [
"dws mcp url get 10043 --format json"
],
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"idempotency": {
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"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.",
"candidates": [
{
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"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": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"interface_mode": {
"value": "composite",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"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.",
"candidates": [
{
"value": "composite",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"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": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"interface_reason": {
"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.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"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.",
"candidates": [
{
"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.",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"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": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"interface_ref": {
"value": null,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "interface_disposition_matrix",
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
"candidates": [
{
"value": null,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true,
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
}
@@ -188,40 +170,29 @@
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"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.",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"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."
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"risk": {
"value": "medium",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"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.",
"candidates": [
{
"value": "medium",
"source": "internal/cli/schema_hints/metadata/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"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": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
@@ -229,19 +200,17 @@
"value": [
"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"
],
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。",
"candidates": [
{
"value": [
"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"
],
"source": "internal/cli/schema_hints/selection/mcp.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
}
@@ -252,12 +221,7 @@
"reviewed": true,
"risk": "medium",
"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"
"corecmd.SchemaDecl"
],
"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
+148 -196
View File
@@ -3,14 +3,14 @@
"tools": {
"pat browser-policy": {
"agent_summary": "配置 PAT 授权流程是否允许打开本地浏览器",
"agent_summary_source": "dws-agent-selection/pat",
"agent_summary_source": "corecmd.SchemaDecl",
"availability": "available",
"avoid_when": [
"需要授予产品 scope 时用 pat chmod,而不是改浏览器策略"
],
"confirmation": "not_required",
"effect": "write",
"effect_source": "agent-hint",
"effect_source": "corecmd.contract",
"examples": [
"dws pat browser-policy --enabled --format json",
"dws pat browser-policy --enabled=false --agentCode \u003cAGENT_CODE\u003e --format json"
@@ -18,33 +18,29 @@
"field_provenance": {
"agent_summary": {
"value": "配置 PAT 授权流程是否允许打开本地浏览器",
"source": "internal/cli/schema_hints/selection/pat.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": "配置 PAT 授权流程是否允许打开本地浏览器",
"source": "internal/cli/schema_hints/selection/pat.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
@@ -52,49 +48,51 @@
"value": [
"需要授予产品 scope 时用 pat chmod,而不是改浏览器策略"
],
"source": "internal/cli/schema_hints/selection/pat.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": [
"需要授予产品 scope 时用 pat chmod,而不是改浏览器策略"
],
"source": "internal/cli/schema_hints/selection/pat.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"confirmation": {
"value": "not_required",
"source": "risk-default",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "not_required",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "not_required",
"source": "risk-default",
"precedence": "inference_or_default",
"selected": true
"selected": false
}
]
},
"effect": {
"value": "write",
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed",
"candidates": [
{
"value": "write",
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "write",
@@ -109,80 +107,80 @@
"dws pat browser-policy --enabled --format json",
"dws pat browser-policy --enabled=false --agentCode \u003cAGENT_CODE\u003e --format json"
],
"source": "internal/cli/schema_hints/selection/pat.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": [
"dws pat browser-policy --enabled --format json",
"dws pat browser-policy --enabled=false --agentCode \u003cAGENT_CODE\u003e --format json"
],
"source": "internal/cli/schema_hints/selection/pat.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"idempotency": {
"value": "unknown",
"source": "effect-default",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "unknown",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "unknown",
"source": "effect-default",
"precedence": "inference_or_default",
"selected": true
"selected": false
}
]
},
"interface_mode": {
"value": "local",
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed",
"candidates": [
{
"value": "local",
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"interface_reason": {
"value": "命令仅操作本地进程或策略文件,不调用 MCP 接口",
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed",
"candidates": [
{
"value": "命令仅操作本地进程或策略文件,不调用 MCP 接口",
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"interface_ref": {
"value": null,
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "interface_disposition_matrix",
"review_reason": "final interface mode local forbids a direct MCP interface_ref",
"candidates": [
{
"value": null,
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true,
"review_reason": "final interface mode local forbids a direct MCP interface_ref"
}
@@ -190,40 +188,29 @@
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed"
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/pat.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"risk": {
"value": "medium",
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed",
"candidates": [
{
"value": "medium",
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "medium",
@@ -237,19 +224,17 @@
"value": [
"需要允许或禁止某 Agent 在 PAT 授权时打开浏览器"
],
"source": "internal/cli/schema_hints/selection/pat.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": [
"需要允许或禁止某 Agent 在 PAT 授权时打开浏览器"
],
"source": "internal/cli/schema_hints/selection/pat.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
}
@@ -260,15 +245,8 @@
"reviewed": true,
"risk": "medium",
"source_refs": [
"CommandRegistry:canonical_path=pat.browser_policy",
"Skill:skills/mono/references/products/pat.md",
"cobra-help:dws pat browser-policy",
"internal/cli/schema_command_registry.json#pat.browser_policy",
"internal/cli/schema_hints/metadata/pat.json",
"internal/cli/schema_hints/selection/pat.json",
"live-dws-schema:pat.browser_policy#FAILED",
"skills/mono/references/products/pat.md",
"structured-hint:internal/cli/schema_hints/selection-review.json#pat.browser_policy"
"corecmd.SchemaDecl",
"skills/mono/references/products/pat.md"
],
"use_when": [
"需要允许或禁止某 Agent 在 PAT 授权时打开浏览器"
@@ -276,7 +254,7 @@
},
"pat chmod": {
"agent_summary": "预览或执行 PAT 批量行为授权(支持 dryRun / pending flow)",
"agent_summary_source": "dws-agent-selection/pat",
"agent_summary_source": "corecmd.SchemaDecl",
"availability": "available",
"avoid_when": [
"普通 OAuth 登录用 auth,不要用 pat",
@@ -285,40 +263,36 @@
],
"confirmation": "user_required",
"effect": "write",
"effect_source": "agent-hint",
"effect_source": "corecmd.contract",
"examples": [
"dws pat chmod --products calendar,aitable --grant-type session --session-id \u003cSESSION_ID\u003e --dry-run --format json"
],
"field_provenance": {
"agent_summary": {
"value": "预览或执行 PAT 批量行为授权(支持 dryRun / pending flow)",
"source": "internal/cli/schema_hints/selection/pat.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": "预览或执行 PAT 批量行为授权(支持 dryRun / pending flow)",
"source": "internal/cli/schema_hints/selection/pat.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "available",
@@ -334,10 +308,9 @@
"授权产品、grant-type 或 session-id 未明确时不要执行写入",
"只要改本地浏览器打开策略时用 pat browser-policy"
],
"source": "internal/cli/schema_hints/selection/pat.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": [
@@ -345,26 +318,23 @@
"授权产品、grant-type 或 session-id 未明确时不要执行写入",
"只要改本地浏览器打开策略时用 pat browser-policy"
],
"source": "internal/cli/schema_hints/selection/pat.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"confirmation": {
"value": "user_required",
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed",
"candidates": [
{
"value": "user_required",
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "user_required",
@@ -377,17 +347,15 @@
},
"effect": {
"value": "write",
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed",
"candidates": [
{
"value": "write",
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "write",
@@ -408,49 +376,51 @@
"value": [
"dws pat chmod --products calendar,aitable --grant-type session --session-id \u003cSESSION_ID\u003e --dry-run --format json"
],
"source": "internal/cli/schema_hints/selection/pat.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": [
"dws pat chmod --products calendar,aitable --grant-type session --session-id \u003cSESSION_ID\u003e --dry-run --format json"
],
"source": "internal/cli/schema_hints/selection/pat.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
},
"idempotency": {
"value": "unknown",
"source": "effect-default",
"precedence": "inference_or_default",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"candidates": [
{
"value": "unknown",
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "unknown",
"source": "effect-default",
"precedence": "inference_or_default",
"selected": true
"selected": false
}
]
},
"interface_mode": {
"value": "mcp",
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed",
"candidates": [
{
"value": "mcp",
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "mcp",
@@ -462,17 +432,15 @@
},
"interface_ref": {
"value": "pat.pat.batch_grant",
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed",
"candidates": [
{
"value": "pat.pat.batch_grant",
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "pat.pat.batch_grant",
@@ -484,40 +452,35 @@
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/pat.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"value": false,
"source": "internal/cli/schema_mcp_metadata.json#tools.pat.batch_grant",
"precedence": "mcp_fallback",
"selected": false
}
]
},
"risk": {
"value": "high",
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed",
"candidates": [
{
"value": "high",
"source": "internal/cli/schema_hints/metadata/pat.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/pat marks this tool as reviewed"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
},
{
"value": "high",
@@ -533,20 +496,18 @@
"命令提示缺少行为授权,需要按产品或 scope 批量授权",
"先 --dry-run 查看 selected/skipped/pending,用户确认后再执行"
],
"source": "internal/cli/schema_hints/selection/pat.json",
"precedence": "reviewed_explicit",
"source": "corecmd.contract",
"precedence": "contract_final",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": [
"命令提示缺少行为授权,需要按产品或 scope 批量授权",
"先 --dry-run 查看 selected/skipped/pending,用户确认后再执行"
],
"source": "internal/cli/schema_hints/selection/pat.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
"source": "corecmd.contract",
"precedence": "contract_final",
"selected": true
}
]
}
@@ -560,18 +521,9 @@
"reviewed": true,
"risk": "high",
"source_refs": [
"CommandRegistry:canonical_path=pat.batch_grant",
"Skill:skills/mono/references/products/pat.md",
"cobra-help:dws pat chmod",
"helper:internal/pat/chmod.go#typed_yes",
"internal/cli/schema_command_registry.json#pat.batch_grant",
"internal/cli/schema_hints/metadata/pat.json",
"internal/cli/schema_hints/selection/pat.json",
"corecmd.SchemaDecl",
"internal/cli/schema_mcp_metadata.json#tools.pat.batch_grant",
"live-dws-schema:pat.batch_grant#FAILED",
"pinned-mcp:pat.batch_grant",
"skills/mono/references/products/pat.md",
"structured-hint:internal/cli/schema_hints/selection-review.json#pat.batch_grant"
"skills/mono/references/products/pat.md"
],
"use_when": [
"命令提示缺少行为授权,需要按产品或 scope 批量授权",
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
+6 -18
View File
@@ -1,11 +1,11 @@
{
"version": 1,
"source_hash": "sha256:f8cb29bdbeaceae6f04cbac8a93ea77f2a84f93f8304d25878c827b959bc0ba0",
"source_hash": "sha256:dc81f9a6bf1c4137d03f3b0aa7d2b17fcd72c5defdfc10ad26ee4911c4257098",
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
"source_files": 160,
"hint_files": 54,
"hint_products": 43,
"hint_tools": 1842,
"hint_tools": 221,
"interface_metadata": {
"source": "mcp-tools-list+cli-registry",
"revision": "4574f7022c32cf4c033e9b7b4156e2fec815fed8",
@@ -13,8 +13,8 @@
"source_tools": 419,
"surface_tools": 414,
"eligible_summaries": 413,
"applied_summaries": 0,
"preserved_summaries": 413,
"applied_summaries": 268,
"preserved_summaries": 145,
"rejected_tools": [
"chat.unread_message_conversation_list"
],
@@ -36,8 +36,8 @@
"tools_with_avoid_when": 845,
"tools_with_examples": 845,
"tools_with_interface_mode": 845,
"unmatched_skill_tools": 122,
"unreviewed_skill_tools": 11
"unmatched_skill_tools": 118,
"unreviewed_skill_tools": 7
},
"source_products": [
"agoal",
@@ -91,18 +91,6 @@
{
"tool_path": "chat.get_group_members"
},
{
"tool_path": "devdoc.devdoc_rag_pretest"
},
{
"tool_path": "devdoc.search_dingtalk_open_docs"
},
{
"tool_path": "devdoc.search_open_error_code_rag"
},
{
"tool_path": "devdoc.search_open_platform_docs"
},
{
"tool_path": "doc",
"candidates": [
+34 -2
View File
@@ -20,6 +20,8 @@ import (
"fmt"
"sort"
"strings"
"github.com/spf13/cobra"
)
var marshalManualAgentSelectionFixture = json.Marshal
@@ -71,17 +73,27 @@ func BuildManualAgentSelectionEvalFixture(bound BoundCommandRegistry, hints Manu
fixture := ManualAgentSelectionFixture{Version: manualAgentSelectionFixtureVersion}
report := ManualAgentSelectionReport{}
expectedTools := make(map[string]bool, len(bound.Commands))
declaredTools := map[string]bool{}
candidatesByProduct := map[string][]string{}
for _, command := range bound.Commands {
canonical := strings.TrimSpace(command.CanonicalPath)
expectedTools[canonical] = true
// Declared tools source selection assertions from the Contract final
// overlay (corecmd.SchemaDecl) instead of a hint-file row.
if HasRuntimeContractFinal(command.PrimaryCommand) {
declaredTools[canonical] = true
}
productID, _, ok := strings.Cut(canonical, ".")
if !ok || strings.TrimSpace(productID) == "" {
return fixture, report, fmt.Errorf("agent_hints selection has invalid bound canonical path %q", canonical)
}
candidatesByProduct[productID] = append(candidatesByProduct[productID], canonical)
}
if err := validateManualAgentHintExactSet("selection tools", expectedTools, mapKeysManualAgentTools(hints.Tools)); err != nil {
hintTools := mapKeysManualAgentTools(hints.Tools)
for canonical := range declaredTools {
hintTools[canonical] = true
}
if err := validateManualAgentHintExactSet("selection tools", expectedTools, hintTools); err != nil {
return fixture, report, err
}
for productID := range candidatesByProduct {
@@ -105,7 +117,10 @@ func BuildManualAgentSelectionEvalFixture(bound BoundCommandRegistry, hints Manu
return fixture, report, err
}
hint := hints.Tools[canonical]
hint, hintOK := hints.Tools[canonical]
if !hintOK && declaredTools[canonical] {
hint = contractFinalSelectionHint(command.PrimaryCommand)
}
if len(hint.UseWhen) == 0 {
return fixture, report, fmt.Errorf("agent_hints tool %s requires at least one positive use_when selection assertion", canonical)
}
@@ -169,6 +184,23 @@ func ValidateManualAgentSelectionContract(bound BoundCommandRegistry, hints Manu
return report, err
}
// contractFinalSelectionHint synthesizes the selection assertions of a
// declared tool from its Contract final overlay, so declared tools keep full
// semantic-eval coverage without a hint-file row.
func contractFinalSelectionHint(command *cobra.Command) ManualAgentToolHint {
hint := ManualAgentToolHint{Reviewed: true, Revision: "contract", Reason: "Contract final declaration (corecmd.SchemaDecl)"}
payload, ok := RuntimeContractFinal(command)
if !ok || payload.Selection == nil {
return hint
}
selection := payload.Selection
hint.AgentSummary = selection.AgentSummary
hint.UseWhen = selection.UseWhen
hint.AvoidWhen = selection.AvoidWhen
hint.Examples = selection.Examples
return hint
}
func validateManualAgentSelectionBinding(bound BoundCommandRegistry, canonical string, command BoundCommandSpec) error {
if command.CanonicalPath != canonical {
return fmt.Errorf("agent_hints selection canonical %q resolves to mismatched BoundCommandRegistry entry %q", canonical, command.CanonicalPath)
File diff suppressed because it is too large Load Diff
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

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