Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50d7e7d98f | ||
|
|
000bc13450 | ||
|
|
ba56b7ff78 | ||
|
|
c5c97e60f3 | ||
|
|
6780177356 | ||
|
|
5fa40b8ada | ||
|
|
8a7d4a7027 | ||
|
|
894e550950 | ||
|
|
bb205c0f5c | ||
|
|
82c6b631bf | ||
|
|
306c30ecad | ||
|
|
f793d980b5 | ||
|
|
706dbb349c | ||
|
|
45d0d1cd72 | ||
|
|
e9a2360d36 | ||
|
|
9531dad9c6 | ||
|
|
eb63b3933e | ||
|
|
54358e1195 | ||
|
|
4c5f8849d0 | ||
|
|
25a849d679 | ||
|
|
ae77915507 | ||
|
|
c494026305 | ||
|
|
010d100e66 | ||
|
|
32598fb38d | ||
|
|
22a20355e7 | ||
|
|
ee286abb05 | ||
|
|
bcb3b99faf | ||
|
|
9278a467b8 | ||
|
|
65ad8e0562 | ||
|
|
1e14ed4a87 | ||
|
|
082a9bb93a | ||
|
|
772bf462ee | ||
|
|
469d509cfb | ||
|
|
ad5909b8a6 | ||
|
|
3cce23e07d | ||
|
|
fd6d3624c3 | ||
|
|
b877172d7d | ||
|
|
28df903801 | ||
|
|
b4b4a31979 | ||
|
|
276837caae | ||
|
|
ec7f4deaf4 | ||
|
|
e135440b98 | ||
|
|
93d7e5a4c6 | ||
|
|
fe969dad51 | ||
|
|
affc8715be | ||
|
|
5c7407532a | ||
|
|
843ba7d81b | ||
|
|
7afd4139fc | ||
|
|
5b7bea8b11 | ||
|
|
0fbdfe0130 | ||
|
|
e5a47a2d18 | ||
|
|
2e51dcf35d | ||
|
|
e54927107f | ||
|
|
a5fd64a908 | ||
|
|
4262a50c16 | ||
|
|
9bc6a15232 | ||
|
|
9d1c3c5c95 | ||
|
|
ba72358f78 | ||
|
|
07fea09561 | ||
|
|
057a860dcc | ||
|
|
aeec39115f | ||
|
|
562c29905f | ||
|
|
7a945318e0 | ||
|
|
b1cefba808 | ||
|
|
124ddd85f2 | ||
|
|
99ca88597e | ||
|
|
22d3dd1096 | ||
|
|
4ad8cce5f3 | ||
|
|
c53ed8d383 | ||
|
|
d10738d0db | ||
|
|
f26968264d | ||
|
|
517eedb412 | ||
|
|
6210840b54 | ||
|
|
2d76433be0 | ||
|
|
b3adfa8d26 | ||
|
|
633862d07a | ||
|
|
2f31f48da0 | ||
|
|
3fb8631e5f | ||
|
|
a57e6bbfeb | ||
|
|
20a4eb77a4 | ||
|
|
e0bd2a88c0 | ||
|
|
059bdfd03e | ||
|
|
260d8ddddd | ||
|
|
c0808ab5e6 | ||
|
|
ea43db1d64 | ||
|
|
7257919a49 | ||
|
|
27f0fd4337 | ||
|
|
e4fde3917d | ||
|
|
812ec4f87e | ||
|
|
8d799979d4 | ||
|
|
2839d36631 | ||
|
|
f1eb1a44d1 | ||
|
|
6c15b591a3 | ||
|
|
d4f6aab04d | ||
|
|
9bf772ea06 | ||
|
|
a086be422a | ||
|
|
53816b3d33 | ||
|
|
0a2e49e7e4 | ||
|
|
ef39a1b8db | ||
|
|
9eccc0c9e0 | ||
|
|
dc5c9fe110 | ||
|
|
3d163242f5 | ||
|
|
2a1fe47c50 | ||
|
|
410fb05498 | ||
|
|
286a84edcd | ||
|
|
a26957c425 | ||
|
|
b8b2d9475e | ||
|
|
b7f04fd2a0 | ||
|
|
7fedbea806 | ||
|
|
6cdd781ae7 | ||
|
|
bb54761e50 | ||
|
|
3e83ac18d1 | ||
|
|
75bd1f1447 | ||
|
|
a26d219b27 | ||
|
|
46af37669c | ||
|
|
c2e4a85ba9 | ||
|
|
e29354ca70 | ||
|
|
0119f6e2a8 | ||
|
|
e98586ebb0 | ||
|
|
bd45de95ab | ||
|
|
066094a68c | ||
|
|
31cade34ff | ||
|
|
e705012011 | ||
|
|
8791088027 | ||
|
|
746b7e403c | ||
|
|
86e34b5489 | ||
|
|
b64438d01c | ||
|
|
4943c6ff49 | ||
|
|
f0fef85905 | ||
|
|
7773eb27f1 | ||
|
|
18e32ad09d | ||
|
|
a9857d94d7 | ||
|
|
461455b4fa | ||
|
|
f0d558a0d1 | ||
|
|
3cf690e779 | ||
|
|
340f01e95c | ||
|
|
621229fca2 | ||
|
|
acfbd35aa2 | ||
|
|
95a5fd069a | ||
|
|
d6292d92d3 | ||
|
|
afb9c27560 | ||
|
|
08c9c8a923 | ||
|
|
f805c966d6 | ||
|
|
eae7955448 | ||
|
|
ce0501b93e | ||
|
|
da93fbcc47 | ||
|
|
92f1daa95f | ||
|
|
9911d8f9c9 | ||
|
|
52324e9bc2 | ||
|
|
a80ac662f5 | ||
|
|
a5cefd67f3 | ||
|
|
8c9c22f4b0 | ||
|
|
37a05db6e9 | ||
|
|
41fdf79aea | ||
|
|
ceca98c573 | ||
|
|
c84a42b0d5 | ||
|
|
fb7696293d | ||
|
|
93d6e1a001 | ||
|
|
4d4817d74f | ||
|
|
110780bf74 | ||
|
|
26b100c6bb |
@@ -490,7 +490,8 @@ 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: 15
|
||||
# cli/smoke shards need headroom beyond go test -timeout for setup + assembly.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -498,6 +499,8 @@ jobs:
|
||||
- app
|
||||
- generators
|
||||
- helpers
|
||||
- cli
|
||||
- smoke
|
||||
- remaining
|
||||
steps:
|
||||
- name: Check out repository
|
||||
@@ -523,7 +526,13 @@ jobs:
|
||||
test -n "$package_output"
|
||||
mapfile -t packages <<< "$package_output"
|
||||
test "${#packages[@]}" -gt 0
|
||||
go test -v -race -count=1 -timeout=12m "${packages[@]}"
|
||||
# cli/smoke own heavy NewRootCommand / Schema assembly under -race; give
|
||||
# them a dedicated budget so remaining is not SIGTERM'd by OOM/timeout.
|
||||
timeout_budget=12m
|
||||
if [ "$TEST_SHARD" = "cli" ] || [ "$TEST_SHARD" = "smoke" ]; then
|
||||
timeout_budget=15m
|
||||
fi
|
||||
go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}"
|
||||
|
||||
test-release-scripts:
|
||||
name: Test (workflow and release contracts)
|
||||
|
||||
@@ -5,21 +5,75 @@ unrelated work, and use `gofmt` for every modified Go file.
|
||||
|
||||
## Build and test
|
||||
|
||||
- Build: `go build ./cmd`
|
||||
- Build: `make build` (wraps `scripts/dev/build.sh` → `go build -o dws ./cmd`; bare `go build ./cmd` fails because output name `cmd` collides with the directory)
|
||||
- Full test suite: `DWS_PACKAGE_VERSION=0.0.0-test go test ./...`
|
||||
- Generate Schema assets: `go generate ./internal/cli` (entry point: `internal/cli/gen.go`)
|
||||
- Refresh pinned MCP metadata: `make fetch-mcp-metadata` (requires `dws auth login`)
|
||||
- Check generated drift: `./scripts/policy/check-generated-drift.sh`
|
||||
- Param aliases generate: `go generate ./internal/cli` (entry point: `internal/cli/gen.go`; Catalog is not generated)
|
||||
- Optional diagnostic MCP dump (not a Schema pin): `make fetch-mcp-metadata` (requires `dws auth login`; writes under `artifacts/`)
|
||||
- Check generated drift + assembly determinism: `./scripts/policy/check-generated-drift.sh`
|
||||
- Check the Schema contract: `./scripts/policy/check-schema-catalog.sh`
|
||||
- Coverage-gate test naming: tests that carry coverage for the macOS platform
|
||||
gate must be named `TestCrossPlatformCoverage*` (or `TestAllShortcuts*`);
|
||||
`scripts/policy/run-platform-coverage-gate.sh` only selects those prefixes,
|
||||
so a covering test with any other name silently leaves its target uncovered.
|
||||
- Package-var injection seams (e.g. `pipelineBuildEffectiveRegistry`): swap
|
||||
them in tests only via `testseam.Swap(t, &seam, stub)` from
|
||||
`internal/testseam` — it restores the previous value through `t.Cleanup`
|
||||
structurally. Like the manual pattern it replaces, Swap mutates global state
|
||||
and is **not** safe for `t.Parallel` tests.
|
||||
- Cross-package test helpers (e.g. `StoreProductDeclRawForTest`) live in
|
||||
per-package `fortest.go` files, never scattered through production files;
|
||||
the `ForTest` suffix is the boundary and production code must not call them.
|
||||
|
||||
Generated Schema JSON is committed. Change its source inputs and generators,
|
||||
then regenerate; do not hand-edit generated Catalog or Agent metadata files.
|
||||
`internal/cli/schema_command_registry.json` is different: it is a reviewed
|
||||
`CommandRegistry` source, not a generated snapshot. It is the single reviewed
|
||||
source of stable canonical identity,
|
||||
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.
|
||||
Schema Catalog delivery is **声明即 Catalog**: production assembles via
|
||||
`RegisterSchemaSourceRoot` → `ResolveSchemaBuild` (factory registered in
|
||||
`internal/app`). There is no
|
||||
`cmd_schema_catalog` `//go:generate` delivery step. `dws schema -f json` remains
|
||||
the wire projection. `cmd_schema_catalog` produces CI/local dumps only;
|
||||
`internal/cli/schema_catalog/`, `internal/cli/schema_meta_index.gob`, and
|
||||
`internal/cli/schema_meta_index.json` must not be committed. `schema_agent_metadata/` is retired: if that directory
|
||||
(or `schema_agent_metadata_audit.json`) is present, policy fails.
|
||||
Command identity is no longer a file input: it is collected from
|
||||
`ContractFinal.Identity` on the live Cobra leaves
|
||||
(`internal/cli/schema_identity_collect.go` → `BuildEffectiveCommandRegistry`).
|
||||
The reviewed `schema_command_registry/` was retired together with that
|
||||
switchover and must not reappear; identity changes happen by editing the leaf
|
||||
declaration. The remaining **reviewed inputs** under `internal/cli` (see Agent
|
||||
Schema contract) keep separate authorities — do not merge them with
|
||||
`param_concepts.json` or promote any of them into Catalog declaration.
|
||||
|
||||
## Command framework declaration
|
||||
|
||||
- Framework definition: `docs/rfc-command-framework-convergence.md` **§5.0**
|
||||
- Today: `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` (+ optional `Contract`) → `corecmd.New`
|
||||
- **Declare = final Schema source**: `Flags` / `Constraints` / `Safety` / `ConstParams` / `Contract` (`corecmd.ContractDecl`; nested fields are `contract.*`)
|
||||
- Naming: `ContractDecl` is the authoring leaf declaration. "Schema" means Catalog / `ToolSpec` delivery — do not reintroduce `SchemaDecl`.
|
||||
- `Safety` uses `contract.SafetySpec` (`internal/corecmd/contract` only — no `cli.*` type alias). Its `confirmation` drives the runtime gate; `effect` / `risk` / `idempotency` are published unchanged. When `Contract` is set, convert once via `contractfinal.RegisterRuntimeContractFinal` (all callers — `corecmd.New` registers internally); assembly **pass-throughs** Final.
|
||||
- Package seam:
|
||||
- types / ProductDecl → `corecmd/contract` (DTO only; **no** Cobra-keyed ContractFinal store)
|
||||
- AnnotateRuntime* writers → `internal/corecmd/runtimeannotate` (framework-owned)
|
||||
- ContractFinal cobra store + Register → `internal/corecmd/contractfinal` (framework-owned)
|
||||
- homology gates → `internal/cli/homology`
|
||||
- Catalog assembly / `ResolveMeta` (`RegisterSchemaSourceRoot` → `ResolveSchemaBuild`); go:embed only for reviewed inputs → `internal/cli` root (package-local aliases for annotate/store APIs live in `runtime_schema_seam.go`; the former `cli/runtimeannotate` / `cli/contractfinal` shim packages are removed — import `corecmd/*` directly)
|
||||
- **Hard rule**: `internal/corecmd` (and its subpackages) must **not** import any `internal/cli` package
|
||||
- Authoring tiers (current, not aspirational):
|
||||
- **Tier1** — `corecmd.New` / `helpers.NewLeafCommand` (fully managed declare + execute)
|
||||
- **Tier2** — `DeclareLeafMetadata` (helpers migration; **Shortcut may also use this path — acceptable**)
|
||||
- **Tier3** — bare Cobra (should shrink over time; reviewed exclusions where needed)
|
||||
- Long-term outlook only: broader mcpbind / fewer hand-written `Execute` bodies. **Not** a current hard requirement to delete `Shortcut.Execute` or force mcpbind.
|
||||
- Description declare vs delivery: construction requires `ContractDecl.Description` (evidence). Catalog delivery prefers Cobra Long → provenance `cobra_help`; without Long, declared text → `contract_final`. Title: declared first, then Short, then MCP. Do **not** read this as "declare = wire final" or dual authority.
|
||||
- **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
|
||||
- **Do not add** new production `AnnotateRuntimeRisk` / `AnnotateRuntimeGate`
|
||||
(`runtime_gate`) call sites; migrate leaves to declared `Safety` /
|
||||
`ContractDecl` instead. Existing annotate sites may remain until migrated.
|
||||
|
||||
## 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.
|
||||
- Gate IDs: `HOM-P*`, `HOM-S*`, `HOM-I1`, `HOM-D1` (see that doc §3–§4). `HOM-P1`/`HOM-D1`/`HOM-S1`/`HOM-S2` are on the `check-schema-catalog.sh` policy whitelist; remaining IDs land incrementally.
|
||||
|
||||
## Agent Schema contract
|
||||
|
||||
@@ -28,90 +82,115 @@ The Schema data flow is one way:
|
||||
```text
|
||||
1. app.NewRootCommand()
|
||||
└─ builds the real Cobra command tree and flags
|
||||
└─ leaf Safety / Contract / contract.ParamDecl declare ContractFinal (declare-or-annotate)
|
||||
|
||||
2. schema_command_registry.json
|
||||
+ schema_hints/metadata/<product>.json tool parameters (+ cli_path)
|
||||
2. CollectIdentitySpecs (ContractFinal.Identity on live Cobra leaves)
|
||||
└─ forms EffectiveCommandRegistry
|
||||
└─ binds exactly to real Cobra leaves and aliases
|
||||
|
||||
3. Parameter resolution
|
||||
Cobra flags
|
||||
+ schema_parameter_bindings.json
|
||||
+ metadata tool parameters
|
||||
+ contract.ParamDecl.Property / native annotations (primary property authority)
|
||||
+ schema_parameter_mapping_ledger.go (mapping_exclusions / removals only;
|
||||
active bindings JSON retired after Track 1 Phase 2)
|
||||
└─ produces ParameterSpec and constraints
|
||||
|
||||
4. Agent and interface semantics
|
||||
schema_hints/selection/<product>.json (selection prose)
|
||||
+ schema_hints/metadata/<product>.json (safety/interface/runtime_gate)
|
||||
+ pinned MCP metadata
|
||||
ProductDecl + leaf ContractFinal Selection / Safety / Interface
|
||||
+ contract.ParamDecl (interface_type / property)
|
||||
└─ resolves Agent metadata by source precedence
|
||||
Markdown is evidence only; it is not concatenated into final prose
|
||||
└─ schema_hints/ and schema_mcp_metadata.json are fully retired
|
||||
|
||||
5. One typed hub
|
||||
5. One typed hub
|
||||
BoundCommandRegistry
|
||||
+ ParameterSpec
|
||||
+ Agent metadata
|
||||
+ Interface metadata
|
||||
└─ resolves every command exactly once into ToolSpec
|
||||
└─ aggregates SchemaRegistry + SchemaIndex
|
||||
└─ aggregates SchemaRegistry + SchemaIndex
|
||||
└─ ResolveSchemaBuild assembles at runtime; deliverySchemaCatalog wraps it (lazy, sync.Once)
|
||||
|
||||
6. One-way publication
|
||||
6. Runtime delivery (no generate-written Catalog authority)
|
||||
SchemaRegistry
|
||||
└─ internal/cli/schema_catalog/
|
||||
(catalog.json + tools/<product>.json; split per product so
|
||||
concurrent feature PRs only rewrite their own shard)
|
||||
└─ dws schema list/product/group/leaf/--all
|
||||
|
||||
7. Runtime consumption (unified API)
|
||||
ResolveMeta(cliPath) → CommandMeta{Identity, Safety, Selection}
|
||||
└─ internal/cli/command_meta.go
|
||||
└─ all consumers (help, schema, agent, skill-gen) call this one function
|
||||
└─ backed by embedded catalog (sync.Once lazy map, O(1) lookup)
|
||||
└─ dws schema list/product/group/leaf/--all (-f json wire)
|
||||
└─ ResolveMeta projects Identity/Safety/Selection from the same registry
|
||||
└─ CI may dump Catalog via cmd_schema_catalog for jq gates / determinism
|
||||
```
|
||||
|
||||
Parameter overlays from metadata are merged into `EffectiveCommandRegistry`
|
||||
*before* Cobra binding; after that point there is no second identity source and
|
||||
no identity precedence winner. The binder must reject a missing/non-runnable
|
||||
Cobra path, an alias collision, and any native identity annotation that
|
||||
disagrees with the effective registry. A missing native identity annotation is
|
||||
allowed because annotations are implementation-side assertions, not identity
|
||||
fallbacks.
|
||||
**Reviewed inputs / 评审输入** (organizational family under `internal/cli`;
|
||||
parallel peers, not one merged authority). These are assembly inputs only —
|
||||
never Catalog declaration authority, never leaf `Contract` / `ProductDecl`
|
||||
substitutes. Keep them side-by-side; do **not** fold one into another:
|
||||
|
||||
| Input | Path | Owns |
|
||||
|---|---|---|
|
||||
| Command identity | collected from `ContractFinal.Identity` on live Cobra leaves (`schema_identity_collect.go`; not a file input) | stable identity, primary CLI path, aliases, navigation |
|
||||
| Param concepts | `param_concepts.json` (+ `.schema.json`) | argv synonym / concept dictionary (reduced to `param_aliases_generated.go`) |
|
||||
| Exclusions | `schema_command_exclusions.go` | exact reviewed CLI paths excluded from Schema (non-empty reason) |
|
||||
| Mapping ledger | `schema_parameter_mapping_ledger.go` | `mapping_exclusions` / removals (CLI flags with no direct RPC property); active bindings JSON retired |
|
||||
|
||||
`schema_mcp_metadata.json` is retired and must not reappear. Interface facts
|
||||
(`interface_ref`, `interface_type`, …) declare on leaf `Contract` /
|
||||
`contract.ParamDecl`. Retiring the pin cleared MCP-sourced `interface_type`
|
||||
values from the wire; schema-compat deliberately accepts clearing (missing =
|
||||
unknown for consumers) while still rejecting any change to a different
|
||||
non-empty value. Re-populating a value requires an explicit `ParamDecl`
|
||||
declaration, not a new pin.
|
||||
|
||||
**Aliases are three distinct layers** (do not conflate):
|
||||
|
||||
| Layer | Owns |
|
||||
|---|---|
|
||||
| `FlagSpec.Aliases` / Cobra flag aliases | executable flag synonyms on a leaf |
|
||||
| `ContractFinal.Identity` `aliases` | reviewed CLI-path aliases for the same command identity |
|
||||
| `param_concepts.json` | argv synonym / concept dictionary (central preparse normalization) |
|
||||
|
||||
**Visibility vs exclusions:** collected identity `visibility` is dormant (all
|
||||
entries default `public`); “runnable but not Agent-visible” belongs in
|
||||
`schema_command_exclusions.go`, not new `visibility` values. Native identity
|
||||
annotations are consistency assertions only — they must agree with the
|
||||
collected identity and never materialize or override it.
|
||||
|
||||
Leaf declare (`Contract` / `ParamDecl` / `Safety` / `ProductDecl`) and the live
|
||||
Cobra tree remain separate from this table: declare owns semantics; Cobra owns
|
||||
executability and flags.
|
||||
|
||||
After binding there is no second identity source and no identity precedence
|
||||
winner. The binder must reject a missing/non-runnable Cobra path, an alias
|
||||
collision, and any native identity annotation that disagrees with the effective
|
||||
registry. A missing native identity annotation is allowed because annotations
|
||||
are implementation-side assertions, not identity fallbacks.
|
||||
|
||||
The assembler resolves every bound command exactly once into one `ToolSpec`.
|
||||
Build-time gates and the snapshot serializer consume that source-resolved typed
|
||||
registry/index. Runtime projections and delivery gates consume the typed
|
||||
registry/index returned by the production snapshot loader. Neither path may
|
||||
reopen annotations, merge source records, or use a previous Catalog or other
|
||||
generated JSON as a source. `schema_catalog/` (catalog.json + per-product
|
||||
tools/<product>.json shards) is output-only in the
|
||||
generation graph. The production loader decoding the embedded published
|
||||
snapshot is a delivery boundary, not source resolution; it must never create or
|
||||
repair a Cobra command, flag, registry entry, or later Catalog generation.
|
||||
CI determinism (`check-schema-assembly.sh`) and policy jq gates consume a
|
||||
fresh assembly dump; runtime consumes the same `ResolveSchemaBuild` path via
|
||||
`RegisterSchemaSourceRoot`. Neither path may reopen annotations, merge source
|
||||
records, or use a previous Catalog JSON as a source.
|
||||
|
||||
### Generation vs consumption separation
|
||||
### Assembly vs consumption
|
||||
|
||||
The Schema system has two physically separated processes:
|
||||
**Assembly** (declare → typed registry; CI + runtime):
|
||||
- Runtime entry: `RegisterSchemaSourceRoot` (`internal/app`) →
|
||||
`ResolveSchemaBuild` / `deliverySchemaCatalog` (lazy, sync.Once).
|
||||
- CI tool: `cmd_schema_catalog` dumps an assembled Catalog for jq/determinism;
|
||||
it is **not** a `//go:generate` or committed delivery step.
|
||||
- `gen.go` only generates `param_aliases_generated.go`.
|
||||
- Inputs: **reviewed inputs** (param_concepts / exclusions / mapping ledger —
|
||||
see table above) + ProductDecl/ContractFinal (identity is collected from
|
||||
`ContractFinal.Identity`) + live Cobra tree.
|
||||
`schema_hints/`, `schema_agent_metadata/`, `schema_command_registry/`, and
|
||||
`schema_mcp_metadata.json` must not reappear.
|
||||
- Gates: `make generate-schema` (param aliases + assembly determinism),
|
||||
`check-generated-drift.sh`, `check-schema-catalog.sh`.
|
||||
|
||||
**Generation** (build-time, slow, reviewed, one-way):
|
||||
- Entry point: `internal/cli/gen.go` (all `//go:generate` pragmas isolated here,
|
||||
not in business code).
|
||||
- Tools: `internal/generator/cmd_schema_agent_metadata` + `cmd_schema_catalog` +
|
||||
`cmd_param_aliases` (standalone Go mains).
|
||||
- Inputs: 7 authored source groups (registry + hints metadata + hints selection +
|
||||
MCP metadata + parameter bindings + reviewed parameter concepts + cobra tree).
|
||||
- Output: `schema_catalog/` (per-product shards) + `schema_agent_metadata/` +
|
||||
`param_aliases_generated.go`.
|
||||
- Refresh MCP metadata: `make fetch-mcp-metadata` (iterates 26 MCP server
|
||||
endpoints, merges with previous data for cross-server interface_ref).
|
||||
- Gates: `make generate-schema` (byte guards on inputs), `check-generated-drift.sh`,
|
||||
`check-command-surface.sh` (catalog structure).
|
||||
|
||||
**Consumption** (runtime, fast, read-only, unified API):
|
||||
**Consumption** (runtime, unified API):
|
||||
- Entry point: `ResolveMeta(cliPath) → CommandMeta{Identity, Safety, Selection}`
|
||||
in `internal/cli/command_meta.go`.
|
||||
- Backed by embedded catalog (`embeddedSchemaCatalog()`, sync.Once, O(1) map).
|
||||
in `internal/cli/command_meta.go` — projected from the assembled registry
|
||||
when the app factory is registered.
|
||||
- Consumers: `--help` (Safety annotation via `RenderSafetyAnnotation`),
|
||||
`dws schema`, agent selection, future skill generation.
|
||||
agent selection, future skill generation; `dws schema` uses the same
|
||||
assembled Catalog (`-f json` wire unchanged).
|
||||
- `SafetyForCLIPath` delegates to `ResolveMeta` (backward compatible).
|
||||
|
||||
This split is architecturally isomorphic to Lark's typed metadata registry,
|
||||
@@ -119,16 +198,17 @@ navigation catalog, and schema renderer. DWS intentionally preserves its
|
||||
existing flat JSON wire contract for compatibility; do not treat architectural
|
||||
alignment as permission to make an unversioned wire-format change.
|
||||
|
||||
The reviewed `CommandRegistry` is the sole source of stable command identity
|
||||
and navigation. The executable Cobra tree remains the source of truth for
|
||||
whether a CLI path exists, is runnable, and which flags it accepts. Schema
|
||||
coverage is bidirectional:
|
||||
The identity collected from `ContractFinal.Identity` (via
|
||||
`CollectIdentitySpecs`) is the sole source of stable command identity and
|
||||
navigation. The executable Cobra tree remains the source of truth for whether
|
||||
a CLI path exists, is runnable, and which flags it accepts. Schema coverage is
|
||||
bidirectional:
|
||||
|
||||
1. Every final `SchemaRegistry` tool, including its serialized Catalog
|
||||
projection, must resolve to an executable Cobra command.
|
||||
2. Every public runnable Cobra leaf must either resolve to Schema or appear as
|
||||
an exact, reviewed exclusion with a non-empty reason in
|
||||
`internal/cli/schema_command_exclusions.json`.
|
||||
`internal/cli/schema_command_exclusions.go` (central Go groups; not JSON).
|
||||
|
||||
Do not use prefix or wildcard exclusions: they can silently hide future
|
||||
commands. Remove an exclusion when its command enters Schema; stale, invalid,
|
||||
@@ -136,104 +216,105 @@ or duplicate exclusions must fail generation and CI.
|
||||
|
||||
When adding or changing an Agent-visible command, review all relevant inputs:
|
||||
|
||||
- `internal/cli/schema_command_registry.json` for the reviewed
|
||||
`CommandRegistry`: canonical identity, primary CLI path, aliases, and stable
|
||||
navigation. It is the identity source and is not a generated artifact.
|
||||
- `internal/cli/schema_command_registry.schema.json` is its closed,
|
||||
machine-readable editing contract. Preserve the local `$schema` reference;
|
||||
unknown fields, invalid visibility values, stale paths, and collisions fail
|
||||
Go validation and policy.
|
||||
- `internal/cli/schema_hints/metadata/<product>.json` for safety, interface,
|
||||
`runtime_gate`, and optional parameter overlays (`parameters` / `cli_path`).
|
||||
- `internal/cli/schema_hints/selection/<product>.json` for reviewed Agent
|
||||
selection prose (`agent_summary`, `use_when`, `avoid_when`, `examples`).
|
||||
- `internal/cli/schema_hints/index.json` only maps product IDs to those files.
|
||||
- Leaf `ContractFinal.Identity` for canonical identity, primary CLI path,
|
||||
aliases, and stable navigation. Identity is collected from the live Cobra
|
||||
leaves (`CollectIdentitySpecs`); there is no separate identity file. Invalid
|
||||
canonical paths, alias collisions, stale paths, and drift fail collection,
|
||||
binding, and policy.
|
||||
- Leaf `Safety` / `Contract` (`corecmd.ContractDecl`) / `contract.ParamDecl`
|
||||
(helpers `LeafSpec` or shortcut `Contract`) for parameter facts, interface
|
||||
disposition, safety, and Agent selection prose. Delivered provenance is
|
||||
`contract_final` from `corecmd.contract` (description may stamp `cobra_help`
|
||||
when Cobra Long wins). Product routing uses `ProductDecl`
|
||||
(`internal/corecmd/contract`; provenance label remains `cli.product_decl`).
|
||||
- `internal/cli/schema_hints/` is fully retired. Do not reintroduce HintFiles,
|
||||
audit JSON, or `imported/` baselines; declare on ProductDecl / the owning
|
||||
leaf instead.
|
||||
- Native Runtime Schema identity annotations, when present, as consistency
|
||||
assertions against `EffectiveCommandRegistry`. They must agree exactly and
|
||||
must never materialize, infer, or override registry identity.
|
||||
- Flag-to-interface property mappings and required/default semantics.
|
||||
- Generated files under `internal/cli/schema_agent_metadata/` and
|
||||
`internal/cli/schema_catalog/` (catalog.json + tools/<product>.json) after
|
||||
running generation.
|
||||
- Do not expect generate-written Catalog delivery. Run
|
||||
`make generate-schema` only to refresh param aliases and prove assembly
|
||||
determinism. Do not expect or commit `schema_agent_metadata/`.
|
||||
|
||||
Run the reverse-completeness tests whenever the Cobra tree changes. A command
|
||||
that works through `dws <path>` but cannot be found through the matching
|
||||
`dws schema` lookup is a contract failure unless it has a reviewed exact
|
||||
exclusion.
|
||||
|
||||
Metadata parameter overlays must reference an exact public runnable Cobra leaf
|
||||
and real flags. They may override Schema description, interface-property/type
|
||||
mapping, `required`, and `required_when`; they must not create commands or
|
||||
flags, define an interface, or advertise an unknown RPC. Every authored entry
|
||||
requires `reviewed: true` and a non-empty review reason.
|
||||
`RegisterSchemaHints` / `ToolSchemaHint` overlays are fully removed. Parameter
|
||||
and selection facts must be declared on the owning leaf (`contract.ParamDecl` /
|
||||
`Contract`) or via `ProductDecl`; do not reintroduce overlay registries.
|
||||
|
||||
For Agent-authored metadata or selection edits:
|
||||
For Agent-authored selection edits:
|
||||
|
||||
1. Confirm the exact command and flag names in the current Cobra tree.
|
||||
2. Edit only the owning block (`metadata/` or `selection/`); do not mix fields.
|
||||
3. Add the smallest possible entry; do not copy generated Catalog fields into
|
||||
the input.
|
||||
4. Describe user-visible semantics in `review_reason` and parameter
|
||||
descriptions.
|
||||
5. Run generation, drift, Schema policy, and the focused CLI tests before
|
||||
2. Declare selection prose on the owning leaf (`Contract.Selection` /
|
||||
`DeclareLeafMetadata`) and product routing via `ProductDecl`; declare
|
||||
safety / parameters / interface on the same leaf.
|
||||
3. Do not copy generated Catalog fields into source inputs.
|
||||
4. Run generation, drift, Schema policy, and the focused CLI tests before
|
||||
proposing the change.
|
||||
|
||||
## Agent curation workflow (Schema hints)
|
||||
## Agent curation workflow
|
||||
|
||||
Use this workflow when refreshing Agent selection prose and confirmation
|
||||
alignment. Prefer **agent-authored review** over bulk merge scripts that dump
|
||||
`selection-review.json` or Skill Markdown into Catalog fields.
|
||||
Skill Markdown into Catalog fields.
|
||||
|
||||
Human-authored inputs are split into two blocks:
|
||||
Human-authored inputs:
|
||||
|
||||
| Block | Path | Owns |
|
||||
|---|---|---|
|
||||
| **metadata** | `internal/cli/schema_hints/metadata/<product>.json` | `effect` / `risk` / `confirmation` / `idempotency` / `interface_*` / `runtime_gate` / optional `parameters` |
|
||||
| **selection** | `internal/cli/schema_hints/selection/<product>.json` | `agent_summary` / `use_when` / `avoid_when` / `examples` (+ product routing) |
|
||||
| **declaration** | helpers / shortcut `Safety` + `Contract` / `contract.ParamDecl` + `ProductDecl` | `effect` / `risk` / `confirmation` / `idempotency` / `interface_*` / parameter facts / selection prose (`contract_final`) |
|
||||
|
||||
`index.json` only maps product IDs to those files. Do not mix selection fields
|
||||
into metadata files or metadata fields into selection files.
|
||||
`schema_hints/` is fully retired. Do not reintroduce HintFiles or audit JSON.
|
||||
|
||||
### Goals
|
||||
|
||||
1. **Selection prose** is decision-oriented (Feishu/Lark style): trigger intent,
|
||||
sibling-command routing, and outcome shape — not a restatement of the
|
||||
summary. Delivered Catalog provenance is `reviewed_explicit` from
|
||||
`selection/`.
|
||||
2. **Safety** follows Runtime: `confirmation=user_required` iff the tool's
|
||||
metadata `runtime_gate != none` (for example `confirm_delete`, `typed_yes`,
|
||||
`confirm_dangerous`).
|
||||
3. **Parameter overrides** (former Manual `commands`) live on metadata tools as
|
||||
`parameters` (+ `cli_path`) and are applied into EffectiveCommandRegistry.
|
||||
summary. Delivered Catalog provenance is `contract_final` from leaf
|
||||
`Contract.Selection` / `ProductDecl`.
|
||||
2. **Safety** follows Runtime: `confirmation=user_required` when the leaf
|
||||
Contract/Safety (or remaining `runtime_gate` annotate) requires a user gate
|
||||
(for example `confirm_delete`, `typed_yes`, `confirm_dangerous`).
|
||||
3. **Parameter facts** are declared on the leaf (`contract.ParamDecl` /
|
||||
`Contract.Parameters` / FlagSpec). Do not reintroduce HintFile or
|
||||
`RegisterSchemaHints` overlays.
|
||||
|
||||
### Authoring
|
||||
|
||||
For every curated tool:
|
||||
|
||||
1. Edit `metadata/<product>.json` for safety/interface/gates/parameters.
|
||||
2. Edit `selection/<product>.json` for selection prose (`reviewed: true`,
|
||||
`review_reason`, `source_refs`).
|
||||
3. Run `make generate-schema`. Do not hand-edit generated
|
||||
`schema_agent_metadata/` or `schema_catalog/`.
|
||||
1. Declare safety/interface/parameters/selection on the owning leaf
|
||||
(`DeclareLeafMetadata` / `Shortcut.Contract` / `contract.ParamDecl`) and product routing
|
||||
via `ProductDecl` when needed.
|
||||
2. Run `make generate-schema` (param aliases + assembly determinism). Do not
|
||||
create or commit `schema_catalog/` or Schema meta-index fixtures.
|
||||
|
||||
### Pull live MCP descriptions (personal token)
|
||||
|
||||
Pinned `internal/cli/schema_mcp_metadata.json` is a sanitized baseline. Prefer
|
||||
live Schema from a logged-in personal session:
|
||||
Schema delivery no longer embeds a pinned MCP JSON. Prefer live Schema from a
|
||||
logged-in personal session when reviewing interface facts before declaring them
|
||||
on the leaf:
|
||||
|
||||
```bash
|
||||
dws auth status # token_valid should be true
|
||||
dws cache refresh # refresh discovery / tools cache
|
||||
dws cache refresh # deprecated no-op: prints a retirement notice (discovery cache is gone; refreshes nothing)
|
||||
dws schema <mcp-canonical> -f json
|
||||
# or CLI path: dws schema --cli-path "drive copy" -f json
|
||||
```
|
||||
|
||||
Resolve MCP identity via `interface_ref` when CLI canonical ≠ MCP path
|
||||
Resolve MCP identity via declared `interface_ref` when CLI canonical ≠ MCP path
|
||||
(example: CLI `drive.copy_document` → live `doc.copy_document`). On pull
|
||||
failure, fall back to Skill + Cobra Help + pinned MCP, and record evidence
|
||||
failure, fall back to Skill + Cobra Help, and record evidence
|
||||
(for example `live-dws-schema:<path>#FAILED`). Never print or commit tokens.
|
||||
`make fetch-mcp-metadata` writes an optional diagnostic dump under `artifacts/`
|
||||
only — do not commit it as a Schema pin.
|
||||
|
||||
Precedence when sources disagree: **Runtime/Cobra > live MCP > pinned MCP >
|
||||
Precedence when sources disagree: **Runtime/Cobra / leaf Contract > live MCP >
|
||||
Skill (evidence only)**.
|
||||
|
||||
### Parallel product agents
|
||||
@@ -242,9 +323,9 @@ Split work by product groups. Each agent must:
|
||||
|
||||
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live `dws schema`
|
||||
for its tools.
|
||||
- Hand-write selection + metadata; forbid wholesale JSON merges from review
|
||||
dumps.
|
||||
- Edit only its `metadata/<product>.json` and `selection/<product>.json`.
|
||||
- Hand-write selection prose and leaf Contract / ProductDecl declarations;
|
||||
forbid wholesale JSON merges from review dumps.
|
||||
- Edit only its product’s leaf declarations (and `ProductDecl` when needed).
|
||||
- **Never** `git checkout` unrelated product files to “clean scope”.
|
||||
|
||||
### Regenerate and gates
|
||||
@@ -255,23 +336,24 @@ make generate-schema
|
||||
go test ./internal/app -run '^TestSheetFinalSchemaConfirmationMatchesRuntimeGuards$' -count=1
|
||||
```
|
||||
|
||||
`check-runtime-confirmation-truth.sh` compares live ContractFinal.Safety with the assembled ToolSpec `confirmation=user_required` and probes the runtime gate.
|
||||
`schema_hints/` must stay absent.
|
||||
|
||||
Example rules (fail generation otherwise):
|
||||
|
||||
- At most two examples per tool; no `--yes` in stored examples.
|
||||
- Examples must match live Cobra argv (path, flags, required groups).
|
||||
- No shell comments in examples.
|
||||
|
||||
After generation, spot-check Catalog: selection provenance is
|
||||
`reviewed_explicit` from `selection/`, and `user_required` count equals
|
||||
metadata `runtime_gate != none`.
|
||||
After generation, spot-check Catalog: selection and safety/interface
|
||||
provenance are `contract_final` from ProductDecl / leaf declarations
|
||||
(`user_required` must match Runtime confirmation gates).
|
||||
|
||||
`make generate-schema` is a full deterministic snapshot rebuild, not an
|
||||
incremental patch over the previous Catalog. It rereads every reviewed input,
|
||||
removes stale generated product metadata, and rewrites the exact metadata and
|
||||
Catalog projections. Incremental work happens only when an Agent or human
|
||||
edits selected `metadata/` or `selection/` entries; the next publication still
|
||||
recomputes all outputs. Generated files must never be read back as merge input,
|
||||
and byte guards fail generation if it changes the hint inputs or CommandRegistry.
|
||||
`make generate-schema` refreshes `param_aliases_generated.go` and runs
|
||||
assembly determinism (`check-schema-assembly.sh`). It does not rewrite a
|
||||
committed Catalog as delivery authority — runtime reassembles from
|
||||
declarations. Byte guards fail if generation mutates parameter-concept
|
||||
inputs; policy fails if the retired `schema_command_registry/` reappears.
|
||||
|
||||
Selection prose may choose a more or less restrictive recommendation. It cannot
|
||||
create a Cobra command or flag, change parameter facts, invent an
|
||||
@@ -317,7 +399,7 @@ proves natural-language understanding.
|
||||
|
||||
Semantic selection is an explicit opt-in live-model check. Run the smoke set
|
||||
(one positive and one negative scenario per product) with
|
||||
`DWS_AGENT_SELECTION_LIVE=1 ARK_API_KEY=... ARK_BASE_URL=... ARK_MODEL=... go test ./internal/app -run TestManualAgentSelectionArkLive -count=1`.
|
||||
`DWS_AGENT_SELECTION_LIVE=1 ARK_API_KEY=... ARK_BASE_URL=... ARK_MODEL=... go test ./internal/app -run TestAgentSelectionArkLive -count=1`.
|
||||
Add `DWS_AGENT_SELECTION_FULL=1` to evaluate every committed tool scenario, or
|
||||
set `DWS_AGENT_SELECTION_CASES` to comma-separated fixture case IDs. Normal CI
|
||||
never calls a model; its blockers remain the reproducible fixture, binding,
|
||||
@@ -341,14 +423,21 @@ Preserve all candidates and the selected source in provenance, and fail
|
||||
same-precedence conflicts rather than silently merging them.
|
||||
|
||||
`required` is the exception. Cobra `MarkFlagRequired` is a hard floor: the
|
||||
final Agent projection must keep `required=true` and cannot be lowered by
|
||||
manual/hint overlays. Overlays may still raise an optional flag to required.
|
||||
`cli_required` continues to mirror the executable Cobra marker.
|
||||
final Agent projection must keep `required=true` and cannot be lowered by a
|
||||
lower-precedence source. A higher-precedence declaration may still raise an
|
||||
optional flag to required. `cli_required` continues to mirror the executable
|
||||
Cobra marker.
|
||||
|
||||
For command text, reviewed `ToolSchemaHint` wins first, then command-specific
|
||||
Cobra Help, then MCP metadata. Generic RPC prose may remain an unselected
|
||||
provenance candidate (and parameter-level `interface_description`); it must not
|
||||
overwrite a specialized leaf's title or description.
|
||||
For command-level description: **declare required, delivery Long may win**.
|
||||
`ContractDecl.Description` is mandatory at construction (declaration evidence).
|
||||
Catalog delivery prefers Cobra Long when present (provenance `cobra_help`,
|
||||
resolution `cobra_help_preferred`); without Long, the declared Description is
|
||||
delivered as `contract_final`. Title keeps declared ContractDecl /
|
||||
ContractFinal first, then Cobra Short, then MCP metadata. This is one authority
|
||||
chain with an explicit delivery preference — not two competing sources.
|
||||
Generic RPC prose may remain an unselected provenance candidate (and
|
||||
parameter-level `interface_description`); it must not overwrite a specialized
|
||||
leaf's title or description.
|
||||
|
||||
For every delivered `ToolSpec` and `ParameterSpec` field, the provenance
|
||||
winner value must exactly equal the delivered value. Checking only source,
|
||||
|
||||
@@ -6,8 +6,18 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- **Pinned MCP metadata retired** — deletes `internal/cli/schema_mcp_metadata.json` and removes its embed/loader/fallback role from Schema assembly. Catalog now assembles from Contract/ParamDecl/Interface + Cobra only; `make fetch-mcp-metadata` remains an optional diagnostic dump under `artifacts/` and refuses the retired pin path. Policy bans the pin from reappearing.
|
||||
- **MCP service review retired** — deletes `schema_mcp_service_review.json` and removes its policy jq / outputguard / test disposition gate (`notify` → `out_of_surface`, snapshot hash pin). No replacement ledger.
|
||||
- **Hints retired; ContractDecl is the leaf Schema source** (#830) — `schema_hints/`, Manual/Schema hint overlays, and `schema_agent_metadata/` delivery are removed. Selection, safety, parameters, and interface facts declare on ProductDecl / leaf `Contract` (`corecmd.ContractDecl` + `contract.ParamDecl` / `Safety`). Authoring renamed `SchemaDecl` → `ContractDecl`; nested fields reuse `contract.*` directly.
|
||||
- **Contract package seam** (#830) — types / ProductDecl live under `internal/corecmd/contract` (DTO only). Annotate writers live in `internal/corecmd/runtimeannotate`; Cobra-keyed ContractFinal store + Register live in `internal/corecmd/contractfinal`; homology gates in `internal/cli/homology`. All packages import `corecmd/*` directly; the former `cli/runtimeannotate` / `cli/contractfinal` shim packages are removed, and the `cli` root keeps only package-local aliases (`runtime_schema_seam.go`). Catalog/`ResolveMeta` stay on the `cli` delivery root. `internal/corecmd` must not import any `internal/cli` package.
|
||||
- **CommandMeta cache for ResolveMeta** — production `ResolveMeta` / leaf `--help` Safety project from the runtime-assembled `SchemaRegistry` into a `map[cli_path]CommandMeta` installed during `deliverySchemaCatalog` sync.Once. Steady-state lookups are O(1); full Catalog wire maps stay deferred. Registry `Source` stamps `runtime-assembled`.
|
||||
|
||||
### 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.
|
||||
- **Constraint "provided" for `at_least_one` / `exactly_one` (H0)** — a flag set to an empty string (`--flag ""`) no longer counts as provided; previously bare Cobra `Changed` satisfied the constraint. Pass a non-blank value for a member of the group.
|
||||
- **Chat media download JSON compatibility** — `dws chat message download-media --format json` once again returns a clean `{success, downloadUrl, output}` result after the file is saved, preserving the temporary URL and resolved local path without progress text corrupting JSON stdout.
|
||||
|
||||
## [1.0.56] - 2026-08-04
|
||||
|
||||
@@ -5,10 +5,12 @@ PUBLISH ?= 0
|
||||
YES ?= 0
|
||||
DWS_POLICY_TMPDIR ?= $(CURDIR)/.worktrees/policy-tmp
|
||||
POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go
|
||||
SCHEMA_CATALOG_OUTPUT ?= artifacts/schema_catalog
|
||||
SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob
|
||||
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
|
||||
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
|
||||
|
||||
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget 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 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
|
||||
|
||||
@@ -34,9 +36,8 @@ help:
|
||||
@printf " make cli-smoke - Verify help for every public top-level command\n"
|
||||
@printf " make mock-mcp-smoke - Verify HTTP and stdio MCP request/response transport\n"
|
||||
@printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n"
|
||||
@printf " make generate-schema - Regenerate embedded Agent metadata and the release Catalog\n"
|
||||
@printf " make generate-schema-agent-metadata - Regenerate versioned Agent metadata\n"
|
||||
@printf " make generate-schema-catalog - Regenerate the embedded release Catalog\n"
|
||||
@printf " make generate-schema - Refresh param_aliases + verify Schema assembly determinism\n"
|
||||
@printf " make generate-schema-catalog - Optional assembled Catalog dump under artifacts/ (not a delivery step)\n"
|
||||
@printf " make package - Build all release artifacts locally\n"
|
||||
@printf " make changelog-pre VERSION=vX.Y.Z-beta.N - Prepare prerelease notes\n"
|
||||
@printf " make changelog-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N - Prepare stable notes\n"
|
||||
@@ -86,7 +87,6 @@ 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
|
||||
@@ -133,26 +133,26 @@ mock-mcp-smoke:
|
||||
$(GO) test -v -count=1 -run '^(TestHTTPClientEndToEnd|TestStdioClientEndToEnd)$$' ./internal/transport
|
||||
|
||||
test-schema-agent-examples:
|
||||
DWS_AGENT_EXAMPLES_DRY_RUN=1 $(GO) test -v -count=1 ./internal/app -run '^TestManualAgentExamplesDryRun$$'
|
||||
DWS_AGENT_EXAMPLES_DRY_RUN=1 $(GO) test -v -count=1 ./internal/app -run '^TestAgentExamplesDryRun$$'
|
||||
|
||||
# generate-schema refreshes param_aliases_generated.go and verifies that
|
||||
# ResolveSchemaBuild assembly is deterministic. Catalog is runtime-assembled
|
||||
# (声明即 Catalog); cmd_schema_catalog is not a committed delivery step.
|
||||
# schema_agent_metadata/ and schema_hints/ must stay absent.
|
||||
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" "$$concepts_guard" "$$concepts_schema_guard" "$$metadata_guard" "$$selection_guard"' EXIT HUP INT TERM; \
|
||||
cp -R internal/cli/schema_command_registry/ "$$registry_guard/"; \
|
||||
trap 'rm -rf "$$concepts_guard" "$$concepts_schema_guard"' EXIT HUP INT TERM; \
|
||||
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; \
|
||||
diff -qr internal/cli/schema_command_registry "$$registry_guard" >/dev/null || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/schema_command_registry/' >&2; \
|
||||
rm -rf internal/cli/schema_agent_metadata internal/cli/schema_agent_metadata_audit.json; \
|
||||
rm -f internal/cli/schema_meta_index.json; \
|
||||
if [ -e internal/cli/schema_command_registry ]; then \
|
||||
printf '%s\n' 'retired schema_command_registry/ must not reappear after generation' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
fi; \
|
||||
cmp -s internal/cli/param_concepts.json "$$concepts_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.json' >&2; \
|
||||
exit 1; \
|
||||
@@ -161,37 +161,27 @@ generate-schema:
|
||||
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; \
|
||||
if [ -e internal/cli/schema_hints ]; then \
|
||||
printf '%s\n' 'retired schema_hints/ must not reappear after generation' >&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; \
|
||||
fi; \
|
||||
if [ -e internal/cli/schema_meta_index.json ]; then \
|
||||
printf '%s\n' 'retired schema_meta_index.json must not remain after generation' >&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; \
|
||||
}; \
|
||||
diff -qr internal/cli/schema_hints/selection "$$selection_guard" >/dev/null || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/schema_hints/selection' >&2; \
|
||||
exit 1; \
|
||||
}
|
||||
|
||||
generate-schema-agent-metadata:
|
||||
$(GO) run ./internal/generator/cmd_schema_agent_metadata \
|
||||
-root . \
|
||||
-registry internal/cli/schema_command_registry \
|
||||
-output-dir internal/cli/schema_agent_metadata \
|
||||
-audit-output internal/cli/schema_agent_metadata_audit.json
|
||||
fi; \
|
||||
./scripts/policy/check-schema-assembly.sh
|
||||
|
||||
# Optional local/CI dump of an assembled Catalog under artifacts/ by default.
|
||||
# Override SCHEMA_CATALOG_OUTPUT and SCHEMA_META_INDEX_OUTPUT as needed. This
|
||||
# is not a go:generate or production delivery step.
|
||||
generate-schema-catalog:
|
||||
$(GO) run -a ./internal/generator/cmd_schema_catalog \
|
||||
-root . \
|
||||
-output internal/cli/schema_catalog
|
||||
-output "$(SCHEMA_CATALOG_OUTPUT)" \
|
||||
-meta-index "$(SCHEMA_META_INDEX_OUTPUT)"
|
||||
|
||||
fetch-mcp-metadata:
|
||||
@printf ' %sRefreshing MCP metadata from live server%s\n' "$(COLOR_RUN)" "$(COLOR_RESET)"
|
||||
@printf ' %sFetching diagnostic MCP dump (not a Schema pin)%s\n' "$(COLOR_RUN)" "$(COLOR_RESET)"
|
||||
@./scripts/dev/fetch_mcp_metadata.sh
|
||||
|
||||
package:
|
||||
|
||||
@@ -396,7 +396,7 @@ The repo ships a complete Agent Skill system under `skills/`, organized into two
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
|
||||
Shared reviewed inputs for Schema generation live separately under `internal/cli/schema_hints/`. They are not Agent Skills and are excluded from binaries and release skill bundles.
|
||||
Leaf safety/parameters/selection prose for Schema generation come from ProductDecl / ContractFinal declarations in Go. The former `internal/cli/schema_hints/` HintFile tree is fully retired and must not reappear.
|
||||
|
||||
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
|
||||
|
||||
|
||||
+1
-1
@@ -390,7 +390,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
|
||||
Schema 生成共享的 reviewed 输入单独位于 `internal/cli/schema_hints/`。它们不是 Agent Skill,也不会进入二进制或发布 skill 包。
|
||||
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
|
||||
|
||||
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
// Command fetch_mcp_metadata pulls tools/list from ALL live MCP server endpoints
|
||||
// and writes a refreshed schema_mcp_metadata.json. This is DWS's equivalent of
|
||||
// lark-cli's scripts/fetch_meta.py.
|
||||
// and writes a local diagnostic dump. It is NOT a Schema delivery refresh:
|
||||
// schema_mcp_metadata.json is retired; production Catalog assembles from
|
||||
// Contract/ParamDecl/Interface + Cobra only.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// dws auth login # ensure valid auth
|
||||
// make fetch-mcp-metadata # runs this tool
|
||||
// make fetch-mcp-metadata # writes artifacts/mcp_metadata_diagnostic.json
|
||||
//
|
||||
// The tool loads auth from the DWS keychain, iterates all 26 static server
|
||||
// endpoints (internal/syncdata.StaticServers), calls tools/list on each,
|
||||
// merges results, and writes schema_mcp_metadata.json.
|
||||
// The tool loads auth from the DWS keychain, iterates static server endpoints
|
||||
// (internal/syncdata.StaticServers), calls tools/list on each, merges results,
|
||||
// and writes the requested -output path (refuses the retired pin path).
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/syncdata"
|
||||
@@ -38,14 +40,15 @@ type toolLister interface {
|
||||
|
||||
// Injection points so run() is fully testable without network/keychain/exit.
|
||||
var (
|
||||
osExit = os.Exit
|
||||
getenv = os.Getenv
|
||||
loadTokenData = auth.LoadTokenDataKeychain
|
||||
staticServers = syncdata.StaticServers
|
||||
registrySource = cli.EmbeddedCommandRegistryMergedJSON
|
||||
listToolsTimeout = 30 * time.Second
|
||||
gitHeadPath = ".git/HEAD"
|
||||
newToolLister = func(token string) toolLister {
|
||||
osExit = os.Exit
|
||||
getenv = os.Getenv
|
||||
loadTokenData = auth.LoadTokenDataKeychain
|
||||
staticServers = syncdata.StaticServers
|
||||
registrySource = collectedIdentityInterfaceRefs
|
||||
collectIdentitySpecs = cli.CollectIdentitySpecs
|
||||
listToolsTimeout = 30 * time.Second
|
||||
gitHeadPath = ".git/HEAD"
|
||||
newToolLister = func(token string) toolLister {
|
||||
return transport.NewClient(&http.Client{Timeout: 60 * time.Second}).WithAuth(token, nil)
|
||||
}
|
||||
)
|
||||
@@ -57,10 +60,14 @@ func main() {
|
||||
func run(args []string, stderr io.Writer) int {
|
||||
flags := flag.NewFlagSet("fetch_mcp_metadata", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
output := flags.String("output", "internal/cli/schema_mcp_metadata.json", "output file path")
|
||||
output := flags.String("output", "artifacts/mcp_metadata_diagnostic.json", "diagnostic dump path (not a Schema pin)")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
if retiredPinnedMCPMetadataPath(*output) {
|
||||
fmt.Fprintln(stderr, "fetch_mcp_metadata: refusing to write retired Schema pin internal/cli/schema_mcp_metadata.json")
|
||||
return 2
|
||||
}
|
||||
|
||||
token := resolveToken(stderr)
|
||||
if token == "" {
|
||||
@@ -74,11 +81,11 @@ func run(args []string, stderr io.Writer) int {
|
||||
servers := staticServers()
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: querying %d server endpoints\n", len(servers))
|
||||
|
||||
// Load CLI registry to build tool_name → interface_ref mapping.
|
||||
// Collect command identity to build tool_name → interface_ref mapping.
|
||||
registryMap := loadRegistryInterfaceRefs(stderr)
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: registry mapping: %d entries\n", len(registryMap))
|
||||
|
||||
// Load the previous schema_mcp_metadata.json to preserve hand-curated
|
||||
// Load a previous diagnostic dump (if any) to preserve hand-curated
|
||||
// cross-server interface_ref mappings that automated matching can't derive.
|
||||
prevData, prevErr := os.ReadFile(*output)
|
||||
prevTools := map[string]map[string]any{}
|
||||
@@ -310,48 +317,51 @@ func buildCrossServerRefs(prevTools map[string]map[string]any, registryMap map[s
|
||||
return index
|
||||
}
|
||||
|
||||
// loadRegistryInterfaceRefs loads the reviewed split CommandRegistry through
|
||||
// the cli package's reassembly API and builds a canonical_path →
|
||||
// {product_id, rpc_name} mapping for interface_ref injection.
|
||||
func loadRegistryInterfaceRefs(stderr io.Writer) map[string]map[string]string {
|
||||
data, err := registrySource()
|
||||
// collectedIdentityInterfaceRefs collects command identity from the live
|
||||
// command tree — the replacement for the retired reviewed CommandRegistry —
|
||||
// and derives the canonical_path → {product_id, rpc_name} mapping used for
|
||||
// interface_ref injection.
|
||||
func collectedIdentityInterfaceRefs() (map[string]map[string]string, error) {
|
||||
root := app.NewSchemaSourceRootCommand()
|
||||
specs, _, err := collectIdentitySpecs(root)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: warning: cannot load registry: %v\n", err)
|
||||
return map[string]map[string]string{}
|
||||
return nil, fmt.Errorf("collect command identity: %w", err)
|
||||
}
|
||||
var reg struct {
|
||||
Products []struct {
|
||||
ID string `json:"id"`
|
||||
Tools []struct {
|
||||
CanonicalPath string `json:"canonical_path"`
|
||||
} `json:"tools"`
|
||||
} `json:"products"`
|
||||
}
|
||||
if err := json.Unmarshal(data, ®); err != nil {
|
||||
// 与读文件失败同等告警:静默返回空映射会让所有 live tool 被丢弃、
|
||||
// 产出 stub-only 快照且零提示(P1#1 的故障模式)。
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: warning: cannot parse registry: %v\n", err)
|
||||
return map[string]map[string]string{}
|
||||
}
|
||||
out := make(map[string]map[string]string)
|
||||
for _, prod := range reg.Products {
|
||||
for _, tool := range prod.Tools {
|
||||
cp := strings.TrimSpace(tool.CanonicalPath)
|
||||
if cp == "" || !strings.Contains(cp, ".") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(cp, ".", 2)
|
||||
out[cp] = map[string]string{
|
||||
"product_id": parts[0],
|
||||
"rpc_name": parts[1],
|
||||
}
|
||||
out := make(map[string]map[string]string, len(specs))
|
||||
for _, spec := range specs {
|
||||
cp := strings.TrimSpace(spec.CanonicalPath)
|
||||
if cp == "" || !strings.Contains(cp, ".") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(cp, ".", 2)
|
||||
out[cp] = map[string]string{
|
||||
"product_id": parts[0],
|
||||
"rpc_name": parts[1],
|
||||
}
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loadRegistryInterfaceRefs builds the canonical_path → interface_ref mapping
|
||||
// from the collected command identity. 与旧实现同等告警:静默返回空映射会让
|
||||
// 所有 live tool 被丢弃、产出 stub-only 快照且零提示(P1#1 的故障模式)。
|
||||
func loadRegistryInterfaceRefs(stderr io.Writer) map[string]map[string]string {
|
||||
refs, err := registrySource()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: warning: cannot collect command identity: %v\n", err)
|
||||
return map[string]map[string]string{}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func retiredPinnedMCPMetadataPath(path string) bool {
|
||||
cleaned := strings.ReplaceAll(strings.TrimSpace(path), "\\", "/")
|
||||
return cleaned == "internal/cli/schema_mcp_metadata.json" ||
|
||||
strings.HasSuffix(cleaned, "/internal/cli/schema_mcp_metadata.json")
|
||||
}
|
||||
|
||||
// extractParams converts a JSON Schema inputSchema (from MCP tools/list) into
|
||||
// the flat param-name → metadata map used by schema_mcp_metadata.json.
|
||||
// the flat param-name → metadata map used by diagnostic dumps.
|
||||
func extractParams(inputSchema map[string]any) map[string]map[string]any {
|
||||
if inputSchema == nil {
|
||||
return nil
|
||||
|
||||
@@ -16,24 +16,66 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/syncdata"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestLoadRegistryInterfaceRefsUsesSplitRegistry(t *testing.T) {
|
||||
func TestCrossPlatformCoverageLoadRegistryInterfaceRefsCollectsIdentity(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
refs := loadRegistryInterfaceRefs(&stderr)
|
||||
if len(refs) == 0 {
|
||||
t.Fatal("loadRegistryInterfaceRefs() returned no reviewed commands")
|
||||
t.Fatal("loadRegistryInterfaceRefs() returned no collected commands")
|
||||
}
|
||||
|
||||
got, ok := refs["calendar.list_calendars"]
|
||||
if !ok {
|
||||
t.Fatal("calendar.list_calendars missing from reassembled split registry")
|
||||
t.Fatal("calendar.list_calendars missing from collected command identity")
|
||||
}
|
||||
if got["product_id"] != "calendar" || got["rpc_name"] != "list_calendars" {
|
||||
t.Fatalf("calendar.list_calendars ref = %#v", got)
|
||||
}
|
||||
|
||||
direct, err := collectedIdentityInterfaceRefs()
|
||||
if err != nil {
|
||||
t.Fatalf("collectedIdentityInterfaceRefs() error = %v", err)
|
||||
}
|
||||
if len(direct) == 0 || direct["calendar.list_calendars"]["rpc_name"] != "list_calendars" {
|
||||
t.Fatalf("collectedIdentityInterfaceRefs() = %#v", direct["calendar.list_calendars"])
|
||||
}
|
||||
|
||||
prevRegistry := registrySource
|
||||
prevCollect := collectIdentitySpecs
|
||||
t.Cleanup(func() {
|
||||
registrySource = prevRegistry
|
||||
collectIdentitySpecs = prevCollect
|
||||
})
|
||||
registrySource = func() (map[string]map[string]string, error) {
|
||||
return nil, errors.New("collect boom")
|
||||
}
|
||||
stderr.Reset()
|
||||
if got := loadRegistryInterfaceRefs(&stderr); len(got) != 0 || !strings.Contains(stderr.String(), "cannot collect command identity") {
|
||||
t.Fatalf("loadRegistryInterfaceRefs error path = %#v stderr=%q", got, stderr.String())
|
||||
}
|
||||
|
||||
collectIdentitySpecs = func(*cobra.Command) ([]cli.CommandSpec, cli.IdentityCollectionReport, error) {
|
||||
return nil, cli.IdentityCollectionReport{}, errors.New("walk boom")
|
||||
}
|
||||
if _, err := collectedIdentityInterfaceRefs(); err == nil || !strings.Contains(err.Error(), "collect command identity") {
|
||||
t.Fatalf("collectedIdentityInterfaceRefs wrap error = %v", err)
|
||||
}
|
||||
collectIdentitySpecs = func(*cobra.Command) ([]cli.CommandSpec, cli.IdentityCollectionReport, error) {
|
||||
return []cli.CommandSpec{
|
||||
{CanonicalPath: ""},
|
||||
{CanonicalPath: "nodot"},
|
||||
{CanonicalPath: "doc.create"},
|
||||
}, cli.IdentityCollectionReport{}, nil
|
||||
}
|
||||
gotRefs, err := collectedIdentityInterfaceRefs()
|
||||
if err != nil || len(gotRefs) != 1 || gotRefs["doc.create"]["rpc_name"] != "create" {
|
||||
t.Fatalf("collectedIdentityInterfaceRefs skip = %#v err=%v", gotRefs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCrossServerRefs(t *testing.T) {
|
||||
@@ -91,8 +133,11 @@ func TestBuildCrossServerRefsSkipsMalformedRefs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunRefreshesCrossServerTools(t *testing.T) {
|
||||
registry := func() ([]byte, error) {
|
||||
return []byte(`{"version":1,"products":[{"id":"aitable","tools":[{"canonical_path":"aitable.advperm_enable"},{"canonical_path":"aitable.advperm_disable"}]}]}`), nil
|
||||
registry := func() (map[string]map[string]string, error) {
|
||||
return map[string]map[string]string{
|
||||
"aitable.advperm_enable": {"product_id": "aitable", "rpc_name": "advperm_enable"},
|
||||
"aitable.advperm_disable": {"product_id": "aitable", "rpc_name": "advperm_disable"},
|
||||
}, nil
|
||||
}
|
||||
servers := []syncdata.ServerInfo{{ID: "aitable-helper", Endpoint: "https://helper.example"}}
|
||||
lister := &fakeLister{
|
||||
@@ -147,8 +192,10 @@ func TestRunRefreshesCrossServerTools(t *testing.T) {
|
||||
// 跨 server 身份时,另一 server 上恰好同名的工具不得直连覆盖其元数据——
|
||||
// 数据源只能是评审身份指向的 live 工具。
|
||||
func TestRunCrossOwnedCanonicalIgnoresNameCoincidence(t *testing.T) {
|
||||
registry := func() ([]byte, error) {
|
||||
return []byte(`{"version":1,"products":[{"id":"aitable","tools":[{"canonical_path":"aitable.advperm_enable"}]}]}`), nil
|
||||
registry := func() (map[string]map[string]string, error) {
|
||||
return map[string]map[string]string{
|
||||
"aitable.advperm_enable": {"product_id": "aitable", "rpc_name": "advperm_enable"},
|
||||
}, nil
|
||||
}
|
||||
servers := []syncdata.ServerInfo{
|
||||
{ID: "aitable", Endpoint: "https://aitable.example"},
|
||||
@@ -298,7 +345,7 @@ func (f *fakeLister) ListTools(_ context.Context, endpoint string) (transport.To
|
||||
}
|
||||
|
||||
// stubDeps swaps every injection point for the duration of one test.
|
||||
func stubDeps(t *testing.T, token string, keychain func() (*auth.TokenData, error), servers []syncdata.ServerInfo, lister toolLister, registry func() ([]byte, error)) {
|
||||
func stubDeps(t *testing.T, token string, keychain func() (*auth.TokenData, error), servers []syncdata.ServerInfo, lister toolLister, registry func() (map[string]map[string]string, error)) {
|
||||
t.Helper()
|
||||
origGetenv, origLoad, origServers, origNew, origRegistry := getenv, loadTokenData, staticServers, newToolLister, registrySource
|
||||
t.Cleanup(func() {
|
||||
@@ -316,12 +363,32 @@ func stubDeps(t *testing.T, token string, keychain func() (*auth.TokenData, erro
|
||||
registrySource = registry
|
||||
}
|
||||
|
||||
func testRegistryJSON() ([]byte, error) {
|
||||
return []byte(`{"version":1,"products":[{"id":"doc","tools":[{"canonical_path":"doc.copy_document"},{"canonical_path":"doc.get_document"},{"canonical_path":"bad-entry"}]}]}`), nil
|
||||
func testRegistryRefs() (map[string]map[string]string, error) {
|
||||
return map[string]map[string]string{
|
||||
"doc.copy_document": {"product_id": "doc", "rpc_name": "copy_document"},
|
||||
"doc.get_document": {"product_id": "doc", "rpc_name": "get_document"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunRefusesRetiredPinnedMCPMetadataPath(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
code := run([]string{"--output", "internal/cli/schema_mcp_metadata.json"}, &stderr)
|
||||
if code != 2 {
|
||||
t.Fatalf("run(retired pin) = %d, want 2", code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "refusing to write retired Schema pin") {
|
||||
t.Fatalf("stderr = %q, want retired-pin refusal", stderr.String())
|
||||
}
|
||||
if !retiredPinnedMCPMetadataPath("internal/cli/schema_mcp_metadata.json") ||
|
||||
!retiredPinnedMCPMetadataPath("/tmp/repo/internal/cli/schema_mcp_metadata.json") ||
|
||||
retiredPinnedMCPMetadataPath("artifacts/mcp_metadata_diagnostic.json") {
|
||||
t.Fatal("retiredPinnedMCPMetadataPath classification is incorrect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoTokenFails(t *testing.T) {
|
||||
stubDeps(t, "", func() (*auth.TokenData, error) { return nil, errors.New("no keychain") }, nil, &fakeLister{}, testRegistryJSON)
|
||||
stubDeps(t, "", func() (*auth.TokenData, error) { return nil, errors.New("no keychain") }, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
if code := run(nil, &stderr); code != 1 {
|
||||
t.Fatalf("run() = %d, want 1", code)
|
||||
@@ -332,7 +399,7 @@ func TestRunNoTokenFails(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunInvalidFlagFails(t *testing.T) {
|
||||
stubDeps(t, "tok", nil, nil, &fakeLister{}, testRegistryJSON)
|
||||
stubDeps(t, "tok", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--nonexistent"}, &stderr); code != 2 {
|
||||
t.Fatalf("run() = %d, want 2", code)
|
||||
@@ -342,7 +409,7 @@ func TestRunInvalidFlagFails(t *testing.T) {
|
||||
func TestResolveTokenKeychainFallback(t *testing.T) {
|
||||
stubDeps(t, "", func() (*auth.TokenData, error) {
|
||||
return &auth.TokenData{AccessToken: "kc-token"}, nil
|
||||
}, nil, &fakeLister{}, testRegistryJSON)
|
||||
}, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
if got := resolveToken(&stderr); got != "kc-token" {
|
||||
t.Fatalf("resolveToken() = %q, want kc-token", got)
|
||||
@@ -353,14 +420,14 @@ func TestResolveTokenKeychainFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveTokenEmptyKeychainToken(t *testing.T) {
|
||||
stubDeps(t, "", func() (*auth.TokenData, error) { return &auth.TokenData{}, nil }, nil, &fakeLister{}, testRegistryJSON)
|
||||
stubDeps(t, "", func() (*auth.TokenData, error) { return &auth.TokenData{}, nil }, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
if got := resolveToken(&stderr); got != "" {
|
||||
t.Fatalf("resolveToken() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWritesSnapshotWithHonestCoverage(t *testing.T) {
|
||||
func TestCrossPlatformCoverageRunWritesSnapshotWithHonestCoverage(t *testing.T) {
|
||||
servers := []syncdata.ServerInfo{
|
||||
{ID: "doc", Endpoint: "https://doc.example"},
|
||||
{ID: "sheet", Endpoint: "https://sheet.example"},
|
||||
@@ -383,7 +450,7 @@ func TestRunWritesSnapshotWithHonestCoverage(t *testing.T) {
|
||||
},
|
||||
errs: map[string]error{"https://sheet.example": errors.New("boom")},
|
||||
}
|
||||
stubDeps(t, "env-token", nil, servers, lister, testRegistryJSON)
|
||||
stubDeps(t, "env-token", nil, servers, lister, testRegistryRefs)
|
||||
|
||||
dir := t.TempDir()
|
||||
output := filepath.Join(dir, "snapshot.json")
|
||||
@@ -446,7 +513,7 @@ func TestRunWritesSnapshotWithHonestCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunIgnoresCorruptPreviousSnapshot(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
output := filepath.Join(t.TempDir(), "snapshot.json")
|
||||
if err := os.WriteFile(output, []byte("{corrupt"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -457,32 +524,20 @@ func TestRunIgnoresCorruptPreviousSnapshot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRegistryLoadFailureStillWritesStublessSnapshot(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, func() ([]byte, error) { return nil, errors.New("no registry") })
|
||||
func TestCrossPlatformCoverageRunRegistryLoadFailureStillWritesStublessSnapshot(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, func() (map[string]map[string]string, error) { return nil, errors.New("no identity") })
|
||||
output := filepath.Join(t.TempDir(), "snapshot.json")
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--output", output}, &stderr); code != 0 {
|
||||
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "cannot load registry") {
|
||||
t.Fatalf("stderr = %q, want registry warning", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUnparsableRegistryYieldsNoRefs(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, func() ([]byte, error) { return []byte("{bad"), nil })
|
||||
if refs := loadRegistryInterfaceRefs(&stderr); len(refs) != 0 {
|
||||
t.Fatalf("refs = %v, want empty for unparsable registry", refs)
|
||||
}
|
||||
// 解析失败必须有告警,不得静默产出空映射。
|
||||
if !strings.Contains(stderr.String(), "cannot parse registry") {
|
||||
t.Fatalf("stderr = %q, want parse warning", stderr.String())
|
||||
if !strings.Contains(stderr.String(), "cannot collect command identity") {
|
||||
t.Fatalf("stderr = %q, want identity collection warning", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWriteFailure(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
badPath := filepath.Join(t.TempDir(), "missing-dir", "snapshot.json")
|
||||
if code := run([]string{"--output", badPath}, &stderr); code != 1 {
|
||||
@@ -498,7 +553,7 @@ func TestWriteMetadataMarshalFailure(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMainDelegatesToRun(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
origExit, origArgs := osExit, os.Args
|
||||
t.Cleanup(func() { osExit, os.Args = origExit, origArgs })
|
||||
exitCode := -1
|
||||
@@ -519,14 +574,14 @@ func TestExtractParamsNilAndNonObjectSchemas(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewToolListerBuildsAuthedClient(t *testing.T) {
|
||||
func TestCrossPlatformCoverageNewToolListerBuildsAuthedClient(t *testing.T) {
|
||||
if lister := newToolLister("tok"); lister == nil {
|
||||
t.Fatal("newToolLister returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRecordsSourceRevision(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
dir := t.TempDir()
|
||||
head := filepath.Join(dir, "HEAD")
|
||||
if err := os.WriteFile(head, []byte("ref: refs/heads/feature\n"), 0o600); err != nil {
|
||||
|
||||
+11
-5
@@ -1,6 +1,6 @@
|
||||
# Architecture
|
||||
|
||||
`dws` is a Go CLI with a versioned, static command surface for DingTalk MCP capabilities. Cobra help serves humans; the embedded Command Catalog serves AI agents.
|
||||
`dws` is a Go CLI with a versioned, static command surface for DingTalk MCP capabilities. Cobra help serves humans; runtime-assembled Schema (`ResolveSchemaBuild`) serves AI agents.
|
||||
|
||||
## High-Level Flow
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
3. `internal/helpers` contains the main command handlers for all product surfaces (`dev`, `chat`, `calendar`, `contact`, `aitable`, etc.).
|
||||
4. `internal/executor` and `internal/transport` execute MCP JSON-RPC calls; `internal/output` formats responses.
|
||||
5. `internal/auth` manages login state, PAT tokens, and agent-code detection.
|
||||
6. Schema generation starts from the reviewed `CommandRegistry`, binds each identity to the exact current Cobra leaf, and then resolves typed constraints, sanitized MCP snapshots, Agent hints, and Skills into one `SchemaRegistry`. Startup and Schema queries do not call MCP `tools/list`.
|
||||
7. The embedded Catalog is a downstream release artifact and never backfills identity or participates in regeneration. Stable flag-to-interface property bindings come from the reviewed, content-addressed v3 manifest in `schema_parameter_bindings.json`; its exact active tuples, corrections, removals, and mapping exclusions are validated against the final bound `SchemaRegistry`. CLI `required` and constraints come from the resolved typed contract, while MCP `required` remains interface-only metadata.
|
||||
6. Schema assembly (`ResolveSchemaBuild`) starts from the reviewed `CommandRegistry`, binds each identity to the exact current Cobra leaf, and then resolves typed constraints, sanitized MCP snapshots, and leaf ContractFinal / ProductDecl into one `SchemaRegistry`. Startup and Schema queries do not call MCP `tools/list`. There is no generate-written Catalog delivery step.
|
||||
7. Production Catalog / `ResolveMeta` consume the lazily assembled registry via `RegisterSchemaSourceRoot` → `ResolveSchemaBuild` / `deliverySchemaCatalog` (声明即 Catalog; lazy `sync.Once`). `ResolveMeta` projects Identity/Safety/Selection from that assembly into an in-process map cache — not a committed `schema_catalog/` or `schema_meta_index.*` fixture. Flag-to-interface property delivery is owned by leaf `ParamDecl.Property` (native annotations). `schema_parameter_mapping_ledger.go` holds reviewed `mapping_exclusions` / `removals` (the empty `schema_parameter_bindings.json` audit table is retired). CLI `required` and constraints come from the resolved typed contract, while MCP `required` remains interface-only metadata.
|
||||
8. Agent selection results are fixed in versioned review inputs. Every public tool has explicit use/avoid/example and interface disposition metadata; Skill references that are not current leaves require an explicit alias/group/stale/out-of-surface review instead of fuzzy runtime matching.
|
||||
|
||||
## Repository Structure
|
||||
@@ -19,8 +19,8 @@
|
||||
- `internal/app`: root command wiring, static utility commands, and plugin loading
|
||||
- `internal/helpers`: product command handlers (dev, chat, calendar, contact, etc.)
|
||||
- `internal/plugin`: versioned plugin manifest, hook, skill, and transport descriptor loading
|
||||
- `internal/cli`: embedded Agent Command Catalog, static schema query, and catalog contracts
|
||||
- `internal/generator`: deterministic Agent metadata and Command Catalog generators
|
||||
- `internal/cli`: Schema assembly, `dws schema` query, and catalog contracts
|
||||
- `internal/generator`: CI/determinism tools (`cmd_schema_catalog` dump) and param-alias generate
|
||||
- `internal/executor`: invocation dispatch and result handling
|
||||
- `internal/transport`: MCP HTTP client and request signing
|
||||
- `internal/auth`: login, token management, agent-code detection, identity
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# 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 contract.SafetySpec │ │
|
||||
│ │ ├─ Effect (read / write / destructive) │ │
|
||||
│ │ ├─ Risk (low / medium / high) │ │
|
||||
│ │ ├─ Confirmation (not_required / user_required) ──▶ 运行时门 │ │
|
||||
│ │ └─ Idempotency (idempotent / retryable / …) │ │
|
||||
│ │ │ │
|
||||
│ │ 四字段彼此独立;同一值同时供运行时与 Schema 使用 │ │
|
||||
│ │ ConfirmFirst: bool (只控制确认门顺序) │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─── Contract 声明 (Agent 可见的元数据) ────────────────────────┐ │
|
||||
│ │ ContractDecl │ │
|
||||
│ │ ├─ Title / Description │ │
|
||||
│ │ ├─ contract.DryRunSpec {PreviewKind, RemoteReads} │ │
|
||||
│ │ ├─ contract.InterfaceSpec {Mode, Availability, Reason, Ref}│ │
|
||||
│ │ ├─ contract.SelectionSpec {AgentSummary, UseWhen, AvoidWhen│ │
|
||||
│ │ │ Prerequisites, Tips, Examples} │ │
|
||||
│ │ ├─ contract.ToolIdentitySpec {ProductID, CanonicalPath, …} │ │
|
||||
│ │ └─ Positionals[] {Name, Type, Required, Variadic} │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─── 执行体 (恰好一个) ─────────────────────────────────────────┐ │
|
||||
│ │ Invoke(Ctx, toolArgs) ← #830 过渡:单步派发(目标 mcpbind)│ │
|
||||
│ │ Orchestrate(Ctx) ← #830 过渡:多步编排(目标 Handler)│ │
|
||||
│ │ 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)
|
||||
• validateContractDecl • Long (约束 help) • Bool(name)
|
||||
• RegisterFlags • RunE (管线) • StrSlice(name)
|
||||
• ValidateConstraintDecls • Changed(name)
|
||||
• embedContractIntoSchema • DryRun() / Yes()
|
||||
• AnnotateConstraints
|
||||
• PostMount
|
||||
```
|
||||
|
||||
## 领域概念
|
||||
|
||||
| 概念 | 类型 | 职责 |
|
||||
|------|------|------|
|
||||
| **corecmd.Spec** | struct | 一个命令的完整契约(声明 + 执行) |
|
||||
| **FlagSpec** | struct | 一个参数的注册、回退链、绑定规则 |
|
||||
| **Constraint** | struct | 参数间的关系约束 |
|
||||
| **Safety** | contract.SafetySpec | 运行时与 Schema 共用的安全契约 |
|
||||
| **ContractDecl** | struct | Agent 可见的完整工具规格声明 |
|
||||
| **contract.SelectionSpec** | struct | Agent 选择该工具的语义指引 |
|
||||
| **contract.InterfaceSpec** | struct | 工具的接口模式与可用性 |
|
||||
| **contract.DryRunSpec** | struct | dry-run 能力声明 |
|
||||
| **contract.ToolIdentitySpec** | struct | 工具在注册表中的身份标识 |
|
||||
| **contract.RuntimeSchemaPositional** | struct | 有序位置参数声明 |
|
||||
| **Ctx** | struct | 执行上下文(类型安全的 flag 读取) |
|
||||
| **New** | func | 统一构建器(`corecmd.Spec` → `*cobra.Command`) |
|
||||
|
||||
## SafetySpec
|
||||
|
||||
`corecmd.Spec.Safety` 使用 `corecmd/contract.SafetySpec`(无 cli 类型别名),没有 command 自定义 Risk/Safety 枚举,也没有 `SafetyDecl` 覆盖层:
|
||||
|
||||
```go
|
||||
Safety: contract.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),注册默认值不算作已提供。
|
||||
|
||||
## ContractDecl 子结构
|
||||
|
||||
### contract.SelectionSpec(Agent 选择指引)
|
||||
|
||||
```go
|
||||
contract.SelectionSpec{
|
||||
AgentSummary: "一句话描述工具做什么",
|
||||
UseWhen: []string{"在什么场景下应该选择这个工具"},
|
||||
AvoidWhen: []string{"什么场景不应该用,应该用什么替代"},
|
||||
Prerequisites: []string{"使用前提条件"},
|
||||
Tips: []string{"使用技巧"},
|
||||
Examples: []string{"dws dev app create --name Bot --dry-run"},
|
||||
}
|
||||
```
|
||||
|
||||
### contract.InterfaceSpec(接口模式)
|
||||
|
||||
```go
|
||||
contract.InterfaceSpec{
|
||||
Mode: "composite", // local / mcp / composite
|
||||
Availability: "available", // available / unavailable
|
||||
Reason: "...", // composite/unavailable 时的原因
|
||||
Ref: &contract.InterfaceRefSpec{ // mcp 时的 ref
|
||||
ProductID: "...",
|
||||
RPCName: "...",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### contract.DryRunSpec(dry-run 能力)
|
||||
|
||||
```go
|
||||
contract.DryRunSpec{
|
||||
PreviewKind: "invocation", // invocation / request / plan
|
||||
RemoteReads: false, // dry-run 时是否发起远端读
|
||||
}
|
||||
```
|
||||
|
||||
## 执行体三选一
|
||||
|
||||
| 执行体 | 适用场景 | 框架做了什么 |
|
||||
|--------|----------|-------------|
|
||||
| **Invoke** | #830 过渡单步派发(生产仍用;目标 mcpbind) | 框架完成 required→constraint→validate→buildArgs→confirm,传入装配好的 toolArgs |
|
||||
| **Orchestrate** | #830 过渡多步编排(生产仍用;目标 Handler) | 框架完成 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 文件
|
||||
@@ -0,0 +1,286 @@
|
||||
# 命令框架架构
|
||||
|
||||
本文档描述 `internal/corecmd` 统一命令框架的当前架构,面向框架使用者和维护者。
|
||||
|
||||
## 概览
|
||||
|
||||
```
|
||||
用户输入 → cobra 命令树 → corecmd.New() → 运行时管线 → 后端派发
|
||||
```
|
||||
|
||||
命令框架将 CLI 命令的**声明**与**执行**分离:
|
||||
|
||||
- **声明面** — 数据字段描述命令是什么(flag、约束、SafetySpec、Contract 元数据)
|
||||
- **执行面** — 钩子函数描述命令做什么(校验、派发、编排)
|
||||
|
||||
框架负责: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 contract.SafetySpec // 运行时与 Schema 的单一安全来源
|
||||
ConfirmFirst bool // 确认门先于参数校验
|
||||
ConstParams map[string]any
|
||||
Contract ContractDecl // 叶子 Contract 声明(非 Catalog Schema)
|
||||
|
||||
// 执行面(恰好一个;Invoke/Orchestrate 为 #830 过渡派发 API,目标 mcpbind+Handler)
|
||||
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` 使用 `corecmd/contract.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: contract.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. **validateContractDecl** — Contract 声明完整性(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) ← #830 过渡:单步派发
|
||||
或 Orchestrate(ctx) ← #830 过渡:多步编排
|
||||
```
|
||||
|
||||
## 消费方式
|
||||
|
||||
### LeafSpec(MCP 直连叶子命令)
|
||||
|
||||
```go
|
||||
func newDevAppCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
return NewLeafCommand(LeafSpec{
|
||||
Use: "create",
|
||||
Short: "创建开放平台企业内部应用",
|
||||
Tool: devAppCreateTool,
|
||||
Safety: contract.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 为必填"},
|
||||
},
|
||||
Contract: ContractDecl{
|
||||
Description: "创建开放平台企业内部应用",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "invocation"},
|
||||
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "create then configure"},
|
||||
Selection: contract.SelectionSpec{
|
||||
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。
|
||||
|
||||
### 声明元数据模式(既有命令补 Contract)
|
||||
|
||||
执行体必须冻结时,用同一套 `LeafSpec` 词汇只声明元数据,写在命令字面量旁:
|
||||
|
||||
```go
|
||||
baseListCmd := &cobra.Command{
|
||||
Use: "list", Short: "获取 AI 表格列表",
|
||||
RunE: func(cmd *cobra.Command, args []string) error { /* 原执行体不动 */ },
|
||||
}
|
||||
DeclareLeafMetadata(baseListCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: ContractDecl{
|
||||
Description: "列出最近访问的 AI 表格 Base。",
|
||||
Interface: aitableMCPInterface("list_bases"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出最近访问的 AI 表格 Base。",
|
||||
UseWhen: []string{"只需浏览最近打开过的 Base 时"},
|
||||
AvoidWhen: []string{"按名称查找优先 base search"},
|
||||
Examples: []string{"dws aitable base list"},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`DeclareLeafMetadata` 调用 `corecmd.AttachContract` 挂 Safety+Contract;不注册 flag、不接管参数投影。可选 `Validate` 与 `ConfirmSafety` 同挂在 **RunE 包装器**内(不是 PreRunE)。当 `Safety.Confirmation=user_required` 时,用**同一份** SafetySpec 包一层 `ConfirmSafety`,保证执行门禁与 Catalog 同源;无 Validate 时确认推迟到 gated `CallTool`,成功返回却未确认则 fail-closed。迁移态入口;新命令仍应走 `NewLeafCommand`。
|
||||
|
||||
### 三档路径(当前可接受)
|
||||
|
||||
| 档 | 入口 | 说明 |
|
||||
|---|---|---|
|
||||
| **Tier1** | `corecmd.New` / `NewLeafCommand` | 完全托管:声明 + 执行都归框架 |
|
||||
| **Tier2** | `DeclareLeafMetadata` | helpers 迁移态;**Shortcut 也可采用,可接受** |
|
||||
| **Tier3** | 裸 Cobra | 应逐步收;新增裸叶需补声明或精确排除 |
|
||||
|
||||
长期展望(非当前硬要求):更多 Shortcut 可收敛到 mcpbind / 减少仅为参数装配的 `Execute`。**不要**把「Shortcut 必须去掉 Execute / 必须 mcpbind」当作当前门禁;也不要否定 Shortcut + `DeclareLeafMetadata`。
|
||||
|
||||
### 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 只在边界将它展开成完整
|
||||
`contract.SafetySpec`;command/Leaf 不再保留该枚举。Shortcut 的 Cobra
|
||||
type/default/usage provenance 保持不变,command 统一补充 Required、Enum 和关系约束投影。
|
||||
需要补 Agent Schema 且执行体暂不迁入时,Shortcut 也可走 Tier2
|
||||
`DeclareLeafMetadata`(与 helpers 同一路径)。
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 / 包 | 职责 |
|
||||
|------|------|
|
||||
| `internal/corecmd/corecmd.go` | 核心类型 + `New` 构建器 + 运行时管线 |
|
||||
| `internal/corecmd/contract_decl.go` | ContractDecl 载荷类型 + 声明完整性守卫 |
|
||||
| `internal/corecmd/contract/` | 契约 DTO(`SafetySpec` / `ParamDecl` / `ProductDecl` / `ContractFinalPayload`);**无** Cobra-keyed Final store |
|
||||
| `internal/corecmd/runtimeannotate/` | `AnnotateRuntime*` 写注解(框架侧;`cli` 薄 re-export) |
|
||||
| `internal/corecmd/contractfinal/` | ContractFinal Cobra store + `RegisterRuntimeContractFinal`(框架侧;`cli` 薄 re-export) |
|
||||
| `internal/cli/homology/` | flag/help/schema 同源门禁(`HOM-*`) |
|
||||
| `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:代码中的 Contract 声明经过 code review 后直接投影为:
|
||||
|
||||
- **Agent Runtime Schema**(`dws.schema.*` Cobra annotations;经 `runtimeannotate` / ContractFinal 嵌入)
|
||||
- **运行时组装的 SchemaRegistry / Catalog ToolSpec wire**(`RegisterSchemaSourceRoot` → `ResolveSchemaBuild`;`dws schema` / `--all` / 完整 leaf 载荷)
|
||||
- **CommandMeta 投影**(装配 Once 同步缓存 `map[cli_path]CommandMeta`;`ResolveMeta` / `SafetyForCLIPath` / leaf `--help` Safety 稳态 O(1) 读缓存,与 SchemaRegistry 同源)
|
||||
- **Dry-run Capabilities**(声明自动索引为 reviewed 能力)
|
||||
|
||||
生产权威是 leaf `ContractFinal` / `ProductDecl`(经 `RegisterSchemaSourceRoot` → `ResolveSchemaBuild` 装配进 Catalog);`InstallBuildTimeAgentMetadataJSON` 仅用于 `cmd_schema_catalog` 的 CI/local dump inject,不是生产交付路径。`schema_agent_metadata/` 与 `schema_hints/` 已退役。不再需要外部 hint 文件维护 selection/metadata/dry-run 信息。Catalog/meta-index 路径不得提交。
|
||||
|
||||
## 设计原则
|
||||
|
||||
1. **声明 vs 执行分离** — Flags/Constraints/Safety/Contract 是声明;Invoke/Validate/PostMount 是执行
|
||||
2. **单一数据源** — 一份声明驱动 --help、Schema、catalog、runtime 校验
|
||||
3. **安全字段不互推** — Confirmation 单独驱动确认,Effect/Risk/Idempotency 原样发布
|
||||
4. **构建时拦截 > 运行时报错** — 声明不完整在命令注册时 panic,不等到用户触发
|
||||
5. **边界兼容** — Shortcut 暂由 adapter 转换,Leaf 直接声明 SafetySpec
|
||||
@@ -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
|
||||
│
|
||||
├── contract.SafetySpec (运行时 + Schema 单一安全来源)
|
||||
├── FlagSpec[] (参数 + 回退链 + 绑定)
|
||||
├── Constraint[] (互斥/至少一个)
|
||||
├── ContractDecl (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) | ✅ ContractDecl 内建 | ⚠️ 运行时 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 |
|
||||
| 选择指引 | contract.SelectionSpec (UseWhen/AvoidWhen) | Description + Tips | 无 |
|
||||
| 安全声明 | contract.SafetySpec 直接声明 | Risk string | 无 |
|
||||
| dry-run 能力声明 | contract.DryRunSpec (reviewed) | DryRun hook 存在性 | 无 |
|
||||
| 接口模式 | contract.InterfaceSpec (local/mcp/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 + contract_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: contract.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 为必填"},
|
||||
},
|
||||
Contract: ContractDecl{
|
||||
Description: "创建开放平台企业内部应用",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "invocation"},
|
||||
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "create then configure"},
|
||||
Selection: contract.SelectionSpec{
|
||||
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 漂移门禁 |
|
||||
@@ -0,0 +1,368 @@
|
||||
# 声明式命令框架迁移指南
|
||||
|
||||
- **受众**:把 helpers 叶子 / Shortcut 从 Tier3 / 遗留 annotate 迁到声明式框架的工程师
|
||||
- **规范权威**(本文不重复政策全文,只给可执行路径):
|
||||
- [`rfc-command-framework-convergence.md`](rfc-command-framework-convergence.md) **§5.0**(声明定义、三档路径、Schema 字段权威)
|
||||
- [`flag-help-schema-homology.md`](flag-help-schema-homology.md)(路径 A:Contract 嵌入 Schema)
|
||||
- 仓库根 [`AGENTS.md`](../AGENTS.md)(Authoring tiers、Agent Schema contract、curation)
|
||||
- **架构速览**:[`command-framework-architecture.md`](command-framework-architecture.md)
|
||||
- **实现基线**:PR #830 起 `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` → `corecmd.New`;Schema 经 `RegisterSchemaSourceRoot` → `ResolveSchemaBuild` 运行时组装(**声明即 Catalog**)
|
||||
|
||||
---
|
||||
|
||||
## 1. Why / 终态
|
||||
|
||||
目标不是「再写一份 Catalog JSON」,而是 **一份声明同时驱动**:
|
||||
|
||||
1. Cobra 可执行表面(flags / required / constraints / help)
|
||||
2. 运行时确认门(`confirmation=user_required` → `ConfirmSafety`)
|
||||
3. Agent Schema(`ToolSpec` / `ResolveMeta`)
|
||||
|
||||
**单向数据流(摘要)**:
|
||||
|
||||
```text
|
||||
leaf Safety + Contract / ParamDecl / ProductDecl
|
||||
→ ContractFinal(corecmd.New 或 AttachContract / DeclareLeafMetadata)
|
||||
→ CollectIdentitySpecs(活 Cobra 叶上的 Identity)
|
||||
→ ResolveSchemaBuild → SchemaRegistry
|
||||
→ dws schema / ResolveMeta(同一组装结果)
|
||||
```
|
||||
|
||||
硬约束:
|
||||
|
||||
| 要做 | 不要做 |
|
||||
|---|---|
|
||||
| 在叶子旁声明 `Safety` / `Contract` / `ParamDecl` | 提交 `schema_catalog/`、`schema_meta_index.*`、`schema_agent_metadata/`、`schema_hints/`、`schema_mcp_metadata.json` |
|
||||
| 用 `make generate-schema` 刷新 param aliases + 证明组装确定性 | 把 Catalog dump 当交付权威或手工改 wire |
|
||||
| help/Schema 事实 **declare OR annotate** | 纯推断、或钩子闭包里「顺带」发明表面 |
|
||||
| `Confirmation` 单独驱动运行时门 | 从 `effect`/`risk` 机械推导 `confirmation` |
|
||||
|
||||
`cmd_schema_catalog` 仅供 CI/local dump;生产路径没有 committed Catalog pin。
|
||||
|
||||
---
|
||||
|
||||
## 2. Authoring tiers(何时用哪一档)
|
||||
|
||||
同一 `ContractFinal` 语义;三档不是互相否定。详见 RFC §5.0.2a。
|
||||
|
||||
| 档 | 入口 | 声明什么 | 执行面 | 何时用 |
|
||||
|---|---|---|---|---|
|
||||
| **Tier1** | `corecmd.New` / `helpers.NewLeafCommand(LeafSpec)` | `Flags` / `Constraints` / `Safety` / `ConstParams` / `Contract` 全进 Spec | 框架注册 flag、投影参数、`ConfirmSafety`、派发 | **新命令默认**;执行面可迁入 LeafSpec/`Call` |
|
||||
| **Tier2** | `helpers.DeclareLeafMetadata(cmd, LeafSpec)` | 仅 `Safety` + `Contract`(可选 `Validate`) | **不**注册 flag;确认挂 RunE 包装器 | helpers / Shortcut 迁移态:先补 Agent Schema,执行面暂时手写 `RunE`/`Execute` |
|
||||
| **Tier3** | 裸 `*cobra.Command` | 无框架声明(或仅 annotate / 精确排除) | 调用方自管 | 应收窄;新增裸叶须迁元数据或进 `schema_command_exclusions.go` |
|
||||
|
||||
选用规则(今日):
|
||||
|
||||
1. 新命令 → **Tier1**。
|
||||
2. 既有叶子要进 Schema、但 `RunE`/`Execute` 还不想重写 → **Tier2**(声明写在命令字面量旁)。
|
||||
3. **Shortcut + `DeclareLeafMetadata` 合法**;多数 Shortcut 已走 `FromShortcut` → `corecmd.New`(Tier1 表面 + 自有 `Execute`)。
|
||||
4. Tier2 → Tier1 与 Shortcut → mcpbind 是后续里程碑,**不是**本阶段硬门槛;不得用 Tier2 绕开「业务 flag 必须声明」的纪律(半接管字段会 panic)。
|
||||
|
||||
`DeclareLeafMetadata` 禁止传入:`Flags` / `Constraints` / `ConstParams` / `Call` / `RunE` / `PostMount` / `ConfirmFirst` / `Server`/`Tool`;**唯一允许的执行钩子是 `Validate`**。
|
||||
|
||||
---
|
||||
|
||||
## 3. What to declare(声明清单)
|
||||
|
||||
### 3.1 框架声明面(数据字段才算声明)
|
||||
|
||||
| 字段 | 作用 | Schema / 运行时 |
|
||||
|---|---|---|
|
||||
| `Flags`(Tier1) | kebab-case flag、Kind、Default、Required、`Bind`、Aliases… | parameters;`Bind` → property |
|
||||
| `Constraints`(Tier1) | `at_least_one` / `exactly_one` / `mutually_exclusive` / custom | constraints 段 + 运行时校验 |
|
||||
| `Safety`(`contract.SafetySpec`) | `effect` / `risk` / `confirmation` / `idempotency` **四字段齐全** | ToolSpec Safety;仅 `confirmation` 驱动确认门 |
|
||||
| `ConstParams`(Tier1) | 固定 toolArgs,不上用户 flag 表 | 载荷;非 parameters |
|
||||
| `Contract`(`corecmd.ContractDecl`) | Identity / Description / Selection / Parameters / Interface / DryRun | ContractFinal → Catalog |
|
||||
| `ProductDecl` | 产品级 routing(`contract.RegisterProductDecl`) | 产品 overview selection |
|
||||
|
||||
钩子(`Validate` / `Call` / `Invoke` / `Orchestrate` / `RunE` / `Execute` / `PostMount`)**不算**声明——行为正确也不能替代 Schema 事实。
|
||||
|
||||
### 3.2 Contract 必填要点
|
||||
|
||||
- `Identity`:`ProductID` / `Name` / `CanonicalPath` / `CLIPath` / `PrimaryCLIPath`(与活 Cobra 路径一致;collector 以此为唯一 identity 源)
|
||||
- `Description`:构造期必填(声明证据);Catalog **交付**可优先 Cobra Long(provenance `cobra_help`)
|
||||
- `Selection`:决策向 `AgentSummary` / `UseWhen` / `AvoidWhen` / `Examples`(勿复述 Short;**禁止** `Reviewed` 字段)
|
||||
- `Parameters`(`[]contract.ParamDecl`):CLI flag 名 → RPC `Property`;`interface_type` 等接口事实写这里,不写 MCP pin
|
||||
- `Interface`:`mcp` / `composite` / `local` + `Ref` 或 `Reason`
|
||||
|
||||
### 3.3 ProductDecl
|
||||
|
||||
在产品根命令构造处注册一次,例如 `internal/helpers/drive.go` 的 `newDriveCommand`:
|
||||
|
||||
```go
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "drive",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "…",
|
||||
UseWhen: []string{"…"},
|
||||
AvoidWhen: []string{"…"},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 分步迁移清单
|
||||
|
||||
### 4.1 典型 helpers 叶子(裸 Cobra → Tier2)
|
||||
|
||||
1. **确认 CLI 路径**:`dws <path> --help`,记下 flag 名与 required 组。
|
||||
2. **选定 Identity**:`product_id` / `name` / `canonical_path` / `cli_path` 与兄弟命令不冲突;aliases 若有则写进 Identity(与 collected registry 一致)。
|
||||
3. **声明 Safety**:写操作填完整四字段;删除/破坏性用 `confirmation=user_required`(见 §5)。
|
||||
4. **声明 Contract**:`Description` + `Selection` + `Interface`;flag→RPC 用 `Parameters: []ParamDecl{{Name, Property}}`。
|
||||
5. **挂元数据**:在 flag 注册与 `RunE` 之后调用:
|
||||
|
||||
```go
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{ /* 四字段 */ },
|
||||
Contract: LeafContract{ /* Identity / Selection / … */ },
|
||||
// 本地副作用且 user_required:补 Validate,避免确认抢先或 fail-closed
|
||||
})
|
||||
```
|
||||
|
||||
6. **Property 缺口**:CLI 有、RPC 无的 selector → `schema_parameter_mapping_ledger.go` 的 `mapping_exclusions`(精确键 + 非空 reason),不要造 binding JSON。
|
||||
7. **产品路由**:若产品尚无 `ProductDecl`,在产品根补注册。
|
||||
8. **验证**(§7)后再开 PR。
|
||||
|
||||
参考:`internal/helpers/drive.go`(`DeclareLeafMetadata` + `RegisterProductDecl`)。
|
||||
|
||||
### 4.2 典型 Shortcut(补 Safety + Contract)
|
||||
|
||||
1. Shortcut 已由 `FromShortcut` → `corecmd.New` 接管 flag/约束/确认;迁移焦点是 **显式 `Safety` + `Contract`**,不要再依赖仅 `Risk` 推断。
|
||||
2. 在 `shortcut.Shortcut{…}` 字面量上增加:
|
||||
|
||||
```go
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{ /* … */ },
|
||||
Description: "…",
|
||||
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "…"},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "…",
|
||||
UseWhen: []string{"…"},
|
||||
AvoidWhen: []string{"…"},
|
||||
Examples: []string{"dws chat +conversation-info --group <openConversationId>"},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
3. `Flags` / `Constraints` / `Validate` / `Execute` 保持 Shortcut 词汇;adapter 映射到 `corecmd.Spec`。
|
||||
4. Examples 用真实可执行路径与 flag;**不要**写 `--yes`。
|
||||
5. 验证(§7)。
|
||||
|
||||
参考:`internal/shortcut/chat/chat_conversation.go`(`ConversationInfo`);adapter:`internal/shortcut/adapter.go`。
|
||||
|
||||
### 4.3 新命令 / 可重写执行面 → Tier1
|
||||
|
||||
优先 `NewLeafCommand(LeafSpec{ Flags, Safety, Contract, Call/… })`,例如 `internal/helpers/devapp.go`。业务 flag 只进 `Flags`,禁止在 `PostMount`/`Validate` 里 `Flags().String` 注册业务面。
|
||||
|
||||
---
|
||||
|
||||
## 5. Safety / confirmation
|
||||
|
||||
### 5.1 声明完整 SafetySpec
|
||||
|
||||
```go
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "destructive", // read | write | destructive
|
||||
Risk: "high", // low | medium | high
|
||||
Confirmation: "user_required", // not_required | user_required
|
||||
Idempotency: "unknown", // idempotent | retryable | non_idempotent | unknown
|
||||
},
|
||||
```
|
||||
|
||||
非空则四字段必须齐全(构造期校验)。字段独立:`effect=destructive` **不会**自动变成 `user_required`。
|
||||
|
||||
### 5.2 迁离 AnnotateRuntimeRisk / AnnotateRuntimeGate
|
||||
|
||||
| 旧路径 | 新路径 |
|
||||
|---|---|
|
||||
| `AnnotateRuntimeRisk` / 字符串 `dws.schema.risk` | 声明 `Safety.Risk`(经 ContractFinal) |
|
||||
| `AnnotateRuntimeGate` / `runtime_gate`(如 `devAppRequireWriteGuard`) | 声明 `Safety.Confirmation=user_required`;框架 `ConfirmSafety` |
|
||||
| 仅 Shortcut `Risk=write` 隐式确认 | 显式 `Safety`(覆盖 Risk 展开) |
|
||||
|
||||
**禁止新增**生产 `AnnotateRuntimeRisk` / `AnnotateRuntimeGate` 调用点;存量 annotate 可保留至迁完(`HOM-S2`)。同源门禁:`confirmation=user_required` ↔ 运行时 gate(见 `check-runtime-confirmation-truth.sh`、`internal/cli/homology`)。
|
||||
|
||||
### 5.3 Tier2 确认时机
|
||||
|
||||
- 有 `Validate`:`Validate` → `ConfirmSafety` → 原 `RunE`
|
||||
- 无 `Validate`:确认推迟到首次 `CallTool`;无 Caller 的本地副作用叶必须补 `Validate`,否则 fail-closed
|
||||
|
||||
执行前需确认时,用 `--yes` 跳过交互;**Schema examples 永不包含 `--yes`**。
|
||||
|
||||
---
|
||||
|
||||
## 6. Parameters / mapping
|
||||
|
||||
1. **主权威**:Tier1 用 `FlagSpec.Bind`(空则 Name);Tier2 / 混合路径用 `contract.ParamDecl{Name, Property}`。
|
||||
2. **mapping ledger**(`internal/cli/schema_parameter_mapping_ledger.go`):只放 `mapping_exclusions` / removals——CLI flag **无**直接 RPC property 时的精确评审排除;非空 reason。
|
||||
3. **不要**:提交 MCP pin(`schema_mcp_metadata.json` 已退役);不要指望 live MCP 创建 CLI flag;不要用 hints overlay 改 `type`/`required`/`default`。
|
||||
4. **required 地板**:Cobra `MarkFlagRequired` 不得被低优先级源降为 optional。
|
||||
5. 接口事实(`interface_ref` / `interface_type`)声明在 leaf `Contract.Interface` / `ParamDecl`;CLI path ≠ MCP path 时用 `Interface.Ref`(例:`drive delete` → `doc.delete_document`)。
|
||||
|
||||
---
|
||||
|
||||
## 7. Schema 验证
|
||||
|
||||
迁移后至少跑:
|
||||
|
||||
```bash
|
||||
make generate-schema
|
||||
./scripts/policy/check-schema-catalog.sh
|
||||
./scripts/policy/check-runtime-confirmation-truth.sh # 若动了 confirmation / gate
|
||||
# 聚焦:
|
||||
go test ./internal/app -run 'TestSheetFinalSchemaConfirmationMatchesRuntimeGuards|TestFinalSchemaParametersMatchExecutableHelpFlags' -count=1
|
||||
```
|
||||
|
||||
手工抽查:
|
||||
|
||||
```bash
|
||||
dws schema --cli-path "drive mkdir" -f json
|
||||
# 或 ResolveMeta 同源:help Safety 行与 Schema confirmation 一致
|
||||
dws drive mkdir --help
|
||||
```
|
||||
|
||||
Examples 规则(组装/门禁):
|
||||
|
||||
- 每 tool 最多两条;路径与 flag 必须是活 Cobra 可接受的
|
||||
- **禁止** `--yes`;禁止 shell 注释
|
||||
- 缺必填 / 约束失败 = 契约 bug,不是「跳过 example」的理由
|
||||
|
||||
可选:`make test-schema-agent-examples`(合同 + 显式 dry-run 能力子集)。
|
||||
|
||||
---
|
||||
|
||||
## 8. Pitfalls(常见坑)
|
||||
|
||||
1. **Schema source root = declarationOnly**
|
||||
`app.NewSchemaSourceRootCommand` 以 `declarationOnly=true` 建树:**跳过** `helpers.InitDeps` / `injectStaticServers`,避免组装时清掉进程里的 ToolCaller / plugin endpoint。叶子构造与 RunE 包装必须对 `deps == nil` 安全(见 `printDocDeprecationWarning` 注释,`internal/helpers/doc.go`)。
|
||||
|
||||
2. **DryRun `RemoteReads`**
|
||||
声明 `Contract.DryRun` / `DryRunSpec` 时,`RemoteReads: false` 表示预览计划不发起远程读;勿把「有 dry-run 能力」与「会打后端」混为一谈。无 reviewed dry-run 能力时,example 门禁不会魔法升级为 runtime dry-run。
|
||||
|
||||
3. **不要复活退役目录 / pin**
|
||||
`schema_hints/`、`schema_agent_metadata/`、`schema_command_registry/`、committed `schema_catalog/`、`schema_mcp_metadata.json` —— 出现即 policy 失败。
|
||||
|
||||
4. **Tier2 半接管**
|
||||
往 `DeclareLeafMetadata` 塞 `Flags`/`Call`/`RunE` 会 panic;要框架管 flag → 升 Tier1。
|
||||
|
||||
5. **Identity 漂移**
|
||||
`Contract.Identity` 必须与活路径一致;不一致组装失败。排除项用 `schema_command_exclusions.go` 精确路径 + reason,禁止前缀通配。
|
||||
|
||||
6. **Selection 里塞 `Reviewed`**
|
||||
旧 hints 标记;声明载荷携带即组装报错。
|
||||
|
||||
7. **在钩子里发明 flag/property**
|
||||
Schema 看不到;同源门禁(`HOM-P*` / `HOM-D1`)会打回来。
|
||||
|
||||
---
|
||||
|
||||
## 9. Worked mini-example(前后对照)
|
||||
|
||||
### 9.1 helpers:裸叶 → Tier2(模式摘自 `drive mkdir` / `drive delete`)
|
||||
|
||||
**Before(概念)**:手写 `cobra.Command` + `RunE` + `Flags().String`,无 `Safety`/`Contract` → Agent Schema 缺叶子或靠已退役 hints。
|
||||
|
||||
**After(Tier2,缩写)**:
|
||||
|
||||
```go
|
||||
driveMkdirCmd := &cobra.Command{
|
||||
Use: "mkdir",
|
||||
Short: "创建文件夹",
|
||||
RunE: /* 既有 callMCPTool("create_folder", …) */,
|
||||
}
|
||||
driveMkdirCmd.Flags().String("name", "", "…")
|
||||
driveMkdirCmd.Flags().String("folder", "", "…")
|
||||
|
||||
DeclareLeafMetadata(driveMkdirCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "drive", Name: "create_folder",
|
||||
CanonicalPath: "drive.create_folder",
|
||||
CLIPath: "drive mkdir", PrimaryCLIPath: "drive mkdir",
|
||||
},
|
||||
Description: "创建文件夹",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp", Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "drive", RPCName: "create_folder"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "创建文件夹",
|
||||
UseWhen: []string{"用户要在钉盘下新建普通文件夹时"},
|
||||
AvoidWhen: []string{"知识库内建文件夹改用 dws wiki node create …"},
|
||||
Examples: []string{`dws drive mkdir --name "项目资料" --format json`},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "folder", Property: "parentId"},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
破坏性写示例(`drive delete`):`Confirmation: "user_required"`,`Effect: "destructive"`,`Interface.Ref` 可指向 `doc`/`delete_document`。
|
||||
|
||||
路径:`internal/helpers/drive.go`。
|
||||
|
||||
### 9.2 Tier1 LeafSpec(`dev app event list` 缩写)
|
||||
|
||||
```go
|
||||
return NewLeafCommand(LeafSpec{
|
||||
Use: "list", Short: "查询应用已订阅的事件列表",
|
||||
Tool: devAppEventListTool,
|
||||
Safety: /* read / low / not_required / idempotent */,
|
||||
Flags: []LeafFlag{
|
||||
{Name: "unified-app-id", Bind: "unifiedAppId", Required: true, Trim: true},
|
||||
{Name: "keyword", Bind: "keyword", OmitEmpty: true, Trim: true},
|
||||
},
|
||||
Contract: LeafContract{ /* Identity + Selection + Interface */ },
|
||||
Call: devAppCallCursor(runner),
|
||||
})
|
||||
```
|
||||
|
||||
路径:`internal/helpers/devapp.go`。
|
||||
|
||||
### 9.3 Shortcut(`chat +conversation-info`)
|
||||
|
||||
见 §4.2;完整字面量:`internal/shortcut/chat/chat_conversation.go`。`FromShortcut` 把 `Flags`/`Constraints`/`Safety`/`Contract` 编进 `corecmd.Spec`(`internal/shortcut/adapter.go`)。
|
||||
|
||||
---
|
||||
|
||||
## 10. Done criteria(完成标准)
|
||||
|
||||
迁移 PR 合并前,下列应满足:
|
||||
|
||||
| 检查项 | 证据 |
|
||||
|---|---|
|
||||
| 叶子有完整 `Safety`(或存量 `runtime_gate` 未扩大) | `dws schema --cli-path "…" -f json` 四字段与声明一致 |
|
||||
| `user_required` ↔ 运行时确认 | `./scripts/policy/check-runtime-confirmation-truth.sh`;相关 `HOM-S1`/`HOM-S2` |
|
||||
| Identity / 路径可执行且进 Schema(或精确 exclusion) | `CollectIdentitySpecs` / reverse-completeness;无前缀排除 |
|
||||
| parameters ⊆ help flags;property 有声明或 mapping exclusion | `check-schema-catalog.sh`;`HOM-P1`/`HOM-D1` |
|
||||
| 无退役 pin / hints / agent_metadata / committed catalog | policy 脚本;`git status` 无这些路径 |
|
||||
| `make generate-schema` 通过(aliases + 组装确定性) | CI / 本地 |
|
||||
| Examples 可执行、无 `--yes` | 组装 example 门禁;必要时 `make test-schema-agent-examples` |
|
||||
| gofmt;未无关改动 | PR diff |
|
||||
|
||||
长期可选(非本阶段硬门槛):Tier2 → Tier1;Shortcut `Execute` 中「只为装配参数」的函数体收敛到 mcpbind 形态 1/2(RFC §3.5 / §5.0.2a.5)。
|
||||
|
||||
---
|
||||
|
||||
## 附录:相关文件速查
|
||||
|
||||
| 主题 | 路径 |
|
||||
|---|---|
|
||||
| Leaf Tier1/Tier2 API | `internal/helpers/leaf.go` |
|
||||
| Leaf → corecmd | `internal/helpers` `FromLeafSpec`;`internal/corecmd` |
|
||||
| Shortcut → corecmd | `internal/shortcut/adapter.go` |
|
||||
| Safety / Contract DTO | `internal/corecmd/contract` |
|
||||
| ContractFinal 注册 | `internal/corecmd/contractfinal` |
|
||||
| Schema 组装入口 | `internal/cli`:`RegisterSchemaSourceRoot`、`ResolveSchemaBuild`、`ResolveMeta` |
|
||||
| 精确排除 | `internal/cli/schema_command_exclusions.go` |
|
||||
| mapping exclusions | `internal/cli/schema_parameter_mapping_ledger.go` |
|
||||
| declaration-only 根 | `internal/app.NewSchemaSourceRootCommand` |
|
||||
@@ -0,0 +1,269 @@
|
||||
# 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. 运行时**组装后的** Schema 交付中的 parameters、关系约束和 SafetySpec:
|
||||
- **SchemaRegistry / Catalog ToolSpec wire** 供 `dws schema` / `--all` / 完整 leaf 载荷(lazy;首次 `dws schema` 触发完整装配)。
|
||||
- **`ResolveMeta` / `SafetyForCLIPath` / leaf `--help` Safety** 与装配同源:`deliverySchemaCatalog` sync.Once 装配后同步物化 `map[cli_path]CommandMeta`;稳态为 O(1) map lookup,不为 Meta 单独重做全量 ToolSpec/wire 投影。
|
||||
Agent metadata 在组装期间经内存 inject,不落盘、不 embed;`schema_agent_metadata/` 已退役,若存在则 policy 失败。
|
||||
|
||||
嵌入机制(已落地):
|
||||
|
||||
```text
|
||||
corecmd.Spec
|
||||
→ RegisterFlags + embedContractIntoSchema
|
||||
→ cobra annotations:
|
||||
dws.schema.contract=command
|
||||
dws.schema.property / type / required (per flag)
|
||||
dws.schema.constraints
|
||||
→ RegisterRuntimeContractFinal(SafetySpec + ContractDecl)
|
||||
→ Schema 组装透传 Contract Final
|
||||
(组装时内存 inject Agent metadata)
|
||||
→ RegisterSchemaSourceRoot → ResolveSchemaBuild
|
||||
→ SchemaRegistry (+ Meta cache map) / dws schema wire projection
|
||||
```
|
||||
|
||||
command/Leaf 不再写 `dws.schema.risk`;SafetySpec 走类型化 Final 载荷,不使用字符串枚举注解。
|
||||
|
||||
### 1.1 硬规则:声明 = 最终数据源(Schema 透传)
|
||||
|
||||
受管命令进入 Schema 的叶子数据由 **Contract 声明**定义最终值;框架(`corecmd.New`)做**类型转换**并注册,Schema 组装**透传**,不得:
|
||||
|
||||
- 把声明序列化成 JSON 注解再解析;
|
||||
- 在声明体系里再挂「评审字段」并行权威;
|
||||
- 用 hints/registry 盖写已声明字段。
|
||||
|
||||
**declare-vs-delivery 例外(Title / Description)**:构造期 `ContractDecl.Description` **必填**(声明证据),但这不是「declare = wire 最终值」。Catalog **交付**时:
|
||||
|
||||
- **description**:有 Cobra Long → 交付 Long(provenance `cobra_help` / `cobra_help_preferred`);无 Long → 交付声明(`contract_final`)。**Short 不进入 description**。
|
||||
- **title**:声明 `ContractDecl.Title` 优先,否则 Cobra Short,再 MCP;组装 stamp 真实 winner。
|
||||
|
||||
这是一条权威链上的显式交付偏好,不是双权威(见 RFC §5.0.4)。
|
||||
|
||||
迁移期未迁完的叶子可暂走旧组装路径;**新声明面不含 review_reason / reviewed 字段**。写命令未设 `Safety` 时,过渡期仍可用 `runtime_gate` annotate(`HOM-S2`)。
|
||||
|
||||
**不采用**路径 B(以 MCP meta / 已退役的 `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`(`contract.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 以 collector 收集的 `ContractFinal.Identity` 声明为准(reviewed 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: contract.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: contract.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 | 已删:`schema_hints/` 不得重现;受管命令以 Contract.Safety / gate 为准 |
|
||||
|
||||
标注与声明冲突时:**Contract 声明胜**(路径 A)。标注不得发明未注册的 CLI flag。
|
||||
|
||||
### 1.4 Schema 全覆盖(`ToolSpec` 无空洞)
|
||||
|
||||
`dws schema` 叶子模型是 `cli.ToolSpec`。命令框架必须为**每一个字段组**指定权威;完整矩阵在 RFC **§5.0.4**,摘要:
|
||||
|
||||
| ToolSpec 组 | 权威类 | 框架声明字段 / 其它源 |
|
||||
|---|---|---|
|
||||
| Identity | 声明源(identity collector 收集 `ContractFinal.Identity`;reviewed `schema_command_registry` 已退役) | `ContractDecl.Identity` 声明 |
|
||||
| Display / Title / Description | 声明证据 + 交付偏好(非双权威) | **title**:ContractDecl 优先,否则 Cobra Short,再 MCP;**description**:构造期 Description 必填;Catalog 交付 Long→`cobra_help`,无 Long→`contract_final`;**Short 不进 description**(RFC §5.0.4) |
|
||||
| Parameters.`name/type/required/default/property` | **声明**(或同形 annotate) | `Flags` / `Bind` |
|
||||
| Parameters.`description` | 声明 usage(`FlagSpec.Usage` / ParamDecl) | `schema_hints/` 已退役;不得用 overlay 改 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 + 内存 inject 的 Agent metadata |
|
||||
| Selection | 声明(ContractFinal / ProductDecl) | `ContractDecl.Selection` / `ProductDecl` |
|
||||
| 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 | identity collector 收集的 `ContractFinal.Identity` 声明(reviewed `schema_command_registry` 已退役) | Schema identity |
|
||||
| use_when / avoid_when / examples / agent_summary 文案 | `ContractDecl.Selection` / `ProductDecl` | Schema selection |
|
||||
| RPC tool 形状、`interface_ref`、interface 描述 | leaf `Contract.Interface` + `ParamDecl`(`schema_parameter_mapping_ledger.go` 仅 mapping_exclusions / removals;`schema_mcp_metadata` 已退役) | Schema `interface_*` 字段;**不得创建 flag** |
|
||||
| 参数描述 overlay(可选) | 生产 metadata 壳为空;参数事实走 ParamDecl / FlagSpec | **Contract/cobra 胜** |
|
||||
| 遗留 Safety 文案(迁移期) | 生产 metadata 壳为空;Safety 走 Contract | 以 Contract.Safety / runtime_gate 为准(见 §4) |
|
||||
| dry-run 正能力 | reviewed dry-run registry | Schema `dry_run` |
|
||||
| positionals | Contract Args / 显式 annotate | Schema `positionals` |
|
||||
| FieldProvenance | 组装派生 | Schema provenance(与值一致) |
|
||||
|
||||
Selection **刻意不**由单命令 Contract 取代(RFC 决策 8 / schema 设计硬规则);identity 的历史决策是不进 Contract、归 reviewed `CommandRegistry`,该 registry 已退役,现由 identity collector 收集 `ContractFinal.Identity` 声明(声明即 identity)。**完整无空洞表见 RFC §5.0.4。**
|
||||
|
||||
## 3. 当前缺口与目标闭环
|
||||
|
||||
已具备:
|
||||
|
||||
- Flags / ConstParams / Constraints → 注册、校验与 `ConstraintHelp`;SafetySpec → 运行时 `ConfirmSafety`(command);
|
||||
- Call / Execute 作为执行体;业务参数不得在 Call 内装配(helpers 门禁);
|
||||
- **Contract → Schema 嵌入**:参数/约束写原生 annotation,SafetySpec 与 ContractDecl 注册为类型化 Contract Final 并由 Schema 组装透传;
|
||||
- Selection 权威为 `ContractDecl.Selection` / `ProductDecl`(`contract_final`);`schema_hints/` 已退役。
|
||||
|
||||
已进 CI(`make policy` → `check-schema-catalog.sh` / `check-runtime-confirmation-truth.sh`):
|
||||
|
||||
| Gate ID | CI 入口(`-run` 白名单 / 脚本) |
|
||||
|---|---|
|
||||
| `HOM-P1` / `HOM-D1` | `./internal/app`:`TestFinalSchemaParametersMatchExecutableHelpFlags`、`TestDeliverySchemaParametersMatchExecutableHelpFlags` |
|
||||
| `HOM-P2`(参数映射/bindings 子集) | `./internal/cli`:`TestSchemaParameterBindingsMatchReviewedBaselineAndDeliveryCatalog`、`TestDeliveryCatalogMCPParameterMappingsAreComplete` 等 bindings 门禁 |
|
||||
| `HOM-S1` / `HOM-S2`(confirmation 同源) | `./internal/cli/homology`:`TestUserRequiredSafetyHomologyWithRuntimeGate` + `check-runtime-confirmation-truth.sh`;`./internal/app`:`TestSheetFinalSchemaConfirmationMatchesRuntimeGuards` |
|
||||
| 词汇/决策钉扎 | `./internal/cli/homology`:`TestHomologyDecisionDocPinsPathAAndGateIDs`、`TestMCPPassthroughAdmissionExcludesLeafAndShortcut`、`TestHomologyCIEntrypointsPinned` |
|
||||
|
||||
仍缺(未宣称全量 CI 覆盖):
|
||||
|
||||
1. 独立可执行的 `HOM-P3`(constraints ≡ AnnotateConstraints)与 `HOM-S3`(read 不得误投影 user_required)全量 gate;
|
||||
2. `HOM-I1` 作为单独 gate ID 的显式用例(MCP bindings ⊆ Contract flags 已有映射审计子集,但未钉 `HOM-I1` 标签)。
|
||||
|
||||
已落地(写命令确认语义,`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 门禁规划
|
||||
|
||||
以下门禁 ID 稳定,便于 CI 认领。§3 表标明哪些已挂入 `check-schema-catalog.sh`。
|
||||
|
||||
| Gate ID | 断言 | 范围 | CI |
|
||||
|---|---|---|---|
|
||||
| `HOM-P1` | 受管 leaf 的 schema `parameters[].name` 集合 ≡ cobra 本地 flag 名集合(排除全局 persistent) | LeafSpec / Contract 编译命令 | **已进**(app help↔schema) |
|
||||
| `HOM-P2` | schema parameter `type` / `required` / `default` 与 cobra DefValue / MarkFlagRequired / FlagSpec 一致;不得用已退役 hints overlay 改写这三项 | 同上 | **部分**(bindings/mapping 门禁) |
|
||||
| `HOM-P3` | schema 关系约束(require_one_of / mutually_exclusive)≡ Contract/Leaf `Constraints` 投影(与 `AnnotateConstraints` 同构) | 声明了 Constraints 的命令 | 规划 |
|
||||
| `HOM-S1` | Contract/Leaf `user_required` Safety 与运行时 Confirm/gate 同源,且 help Safety 行同语义 | 受管写/破坏性命令 | **已进** |
|
||||
| `HOM-S2` | 若命令走显式 write guard(如 `devAppRequireWriteGuard`)而非完整 SafetySpec,则必须人工标注 `dws.schema.runtime_gate`;Schema 不得呈 `confirmation=not_required`;符合 §1.1 declare OR annotate | 今日 devapp 写命令 | **已进**(同源测试含 gate 路径) |
|
||||
| `HOM-S3` | `Risk=read`(或空→read)不得投影为 `user_required`,除非有 reviewed exclusion reason | 受管读命令 | 规划 |
|
||||
| `HOM-I1` | `interface_ref` 存在时,bindings 覆盖的 CLI flag ⊆ Contract flags;MCP meta **不**引入额外 CLI flag | 有 MCP 绑定的命令 | **部分**(mapping 审计) |
|
||||
| `HOM-D1` | `dws <path> --help` Flags 段与 schema leaf parameters 零未解释增量 | 受管公开 leaf | **已进**(与 HOM-P1 同测) |
|
||||
|
||||
落地顺序建议:
|
||||
|
||||
1. 保持 `HOM-P1`/`HOM-D1`/`HOM-S1`/`HOM-S2` 在 `check-schema-catalog.sh` 白名单中(勿再只靠词汇钉扎);
|
||||
2. 补独立 `HOM-P3` / `HOM-S3` 可执行 gate,并把 `HOM-P2`/`HOM-I1` 从「部分」升到全量标签;
|
||||
3. 新 Leaf 继续走 Contract 嵌入;禁止 `schema_hints/` 回潮。
|
||||
|
||||
## 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. 在 identity 声明面标记 `surface_kind=mcp_passthrough`(名称可调整;原计划标在 reviewed registry,该 registry 已退役),且 **不得**与 LeafSpec/Shortcut 手写定义双注册同一 `cli_path`;
|
||||
6. 文档与门禁写明:该通道是子集优化,失败时回退/禁止扩张到产品 CLI。
|
||||
|
||||
显式排除(永远走路径 A / Shortcut):
|
||||
|
||||
- 全部 `LeafSpec` 命令(含 `dws dev app …`);
|
||||
- 全部 `+shortcut` 与 smart 编排;
|
||||
- 任何需要 `devAppRequireWriteGuard`、cursor 工具注入、或响应投影的命令。
|
||||
|
||||
## 6. 非目标
|
||||
|
||||
- ~~不把 canonical identity / 导航塞进 Contract(仍归 reviewed `CommandRegistry`)~~ —— 该非目标已被后续演进取代:reviewed `CommandRegistry` 已退役,identity 现由 collector 收集 `ContractFinal.Identity` 声明(声明即 identity,不再是独立评审文件)。selection 文案已由 `ContractDecl.Selection` / `ProductDecl` 声明(非 hints)。
|
||||
- 不要求删除 LeafSpec 门面。
|
||||
- 不把「生成 catalog 字节一致」当作运行时同源的充分条件(仍需 `HOM-*` 与差分门禁)。
|
||||
+2
-2
@@ -111,9 +111,9 @@ dws contact user search --query "Alice" -o result.json
|
||||
|
||||
## Schema Introspection / Schema 查询
|
||||
|
||||
`--help` 展示当前二进制的 Cobra 命令和可接受 flag,`dws schema` 查询同版本内嵌的 Agent 命令契约。Schema 查询不访问 MCP endpoint、不执行 `tools/list`,也不搜索钉钉文档或任何业务数据。
|
||||
`--help` 展示当前二进制的 Cobra 命令和可接受 flag,`dws schema` 查询同版本运行时组装的 Agent 命令契约。Schema 查询不访问 MCP endpoint、不执行 `tools/list`,也不搜索钉钉文档或任何业务数据。
|
||||
|
||||
Schema 的稳定 `canonical_path`、主 CLI 路径和 aliases 来自 reviewed `CommandRegistry`,并在发布时逐项绑定当前 Cobra tree。编辑 `internal/cli/schema_command_registry.json` 时必须遵守同目录的 `schema_command_registry.schema.json`;普通生成流程只校验该 reviewed input,不会覆盖它。Native annotation 只做实现一致性校验;Catalog 是该统一强类型契约的发布输出,不作为命令发现或下一轮生成的输入。
|
||||
Schema 的稳定 `canonical_path`、主 CLI 路径和 aliases 收集自命令树叶节点上的 `ContractFinal.Identity`(`CollectIdentitySpecs`),并在发布时逐项绑定当前 Cobra tree。原 reviewed `schema_command_registry/` 已退役,身份变化通过编辑叶节点声明完成。Native annotation 只做实现一致性校验;Catalog 是该统一强类型契约的发布输出,不作为命令发现或下一轮生成的输入。
|
||||
|
||||
### 路径写法
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,17 +6,21 @@ DWS Schema 是当前二进制公开 CLI 的版本化 Agent 执行契约。它描
|
||||
|
||||
设计遵循三条硬规则:
|
||||
|
||||
1. **Schema 描述 CLI,不制造 CLI。** `CommandRegistry`、manual hint、metadata 和 Catalog 都不能凭空创建 Cobra 命令或 flag;registry 中的每个路径都必须精确绑定真实 runnable Cobra leaf。
|
||||
1. **Schema 描述 CLI,不制造 CLI。** `CommandRegistry`、ProductDecl / leaf `Contract`、metadata 和 Catalog 都不能凭空创建 Cobra 命令或 flag;registry 中的每个路径都必须精确绑定真实 runnable Cobra leaf。interface 事实由 leaf `Contract` / `ParamDecl` 声明(`schema_mcp_metadata` 已退役),**不得**从 MCP meta 生成 CLI flag(见 §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。
|
||||
3. **Collector-first,Catalog 只出不进。** identity collector(`CollectIdentitySpecs`,遍历携带 `ContractFinal.Identity` 的 live Cobra leaves)是稳定 command identity/navigation 的唯一事实源;reviewed `schema_command_registry/` 已退役,identity 由声明(Contract)即代码提供,不再有独立的 reviewed identity 文件。production 通过 `RegisterSchemaSourceRoot` → `ResolveSchemaBuild` 组装 `SchemaRegistry`,并从它投影 ToolSpec wire 与 `ResolveMeta`。`cmd_schema_catalog` 只能生成 CI/local dump,`internal/cli/schema_catalog/`、`schema_meta_index.gob` 和 `schema_meta_index.json` 不得提交或成为运行时来源。`schema_agent_metadata/` / `schema_hints/` / `schema_command_registry/` 已退役;若存在则 policy 失败。生产 Agent selection / safety / interface 权威为 leaf `ContractFinal` 与 `ProductDecl`;`agent_metadata_inject.go` / `InstallBuildTimeAgentMetadataJSON` 仅作 `cmd_schema_catalog` CI/local dump 辅助,不得作为生产权威。
|
||||
|
||||
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
|
||||
schema_command_registry.json (reviewed CommandRegistry source)
|
||||
+ reviewed manual command additions
|
||||
identity collector (CollectIdentitySpecs)
|
||||
walks live Cobra leaves carrying ContractFinal.Identity;
|
||||
reviewed exclusions applied; single identity source
|
||||
(reviewed schema_command_registry/ retired)
|
||||
|
|
||||
v
|
||||
EffectiveCommandRegistry
|
||||
@@ -28,26 +32,13 @@ schema_command_registry.json (reviewed CommandRegistry source)
|
||||
v
|
||||
BoundCommandRegistry
|
||||
|
|
||||
+----------------------+
|
||||
|
|
||||
skills/mono Markdown + internal/cli/schema_hints/*.json |
|
||||
+ schema_mcp_metadata.json |
|
||||
| |
|
||||
v |
|
||||
Agent-metadata normalization |
|
||||
| |
|
||||
v |
|
||||
schema_agent_metadata/*.json |
|
||||
(generated normalized input) |
|
||||
| |
|
||||
+-----------------------+
|
||||
|
|
||||
v
|
||||
live Cobra flag facts / typed parameter metadata
|
||||
+ schema_hints/metadata/*.json (reviewed parameter overlay + safety)
|
||||
+ schema_hints/selection/*.json (reviewed Agent selection prose)
|
||||
+ schema_parameter_bindings.json (reviewed flag -> RPC property)
|
||||
+ schema_mcp_metadata.json (pinned, sanitized interface facts)
|
||||
+ normalized Agent metadata
|
||||
+ leaf Contract (Safety / ContractDecl / ParamDecl → contract_final)
|
||||
+ ProductDecl (product routing prose; production Agent authority)
|
||||
+ schema_parameter_mapping_ledger.go (reviewed mapping exclusions / removals)
|
||||
+ leaf Contract.Interface / ParamDecl (declared interface facts)
|
||||
+ skills/mono Markdown (evidence only; not concatenated)
|
||||
|
|
||||
v
|
||||
source adapters + resolvers
|
||||
@@ -60,18 +51,14 @@ live Cobra flag facts / typed parameter metadata
|
||||
+-----------+-----------+
|
||||
| |
|
||||
v v
|
||||
build-time typed gates snapshot serializer
|
||||
|
|
||||
v
|
||||
schema_catalog/
|
||||
(catalog.json + tools/<product>.json,
|
||||
release output only)
|
||||
|
|
||||
v
|
||||
go:embed -> typed loader
|
||||
build-time typed gates RegisterSchemaSourceRoot
|
||||
-> ResolveSchemaBuild
|
||||
(runtime assembly; lazy Once)
|
||||
|
|
||||
v
|
||||
SchemaRegistry + SchemaIndex
|
||||
+ ResolveMeta projection cache
|
||||
(in-process map; not gob/json fixture)
|
||||
|
|
||||
+---------------------+------------------+
|
||||
| | |
|
||||
@@ -82,9 +69,12 @@ live Cobra flag facts / typed parameter metadata
|
||||
|
|
||||
v
|
||||
runtime query + delivery gates
|
||||
(cmd_schema_catalog = CI/local dump only;
|
||||
InstallBuildTimeAgentMetadataJSON = dump helper,
|
||||
not production Agent authority)
|
||||
```
|
||||
|
||||
`--help` 是 Cobra 自身的人类可读投影,不从 Catalog 生成。Schema projections 和 `--help` 共享同一真实 Cobra 命令面,但承担不同职责。Binder 之后不得再从 annotation、manual hint 或生成 JSON 重新解析 command identity。
|
||||
`--help` 是 Cobra 自身的人类可读投影,不从 Catalog 生成。Schema projections 和 `--help` 共享同一真实 Cobra 命令面,但承担不同职责。Binder 之后不得再从 annotation、已退役 hint overlay 或生成 JSON 重新解析 command identity。
|
||||
|
||||
## 3. 与 Lark 的关系
|
||||
|
||||
@@ -95,8 +85,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 的 identity collector 从携带 `ContractFinal.Identity` 的 live Cobra leaves 收集 identity,绑定前生成唯一的 `EffectiveCommandRegistry`(reviewed `schema_command_registry/` 已退役),因此不存在 “native-first”、“legacy registry fallback” 或 Catalog fallback。飞书也是**分层单权威**(API 用平台 meta,Shortcut 用手写契约),不是全家只有 meta——DWS 对齐的是这一分层,而不是「用 MCP meta 生成全部 CLI」。
|
||||
|
||||
DWS 内部 resolved model 为:
|
||||
|
||||
@@ -121,26 +113,38 @@ DWS 当前对外仍保留兼容 wire:leaf 使用 flat `parameters`,安全和
|
||||
|
||||
| 来源 | 负责内容 | 明确不负责 |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| Contract / LeafSpec / `corecmd.Spec` | **CLI 表面权威**:flags、defaults、required、enum、关系约束、运行时 Risk;编译为 cobra 与 help | canonical identity、selection 文案、虚构 RPC |
|
||||
| identity collector(`CollectIdentitySpecs`) | 从 live Cobra leaves 的 `ContractFinal.Identity` 声明收集稳定 canonical identity、primary CLI path、alias、exposure 和导航(reviewed `schema_command_registry/` 已退役) | 创建 Cobra 命令/flag、参数、安全、endpoint/token |
|
||||
| reviewed exclusions(`ReviewedRuntimeSchemaExclusions`) | exact、reviewed、带 reason 地将指定 public runnable leaf 排除出 effective 表面 | 运行时 fallback、prefix/wildcard 排除、创建命令 |
|
||||
| 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/selection/*.json` | reviewed selection prose(summary / use_when / avoid_when / examples) | 创建 Cobra 命令或参数、改写 safety |
|
||||
| typed parameter metadata / constraints | 由 Contract 约束投影而来的 `require_one_of` / 互斥等;以及仍需 reviewed 的 `required_when` 等 | 命令 identity |
|
||||
| `schema_parameter_mapping_ledger.go` | CLI flag 无直接 RPC property 的 exclusions / removals | 命令发现、risk 推断、创建 CLI flag;property 交付归 ParamDecl.Property |
|
||||
| leaf `Contract.Interface` / `ParamDecl` | 声明的 RPC identity 与 interface_* 事实(`schema_mcp_metadata.json` 已退役) | CLI identity、运行时路由、**创建 CLI flag**、risk 推断 |
|
||||
| ProductDecl + leaf `Contract.Selection` | reviewed selection / product routing prose(`contract_final`) | 创建 Cobra 命令或参数、改写 safety;`schema_hints/` 已退役 |
|
||||
| Skills/Markdown | 产品路由、工作流和使用建议 | 命令存在性和 flag 事实 |
|
||||
| `schema_catalog/`(catalog.json + tools/<product>.json)及其他 generated JSON | resolved registry 的兼容发布序列化;运行时由 production loader 解回 typed registry/index | generation/source resolution 输入、identity fallback、手工修复源 |
|
||||
| `cmd_schema_catalog` CI/local dump(可选 `schema_catalog/` / meta-index) | resolved registry 的兼容序列化快照,仅供 jq/determinism;不得提交为 runtime 来源 | production delivery、`ResolveMeta` 权威、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,不构成第二事实源。
|
||||
identity collector 从 live Cobra leaves 的 `ContractFinal.Identity` 声明生成 `CommandSpec`,应用 reviewed exclusions 后按确定性规则索引为 effective registry;从 binder 开始,下游只看到一个稳定 identity/navigation 模型。reviewed `schema_command_registry/` 已退役,其历史角色(稳定 canonical identity/navigation 事实源)由 collector 承接。旧 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` / `ResolveSchemaBuild`)消费这些注解进入 typed `SchemaRegistry`(runtime assembly;非 `go:embed` catalog 交付)。
|
||||
- **硬规则**:CLI 表面事实 = **声明(Contract 数据字段)OR 人工标注**;禁止纯推断。非 CLI 表面字段(identity / selection / interface)必须有**评审源**。声明写法见同源文档 §1.2;标注见 §1.3;**`ToolSpec` 全字段权威见 RFC §5.0.4 / 同源 §1.4**。
|
||||
- **非同源面(有意)**:identity(collector)、selection 文案(ProductDecl / leaf `Contract.Selection`)、RPC 形状(MCP meta 仅 `interface_*`)、dry-run 正能力 registry。
|
||||
- **禁止**:以 MCP meta 为主通道生成 Leaf/Shortcut 的 flag;已退役的 hint overlay 改写 type/required/default;Schema 字段无权威归属。
|
||||
|
||||
## 5. 统一解析与 precedence
|
||||
|
||||
### 5.1 Identity
|
||||
|
||||
- Reviewed base `CommandRegistry` 是 stable canonical identity、primary path、alias 和 navigation 的唯一基础事实源。
|
||||
- Reviewed manual command addition 只能引用精确存在的 runnable Cobra leaf;它在绑定前合并进 `EffectiveCommandRegistry`。若与 base Registry 的 identity/path/alias 冲突,生成失败,不能按 precedence 静默覆盖。
|
||||
- identity collector(`CollectIdentitySpecs`)是 stable canonical identity、primary path、alias 和 navigation 的唯一基础事实源:每个携带 `ContractFinal.Identity` 的 runnable Cobra leaf(含 Hidden deprecated/migration shims)贡献一条 identity,reviewed exclusions 精确应用;reviewed `schema_command_registry/` 已退役,其历史角色由 collector 承接。
|
||||
- Collector 输出的 `CommandSpec` 在索引时 fail-closed 校验 canonical/product/path/alias/visibility 的合法性与唯一性;重复 identity、alias 复用 primary path 或 alias collision 全部失败,不能按 precedence 静默覆盖。
|
||||
- Binder 必须把 effective entry 的 primary path 和每个 alias 精确解析到同一个真实 executable leaf;stale path、phantom path、重复 identity 或 alias collision 全部失败。
|
||||
- Native identity annotation 是可选的一致性证据:存在时必须与 effective entry 精确一致;缺失不触发补写、推断或 fallback。
|
||||
- Public runnable Cobra leaf 未进入 effective registry 时,必须存在 exact、reviewed、带 reason 的 exclusion;不得用 prefix/wildcard 排除。
|
||||
@@ -154,30 +158,35 @@ DWS 当前对外仍保留兼容 wire:leaf 使用 flat `parameters`,安全和
|
||||
constraint、Cobra help 和 interface resolver 提供,因此删除该过渡层没有数据迁移缺口。
|
||||
CI 同时禁止重新加入 generated native contracts 或 materialization 入口。
|
||||
|
||||
#### CommandRegistry 输入审计
|
||||
#### Identity 输入审计(registry 已退役)
|
||||
|
||||
`schema_command_registry.json` 是 reviewed source,不是生成快照。它必须保留
|
||||
`$schema: ./schema_command_registry.schema.json`。该 JSON Schema 对 root、product
|
||||
和 CommandSpec 全部使用 `additionalProperties: false`,并约束:
|
||||
历史上 identity 来自 reviewed `schema_command_registry/`(`registry.json` +
|
||||
`products/*.json`,由随附 JSON Schema 校验)。该 reviewed registry 已退役,
|
||||
改由 identity collector 承接:identity 现在由 `CollectIdentitySpecs` 从 live
|
||||
Cobra 树的 `ContractFinal.Identity` 声明收集,输入契约即声明本身。严格 Go
|
||||
索引(`indexCommandSpecs`)继续 fail-closed 校验:
|
||||
|
||||
- canonical identity、`source_product_id` 和精确 CLI path 的格式;
|
||||
- `aliases` 唯一且不能复用 primary path;
|
||||
- `visibility` 只允许 `public | compat | internal`,省略时明确归一化为
|
||||
`public`;
|
||||
- primary path、alias、canonical 和 product 之间无法由 JSON Schema 表达的
|
||||
交叉约束,继续由 Go strict loader 和 Cobra binder fail-closed 校验。
|
||||
- primary path、alias、canonical 和 product 之间的交叉唯一性约束。
|
||||
|
||||
Registry semantic hash 覆盖 canonical、primary CLI path、alias 集合、
|
||||
Registry semantic hash 仍覆盖 canonical、primary CLI path、alias 集合、
|
||||
`source_product_id` 和 normalized visibility。格式、顺序以及省略的等价默认值
|
||||
不改变 hash;上述任一稳定契约字段变化都必须改变 hash。测试逐字段验证这一点,
|
||||
不使用当前命令数量作为常量。
|
||||
|
||||
普通 `go generate ./internal/cli` 只把 Registry 作为 validation-only 输入并生成
|
||||
Agent metadata/Catalog 等单向下游资产,不生成或覆盖 Registry。drift policy 在生成
|
||||
前后对 reviewed Registry 做 byte-for-byte guard;独立的
|
||||
`check-schema-command-registry.sh` 在 interface/provenance/Catalog policy 之前检查
|
||||
JSON 输入契约、禁用旧 native materialization 符号,并从 Registry 动态计算审计
|
||||
数量,不能硬编码某次快照的 tool count。
|
||||
普通 `go generate ./internal/cli` 只生成
|
||||
`param_aliases_generated.go`;production Catalog / `ResolveMeta` 由 runtime
|
||||
`ResolveSchemaBuild` 装配(`deliverySchemaCatalog` Once 后缓存 Meta 投影)。
|
||||
`cmd_schema_catalog` 仅按需打 CI/local dump;其 `InstallBuildTimeAgentMetadataJSON`
|
||||
inject 仅服务 dump,生产 Agent 权威仍是 leaf `ContractFinal` / `ProductDecl`。
|
||||
不写也不 embed `schema_agent_metadata/`。drift policy 禁止已退役的
|
||||
`schema_command_registry/` 在生成前后重新出现;原独立脚本
|
||||
`check-schema-command-registry.sh` 的 registry-agnostic side guards(禁用旧
|
||||
native materialization 符号、go:generate 单轨、lazy loader 纪律)已迁入
|
||||
`check-schema-catalog.sh`。
|
||||
|
||||
### 5.2 Parameter
|
||||
|
||||
@@ -186,15 +195,17 @@ JSON 输入契约、禁用旧 native materialization 符号,并从 Registry
|
||||
实现中的参数字段顺序固定为:
|
||||
|
||||
```text
|
||||
reviewed manual > versioned binding > command constraint > typed metadata
|
||||
> native/Cobra contract > ToolSchemaHint > MCP metadata
|
||||
> inference/default
|
||||
versioned binding > command constraint > typed metadata
|
||||
> native/Cobra contract (ParamDecl / ContractFinal)
|
||||
> MCP metadata > inference/default
|
||||
```
|
||||
|
||||
命令 `title` / `description` 使用独立但同样确定的文本顺序:
|
||||
|
||||
```text
|
||||
reviewed ToolSchemaHint > command-specific Cobra Help > MCP metadata > inference
|
||||
description: Cobra Long > ContractDecl description (contract_final) > MCP metadata > inference
|
||||
(Long 胜出时 provenance = cobra_help / cobra_help_preferred)
|
||||
title: ContractDecl / ContractFinal > Cobra Short > MCP metadata > inference
|
||||
```
|
||||
|
||||
因此多个 CLI leaf 复用同一个 RPC 时,通用 RPC 文案只能作为未选中的
|
||||
@@ -252,31 +263,24 @@ dws schema --all # 所有工具的完整 leaf 导
|
||||
|
||||
## 8. 生成与发布
|
||||
|
||||
当 Cobra、flag、identity、binding、manual hint、Agent hint 或 Skill 发生变化时:
|
||||
当 Cobra、flag、identity、binding、leaf `Contract` / ProductDecl 或 Skill 发生变化时:
|
||||
|
||||
1. 审核真实 Cobra 变化,确认命令和 flag 已实际存在。新增或修改稳定 command identity、primary CLI path 或 alias 时,精确编辑 reviewed `CommandRegistry`(当前持久化文件为 `schema_command_registry.json`)。参数、Skill 或 metadata 单独变化时不要机械改写 Registry,也不要从旧 Catalog 反向生成它。
|
||||
2. 仅对明确例外使用 reviewed manual command addition;它必须精确引用现有 runnable leaf、带 reason,并在生成时归一化进 `EffectiveCommandRegistry`。Native identity annotation 若存在,应作为与 Registry 一致的实现断言维护,而不是用来 materialize identity。
|
||||
3. 生成 Agent metadata:
|
||||
|
||||
```bash
|
||||
make generate-schema-agent-metadata
|
||||
```
|
||||
|
||||
4. 从统一 typed registry 生成最终 Catalog:
|
||||
1. 审核真实 Cobra 变化,确认命令和 flag 已实际存在。新增或修改稳定 command identity、primary CLI path 或 alias 现在通过 leaf Contract 的 `ContractFinal.Identity` 声明完成(identity collector 据此收集;reviewed `schema_command_registry/` 已退役)。参数、Skill 或 metadata 单独变化时不要机械改写 identity 声明,也不要从旧 Catalog 反向生成它。
|
||||
2. 不应进入稳定 Agent 契约的 public runnable leaf 使用 reviewed exclusions(exact path、带 reason)。Native identity annotation 若存在,应作为与 identity 声明一致的实现断言维护,而不是用来 materialize identity。
|
||||
3. 生成参数别名并验证运行时 Schema 组装。`go generate ./internal/cli` 只运行 `cmd_param_aliases`;`cmd_schema_catalog` 仅按需生成 CI/local dump。生产权威为 leaf `ContractFinal` / `ProductDecl`;CI dump 可经 `agent_metadata_inject.go` / `InstallBuildTimeAgentMetadataJSON` 在内存中注入 Agent metadata,不写 `schema_agent_metadata/`:
|
||||
|
||||
```bash
|
||||
make generate-schema
|
||||
go generate ./internal/cli
|
||||
# 可选:生成 artifacts/ 下的 CI/local dump
|
||||
make generate-schema-catalog
|
||||
```
|
||||
|
||||
也可以运行 `go generate ./internal/cli` 生成正常发布资产。生成文件包括:
|
||||
`cmd_schema_agent_metadata` 可保留为非交付工具/测试,但不是 `go:generate` 入口,也不应再作为发布步骤。
|
||||
|
||||
- `internal/cli/schema_agent_metadata/index.json`
|
||||
- `internal/cli/schema_agent_metadata/<product>.json`
|
||||
- `internal/cli/schema_agent_metadata_audit.json`
|
||||
- `internal/cli/schema_catalog/catalog.json`(全局信封 + Catalog)
|
||||
- `internal/cli/schema_catalog/tools/<product>.json`(每产品 leaf ToolSpecs,按产品分片以免并发 PR 冲突)
|
||||
生成文件只有 `internal/cli/param_aliases_generated.go`(参数别名生成物)。`cmd_schema_catalog` 的 Catalog 和 meta-index 是可选 CI/local dump,不是交付物,且不得写入或提交到 `internal/cli/`。
|
||||
|
||||
只编辑来源;不要手工编辑 Agent metadata 或 Catalog 输出。
|
||||
`schema_agent_metadata/`、`schema_agent_metadata_audit.json` 与 `schema_hints/` 已退役;若存在则 policy 失败。只编辑来源;不要手工编辑或提交 Catalog / meta-index dump。
|
||||
|
||||
## 9. Completeness 与 final-delivery invariant
|
||||
|
||||
@@ -284,19 +288,19 @@ dws schema --all # 所有工具的完整 leaf 导
|
||||
|
||||
- 每个 public runnable Cobra leaf 要么能通过最终 embedded `SchemaIndex` 查询,要么有 exact、reviewed、带 reason 的 exclusion。
|
||||
- 每个最终 canonical path、primary CLI path 和 alias 都必须解析到同一个可执行 leaf;不得有 phantom path 或 collision。
|
||||
- `EffectiveCommandRegistry`、`SchemaRegistry/SchemaIndex`、Agent metadata 和 Catalog canonical sets 必须精确一致,不能只比较 count。
|
||||
- `EffectiveCommandRegistry`、`SchemaRegistry/SchemaIndex` 与 Catalog canonical sets 必须精确一致(含组装时内存 inject 的 Agent metadata 语义),不能只比较 count。
|
||||
- Leaf payload、`--all` 中对应 tool 和 Catalog full tool 必须是同一个 resolved `ToolSpec` 的内容级等价投影,并通过 production loader round-trip。
|
||||
- overview/product/group summary 与 Catalog summary 必须等于同一个 `ToolSpec.ToSummaryPayload()`;alias 查询只允许 `cli_path` 和 `is_alias` 这两个视图字段变化。
|
||||
- 每个最终字段及 parameter field 的 provenance winner value 必须与 delivered value 精确一致;不能只验证 provenance source、count 或字段是否存在。
|
||||
- 每个 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。
|
||||
|
||||
推荐本地验证:
|
||||
|
||||
```bash
|
||||
make generate-schema-agent-metadata
|
||||
make generate-schema-catalog
|
||||
make generate-schema
|
||||
./scripts/policy/check-generated-drift.sh
|
||||
./scripts/policy/check-schema-catalog.sh
|
||||
go test ./internal/cli ./internal/app ./internal/generator/... -count=1
|
||||
@@ -306,8 +310,12 @@ go test ./internal/cli ./internal/app ./internal/generator/... -count=1
|
||||
|
||||
- 运行时调用 MCP `tools/list` 或访问网络生成 Schema。
|
||||
- 从 `schema_catalog/` 等生成 JSON 反向创建/补齐 Cobra leaf、flag、CommandRegistry 或下一轮 Catalog。
|
||||
- 重新引入 `schema_agent_metadata/`(或 audit JSON)作为交付物、`go:embed` 目标,或把它写回 `go:generate` 入口。
|
||||
- 从 MCP meta / 已退役的 `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 可用。
|
||||
- 让 ProductDecl / leaf `Contract`、CommandRegistry 或 interface metadata 宣称一个不存在的命令、flag 或 RPC 可用。
|
||||
- 重新引入 `schema_hints/`(含 selection/metadata/imported/audit JSON)或任何 HintFile overlay,并在与 Contract/cobra 冲突时赢得 type/required/default。
|
||||
- 把 `schema --all` 当作普通业务数据查询,或把其完整结果无条件注入 Agent 上下文。
|
||||
- 将 LeafSpec、`+shortcut` 或 write-guard/cursor/多步命令注册为 `mcp_passthrough` 表面。
|
||||
|
||||
@@ -63,18 +63,18 @@
|
||||
|---|:---:|---|
|
||||
| covered-1to1 | 144 | lark 组合在钉钉塌缩成 1:1,封装层已覆盖 |
|
||||
| no-dingtalk-tool | 127 | 钉钉无对应工具,客观不可对齐 |
|
||||
| **gap-buildable** | **42** | 钉钉有工具、值得补成智能 shortcut(**建设目标**);已建 minutes `+detail`/`+replace-batch`、base `+record-share-links`/`+resolve-base`、im `+thread-replies`/`+chat-messages`、task `+related-tasks` |
|
||||
| covered-smart | 48 | 已建智能 shortcut / 部分覆盖 |
|
||||
| **gap-buildable** | **41** | 钉钉有工具、值得补成智能 shortcut(**建设目标**);已建 minutes `+detail`/`+replace-batch`、base `+record-share-links`/`+resolve-base`、im `+thread-replies`/`+chat-messages`/`+chat-list`、task `+related-tasks` |
|
||||
| covered-smart | 49 | 已建智能 shortcut / 部分覆盖 |
|
||||
|
||||
## 🎯 gap-buildable 目标清单(原 49 条,已建 7 → 剩 42,按服务)
|
||||
## 🎯 gap-buildable 目标清单(原 49 条,已建 8 → 剩 41,按服务)
|
||||
|
||||
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
|
||||
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、im `+chat-list`(✅ smart `chat +chat-list`)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
|
||||
|
||||
### im → chat(6)
|
||||
### im → chat(5)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+chat-list` | read | dws 有 list-my-groups/list-all-conversations 原子 tool,但无 types 枚举+bot剥p2p降级、无 exclude-muted 客户端过滤、无字段投影 |
|
||||
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` 当前页过滤 + `--exclude-muted` + page-size/page-token 别名 + openConversationId/name/conversationType 投影。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
|
||||
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊 list_conversation_message_v2 / list_individual_chat_message 互斥 + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
|
||||
| `+chat-search` | read | dws 无群名模糊搜索v2对应 tool(search_common_groups/find 语义不同),缺 query规范化、mode映射、mute过滤、meta投影 |
|
||||
| `+messages-resources-download` | write | dws download-media 走 get_resource_download_url 拿URL,缺分片Range下载/重试/扩展名推断/安全落盘路径校验 |
|
||||
@@ -176,7 +176,7 @@
|
||||
|
||||
## 已建智能 shortcut(covered-smart,48)— 可继续升级保真度
|
||||
|
||||
- **im**: +chat-members-list +messages-send +threads-messages-list
|
||||
- **im**: +chat-members-list +chat-list +messages-send +threads-messages-list
|
||||
- **task**: +complete +assign +get-my-tasks +get-related-tasks
|
||||
- **contact**: +search-user
|
||||
- **calendar**: +agenda +create +update +freebusy +suggestion
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -140,7 +140,7 @@ func TestCrossPlatformCoverageDirectRuntimeRemainingCoverage(t *testing.T) {
|
||||
products := map[string]bool{}
|
||||
aliases := map[string]string{}
|
||||
tools := map[string]string{}
|
||||
registerDynamicServer(mcptypes.ServerDescriptor{CLI: mcptypes.CLIOverlay{Skip: true}}, endpoints, products, aliases, tools)
|
||||
registerDynamicServer(mcptypes.ServerDescriptor{CLI: mcptypes.CLIOverlay{Skip: true}}, endpoints, products, aliases, tools, false)
|
||||
registerDynamicServer(mcptypes.ServerDescriptor{
|
||||
Endpoint: "https://server.test",
|
||||
CLI: mcptypes.CLIOverlay{
|
||||
@@ -148,7 +148,7 @@ func TestCrossPlatformCoverageDirectRuntimeRemainingCoverage(t *testing.T) {
|
||||
Tools: []mcptypes.CLITool{{Name: "tool"}, {Name: " "}},
|
||||
ToolOverrides: map[string]mcptypes.CLIToolOverride{"override": {}, " ": {}},
|
||||
},
|
||||
}, endpoints, products, aliases, tools)
|
||||
}, endpoints, products, aliases, tools, false)
|
||||
if endpoints["command"] == "" || aliases["alias"] != "id" || tools["override"] == "" {
|
||||
t.Fatalf("registered dynamic server = %#v %#v %#v", endpoints, aliases, tools)
|
||||
}
|
||||
@@ -200,7 +200,7 @@ func TestCrossPlatformCoverageDirectRuntimeRemainingCoverage(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
if got, ok := directRuntimeToolEndpoint("append-override"); !ok || got != "https://append.test" {
|
||||
if got, ok := directRuntimeEndpoint("", "append-override"); !ok || got != "https://append.test" {
|
||||
t.Fatalf("append override endpoint = %q, %v", got, ok)
|
||||
}
|
||||
}
|
||||
@@ -274,7 +274,7 @@ func TestCrossPlatformCoverageEmbeddedSkillAndTinyCommandsRemainingCoverage(t *t
|
||||
if err := completion.RunE(completion, []string{"other"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog := newCatalogCommand(nil)
|
||||
catalog := newCatalogCommand()
|
||||
catalog.SetOut(io.Discard)
|
||||
if err := catalog.RunE(catalog, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -12,7 +12,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -26,6 +29,20 @@ var (
|
||||
)
|
||||
|
||||
func newAuditCommand() *cobra.Command {
|
||||
// Product-level Agent routing Decl (migrated from selection/audit.json
|
||||
// products.audit). Catalog assembly stamps provenance contract_final.
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "audit",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "查看、导出和校验本地操作审计日志",
|
||||
UseWhen: []string{
|
||||
"需要排查本机 CLI 操作审计记录,或验证审计文件完整性",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"查钉钉业务数据或发消息请用对应产品命令,不要用 audit",
|
||||
},
|
||||
},
|
||||
})
|
||||
cmd := &cobra.Command{
|
||||
Use: "audit",
|
||||
Short: "操作审计日志管理",
|
||||
@@ -64,6 +81,39 @@ func newAuditTailCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVarP(&n, "lines", "n", 20, "显示最近 N 条记录")
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "audit",
|
||||
Name: "tail",
|
||||
CanonicalPath: "audit.tail",
|
||||
CLIPath: "audit tail",
|
||||
PrimaryCLIPath: "audit tail",
|
||||
},
|
||||
Description: "查看本地操作审计日志最近 N 条记录",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "local",
|
||||
Availability: "available",
|
||||
Reason: "命令读取本地审计日志尾部,不绑定 pinned MCP RPC",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
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 +149,39 @@ 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: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "audit",
|
||||
Name: "export",
|
||||
CanonicalPath: "audit.export",
|
||||
CLIPath: "audit export",
|
||||
PrimaryCLIPath: "audit export",
|
||||
},
|
||||
Description: "按日期范围导出本地操作审计日志(jsonl 或 csv)",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "local",
|
||||
Availability: "available",
|
||||
Reason: "命令读取并导出本地审计日志文件,不绑定 pinned MCP RPC",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
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 +235,36 @@ func newAuditVerifyCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&file, "file", "", "指定审计文件路径(默认最新文件)")
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "audit",
|
||||
Name: "verify",
|
||||
CanonicalPath: "audit.verify",
|
||||
CLIPath: "audit verify",
|
||||
PrimaryCLIPath: "audit verify",
|
||||
},
|
||||
Description: "校验本地审计日志文件的哈希链完整性",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "local",
|
||||
Availability: "available",
|
||||
Reason: "命令校验本地审计日志哈希链,不绑定 pinned MCP RPC",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "校验本地审计日志文件的哈希链完整性",
|
||||
UseWhen: []string{"怀疑审计文件被篡改,或需要确认最新/指定文件哈希链是否完整"},
|
||||
AvoidWhen: []string{"只浏览或导出日志内容时用 audit tail / audit export"},
|
||||
Examples: []string{
|
||||
"dws audit verify",
|
||||
"dws audit verify --file /path/to/audit.jsonl",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newCatalogCommand(_ cli.CatalogLoader) *cobra.Command {
|
||||
func newCatalogCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "catalog",
|
||||
Short: "查看服务目录 (静态端点模式)",
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"time"
|
||||
|
||||
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"
|
||||
eventbus "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
@@ -288,20 +287,18 @@ func TestCrossPlatformCoverageRecoveryPureCoverage(t *testing.T) {
|
||||
if _, err := (*recoveryRuntime)(nil).CallToolDirect(context.Background(), "x", "y", nil); err == nil {
|
||||
t.Fatal("nil recovery runtime call succeeded")
|
||||
}
|
||||
if _, err := (&recoveryRuntime{}).resolveEndpoint(context.Background(), "missing", "tool"); err == nil {
|
||||
t.Fatal("missing recovery endpoint succeeded")
|
||||
}
|
||||
loaderErr := errors.New("catalog")
|
||||
runtime := &recoveryRuntime{loader: cli.CatalogLoaderFrom(cli.Catalog{}, loaderErr)}
|
||||
if _, err := runtime.resolveEndpoint(context.Background(), "missing", "tool"); !errors.Is(err, loaderErr) {
|
||||
t.Fatalf("catalog recovery error = %v", err)
|
||||
}
|
||||
runtime.loader = cli.StaticLoader{Catalog: cli.Catalog{Products: []cli.CanonicalProduct{{ID: "empty"}, {ID: "ok", Endpoint: " https://catalog.test "}}}}
|
||||
if _, err := runtime.resolveEndpoint(context.Background(), "empty", "tool"); err == nil {
|
||||
t.Fatal("empty catalog endpoint succeeded")
|
||||
if _, err := (&recoveryRuntime{}).resolveEndpoint(context.Background(), "missing", "tool"); err == nil || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) {
|
||||
t.Fatalf("missing recovery endpoint error = %v", err)
|
||||
} else {
|
||||
var apiErr *apperrors.Error
|
||||
if !errors.As(err, &apiErr) || apiErr.Category != apperrors.CategoryAPI || apiErr.Operation != "discovery.resolve" || apiErr.Reason != "endpoint_not_resolved" {
|
||||
t.Fatalf("missing recovery endpoint classification = %#v", err)
|
||||
}
|
||||
}
|
||||
t.Setenv("DINGTALK_OK_MCP_URL", " https://catalog.test ")
|
||||
runtime := &recoveryRuntime{}
|
||||
if got, err := runtime.resolveEndpoint(context.Background(), "ok", "tool"); err != nil || got != "https://catalog.test" {
|
||||
t.Fatalf("catalog endpoint = %q %v", got, err)
|
||||
t.Fatalf("recovery endpoint override = %q %v", got, err)
|
||||
}
|
||||
if recoveryRuntimeToken(nil) != "" || recoveryRuntimeToken(&GlobalFlags{Token: " token "}) != "token" {
|
||||
t.Fatal("recovery token mismatch")
|
||||
@@ -451,11 +448,14 @@ func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
|
||||
},
|
||||
}
|
||||
SetDynamicServers([]mcptypes.ServerDescriptor{{CLI: mcptypes.CLIOverlay{Skip: true}}, server, {CLI: mcptypes.CLIOverlay{ID: "empty"}}})
|
||||
if got, ok := directRuntimeToolEndpoint("tool"); !ok || got != "https://one.test" {
|
||||
if got, ok := directRuntimeEndpoint("unknown", "tool"); !ok || got != "https://one.test" {
|
||||
t.Fatalf("tool endpoint = %q %v", got, ok)
|
||||
}
|
||||
if _, ok := directRuntimeToolEndpoint(" "); ok {
|
||||
t.Fatal("blank tool endpoint resolved")
|
||||
if got, ok := directRuntimeEndpoint("unknown", "override"); !ok || got != "https://one.test" {
|
||||
t.Fatalf("tool override endpoint = %q %v", got, ok)
|
||||
}
|
||||
if _, ok := directRuntimeEndpoint("unknown", "skip"); ok {
|
||||
t.Fatal("server-override tool endpoint resolved")
|
||||
}
|
||||
for _, id := range []string{"one", "cmd", "alias"} {
|
||||
if got, ok := directRuntimeEndpoint(id, ""); !ok || got != "https://one.test" {
|
||||
@@ -469,11 +469,6 @@ func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
|
||||
t.Fatalf("direct runtime IDs = %#v", ids)
|
||||
}
|
||||
|
||||
t.Setenv(cli.CatalogFixtureEnv, "fixture")
|
||||
if shouldUseDirectRuntime(executor.Invocation{Kind: "helper_invocation"}) {
|
||||
t.Fatal("fixture should disable direct runtime")
|
||||
}
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
if !shouldUseDirectRuntime(executor.Invocation{Kind: "helper_invocation"}) || !shouldUseDirectRuntime(executor.Invocation{Kind: "compat_invocation"}) || shouldUseDirectRuntime(executor.Invocation{}) {
|
||||
t.Fatal("direct runtime kind mismatch")
|
||||
}
|
||||
@@ -798,7 +793,6 @@ func TestCrossPlatformCoverageRuntimeRunnerRoutingCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
r.transport = transport.NewClient(nil)
|
||||
r.loader = cli.StaticLoader{}
|
||||
r.globalFlags = &GlobalFlags{Mock: true}
|
||||
if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["content"] == nil {
|
||||
t.Fatalf("mock route = %#v %v", got, err)
|
||||
@@ -809,18 +803,16 @@ func TestCrossPlatformCoverageRuntimeRunnerRoutingCoverage(t *testing.T) {
|
||||
}
|
||||
t.Setenv("DINGTALK_PRODUCT_MCP_URL", "")
|
||||
|
||||
// Dry-run is an execution barrier enforced by Run before endpoint
|
||||
// resolution, so a dry-run invocation returns a local preview and never
|
||||
// reaches handleCatalogMiss or the fallback runner.
|
||||
r.globalFlags = &GlobalFlags{DryRun: true}
|
||||
r.loader = cli.CatalogLoaderFrom(cli.Catalog{}, errors.New("load failure"))
|
||||
if _, err := r.runSingle(context.Background(), inv, false); err == nil || !strings.Contains(err.Error(), "load failure") {
|
||||
t.Fatalf("catalog failure = %v", err)
|
||||
}
|
||||
r.loader = cli.StaticLoader{}
|
||||
if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["fallback"] != true || !fallback.last.DryRun {
|
||||
t.Fatalf("dry catalog miss = %#v %v (invocation %#v)", got, err, fallback.last)
|
||||
if got, err := r.Run(context.Background(), inv); err != nil || got.Response["dry_run"] != true || got.Response["fallback"] != nil {
|
||||
t.Fatalf("dry endpoint miss = %#v %v", got, err)
|
||||
}
|
||||
r.globalFlags = &GlobalFlags{}
|
||||
if _, err := r.runSingle(context.Background(), inv, false); err == nil {
|
||||
t.Fatal("catalog miss succeeded")
|
||||
if _, err := r.runSingle(context.Background(), inv, false); err == nil || !strings.Contains(err.Error(), "no dynamic endpoint registered for product or tool") {
|
||||
t.Fatalf("endpoint miss = %v", err)
|
||||
}
|
||||
devInv := inv
|
||||
devInv.CanonicalProduct = devappProductID
|
||||
@@ -828,29 +820,20 @@ func TestCrossPlatformCoverageRuntimeRunnerRoutingCoverage(t *testing.T) {
|
||||
t.Fatal("devapp catalog miss succeeded")
|
||||
}
|
||||
|
||||
product := cli.CanonicalProduct{ID: "product", Endpoint: "https://catalog.test", Tools: []cli.ToolDescriptor{{RPCName: "tool"}}}
|
||||
r.loader = cli.StaticLoader{Catalog: cli.Catalog{Products: []cli.CanonicalProduct{product}}}
|
||||
r.globalFlags = &GlobalFlags{DryRun: true}
|
||||
if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["endpoint"] != "https://catalog.test" {
|
||||
t.Fatalf("catalog dry route = %#v %v", got, err)
|
||||
}
|
||||
product.Tools = nil
|
||||
r.loader = cli.StaticLoader{Catalog: cli.Catalog{Products: []cli.CanonicalProduct{product}}}
|
||||
SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: "https://direct.test", CLI: mcptypes.CLIOverlay{ID: "product", Tools: []mcptypes.CLITool{{Name: "tool"}}}}})
|
||||
t.Cleanup(func() { SetDynamicServers(nil) })
|
||||
if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["endpoint"] != "https://direct.test" {
|
||||
t.Fatalf("undeclared tool direct route = %#v %v", got, err)
|
||||
directInv := inv
|
||||
directInv.Kind = "helper_invocation"
|
||||
directInv.DryRun = true
|
||||
if got, err := r.runSingle(context.Background(), directInv, false); err != nil || got.Response["dry_run"] != true || got.Response["endpoint"] != "https://direct.test" {
|
||||
t.Fatalf("direct runtime route = %#v %v", got, err)
|
||||
}
|
||||
SetDynamicServers(nil)
|
||||
if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["fallback"] != true || !fallback.last.DryRun {
|
||||
t.Fatalf("undeclared tool dry miss = %#v %v (invocation %#v)", got, err, fallback.last)
|
||||
}
|
||||
|
||||
SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: "https://owner.test", CLI: mcptypes.CLIOverlay{ID: "owner", Tools: []mcptypes.CLITool{{Name: "tool"}}}}})
|
||||
product.Tools = []cli.ToolDescriptor{{RPCName: "tool"}}
|
||||
r.loader = cli.StaticLoader{Catalog: cli.Catalog{Products: []cli.CanonicalProduct{product}}}
|
||||
if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["endpoint"] != "https://owner.test" {
|
||||
t.Fatalf("tool owner correction = %#v %v", got, err)
|
||||
// With no dynamic endpoint registered, a dry-run helper invocation is still
|
||||
// stopped by the Run barrier before endpoint resolution.
|
||||
r.globalFlags = &GlobalFlags{DryRun: true}
|
||||
if got, err := r.Run(context.Background(), directInv); err != nil || got.Response["dry_run"] != true || got.Response["fallback"] != nil {
|
||||
t.Fatalf("direct runtime dry miss = %#v %v", got, err)
|
||||
}
|
||||
|
||||
for _, result := range []executor.Result{{}, {Response: map[string]any{"content": "value"}}, {Response: map[string]any{"value": 1}}} {
|
||||
@@ -961,7 +944,7 @@ func TestCrossPlatformCoverageExecuteInvocationCoverage(t *testing.T) {
|
||||
t.Fatalf("stdio dry-run = %#v %v", got, err)
|
||||
}
|
||||
stdioInv.DryRun = false
|
||||
if _, err := r.executeStdioInvocation(context.Background(), stdioInv); err == nil {
|
||||
if _, err := r.executeStdioInvocationAtEndpoint(context.Background(), "", stdioInv); err == nil {
|
||||
t.Fatal("missing stdio client succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,11 +298,11 @@ func TestCrossPlatformCoverageRootUtilityAndTimingCoverage(t *testing.T) {
|
||||
deduplicateCommands(dedupRoot)
|
||||
addPluginCommandsSafe(dedupRoot, []*cobra.Command{{Use: "same"}, {Use: "new"}})
|
||||
_ = newCompletionCommand(dedupRoot)
|
||||
_ = newCatalogCommand(nil)
|
||||
_ = newCatalogCommand()
|
||||
_ = newConfigCommand()
|
||||
_ = newCacheCommand()
|
||||
_ = newVersionCommand()
|
||||
_ = newRecoveryCommand(context.Background(), nil, &GlobalFlags{})
|
||||
_ = newRecoveryCommand(&GlobalFlags{})
|
||||
_ = newAPICommand(&GlobalFlags{})
|
||||
_ = NewRootCommand(context.Background())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageTokenResolutionErrorBranches(t *testing.T) {
|
||||
if err := tokenResolutionError(nil); err != nil {
|
||||
t.Fatalf("nil error must pass through: %v", err)
|
||||
}
|
||||
if err := tokenResolutionError(context.Canceled); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("context cancellation must pass through unwrapped: %v", err)
|
||||
}
|
||||
if err := tokenResolutionError(context.DeadlineExceeded); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("deadline exceeded must pass through unwrapped: %v", err)
|
||||
}
|
||||
if err := tokenResolutionError(authpkg.ErrTokenDataNotFound); err == nil ||
|
||||
!strings.Contains(err.Error(), "dws auth login") {
|
||||
t.Fatalf("not-found must become a login guidance error: %v", err)
|
||||
}
|
||||
generic := errors.New("keychain locked")
|
||||
if err := tokenResolutionError(generic); err == nil ||
|
||||
!strings.Contains(err.Error(), "resolve access token") || !errors.Is(err, generic) {
|
||||
t.Fatalf("generic error must wrap with resolve access token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProfileSwitchCellsSameCorp(t *testing.T) {
|
||||
profile := authpkg.Profile{CorpID: "corp-1", CorpName: "钉钉", UserName: "Alice", UserID: "u1"}
|
||||
cfg := &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{
|
||||
profile,
|
||||
{CorpID: "corp-1", CorpName: "钉钉", UserName: "Bob", UserID: "u2"},
|
||||
}}
|
||||
org, _ := profileSwitchProfileCells(profile, cfg)
|
||||
if !strings.Contains(org, " / Alice") {
|
||||
t.Fatalf("same-corp label must disambiguate with user name: %q", org)
|
||||
}
|
||||
|
||||
noName := authpkg.Profile{CorpID: "corp-1", CorpName: "钉钉", UserID: "u1"}
|
||||
org, _ = profileSwitchProfileCells(noName, cfg)
|
||||
if !strings.Contains(org, " / u1") {
|
||||
t.Fatalf("blank user name must fall back to user id: %q", org)
|
||||
}
|
||||
|
||||
blankUser := authpkg.Profile{CorpID: "corp-1", CorpName: "钉钉"}
|
||||
org, _ = profileSwitchProfileCells(blankUser, cfg)
|
||||
if strings.Contains(org, " / ") {
|
||||
t.Fatalf("blank user name and id must not append a suffix: %q", org)
|
||||
}
|
||||
|
||||
org, _ = profileSwitchProfileCells(profile, nil)
|
||||
if strings.Contains(org, " / ") {
|
||||
t.Fatalf("nil config must not append a suffix: %q", org)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthRefreshRetryHelpers(t *testing.T) {
|
||||
var nilErr *authRefreshFailureError
|
||||
if got := nilErr.Unwrap(); got != nil {
|
||||
t.Fatalf("nil receiver Unwrap = %v, want nil", got)
|
||||
}
|
||||
rejection := errors.New("rejected")
|
||||
refresh := errors.New("refresh boom")
|
||||
wrapped := (&authRefreshFailureError{rejection: rejection, refresh: refresh}).Unwrap()
|
||||
if len(wrapped) != 2 || !errors.Is(wrapped[0], rejection) || !errors.Is(wrapped[1], refresh) {
|
||||
t.Fatalf("Unwrap must surface both causes: %v", wrapped)
|
||||
}
|
||||
|
||||
if ctx := withAuthRetrying(nil); ctx == nil || !IsAuthRetrying(ctx) {
|
||||
t.Fatal("withAuthRetrying(nil) must produce a retry-marked background context")
|
||||
}
|
||||
|
||||
var nilRunner *runtimeRunner
|
||||
if nilRunner.managesRuntimeOAuth(false) {
|
||||
t.Fatal("nil runner must not manage runtime OAuth")
|
||||
}
|
||||
if (&runtimeRunner{}).managesRuntimeOAuth(true) {
|
||||
t.Fatal("plugin-owned auth must opt out of runtime OAuth management")
|
||||
}
|
||||
if !(&runtimeRunner{}).managesRuntimeOAuth(false) {
|
||||
t.Fatal("nil global flags must manage runtime OAuth")
|
||||
}
|
||||
if !(&runtimeRunner{globalFlags: &GlobalFlags{}}).managesRuntimeOAuth(false) {
|
||||
t.Fatal("blank token must manage runtime OAuth")
|
||||
}
|
||||
if (&runtimeRunner{globalFlags: &GlobalFlags{Token: "tok"}}).managesRuntimeOAuth(false) {
|
||||
t.Fatal("explicit token must not manage runtime OAuth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageForceRefreshRejectedGuards(t *testing.T) {
|
||||
if _, err := ForceRefreshAccessToken(context.Background(), " "); err == nil ||
|
||||
!strings.Contains(err.Error(), "config directory is empty") {
|
||||
t.Fatalf("blank config dir error = %v", err)
|
||||
}
|
||||
testseam.Swap(t, &loadRefreshTokenData, func(string) (*authpkg.TokenData, error) { return nil, nil })
|
||||
if _, err := ForceRefreshAccessToken(context.Background(), t.TempDir()); err == nil ||
|
||||
!strings.Contains(err.Error(), "stored access token is empty") {
|
||||
t.Fatalf("nil token data must report stored access token is empty: %v", err)
|
||||
}
|
||||
if _, err := forceRefreshRejectedAccessToken(context.Background(), " ", "tok"); err == nil ||
|
||||
!strings.Contains(err.Error(), "config directory is empty") {
|
||||
t.Fatalf("blank config dir error = %v", err)
|
||||
}
|
||||
if _, err := forceRefreshRejectedAccessToken(context.Background(), t.TempDir(), " "); err == nil ||
|
||||
!strings.Contains(err.Error(), "rejected access token is empty") {
|
||||
t.Fatalf("blank rejected token error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageGetCachedRuntimeTokenSeam(t *testing.T) {
|
||||
token, err := getCachedRuntimeToken(context.Background())
|
||||
if err != nil && strings.TrimSpace(token) != "" {
|
||||
t.Fatalf("failed token resolution must not return a token: %q / %v", token, err)
|
||||
}
|
||||
}
|
||||
+35
-120
@@ -16,12 +16,10 @@ package app
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
@@ -112,54 +110,9 @@ func SetDynamicServers(servers []mcptypes.ServerDescriptor) {
|
||||
products := make(map[string]bool)
|
||||
aliases := make(map[string]string)
|
||||
toolEndpoints := make(map[string]string)
|
||||
registerDynamicServer(defaultPATServerDescriptor(), endpoints, products, aliases, toolEndpoints)
|
||||
registerDynamicServer(defaultPATServerDescriptor(), endpoints, products, aliases, toolEndpoints, false)
|
||||
for _, server := range servers {
|
||||
if server.CLI.Skip {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(server.CLI.ID)
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
if id != "" && endpoint != "" {
|
||||
endpoints[id] = endpoint
|
||||
products[id] = true
|
||||
}
|
||||
cmd := strings.TrimSpace(server.CLI.Command)
|
||||
if cmd != "" && cmd != id && endpoint != "" {
|
||||
endpoints[cmd] = endpoint
|
||||
products[cmd] = true
|
||||
}
|
||||
for _, alias := range server.CLI.Aliases {
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias != "" && endpoint != "" {
|
||||
endpoints[alias] = endpoint
|
||||
products[alias] = true
|
||||
// Build alias → CLI.ID mapping
|
||||
aliases[alias] = id
|
||||
}
|
||||
}
|
||||
// Build tool → endpoint mapping from CLI tools and overrides.
|
||||
if endpoint != "" {
|
||||
for _, tool := range server.CLI.Tools {
|
||||
toolName := strings.TrimSpace(tool.Name)
|
||||
if toolName != "" {
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
for toolName, override := range server.CLI.ToolOverrides {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName == "" {
|
||||
continue
|
||||
}
|
||||
// Leaves with serverOverride are routed to a different server's
|
||||
// endpoint (e.g. chat's "search_my_robots" → bot). Registering
|
||||
// them here would overwrite the real owner's tool → endpoint
|
||||
// mapping and send the invocation to the wrong MCP URL.
|
||||
if strings.TrimSpace(override.ServerOverride) != "" {
|
||||
continue
|
||||
}
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
registerDynamicServer(server, endpoints, products, aliases, toolEndpoints, false)
|
||||
}
|
||||
dynamicEndpoints = endpoints
|
||||
dynamicProducts = products
|
||||
@@ -167,7 +120,13 @@ func SetDynamicServers(servers []mcptypes.ServerDescriptor) {
|
||||
dynamicToolEndpoints = toolEndpoints
|
||||
}
|
||||
|
||||
func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[string]string, products map[string]bool, aliases map[string]string, toolEndpoints map[string]string) {
|
||||
// registerDynamicServer is the shared dynamic-server registration core used by
|
||||
// SetDynamicServers and AppendDynamicServer. It records the descriptor's
|
||||
// endpoint under its ID, command name, and aliases, and builds the tool →
|
||||
// endpoint map from its CLI tools and overrides. With protectCommandKey=true
|
||||
// (AppendDynamicServer) an already-registered command-key endpoint is never
|
||||
// overwritten; with false the entry is replaced.
|
||||
func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[string]string, products map[string]bool, aliases map[string]string, toolEndpoints map[string]string, protectCommandKey bool) {
|
||||
if server.CLI.Skip {
|
||||
return
|
||||
}
|
||||
@@ -179,7 +138,13 @@ func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[strin
|
||||
}
|
||||
cmd := strings.TrimSpace(server.CLI.Command)
|
||||
if cmd != "" && cmd != id && endpoint != "" {
|
||||
endpoints[cmd] = endpoint
|
||||
if protectCommandKey {
|
||||
if _, exists := endpoints[cmd]; !exists {
|
||||
endpoints[cmd] = endpoint
|
||||
}
|
||||
} else {
|
||||
endpoints[cmd] = endpoint
|
||||
}
|
||||
products[cmd] = true
|
||||
}
|
||||
for _, alias := range server.CLI.Aliases {
|
||||
@@ -187,11 +152,11 @@ func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[strin
|
||||
if alias != "" && endpoint != "" {
|
||||
endpoints[alias] = endpoint
|
||||
products[alias] = true
|
||||
// Build alias -> CLI.ID mapping.
|
||||
// Build alias → CLI.ID mapping.
|
||||
aliases[alias] = id
|
||||
}
|
||||
}
|
||||
// Build tool -> endpoint mapping from CLI tools and overrides.
|
||||
// Build tool → endpoint mapping from CLI tools and overrides.
|
||||
if endpoint != "" {
|
||||
for _, tool := range server.CLI.Tools {
|
||||
toolName := strings.TrimSpace(tool.Name)
|
||||
@@ -199,19 +164,29 @@ func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[strin
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
for toolName := range server.CLI.ToolOverrides {
|
||||
for toolName, override := range server.CLI.ToolOverrides {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName != "" {
|
||||
toolEndpoints[toolName] = endpoint
|
||||
if toolName == "" {
|
||||
continue
|
||||
}
|
||||
// Leaves with serverOverride are routed to a different server's
|
||||
// endpoint (e.g. chat's "search_my_robots" → bot). Registering
|
||||
// them here would overwrite the real owner's tool → endpoint
|
||||
// mapping and send the invocation to the wrong MCP URL.
|
||||
if strings.TrimSpace(override.ServerOverride) != "" {
|
||||
continue
|
||||
}
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shouldUseDirectRuntime gates endpoint resolution to the only invocation
|
||||
// kinds the executor constructs: compat_invocation via
|
||||
// NewCompatibilityInvocation and helper_invocation via NewHelperInvocation.
|
||||
// Every other kind skips direct-runtime resolution and terminates at
|
||||
// handleCatalogMiss in the runner.
|
||||
func shouldUseDirectRuntime(invocation executor.Invocation) bool {
|
||||
if strings.TrimSpace(os.Getenv(cli.CatalogFixtureEnv)) != "" {
|
||||
return false
|
||||
}
|
||||
switch invocation.Kind {
|
||||
case "compat_invocation", "helper_invocation":
|
||||
return true
|
||||
@@ -220,22 +195,6 @@ func shouldUseDirectRuntime(invocation executor.Invocation) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// directRuntimeToolEndpoint returns the MCP endpoint owned by the server
|
||||
// whose toolOverrides registered this tool name. Used to correct catalog
|
||||
// lookups when two envelope servers share the same cli.command and the
|
||||
// per-product endpoint map collides (see runner.go cross-check).
|
||||
func directRuntimeToolEndpoint(toolName string) (string, bool) {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName == "" {
|
||||
return "", false
|
||||
}
|
||||
dynamicMu.RLock()
|
||||
defer dynamicMu.RUnlock()
|
||||
|
||||
endpoint, ok := dynamicToolEndpoints[toolName]
|
||||
return endpoint, ok && strings.TrimSpace(endpoint) != ""
|
||||
}
|
||||
|
||||
func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
// Priority 0: env-var override always wins (DINGTALK_<PRODUCT>_MCP_URL).
|
||||
normalized := normalizeDirectRuntimeProductID(productID)
|
||||
@@ -377,51 +336,7 @@ func AppendDynamicServer(server mcptypes.ServerDescriptor) {
|
||||
dynamicToolEndpoints = make(map[string]string)
|
||||
}
|
||||
|
||||
if server.CLI.Skip {
|
||||
return
|
||||
}
|
||||
|
||||
id := strings.TrimSpace(server.CLI.ID)
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
if id != "" && endpoint != "" {
|
||||
dynamicEndpoints[id] = endpoint
|
||||
dynamicProducts[id] = true
|
||||
}
|
||||
cmd := strings.TrimSpace(server.CLI.Command)
|
||||
if cmd != "" && cmd != id && endpoint != "" {
|
||||
if _, exists := dynamicEndpoints[cmd]; !exists {
|
||||
dynamicEndpoints[cmd] = endpoint
|
||||
}
|
||||
dynamicProducts[cmd] = true
|
||||
}
|
||||
for _, alias := range server.CLI.Aliases {
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias != "" && endpoint != "" {
|
||||
dynamicEndpoints[alias] = endpoint
|
||||
dynamicProducts[alias] = true
|
||||
dynamicAliases[alias] = id
|
||||
}
|
||||
}
|
||||
if endpoint != "" {
|
||||
for _, tool := range server.CLI.Tools {
|
||||
toolName := strings.TrimSpace(tool.Name)
|
||||
if toolName != "" {
|
||||
dynamicToolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
for toolName, override := range server.CLI.ToolOverrides {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName == "" {
|
||||
continue
|
||||
}
|
||||
// Leaves with serverOverride are routed to a different server's
|
||||
// endpoint; skip to avoid overwriting the real owner's mapping.
|
||||
if strings.TrimSpace(override.ServerOverride) != "" {
|
||||
continue
|
||||
}
|
||||
dynamicToolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
registerDynamicServer(server, dynamicEndpoints, dynamicProducts, dynamicAliases, dynamicToolEndpoints, true)
|
||||
}
|
||||
|
||||
func normalizeDirectRuntimeProductID(productID string) string {
|
||||
|
||||
+173
-40
@@ -29,9 +29,11 @@ import (
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
|
||||
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"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
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"
|
||||
@@ -73,6 +75,20 @@ var (
|
||||
// newEventCommand returns the `event` parent command and all its subcommands.
|
||||
// Wired into root.go's utilityCommands list.
|
||||
func newEventCommand() *cobra.Command {
|
||||
// Product-level Agent routing Decl (migrated from selection/event.json
|
||||
// products.event). Catalog assembly stamps provenance contract_final.
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "event",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "订阅/消费个人消息、动作与群生命周期事件,并管理订阅生命周期",
|
||||
UseWhen: []string{
|
||||
"需要实时监听个人消息接收、全量消息、已读、撤回、表情回应或群生命周期事件,或管理个人事件订阅生命周期",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"查历史聊天或主动发消息分别用 chat 查询/发送命令",
|
||||
},
|
||||
},
|
||||
})
|
||||
cmd := &cobra.Command{
|
||||
Use: "event",
|
||||
Short: "事件订阅 (DingTalk Stream 长连接)",
|
||||
@@ -353,7 +369,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
f.StringVar(&streamOpts.TicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")),
|
||||
"个人 Stream 取票 URL;默认由 MCP base 派生 /stream/connections/ticket")
|
||||
hideEventInternalFlags(cmd, "as")
|
||||
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
|
||||
cli.AnnotateRuntimePositionals(cmd, contract.RuntimeSchemaPositional{
|
||||
Name: "event_key",
|
||||
Type: "string",
|
||||
Description: "要消费的一个或多个个人事件码;多个事件必须共享同一目标和过滤上下文",
|
||||
@@ -361,6 +377,46 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
Variadic: true,
|
||||
Index: 0,
|
||||
})
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "event",
|
||||
Name: "consume",
|
||||
CanonicalPath: "event.consume",
|
||||
CLIPath: "event consume",
|
||||
PrimaryCLIPath: "event consume",
|
||||
},
|
||||
Description: "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
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: contract.SelectionSpec{
|
||||
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 +784,36 @@ 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: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "event",
|
||||
Name: "list",
|
||||
CanonicalPath: "event.list",
|
||||
CLIPath: "event list",
|
||||
PrimaryCLIPath: "event list",
|
||||
},
|
||||
Description: "列出支持的个人事件目录与状态说明",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "local",
|
||||
Availability: "available",
|
||||
Reason: "命令读取 CLI 内置的个人事件目录,不绑定 pinned MCP RPC",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
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 +882,33 @@ 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: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "event",
|
||||
Name: "status",
|
||||
CanonicalPath: "event.status",
|
||||
CLIPath: "event status",
|
||||
PrimaryCLIPath: "event status",
|
||||
},
|
||||
Description: "查看个人事件订阅、本地 bus 与消费进程状态",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
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: contract.SelectionSpec{
|
||||
AgentSummary: "查看个人事件订阅、本地 bus 与消费进程状态",
|
||||
UseWhen: []string{"需要确认订阅是否活跃、bus/consume 是否仍在运行"},
|
||||
AvoidWhen: []string{"取消订阅用 event stop;列事件目录用 event list"},
|
||||
Examples: []string{"dws event status --format json"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -1037,39 +1150,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 {
|
||||
@@ -1095,13 +1191,68 @@ func newEventStopCommand() *cobra.Command {
|
||||
"个人事件 sourceId;开源版默认 open,可由 edition 覆盖")
|
||||
cmd.Flags().BoolVar(&opts.All, "all", false, "取消当前身份下本地记录的所有个人订阅")
|
||||
hideEventInternalFlags(cmd, "as")
|
||||
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
|
||||
cli.AnnotateRuntimePositionals(cmd, contract.RuntimeSchemaPositional{
|
||||
Name: "subscribe_id",
|
||||
Type: "string",
|
||||
Description: "要取消的个人事件订阅 ID;与 --all 二选一",
|
||||
Required: false,
|
||||
Index: 0,
|
||||
})
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.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
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "event",
|
||||
Name: "stop",
|
||||
CanonicalPath: "event.stop",
|
||||
CLIPath: "event stop",
|
||||
PrimaryCLIPath: "event stop",
|
||||
},
|
||||
Description: "取消个人事件订阅并停止对应本地消费",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
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: contract.SelectionSpec{
|
||||
AgentSummary: "取消个人事件订阅并停止对应本地消费",
|
||||
UseWhen: []string{"用户明确要求取消已知 subscribe_id(或清理全部)并停止消费"},
|
||||
AvoidWhen: []string{
|
||||
"只需检查状态时用 event status",
|
||||
"目标订阅不明确或用户未确认时不要停止",
|
||||
},
|
||||
Examples: []string{"dws event stop SUBSCRIBE_ID --dry-run"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -1110,20 +1261,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,
|
||||
@@ -1274,7 +1411,3 @@ func eventTypesWithDefault(types []string) []string {
|
||||
}
|
||||
return registry.CatchAllEventTypes()
|
||||
}
|
||||
|
||||
// compile-time guard: avoid "imported and not used" if any of these
|
||||
// indirect imports become unused after future refactors.
|
||||
var _ = io.Discard
|
||||
|
||||
@@ -29,8 +29,11 @@ import (
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
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"
|
||||
@@ -170,13 +173,43 @@ func newEventSchemaCommand() *cobra.Command {
|
||||
cmd.Flags().StringVarP(&formatRaw, "format", "f", "json", "输出格式: json")
|
||||
cmd.Flags().BoolVar(&flatten, "flatten", false, "显示 --flatten 消费模式对应的顶层业务字段 schema")
|
||||
hideEventInternalFlags(cmd, "as")
|
||||
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
|
||||
cli.AnnotateRuntimePositionals(cmd, contract.RuntimeSchemaPositional{
|
||||
Name: "event_key",
|
||||
Type: "string",
|
||||
Description: "要查询 payload 字段定义的个人事件码",
|
||||
Required: true,
|
||||
Index: 0,
|
||||
})
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "event",
|
||||
Name: "schema",
|
||||
CanonicalPath: "event.schema",
|
||||
CLIPath: "event schema",
|
||||
PrimaryCLIPath: "event schema",
|
||||
},
|
||||
Description: "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "local",
|
||||
Availability: "available",
|
||||
Reason: "命令读取 CLI 内置的个人事件 payload 定义,不绑定 pinned MCP RPC",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ func TestEventStopDryRunPrecedesConfirmationAndReturnsPreview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStopDryRunDoesNotBypassTargetValidation(t *testing.T) {
|
||||
func TestCrossPlatformCoverageEventStopDryRunDoesNotBypassTargetValidation(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// This file holds deliberate no-op stubs that act as edition-sync anchors for
|
||||
// the private wukong edition overlay. The overlay replaces these function
|
||||
// bodies with real host-compatibility configuration while the open-source
|
||||
// build keeps them empty; both editions therefore share identical call sites
|
||||
// (access_token_resolve.go, auth_command.go, doctor_command.go,
|
||||
// force_refresh.go). These stubs are sync seams for the edition overlay, not
|
||||
// dead code: do not delete them or their call sites during cleanup.
|
||||
package app
|
||||
|
||||
import (
|
||||
|
||||
+13
-7
@@ -27,9 +27,11 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller, loadUserShortcuts bool) []*cobra.Command {
|
||||
injectStaticServers()
|
||||
helpers.InitDeps(caller)
|
||||
// mountLegacyPublicCommands builds the product + shortcut command tree without
|
||||
// mutating process-global MCP deps or dynamic server endpoints. Used by the
|
||||
// Schema source root (declaration-only) path so assembly cannot clobber a live
|
||||
// runtime's InitDeps caller or plugin endpoints.
|
||||
func mountLegacyPublicCommands(runner executor.Runner, loadUserShortcuts bool) []*cobra.Command {
|
||||
commands := helpers.NewPublicCommands(runner)
|
||||
// Load user-defined shortcuts (~/.dws/shortcuts/*.yaml) BEFORE compiling the
|
||||
// command tree, so distilled high-frequency operations mount alongside the
|
||||
@@ -51,6 +53,14 @@ func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller,
|
||||
return mergeTopLevelCommands(commands)
|
||||
}
|
||||
|
||||
// newLegacyPublicCommands is the executable CLI path: inject static MCP
|
||||
// endpoints, InitDeps, then mount the public command tree.
|
||||
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller, loadUserShortcuts bool) []*cobra.Command {
|
||||
injectStaticServers()
|
||||
helpers.InitDeps(caller)
|
||||
return mountLegacyPublicCommands(runner, loadUserShortcuts)
|
||||
}
|
||||
|
||||
func injectStaticServers() {
|
||||
hooks := edition.Get()
|
||||
var servers []edition.ServerInfo
|
||||
@@ -82,10 +92,6 @@ func injectStaticServers() {
|
||||
SetDynamicServers(descriptors)
|
||||
}
|
||||
|
||||
func newLegacyHiddenCommands(_ executor.Runner) []*cobra.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeTopLevelCommands(commands []*cobra.Command) []*cobra.Command {
|
||||
byName := make(map[string]*cobra.Command, len(commands))
|
||||
for _, cmd := range commands {
|
||||
|
||||
@@ -18,7 +18,10 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -74,13 +77,43 @@ func newMCPURLGetCommand(caller edition.ToolCaller) *cobra.Command {
|
||||
return writeMCPURLResult(cmd, result)
|
||||
},
|
||||
}
|
||||
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
|
||||
cli.AnnotateRuntimePositionals(cmd, contract.RuntimeSchemaPositional{
|
||||
Name: "mcp_id",
|
||||
Type: "string",
|
||||
Description: "钉钉 MCP 市场中的 mcpId",
|
||||
Required: true,
|
||||
Index: 0,
|
||||
})
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mcp",
|
||||
Name: "url_get",
|
||||
CanonicalPath: "mcp.url_get",
|
||||
CLIPath: "mcp url get",
|
||||
PrimaryCLIPath: "mcp url get",
|
||||
},
|
||||
Description: "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
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: contract.SelectionSpec{
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ func executeParamAliasDryRunE2E(t *testing.T, args ...string) (*pipeline.Context
|
||||
}()
|
||||
rejectRunner := ¶mAliasDryRunRejectRunner{}
|
||||
originalRunnerFactory := rootNewCommandRunnerWithFlags
|
||||
rootNewCommandRunnerWithFlags = func(cli.CatalogLoader, *GlobalFlags) executor.Runner {
|
||||
rootNewCommandRunnerWithFlags = func(*GlobalFlags) executor.Runner {
|
||||
return rejectRunner
|
||||
}
|
||||
root := NewRootCommand()
|
||||
@@ -172,7 +172,7 @@ func executeParamAliasE2E(t *testing.T, caller *paramAliasCaptureCaller, args ..
|
||||
defer func() { os.Args = originalArgs }()
|
||||
|
||||
originalRunnerFactory := rootNewCommandRunnerWithFlags
|
||||
rootNewCommandRunnerWithFlags = func(cli.CatalogLoader, *GlobalFlags) executor.Runner {
|
||||
rootNewCommandRunnerWithFlags = func(*GlobalFlags) executor.Runner {
|
||||
return ¶mAliasCaptureRunner{caller: caller}
|
||||
}
|
||||
root := NewRootCommand()
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -417,10 +418,8 @@ func TestConflictingPluginDescriptorCannotReplaceDistributionEndpoint(t *testing
|
||||
|
||||
func TestSchemaSourceRootDoesNotLoadRuntimePlugins(t *testing.T) {
|
||||
isolatePluginRuntime(t)
|
||||
previous := rootLoadPlugins
|
||||
t.Cleanup(func() { rootLoadPlugins = previous })
|
||||
var calls atomic.Int32
|
||||
rootLoadPlugins = func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
|
||||
testseam.Swap(t, &rootLoadPlugins, func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
|
||||
calls.Add(1)
|
||||
AppendDynamicServer(conferencePluginDescriptor())
|
||||
return buildPluginCommands(
|
||||
@@ -428,7 +427,7 @@ func TestSchemaSourceRootDoesNotLoadRuntimePlugins(t *testing.T) {
|
||||
executor.EchoRunner{},
|
||||
nil,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
base := NewSchemaSourceRootCommand()
|
||||
if calls.Load() != 0 {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/userdef"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
@@ -27,14 +28,11 @@ import (
|
||||
type schemaSourceContextKey struct{}
|
||||
|
||||
func TestSchemaSourceRootPropagatesContextWithoutLoadingPlugins(t *testing.T) {
|
||||
previous := rootLoadPlugins
|
||||
t.Cleanup(func() { rootLoadPlugins = previous })
|
||||
|
||||
pluginLoads := 0
|
||||
rootLoadPlugins = func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
|
||||
testseam.Swap(t, &rootLoadPlugins, func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
|
||||
pluginLoads++
|
||||
return nil
|
||||
}
|
||||
})
|
||||
wantContext := context.WithValue(context.Background(), schemaSourceContextKey{}, "schema")
|
||||
root := NewSchemaSourceRootCommand(wantContext)
|
||||
if root.Context() != wantContext {
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
@@ -23,7 +22,7 @@ var (
|
||||
recoverySaveAnalysis = (*recovery.Store).SaveAnalysis
|
||||
)
|
||||
|
||||
func newRecoveryCommand(_ context.Context, loader cli.CatalogLoader, flags *GlobalFlags) *cobra.Command {
|
||||
func newRecoveryCommand(flags *GlobalFlags) *cobra.Command {
|
||||
var (
|
||||
planUseLast bool
|
||||
planEventID string
|
||||
@@ -34,7 +33,7 @@ func newRecoveryCommand(_ context.Context, loader cli.CatalogLoader, flags *Glob
|
||||
executionFile string
|
||||
)
|
||||
|
||||
runtime := newRecoveryRuntime(loader, flags)
|
||||
runtime := newRecoveryRuntime(flags)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "recovery",
|
||||
@@ -259,12 +258,11 @@ func legacyRecoveryAttempts(count int, actions []string, result, errorSummary st
|
||||
}
|
||||
|
||||
type recoveryRuntime struct {
|
||||
loader cli.CatalogLoader
|
||||
transport *transport.Client
|
||||
flags *GlobalFlags
|
||||
}
|
||||
|
||||
func newRecoveryRuntime(loader cli.CatalogLoader, flags *GlobalFlags) *recoveryRuntime {
|
||||
func newRecoveryRuntime(flags *GlobalFlags) *recoveryRuntime {
|
||||
var httpClient *http.Client
|
||||
if flags != nil && flags.Timeout > 0 {
|
||||
httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second}
|
||||
@@ -272,7 +270,6 @@ func newRecoveryRuntime(loader cli.CatalogLoader, flags *GlobalFlags) *recoveryR
|
||||
client := transport.NewClient(httpClient)
|
||||
client.ExtraHeaders = resolveIdentityHeaders()
|
||||
return &recoveryRuntime{
|
||||
loader: loader,
|
||||
transport: client,
|
||||
flags: flags,
|
||||
}
|
||||
@@ -353,22 +350,11 @@ func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (r *recoveryRuntime) resolveEndpoint(ctx context.Context, productID, toolName string) (string, error) {
|
||||
func (r *recoveryRuntime) resolveEndpoint(_ context.Context, productID, toolName string) (string, error) {
|
||||
if endpoint, ok := directRuntimeEndpoint(productID, toolName); ok {
|
||||
return endpoint, nil
|
||||
}
|
||||
if r == nil || r.loader == nil {
|
||||
return "", fmt.Errorf("未找到服务 %s 的 endpoint", productID)
|
||||
}
|
||||
catalog, err := r.loader.Load(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
product, ok := catalog.FindProduct(productID)
|
||||
if !ok || strings.TrimSpace(product.Endpoint) == "" {
|
||||
return "", fmt.Errorf("未找到服务 %s 的 endpoint", productID)
|
||||
}
|
||||
return strings.TrimSpace(product.Endpoint), nil
|
||||
return "", endpointNotResolvedError(productID, toolName, "no dynamic endpoint registered for product or tool")
|
||||
}
|
||||
|
||||
func recoveryRuntimeToken(flags *GlobalFlags) string {
|
||||
|
||||
@@ -9,13 +9,12 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
func recoveryCoverageRun(cmdArgs ...string) (string, error) {
|
||||
cmd := newRecoveryCommand(context.Background(), cli.StaticLoader{}, &GlobalFlags{})
|
||||
cmd := newRecoveryCommand(&GlobalFlags{})
|
||||
out := &strings.Builder{}
|
||||
cmd.SetOut(out)
|
||||
cmd.SetErr(io.Discard)
|
||||
@@ -49,7 +48,7 @@ func TestCrossPlatformCoverageRecoveryCommandRemainingCoverage(t *testing.T) {
|
||||
}
|
||||
recoverySaveAnalysis = oldSaveAnalysis
|
||||
|
||||
parent := newRecoveryCommand(context.Background(), nil, nil)
|
||||
parent := newRecoveryCommand(nil)
|
||||
parent.SetOut(io.Discard)
|
||||
if err := parent.RunE(parent, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -127,13 +126,12 @@ func TestCrossPlatformCoverageRecoveryExecutionAndRuntimeRemainingCoverage(t *te
|
||||
t.Fatal("invalid attempt array should fail")
|
||||
}
|
||||
|
||||
fail := errors.New("catalog")
|
||||
SetDynamicServers(nil)
|
||||
runtime := &recoveryRuntime{
|
||||
loader: cli.CatalogLoaderFrom(cli.Catalog{}, fail),
|
||||
transport: transport.NewClient(nil),
|
||||
flags: &GlobalFlags{Token: "token"},
|
||||
}
|
||||
if _, err := runtime.CallToolDirect(context.Background(), "missing", "tool", nil); !errors.Is(err, fail) {
|
||||
if _, err := runtime.CallToolDirect(context.Background(), "missing", "tool", nil); err == nil || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) {
|
||||
t.Fatalf("direct resolution error = %v", err)
|
||||
}
|
||||
if got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{}); err == nil || got.DocSearch.Status != "error" {
|
||||
|
||||
+56
-62
@@ -369,41 +369,43 @@ func commandRequestsJSONErrors(cmd *cobra.Command) bool {
|
||||
// is propagated to background goroutines and the Cobra command tree so
|
||||
// that SIGINT/SIGTERM can cancel in-flight work.
|
||||
func NewRootCommand(ctx ...context.Context) *cobra.Command {
|
||||
registerSchemaRuntimeDelivery()
|
||||
var rootCtx context.Context
|
||||
if len(ctx) > 0 && ctx[0] != nil {
|
||||
rootCtx = ctx[0]
|
||||
}
|
||||
return newRootCommandWithEngine(rootCtx, nil, true)
|
||||
return newRootCommandWithEngine(rootCtx, nil, true, false)
|
||||
}
|
||||
|
||||
// NewSchemaSourceRootCommand constructs the distribution-owned command tree
|
||||
// used by Schema generation and command-surface policy. Installed plugins and
|
||||
// user-defined shortcuts must not change the reviewed embedded Schema.
|
||||
// used as the Schema assembly source root (RegisterSchemaSourceRoot →
|
||||
// ResolveSchemaBuild) and by command-surface policy. Installed plugins and
|
||||
// user-defined shortcuts must not change the reviewed Schema surface.
|
||||
// declarationOnly skips injectStaticServers / helpers.InitDeps so Schema
|
||||
// assembly cannot clobber a live process's ToolCaller or plugin endpoints.
|
||||
func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
|
||||
var rootCtx context.Context
|
||||
if len(ctx) > 0 && ctx[0] != nil {
|
||||
rootCtx = ctx[0]
|
||||
}
|
||||
return newRootCommandWithEngine(rootCtx, nil, false)
|
||||
return newRootCommandWithEngine(rootCtx, nil, false, true)
|
||||
}
|
||||
|
||||
// NewRootCommandWithEngine constructs the root CLI command with an
|
||||
// optional pipeline engine for input correction. When engine is nil,
|
||||
// no pipeline processing is applied.
|
||||
func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command {
|
||||
return newRootCommandWithEngine(rootCtx, engine, true)
|
||||
registerSchemaRuntimeDelivery()
|
||||
return newRootCommandWithEngine(rootCtx, engine, true, false)
|
||||
}
|
||||
|
||||
func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool) *cobra.Command {
|
||||
func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool, declarationOnly bool) *cobra.Command {
|
||||
if rootCtx == nil {
|
||||
rootCtx = context.Background()
|
||||
}
|
||||
flags := &GlobalFlags{}
|
||||
authpkg.SetRuntimeProfile(preparseProfileFlag(os.Args[1:]))
|
||||
loader := cli.EnvironmentLoader{
|
||||
LookupEnv: os.LookupEnv,
|
||||
}
|
||||
runner := rootNewCommandRunnerWithFlags(loader, flags)
|
||||
runner := rootNewCommandRunnerWithFlags(flags)
|
||||
|
||||
root := &cobra.Command{
|
||||
Use: "dws",
|
||||
@@ -458,13 +460,8 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
|
||||
bindPersistentFlags(root, flags)
|
||||
|
||||
schemaCmd := newSchemaCommand(loader)
|
||||
mcpCmd := newMCPCommand(rootCtx, loader, runner, engine)
|
||||
// The legacy dynamic MCP surface remains disabled, but reviewed static MCP
|
||||
// helpers registered below are part of the public CLI and Schema surface.
|
||||
mcpCmd.Hidden = false
|
||||
mcpCmd.Short = "管理 MCP 服务连接信息"
|
||||
mcpCmd.Long = "管理经过审核并纳入 Schema 的 MCP 服务连接辅助能力。"
|
||||
schemaCmd := cli.NewSchemaCommand()
|
||||
mcpCmd := cli.NewMCPCommand()
|
||||
// Wrap the caller so every MCP tool call's shape is recorded to the local
|
||||
// usage log (privacy-preserving; see internal/shortcut/usage). Powers
|
||||
// `dws shortcut stats` and future high-frequency shortcut distillation.
|
||||
@@ -477,13 +474,13 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
newAPICommand(flags),
|
||||
newSkillCommand(),
|
||||
newCacheCommand(),
|
||||
newCatalogCommand(loader),
|
||||
newCatalogCommand(),
|
||||
newConfigCommand(),
|
||||
newDoctorCommand(),
|
||||
newEventCommand(),
|
||||
newAuditCommand(),
|
||||
newCompletionCommand(root),
|
||||
newRecoveryCommand(rootCtx, loader, flags),
|
||||
newRecoveryCommand(flags),
|
||||
newUpgradeCommand(),
|
||||
newVersionCommand(),
|
||||
newPluginCommand(),
|
||||
@@ -493,8 +490,14 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
}
|
||||
root.AddCommand(utilityCommands...)
|
||||
|
||||
root.AddCommand(newLegacyPublicCommands(runner, patCaller, loadRuntimeExtensions)...)
|
||||
root.AddCommand(newLegacyHiddenCommands(runner)...)
|
||||
if declarationOnly {
|
||||
// Schema / surface assembly: mount the reviewed tree only. Do not
|
||||
// injectStaticServers or InitDeps — those mutate process globals and
|
||||
// would clobber a live runtime's caller and plugin endpoints.
|
||||
root.AddCommand(mountLegacyPublicCommands(runner, loadRuntimeExtensions)...)
|
||||
} else {
|
||||
root.AddCommand(newLegacyPublicCommands(runner, patCaller, loadRuntimeExtensions)...)
|
||||
}
|
||||
|
||||
// PAT authorization commands (open-source core)
|
||||
pat.RegisterCommands(root, patCaller)
|
||||
@@ -692,18 +695,6 @@ func newVersionCommand() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func newSchemaCommand(loader cli.CatalogLoader) *cobra.Command {
|
||||
return cli.NewSchemaCommand(loader)
|
||||
}
|
||||
|
||||
// buildMCPCommandFn is a test seam for newMCPCommand.
|
||||
var buildMCPCommandFn = cli.NewMCPCommand
|
||||
|
||||
// newMCPCommand builds the `dws mcp` command tree.
|
||||
func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner, engine *pipeline.Engine) *cobra.Command {
|
||||
return buildMCPCommandFn(ctx, loader, runner, engine)
|
||||
}
|
||||
|
||||
// hideNonDirectRuntimeCommands marks top-level product commands as hidden
|
||||
// unless they correspond to a static endpoint product or an edition-visible
|
||||
// compatibility command.
|
||||
@@ -711,27 +702,6 @@ func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executo
|
||||
// stay hidden.
|
||||
func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
allowedProducts := resolveVisibleProducts()
|
||||
staticCommands := map[string]bool{
|
||||
"auth": true,
|
||||
"api": true,
|
||||
"audit": true,
|
||||
"cache": true,
|
||||
"config": true,
|
||||
"dev": true,
|
||||
"doctor": true,
|
||||
"event": true,
|
||||
"completion": true,
|
||||
"skill": true,
|
||||
"plugin": true,
|
||||
"profile": true,
|
||||
"version": true,
|
||||
"help": true,
|
||||
"markdown": true,
|
||||
"recovery": true,
|
||||
"schema": true,
|
||||
"mcp": true,
|
||||
"upgrade": true,
|
||||
}
|
||||
for _, cmd := range root.Commands() {
|
||||
name := cmd.Name()
|
||||
if cmd.Hidden {
|
||||
@@ -747,16 +717,40 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
}
|
||||
}
|
||||
|
||||
// builtinCommandNames is the shared base set of built-in command names. Both
|
||||
// staticCommands (the visibility allow-list used by
|
||||
// hideNonDirectRuntimeCommands) and reservedCommands (the plugin-override
|
||||
// blocklist) derive from this single set so they cannot drift apart.
|
||||
var builtinCommandNames = map[string]bool{
|
||||
"auth": true, "api": true, "audit": true, "cache": true, "config": true,
|
||||
"doctor": true, "event": true, "completion": true, "skill": true,
|
||||
"plugin": true, "profile": true, "version": true, "help": true,
|
||||
"recovery": true, "schema": true, "mcp": true, "upgrade": true,
|
||||
}
|
||||
|
||||
// commandNameSet returns a new set containing every name in base plus extras.
|
||||
func commandNameSet(base map[string]bool, extras ...string) map[string]bool {
|
||||
set := make(map[string]bool, len(base)+len(extras))
|
||||
for name := range base {
|
||||
set[name] = true
|
||||
}
|
||||
for _, extra := range extras {
|
||||
set[extra] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// staticCommands is the set of built-in commands that stay visible even when
|
||||
// they are not backed by a static endpoint product. Asymmetry with
|
||||
// reservedCommands is intentional: dev/markdown stay visible but are not
|
||||
// plugin-reserved, while login/logout are plugin-reserved but are not static
|
||||
// top-level commands.
|
||||
var staticCommands = commandNameSet(builtinCommandNames, "dev", "markdown")
|
||||
|
||||
// reservedCommands is the set of built-in command names that plugins must
|
||||
// not override. This protects core CLI functionality from being hijacked
|
||||
// by a malicious or misconfigured plugin.
|
||||
var reservedCommands = map[string]bool{
|
||||
"auth": true, "api": true, "audit": true, "login": true, "logout": true,
|
||||
"plugin": true, "profile": true, "skill": true, "cache": true,
|
||||
"config": true, "doctor": true, "event": true, "completion": true,
|
||||
"recovery": true, "upgrade": true, "version": true,
|
||||
"schema": true, "mcp": true, "help": true,
|
||||
}
|
||||
var reservedCommands = commandNameSet(builtinCommandNames, "login", "logout")
|
||||
|
||||
var replaceablePluginFallbacks = map[string]bool{
|
||||
"conference": true,
|
||||
@@ -1332,7 +1326,7 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
|
||||
// Register → PreParse → PostParse → PreRequest → PostResponse.
|
||||
//
|
||||
// Phases are invoked at their respective integration points:
|
||||
// - Register: during command tree construction (newMCPCommand)
|
||||
// - Register: during command tree construction (cli.NewMCPCommand)
|
||||
// - PreParse: before Cobra parses raw argv (RunPreParse)
|
||||
// - PostParse: after Cobra parsing, before validation (canonical RunE)
|
||||
// - PreRequest: after validation, before JSON-RPC dispatch (canonical RunE)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// 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 "testing"
|
||||
|
||||
// BenchmarkNewRootCommand measures building the real Cobra tree — roughly 800
|
||||
// leaves with their flags, Schema annotations and PostMount work.
|
||||
//
|
||||
// It is the other half of the cold-start attribution: `dws version` never
|
||||
// decodes the Schema catalog, so whatever it spends beyond process start is
|
||||
// mostly this. Without splitting the two, an optimization aimed at JSON parsing
|
||||
// could target the smaller cost.
|
||||
func BenchmarkNewRootCommand(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
root := NewRootCommand()
|
||||
if root == nil {
|
||||
b.Fatal("nil root")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,9 +163,16 @@ func commandShort(cmd *cobra.Command) string {
|
||||
|
||||
// resolveVisibleProducts returns the set of top-level product IDs that should
|
||||
// be treated as visible. It unions the edition's VisibleProducts hook (when
|
||||
// set) with DirectRuntimeProductIDs(), so dynamically-registered products —
|
||||
// including plugins loaded via AppendDynamicServer — are never silently hidden
|
||||
// by a static VisibleProducts list.
|
||||
// set), StaticServers product IDs, and DirectRuntimeProductIDs(), so
|
||||
// dynamically-registered products — including plugins loaded via
|
||||
// AppendDynamicServer — are never silently hidden by a static VisibleProducts
|
||||
// list.
|
||||
//
|
||||
// StaticServers are consulted directly (without injectStaticServers) so the
|
||||
// declaration-only Schema source root can keep reviewed products visible
|
||||
// without mutating the process-global dynamic endpoint registry.
|
||||
// SupplementServers stay out of this set: they are helper-only endpoints and
|
||||
// must not synthesize top-level product visibility.
|
||||
func resolveVisibleProducts() map[string]bool {
|
||||
allowed := map[string]bool{}
|
||||
if fn := edition.Get().VisibleProducts; fn != nil {
|
||||
@@ -173,6 +180,13 @@ func resolveVisibleProducts() map[string]bool {
|
||||
allowed[p] = true
|
||||
}
|
||||
}
|
||||
if fn := edition.Get().StaticServers; fn != nil {
|
||||
for _, server := range fn() {
|
||||
if id := strings.TrimSpace(server.ID); id != "" {
|
||||
allowed[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for id := range DirectRuntimeProductIDs() {
|
||||
allowed[id] = true
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -161,9 +162,7 @@ func TestRootChatMediaUploadWithoutAppCredentialsReturnsMigrationValidation(t *t
|
||||
"--file", filePath,
|
||||
"--type", "image",
|
||||
}
|
||||
previousArgs := os.Args
|
||||
os.Args = append([]string{"dws"}, commandArgs...)
|
||||
t.Cleanup(func() { os.Args = previousArgs })
|
||||
testseam.Swap(t, &os.Args, append([]string{"dws"}, commandArgs...))
|
||||
|
||||
root := NewRootCommand()
|
||||
var output bytes.Buffer
|
||||
|
||||
+45
-94
@@ -30,7 +30,6 @@ import (
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||
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"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||
@@ -123,7 +122,7 @@ func logHostOwnedPATDecisionOnce() {
|
||||
})
|
||||
}
|
||||
|
||||
func newCommandRunnerWithFlags(loader cli.CatalogLoader, flags *GlobalFlags) executor.Runner {
|
||||
func newCommandRunnerWithFlags(flags *GlobalFlags) executor.Runner {
|
||||
// Ensure DWS_CLIENT_ID env is populated from persisted config before
|
||||
// resolveIdentityHeaders reads it. This covers fresh-process cold starts
|
||||
// where no env var has been inherited from a parent process.
|
||||
@@ -141,10 +140,8 @@ func newCommandRunnerWithFlags(loader cli.CatalogLoader, flags *GlobalFlags) exe
|
||||
transportClient.ExtraHeaders = resolveIdentityHeaders()
|
||||
transportClient.FileLogger = FileLoggerInstance()
|
||||
return &runtimeRunner{
|
||||
loader: loader,
|
||||
transport: transportClient,
|
||||
globalFlags: flags,
|
||||
fallback: executor.EchoRunner{},
|
||||
scanner: newRuntimeContentScanner(),
|
||||
enforceContentScan: runtimeFlagEnabled(os.Getenv(runtimeContentScanEnforceEnv), false),
|
||||
includeScanReport: runtimeFlagEnabled(os.Getenv(runtimeContentScanReportOutputEnv), false),
|
||||
@@ -152,7 +149,6 @@ func newCommandRunnerWithFlags(loader cli.CatalogLoader, flags *GlobalFlags) exe
|
||||
}
|
||||
|
||||
type runtimeRunner struct {
|
||||
loader cli.CatalogLoader
|
||||
transport *transport.Client
|
||||
globalFlags *GlobalFlags
|
||||
fallback executor.Runner
|
||||
@@ -204,20 +200,14 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
return r.runMultiProfile(ctx, invocation, selections)
|
||||
}
|
||||
if strings.TrimSpace(rawProfile) != "" {
|
||||
profile, err := authpkg.ResolveProfile(defaultConfigDir(), rawProfile)
|
||||
profile, err := runnerResolveProfile(defaultConfigDir(), rawProfile)
|
||||
if err != nil {
|
||||
return executor.Result{}, apperrors.NewValidation(err.Error())
|
||||
}
|
||||
if profile == nil {
|
||||
return executor.Result{}, apperrors.NewValidation(fmt.Sprintf("profile %q not found", rawProfile))
|
||||
}
|
||||
resolvedSelector := authpkg.ProfileSelector(*profile)
|
||||
if strings.TrimSpace(profile.UserID) == "" {
|
||||
// Preserve a unique local-name selector for an unresolved account.
|
||||
// Reducing it to corpId can select a different exact account through
|
||||
// the organization's current-account pointer.
|
||||
resolvedSelector = rawProfile
|
||||
}
|
||||
resolvedSelector := profileRuntimeSelector(*profile, rawProfile)
|
||||
authpkg.SetRuntimeProfile(resolvedSelector)
|
||||
defer authpkg.SetRuntimeProfile(rawProfile)
|
||||
}
|
||||
@@ -244,12 +234,12 @@ func (r *runtimeRunner) RunReadOnly(ctx context.Context, invocation executor.Inv
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invocation, prefetchToken bool) (executor.Result, error) {
|
||||
if r.loader == nil || r.transport == nil {
|
||||
if r.transport == nil {
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
}
|
||||
r.transport.ExtraHeaders = resolveIdentityHeaders()
|
||||
|
||||
// Mock mode: skip catalog validation, use a placeholder endpoint.
|
||||
// Mock mode: skip endpoint resolution, use a placeholder endpoint.
|
||||
if r.globalFlags != nil && r.globalFlags.Mock {
|
||||
endpoint := fmt.Sprintf("https://mock-mcp-%s.dingtalk.com", invocation.CanonicalProduct)
|
||||
if override, ok := productEndpointOverride(invocation.CanonicalProduct); ok {
|
||||
@@ -260,7 +250,7 @@ func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invoc
|
||||
|
||||
// Prefetch the Keychain token in the background. Keychain access costs
|
||||
// ~70ms on macOS; starting it here lets the load overlap with endpoint
|
||||
// resolution and catalog loading below.
|
||||
// resolution below.
|
||||
if prefetchToken {
|
||||
go func() {
|
||||
_, _ = runnerGetCachedRuntimeToken(ctx)
|
||||
@@ -273,56 +263,21 @@ func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invoc
|
||||
}
|
||||
}
|
||||
|
||||
catalogStart := time.Now()
|
||||
catalog, err := r.loader.Load(ctx)
|
||||
RecordTiming(ctx, "catalog_load", time.Since(catalogStart))
|
||||
if err != nil {
|
||||
var degraded *cli.CatalogDegraded
|
||||
if !errors.As(err, °raded) {
|
||||
return executor.Result{}, err
|
||||
}
|
||||
}
|
||||
// Discovery is retired: endpoint resolution is the dynamic server
|
||||
// registry only, so a direct-runtime miss is terminal.
|
||||
return r.handleCatalogMiss(ctx, invocation, "no dynamic endpoint registered for product or tool")
|
||||
}
|
||||
|
||||
product, ok := catalog.FindProduct(invocation.CanonicalProduct)
|
||||
if !ok || strings.TrimSpace(product.Endpoint) == "" {
|
||||
return r.handleCatalogMiss(ctx, invocation, "product missing from discovery catalog and no supplement/env override")
|
||||
// profileRuntimeSelector resolves the runtime profile selector for an
|
||||
// invocation. It preserves a unique local-name selector for an unresolved
|
||||
// account (empty UserID): reducing it to corpId can select a different exact
|
||||
// account through the organization's current-account pointer. Shared by the
|
||||
// single-profile path in Run and the multi-profile path in runMultiProfile.
|
||||
func profileRuntimeSelector(profile authpkg.Profile, rawSelector string) string {
|
||||
if strings.TrimSpace(profile.UserID) == "" {
|
||||
return rawSelector
|
||||
}
|
||||
if _, ok := product.FindTool(invocation.Tool); !ok {
|
||||
// Catalog knows the product but not the tool — this happens when the
|
||||
// catalog entry came from SupplementServers (endpoint-only, no tool
|
||||
// list). Trust directRuntimeEndpoint to re-resolve a working endpoint
|
||||
// for the tool. If that also misses, fall through to handleCatalogMiss
|
||||
// so stderr still carries the explicit not-resolved signal.
|
||||
if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct, invocation.Tool); ok {
|
||||
if r.globalFlags != nil && r.globalFlags.DryRun {
|
||||
invocation.DryRun = true
|
||||
}
|
||||
return r.executeInvocation(ctx, endpoint, invocation)
|
||||
}
|
||||
return r.handleCatalogMiss(ctx, invocation, fmt.Sprintf("tool %q not declared by product %q in discovery catalog", invocation.Tool, invocation.CanonicalProduct))
|
||||
}
|
||||
if r.globalFlags != nil && r.globalFlags.DryRun {
|
||||
invocation.DryRun = true
|
||||
}
|
||||
|
||||
endpoint := product.Endpoint
|
||||
if override, ok := productEndpointOverride(invocation.CanonicalProduct); ok {
|
||||
endpoint = override
|
||||
}
|
||||
// Multi-server tool-name authority correction.
|
||||
//
|
||||
// When two envelope servers share the same cli.command (e.g. group-chat
|
||||
// and im both publish `dws chat ...`), the endpoints[cmd] map in
|
||||
// registerDynamicServer is the second-writer wins, and catalog FindProduct
|
||||
// may pick the wrong product's Endpoint for a tool whose real owner is
|
||||
// a different server. Cross-check the canonical tool→endpoint map: when
|
||||
// the per-tool endpoint exists and differs from the per-product endpoint
|
||||
// catalog returned, trust the tool-owner endpoint (the server that
|
||||
// actually declares this tool in its toolOverrides).
|
||||
if toolEndpoint, ok := directRuntimeToolEndpoint(invocation.Tool); ok && toolEndpoint != "" && toolEndpoint != endpoint {
|
||||
endpoint = toolEndpoint
|
||||
}
|
||||
return r.executeInvocation(ctx, endpoint, invocation)
|
||||
return authpkg.ProfileSelector(profile)
|
||||
}
|
||||
|
||||
type multiProfileSelection struct {
|
||||
@@ -376,10 +331,7 @@ func (r *runtimeRunner) runMultiProfile(ctx context.Context, invocation executor
|
||||
failed := 0
|
||||
|
||||
for _, selection := range selections {
|
||||
resolvedSelector := authpkg.ProfileSelector(selection.Profile)
|
||||
if strings.TrimSpace(selection.Profile.UserID) == "" {
|
||||
resolvedSelector = selection.Selector
|
||||
}
|
||||
resolvedSelector := profileRuntimeSelector(selection.Profile, selection.Selector)
|
||||
authpkg.SetRuntimeProfile(resolvedSelector)
|
||||
result, err := r.runSingle(ctx, cloneInvocation(invocation), false)
|
||||
|
||||
@@ -468,45 +420,51 @@ func multiProfileErrorPayload(err error) map[string]any {
|
||||
return payload
|
||||
}
|
||||
|
||||
// handleCatalogMiss decides what to do when discovery catalog does not cover the
|
||||
// requested product / tool and no `directRuntimeEndpoint` match fired earlier.
|
||||
// handleCatalogMiss decides what to do when the dynamic server registry has
|
||||
// no endpoint for the requested product / tool and no `directRuntimeEndpoint`
|
||||
// match fired earlier. The discovery catalog is retired; endpoint resolution
|
||||
// is the dynamic server registry only, so a registry miss here is terminal.
|
||||
//
|
||||
// Previously every catalog miss silently fell through to EchoRunner, which
|
||||
// Previously every miss silently fell through to EchoRunner, which
|
||||
// returns an empty `executor.Result{Response: nil}`. The helper-invocation
|
||||
// adapter then converted that into `&edition.ToolResult{}`, whose `Content`
|
||||
// marshals to `null`, surfacing as `{"Content": null}` at the CLI. Users had no
|
||||
// signal that endpoint resolution failed — see the fix-wukong-discovery-missing-servers plan (Phase 3) for the full trace.
|
||||
//
|
||||
// New contract:
|
||||
// - Dry-run (invocation.DryRun or globalFlags.DryRun): keep EchoRunner so
|
||||
// `--dry-run` still prints the planned payload without real execution.
|
||||
// - Otherwise: return an explicit apperrors.NewAPI("endpoint_not_resolved")
|
||||
// with the offending product/tool attached. This fails fast to stderr and
|
||||
// makes missing envelopes / supplement gaps immediately visible.
|
||||
func (r *runtimeRunner) handleCatalogMiss(ctx context.Context, invocation executor.Invocation, detail string) (executor.Result, error) {
|
||||
dryRun := invocation.DryRun || (r.globalFlags != nil && r.globalFlags.DryRun)
|
||||
if dryRun {
|
||||
invocation.DryRun = true
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
}
|
||||
// Contract: return an explicit apperrors.NewAPI("endpoint_not_resolved")
|
||||
// with the offending product/tool attached. This fails fast to stderr and
|
||||
// makes missing envelopes / supplement gaps immediately visible. Dry-run
|
||||
// invocations never reach this path in production: Run enforces the dry-run
|
||||
// barrier before endpoint resolution, and runSingle is only re-entered via
|
||||
// Run (multi-profile and PAT retry both route back through it).
|
||||
func (r *runtimeRunner) handleCatalogMiss(_ context.Context, invocation executor.Invocation, detail string) (executor.Result, error) {
|
||||
return executor.Result{}, endpointNotResolvedError(invocation.CanonicalProduct, invocation.Tool, detail)
|
||||
}
|
||||
|
||||
// endpointNotResolvedError builds the shared terminal error for a dynamic
|
||||
// server registry miss. Both the runtime runner (handleCatalogMiss) and the
|
||||
// recovery runtime (resolveEndpoint) use it so endpoint misses classify
|
||||
// identically: CategoryAPI, operation "discovery.resolve", reason
|
||||
// "endpoint_not_resolved", with the product ID as the server key.
|
||||
func endpointNotResolvedError(productID, toolName, detail string) error {
|
||||
hint := "当前命令已注册,但静态端点目录中缺少对应 product/server endpoint。这通常是服务发现下线后的同步产物缺口,不是参数错误;请不要通过反复调整 flag 重试。"
|
||||
actions := []string{
|
||||
"确认 internal/syncdata.StaticServers() 是否包含该 product/server",
|
||||
"运行 sync-oss 重新生成静态端点与路由",
|
||||
"若该能力已下线,请在 skill 与 --help 中标记 unavailable 并提供替代命令",
|
||||
}
|
||||
if strings.TrimSpace(invocation.CanonicalProduct) == devappProductID {
|
||||
if strings.TrimSpace(productID) == devappProductID {
|
||||
hint = "dev app(product id: devapp)是 helper-only 产品,命令树不依赖服务发现;真实调用需要通过 StaticServers/SupplementServers 注入 MCP endpoint,或本地调试临时设置 DINGTALK_DEVAPP_MCP_URL。"
|
||||
actions = []string{
|
||||
"检查 StaticServers/SupplementServers 是否包含 devapp endpoint",
|
||||
"本地调试可临时设置 DINGTALK_DEVAPP_MCP_URL 后重试",
|
||||
}
|
||||
}
|
||||
return executor.Result{}, apperrors.NewAPI(
|
||||
fmt.Sprintf("endpoint not resolved for product %q (tool %q): %s", invocation.CanonicalProduct, invocation.Tool, detail),
|
||||
return apperrors.NewAPI(
|
||||
fmt.Sprintf("endpoint not resolved for product %q (tool %q): %s", productID, toolName, detail),
|
||||
apperrors.WithOperation("discovery.resolve"),
|
||||
apperrors.WithReason("endpoint_not_resolved"),
|
||||
apperrors.WithServerKey(invocation.CanonicalProduct),
|
||||
apperrors.WithServerKey(productID),
|
||||
apperrors.WithHint(hint),
|
||||
apperrors.WithActions(actions...),
|
||||
)
|
||||
@@ -792,13 +750,6 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
return executor.Result{Invocation: invocation, Response: response}, nil
|
||||
}
|
||||
|
||||
// executeStdioInvocation dispatches a tool call through a local StdioClient
|
||||
// subprocess instead of the HTTP transport. This is used for plugin stdio
|
||||
// servers whose endpoints use the stdio:// scheme.
|
||||
func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
return r.executeStdioInvocationAtEndpoint(ctx, "", invocation)
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) executeStdioInvocationAtEndpoint(
|
||||
ctx context.Context,
|
||||
endpoint string,
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"testing"
|
||||
|
||||
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"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
|
||||
@@ -35,7 +34,7 @@ func TestCrossPlatformCoverageRunnerRemainingRoutingCoverage(t *testing.T) {
|
||||
runnerGetCachedRuntimeToken = oldCachedToken
|
||||
})
|
||||
|
||||
created := newCommandRunnerWithFlags(cli.StaticLoader{}, &GlobalFlags{Timeout: 2})
|
||||
created := newCommandRunnerWithFlags(&GlobalFlags{Timeout: 2})
|
||||
if created.(*runtimeRunner).transport == nil {
|
||||
t.Fatal("runner transport was not created")
|
||||
}
|
||||
@@ -55,21 +54,20 @@ func TestCrossPlatformCoverageRunnerRemainingRoutingCoverage(t *testing.T) {
|
||||
return "", nil
|
||||
}
|
||||
r := &runtimeRunner{
|
||||
loader: cli.CatalogLoaderFrom(cli.Catalog{}, wantErr),
|
||||
transport: transport.NewClient(nil),
|
||||
fallback: runnerCoverageFallback{},
|
||||
}
|
||||
directMiss := inv
|
||||
directMiss.Kind = "helper_invocation"
|
||||
if _, err := r.runSingle(context.Background(), directMiss, false); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("direct runtime miss load error = %v", err)
|
||||
if _, err := r.runSingle(context.Background(), directMiss, false); err == nil || !strings.Contains(err.Error(), "no dynamic endpoint registered for product or tool") {
|
||||
t.Fatalf("direct runtime miss = %v", err)
|
||||
}
|
||||
directHit := executor.Invocation{Kind: "helper_invocation", CanonicalProduct: defaultPATProductID, Tool: "pat", DryRun: true}
|
||||
if got, err := r.runSingle(context.Background(), directHit, false); err != nil || got.Response["dry_run"] != true {
|
||||
t.Fatalf("direct runtime hit = %#v, %v", got, err)
|
||||
}
|
||||
if _, err := r.runSingle(context.Background(), inv, true); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("runSingle error = %v", err)
|
||||
if _, err := r.runSingle(context.Background(), inv, true); err == nil {
|
||||
t.Fatal("endpoint miss succeeded")
|
||||
}
|
||||
<-prefetched
|
||||
|
||||
@@ -113,17 +111,18 @@ func TestCrossPlatformCoverageRunnerRemainingRoutingCoverage(t *testing.T) {
|
||||
t.Fatalf("multi success aggregation = %#v, %v", result, err)
|
||||
}
|
||||
|
||||
product := cli.CanonicalProduct{ID: "product", Endpoint: "https://catalog.test", Tools: []cli.ToolDescriptor{{RPCName: "tool"}}}
|
||||
r = &runtimeRunner{
|
||||
loader: cli.StaticLoader{Catalog: cli.Catalog{Products: []cli.CanonicalProduct{product}}},
|
||||
transport: transport.NewClient(nil),
|
||||
globalFlags: &GlobalFlags{DryRun: true},
|
||||
fallback: runnerCoverageFallback{},
|
||||
}
|
||||
overrideInv := inv
|
||||
overrideInv.Kind = "helper_invocation"
|
||||
overrideInv.DryRun = true
|
||||
t.Setenv("DINGTALK_PRODUCT_MCP_URL", "https://override.test")
|
||||
got, err := r.runSingle(context.Background(), inv, false)
|
||||
got, err := r.runSingle(context.Background(), overrideInv, false)
|
||||
if err != nil || got.Response["endpoint"] != "https://override.test" {
|
||||
t.Fatalf("catalog override = %#v, %v", got, err)
|
||||
t.Fatalf("direct runtime override = %#v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,7 +306,7 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test
|
||||
inv := executor.Invocation{CanonicalProduct: "stdio-product", Tool: "tool"}
|
||||
wantErr := errors.New("stdio failed")
|
||||
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return wantErr }
|
||||
if _, err := r.executeStdioInvocation(context.Background(), inv); err == nil || !strings.Contains(err.Error(), "stdio initialize failed") {
|
||||
if _, err := r.executeStdioInvocationAtEndpoint(context.Background(), "", inv); err == nil || !strings.Contains(err.Error(), "stdio initialize failed") {
|
||||
t.Fatalf("stdio initialize error = %v", err)
|
||||
}
|
||||
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return nil }
|
||||
@@ -322,19 +321,19 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test
|
||||
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
return transport.ToolCallResult{}, wantErr
|
||||
}
|
||||
if _, err := r.executeStdioInvocation(context.Background(), inv); err == nil || !strings.Contains(err.Error(), "stdio failed") {
|
||||
if _, err := r.executeStdioInvocationAtEndpoint(context.Background(), "", inv); err == nil || !strings.Contains(err.Error(), "stdio failed") {
|
||||
t.Fatalf("stdio call error = %v", err)
|
||||
}
|
||||
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
return transport.ToolCallResult{IsError: true, Content: map[string]any{"message": "tool failed"}}, nil
|
||||
}
|
||||
if _, err := r.executeStdioInvocation(context.Background(), inv); err == nil || !strings.Contains(err.Error(), "tool failed") {
|
||||
if _, err := r.executeStdioInvocationAtEndpoint(context.Background(), "", inv); err == nil || !strings.Contains(err.Error(), "tool failed") {
|
||||
t.Fatalf("stdio tool error = %v", err)
|
||||
}
|
||||
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
return transport.ToolCallResult{Content: map[string]any{"ok": true}}, nil
|
||||
}
|
||||
if got, err := r.executeStdioInvocation(context.Background(), inv); err != nil || !got.Invocation.Implemented {
|
||||
if got, err := r.executeStdioInvocationAtEndpoint(context.Background(), "", inv); err != nil || !got.Invocation.Implemented {
|
||||
t.Fatalf("stdio success = %#v, %v", got, err)
|
||||
}
|
||||
RegisterStdioClient("plugin/server-key", client)
|
||||
|
||||
@@ -27,35 +27,42 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
var (
|
||||
manualAgentExamplePlaceholderPattern = regexp.MustCompile(`<([^>]+)>`)
|
||||
manualAgentExampleDryRunJSONPattern = regexp.MustCompile(`(?i)"dry_run"\s*:\s*true`)
|
||||
manualAgentExampleDryRunPlanPattern = regexp.MustCompile(`(?i)"preview_kind"\s*:\s*"plan"`)
|
||||
agentExamplePlaceholderPattern = regexp.MustCompile(`<([^>]+)>`)
|
||||
agentExampleDryRunJSONPattern = regexp.MustCompile(`(?i)"dry_run"\s*:\s*true`)
|
||||
agentExampleDryRunPlanPattern = regexp.MustCompile(`(?i)"preview_kind"\s*:\s*"plan"`)
|
||||
// agentExampleDryRunInvocationPattern 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.
|
||||
agentExampleDryRunInvocationPattern = regexp.MustCompile(
|
||||
`"kind"\s*:\s*"(?:(?:helper|compat|workflow)_invocation|connect_preview)"`)
|
||||
)
|
||||
|
||||
// TestManualAgentExamplesContract is the always-on gate. It validates every
|
||||
// TestAgentExamplesContract is the always-on gate. It validates every
|
||||
// example, including contract_only entries, against the live bound Cobra path,
|
||||
// flags, required arguments, constraints, and final typed safety.
|
||||
func TestManualAgentExamplesContract(t *testing.T) {
|
||||
plan := manualAgentExampleExecutionPlan(t)
|
||||
func TestAgentExamplesContract(t *testing.T) {
|
||||
plan := agentExampleExecutionPlan(t)
|
||||
if plan.Total == 0 {
|
||||
t.Fatal("no reviewed Agent examples were contract validated")
|
||||
}
|
||||
t.Logf("Agent example contract: total=%d contract=%d dry_run=%d contract_only=%d", plan.Total, plan.Contract, plan.DryRun, plan.ContractOnly)
|
||||
}
|
||||
|
||||
// TestManualAgentExamplesDryRun first validates every reviewed example against
|
||||
// TestAgentExamplesDryRun first validates every reviewed example against
|
||||
// its real BoundCommand, Cobra required arguments, and final typed constraints.
|
||||
// It then executes only the deterministic, explicitly declared dry_run subset
|
||||
// without injecting --yes. Global flag inheritance is not treated as capability
|
||||
// evidence. Runtime failures never create implicit skips. No shell is involved
|
||||
// and HOME is isolated.
|
||||
func TestManualAgentExamplesDryRun(t *testing.T) {
|
||||
func TestAgentExamplesDryRun(t *testing.T) {
|
||||
if os.Getenv("DWS_AGENT_EXAMPLES_DRY_RUN") != "1" {
|
||||
t.Skip("set DWS_AGENT_EXAMPLES_DRY_RUN=1 to execute the explicitly reviewed Agent dry-run subset")
|
||||
}
|
||||
@@ -74,45 +81,45 @@ func TestManualAgentExamplesDryRun(t *testing.T) {
|
||||
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:1")
|
||||
t.Setenv("NO_PROXY", "")
|
||||
|
||||
plan := manualAgentExampleExecutionPlan(t)
|
||||
plan := agentExampleExecutionPlan(t)
|
||||
if plan.Total == 0 {
|
||||
t.Fatal("no reviewed Agent examples were contract validated")
|
||||
}
|
||||
t.Chdir(sandboxRoot)
|
||||
files := newManualAgentExampleFiles(t, sandboxRoot)
|
||||
files := newAgentExampleFiles(t, sandboxRoot)
|
||||
|
||||
selected := 0
|
||||
executed := 0
|
||||
for _, execution := range plan.Examples {
|
||||
if !manualAgentExampleShouldExerciseDryRun(execution) {
|
||||
if !agentExampleShouldExerciseDryRun(execution) {
|
||||
continue
|
||||
}
|
||||
selected++
|
||||
execution := execution
|
||||
t.Run(fmt.Sprintf("%s/%d", strings.ReplaceAll(execution.CanonicalPath, ".", "/"), execution.Index), func(t *testing.T) {
|
||||
argv, err := cli.ParseManualAgentExampleArgv(execution.Example)
|
||||
argv, err := cli.ParseAgentExampleArgv(execution.Example)
|
||||
if err != nil {
|
||||
t.Fatalf("parse example %q: %v", execution.Example, err)
|
||||
}
|
||||
args := materializeManualAgentExampleArgv(argv[1:], files)
|
||||
if manualAgentExampleHasFlag(args, "yes") {
|
||||
args := materializeAgentExampleArgv(argv[1:], files)
|
||||
if agentExampleHasFlag(args, "yes") {
|
||||
t.Fatalf("dry-run gate must not inject or accept --yes\nsource: %s\nargv: %q", execution.Example, args)
|
||||
}
|
||||
if !manualAgentExampleHasFlag(args, "dry-run") {
|
||||
if !agentExampleHasFlag(args, "dry-run") {
|
||||
args = append([]string{"--dry-run"}, args...)
|
||||
}
|
||||
|
||||
capture, err := executeManualAgentExampleCapture(t, args)
|
||||
capture, err := executeAgentExampleCapture(t, args)
|
||||
if capture.ToolCallAttempts != 0 {
|
||||
t.Fatalf("eligible dry-run attempted %d ToolCaller invocation(s)\nsource: %s\nargv: %q\noutput:\n%s", capture.ToolCallAttempts, execution.Example, args, capture.Output)
|
||||
}
|
||||
if capture.StdinBytesRead != 0 || manualAgentExamplePromptObserved(capture.Output) {
|
||||
if capture.StdinBytesRead != 0 || agentExamplePromptObserved(capture.Output) {
|
||||
t.Fatalf("eligible dry-run entered an interactive confirmation path (stdin bytes read: %d)\nsource: %s\nargv: %q\noutput:\n%s", capture.StdinBytesRead, execution.Example, args, capture.Output)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run example failed: %v\nsource: %s\nargv: %q\noutput:\n%s", err, execution.Example, args, capture.Output)
|
||||
}
|
||||
previewKind, observed := manualAgentExampleDryRunEvidence(capture)
|
||||
previewKind, observed := agentExampleDryRunEvidence(capture)
|
||||
if !observed {
|
||||
t.Fatalf("example returned without audited dry-run evidence (caller dry-run checks: %d)\nsource: %s\nargv: %q\noutput:\n%s", capture.DryRunChecks, execution.Example, args, capture.Output)
|
||||
}
|
||||
@@ -133,27 +140,20 @@ func TestManualAgentExamplesDryRun(t *testing.T) {
|
||||
}
|
||||
sort.Strings(reasonCodes)
|
||||
for _, reasonCode := range reasonCodes {
|
||||
t.Logf("Agent examples contract_only[%s]=%d", reasonCode, plan.ContractOnlyByReason[cli.ManualAgentExampleReasonCode(reasonCode)])
|
||||
t.Logf("Agent examples contract_only[%s]=%d", reasonCode, plan.ContractOnlyByReason[cli.AgentExampleReasonCode(reasonCode)])
|
||||
}
|
||||
}
|
||||
|
||||
// manualAgentExampleShouldExerciseDryRun is the single selection boundary for
|
||||
// agentExampleShouldExerciseDryRun is the single selection boundary for
|
||||
// the runtime gate. Capability comes only from the final typed ToolSpec; the
|
||||
// example disposition may narrow that set but can never invent support.
|
||||
func manualAgentExampleShouldExerciseDryRun(execution cli.ManualAgentExampleExecution) bool {
|
||||
return execution.DryRun != nil && execution.Mode == cli.ManualAgentExampleModeDryRun
|
||||
func agentExampleShouldExerciseDryRun(execution cli.AgentExampleExecution) bool {
|
||||
return execution.DryRun != nil && execution.Mode == cli.AgentExampleModeDryRun
|
||||
}
|
||||
|
||||
func manualAgentExampleExecutionPlan(t testing.TB) cli.ManualAgentExampleExecutionPlan {
|
||||
func agentExampleExecutionPlan(t testing.TB) cli.AgentExampleExecutionPlan {
|
||||
t.Helper()
|
||||
hints, err := cli.LoadAgentHintsFromSelectionForValidation(os.DirFS("../cli/schema_hints/selection"))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAgentHintsFromSelectionForValidation() error = %v", err)
|
||||
}
|
||||
contractRoot := NewRootCommand()
|
||||
if _, err := cli.ApplyEmbeddedManualSchemaHints(contractRoot); err != nil {
|
||||
t.Fatalf("ApplyEmbeddedManualSchemaHints() error = %v", err)
|
||||
}
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(contractRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildEffectiveCommandRegistry() error = %v", err)
|
||||
@@ -169,41 +169,41 @@ func manualAgentExampleExecutionPlan(t testing.TB) cli.ManualAgentExampleExecuti
|
||||
if err := cli.ValidateReviewedDryRunCapabilityDelivery(registry); err != nil {
|
||||
t.Fatalf("ValidateReviewedDryRunCapabilityDelivery() error = %v", err)
|
||||
}
|
||||
plan, err := cli.BuildManualAgentExampleExecutionPlan(bound, registry, hints)
|
||||
plan, err := cli.BuildAgentExampleExecutionPlan(bound, registry)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildManualAgentExampleExecutionPlan() error = %v", err)
|
||||
t.Fatalf("BuildAgentExampleExecutionPlan() error = %v", err)
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
type manualAgentExampleCapture struct {
|
||||
type agentExampleCapture struct {
|
||||
Output string
|
||||
DryRunChecks int64
|
||||
ToolCallAttempts int64
|
||||
StdinBytesRead int64
|
||||
}
|
||||
|
||||
type manualAgentExampleFailClosedCaller struct {
|
||||
type agentExampleFailClosedCaller struct {
|
||||
dryRunChecks atomic.Int64
|
||||
toolCallAttempts atomic.Int64
|
||||
}
|
||||
|
||||
func (c *manualAgentExampleFailClosedCaller) CallTool(_ context.Context, productID, toolName string, _ map[string]any) (*edition.ToolResult, error) {
|
||||
func (c *agentExampleFailClosedCaller) CallTool(_ context.Context, productID, toolName string, _ map[string]any) (*edition.ToolResult, error) {
|
||||
c.toolCallAttempts.Add(1)
|
||||
return nil, fmt.Errorf("real ToolCaller invocation blocked during Agent example dry-run: %s/%s", productID, toolName)
|
||||
}
|
||||
|
||||
func (c *manualAgentExampleFailClosedCaller) Format() string { return "json" }
|
||||
func (c *agentExampleFailClosedCaller) Format() string { return "json" }
|
||||
|
||||
func (c *manualAgentExampleFailClosedCaller) DryRun() bool {
|
||||
func (c *agentExampleFailClosedCaller) DryRun() bool {
|
||||
c.dryRunChecks.Add(1)
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *manualAgentExampleFailClosedCaller) Fields() string { return "" }
|
||||
func (c *manualAgentExampleFailClosedCaller) JQ() string { return "" }
|
||||
func (c *agentExampleFailClosedCaller) Fields() string { return "" }
|
||||
func (c *agentExampleFailClosedCaller) JQ() string { return "" }
|
||||
|
||||
func executeManualAgentExampleCapture(t testing.TB, args []string) (manualAgentExampleCapture, error) {
|
||||
func executeAgentExampleCapture(t testing.TB, args []string) (agentExampleCapture, error) {
|
||||
t.Helper()
|
||||
oldArgs := os.Args
|
||||
os.Args = append([]string{"dws"}, args...)
|
||||
@@ -239,7 +239,7 @@ func executeManualAgentExampleCapture(t testing.TB, args []string) (manualAgentE
|
||||
|
||||
root := NewRootCommand()
|
||||
originalCaller := helpers.GetCaller()
|
||||
auditCaller := &manualAgentExampleFailClosedCaller{}
|
||||
auditCaller := &agentExampleFailClosedCaller{}
|
||||
helpers.InitDeps(auditCaller)
|
||||
defer helpers.InitDeps(originalCaller)
|
||||
var output bytes.Buffer
|
||||
@@ -261,7 +261,7 @@ func executeManualAgentExampleCapture(t testing.TB, args []string) (manualAgentE
|
||||
if err != nil {
|
||||
t.Fatalf("inspect guarded stdin: %v", err)
|
||||
}
|
||||
return manualAgentExampleCapture{
|
||||
return agentExampleCapture{
|
||||
Output: output.String() + string(captured),
|
||||
DryRunChecks: auditCaller.dryRunChecks.Load(),
|
||||
ToolCallAttempts: auditCaller.toolCallAttempts.Load(),
|
||||
@@ -269,7 +269,7 @@ func executeManualAgentExampleCapture(t testing.TB, args []string) (manualAgentE
|
||||
}, execErr
|
||||
}
|
||||
|
||||
type manualAgentExampleFiles struct {
|
||||
type agentExampleFiles struct {
|
||||
root string
|
||||
markdown string
|
||||
json string
|
||||
@@ -278,7 +278,7 @@ type manualAgentExampleFiles struct {
|
||||
image string
|
||||
}
|
||||
|
||||
func newManualAgentExampleFiles(t testing.TB, root string) manualAgentExampleFiles {
|
||||
func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles {
|
||||
t.Helper()
|
||||
markdown := filepath.Join(root, "content.md")
|
||||
jsonFile := filepath.Join(root, "report.json")
|
||||
@@ -296,13 +296,13 @@ func newManualAgentExampleFiles(t testing.TB, root string) manualAgentExampleFil
|
||||
t.Fatalf("write dry-run fixture %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
return manualAgentExampleFiles{root: root, markdown: markdown, json: jsonFile, batch: batch, binary: binary, image: image}
|
||||
return agentExampleFiles{root: root, markdown: markdown, json: jsonFile, batch: batch, binary: binary, image: image}
|
||||
}
|
||||
|
||||
func materializeManualAgentExampleArgv(argv []string, files manualAgentExampleFiles) []string {
|
||||
func materializeAgentExampleArgv(argv []string, files agentExampleFiles) []string {
|
||||
result := append([]string(nil), argv...)
|
||||
for index := range result {
|
||||
result[index] = manualAgentExamplePlaceholderPattern.ReplaceAllStringFunc(result[index], func(match string) string {
|
||||
result[index] = agentExamplePlaceholderPattern.ReplaceAllStringFunc(result[index], func(match string) string {
|
||||
name := strings.TrimSuffix(strings.TrimPrefix(match, "<"), ">")
|
||||
switch strings.ToLower(name) {
|
||||
case "basetime", "remindertimestamp", "reminder-time-stamp":
|
||||
@@ -323,7 +323,7 @@ func materializeManualAgentExampleArgv(argv []string, files manualAgentExampleFi
|
||||
}
|
||||
|
||||
for index := 0; index < len(result); index++ {
|
||||
name, inline, ok := manualAgentExampleLongFlag(result[index])
|
||||
name, inline, ok := agentExampleLongFlag(result[index])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -368,7 +368,7 @@ func materializeManualAgentExampleArgv(argv []string, files manualAgentExampleFi
|
||||
return result
|
||||
}
|
||||
|
||||
func manualAgentExampleLongFlag(argument string) (name, inline string, ok bool) {
|
||||
func agentExampleLongFlag(argument string) (name, inline string, ok bool) {
|
||||
if !strings.HasPrefix(argument, "--") {
|
||||
return "", "", false
|
||||
}
|
||||
@@ -376,7 +376,7 @@ func manualAgentExampleLongFlag(argument string) (name, inline string, ok bool)
|
||||
return name, inline, name != ""
|
||||
}
|
||||
|
||||
func manualAgentExampleHasFlag(argv []string, target string) bool {
|
||||
func agentExampleHasFlag(argv []string, target string) bool {
|
||||
for _, argument := range argv {
|
||||
if argument == "--"+target || strings.HasPrefix(argument, "--"+target+"=") {
|
||||
return true
|
||||
@@ -385,33 +385,36 @@ func manualAgentExampleHasFlag(argv []string, target string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func manualAgentExampleDryRunObserved(capture manualAgentExampleCapture) bool {
|
||||
_, ok := manualAgentExampleDryRunEvidence(capture)
|
||||
func agentExampleDryRunObserved(capture agentExampleCapture) bool {
|
||||
_, ok := agentExampleDryRunEvidence(capture)
|
||||
return ok
|
||||
}
|
||||
|
||||
func manualAgentExampleDryRunEvidence(capture manualAgentExampleCapture) (string, bool) {
|
||||
func agentExampleDryRunEvidence(capture agentExampleCapture) (string, bool) {
|
||||
normalized := strings.ToLower(capture.Output)
|
||||
if manualAgentExampleDryRunJSONPattern.MatchString(capture.Output) && manualAgentExampleDryRunPlanPattern.MatchString(capture.Output) {
|
||||
return cli.DryRunPreviewPlan, true
|
||||
if agentExampleDryRunJSONPattern.MatchString(capture.Output) && agentExampleDryRunPlanPattern.MatchString(capture.Output) {
|
||||
return contract.DryRunPreviewPlan, true
|
||||
}
|
||||
if manualAgentExampleDryRunJSONPattern.MatchString(capture.Output) {
|
||||
return cli.DryRunPreviewRequest, true
|
||||
if agentExampleDryRunJSONPattern.MatchString(capture.Output) && agentExampleDryRunInvocationPattern.MatchString(capture.Output) {
|
||||
return contract.DryRunPreviewInvocation, true
|
||||
}
|
||||
if agentExampleDryRunJSONPattern.MatchString(capture.Output) {
|
||||
return contract.DryRunPreviewRequest, true
|
||||
}
|
||||
if strings.Contains(normalized, "[dry-run]") {
|
||||
return cli.DryRunPreviewInvocation, true
|
||||
return contract.DryRunPreviewInvocation, true
|
||||
}
|
||||
if capture.DryRunChecks > 0 && strings.Contains(capture.Output, "操作:") {
|
||||
return cli.DryRunPreviewPlan, true
|
||||
return contract.DryRunPreviewPlan, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func TestManualAgentExampleDryRunEvidenceAcceptsSharedAndCommandPlans(t *testing.T) {
|
||||
if !manualAgentExampleDryRunObserved(manualAgentExampleCapture{Output: "[DRY-RUN] Preview only, not executed:\nTool: calendar_list"}) {
|
||||
func TestAgentExampleDryRunEvidenceAcceptsSharedAndCommandPlans(t *testing.T) {
|
||||
if !agentExampleDryRunObserved(agentExampleCapture{Output: "[DRY-RUN] Preview only, not executed:\nTool: calendar_list"}) {
|
||||
t.Fatal("dry-run output with a Tool and nil Arguments was not recognized")
|
||||
}
|
||||
if manualAgentExampleDryRunObserved(manualAgentExampleCapture{Output: "Tool: calendar_list"}) {
|
||||
if agentExampleDryRunObserved(agentExampleCapture{Output: "Tool: calendar_list"}) {
|
||||
t.Fatal("a Tool line without dry-run evidence must not be accepted")
|
||||
}
|
||||
for _, falseEvidence := range []string{
|
||||
@@ -419,28 +422,28 @@ func TestManualAgentExampleDryRunEvidenceAcceptsSharedAndCommandPlans(t *testing
|
||||
"Run again with --dry-run to preview the operation",
|
||||
`{"dry_run":false,"executed":true}`,
|
||||
} {
|
||||
if manualAgentExampleDryRunObserved(manualAgentExampleCapture{Output: falseEvidence}) {
|
||||
if agentExampleDryRunObserved(agentExampleCapture{Output: falseEvidence}) {
|
||||
t.Errorf("non-evidence text was mistaken for a successful dry-run: %q", falseEvidence)
|
||||
}
|
||||
}
|
||||
operationSummary := "操作: 下载钉盘文件\n文件ID: test"
|
||||
if manualAgentExampleDryRunObserved(manualAgentExampleCapture{Output: operationSummary}) {
|
||||
if agentExampleDryRunObserved(agentExampleCapture{Output: operationSummary}) {
|
||||
t.Fatal("a human-only operation summary without an audited dry-run check must not be accepted")
|
||||
}
|
||||
if !manualAgentExampleDryRunObserved(manualAgentExampleCapture{Output: operationSummary, DryRunChecks: 1}) {
|
||||
if !agentExampleDryRunObserved(agentExampleCapture{Output: operationSummary, DryRunChecks: 1}) {
|
||||
t.Fatal("a command plan guarded by the injected caller's dry-run check was not recognized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageManualAgentExampleDryRunEvidenceClassifiesStructuredPlan(t *testing.T) {
|
||||
kind, observed := manualAgentExampleDryRunEvidence(manualAgentExampleCapture{
|
||||
func TestCrossPlatformCoverageAgentExampleDryRunEvidenceClassifiesStructuredPlan(t *testing.T) {
|
||||
kind, observed := agentExampleDryRunEvidence(agentExampleCapture{
|
||||
Output: `{"dry_run":true,"executed":false,"preview_kind":"plan"}`,
|
||||
DryRunChecks: 1,
|
||||
})
|
||||
if !observed || kind != cli.DryRunPreviewPlan {
|
||||
if !observed || kind != contract.DryRunPreviewPlan {
|
||||
t.Fatalf("structured plan classified as kind=%q observed=%v", kind, observed)
|
||||
}
|
||||
if manualAgentExampleDryRunObserved(manualAgentExampleCapture{
|
||||
if agentExampleDryRunObserved(agentExampleCapture{
|
||||
Output: `{"dry_run":false,"executed":false,"preview_kind":"plan"}`,
|
||||
DryRunChecks: 1,
|
||||
}) {
|
||||
@@ -448,7 +451,38 @@ func TestCrossPlatformCoverageManualAgentExampleDryRunEvidenceClassifiesStructur
|
||||
}
|
||||
}
|
||||
|
||||
func manualAgentExamplePromptObserved(output string) bool {
|
||||
func TestAgentExampleDryRunEvidenceClassifiesExecutorEnvelopeAsInvocation(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 := agentExampleDryRunEvidence(agentExampleCapture{Output: envelope})
|
||||
if !observed || kind != contract.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 := agentExampleDryRunEvidence(agentExampleCapture{Output: out}); !ok || kind != contract.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 := agentExampleDryRunEvidence(agentExampleCapture{Output: mcpDoc}); !ok || kind != contract.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 := agentExampleDryRunEvidence(agentExampleCapture{Output: planDoc}); !ok || kind != contract.DryRunPreviewPlan {
|
||||
t.Fatalf("plan precedence broken: %q/%v", kind, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func agentExamplePromptObserved(output string) bool {
|
||||
normalized := strings.ToLower(output)
|
||||
for _, marker := range []string{
|
||||
"confirm ",
|
||||
@@ -469,7 +503,7 @@ func manualAgentExamplePromptObserved(output string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func TestManualAgentExamplePromptObservedRejectsInteractiveConfirmation(t *testing.T) {
|
||||
func TestAgentExamplePromptObservedRejectsInteractiveConfirmation(t *testing.T) {
|
||||
for _, prompt := range []string{
|
||||
"Confirm deletion? (yes/no):",
|
||||
"Confirm action? (yes/no):",
|
||||
@@ -480,11 +514,11 @@ func TestManualAgentExamplePromptObservedRejectsInteractiveConfirmation(t *testi
|
||||
"Are you sure you want to continue?",
|
||||
"Operation cancelled",
|
||||
} {
|
||||
if !manualAgentExamplePromptObserved(prompt) {
|
||||
if !agentExamplePromptObserved(prompt) {
|
||||
t.Errorf("interactive confirmation output was not detected: %q", prompt)
|
||||
}
|
||||
}
|
||||
if manualAgentExamplePromptObserved(`{"dry_run":true,"confirmation":"user_required"}`) {
|
||||
if agentExamplePromptObserved(`{"dry_run":true,"confirmation":"user_required"}`) {
|
||||
t.Fatal("typed safety metadata was mistaken for an interactive prompt")
|
||||
}
|
||||
}
|
||||
@@ -498,26 +532,26 @@ func TestAitableAdvpermDisableDryRunSkipsConfirmationAndToolCall(t *testing.T) {
|
||||
"aitable", "advperm", "disable",
|
||||
"--base-id", "BASE_ID",
|
||||
}
|
||||
if manualAgentExampleHasFlag(args, "yes") {
|
||||
if agentExampleHasFlag(args, "yes") {
|
||||
t.Fatal("regression test must not bypass confirmation with --yes")
|
||||
}
|
||||
capture, err := executeManualAgentExampleCapture(t, args)
|
||||
capture, err := executeAgentExampleCapture(t, args)
|
||||
if err != nil {
|
||||
t.Fatalf("advperm disable fail-closed dry-run failed: %v\noutput:\n%s", err, capture.Output)
|
||||
}
|
||||
if capture.StdinBytesRead != 0 || manualAgentExamplePromptObserved(capture.Output) {
|
||||
if capture.StdinBytesRead != 0 || agentExamplePromptObserved(capture.Output) {
|
||||
t.Fatalf("advperm disable dry-run entered confirmation (stdin bytes read: %d)\noutput:\n%s", capture.StdinBytesRead, capture.Output)
|
||||
}
|
||||
if capture.ToolCallAttempts != 0 {
|
||||
t.Fatalf("advperm disable dry-run attempted %d real ToolCaller invocation(s)\noutput:\n%s", capture.ToolCallAttempts, capture.Output)
|
||||
}
|
||||
if !manualAgentExampleDryRunObserved(capture) {
|
||||
if !agentExampleDryRunObserved(capture) {
|
||||
t.Fatalf("advperm disable returned no audited dry-run evidence (caller dry-run checks: %d)\noutput:\n%s", capture.DryRunChecks, capture.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualAgentExampleFailClosedCallerRecordsToolCalls(t *testing.T) {
|
||||
caller := &manualAgentExampleFailClosedCaller{}
|
||||
func TestAgentExampleFailClosedCallerRecordsToolCalls(t *testing.T) {
|
||||
caller := &agentExampleFailClosedCaller{}
|
||||
if !caller.DryRun() {
|
||||
t.Fatal("fail-closed caller must advertise dry-run mode")
|
||||
}
|
||||
@@ -532,7 +566,7 @@ func TestManualAgentExampleFailClosedCallerRecordsToolCalls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualAgentExampleChatGroupMuteMemberUsesCommandDryRunPreview(t *testing.T) {
|
||||
func TestAgentExampleChatGroupMuteMemberUsesCommandDryRunPreview(t *testing.T) {
|
||||
sandboxRoot := t.TempDir()
|
||||
configDir := filepath.Join(sandboxRoot, "config")
|
||||
if err := os.MkdirAll(configDir, 0o700); err != nil {
|
||||
@@ -541,7 +575,7 @@ func TestManualAgentExampleChatGroupMuteMemberUsesCommandDryRunPreview(t *testin
|
||||
setTestHome(t, sandboxRoot)
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
capture, err := executeManualAgentExampleCapture(t, []string{
|
||||
capture, err := executeAgentExampleCapture(t, []string{
|
||||
"--dry-run",
|
||||
"chat", "group-mute-member",
|
||||
"--group", "test_openConversationId",
|
||||
@@ -557,10 +591,10 @@ func TestManualAgentExampleChatGroupMuteMemberUsesCommandDryRunPreview(t *testin
|
||||
if capture.DryRunChecks == 0 {
|
||||
t.Fatalf("group-mute-member did not enter its audited command dry-run path\noutput:\n%s", capture.Output)
|
||||
}
|
||||
if capture.StdinBytesRead != 0 || manualAgentExamplePromptObserved(capture.Output) {
|
||||
if capture.StdinBytesRead != 0 || agentExamplePromptObserved(capture.Output) {
|
||||
t.Fatalf("group-mute-member dry-run entered an interactive prompt (stdin bytes read: %d)\noutput:\n%s", capture.StdinBytesRead, capture.Output)
|
||||
}
|
||||
if !manualAgentExampleDryRunObserved(capture) {
|
||||
if !agentExampleDryRunObserved(capture) {
|
||||
t.Fatalf("group-mute-member returned no audited dry-run evidence\noutput:\n%s", capture.Output)
|
||||
}
|
||||
for _, expected := range []string{`"uids"`, `"userId1"`, `"userId2"`} {
|
||||
|
||||
@@ -14,13 +14,12 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
)
|
||||
|
||||
func TestManualAgentSelectionScenariosCoverEveryExecutableSchemaTool(t *testing.T) {
|
||||
func TestAgentSelectionScenariosCoverEveryExecutableSchemaTool(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
@@ -30,14 +29,9 @@ func TestManualAgentSelectionScenariosCoverEveryExecutableSchemaTool(t *testing.
|
||||
if err != nil {
|
||||
t.Fatalf("BindEffectiveCommandRegistry() error = %v", err)
|
||||
}
|
||||
hints, err := cli.LoadAgentHintsFromSelectionForValidation(os.DirFS("../cli/schema_hints/selection"))
|
||||
fixture, report, err := cli.BuildAgentSelectionEvalFixture(bound)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAgentHintsFromSelectionForValidation() error = %v", err)
|
||||
}
|
||||
|
||||
fixture, report, err := cli.BuildManualAgentSelectionEvalFixture(bound, hints)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildManualAgentSelectionEvalFixture() error = %v", err)
|
||||
t.Fatalf("BuildAgentSelectionEvalFixture() error = %v", err)
|
||||
}
|
||||
if report.Tools != len(bound.Commands) {
|
||||
t.Fatalf("selection tools = %d, bound commands = %d", report.Tools, len(bound.Commands))
|
||||
|
||||
@@ -31,42 +31,42 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
)
|
||||
|
||||
const manualAgentSelectionLiveBatchSize = 12
|
||||
const agentSelectionLiveBatchSize = 12
|
||||
|
||||
type manualAgentSelectionLiveCandidate struct {
|
||||
type agentSelectionLiveCandidate struct {
|
||||
CanonicalPath string `json:"canonical_path"`
|
||||
AgentSummary string `json:"agent_summary"`
|
||||
UseWhen []string `json:"use_when"`
|
||||
AvoidWhen []string `json:"avoid_when"`
|
||||
}
|
||||
|
||||
type manualAgentSelectionLiveInput struct {
|
||||
Cases []manualAgentSelectionLiveCase `json:"cases"`
|
||||
Candidates []manualAgentSelectionLiveCandidate `json:"candidates"`
|
||||
type agentSelectionLiveInput struct {
|
||||
Cases []agentSelectionLiveCase `json:"cases"`
|
||||
Candidates []agentSelectionLiveCandidate `json:"candidates"`
|
||||
}
|
||||
|
||||
// manualAgentSelectionLiveCase is deliberately answer-free. Expected and
|
||||
// agentSelectionLiveCase is deliberately answer-free. Expected and
|
||||
// forbidden canonicals stay only in the local assertion fixture and are never
|
||||
// sent to the model being evaluated.
|
||||
type manualAgentSelectionLiveCase struct {
|
||||
type agentSelectionLiveCase struct {
|
||||
ID string `json:"id"`
|
||||
Scenario string `json:"scenario"`
|
||||
}
|
||||
|
||||
type manualAgentSelectionLiveResult struct {
|
||||
type agentSelectionLiveResult struct {
|
||||
ID string `json:"id"`
|
||||
CanonicalPath string `json:"canonical_path"`
|
||||
}
|
||||
|
||||
type manualAgentSelectionLiveResponse struct {
|
||||
Results []manualAgentSelectionLiveResult `json:"results"`
|
||||
type agentSelectionLiveResponse struct {
|
||||
Results []agentSelectionLiveResult `json:"results"`
|
||||
}
|
||||
|
||||
// TestManualAgentSelectionArkLive is intentionally opt-in. Deterministic CI
|
||||
// TestAgentSelectionArkLive is intentionally opt-in. Deterministic CI
|
||||
// validates all fixture and Cobra facts without network access; this test asks
|
||||
// a real model to interpret the reviewed natural-language scenarios. Set
|
||||
// DWS_AGENT_SELECTION_FULL=1 to evaluate every positive and negative case.
|
||||
func TestManualAgentSelectionArkLive(t *testing.T) {
|
||||
func TestAgentSelectionArkLive(t *testing.T) {
|
||||
if os.Getenv("DWS_AGENT_SELECTION_LIVE") != "1" {
|
||||
t.Skip("set DWS_AGENT_SELECTION_LIVE=1 and ARK_API_KEY/ARK_BASE_URL/ARK_MODEL to run live Agent command-selection evaluation")
|
||||
}
|
||||
@@ -82,47 +82,47 @@ func TestManualAgentSelectionArkLive(t *testing.T) {
|
||||
t.Fatalf("%s is required when DWS_AGENT_SELECTION_LIVE=1", name)
|
||||
}
|
||||
}
|
||||
if err := validateManualAgentSelectionLiveBaseURL(baseURL, os.Getenv("DWS_AGENT_SELECTION_ALLOWED_BASE_URLS")); err != nil {
|
||||
if err := validateAgentSelectionLiveBaseURL(baseURL, os.Getenv("DWS_AGENT_SELECTION_ALLOWED_BASE_URLS")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fixture, hints := manualAgentSelectionLiveFixture(t)
|
||||
cases := selectManualAgentSelectionLiveCases(t, fixture.Cases)
|
||||
for _, batch := range batchManualAgentSelectionLiveCases(cases, manualAgentSelectionLiveBatchSize) {
|
||||
fixture, selectionSet := agentSelectionLiveFixture(t)
|
||||
cases := selectAgentSelectionLiveCases(t, fixture.Cases)
|
||||
for _, batch := range batchAgentSelectionLiveCases(cases, agentSelectionLiveBatchSize) {
|
||||
productID := batch[0].ProductID
|
||||
t.Run(productID+"/"+sanitizeManualAgentSelectionLiveTestID(batch[0].ID), func(t *testing.T) {
|
||||
input := buildManualAgentSelectionLiveInput(batch, hints)
|
||||
results := callManualAgentSelectionLiveModel(t, baseURL, apiKey, model, input)
|
||||
assertManualAgentSelectionLiveResults(t, batch, results)
|
||||
t.Run(productID+"/"+sanitizeAgentSelectionLiveTestID(batch[0].ID), func(t *testing.T) {
|
||||
input := buildAgentSelectionLiveInput(batch, selectionSet)
|
||||
results := callAgentSelectionLiveModel(t, baseURL, apiKey, model, input)
|
||||
assertAgentSelectionLiveResults(t, batch, results)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func buildManualAgentSelectionLiveInput(batch []cli.ManualAgentSelectionCase, hints cli.ManualAgentHintSet) manualAgentSelectionLiveInput {
|
||||
input := manualAgentSelectionLiveInput{Cases: make([]manualAgentSelectionLiveCase, 0, len(batch))}
|
||||
func buildAgentSelectionLiveInput(batch []cli.AgentSelectionCase, selectionSet cli.FixtureAgentSelectionSet) agentSelectionLiveInput {
|
||||
input := agentSelectionLiveInput{Cases: make([]agentSelectionLiveCase, 0, len(batch))}
|
||||
if len(batch) == 0 {
|
||||
return input
|
||||
}
|
||||
for _, selectionCase := range batch {
|
||||
input.Cases = append(input.Cases, manualAgentSelectionLiveCase{
|
||||
input.Cases = append(input.Cases, agentSelectionLiveCase{
|
||||
ID: selectionCase.ID,
|
||||
Scenario: selectionCase.Scenario,
|
||||
})
|
||||
}
|
||||
input.Candidates = make([]manualAgentSelectionLiveCandidate, 0, len(batch[0].CandidateCanonicals))
|
||||
input.Candidates = make([]agentSelectionLiveCandidate, 0, len(batch[0].CandidateCanonicals))
|
||||
for _, canonical := range batch[0].CandidateCanonicals {
|
||||
hint := hints.Tools[canonical]
|
||||
input.Candidates = append(input.Candidates, manualAgentSelectionLiveCandidate{
|
||||
tool := selectionSet.Tools[canonical]
|
||||
input.Candidates = append(input.Candidates, agentSelectionLiveCandidate{
|
||||
CanonicalPath: canonical,
|
||||
AgentSummary: hint.AgentSummary,
|
||||
UseWhen: hint.UseWhen,
|
||||
AvoidWhen: hint.AvoidWhen,
|
||||
AgentSummary: tool.AgentSummary,
|
||||
UseWhen: tool.UseWhen,
|
||||
AvoidWhen: tool.AvoidWhen,
|
||||
})
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func manualAgentSelectionLiveFixture(t testing.TB) (cli.ManualAgentSelectionFixture, cli.ManualAgentHintSet) {
|
||||
func agentSelectionLiveFixture(t testing.TB) (cli.AgentSelectionFixture, cli.FixtureAgentSelectionSet) {
|
||||
t.Helper()
|
||||
root := NewRootCommand()
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
@@ -133,18 +133,14 @@ func manualAgentSelectionLiveFixture(t testing.TB) (cli.ManualAgentSelectionFixt
|
||||
if err != nil {
|
||||
t.Fatalf("BindEffectiveCommandRegistry() error = %v", err)
|
||||
}
|
||||
hints, err := cli.LoadAgentHintsFromSelectionForValidation(os.DirFS("../cli/schema_hints/selection"))
|
||||
fixture, _, err := cli.BuildAgentSelectionEvalFixture(bound)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAgentHintsFromSelectionForValidation() error = %v", err)
|
||||
t.Fatalf("BuildAgentSelectionEvalFixture() error = %v", err)
|
||||
}
|
||||
fixture, _, err := cli.BuildManualAgentSelectionEvalFixture(bound, hints)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildManualAgentSelectionEvalFixture() error = %v", err)
|
||||
}
|
||||
return fixture, hints
|
||||
return fixture, cli.FixtureAgentSelectionSet{}
|
||||
}
|
||||
|
||||
func selectManualAgentSelectionLiveCases(t testing.TB, cases []cli.ManualAgentSelectionCase) []cli.ManualAgentSelectionCase {
|
||||
func selectAgentSelectionLiveCases(t testing.TB, cases []cli.AgentSelectionCase) []cli.AgentSelectionCase {
|
||||
t.Helper()
|
||||
if raw := strings.TrimSpace(os.Getenv("DWS_AGENT_SELECTION_CASES")); raw != "" {
|
||||
selected := map[string]bool{}
|
||||
@@ -153,7 +149,7 @@ func selectManualAgentSelectionLiveCases(t testing.TB, cases []cli.ManualAgentSe
|
||||
selected[id] = true
|
||||
}
|
||||
}
|
||||
result := make([]cli.ManualAgentSelectionCase, 0, len(selected))
|
||||
result := make([]cli.AgentSelectionCase, 0, len(selected))
|
||||
for _, selectionCase := range cases {
|
||||
if selected[selectionCase.ID] {
|
||||
result = append(result, selectionCase)
|
||||
@@ -171,13 +167,13 @@ func selectManualAgentSelectionLiveCases(t testing.TB, cases []cli.ManualAgentSe
|
||||
return result
|
||||
}
|
||||
if os.Getenv("DWS_AGENT_SELECTION_FULL") == "1" {
|
||||
return append([]cli.ManualAgentSelectionCase(nil), cases...)
|
||||
return append([]cli.AgentSelectionCase(nil), cases...)
|
||||
}
|
||||
|
||||
// Smoke mode exercises one positive and one negative scenario per product.
|
||||
seenPositive := map[string]bool{}
|
||||
seenNegative := map[string]bool{}
|
||||
result := make([]cli.ManualAgentSelectionCase, 0)
|
||||
result := make([]cli.AgentSelectionCase, 0)
|
||||
for _, selectionCase := range cases {
|
||||
if selectionCase.ExpectedCanonical != "" && !seenPositive[selectionCase.ProductID] {
|
||||
seenPositive[selectionCase.ProductID] = true
|
||||
@@ -191,11 +187,11 @@ func selectManualAgentSelectionLiveCases(t testing.TB, cases []cli.ManualAgentSe
|
||||
return result
|
||||
}
|
||||
|
||||
func batchManualAgentSelectionLiveCases(cases []cli.ManualAgentSelectionCase, batchSize int) [][]cli.ManualAgentSelectionCase {
|
||||
func batchAgentSelectionLiveCases(cases []cli.AgentSelectionCase, batchSize int) [][]cli.AgentSelectionCase {
|
||||
if batchSize <= 0 {
|
||||
batchSize = 1
|
||||
}
|
||||
grouped := map[string][]cli.ManualAgentSelectionCase{}
|
||||
grouped := map[string][]cli.AgentSelectionCase{}
|
||||
products := make([]string, 0)
|
||||
for _, selectionCase := range cases {
|
||||
if _, ok := grouped[selectionCase.ProductID]; !ok {
|
||||
@@ -204,7 +200,7 @@ func batchManualAgentSelectionLiveCases(cases []cli.ManualAgentSelectionCase, ba
|
||||
grouped[selectionCase.ProductID] = append(grouped[selectionCase.ProductID], selectionCase)
|
||||
}
|
||||
sort.Strings(products)
|
||||
result := make([][]cli.ManualAgentSelectionCase, 0)
|
||||
result := make([][]cli.AgentSelectionCase, 0)
|
||||
for _, productID := range products {
|
||||
productCases := grouped[productID]
|
||||
for start := 0; start < len(productCases); start += batchSize {
|
||||
@@ -212,15 +208,15 @@ func batchManualAgentSelectionLiveCases(cases []cli.ManualAgentSelectionCase, ba
|
||||
if end > len(productCases) {
|
||||
end = len(productCases)
|
||||
}
|
||||
result = append(result, append([]cli.ManualAgentSelectionCase(nil), productCases[start:end]...))
|
||||
result = append(result, append([]cli.AgentSelectionCase(nil), productCases[start:end]...))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func callManualAgentSelectionLiveModel(t testing.TB, baseURL, apiKey, model string, input manualAgentSelectionLiveInput) []manualAgentSelectionLiveResult {
|
||||
func callAgentSelectionLiveModel(t testing.TB, baseURL, apiKey, model string, input agentSelectionLiveInput) []agentSelectionLiveResult {
|
||||
t.Helper()
|
||||
body, err := marshalManualAgentSelectionLiveRequest(baseURL, model, input)
|
||||
body, err := marshalAgentSelectionLiveRequest(baseURL, model, input)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal live selection request: %v", err)
|
||||
}
|
||||
@@ -260,7 +256,7 @@ func callManualAgentSelectionLiveModel(t testing.TB, baseURL, apiKey, model stri
|
||||
if len(envelope.Choices) == 0 || strings.TrimSpace(envelope.Choices[0].Message.Content) == "" {
|
||||
t.Fatal("live selection response has no model content")
|
||||
}
|
||||
var selection manualAgentSelectionLiveResponse
|
||||
var selection agentSelectionLiveResponse
|
||||
decoder := json.NewDecoder(strings.NewReader(envelope.Choices[0].Message.Content))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&selection); err != nil {
|
||||
@@ -269,7 +265,7 @@ func callManualAgentSelectionLiveModel(t testing.TB, baseURL, apiKey, model stri
|
||||
return selection.Results
|
||||
}
|
||||
|
||||
func marshalManualAgentSelectionLiveRequest(baseURL, model string, input manualAgentSelectionLiveInput) ([]byte, error) {
|
||||
func marshalAgentSelectionLiveRequest(baseURL, model string, input agentSelectionLiveInput) ([]byte, error) {
|
||||
inputJSON, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal live selection input: %w", err)
|
||||
@@ -294,9 +290,9 @@ func marshalManualAgentSelectionLiveRequest(baseURL, model string, input manualA
|
||||
return json.Marshal(requestBody)
|
||||
}
|
||||
|
||||
func assertManualAgentSelectionLiveResults(t testing.TB, cases []cli.ManualAgentSelectionCase, results []manualAgentSelectionLiveResult) {
|
||||
func assertAgentSelectionLiveResults(t testing.TB, cases []cli.AgentSelectionCase, results []agentSelectionLiveResult) {
|
||||
t.Helper()
|
||||
byID := make(map[string]manualAgentSelectionLiveResult, len(results))
|
||||
byID := make(map[string]agentSelectionLiveResult, len(results))
|
||||
for _, result := range results {
|
||||
if _, exists := byID[result.ID]; exists {
|
||||
t.Fatalf("live selection returned duplicate case ID %q", result.ID)
|
||||
@@ -315,7 +311,7 @@ func assertManualAgentSelectionLiveResults(t testing.TB, cases []cli.ManualAgent
|
||||
t.Errorf("live selection returned empty canonical for %q", selectionCase.ID)
|
||||
continue
|
||||
}
|
||||
if selected != "none" && !containsManualAgentSelectionCanonical(selectionCase.CandidateCanonicals, selected) {
|
||||
if selected != "none" && !containsAgentSelectionCanonical(selectionCase.CandidateCanonicals, selected) {
|
||||
t.Errorf("live selection returned non-candidate %q for %q", selected, selectionCase.ID)
|
||||
continue
|
||||
}
|
||||
@@ -336,13 +332,13 @@ func assertManualAgentSelectionLiveResults(t testing.TB, cases []cli.ManualAgent
|
||||
}
|
||||
}
|
||||
|
||||
func validateManualAgentSelectionLiveBaseURL(raw, extraAllowed string) error {
|
||||
func validateAgentSelectionLiveBaseURL(raw, extraAllowed string) error {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.User != nil {
|
||||
return fmt.Errorf("ARK_BASE_URL must be an absolute HTTP(S) API base without query or fragment")
|
||||
}
|
||||
if parsed.Scheme == "http" {
|
||||
if !manualAgentSelectionLoopbackHost(parsed.Hostname()) {
|
||||
if !agentSelectionLiveLoopbackHost(parsed.Hostname()) {
|
||||
return fmt.Errorf("ARK_BASE_URL may use plaintext HTTP only for a loopback test endpoint")
|
||||
}
|
||||
return nil
|
||||
@@ -367,7 +363,7 @@ func validateManualAgentSelectionLiveBaseURL(raw, extraAllowed string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func manualAgentSelectionLoopbackHost(host string) bool {
|
||||
func agentSelectionLiveLoopbackHost(host string) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(host), "localhost") {
|
||||
return true
|
||||
}
|
||||
@@ -375,7 +371,7 @@ func manualAgentSelectionLoopbackHost(host string) bool {
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
func containsManualAgentSelectionCanonical(values []string, target string) bool {
|
||||
func containsAgentSelectionCanonical(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
@@ -384,36 +380,36 @@ func containsManualAgentSelectionCanonical(values []string, target string) bool
|
||||
return false
|
||||
}
|
||||
|
||||
func sanitizeManualAgentSelectionLiveTestID(value string) string {
|
||||
func sanitizeAgentSelectionLiveTestID(value string) string {
|
||||
value = strings.ReplaceAll(value, ".", "_")
|
||||
value = strings.ReplaceAll(value, "/", "_")
|
||||
return value
|
||||
}
|
||||
|
||||
func TestManualAgentSelectionLiveResultContract(t *testing.T) {
|
||||
cases := []cli.ManualAgentSelectionCase{
|
||||
func TestAgentSelectionLiveResultContract(t *testing.T) {
|
||||
cases := []cli.AgentSelectionCase{
|
||||
{ID: "sample.search/use_when/0", Scenario: "find an item", ExpectedCanonical: "sample.search", CandidateCanonicals: []string{"sample.create", "sample.search"}},
|
||||
{ID: "sample.search/avoid_when/0", Scenario: "create an item", ForbiddenCanonical: "sample.search", CandidateCanonicals: []string{"sample.create", "sample.search"}},
|
||||
}
|
||||
t.Run("accepts exact positive and negative choices", func(t *testing.T) {
|
||||
assertManualAgentSelectionLiveResults(t, cases, []manualAgentSelectionLiveResult{
|
||||
assertAgentSelectionLiveResults(t, cases, []agentSelectionLiveResult{
|
||||
{ID: cases[0].ID, CanonicalPath: "sample.search"},
|
||||
{ID: cases[1].ID, CanonicalPath: "sample.create"},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestManualAgentSelectionLiveInputDoesNotLeakAssertionsOrRepeatCandidates(t *testing.T) {
|
||||
batch := []cli.ManualAgentSelectionCase{
|
||||
func TestAgentSelectionLiveInputDoesNotLeakAssertionsOrRepeatCandidates(t *testing.T) {
|
||||
batch := []cli.AgentSelectionCase{
|
||||
{ID: "sample.search/use_when/0", Scenario: "find an item", ExpectedCanonical: "sample.search", CandidateCanonicals: []string{"sample.create", "sample.search"}},
|
||||
{ID: "sample.search/avoid_when/0", Scenario: "create an item", ForbiddenCanonical: "sample.search", CandidateCanonicals: []string{"sample.create", "sample.search"}},
|
||||
}
|
||||
hints := cli.ManualAgentHintSet{Tools: map[string]cli.ManualAgentToolHint{
|
||||
selectionSet := cli.FixtureAgentSelectionSet{Tools: map[string]cli.AgentToolSelection{
|
||||
"sample.create": {AgentSummary: "Create an item", UseWhen: []string{"create"}, AvoidWhen: []string{"find"}},
|
||||
"sample.search": {AgentSummary: "Search items", UseWhen: []string{"find"}, AvoidWhen: []string{"create"}},
|
||||
}}
|
||||
input := buildManualAgentSelectionLiveInput(batch, hints)
|
||||
data, err := marshalManualAgentSelectionLiveRequest("https://ark.cn-beijing.volces.com/api/plan/v3", "fixed-model", input)
|
||||
input := buildAgentSelectionLiveInput(batch, selectionSet)
|
||||
data, err := marshalAgentSelectionLiveRequest("https://ark.cn-beijing.volces.com/api/plan/v3", "fixed-model", input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -430,7 +426,7 @@ func TestManualAgentSelectionLiveInputDoesNotLeakAssertionsOrRepeatCandidates(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManualAgentSelectionLiveBaseURL(t *testing.T) {
|
||||
func TestValidateAgentSelectionLiveBaseURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
@@ -450,7 +446,7 @@ func TestValidateManualAgentSelectionLiveBaseURL(t *testing.T) {
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := validateManualAgentSelectionLiveBaseURL(test.baseURL, test.extraAllowed)
|
||||
err := validateAgentSelectionLiveBaseURL(test.baseURL, test.extraAllowed)
|
||||
if test.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("validate base URL: %v", err)
|
||||
|
||||
@@ -10,12 +10,12 @@ import (
|
||||
)
|
||||
|
||||
func TestRuntimeSchemaCompletenessCoversPublicCommandTree(t *testing.T) {
|
||||
exclusions, err := cli.EmbeddedRuntimeSchemaExclusions()
|
||||
exclusions, err := cli.ReviewedRuntimeSchemaExclusions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
if err := cli.ValidateEmbeddedRuntimeSchemaCompleteness(root); err != nil {
|
||||
if err := cli.ValidateRuntimeSchemaCompleteness(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report := cli.RuntimeSchemaCompleteness(root, exclusions)
|
||||
|
||||
@@ -45,7 +45,7 @@ func fullSchemaSnapshotForTest(t testing.TB) cli.SchemaCatalogSnapshot {
|
||||
return fullSchemaSnapshot
|
||||
}
|
||||
|
||||
func TestEmbeddedSchemaContractMapsToExecutableTree(t *testing.T) {
|
||||
func TestDeliverySchemaContractMapsToExecutableTree(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
@@ -63,7 +63,7 @@ func TestEmbeddedSchemaContractMapsToExecutableTree(t *testing.T) {
|
||||
root.SetErr(&stderr)
|
||||
root.SetArgs([]string{"schema", "--all", "--format", "json"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute embedded schema --all: %v; stderr=%s", err, stderr.String())
|
||||
t.Fatalf("execute delivery schema --all: %v; stderr=%s", err, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Products []struct {
|
||||
@@ -73,7 +73,7 @@ func TestEmbeddedSchemaContractMapsToExecutableTree(t *testing.T) {
|
||||
} `json:"products"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode embedded schema --all: %v", err)
|
||||
t.Fatalf("decode delivery schema --all: %v", err)
|
||||
}
|
||||
actual := make(map[string]bool)
|
||||
var duplicates []string
|
||||
@@ -81,7 +81,7 @@ func TestEmbeddedSchemaContractMapsToExecutableTree(t *testing.T) {
|
||||
for _, tool := range product.Tools {
|
||||
canonical := strings.TrimSpace(tool.CanonicalPath)
|
||||
if canonical == "" {
|
||||
t.Fatal("embedded schema --all contains an empty canonical path")
|
||||
t.Fatal("delivery schema --all contains an empty canonical path")
|
||||
}
|
||||
if actual[canonical] {
|
||||
duplicates = append(duplicates, canonical)
|
||||
@@ -91,7 +91,7 @@ func TestEmbeddedSchemaContractMapsToExecutableTree(t *testing.T) {
|
||||
}
|
||||
if len(duplicates) > 0 {
|
||||
sort.Strings(duplicates)
|
||||
t.Fatalf("embedded schema --all contains duplicate canonicals: %v", duplicates)
|
||||
t.Fatalf("delivery schema --all contains duplicate canonicals: %v", duplicates)
|
||||
}
|
||||
|
||||
var missing, extra []string
|
||||
@@ -108,16 +108,16 @@ func TestEmbeddedSchemaContractMapsToExecutableTree(t *testing.T) {
|
||||
if len(missing) > 0 || len(extra) > 0 {
|
||||
sort.Strings(missing)
|
||||
sort.Strings(extra)
|
||||
t.Fatalf("embedded Schema canonical set differs from EffectiveCommandRegistry: missing=%v extra=%v", missing, extra)
|
||||
t.Fatalf("runtime-assembled Schema canonical set differs from EffectiveCommandRegistry: missing=%v extra=%v", missing, extra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedSchemaContractMapsToExecutableTree(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
bindings, err := cli.EmbeddedSchemaParameterBindings()
|
||||
bindings, err := cli.LoadSchemaParameterBindings()
|
||||
if err != nil {
|
||||
t.Fatalf("EmbeddedSchemaParameterBindings() error = %v", err)
|
||||
t.Fatalf("LoadSchemaParameterBindings() error = %v", err)
|
||||
}
|
||||
if len(snapshot.Tools) == 0 {
|
||||
t.Fatal("generated Schema Catalog contains no tools")
|
||||
@@ -370,9 +370,6 @@ func schemaContractStringSlice(value any) []string {
|
||||
// delivered set must always equal the public EffectiveCommandRegistry set.
|
||||
func schemaContractPayloadForBoundCanonicals(t *testing.T, root *cobra.Command, canonicals ...string) cli.SchemaSnapshotPayload {
|
||||
t.Helper()
|
||||
if _, err := cli.ApplyEmbeddedManualSchemaHints(root); err != nil {
|
||||
t.Fatalf("apply manual Schema hints: %v", err)
|
||||
}
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatalf("build effective CommandRegistry: %v", err)
|
||||
@@ -458,14 +455,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 {
|
||||
|
||||
@@ -19,11 +19,11 @@ import (
|
||||
|
||||
// TestFinalSchemaParametersMatchExecutableHelpFlags is the fast, in-process
|
||||
// Help <-> Schema parameter completeness gate. It deliberately starts from the
|
||||
// reviewed registry and its exact Cobra bindings, then compares every public
|
||||
// primary leaf with the final delivered ToolSpec projection. The binder has
|
||||
// already proved that reviewed compatibility leaves have the same executable
|
||||
// contract as their primary, so aliases do not create a second parameter
|
||||
// source here.
|
||||
// collected command identity and its exact Cobra bindings, then compares
|
||||
// every public primary leaf with the final delivered ToolSpec projection. The
|
||||
// binder has already proved that compatibility aliases of a leaf share the
|
||||
// same executable contract as their primary, so aliases do not create a
|
||||
// second parameter source here.
|
||||
func TestFinalSchemaParametersMatchExecutableHelpFlags(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
bound := boundSchemaCommandsForHelpFlagTest(t, root)
|
||||
@@ -31,15 +31,15 @@ func TestFinalSchemaParametersMatchExecutableHelpFlags(t *testing.T) {
|
||||
assertSchemaParametersMatchExecutableHelpFlags(t, bound, snapshot.Tools, "source-built final Schema")
|
||||
}
|
||||
|
||||
// TestEmbeddedSchemaParametersMatchExecutableHelpFlags runs the same exact-set
|
||||
// TestDeliverySchemaParametersMatchExecutableHelpFlags runs the same exact-set
|
||||
// gate against the artifact that ships in the binary. Going through the real
|
||||
// schema --all command is intentional: a stale generated Catalog must fail
|
||||
// even when a fresh source-built snapshot would agree with Cobra Help.
|
||||
func TestEmbeddedSchemaParametersMatchExecutableHelpFlags(t *testing.T) {
|
||||
func TestDeliverySchemaParametersMatchExecutableHelpFlags(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
bound := boundSchemaCommandsForHelpFlagTest(t, root)
|
||||
tools := embeddedSchemaAllToolsForHelpFlagTest(t, root)
|
||||
assertSchemaParametersMatchExecutableHelpFlags(t, bound, tools, "embedded schema --all")
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, root)
|
||||
assertSchemaParametersMatchExecutableHelpFlags(t, bound, tools, "delivery schema --all")
|
||||
}
|
||||
|
||||
func boundSchemaCommandsForHelpFlagTest(t testing.TB, root *cobra.Command) cli.BoundCommandRegistry {
|
||||
@@ -55,14 +55,14 @@ func boundSchemaCommandsForHelpFlagTest(t testing.TB, root *cobra.Command) cli.B
|
||||
return bound
|
||||
}
|
||||
|
||||
func embeddedSchemaAllToolsForHelpFlagTest(t testing.TB, root *cobra.Command) map[string]map[string]any {
|
||||
func deliverySchemaAllToolsForHelpFlagTest(t testing.TB, root *cobra.Command) map[string]map[string]any {
|
||||
t.Helper()
|
||||
var stdout, stderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
root.SetArgs([]string{"schema", "--all", "--format", "json"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute embedded schema --all: %v; stderr=%s", err, stderr.String())
|
||||
t.Fatalf("execute delivery schema --all: %v; stderr=%s", err, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Products []struct {
|
||||
@@ -70,23 +70,23 @@ func embeddedSchemaAllToolsForHelpFlagTest(t testing.TB, root *cobra.Command) ma
|
||||
} `json:"products"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode embedded schema --all: %v", err)
|
||||
t.Fatalf("decode delivery schema --all: %v", err)
|
||||
}
|
||||
tools := make(map[string]map[string]any)
|
||||
for _, product := range payload.Products {
|
||||
for _, tool := range product.Tools {
|
||||
canonical := strings.TrimSpace(schemaContractString(tool["canonical_path"]))
|
||||
if canonical == "" {
|
||||
t.Fatal("embedded schema --all contains an empty canonical path")
|
||||
t.Fatal("delivery schema --all contains an empty canonical path")
|
||||
}
|
||||
if _, exists := tools[canonical]; exists {
|
||||
t.Fatalf("embedded schema --all contains duplicate canonical %q", canonical)
|
||||
t.Fatalf("delivery schema --all contains duplicate canonical %q", canonical)
|
||||
}
|
||||
tools[canonical] = tool
|
||||
}
|
||||
}
|
||||
if len(tools) == 0 {
|
||||
t.Fatal("embedded schema --all contains no tools")
|
||||
t.Fatal("delivery schema --all contains no tools")
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// 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 (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
)
|
||||
|
||||
// TestCollectedIdentityIsValidSingleSource is the standing gate for the
|
||||
// retired reviewed registry: command identity collected from live Cobra
|
||||
// leaves carrying ContractFinal.Identity must be a valid single source for
|
||||
// EffectiveCommandRegistry assembly. It asserts the collector returns a
|
||||
// non-empty spec set with no missing primaries, builds an effective registry
|
||||
// from it, and produces a stable SourceHash across repeated collection walks.
|
||||
func TestCrossPlatformCoverageCollectedIdentityIsValidSingleSource(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
collected, report, err := cli.CollectIdentitySpecs(root)
|
||||
if err != nil {
|
||||
t.Fatalf("collect identity specs: %v", err)
|
||||
}
|
||||
if len(collected) == 0 {
|
||||
t.Fatal("identity collection returned no command specs")
|
||||
}
|
||||
if len(report.MissingPrimary) != 0 {
|
||||
t.Fatalf("identity collection reported missing primaries: %v", report.MissingPrimary)
|
||||
}
|
||||
|
||||
t.Logf("walk leaves=%d withIdentity=%d hiddenPrimaries=%d excluded=%d noIdentity=%d collected=%d",
|
||||
report.Leaves, report.WithIdentity, report.HiddenPrimaries, report.Excluded, len(report.NoIdentity), len(collected))
|
||||
for _, leaf := range report.NoIdentity {
|
||||
t.Logf("NO_IDENTITY_LEAF %s", leaf)
|
||||
}
|
||||
|
||||
effective, err := cli.BuildEffectiveFromSpecs(collected)
|
||||
if err != nil {
|
||||
t.Fatalf("build effective registry from collected specs: %v", err)
|
||||
}
|
||||
sourceHash := effective.SourceHash()
|
||||
t.Logf("collected SourceHash=%s commands=%d", sourceHash, len(effective.Commands))
|
||||
if sourceHash == "" {
|
||||
t.Fatal("effective registry SourceHash is empty")
|
||||
}
|
||||
|
||||
collectedAgain, _, err := cli.CollectIdentitySpecs(root)
|
||||
if err != nil {
|
||||
t.Fatalf("re-collect identity specs: %v", err)
|
||||
}
|
||||
effectiveAgain, err := cli.BuildEffectiveFromSpecs(collectedAgain)
|
||||
if err != nil {
|
||||
t.Fatalf("rebuild effective registry from collected specs: %v", err)
|
||||
}
|
||||
if got := effectiveAgain.SourceHash(); got != sourceHash {
|
||||
t.Fatalf("collected identity SourceHash is not stable across walks: %q vs %q", got, sourceHash)
|
||||
}
|
||||
}
|
||||
@@ -5,74 +5,61 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReviewedRoutedInterfacesReachFinalSchema(t *testing.T) {
|
||||
type interfaceCase struct {
|
||||
canonical string
|
||||
mode string
|
||||
reason string
|
||||
sourceSuffix string
|
||||
canonical string
|
||||
mode string
|
||||
reason string
|
||||
}
|
||||
tests := []interfaceCase{
|
||||
{
|
||||
canonical: "attendance.get_attendance_summary",
|
||||
mode: "composite",
|
||||
reason: "Reviewed unpinned remote adapter: the CLI calls attendance-wukong/get_user_attendance_summary, which is absent from the pinned MCP metadata snapshot; the incompatible attendance/get_attendance_summary contract must not be advertised.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/attendance.json",
|
||||
canonical: "attendance.get_attendance_summary",
|
||||
mode: "composite",
|
||||
reason: "Reviewed unpinned remote adapter: the CLI calls attendance-wukong/get_user_attendance_summary, which is absent from the pinned MCP metadata snapshot; the incompatible attendance/get_attendance_summary contract must not be advertised.",
|
||||
},
|
||||
{
|
||||
canonical: "drive.list_files",
|
||||
mode: "composite",
|
||||
reason: "The CLI command routes by --workspace between drive/list_files and doc/list_nodes, so the reviewed executable wrapper has no single direct MCP interface.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/drive.json",
|
||||
canonical: "drive.list_files",
|
||||
mode: "composite",
|
||||
reason: "The CLI command routes by --workspace between drive/list_files and doc/list_nodes, so the reviewed executable wrapper has no single direct MCP interface.",
|
||||
},
|
||||
{
|
||||
canonical: "chat.search_groups",
|
||||
mode: "composite",
|
||||
reason: "Reviewed unpinned remote adapter: the CLI calls im/search_groups with a flat payload, while the pinned snapshot only contains the incompatible chat/search_groups_by_keyword contract.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/chat.json",
|
||||
canonical: "chat.search_groups",
|
||||
mode: "composite",
|
||||
reason: "Reviewed unpinned remote adapter: the CLI calls im/search_groups with a flat payload, while the pinned snapshot only contains the incompatible chat/search_groups_by_keyword contract.",
|
||||
},
|
||||
{
|
||||
canonical: "sheet.range_batch_set_style",
|
||||
mode: "composite",
|
||||
reason: "The CLI reads a local batch file and performs multiple sheet/update_range calls with local continue-on-error control; the workflow has no single direct MCP interface.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/sheet.json",
|
||||
canonical: "sheet.range_batch_set_style",
|
||||
mode: "composite",
|
||||
reason: "The CLI reads a local batch file and performs multiple sheet/update_range calls with local continue-on-error control; the workflow has no single direct MCP interface.",
|
||||
},
|
||||
{
|
||||
canonical: "sheet.range_read",
|
||||
mode: "composite",
|
||||
reason: "Reviewed unpinned remote adapter: the CLI calls sheet/get_cell_infos, which is absent from the pinned MCP metadata snapshot; the incompatible sheet/get_range contract must not be advertised.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/sheet.json",
|
||||
canonical: "sheet.range_read",
|
||||
mode: "composite",
|
||||
reason: "Reviewed unpinned remote adapter: the CLI calls sheet/get_cell_infos, which is absent from the pinned MCP metadata snapshot; the incompatible sheet/get_range contract must not be advertised.",
|
||||
},
|
||||
{
|
||||
canonical: "wiki.list_wikiSpaces",
|
||||
mode: "composite",
|
||||
reason: "The CLI command routes by --type between wiki/list_wikiSpaces and drive/list_spaces, so the reviewed executable wrapper has no single direct MCP interface.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/wiki.json",
|
||||
canonical: "wiki.list_wikiSpaces",
|
||||
mode: "composite",
|
||||
reason: "The CLI command routes by --type between wiki/list_wikiSpaces and drive/list_spaces, so the reviewed executable wrapper has no single direct MCP interface.",
|
||||
},
|
||||
{
|
||||
canonical: "event.consume",
|
||||
mode: "composite",
|
||||
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.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/event.json",
|
||||
canonical: "event.consume",
|
||||
mode: "composite",
|
||||
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.",
|
||||
},
|
||||
{
|
||||
canonical: "event.status",
|
||||
mode: "composite",
|
||||
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.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/event.json",
|
||||
canonical: "event.status",
|
||||
mode: "composite",
|
||||
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.",
|
||||
},
|
||||
{
|
||||
canonical: "event.stop",
|
||||
mode: "composite",
|
||||
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.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/event.json",
|
||||
canonical: "event.stop",
|
||||
mode: "composite",
|
||||
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.",
|
||||
},
|
||||
}
|
||||
for _, canonical := range []string{
|
||||
@@ -82,10 +69,9 @@ func TestReviewedRoutedInterfacesReachFinalSchema(t *testing.T) {
|
||||
"aitable.view_update_timebar",
|
||||
} {
|
||||
tests = append(tests, interfaceCase{
|
||||
canonical: canonical,
|
||||
mode: "composite",
|
||||
reason: "The CLI performs an aitable/get_views preflight, locally transforms the requested configuration, and then calls aitable/update_view; the two-call workflow has no single direct MCP interface.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/aitable.json",
|
||||
canonical: canonical,
|
||||
mode: "composite",
|
||||
reason: "The CLI performs an aitable/get_views preflight, locally transforms the requested configuration, and then calls aitable/update_view; the two-call workflow has no single direct MCP interface.",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -119,11 +105,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,151 +160,81 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReviewedInterfaceDispositionSourceOwnsRuntimeSurface asserts that every
|
||||
// delivered Catalog tool owns interface disposition via ContractFinal
|
||||
// (corecmd.contract). Former schema_hints audit JSON
|
||||
// (runtime-surface-completeness / zz-interface-disposition-review) is retired.
|
||||
func TestReviewedInterfaceDispositionSourceOwnsRuntimeSurface(t *testing.T) {
|
||||
type hintFile struct {
|
||||
Source map[string]any `json:"source"`
|
||||
Tools map[string]map[string]any `json:"tools"`
|
||||
}
|
||||
load := func(path string) hintFile {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
var value hintFile
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
t.Fatalf("decode %s: %v", path, err)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
if dispositions.Source["reviewed"] != true {
|
||||
t.Fatalf("interface disposition source reviewed = %#v, want true", dispositions.Source["reviewed"])
|
||||
}
|
||||
for canonical, hint := range runtimeSurface.Tools {
|
||||
if hint["reviewed"] != false {
|
||||
t.Errorf("%s runtime surface reviewed = %#v, want false", canonical, hint["reviewed"])
|
||||
}
|
||||
for _, field := range []string{"interface_mode", "availability", "interface_ref", "interface_reason"} {
|
||||
if _, exists := hint[field]; exists {
|
||||
t.Errorf("%s runtime surface still owns %s", canonical, field)
|
||||
}
|
||||
}
|
||||
if _, exists := dispositions.Tools[canonical]; !exists {
|
||||
t.Errorf("%s runtime surface has no reviewed interface disposition", canonical)
|
||||
}
|
||||
}
|
||||
|
||||
allowedFields := map[string]bool{
|
||||
"interface_mode": true,
|
||||
"availability": true,
|
||||
"interface_ref": true,
|
||||
"interface_reason": true,
|
||||
}
|
||||
canonicals := make([]string, 0, len(dispositions.Tools))
|
||||
for canonical, hint := range dispositions.Tools {
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
|
||||
canonicals := make([]string, 0, len(tools))
|
||||
for canonical := range tools {
|
||||
canonicals = append(canonicals, canonical)
|
||||
for field := range hint {
|
||||
if !allowedFields[field] {
|
||||
t.Errorf("%s interface-only source contains non-interface field %s", canonical, field)
|
||||
}
|
||||
}
|
||||
mode := schemaContractString(hint["interface_mode"])
|
||||
if mode == "local" {
|
||||
t.Errorf("%s remote interface review is incorrectly classified local", canonical)
|
||||
}
|
||||
if schemaContractString(hint["availability"]) != "available" {
|
||||
t.Errorf("%s reviewed disposition is not available", canonical)
|
||||
}
|
||||
switch mode {
|
||||
case "mcp":
|
||||
ref := schemaInterfaceObject(hint["interface_ref"])
|
||||
if schemaContractString(ref["product_id"]) == "" || schemaContractString(ref["rpc_name"]) == "" {
|
||||
t.Errorf("%s reviewed mcp disposition has no complete interface_ref", canonical)
|
||||
}
|
||||
case "composite":
|
||||
if hint["interface_ref"] != nil {
|
||||
t.Errorf("%s reviewed composite disposition advertises interface_ref %#v", canonical, hint["interface_ref"])
|
||||
}
|
||||
if schemaContractString(hint["interface_reason"]) == "" {
|
||||
t.Errorf("%s reviewed composite disposition has no reason", canonical)
|
||||
}
|
||||
default:
|
||||
t.Errorf("%s reviewed disposition mode = %q", canonical, mode)
|
||||
}
|
||||
}
|
||||
sort.Strings(canonicals)
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
|
||||
if len(canonicals) == 0 {
|
||||
t.Fatal("delivery schema --all contains no tools")
|
||||
}
|
||||
for _, canonical := range canonicals {
|
||||
want := dispositions.Tools[canonical]
|
||||
tool := payload.Tools[canonical]
|
||||
if got := schemaContractString(tool["interface_mode"]); got != schemaContractString(want["interface_mode"]) {
|
||||
t.Errorf("%s final interface_mode = %q, want %q", canonical, got, want["interface_mode"])
|
||||
tool := tools[canonical]
|
||||
mode := schemaContractString(tool["interface_mode"])
|
||||
availability := schemaContractString(tool["availability"])
|
||||
switch mode {
|
||||
case "mcp", "composite", "local":
|
||||
default:
|
||||
t.Errorf("%s interface_mode = %q, want mcp|composite|local", canonical, mode)
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != schemaContractString(want["availability"]) {
|
||||
t.Errorf("%s final availability = %q, want %q", canonical, got, want["availability"])
|
||||
switch availability {
|
||||
case "available", "unavailable":
|
||||
default:
|
||||
t.Errorf("%s availability = %q, want available|unavailable", canonical, availability)
|
||||
}
|
||||
if schemaContractString(want["interface_mode"]) == "composite" {
|
||||
switch {
|
||||
case availability == "unavailable":
|
||||
if tool["interface_ref"] != nil {
|
||||
t.Errorf("%s final composite interface_ref = %#v, want nil", canonical, tool["interface_ref"])
|
||||
t.Errorf("%s unavailable interface_ref = %#v, want nil", canonical, tool["interface_ref"])
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got != schemaContractString(want["interface_reason"]) {
|
||||
t.Errorf("%s final interface_reason = %q, want %q", canonical, got, want["interface_reason"])
|
||||
if schemaContractString(tool["interface_reason"]) == "" {
|
||||
t.Errorf("%s unavailable disposition missing interface_reason", canonical)
|
||||
}
|
||||
} else {
|
||||
gotRef := schemaInterfaceObject(tool["interface_ref"])
|
||||
wantRef := schemaInterfaceObject(want["interface_ref"])
|
||||
for _, field := range []string{"product_id", "rpc_name"} {
|
||||
if got := schemaContractString(gotRef[field]); got != schemaContractString(wantRef[field]) {
|
||||
t.Errorf("%s final interface_ref.%s = %q, want %q", canonical, field, got, wantRef[field])
|
||||
}
|
||||
case mode == "mcp":
|
||||
ref := schemaInterfaceObject(tool["interface_ref"])
|
||||
if schemaContractString(ref["product_id"]) == "" || schemaContractString(ref["rpc_name"]) == "" {
|
||||
t.Errorf("%s mcp disposition has incomplete interface_ref", canonical)
|
||||
}
|
||||
case mode == "composite":
|
||||
if tool["interface_ref"] != nil {
|
||||
t.Errorf("%s composite interface_ref = %#v, want nil", canonical, tool["interface_ref"])
|
||||
}
|
||||
if schemaContractString(tool["interface_reason"]) == "" {
|
||||
t.Errorf("%s composite disposition missing interface_reason", canonical)
|
||||
}
|
||||
case mode == "local":
|
||||
if tool["interface_ref"] != nil {
|
||||
t.Errorf("%s local interface_ref = %#v, want nil", canonical, tool["interface_ref"])
|
||||
}
|
||||
}
|
||||
provenance := schemaContractMap(tool["field_provenance"])
|
||||
fields := []string{"interface_mode", "availability", "interface_ref"}
|
||||
if schemaContractString(want["interface_mode"]) == "composite" {
|
||||
if mode == "composite" || availability == "unavailable" {
|
||||
fields = append(fields, "interface_reason")
|
||||
}
|
||||
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 %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 %s source = %q, want corecmd.contract", canonical, field, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func TestOAApprovalDualModeConstraintsReachEmbeddedSchema(t *testing.T) {
|
||||
tools := embeddedSchemaAllToolsForHelpFlagTest(t, NewRootCommand())
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
|
||||
|
||||
tests := []struct {
|
||||
canonical string
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// 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"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
)
|
||||
|
||||
const schemaResolveMetaCacheChildEnv = "DWS_SCHEMA_RESOLVE_META_CACHE_CHILD"
|
||||
|
||||
// TestResolveMetaAndLeafHelpReuseAssembledMetaCache proves production
|
||||
// RegisterSchemaSourceRoot → delivery Once caches CommandMeta: first
|
||||
// ResolveMeta pays assembly once; subsequent ResolveMeta and leaf --help
|
||||
// Safety do not increment the Catalog counter.
|
||||
func TestResolveMetaAndLeafHelpReuseAssembledMetaCache(t *testing.T) {
|
||||
if os.Getenv(schemaResolveMetaCacheChildEnv) == "1" {
|
||||
registerSchemaRuntimeDelivery()
|
||||
|
||||
counts := cli.RuntimeSchemaMetadataLoadCounts()
|
||||
if counts.Catalog != 0 || counts.MetaIndex != 0 {
|
||||
t.Fatalf("precondition Catalog/MetaIndex = %#v", counts)
|
||||
}
|
||||
|
||||
meta, ok := cli.ResolveMeta("dev app delete")
|
||||
if !ok || meta.Identity.Canonical == "" {
|
||||
t.Fatalf("ResolveMeta(dev app delete) = %#v ok=%v", meta, ok)
|
||||
}
|
||||
counts = cli.RuntimeSchemaMetadataLoadCounts()
|
||||
if counts.Catalog != 1 || counts.MetaIndex != 1 {
|
||||
t.Fatalf("after first ResolveMeta counts = %#v, want Catalog=1 MetaIndex=1", counts)
|
||||
}
|
||||
|
||||
for range 4 {
|
||||
if _, ok := cli.ResolveMeta("dev app delete"); !ok {
|
||||
t.Fatal("steady ResolveMeta ok=false")
|
||||
}
|
||||
}
|
||||
counts = cli.RuntimeSchemaMetadataLoadCounts()
|
||||
if counts.Catalog != 1 || counts.MetaIndex != 1 {
|
||||
t.Fatalf("after steady ResolveMeta counts = %#v", counts)
|
||||
}
|
||||
|
||||
root := NewRootCommand()
|
||||
var helpOut bytes.Buffer
|
||||
root.SetOut(&helpOut)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"dev", "app", "delete", "--help"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("dev app delete --help: %v", err)
|
||||
}
|
||||
if meta.Safety.ShouldRender() && !strings.Contains(helpOut.String(), "Safety:") {
|
||||
t.Fatalf("leaf --help missing Safety annotation; output=%q", helpOut.String())
|
||||
}
|
||||
counts = cli.RuntimeSchemaMetadataLoadCounts()
|
||||
if counts.Catalog != 1 || counts.MetaIndex != 1 {
|
||||
t.Fatalf("leaf --help re-assembled Schema: %#v", counts)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
command := exec.Command(os.Args[0], "-test.run=^TestResolveMetaAndLeafHelpReuseAssembledMetaCache$", "-test.count=1")
|
||||
command.Env = append(os.Environ(), schemaResolveMetaCacheChildEnv+"=1")
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMeta cache child failed: %v\n%s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
}
|
||||
@@ -15,45 +15,54 @@ type finalSchemaSafetyWant struct {
|
||||
risk string
|
||||
confirmation string
|
||||
idempotency string
|
||||
// provenance is the expected field_provenance precedence; empty defaults
|
||||
// to "contract_final" (DeclareLeafMetadata / corecmd.Contract). Production
|
||||
// metadata shells no longer carry reviewed tool rows.
|
||||
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;effect/risk 逐字
|
||||
// 保持 merge-base 评审值(写操作一律 high,publish 为 write/high),
|
||||
// 重分级需要独立的契约变更 PR。
|
||||
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: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.apply_dev_app_permissions", effect: "write", risk: "high", 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: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.disable_dev_app_robot", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.enable_dev_app", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.enable_dev_app_robot", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.publish_dev_app_version", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.remove_dev_app_members", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.remove_dev_app_permissions", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.set_extension_robot_config", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.set_extension_webapp_config", effect: "write", risk: "high", 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: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.unsubscribe_dev_app_events", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.update_dev_app", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.update_dev_app_security_config", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
}
|
||||
assertFinalSchemaSafety(t, wants)
|
||||
}
|
||||
@@ -77,13 +86,17 @@ func assertFinalSchemaSafety(t *testing.T, wants []finalSchemaSafetyWant) {
|
||||
"confirmation": want.confirmation,
|
||||
"idempotency": want.idempotency,
|
||||
}
|
||||
wantProvenance := want.provenance
|
||||
if wantProvenance == "" {
|
||||
wantProvenance = "contract_final"
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -16,12 +16,16 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 265
|
||||
schemaPublishedShortcutCount = 215
|
||||
publicShortcutCount = 266
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including hidden leaves such as minutes.shortcut_minutes_search.
|
||||
schemaPublishedShortcutCount = 216
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 215
|
||||
)
|
||||
|
||||
func TestEmbeddedSchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
tools := embeddedSchemaAllToolsForHelpFlagTest(t, NewRootCommand())
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
|
||||
public := make([]shortcut.Shortcut, 0, publicShortcutCount)
|
||||
for _, candidate := range shortcut.All() {
|
||||
if candidate.UserDefined || !shortcut.InPublicCatalog(candidate.Service, candidate.Command) {
|
||||
@@ -40,10 +44,10 @@ func TestEmbeddedSchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *tes
|
||||
}
|
||||
}
|
||||
if deliveredShortcuts != schemaPublishedShortcutCount {
|
||||
t.Fatalf("embedded schema --all shortcut tools = %d, want %d", deliveredShortcuts, schemaPublishedShortcutCount)
|
||||
t.Fatalf("delivery schema --all shortcut tools = %d, want %d", deliveredShortcuts, schemaPublishedShortcutCount)
|
||||
}
|
||||
|
||||
exclusions, err := cli.EmbeddedRuntimeSchemaExclusions()
|
||||
exclusions, err := cli.ReviewedRuntimeSchemaExclusions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -64,23 +68,23 @@ func TestEmbeddedSchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *tes
|
||||
if tool == nil {
|
||||
cliPath := declared.Service + " " + declared.Command
|
||||
if !excludedPaths[cliPath] {
|
||||
t.Fatalf("embedded schema --all is missing %s (%s) without an exact reviewed exclusion", canonical, cliPath)
|
||||
t.Fatalf("delivery schema --all is missing %s (%s) without an exact reviewed exclusion", canonical, cliPath)
|
||||
}
|
||||
excludedShortcuts++
|
||||
return
|
||||
}
|
||||
assertEmbeddedShortcutIdentityAndSelection(t, tool, declared, canonical)
|
||||
assertEmbeddedShortcutSafetyAndInterface(t, tool, declared, canonical)
|
||||
assertEmbeddedShortcutParameters(t, tool, declared, canonical)
|
||||
assertEmbeddedShortcutConstraints(t, tool, declared, canonical)
|
||||
assertDeliveryShortcutIdentityAndSelection(t, tool, declared, canonical)
|
||||
assertDeliveryShortcutSafetyAndInterface(t, tool, declared, canonical)
|
||||
assertDeliveryShortcutParameters(t, tool, declared, canonical)
|
||||
assertDeliveryShortcutConstraints(t, tool, declared, canonical)
|
||||
})
|
||||
}
|
||||
if got, want := excludedShortcuts, publicShortcutCount-schemaPublishedShortcutCount; got != want {
|
||||
if got, want := excludedShortcuts, publicShortcutCount-publiclyDeliveredShortcutCount; got != want {
|
||||
t.Fatalf("exactly excluded public shortcuts = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T) {
|
||||
func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "chat +messages-read-status")
|
||||
if got, want := schemaContractString(leaf["canonical_path"]), "chat.shortcut_messages_read_status"; got != want {
|
||||
t.Fatalf("shortcut leaf canonical_path = %q, want %q", got, want)
|
||||
@@ -89,11 +93,15 @@ func TestEmbeddedShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
t.Fatalf("shortcut leaf confirmation = %q, want %q", got, want)
|
||||
}
|
||||
conversationID := schemaContractMap(leaf["parameters"])["conversation-id"]
|
||||
if required, _ := conversationID["required"].(bool); !required {
|
||||
t.Fatal("public --conversation-id must become required after hidden compatibility aliases are removed from Schema")
|
||||
if required, _ := conversationID["required"].(bool); required {
|
||||
t.Fatal("public --conversation-id must stay optional when hidden siblings still satisfy the declared exactly_one group")
|
||||
}
|
||||
if got := leaf["constraints"]; got != nil {
|
||||
t.Fatalf("shortcut leaf constraints = %#v, want omitted after hidden compatibility aliases collapse", got)
|
||||
wantMessagesConstraints := map[string]any{
|
||||
"require_one_of": [][]string{{"conversation-id", "group", "id"}},
|
||||
"mutually_exclusive": [][]string{{"conversation-id", "group", "id"}},
|
||||
}
|
||||
if got := leaf["constraints"]; !schemaContractJSONEqual(got, wantMessagesConstraints) {
|
||||
t.Fatalf("shortcut leaf constraints = %#v, want %#v", got, wantMessagesConstraints)
|
||||
}
|
||||
|
||||
constrainedLeaf := executeShortcutSchemaQuery(t, "--cli-path", "calendar +freebusy")
|
||||
@@ -143,7 +151,7 @@ func shortcutSchemaCanonical(declared shortcut.Shortcut) string {
|
||||
return declared.Service + ".shortcut_" + name
|
||||
}
|
||||
|
||||
func assertEmbeddedShortcutIdentityAndSelection(
|
||||
func assertDeliveryShortcutIdentityAndSelection(
|
||||
t testing.TB,
|
||||
tool map[string]any,
|
||||
declared shortcut.Shortcut,
|
||||
@@ -179,7 +187,7 @@ func assertEmbeddedShortcutIdentityAndSelection(
|
||||
}
|
||||
}
|
||||
|
||||
func assertEmbeddedShortcutSafetyAndInterface(
|
||||
func assertDeliveryShortcutSafetyAndInterface(
|
||||
t testing.TB,
|
||||
tool map[string]any,
|
||||
declared shortcut.Shortcut,
|
||||
@@ -214,7 +222,7 @@ func assertEmbeddedShortcutSafetyAndInterface(
|
||||
}
|
||||
}
|
||||
|
||||
func assertEmbeddedShortcutParameters(
|
||||
func assertDeliveryShortcutParameters(
|
||||
t testing.TB,
|
||||
tool map[string]any,
|
||||
declared shortcut.Shortcut,
|
||||
@@ -308,14 +316,20 @@ func shortcutSchemaRequired(declared shortcut.Shortcut, flagName string) bool {
|
||||
visible = append(visible, constrained)
|
||||
}
|
||||
}
|
||||
if len(visible) == 1 && visible[0] == flagName {
|
||||
// Match AnnotateConstraints: only collapse to required when the projected
|
||||
// group has a single member (no remaining hidden siblings).
|
||||
flags := visible
|
||||
if len(visible) < len(constraint.Flags) {
|
||||
flags = append([]string(nil), constraint.Flags...)
|
||||
}
|
||||
if len(flags) == 1 && flags[0] == flagName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func assertEmbeddedShortcutConstraints(
|
||||
func assertDeliveryShortcutConstraints(
|
||||
t testing.TB,
|
||||
tool map[string]any,
|
||||
declared shortcut.Shortcut,
|
||||
@@ -330,12 +344,18 @@ func assertEmbeddedShortcutConstraints(
|
||||
}
|
||||
want := map[string][][]string{}
|
||||
for _, constraint := range declared.Constraints {
|
||||
flags := make([]string, 0, len(constraint.Flags))
|
||||
visible := make([]string, 0, len(constraint.Flags))
|
||||
for _, flagName := range constraint.Flags {
|
||||
if public[flagName] {
|
||||
flags = append(flags, flagName)
|
||||
visible = append(visible, flagName)
|
||||
}
|
||||
}
|
||||
// Match AnnotateConstraints declare≡execute projection: keep the full
|
||||
// declared group when any hidden sibling remains.
|
||||
flags := visible
|
||||
if len(visible) < len(constraint.Flags) {
|
||||
flags = append([]string(nil), constraint.Flags...)
|
||||
}
|
||||
switch constraint.Kind {
|
||||
case shortcut.ConstraintAtLeastOne:
|
||||
if len(flags) > 1 {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// 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 (
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var registerSchemaRuntimeDeliveryOnce sync.Once
|
||||
|
||||
// registerSchemaRuntimeDelivery installs the ResolveSchemaBuild root factory
|
||||
// for production Catalog / ResolveMeta delivery. Called from NewRootCommand
|
||||
// (and optionally cmd entrypoints). Intentionally NOT an init() side effect:
|
||||
// importing app from package cli_test must not flip package-cli tests onto
|
||||
// the assembly path.
|
||||
func registerSchemaRuntimeDelivery() {
|
||||
registerSchemaRuntimeDeliveryOnce.Do(func() {
|
||||
cli.RegisterSchemaSourceRoot(func() *cobra.Command {
|
||||
return NewSchemaSourceRootCommand()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
)
|
||||
|
||||
// TestCrossPlatformCoverageRegisterSchemaRuntimeDelivery covers the production
|
||||
// RegisterSchemaSourceRoot install path used by NewRootCommand.
|
||||
func TestCrossPlatformCoverageRegisterSchemaRuntimeDelivery(t *testing.T) {
|
||||
registerSchemaRuntimeDelivery()
|
||||
if !cli.SchemaSourceRootRegistered() {
|
||||
t.Fatal("registerSchemaRuntimeDelivery did not install Schema source root")
|
||||
}
|
||||
meta, ok := cli.ResolveMeta("dev app delete")
|
||||
if !ok || meta.Identity.Canonical == "" {
|
||||
t.Fatalf("ResolveMeta after registerSchemaRuntimeDelivery = %#v ok=%v", meta, ok)
|
||||
}
|
||||
safety, ok := cli.SafetyForCLIPath("dev app delete")
|
||||
if !ok || safety.Effect == "" {
|
||||
t.Fatalf("SafetyForCLIPath after register = %#v ok=%v", safety, ok)
|
||||
}
|
||||
// Idempotent: Once must not panic or clear the factory.
|
||||
registerSchemaRuntimeDelivery()
|
||||
if !cli.SchemaSourceRootRegistered() {
|
||||
t.Fatal("second registerSchemaRuntimeDelivery cleared Schema source root")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// schemaSourceRootMarkerCaller is a recognizable ToolCaller used to detect
|
||||
// InitDeps clobbering when NewSchemaSourceRootCommand builds the tree.
|
||||
type schemaSourceRootMarkerCaller struct{}
|
||||
|
||||
func (schemaSourceRootMarkerCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
return &edition.ToolResult{}, nil
|
||||
}
|
||||
func (schemaSourceRootMarkerCaller) Format() string { return "json" }
|
||||
func (schemaSourceRootMarkerCaller) DryRun() bool { return false }
|
||||
func (schemaSourceRootMarkerCaller) Fields() string { return "" }
|
||||
func (schemaSourceRootMarkerCaller) JQ() string { return "" }
|
||||
|
||||
// TestSchemaSourceRootPreservesRuntimeDepsAndPluginEndpoints ensures Schema
|
||||
// assembly (NewSchemaSourceRootCommand / ResolveMeta delivery) does not call
|
||||
// helpers.InitDeps or SetDynamicServers, which would wipe a live process's
|
||||
// caller and plugin endpoints.
|
||||
func TestSchemaSourceRootPreservesRuntimeDepsAndPluginEndpoints(t *testing.T) {
|
||||
const (
|
||||
pluginID = "schema-source-root-plugin-marker"
|
||||
pluginEndpoint = "https://schema-source-root-plugin.example/mcp"
|
||||
)
|
||||
|
||||
dynamicMu.RLock()
|
||||
previousEndpoints := dynamicEndpoints
|
||||
previousProducts := dynamicProducts
|
||||
previousAliases := dynamicAliases
|
||||
previousToolEndpoints := dynamicToolEndpoints
|
||||
dynamicMu.RUnlock()
|
||||
t.Cleanup(func() {
|
||||
dynamicMu.Lock()
|
||||
dynamicEndpoints = previousEndpoints
|
||||
dynamicProducts = previousProducts
|
||||
dynamicAliases = previousAliases
|
||||
dynamicToolEndpoints = previousToolEndpoints
|
||||
dynamicMu.Unlock()
|
||||
})
|
||||
|
||||
marker := schemaSourceRootMarkerCaller{}
|
||||
helpers.InitDepsForTest(t, marker)
|
||||
SetDynamicServers(nil)
|
||||
AppendDynamicServer(mcptypes.ServerDescriptor{
|
||||
Key: pluginID,
|
||||
Endpoint: pluginEndpoint,
|
||||
CLI: mcptypes.CLIOverlay{
|
||||
ID: pluginID,
|
||||
Command: pluginID,
|
||||
},
|
||||
})
|
||||
|
||||
root := NewSchemaSourceRootCommand()
|
||||
if root == nil {
|
||||
t.Fatal("NewSchemaSourceRootCommand returned nil")
|
||||
}
|
||||
|
||||
if got := helpers.GetCaller(); got != marker {
|
||||
t.Fatalf("helpers.GetCaller() after NewSchemaSourceRootCommand = %T (%p), want marker caller preserved", got, got)
|
||||
}
|
||||
gotEndpoint, ok := directRuntimeEndpoint(pluginID, "")
|
||||
if !ok || gotEndpoint != pluginEndpoint {
|
||||
t.Fatalf("plugin endpoint after NewSchemaSourceRootCommand = %q ok=%v, want %q preserved", gotEndpoint, ok, pluginEndpoint)
|
||||
}
|
||||
|
||||
resolved, err := cli.ResolveSchemaBuild(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveSchemaBuild after declaration-only root: %v", err)
|
||||
}
|
||||
if resolved.CommandCount() == 0 {
|
||||
t.Fatal("ResolveSchemaBuild command count is 0")
|
||||
}
|
||||
|
||||
// Delivery path also builds NewSchemaSourceRootCommand via the factory.
|
||||
registerSchemaRuntimeDelivery()
|
||||
meta, ok := cli.ResolveMeta("dev app delete")
|
||||
if !ok || meta.Identity.Canonical == "" {
|
||||
t.Fatalf("ResolveMeta after registerSchemaRuntimeDelivery = %#v ok=%v", meta, ok)
|
||||
}
|
||||
if got := helpers.GetCaller(); got != marker {
|
||||
t.Fatalf("helpers.GetCaller() after ResolveMeta delivery = %T, want marker caller preserved", got)
|
||||
}
|
||||
gotEndpoint, ok = directRuntimeEndpoint(pluginID, "")
|
||||
if !ok || gotEndpoint != pluginEndpoint {
|
||||
t.Fatalf("plugin endpoint after ResolveMeta delivery = %q ok=%v, want %q preserved", gotEndpoint, ok, pluginEndpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageSchemaSourceRootStaticServerVisibilityWithoutInject
|
||||
// proves the declaration-only Schema source root does not need
|
||||
// injectStaticServers to keep StaticServers products visible. VisibleProducts
|
||||
// may be unset; StaticServers alone must still prevent
|
||||
// hideNonDirectRuntimeCommands from marking those products Hidden — without
|
||||
// mutating dynamic endpoints.
|
||||
func TestCrossPlatformCoverageSchemaSourceRootStaticServerVisibilityWithoutInject(t *testing.T) {
|
||||
previous := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(previous) })
|
||||
|
||||
dynamicMu.RLock()
|
||||
previousEndpoints := dynamicEndpoints
|
||||
previousProducts := dynamicProducts
|
||||
previousAliases := dynamicAliases
|
||||
previousToolEndpoints := dynamicToolEndpoints
|
||||
dynamicMu.RUnlock()
|
||||
t.Cleanup(func() {
|
||||
dynamicMu.Lock()
|
||||
dynamicEndpoints = previousEndpoints
|
||||
dynamicProducts = previousProducts
|
||||
dynamicAliases = previousAliases
|
||||
dynamicToolEndpoints = previousToolEndpoints
|
||||
dynamicMu.Unlock()
|
||||
})
|
||||
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "schema-source-root-static-visibility",
|
||||
StaticServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{ID: "", Name: "ignored-empty", Endpoint: "https://schema-source-root-static.example/empty"},
|
||||
{ID: " ", Name: "ignored-blank", Endpoint: "https://schema-source-root-static.example/blank"},
|
||||
{ID: "doc", Name: "Doc", Endpoint: "https://schema-source-root-static.example/doc"},
|
||||
}
|
||||
},
|
||||
// VisibleProducts intentionally nil: declaration-only visibility must
|
||||
// still derive from StaticServers without SetDynamicServers.
|
||||
})
|
||||
SetDynamicServers(nil)
|
||||
|
||||
root := NewSchemaSourceRootCommand()
|
||||
var doc *cobra.Command
|
||||
for _, cmd := range root.Commands() {
|
||||
if cmd.Name() == "doc" {
|
||||
doc = cmd
|
||||
break
|
||||
}
|
||||
}
|
||||
if doc == nil {
|
||||
t.Fatal("doc command missing from Schema source root")
|
||||
}
|
||||
if doc.Hidden {
|
||||
t.Fatal("declaration-only Schema source root hid doc despite StaticServers")
|
||||
}
|
||||
// Endpoint resolution may still read StaticServers directly; the invariant
|
||||
// is that declaration-only construction must not mutate the dynamic registry.
|
||||
dynamicMu.RLock()
|
||||
_, injected := dynamicProducts["doc"]
|
||||
dynamicMu.RUnlock()
|
||||
if injected {
|
||||
t.Fatal("declaration-only Schema source root must not inject StaticServers into dynamicProducts")
|
||||
}
|
||||
}
|
||||
@@ -26,12 +26,12 @@ func TestTodoListAttachmentDeliveredSchemaMatchesExecutableHelp(t *testing.T) {
|
||||
root.SetErr(&stderr)
|
||||
root.SetArgs([]string{"schema", cliPath, "--format", "json"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute embedded schema leaf: %v; stderr=%s", err, stderr.String())
|
||||
t.Fatalf("execute delivery schema leaf: %v; stderr=%s", err, stderr.String())
|
||||
}
|
||||
|
||||
var tool map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &tool); err != nil {
|
||||
t.Fatalf("decode embedded schema leaf: %v", err)
|
||||
t.Fatalf("decode delivery schema leaf: %v", err)
|
||||
}
|
||||
if got := schemaContractString(tool["canonical_path"]); got != canonicalPath {
|
||||
t.Fatalf("canonical_path = %q, want %q", got, canonicalPath)
|
||||
|
||||
@@ -110,7 +110,7 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing.
|
||||
if err := runSkillAdd(cmd, []string{"id", "target"}); err == nil {
|
||||
t.Fatal("invalid skill target should fail")
|
||||
}
|
||||
skillResolveTargetPath = func(string) (string, error) { return "dest", nil }
|
||||
skillResolveTargetPath = func(string) (string, error) { return filepath.Join(t.TempDir(), "dest"), nil }
|
||||
skillLoadAccessToken = func(context.Context) (string, error) { return "", fail }
|
||||
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
|
||||
t.Fatalf("skill add auth error = %v", err)
|
||||
@@ -280,7 +280,7 @@ func TestCrossPlatformCoverageSkillCommandLowLevelRemainingCoverage(t *testing.T
|
||||
}
|
||||
|
||||
skillMkdirAll = func(string, os.FileMode) error { return fail }
|
||||
if err := extractSkillZip("missing", "dest"); err == nil {
|
||||
if err := extractSkillZip("missing", filepath.Join(t.TempDir(), "dest")); err == nil {
|
||||
t.Fatal("zip destination mkdir failure should propagate")
|
||||
}
|
||||
zipPath := filepath.Join(t.TempDir(), "files.zip")
|
||||
|
||||
@@ -61,7 +61,7 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
|
||||
fail := errors.New("failure")
|
||||
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
|
||||
skillSetupResolveSource = func(string, string) (string, func(), error) { return "source", func() {}, nil }
|
||||
skillSetupResolveTargets = func(string, string) ([]string, error) { return []string{"dest"}, nil }
|
||||
skillSetupResolveTargets = func(string, string) ([]string, error) { return []string{filepath.Join(t.TempDir(), "dest")}, nil }
|
||||
skillSetupFilterMulti = func(all, _, _ []string) ([]string, error) { return all, nil }
|
||||
|
||||
skillSetupListMulti = func(string) ([]string, error) { return nil, fail }
|
||||
@@ -231,18 +231,18 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
}
|
||||
|
||||
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{"dest"}, &out, &errOut)
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
|
||||
if skipped != 2 {
|
||||
t.Fatal("multi mkdir failure count mismatch")
|
||||
}
|
||||
skillSetupMkdirAll = func(string, os.FileMode) error { return nil }
|
||||
skillSetupRemoveAll = func(string) error { return fail }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{"dest"}, &out, &errOut)
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
|
||||
if skipped != 1 {
|
||||
t.Fatal("multi remove failure count mismatch")
|
||||
}
|
||||
skillSetupRemoveAll = func(string) error { return nil }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{"dest"}, &out, &errOut)
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
|
||||
if skipped != 1 {
|
||||
t.Fatal("multi copy failure count mismatch")
|
||||
}
|
||||
|
||||
@@ -429,7 +429,7 @@ func TestCrossPlatformCoverageUpgradeBinaryHelpersFailureCoverage(t *testing.T)
|
||||
}
|
||||
upgradeMkdirAll = func(string, os.FileMode) error { return nil }
|
||||
upgradeCommandOutput = func(string, ...string) ([]byte, error) { return []byte("tar failed"), fail }
|
||||
if err := extractTarGz("archive", "dest"); err == nil || !strings.Contains(err.Error(), "tar failed") {
|
||||
if err := extractTarGz("archive", filepath.Join(t.TempDir(), "dest")); err == nil || !strings.Contains(err.Error(), "tar failed") {
|
||||
t.Fatalf("tar error = %v", err)
|
||||
}
|
||||
upgradeUserHomeDir = func() (string, error) { return "", fail }
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -78,9 +79,7 @@ func installRejectedTokenHookStore(t *testing.T, data TokenData) *rejectedTokenH
|
||||
func installOAuthRefreshStub(t *testing.T, fn func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error)) {
|
||||
t.Helper()
|
||||
resetRejectedTokenRefreshCoordinator(t)
|
||||
previous := oauthRefreshToken
|
||||
oauthRefreshToken = fn
|
||||
t.Cleanup(func() { oauthRefreshToken = previous })
|
||||
testseam.Swap(t, &oauthRefreshToken, fn)
|
||||
}
|
||||
|
||||
func resetRejectedTokenRefreshCoordinator(t *testing.T) {
|
||||
@@ -121,11 +120,10 @@ func installProfilesAcquireProbe(t *testing.T) <-chan struct{} {
|
||||
t.Helper()
|
||||
previous := profilesAcquireDualLock
|
||||
attempted := make(chan struct{}, 1)
|
||||
profilesAcquireDualLock = func(ctx context.Context, configDir string) (*DualLock, error) {
|
||||
testseam.Swap(t, &profilesAcquireDualLock, func(ctx context.Context, configDir string) (*DualLock, error) {
|
||||
attempted <- struct{}{}
|
||||
return previous(ctx, configDir)
|
||||
}
|
||||
t.Cleanup(func() { profilesAcquireDualLock = previous })
|
||||
})
|
||||
return attempted
|
||||
}
|
||||
|
||||
|
||||
@@ -4,31 +4,31 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeAnnotationAPIsCoverage(t *testing.T) {
|
||||
AttachRuntimeSchema(nil, "p", "t", "s")
|
||||
AttachRuntimeSchema(&cobra.Command{}, "", "", "")
|
||||
AnnotateRuntimeToolMetadata(nil, "", "", "")
|
||||
AnnotateRuntimeFlag(nil, "x", "x", "string", false, "")
|
||||
AnnotateRuntimeFlag(nil, "x", "x", "string", false)
|
||||
AnnotateRuntimeFlagProperty(nil, "x", "x")
|
||||
AnnotateRuntimeRequiredFlags(nil, "x")
|
||||
AnnotateRuntimeFlagRequiredValue(nil, "x", false)
|
||||
AnnotateRuntimeFlagInterfaceType(nil, "x", "string")
|
||||
AnnotateRuntimeFlagRequiredWhen(nil, "x", "when")
|
||||
AnnotateRuntimeFlagFormat(nil, "x", "uri")
|
||||
AnnotateRuntimeFlagEnum(nil, "x", "a")
|
||||
AnnotateRuntimeFlagExample(nil, "x", "a")
|
||||
AnnotateRuntimeConstraints(nil, RuntimeSchemaConstraints{})
|
||||
AnnotateRuntimePositionals(nil)
|
||||
ExcludeFromRuntimeSchema(nil)
|
||||
|
||||
cmd := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}}
|
||||
cmd.Flags().StringP("value", "v", "", "value")
|
||||
AttachRuntimeSchema(cmd, " product ", " tool ", " source ")
|
||||
AnnotateRuntimeToolMetadata(cmd, " Title ", " Description ", " source ")
|
||||
AnnotateRuntimeFlag(cmd, "", "property", "string", true, "")
|
||||
AnnotateRuntimeFlag(cmd, "missing", "property", "string", true, "")
|
||||
AnnotateRuntimeFlag(cmd, "value", " property ", " string ", true, "")
|
||||
AnnotateRuntimeFlag(cmd, "", "property", "string", true)
|
||||
AnnotateRuntimeFlag(cmd, "missing", "property", "string", true)
|
||||
AnnotateRuntimeFlag(cmd, "value", " property ", " string ", true)
|
||||
AnnotateRuntimeFlagProperty(cmd, "missing", "property")
|
||||
AnnotateRuntimeFlagProperty(cmd, "value", "property2")
|
||||
AnnotateRuntimeRequiredFlags(cmd, "missing", "value")
|
||||
@@ -44,23 +44,22 @@ func TestCrossPlatformCoverageRuntimeAnnotationAPIsCoverage(t *testing.T) {
|
||||
AnnotateRuntimeConstraints(cmd, RuntimeSchemaConstraints{RequireOneOf: [][]string{{"value", "other"}}})
|
||||
AnnotateRuntimeConstraints(cmd, RuntimeSchemaConstraints{RequireTogether: [][]string{{"value", "other"}}})
|
||||
AnnotateRuntimePositionals(cmd,
|
||||
RuntimeSchemaPositional{Name: "", Index: 0},
|
||||
RuntimeSchemaPositional{Name: "bad", Index: -1},
|
||||
RuntimeSchemaPositional{Name: " second ", Index: 1, Description: " desc "},
|
||||
RuntimeSchemaPositional{Name: "first", Index: 0, Type: " number "},
|
||||
contract.RuntimeSchemaPositional{Name: "", Index: 0},
|
||||
contract.RuntimeSchemaPositional{Name: "bad", Index: -1},
|
||||
contract.RuntimeSchemaPositional{Name: " second ", Index: 1, Description: " desc "},
|
||||
contract.RuntimeSchemaPositional{Name: "first", Index: 0, Type: " number "},
|
||||
)
|
||||
ExcludeFromRuntimeSchema(cmd)
|
||||
setRuntimeCommandAnnotation(cmd, "empty", " ")
|
||||
setFlagAnnotation(nil, "x", "y")
|
||||
setFlagAnnotation(cmd.Flags().Lookup("value"), "empty", " ")
|
||||
setFlagAnnotationValues(nil, "x", "y")
|
||||
setFlagAnnotationValues(cmd.Flags().Lookup("value"), "empty", " ")
|
||||
if cmd.Annotations[runtimeSchemaProductAnnotation] != "product" || cmd.Annotations[runtimeSchemaExcludeAnnotation] != "true" {
|
||||
if cmd.Annotations[runtimeSchemaProductAnnotation] != "product" {
|
||||
t.Fatalf("command annotations = %#v", cmd.Annotations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeRegistriesAndSchemaHintsCoverage(t *testing.T) {
|
||||
func TestCrossPlatformCoverageRuntimeRegistriesCoverage(t *testing.T) {
|
||||
originalConstraints := runtimeSchemaConstraintsByCanonical
|
||||
originalParameters := runtimeSchemaParameterMetadataByCanonical
|
||||
t.Cleanup(func() {
|
||||
@@ -94,21 +93,6 @@ func TestCrossPlatformCoverageRuntimeRegistriesAndSchemaHintsCoverage(t *testing
|
||||
t.Fatal("cloneRuntimeSchemaStringMap(nil) != nil")
|
||||
}
|
||||
assertPanics(t, func() { RegisterRuntimeSchemaParameterMetadata("sample.run", metadata) })
|
||||
|
||||
registry := newSchemaHintRegistry()
|
||||
assertPanics(t, func() { registry.RegisterProduct("", nil) })
|
||||
assertPanics(t, func() { registry.RegisterProduct("sample", map[string]ToolSchemaHint{"": {}}) })
|
||||
registry.RegisterProduct(" sample ", map[string]ToolSchemaHint{"run": {Title: "Run"}, "other.path": {Title: "Other"}})
|
||||
assertPanics(t, func() { registry.RegisterProduct("sample", map[string]ToolSchemaHint{"run": {}}) })
|
||||
if _, ok := registry.Lookup(" "); ok {
|
||||
t.Fatal("Lookup(empty) succeeded")
|
||||
}
|
||||
if hint, ok := registry.Lookup(" sample.run "); !ok || hint.Title != "Run" {
|
||||
t.Fatalf("Lookup(sample.run) = %#v, %v", hint, ok)
|
||||
}
|
||||
if canonicalHintPath("sample", "") != "" || canonicalHintPath("sample", "other.path") != "other.path" || canonicalHintPath("sample", "run") != "sample.run" {
|
||||
t.Fatal("canonicalHintPath mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func assertPanics(t *testing.T, fn func()) {
|
||||
|
||||
+37
-209
@@ -14,52 +14,44 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var schemaCommandCatalogError = embeddedSchemaCatalogError
|
||||
// schemaCommandCatalogError / payloads use deliverySchemaCatalog
|
||||
// (RegisterSchemaSourceRoot → ResolveSchemaBuild). There is no committed
|
||||
// Schema Catalog embed fallback.
|
||||
var schemaCommandCatalogError = deliverySchemaCatalogError
|
||||
|
||||
type FlagKind string
|
||||
|
||||
const (
|
||||
flagString FlagKind = "string"
|
||||
flagInteger FlagKind = "integer"
|
||||
flagNumber FlagKind = "number"
|
||||
flagBoolean FlagKind = "boolean"
|
||||
flagStringArray FlagKind = "string_array"
|
||||
flagIntegerList FlagKind = "integer_array"
|
||||
flagNumberList FlagKind = "number_array"
|
||||
flagBooleanList FlagKind = "boolean_array"
|
||||
flagJSON FlagKind = "json"
|
||||
)
|
||||
|
||||
type FlagSpec struct {
|
||||
PropertyName string
|
||||
FlagName string
|
||||
Alias string
|
||||
Shorthand string
|
||||
Kind FlagKind
|
||||
Description string
|
||||
}
|
||||
|
||||
// NewMCPCommand returns a stub command since the canonical discovery
|
||||
// surface has been removed. The command tree is now built from plugins
|
||||
// and static endpoint registration only.
|
||||
func NewMCPCommand(_ context.Context, _ CatalogLoader, _ executor.Runner, _ *pipeline.Engine) *cobra.Command {
|
||||
// NewMCPCommand registers the mcp product declaration and returns its root
|
||||
// command. The app layer attaches reviewed static MCP helpers as
|
||||
// subcommands; live discovery surfaces are retired.
|
||||
func NewMCPCommand() *cobra.Command {
|
||||
// Product-level Agent routing Decl (migrated from selection/mcp.json
|
||||
// products.mcp). Schema assembly stamps provenance contract_final.
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "mcp",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "解析和管理当前身份可用的 MCP 服务连接信息",
|
||||
UseWhen: []string{
|
||||
"需要把钉钉 MCP 市场中的服务连接到支持 Streamable HTTP 的 Agent 或客户端",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"查询普通钉钉业务数据时使用对应产品命令,不要使用 mcp",
|
||||
},
|
||||
},
|
||||
})
|
||||
// The legacy dynamic MCP surface has been removed; the app layer adds
|
||||
// reviewed static MCP helpers as subcommands of this command.
|
||||
cmd := &cobra.Command{
|
||||
Use: "mcp",
|
||||
Short: "Canonical MCP-derived CLI surface (static mode)",
|
||||
Long: "The canonical MCP command surface is disabled. Commands are now registered via plugins and static endpoints.",
|
||||
Hidden: true,
|
||||
Short: "管理 MCP 服务连接信息",
|
||||
Long: "管理经过审核并纳入 Schema 的 MCP 服务连接辅助能力。",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -69,16 +61,17 @@ func NewMCPCommand(_ context.Context, _ CatalogLoader, _ executor.Runner, _ *pip
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewSchemaCommand serves the versioned embedded typed contract. A malformed
|
||||
// release snapshot fails closed; falling back to the live Cobra tree would
|
||||
// hide a broken delivery artifact and reintroduce a second Schema data path.
|
||||
func NewSchemaCommand(_ CatalogLoader) *cobra.Command {
|
||||
// NewSchemaCommand serves the typed Schema contract. Production assembles from
|
||||
// declarations via ResolveSchemaBuild (factory registered by internal/app).
|
||||
// A malformed assembly fails closed; the command takes no discovery loader
|
||||
// because queries never run service discovery.
|
||||
func NewSchemaCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "schema [path]",
|
||||
Short: "渐进查看命令 Schema (产品 / 分组 / 工具参数)",
|
||||
Long: `查看当前可运行命令的 Schema 元数据。
|
||||
|
||||
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。--all 输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)。--compact 去除 provenance / debug 字段,仅保留 Agent 选参所需信息(适合 Agent 上下文)。helper、MCP 与本地 Cobra 命令均须先进入 reviewed Registry,并从同一内嵌 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
|
||||
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。--all 输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)。--compact 去除 provenance / debug 字段,仅保留 Agent 选参所需信息(适合 Agent 上下文)。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -99,16 +92,16 @@ func NewSchemaCommand(_ CatalogLoader) *cobra.Command {
|
||||
args = []string{cliPath}
|
||||
}
|
||||
if err := schemaCommandCatalogError(); err != nil {
|
||||
return fmt.Errorf("load embedded typed Schema registry: %w", err)
|
||||
return fmt.Errorf("load typed Schema registry: %w", err)
|
||||
}
|
||||
var payload map[string]any
|
||||
var err error
|
||||
if all {
|
||||
payload, err = embeddedSchemaAllPayload()
|
||||
payload, err = deliverySchemaAllPayload()
|
||||
} else if len(args) == 0 {
|
||||
payload, err = embeddedSchemaOverviewPayload()
|
||||
payload, err = deliverySchemaOverviewPayload()
|
||||
} else {
|
||||
payload, err = embeddedSchemaPayload(args)
|
||||
payload, err = queryDeliverySchemaPayload(args)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -125,171 +118,6 @@ func NewSchemaCommand(_ CatalogLoader) *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func BuildFlagSpecs(schema map[string]any, hints map[string]CLIFlagHint) []FlagSpec {
|
||||
properties, ok := nestedMap(schema, "properties")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(properties))
|
||||
for key := range properties {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
specs := make([]FlagSpec, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
propertySchema, ok := properties[key].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
kind, ok := flagKindForSchema(propertySchema)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
specs = append(specs, FlagSpec{
|
||||
PropertyName: key,
|
||||
FlagName: strings.ReplaceAll(key, "_", "-"),
|
||||
Alias: strings.TrimSpace(hints[key].Alias),
|
||||
Shorthand: strings.TrimSpace(hints[key].Shorthand),
|
||||
Kind: kind,
|
||||
Description: schemaDescription(propertySchema),
|
||||
})
|
||||
}
|
||||
return specs
|
||||
}
|
||||
|
||||
// canRegisterToolFlag reports whether a long flag named name can be
|
||||
// registered on cmd without panicking pflag ("flag redefined").
|
||||
func canRegisterToolFlag(cmd *cobra.Command, name string) bool {
|
||||
if name == "" || name == "json" || name == "params" {
|
||||
return false
|
||||
}
|
||||
return cmd.Flags().Lookup(name) == nil
|
||||
}
|
||||
|
||||
// safeToolShorthand returns short when it is a single-character shorthand not
|
||||
// yet bound on cmd; otherwise "" (drop the shorthand, keep the long flag).
|
||||
func safeToolShorthand(cmd *cobra.Command, short string) string {
|
||||
short = strings.TrimSpace(short)
|
||||
if len(short) != 1 {
|
||||
return ""
|
||||
}
|
||||
if cmd.Flags().ShorthandLookup(short) != nil {
|
||||
return ""
|
||||
}
|
||||
return short
|
||||
}
|
||||
|
||||
func applyFlagSpecs(cmd *cobra.Command, specs []FlagSpec) {
|
||||
for _, spec := range specs {
|
||||
usage := spec.Description
|
||||
if usage == "" {
|
||||
usage = fmt.Sprintf("Override %s", spec.PropertyName)
|
||||
}
|
||||
primary := strings.TrimSpace(spec.FlagName)
|
||||
if !canRegisterToolFlag(cmd, primary) {
|
||||
continue
|
||||
}
|
||||
shorthand := safeToolShorthand(cmd, spec.Shorthand)
|
||||
alias := strings.TrimSpace(spec.Alias)
|
||||
if alias == primary || !canRegisterToolFlag(cmd, alias) {
|
||||
alias = ""
|
||||
}
|
||||
|
||||
switch spec.Kind {
|
||||
case flagString, flagJSON:
|
||||
cmd.Flags().StringP(primary, shorthand, "", usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().String(alias, "", usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
case flagInteger:
|
||||
cmd.Flags().IntP(primary, shorthand, 0, usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().Int(alias, 0, usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
case flagNumber:
|
||||
cmd.Flags().Float64P(primary, shorthand, 0, usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().Float64(alias, 0, usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
case flagBoolean:
|
||||
cmd.Flags().BoolP(primary, shorthand, false, usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().Bool(alias, false, usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
case flagStringArray, flagIntegerList, flagNumberList, flagBooleanList:
|
||||
cmd.Flags().StringSliceP(primary, shorthand, nil, usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().StringSlice(alias, nil, usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func nestedMap(root map[string]any, key string) (map[string]any, bool) {
|
||||
if root == nil {
|
||||
return nil, false
|
||||
}
|
||||
value, ok := root[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out, ok := value.(map[string]any)
|
||||
return out, ok
|
||||
}
|
||||
|
||||
func flagKindForSchema(schema map[string]any) (FlagKind, bool) {
|
||||
if _, ok := schema["enum"].([]any); ok {
|
||||
return flagString, true
|
||||
}
|
||||
switch schema["type"] {
|
||||
case "string":
|
||||
return flagString, true
|
||||
case "integer":
|
||||
return flagInteger, true
|
||||
case "number":
|
||||
return flagNumber, true
|
||||
case "boolean":
|
||||
return flagBoolean, true
|
||||
case "object":
|
||||
return flagJSON, true
|
||||
case "array":
|
||||
items, ok := schema["items"].(map[string]any)
|
||||
if !ok {
|
||||
return flagJSON, true
|
||||
}
|
||||
if _, ok := items["enum"].([]any); ok {
|
||||
return flagStringArray, true
|
||||
}
|
||||
switch items["type"] {
|
||||
case "string":
|
||||
return flagStringArray, true
|
||||
case "integer":
|
||||
return flagIntegerList, true
|
||||
case "number":
|
||||
return flagNumberList, true
|
||||
case "boolean":
|
||||
return flagBooleanList, true
|
||||
case "object":
|
||||
return flagJSON, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func schemaDescription(schema map[string]any) string {
|
||||
value, _ := schema["description"].(string)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
// splitSchemaPathTokens splits a CLI path on dots, slashes, and
|
||||
// whitespace, returning only non-empty tokens.
|
||||
func splitSchemaPathTokens(raw string) []string {
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// newToolCommandFixture mirrors the flag environment of newToolCommand: the
|
||||
// reserved payload flags are registered before the spec loop runs.
|
||||
func newToolCommandFixture() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "probe"}
|
||||
cmd.Flags().String("json", "", "Base JSON object payload for this tool invocation")
|
||||
cmd.Flags().String("params", "", "Additional JSON object payload merged after --json")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// TestApplyFlagSpecsSkipsReservedNames locks in the fix for the 1.0.32-class
|
||||
// lock-out: a tool schema property named after a reserved payload flag
|
||||
// ("params", as cached during the chat_permission_grant incident, or "json")
|
||||
// must be skipped instead of panicking pflag ("flag redefined") — that panic
|
||||
// fires while the canonical tree is assembled, before Cobra dispatches
|
||||
// anything, and used to kill every invocation including `dws cache refresh`
|
||||
// and `dws upgrade`.
|
||||
func TestApplyFlagSpecsSkipsReservedNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newToolCommandFixture()
|
||||
applyFlagSpecs(cmd, []FlagSpec{
|
||||
{PropertyName: "params", FlagName: "params", Kind: flagString, Description: "命令授权参数"},
|
||||
{PropertyName: "json", FlagName: "json", Kind: flagString},
|
||||
{PropertyName: "scope", FlagName: "scope", Kind: flagString},
|
||||
})
|
||||
|
||||
if cmd.Flags().Lookup("scope") == nil {
|
||||
t.Errorf("non-colliding flag --scope was not registered")
|
||||
}
|
||||
// The reserved flags must keep their payload usage strings, proving the
|
||||
// schema-derived specs did not touch them.
|
||||
if got := cmd.Flags().Lookup("params").Usage; got != "Additional JSON object payload merged after --json" {
|
||||
t.Errorf("--params usage = %q, want the reserved payload usage", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyFlagSpecsSkipsDuplicates covers duplicate property names within a
|
||||
// single tool schema (or a spec colliding with an already-applied one).
|
||||
func TestApplyFlagSpecsSkipsDuplicates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newToolCommandFixture()
|
||||
applyFlagSpecs(cmd, []FlagSpec{
|
||||
{PropertyName: "scope", FlagName: "scope", Kind: flagString, Description: "first"},
|
||||
{PropertyName: "scope", FlagName: "scope", Kind: flagBoolean, Description: "second"},
|
||||
})
|
||||
|
||||
flag := cmd.Flags().Lookup("scope")
|
||||
if flag == nil {
|
||||
t.Fatalf("--scope was not registered at all")
|
||||
}
|
||||
if flag.Usage != "first" {
|
||||
t.Errorf("--scope usage = %q, want the first spec to win", flag.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyFlagSpecsSkipsCollidingAlias verifies an alias colliding with a
|
||||
// reserved or existing flag is dropped while the primary still registers.
|
||||
func TestApplyFlagSpecsSkipsCollidingAlias(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newToolCommandFixture()
|
||||
applyFlagSpecs(cmd, []FlagSpec{
|
||||
{PropertyName: "scope", FlagName: "scope", Alias: "params", Kind: flagString},
|
||||
})
|
||||
|
||||
if cmd.Flags().Lookup("scope") == nil {
|
||||
t.Errorf("primary flag --scope was not registered when its alias collided")
|
||||
}
|
||||
if got := cmd.Flags().Lookup("params").Usage; got != "Additional JSON object payload merged after --json" {
|
||||
t.Errorf("--params usage = %q, alias overwrote the reserved payload flag", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyFlagSpecsSanitizesShorthand verifies multi-character and duplicate
|
||||
// shorthands (both pflag panics) degrade to long-flag-only registration.
|
||||
func TestApplyFlagSpecsSanitizesShorthand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newToolCommandFixture()
|
||||
applyFlagSpecs(cmd, []FlagSpec{
|
||||
{PropertyName: "alpha", FlagName: "alpha", Shorthand: "ab", Kind: flagString},
|
||||
{PropertyName: "beta", FlagName: "beta", Shorthand: "s", Kind: flagString},
|
||||
{PropertyName: "gamma", FlagName: "gamma", Shorthand: "s", Kind: flagString},
|
||||
})
|
||||
|
||||
for _, name := range []string{"alpha", "beta", "gamma"} {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Errorf("--%s was not registered", name)
|
||||
}
|
||||
}
|
||||
if flag := cmd.Flags().ShorthandLookup("s"); flag == nil || flag.Name != "beta" {
|
||||
t.Errorf("shorthand -s should stay bound to the first claimant --beta, got %v", flag)
|
||||
}
|
||||
}
|
||||
+102
-23
@@ -14,18 +14,20 @@
|
||||
// command_meta.go provides the unified metadata consumption API. All runtime
|
||||
// consumers (help, schema, agent selection, skill generation) call ResolveMeta
|
||||
// to get a CommandMeta struct — one function, one struct, no need to know which
|
||||
// of the 6 generation layers a field comes from.
|
||||
// declaration layer a field comes from.
|
||||
//
|
||||
// This is the "simple consumption" half of the generation/consumption split:
|
||||
// - Generation (gen.go + internal/generator/): 6 inputs → catalog snapshot.
|
||||
// - Consumption (this file): catalog snapshot → ResolveMeta → CommandMeta.
|
||||
// ResolveMeta projects CommandMeta from the lazily assembled SchemaRegistry
|
||||
// (RegisterSchemaSourceRoot → ResolveSchemaBuild). Without a registered
|
||||
// factory it fails closed — there is no schema_meta_index.gob fallback.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// CommandMeta is the complete runtime metadata view for a single command.
|
||||
@@ -58,21 +60,92 @@ var (
|
||||
metaByCLIPath map[string]CommandMeta
|
||||
)
|
||||
|
||||
// initMetaByCLIPath builds the cli_path → CommandMeta lookup from the embedded
|
||||
// catalog. Runs once (sync.Once); the catalog is already decoded at package init.
|
||||
func initMetaByCLIPath() {
|
||||
metaByCLIPath = buildMetaByCLIPath(embeddedSchemaCatalog())
|
||||
// Counter names retain "MetaIndex" for RuntimeSchemaMetadataLoadCounts
|
||||
// compatibility; they now count ResolveMeta lookup init from assembled Catalog.
|
||||
var (
|
||||
runtimeDeliverySchemaMetaIndexErr error
|
||||
runtimeDeliverySchemaMetaIndexLazyCount atomic.Uint64
|
||||
)
|
||||
|
||||
// installDeliveryCommandMeta materializes the ResolveMeta lookup from an
|
||||
// assembled Catalog. Called from deliverySchemaCatalog's sync.Once so Meta
|
||||
// shares assembly and subsequent ResolveMeta is a plain map read.
|
||||
func installDeliveryCommandMeta(loaded loadedSchemaCatalog, err error) {
|
||||
runtimeDeliverySchemaMetaIndexLazyCount.Add(1)
|
||||
if err != nil {
|
||||
runtimeDeliverySchemaMetaIndexErr = err
|
||||
metaByCLIPath = nil
|
||||
metaByCLIPathOnce.Do(func() {})
|
||||
return
|
||||
}
|
||||
metaByCLIPath = buildMetaByCLIPath(loaded)
|
||||
runtimeDeliverySchemaMetaIndexErr = nil
|
||||
metaByCLIPathOnce.Do(func() {})
|
||||
}
|
||||
|
||||
// buildMetaByCLIPath constructs the lookup from a loaded catalog snapshot.
|
||||
// Split from initMetaByCLIPath so malformed-snapshot guards stay testable.
|
||||
// panicIfMetaIndexUnusable fails closed when CommandMeta lookup could not be
|
||||
// built. Callers must not treat this as "command missing".
|
||||
func panicIfMetaIndexUnusable(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
panic(fmt.Sprintf("schema CommandMeta index is unusable: %v", err))
|
||||
}
|
||||
|
||||
// buildMetaByCLIPath constructs the lookup from a loaded catalog.
|
||||
// Prefer the typed Registry (production cold-start path). Fall back to
|
||||
// Snapshot.Tools maps for unit tests that synthesize untyped fixtures.
|
||||
func buildMetaByCLIPath(loaded loadedSchemaCatalog) map[string]CommandMeta {
|
||||
if len(loaded.Registry.Products) > 0 {
|
||||
return buildMetaByCLIPathFromRegistry(loaded.Registry)
|
||||
}
|
||||
return buildMetaByCLIPathFromSnapshotTools(loaded.Snapshot.Tools)
|
||||
}
|
||||
|
||||
func buildMetaByCLIPathFromRegistry(registry SchemaRegistry) map[string]CommandMeta {
|
||||
lookup := make(map[string]CommandMeta)
|
||||
if loaded.Snapshot.Tools == nil {
|
||||
metas := make([]CommandMeta, 0, 64)
|
||||
for _, product := range registry.Products {
|
||||
for _, tool := range product.Tools {
|
||||
cliPath := strings.TrimSpace(tool.Identity.CLIPath)
|
||||
if cliPath == "" {
|
||||
continue
|
||||
}
|
||||
meta := CommandMeta{
|
||||
Identity: CommandIdentity{
|
||||
CLIPath: cliPath,
|
||||
Canonical: tool.Identity.CanonicalPath,
|
||||
Aliases: append([]string(nil), tool.Identity.Aliases...),
|
||||
ProductID: tool.Identity.ProductID,
|
||||
Title: tool.Title,
|
||||
},
|
||||
Safety: CommandSafety{
|
||||
Effect: tool.Safety.Effect,
|
||||
Risk: tool.Safety.Risk,
|
||||
Confirmation: tool.Safety.Confirmation,
|
||||
Idempotency: tool.Safety.Idempotency,
|
||||
},
|
||||
Selection: CommandSelection{
|
||||
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...),
|
||||
},
|
||||
}
|
||||
lookup[cliPath] = meta
|
||||
metas = append(metas, meta)
|
||||
}
|
||||
}
|
||||
return registerCommandMetaAliases(lookup, metas)
|
||||
}
|
||||
|
||||
func buildMetaByCLIPathFromSnapshotTools(tools map[string]map[string]any) map[string]CommandMeta {
|
||||
lookup := make(map[string]CommandMeta)
|
||||
if tools == nil {
|
||||
return lookup
|
||||
}
|
||||
metas := make([]CommandMeta, 0, len(loaded.Snapshot.Tools))
|
||||
for _, tool := range loaded.Snapshot.Tools {
|
||||
metas := make([]CommandMeta, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
cliPath := schemaString(tool["cli_path"])
|
||||
if cliPath == "" {
|
||||
continue
|
||||
@@ -101,12 +174,13 @@ func buildMetaByCLIPath(loaded loadedSchemaCatalog) map[string]CommandMeta {
|
||||
lookup[cliPath] = meta
|
||||
metas = append(metas, meta)
|
||||
}
|
||||
// Register compat alias paths (e.g. "report list") against the same
|
||||
// metadata in a second pass, sorted by primary cli_path: primary paths
|
||||
// always win (registered above, aliases only fill vacancies), and an
|
||||
// alias-vs-alias collision resolves deterministically to the owner with
|
||||
// the lexicographically smallest primary path — Snapshot.Tools is a map,
|
||||
// so relying on iteration order would make the winner vary per process.
|
||||
return registerCommandMetaAliases(lookup, metas)
|
||||
}
|
||||
|
||||
// registerCommandMetaAliases fills compat alias paths. Primary paths always
|
||||
// win; alias-vs-alias collisions resolve to the owner with the
|
||||
// lexicographically smallest primary cli_path (map iteration is unstable).
|
||||
func registerCommandMetaAliases(lookup map[string]CommandMeta, metas []CommandMeta) map[string]CommandMeta {
|
||||
sort.Slice(metas, func(i, j int) bool {
|
||||
return metas[i].Identity.CLIPath < metas[j].Identity.CLIPath
|
||||
})
|
||||
@@ -126,10 +200,15 @@ func buildMetaByCLIPath(loaded loadedSchemaCatalog) map[string]CommandMeta {
|
||||
|
||||
// ResolveMeta returns the complete metadata for a command identified by its CLI
|
||||
// path (e.g. "dev app delete") or one of its compat aliases (e.g. "report list"
|
||||
// for "report inbox list"). Returns ok=false for commands not in the embedded
|
||||
// catalog (utility commands, hidden commands, shortcuts).
|
||||
// for "report inbox list"). Returns ok=false for commands not in the Schema
|
||||
// surface (utility commands, hidden commands, shortcuts).
|
||||
//
|
||||
// Ensures deliverySchemaCatalog Once (assembles + caches Meta), then O(1) map
|
||||
// lookup. Without a registered source root, fails closed (panic).
|
||||
func ResolveMeta(cliPath string) (CommandMeta, bool) {
|
||||
metaByCLIPathOnce.Do(initMetaByCLIPath)
|
||||
m, ok := metaByCLIPath[strings.TrimSpace(cliPath)]
|
||||
cliPath = strings.TrimSpace(cliPath)
|
||||
_ = deliverySchemaCatalog()
|
||||
panicIfMetaIndexUnusable(runtimeDeliverySchemaMetaIndexErr)
|
||||
m, ok := metaByCLIPath[cliPath]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
@@ -21,10 +21,11 @@ import (
|
||||
)
|
||||
|
||||
// CommandSafety holds the safety metadata for a CLI command, resolved at
|
||||
// runtime from the embedded schema catalog. This is a read-only view over the
|
||||
// catalog — NOT a second safety source. The catalog remains the single
|
||||
// authoritative reviewed source; this struct merely provides typed access for
|
||||
// consumers (help rendering, skill generation).
|
||||
// runtime via ResolveMeta. This is a read-only view — NOT a second safety
|
||||
// source. Production Meta is projected from the runtime-assembled
|
||||
// SchemaRegistry into a cached map[cli_path]CommandMeta during
|
||||
// deliverySchemaCatalog's sync.Once; steady-state ResolveMeta / leaf --help
|
||||
// are O(1) map lookups (同源 with assembly, not a separate gob authority).
|
||||
type CommandSafety struct {
|
||||
Effect string // read / write / destructive
|
||||
Risk string // low / medium / high
|
||||
@@ -41,12 +42,18 @@ func (s CommandSafety) ShouldRender() bool {
|
||||
}
|
||||
|
||||
// SafetyForCLIPath returns the safety metadata for a command identified by its
|
||||
// CLI path (e.g. "dev app delete"). Returns ok=false when the command is not
|
||||
// in the embedded catalog (utility commands, hidden commands, shortcuts).
|
||||
// CLI path (e.g. "dev app delete"). Returns ok=false when the path is absent
|
||||
// from the Schema surface (utility commands, hidden commands, shortcuts), or
|
||||
// when no Schema source root is registered (synthetic help trees in unit
|
||||
// tests). With a registered factory, assembly failure panics via ResolveMeta
|
||||
// (fail-closed).
|
||||
//
|
||||
// Deprecated: use ResolveMeta(cliPath).Safety for the complete metadata view.
|
||||
// Kept for backward compatibility with existing callers.
|
||||
func SafetyForCLIPath(cliPath string) (CommandSafety, bool) {
|
||||
if !SchemaSourceRootRegistered() {
|
||||
return CommandSafety{}, false
|
||||
}
|
||||
meta, ok := ResolveMeta(cliPath)
|
||||
if !ok {
|
||||
return CommandSafety{}, false
|
||||
@@ -59,7 +66,12 @@ func SafetyForCLIPath(cliPath string) (CommandSafety, bool) {
|
||||
// the shared entry point for ALL help rendering paths (root HelpFunc, product
|
||||
// group custom HelpFuncs like calendar's). It avoids the timing issue where a
|
||||
// group captures origHelp before configureRootHelp sets the root's custom func.
|
||||
// Without a registered Schema source root it is a no-op (unit-test synthetic
|
||||
// roots); production NewRootCommand always registers the factory.
|
||||
func RenderSafetyAnnotation(cmd *cobra.Command) {
|
||||
if !SchemaSourceRootRegistered() {
|
||||
return
|
||||
}
|
||||
cliPath := strings.TrimSpace(strings.TrimPrefix(cmd.CommandPath(), cmd.Root().Name()+" "))
|
||||
safety, ok := SafetyForCLIPath(cliPath)
|
||||
if !ok || !safety.ShouldRender() {
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -37,6 +39,19 @@ func TestSafetyForCLIPathKnownCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSafetyForCLIPathRegisteredFactory(t *testing.T) {
|
||||
if !SchemaSourceRootRegistered() {
|
||||
t.Fatal("package-cli TestMain must register a Schema source root")
|
||||
}
|
||||
s, ok := SafetyForCLIPath("dev app delete")
|
||||
if !ok || s.Effect != "destructive" {
|
||||
t.Fatalf("SafetyForCLIPath = %#v ok=%v", s, ok)
|
||||
}
|
||||
if _, ok := SafetyForCLIPath("nonexistent fake command"); ok {
|
||||
t.Fatal("unknown path must return ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafetyForCLIPathUnknownSkips(t *testing.T) {
|
||||
// 不存在的命令 → ok=false。
|
||||
_, ok := SafetyForCLIPath("nonexistent fake command")
|
||||
@@ -69,6 +84,26 @@ func TestSafetyForCLIPathTrimsWhitespace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMetaFailsClosedOnUnusableMetaIndex(t *testing.T) {
|
||||
// Guard is unit-tested without poisoning the process-wide sync.Once used
|
||||
// by the real embedded index. Decode failure must panic, not look like a
|
||||
// missing CLI path (which would silently drop help Safety).
|
||||
if _, err := decodeSchemaMetaIndexLookup([]byte(`{not-json`)); err == nil {
|
||||
t.Fatal("decodeSchemaMetaIndexLookup(corrupt) error = nil, want fail-closed decode error")
|
||||
}
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
t.Fatal("panicIfMetaIndexUnusable(err) did not panic")
|
||||
}
|
||||
msg, _ := r.(string)
|
||||
if msg == "" || !strings.Contains(msg, "schema CommandMeta index is unusable") {
|
||||
t.Fatalf("panic = %#v, want unusable CommandMeta index message", r)
|
||||
}
|
||||
}()
|
||||
panicIfMetaIndexUnusable(fmt.Errorf("decode schema meta index: unexpected EOF"))
|
||||
}
|
||||
|
||||
func TestResolveMetaComplete(t *testing.T) {
|
||||
// dev app delete: destructive + high + user_required + 有 selection。
|
||||
m, ok := ResolveMeta("dev app delete")
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
// 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 (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalMCPMetadataLookup(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "reply"}
|
||||
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
|
||||
cmd.Flags().String("text", "", "text")
|
||||
runtimeannotate.AnnotateRuntimeFlag(cmd, "text", "text", "string", false)
|
||||
contractfinal.RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{
|
||||
Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "chat", Name: "reply_personal_message", CanonicalPath: "chat.reply_personal_message",
|
||||
CLIPath: "chat reply", PrimaryCLIPath: "chat reply",
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeMCP,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "send_personal_message"},
|
||||
},
|
||||
})
|
||||
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "chat", ToolName: "reply_personal_message", Command: cmd,
|
||||
CLIPath: "chat reply", PrimaryCLIPath: "chat reply",
|
||||
}
|
||||
metadata := runtimeSchemaMetadataSources{
|
||||
MCP: embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
|
||||
"chat.send_personal_message": {Parameters: map[string]embeddedMCPParamMeta{
|
||||
"text": {Type: "string"},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
if _, err := runtimeToolSpecFromContractFinal(entry, mustFinal(t, cmd), metadata); err != nil {
|
||||
t.Fatalf("runtimeToolSpecFromContractFinal with MCP metadata = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "create", Short: "s", Long: "l"}
|
||||
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
|
||||
cmd.Flags().String("mode", "", "usage")
|
||||
runtimeannotate.AnnotateRuntimeFlag(cmd, "mode", "mode", "string", false)
|
||||
contractfinal.RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{
|
||||
Title: "Final Title",
|
||||
Safety: &contract.SafetySpec{
|
||||
Effect: "write", Confirmation: "user_required", Idempotency: "none",
|
||||
},
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewInvocation},
|
||||
Selection: &contract.SelectionSpec{
|
||||
AgentSummary: "from contract",
|
||||
UseWhen: []string{"create things"},
|
||||
},
|
||||
Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "dev", Name: "create_thing", CanonicalPath: "dev.create_thing",
|
||||
CLIPath: "dev create", PrimaryCLIPath: "dev create",
|
||||
},
|
||||
})
|
||||
|
||||
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 != contract.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 TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalIdentityMismatchFails(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]contract.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, contract.ContractFinalPayload{Identity: &id}, runtimeSchemaMetadataSources{})
|
||||
if err == nil {
|
||||
t.Fatal("declared identity conflicting with bound entry must fail assembly")
|
||||
}
|
||||
})
|
||||
}
|
||||
consistent := contract.ContractFinalPayload{Identity: &contract.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 TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalRejectsReviewedSelection(t *testing.T) {
|
||||
reviewed := true
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "dev",
|
||||
ToolName: "create_thing",
|
||||
CLIName: "create",
|
||||
CLIPath: "dev create",
|
||||
PrimaryCLIPath: "dev create",
|
||||
Command: &cobra.Command{Use: "create"},
|
||||
Source: "test",
|
||||
}
|
||||
_, err := runtimeToolSpecFromContractFinal(entry, contract.ContractFinalPayload{
|
||||
Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "dev", Name: "create_thing", CanonicalPath: "dev.create_thing",
|
||||
CLIPath: "dev create", PrimaryCLIPath: "dev create",
|
||||
CLIName: "create", Source: "test",
|
||||
},
|
||||
Selection: &contract.SelectionSpec{AgentSummary: "sum", Reviewed: &reviewed},
|
||||
}, runtimeSchemaMetadataSources{})
|
||||
if err == nil || !strings.Contains(err.Error(), "must not carry reviewed field") {
|
||||
t.Fatalf("declaration payload carrying Reviewed must fail assembly, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalOptionalIdentityMismatch(t *testing.T) {
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "dev",
|
||||
ToolName: "create_thing",
|
||||
CLIName: "create",
|
||||
Group: "dev thing",
|
||||
CLIPath: "dev create",
|
||||
PrimaryCLIPath: "dev create",
|
||||
Command: &cobra.Command{Use: "create"},
|
||||
Source: "test",
|
||||
}
|
||||
for name, id := range map[string]contract.ToolIdentitySpec{
|
||||
"cli_name": {
|
||||
ProductID: "dev", Name: "create_thing", CanonicalPath: "dev.create_thing",
|
||||
CLIPath: "dev create", PrimaryCLIPath: "dev create", CLIName: "other", Source: "test",
|
||||
},
|
||||
"group": {
|
||||
ProductID: "dev", Name: "create_thing", CanonicalPath: "dev.create_thing",
|
||||
CLIPath: "dev create", PrimaryCLIPath: "dev create", CLIName: "create",
|
||||
Group: "other group", Source: "test",
|
||||
},
|
||||
"source": {
|
||||
ProductID: "dev", Name: "create_thing", CanonicalPath: "dev.create_thing",
|
||||
CLIPath: "dev create", PrimaryCLIPath: "dev create", CLIName: "create",
|
||||
Source: "other",
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := runtimeToolSpecFromContractFinal(entry, contract.ContractFinalPayload{Identity: &id}, runtimeSchemaMetadataSources{})
|
||||
if err == nil || !strings.Contains(err.Error(), name+":") {
|
||||
t.Fatalf("optional identity field %s mismatch error = %v", name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalIdentityOverridesApplied(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "create"}
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "dev",
|
||||
SourceProductID: "src",
|
||||
ToolName: "create_thing",
|
||||
CLIName: "create",
|
||||
Group: "dev thing",
|
||||
CLIPath: "dev create",
|
||||
PrimaryCLIPath: "dev create",
|
||||
Aliases: []string{"dev alt"},
|
||||
Command: cmd,
|
||||
Source: "test",
|
||||
}
|
||||
final := contract.ContractFinalPayload{Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "dev",
|
||||
SourceProductID: "src",
|
||||
Name: "create_thing",
|
||||
CLIName: "create",
|
||||
CanonicalPath: "dev.create_thing",
|
||||
CLIPath: "dev create",
|
||||
PrimaryCLIPath: "dev create",
|
||||
Group: "dev thing",
|
||||
Aliases: []string{"dev alt"},
|
||||
Source: "test",
|
||||
}}
|
||||
spec, err := runtimeToolSpecFromContractFinal(entry, final, runtimeSchemaMetadataSources{})
|
||||
if err != nil {
|
||||
t.Fatalf("consistent declared identity with all override fields failed: %v", err)
|
||||
}
|
||||
if spec.Identity.SourceProductID != "src" {
|
||||
t.Fatalf("source_product_id = %q, want src", spec.Identity.SourceProductID)
|
||||
}
|
||||
if spec.Identity.Group != "dev thing" {
|
||||
t.Fatalf("group = %q, want dev thing", spec.Identity.Group)
|
||||
}
|
||||
if len(spec.Identity.Aliases) != 1 || spec.Identity.Aliases[0] != "dev alt" {
|
||||
t.Fatalf("aliases = %v, want [dev alt]", spec.Identity.Aliases)
|
||||
}
|
||||
if spec.Identity.CanonicalPath != "dev.create_thing" || spec.Identity.Source != "test" {
|
||||
t.Fatalf("identity = %#v", spec.Identity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalSameLengthAliasMismatchFails(t *testing.T) {
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "dev",
|
||||
ToolName: "create_thing",
|
||||
CLIName: "create",
|
||||
CLIPath: "dev create",
|
||||
PrimaryCLIPath: "dev create",
|
||||
Aliases: []string{"dev rm"},
|
||||
Command: &cobra.Command{Use: "create"},
|
||||
Source: "test",
|
||||
}
|
||||
// Same-length alias sets with different members must be detected by the
|
||||
// element-wise comparison, not only by the length fast-path.
|
||||
final := contract.ContractFinalPayload{Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "dev", Name: "create_thing", CanonicalPath: "dev.create_thing",
|
||||
CLIPath: "dev create", PrimaryCLIPath: "dev create",
|
||||
Aliases: []string{"dev other"},
|
||||
}}
|
||||
_, err := runtimeToolSpecFromContractFinal(entry, final, runtimeSchemaMetadataSources{})
|
||||
if err == nil || !strings.Contains(err.Error(), "aliases") {
|
||||
t.Fatalf("same-length alias mismatch error = %v, want aliases mismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalSafetyAnnotationFallbacks(t *testing.T) {
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "dev",
|
||||
ToolName: "create_thing",
|
||||
CLIName: "create",
|
||||
CLIPath: "dev create",
|
||||
PrimaryCLIPath: "dev create",
|
||||
Source: "test",
|
||||
}
|
||||
|
||||
// Safety nil + Contract Risk annotation: Risk overlay wins.
|
||||
riskCmd := &cobra.Command{Use: "create"}
|
||||
riskCmd.Annotations = map[string]string{runtimeannotate.AnnotationRisk: "write"}
|
||||
entry.Command = riskCmd
|
||||
spec, err := runtimeToolSpecFromContractFinal(entry, contract.ContractFinalPayload{}, runtimeSchemaMetadataSources{})
|
||||
if err != nil {
|
||||
t.Fatalf("risk-annotated declared leaf failed: %v", err)
|
||||
}
|
||||
if spec.Safety.Effect != "write" || spec.Safety.Risk != "medium" || spec.Safety.Confirmation != "user_required" {
|
||||
t.Fatalf("risk fallback safety = %#v", spec.Safety)
|
||||
}
|
||||
if spec.Safety.EffectSource != "corecmd.contract" {
|
||||
t.Fatalf("risk fallback effect source = %q", spec.Safety.EffectSource)
|
||||
}
|
||||
|
||||
// Safety nil + runtime gate annotation: gate overlay wins.
|
||||
gateCmd := &cobra.Command{Use: "create"}
|
||||
gateCmd.Annotations = map[string]string{runtimeannotate.AnnotationRuntimeGate: "devAppRequireWriteGuard"}
|
||||
entry.Command = gateCmd
|
||||
spec, err = runtimeToolSpecFromContractFinal(entry, contract.ContractFinalPayload{}, runtimeSchemaMetadataSources{})
|
||||
if err != nil {
|
||||
t.Fatalf("gate-annotated declared leaf failed: %v", err)
|
||||
}
|
||||
if spec.Safety.Confirmation != "user_required" || spec.Safety.Effect != "write" || spec.Safety.Risk != "medium" {
|
||||
t.Fatalf("gate fallback safety = %#v", spec.Safety)
|
||||
}
|
||||
if spec.Safety.EffectSource != "corecmd.contract_gate" {
|
||||
t.Fatalf("gate fallback effect source = %q", spec.Safety.EffectSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalParameterResolutionError(t *testing.T) {
|
||||
oldParameters := resolveRuntimeParameters
|
||||
t.Cleanup(func() { resolveRuntimeParameters = oldParameters })
|
||||
resolveRuntimeParameters = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
return nil, errors.New("parameters failed")
|
||||
}
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "dev",
|
||||
ToolName: "create_thing",
|
||||
Command: &cobra.Command{Use: "create"},
|
||||
}
|
||||
_, err := runtimeToolSpecFromContractFinal(entry, contract.ContractFinalPayload{}, runtimeSchemaMetadataSources{})
|
||||
if err == nil || !strings.Contains(err.Error(), "resolve Contract Schema parameters") {
|
||||
t.Fatalf("parameter resolution error = %v, want resolve Contract Schema parameters", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustFinal(t *testing.T, cmd *cobra.Command) contract.ContractFinalPayload {
|
||||
t.Helper()
|
||||
final, ok := contractfinal.RuntimeContractFinal(cmd)
|
||||
if !ok {
|
||||
t.Fatal("missing final")
|
||||
}
|
||||
return final
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// 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/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageApplyContractRiskToSafety(t *testing.T) {
|
||||
base := contract.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 TestCrossPlatformCoverageApplyContractGateToSafety(t *testing.T) {
|
||||
base := contract.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 := contract.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)
|
||||
}
|
||||
if got = applyContractGateToSafety(base, " "); got != base {
|
||||
t.Fatalf("blank gate must be a no-op, got %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -5,130 +5,27 @@ package cli
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageCanonicalCommandsAndFlags(t *testing.T) {
|
||||
mcp := NewMCPCommand(t.Context(), nil, nil, nil)
|
||||
mcp := NewMCPCommand()
|
||||
mcp.SetOut(&bytes.Buffer{})
|
||||
if err := mcp.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
schema := map[string]any{"properties": map[string]any{
|
||||
"str": map[string]any{"type": "string", "description": " text "},
|
||||
"enum": map[string]any{"enum": []any{"a"}},
|
||||
"int": map[string]any{"type": "integer"},
|
||||
"num": map[string]any{"type": "number"},
|
||||
"bool": map[string]any{"type": "boolean"},
|
||||
"object": map[string]any{"type": "object"},
|
||||
"strings": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
"enums": map[string]any{"type": "array", "items": map[string]any{"enum": []any{"x"}}},
|
||||
"ints": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}},
|
||||
"nums": map[string]any{"type": "array", "items": map[string]any{"type": "number"}},
|
||||
"bools": map[string]any{"type": "array", "items": map[string]any{"type": "boolean"}},
|
||||
"objects": map[string]any{"type": "array", "items": map[string]any{"type": "object"}},
|
||||
"unknown": map[string]any{"type": "future"},
|
||||
"array": map[string]any{"type": "array"},
|
||||
"invalid": "not schema",
|
||||
}}
|
||||
specs := BuildFlagSpecs(schema, map[string]CLIFlagHint{
|
||||
"str": {Alias: "string-alias", Shorthand: "s"},
|
||||
"int": {Alias: "int-alias", Shorthand: "i"},
|
||||
"num": {Alias: "num-alias", Shorthand: "n"},
|
||||
"bool": {Alias: "bool-alias", Shorthand: "b"},
|
||||
})
|
||||
if len(specs) != 13 {
|
||||
t.Fatalf("flag specs = %d, want 13", len(specs))
|
||||
}
|
||||
if BuildFlagSpecs(nil, nil) != nil || BuildFlagSpecs(map[string]any{}, nil) != nil {
|
||||
t.Fatal("missing properties produced flag specs")
|
||||
}
|
||||
cmd := &cobra.Command{Use: "flags"}
|
||||
cmd.Flags().String("occupied", "", "")
|
||||
cmd.Flags().StringP("short", "s", "", "")
|
||||
specs = append(specs,
|
||||
FlagSpec{PropertyName: "skip", FlagName: "json", Kind: flagString},
|
||||
FlagSpec{PropertyName: "same", FlagName: "same", Alias: "same", Kind: flagString},
|
||||
FlagSpec{PropertyName: "occupied", FlagName: "occupied", Kind: flagString},
|
||||
FlagSpec{PropertyName: "default", FlagName: "default", Kind: flagString},
|
||||
)
|
||||
applyFlagSpecs(cmd, specs)
|
||||
applyFlagSpecs(cmd, []FlagSpec{{PropertyName: "array-alias", FlagName: "array-alias", Alias: "array-hidden", Kind: flagStringArray}})
|
||||
for _, name := range []string{"str", "string-alias", "int", "int-alias", "num", "num-alias", "bool", "bool-alias", "strings", "ints", "nums", "bools", "objects", "array", "default"} {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Fatalf("flag %q not registered", name)
|
||||
}
|
||||
}
|
||||
if nested, ok := nestedMap(map[string]any{"x": map[string]any{"y": true}}, "x"); !ok || !nested["y"].(bool) {
|
||||
t.Fatal("nested map lookup failed")
|
||||
}
|
||||
if _, ok := nestedMap(nil, "x"); ok {
|
||||
t.Fatal("nil nested map matched")
|
||||
}
|
||||
if _, ok := nestedMap(map[string]any{}, "x"); ok {
|
||||
t.Fatal("missing nested map matched")
|
||||
}
|
||||
if _, ok := nestedMap(map[string]any{"x": 1}, "x"); ok {
|
||||
t.Fatal("non-map nested value matched")
|
||||
}
|
||||
if schemaDescription(map[string]any{"description": 1}) != "" {
|
||||
t.Fatal("non-string description rendered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLoaderAndPriorityEdges(t *testing.T) {
|
||||
for _, reason := range []CatalogDegradedReason{DegradedUnauthenticated, DegradedMarketUnreachable, DegradedRuntimeAllFailed, "other"} {
|
||||
degraded := newCatalogDegraded(reason, 2)
|
||||
if degraded.Error() == "" || degradedHint(reason, 2) == "" || degraded.Hint == "" {
|
||||
t.Fatalf("degraded %q = %#v", reason, degraded)
|
||||
}
|
||||
}
|
||||
catalog := Catalog{Products: []CanonicalProduct{{ID: "product", Tools: []ToolDescriptor{{RPCName: "tool"}}}}}
|
||||
product, ok := catalog.FindProduct("product")
|
||||
if !ok {
|
||||
t.Fatal("product not found")
|
||||
}
|
||||
if _, ok := catalog.FindProduct("missing"); ok {
|
||||
t.Fatal("missing product found")
|
||||
}
|
||||
if _, ok := product.FindTool("tool"); !ok {
|
||||
t.Fatal("tool not found")
|
||||
}
|
||||
if _, ok := product.FindTool("missing"); ok {
|
||||
t.Fatal("missing tool found")
|
||||
}
|
||||
if got, err := (StaticLoader{Catalog: catalog}).Load(t.Context()); err != nil || len(got.Products) != 1 {
|
||||
t.Fatalf("static load = %#v, %v", got, err)
|
||||
}
|
||||
failure := errors.New("load failed")
|
||||
if got, err := CatalogLoaderFrom(catalog, failure).Load(t.Context()); !errors.Is(err, failure) || len(got.Products) != 1 {
|
||||
t.Fatalf("preloaded load = %#v, %v", got, err)
|
||||
}
|
||||
loader := NewEnvironmentLoader()
|
||||
if got, err := loader.Load(t.Context()); err != nil || len(got.Products) != 0 {
|
||||
t.Fatalf("environment load = %#v, %v", got, err)
|
||||
}
|
||||
func TestCrossPlatformCoveragePriorityEdges(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "priority"}
|
||||
SetOverridePriority(cmd, 42)
|
||||
if got := OverridePriority(cmd); got != 42 {
|
||||
t.Fatalf("priority = %d", got)
|
||||
}
|
||||
original := edition.Get()
|
||||
edition.Override(&edition.Hooks{IsEmbedded: true})
|
||||
t.Cleanup(func() { edition.Override(original) })
|
||||
for _, reason := range []CatalogDegradedReason{DegradedUnauthenticated, DegradedMarketUnreachable, DegradedRuntimeAllFailed} {
|
||||
if degradedHint(reason, 2) == "" {
|
||||
t.Fatalf("embedded degraded hint %q is empty", reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaValidationCompleteMatrix(t *testing.T) {
|
||||
@@ -214,13 +111,29 @@ func TestCrossPlatformCoverageSchemaValidationCompleteMatrix(t *testing.T) {
|
||||
|
||||
func TestCrossPlatformCoverageStdinCompleteMatrix(t *testing.T) {
|
||||
original := os.Stdin
|
||||
t.Cleanup(func() { os.Stdin = original })
|
||||
// Restore/close before t.TempDir cleanup. defer runs on return and on
|
||||
// t.Fatal (Goexit); open Windows handles otherwise block RemoveAll.
|
||||
defer func() {
|
||||
if current := os.Stdin; current != nil && current != original {
|
||||
os.Stdin = original
|
||||
_ = current.Close()
|
||||
return
|
||||
}
|
||||
os.Stdin = original
|
||||
}()
|
||||
setStdin := func(f *os.File) {
|
||||
if prev := os.Stdin; prev != nil && prev != original {
|
||||
_ = prev.Close()
|
||||
}
|
||||
os.Stdin = f
|
||||
}
|
||||
|
||||
closed, err := os.CreateTemp(t.TempDir(), "closed")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = closed.Close()
|
||||
os.Stdin = closed
|
||||
setStdin(closed)
|
||||
if StdinIsPipe() {
|
||||
t.Fatal("closed stdin reported as pipe")
|
||||
}
|
||||
@@ -236,7 +149,7 @@ func TestCrossPlatformCoverageStdinCompleteMatrix(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = stdin.Seek(0, 0)
|
||||
os.Stdin = stdin
|
||||
setStdin(stdin)
|
||||
if !StdinIsPipe() {
|
||||
t.Fatal("regular file stdin did not report pipe")
|
||||
}
|
||||
@@ -270,8 +183,7 @@ func TestCrossPlatformCoverageStdinCompleteMatrix(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer largeStdin.Close()
|
||||
os.Stdin = largeStdin
|
||||
setStdin(largeStdin)
|
||||
if _, err := readStdinBounded(); err == nil {
|
||||
t.Fatal("oversized stdin accepted")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// 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/atomic"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Test helpers for this package's lazy delivery state. Production code must
|
||||
// not call these; the ForTest suffix is the boundary. Cross-package tests use
|
||||
// the exported ones; in-package tests may use the unexported resetters.
|
||||
|
||||
func resetDeliverySchemaCatalogStateForTest() {
|
||||
resetDeliverySchemaCatalogState()
|
||||
}
|
||||
|
||||
func resetMetaByCLIPathStateForTest() {
|
||||
resetSchemaDeliveryState()
|
||||
}
|
||||
|
||||
func resetSchemaSourceRootAtomicForTest() {
|
||||
schemaSourceRoot = atomic.Value{}
|
||||
}
|
||||
|
||||
// clearDeclaredDryRunCapabilitiesForTest resets the declared index (tests only).
|
||||
func clearDeclaredDryRunCapabilitiesForTest() {
|
||||
declaredDryRunCapabilities.Range(func(key, _ any) bool {
|
||||
declaredDryRunCapabilities.Delete(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func resetReviewedDryRunCapabilitiesLazyForTest() {
|
||||
resetReviewedDryRunCapabilitiesLazy()
|
||||
}
|
||||
|
||||
func setReviewedDryRunCapabilityGroupsForTest(groups []dryRunCapabilityGroup) func() {
|
||||
previous := reviewedDryRunCapabilityGroups
|
||||
reviewedDryRunCapabilityGroups = groups
|
||||
resetReviewedDryRunCapabilitiesLazy()
|
||||
return func() {
|
||||
reviewedDryRunCapabilityGroups = previous
|
||||
resetReviewedDryRunCapabilitiesLazy()
|
||||
}
|
||||
}
|
||||
|
||||
// InstallProductionSchemaAssemblyForTest installs the production
|
||||
// assembleSchemaCatalogFromRoot delivery path with factory and clears lazy
|
||||
// caches. Tests must Cleanup via RegisterSchemaSourceRoot(nil) or a restore
|
||||
// helper that reinstalls their prior delivery stub.
|
||||
func InstallProductionSchemaAssemblyForTest(factory func() *cobra.Command) {
|
||||
RegisterSchemaSourceRoot(factory)
|
||||
assembleDeliverySchemaCatalogFn = assembleSchemaCatalogFromRoot
|
||||
resetDeliverySchemaCatalogStateForTest()
|
||||
}
|
||||
|
||||
// DeliverySchemaAllPayloadForTest returns schema --all through the installed
|
||||
// delivery loader (catalog_hash comes from Snapshot.SourceHash).
|
||||
func DeliverySchemaAllPayloadForTest() (map[string]any, error) {
|
||||
return deliverySchemaAllPayload()
|
||||
}
|
||||
|
||||
// RestorePackageCLISchemaDeliveryForTest reinstalls the package-cli TestMain
|
||||
// assembled-delivery stub after a production-assembly exercise. Outside package
|
||||
// cli TestMain it clears the factory and resets lazy delivery state.
|
||||
func RestorePackageCLISchemaDeliveryForTest() {
|
||||
if restorePackageCLISchemaDeliveryHook != nil {
|
||||
restorePackageCLISchemaDeliveryHook()
|
||||
return
|
||||
}
|
||||
storeSchemaSourceRootFn(nil)
|
||||
assembleDeliverySchemaCatalogFn = assembleSchemaCatalogFromRoot
|
||||
resetSchemaDeliveryState()
|
||||
}
|
||||
|
||||
// restorePackageCLISchemaDeliveryHook is installed by package-cli TestMain so
|
||||
// external cli_test helpers can restore the assembled-delivery stub.
|
||||
var restorePackageCLISchemaDeliveryHook func()
|
||||
@@ -0,0 +1,112 @@
|
||||
// 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 (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestProductionCodeDoesNotCallForTestHelpers enforces the fortest.go boundary:
|
||||
// production (non-test) code must not call symbols whose names end in ForTest.
|
||||
// Definitions may live in fortest.go; *_test.go may call them freely.
|
||||
func TestProductionCodeDoesNotCallForTestHelpers(t *testing.T) {
|
||||
root := moduleRoot(t)
|
||||
fset := token.NewFileSet()
|
||||
var violations []string
|
||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
name := d.Name()
|
||||
switch name {
|
||||
case ".git", "vendor", "node_modules", ".worktrees", "artifacts", "dist", "target":
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") {
|
||||
return nil
|
||||
}
|
||||
base := filepath.Base(path)
|
||||
if strings.HasSuffix(base, "_test.go") || base == "fortest.go" {
|
||||
return nil
|
||||
}
|
||||
rel, relErr := filepath.Rel(root, path)
|
||||
if relErr != nil {
|
||||
rel = path
|
||||
}
|
||||
file, parseErr := parser.ParseFile(fset, path, nil, 0)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
name := calledIdentName(call.Fun)
|
||||
if name == "" || !strings.HasSuffix(name, "ForTest") {
|
||||
return true
|
||||
}
|
||||
pos := fset.Position(call.Pos())
|
||||
violations = append(violations, rel+":"+strconv.Itoa(pos.Line)+": call to "+name)
|
||||
return true
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk module: %v", err)
|
||||
}
|
||||
if len(violations) > 0 {
|
||||
t.Fatalf("production code must not call *ForTest helpers:\n %s", strings.Join(violations, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
func moduleRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getcwd: %v", err)
|
||||
}
|
||||
dir := wd
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||
return dir
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
t.Fatal("go.mod not found above package directory")
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
func calledIdentName(fun ast.Expr) string {
|
||||
switch v := fun.(type) {
|
||||
case *ast.Ident:
|
||||
return v.Name
|
||||
case *ast.SelectorExpr:
|
||||
return v.Sel.Name
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
+21
-21
@@ -11,31 +11,31 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// gen.go is the single entry point for reviewed CLI asset generation. It isolates
|
||||
// all //go:generate pragmas from business code so that:
|
||||
// - schema_agent_metadata.go / schema_catalog.go contain only types + embed.
|
||||
// - Generation is a standalone process (make generate-schema triggers this).
|
||||
// - The authored-input → generated-output contract is documented in one place.
|
||||
// gen.go isolates //go:generate pragmas from business code.
|
||||
//
|
||||
// Generation inputs (authored, reviewed):
|
||||
// 1. schema_command_registry/ identity (canonical/aliases/navigation)
|
||||
// 2. schema_hints/metadata/*.json safety (effect/risk/confirmation)
|
||||
// 3. schema_hints/selection/*.json selection (use_when/avoid_when)
|
||||
// 4. schema_mcp_metadata.json MCP server tool definitions
|
||||
// 5. schema_parameter_bindings.json parameter type/property mappings
|
||||
// 6. param_concepts.json + schema reviewed parameter synonym policy
|
||||
// 7. cobra command tree (Go runtime) flags/usage/required (reflected)
|
||||
// Schema Catalog delivery is NOT generated here. Production assembles Catalog
|
||||
// at runtime via ResolveSchemaBuild (声明即 Catalog); see schema_source_root.go
|
||||
// and internal/app/schema_source_register.go. cmd_schema_catalog remains a
|
||||
// CI/determinism tool (make generate-schema / check-generated-drift) and must
|
||||
// not be a committed delivery step.
|
||||
//
|
||||
// Generation outputs (embedded at build):
|
||||
// - schema_agent_metadata/*.json per-product agent metadata
|
||||
// - schema_catalog/ per-product catalog shards
|
||||
// Inputs consumed by runtime assembly:
|
||||
// 1. leaf ContractFinal.Identity identity (canonical/aliases/navigation),
|
||||
// collected from the live Cobra tree
|
||||
// 2. contract.ProductDecl + leaf ContractFinal Agent routing / selection prose
|
||||
// (Interface / ParamDecl own interface facts)
|
||||
// 3. schema_parameter_mapping_ledger.go mapping exclusions / removals (Go)
|
||||
// (active bindings retired; ParamDecl.Property owns delivery)
|
||||
// 4. param_concepts.json + schema reviewed parameter synonym policy
|
||||
// 5. cobra command tree (Go runtime) flags/usage/required (reflected)
|
||||
//
|
||||
// schema_hints/, schema_agent_metadata/, schema_command_registry/,
|
||||
// schema_mcp_metadata.json, and schema_mcp_service_review(.json|/ledger) are
|
||||
// retired.
|
||||
//
|
||||
// Remaining generated output from this file:
|
||||
// - param_aliases_generated.go per-command parameter normalization
|
||||
|
||||
package cli
|
||||
|
||||
//go:generate go run ../generator/cmd_schema_agent_metadata -root ../.. -registry internal/cli/schema_command_registry -output-dir schema_agent_metadata -audit-output schema_agent_metadata_audit.json
|
||||
// Rebuild all dependencies so the Catalog compiler cannot reuse the cli
|
||||
// package cached by the preceding metadata generator with the old embedded
|
||||
// JSON files.
|
||||
//go:generate go run -a ../generator/cmd_schema_catalog -root ../.. -output schema_catalog
|
||||
//go:generate go run ../generator/cmd_param_aliases -root ../.. -output param_aliases_generated.go
|
||||
|
||||
@@ -48,7 +48,6 @@ func TestGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
|
||||
t.Fatalf("read gen.go: %v", err)
|
||||
}
|
||||
for _, generator := range []string{
|
||||
"cmd_schema_agent_metadata",
|
||||
"cmd_schema_catalog",
|
||||
"cmd_param_aliases",
|
||||
} {
|
||||
@@ -56,4 +55,13 @@ func TestGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
|
||||
t.Errorf("gen.go does not register %s", generator)
|
||||
}
|
||||
}
|
||||
if bytes.Contains(content, []byte("cmd_schema_agent_metadata")) {
|
||||
t.Error("gen.go must not regenerate retired schema_agent_metadata/")
|
||||
}
|
||||
for _, line := range bytes.Split(content, []byte("\n")) {
|
||||
trimmed := strings.TrimSpace(string(line))
|
||||
if strings.HasPrefix(trimmed, "//go:generate") && strings.Contains(trimmed, "schema_agent_metadata") {
|
||||
t.Errorf("go:generate must not target schema_agent_metadata: %s", trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// 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 homology holds Schema/CLI homology and ContractFinal consistency
|
||||
// gates that exercise the live command tree and delivered Catalog.
|
||||
//
|
||||
// These tests intentionally live outside the cli root so contract / homology
|
||||
// policy noise does not clutter the delivery package surface. They import
|
||||
// internal/cli and related packages; they do not redefine delivery types.
|
||||
package homology
|
||||
@@ -0,0 +1,239 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package homology
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
)
|
||||
|
||||
// TestAllCommandsContractFinalConsistentWithLiveCatalog 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
|
||||
func TestAllCommandsContractFinalConsistentWithLiveCatalog(t *testing.T) {
|
||||
root := app.NewSchemaSourceRootCommand()
|
||||
liveReg, err := cli.AssembleSchemaRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatalf("AssembleSchemaRegistry(live) error = %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
|
||||
}
|
||||
final, has := contractfinal.RuntimeContractFinal(cmd.PrimaryCommand)
|
||||
if !has {
|
||||
failCount++
|
||||
rows = append(rows, row{canonical, cliPath, "FAIL", "no contractfinal.RuntimeContractFinal on PrimaryCommand"})
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
problems := compareFinalToLive(canonical, final, liveTool)
|
||||
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\n",
|
||||
checked, failCount, checked-failCount, len(liveByCanon))
|
||||
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 == 0 {
|
||||
t.Fatal("no bound commands with ContractFinal were checked against live catalog")
|
||||
}
|
||||
if err := cli.ValidateRuntimeSchemaCompleteness(app.NewSchemaSourceRootCommand()); err != nil {
|
||||
t.Fatalf("ValidateRuntimeSchemaCompleteness() 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
|
||||
}
|
||||
|
||||
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 compareFinalToLive(canonical string, final contract.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))
|
||||
}
|
||||
}
|
||||
// Description is intentionally not compared: ContractFinal stores the
|
||||
// authored ContractDecl.Description, while Catalog delivery prefers Cobra
|
||||
// Long when present (provenance cobra_help). live↔embed still checks the
|
||||
// delivered description string.
|
||||
_ = 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 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
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package homology
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
)
|
||||
|
||||
func TestContractFinalFoundForHrbrain(t *testing.T) {
|
||||
root := app.NewSchemaSourceRootCommand()
|
||||
hrbrain, _, err := root.Find([]string{"hrbrain", "search", "employees"})
|
||||
if err != nil || hrbrain == nil {
|
||||
t.Fatalf("hrbrain search employees not found: %v", err)
|
||||
}
|
||||
final, ok := contractfinal.RuntimeContractFinal(hrbrain)
|
||||
if !ok {
|
||||
t.Fatal("ContractFinal NOT found for hrbrain search employees — this is the root cause of contract.ParamDecl not working")
|
||||
}
|
||||
t.Logf("ContractFinal found: title=%s params=%d", final.Title, len(final.Parameters))
|
||||
}
|
||||
|
||||
func TestContractFinalFoundForAisearch(t *testing.T) {
|
||||
root := app.NewSchemaSourceRootCommand()
|
||||
enterprise, _, err := root.Find([]string{"aisearch", "enterprise"})
|
||||
if err != nil || enterprise == nil {
|
||||
t.Fatalf("aisearch enterprise not found: %v", err)
|
||||
}
|
||||
final, ok := contractfinal.RuntimeContractFinal(enterprise)
|
||||
if !ok {
|
||||
t.Fatal("ContractFinal NOT found for aisearch enterprise")
|
||||
}
|
||||
t.Logf("ContractFinal found: title=%s params=%d", final.Title, len(final.Parameters))
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// 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 homology
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ciHomologyEntrypoints are executable gates already wired into
|
||||
// scripts/policy/check-schema-catalog.sh (and confirmation-truth helper).
|
||||
// Vocabulary-only pinning is not enough: these names must stay on the policy
|
||||
// -run whitelist.
|
||||
var ciHomologyEntrypoints = []string{
|
||||
"TestUserRequiredSafetyHomologyWithRuntimeGate",
|
||||
"TestHomologyDecisionDocPinsPathAAndGateIDs",
|
||||
"TestMCPPassthroughAdmissionExcludesLeafAndShortcut",
|
||||
"TestHomologyCIEntrypointsPinned",
|
||||
"TestFinalSchemaParametersMatchExecutableHelpFlags",
|
||||
"TestDeliverySchemaParametersMatchExecutableHelpFlags",
|
||||
"TestSheetFinalSchemaConfirmationMatchesRuntimeGuards",
|
||||
}
|
||||
|
||||
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 主路径",
|
||||
"已进 CI",
|
||||
"check-schema-catalog.sh",
|
||||
} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomologyCIEntrypointsPinned(t *testing.T) {
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
root := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", ".."))
|
||||
script := filepath.Join(root, "scripts", "policy", "check-schema-catalog.sh")
|
||||
raw, err := os.ReadFile(script)
|
||||
if err != nil {
|
||||
t.Fatalf("read check-schema-catalog.sh: %v", err)
|
||||
}
|
||||
body := string(raw)
|
||||
for _, name := range ciHomologyEntrypoints {
|
||||
if !strings.Contains(body, name) {
|
||||
t.Fatalf("check-schema-catalog.sh missing homology CI entrypoint %q", name)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, "./internal/cli/homology") {
|
||||
t.Fatal("check-schema-catalog.sh must run ./internal/cli/homology for HOM gate tests")
|
||||
}
|
||||
doc := filepath.Join(root, "docs", "flag-help-schema-homology.md")
|
||||
docRaw, err := os.ReadFile(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("read homology doc: %v", err)
|
||||
}
|
||||
docBody := string(docRaw)
|
||||
for _, name := range []string{
|
||||
"TestUserRequiredSafetyHomologyWithRuntimeGate",
|
||||
"TestFinalSchemaParametersMatchExecutableHelpFlags",
|
||||
"TestHomologyCIEntrypointsPinned",
|
||||
} {
|
||||
if !strings.Contains(docBody, name) {
|
||||
t.Fatalf("homology doc §3 CI table missing %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package homology
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
)
|
||||
|
||||
func TestParamDeclAnnotationCompare(t *testing.T) {
|
||||
root := app.NewSchemaSourceRootCommand()
|
||||
for _, path := range [][]string{
|
||||
{"aisearch", "enterprise"},
|
||||
{"hrbrain", "search", "employees"},
|
||||
{"chat", "add-emoji"},
|
||||
} {
|
||||
cmd, _, err := root.Find(path)
|
||||
if err != nil || cmd == nil {
|
||||
t.Logf("%v: NOT FOUND", path)
|
||||
continue
|
||||
}
|
||||
ann := cmd.Annotations["dws.schema.param_decls"]
|
||||
if ann == "" {
|
||||
t.Logf("%v: NO param_decls annotation", path)
|
||||
} else {
|
||||
t.Logf("%v: HAS param_decls (%d bytes)", path, len(ann))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package homology
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// homologyProbeCaller is a throwaway ToolCaller for Execute-based confirmation
|
||||
// probes. Schema source roots stay declaration-only (no InitDeps); the probe
|
||||
// path initializes deps locally so leaf RunE wrappers do not nil-deref.
|
||||
type homologyProbeCaller struct{}
|
||||
|
||||
func (homologyProbeCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
return &edition.ToolResult{}, nil
|
||||
}
|
||||
func (homologyProbeCaller) Format() string { return "json" }
|
||||
func (homologyProbeCaller) DryRun() bool { return false }
|
||||
func (homologyProbeCaller) Fields() string { return "" }
|
||||
func (homologyProbeCaller) JQ() string { return "" }
|
||||
|
||||
// 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) {
|
||||
// Declaration-only Schema roots intentionally skip InitDeps. Probes Execute
|
||||
// live leaves (including deprecated doc wrappers), so install a local
|
||||
// throwaway caller without touching SetDynamicServers. Restore the prior
|
||||
// deps pointer (including nil) — InitDeps(previousCaller) cannot.
|
||||
helpers.InitDepsForTest(t, homologyProbeCaller{})
|
||||
|
||||
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 := contractfinal.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 contract.SafetySpec it wires into ConfirmSafety. Prove that contract.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(), "用户取消了操作")
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// CatalogDegradedReason identifies why catalog discovery returned empty.
|
||||
type CatalogDegradedReason string
|
||||
|
||||
const (
|
||||
DegradedUnauthenticated CatalogDegradedReason = "unauthenticated"
|
||||
DegradedMarketUnreachable CatalogDegradedReason = "market_unreachable"
|
||||
DegradedRuntimeAllFailed CatalogDegradedReason = "runtime_all_failed"
|
||||
)
|
||||
|
||||
// CatalogDegraded is returned by EnvironmentLoader.Load when discovery
|
||||
// fails for a diagnosable reason. Callers that need graceful degradation
|
||||
// (e.g. the runtime runner) can check errors.As and fall back to an
|
||||
// empty catalog; callers like the schema command can surface the hint.
|
||||
type CatalogDegraded struct {
|
||||
Reason CatalogDegradedReason
|
||||
Hint string
|
||||
ServerCount int
|
||||
}
|
||||
|
||||
func (e *CatalogDegraded) Error() string { return string(e.Reason) + ": " + e.Hint }
|
||||
|
||||
func degradedHint(reason CatalogDegradedReason, serverCount int) string {
|
||||
embedded := edition.Get().IsEmbedded
|
||||
switch reason {
|
||||
case DegradedUnauthenticated:
|
||||
if embedded {
|
||||
return "未登录,请重新认证"
|
||||
}
|
||||
return "未登录,无法发现 MCP 服务。请先执行: dws auth login"
|
||||
case DegradedMarketUnreachable:
|
||||
if embedded {
|
||||
return "无法连接 MCP 市场,请检查网络"
|
||||
}
|
||||
return "无法连接 MCP 市场,请检查网络"
|
||||
case DegradedRuntimeAllFailed:
|
||||
if embedded {
|
||||
return fmt.Sprintf("已发现 %d 个服务但连接全部失败,请稍后重试", serverCount)
|
||||
}
|
||||
return fmt.Sprintf("已发现 %d 个服务但连接全部失败;静态端点模式下请检查 internal/syncdata 生成物或稍后重试", serverCount)
|
||||
default:
|
||||
return "MCP 服务发现失败"
|
||||
}
|
||||
}
|
||||
|
||||
func newCatalogDegraded(reason CatalogDegradedReason, serverCount int) *CatalogDegraded {
|
||||
return &CatalogDegraded{
|
||||
Reason: reason,
|
||||
Hint: degradedHint(reason, serverCount),
|
||||
ServerCount: serverCount,
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
CatalogFixtureEnv = "DWS_CATALOG_FIXTURE"
|
||||
CacheDirEnv = "DWS_CACHE_DIR"
|
||||
PluginColdTimeoutEnv = "DWS_PLUGIN_COLD_TIMEOUT"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Catalog types (formerly in internal/ir, now inlined as minimal stubs)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Catalog holds the discovered MCP product surface.
|
||||
type Catalog struct {
|
||||
Products []CanonicalProduct `json:"products"`
|
||||
}
|
||||
|
||||
// FindProduct returns the product with the given ID.
|
||||
func (c Catalog) FindProduct(id string) (CanonicalProduct, bool) {
|
||||
for _, product := range c.Products {
|
||||
if product.ID == id {
|
||||
return product, true
|
||||
}
|
||||
}
|
||||
return CanonicalProduct{}, false
|
||||
}
|
||||
|
||||
// CanonicalProduct represents a single MCP product in the catalog.
|
||||
type CanonicalProduct struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
ServerKey string `json:"server_key"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Tools []ToolDescriptor `json:"tools"`
|
||||
}
|
||||
|
||||
// FindTool returns the tool with the given RPC name.
|
||||
func (p CanonicalProduct) FindTool(name string) (ToolDescriptor, bool) {
|
||||
for _, tool := range p.Tools {
|
||||
if tool.RPCName == name {
|
||||
return tool, true
|
||||
}
|
||||
}
|
||||
return ToolDescriptor{}, false
|
||||
}
|
||||
|
||||
// ToolDescriptor represents a single tool in a canonical product.
|
||||
type ToolDescriptor struct {
|
||||
RPCName string `json:"rpc_name"`
|
||||
CanonicalPath string `json:"canonical_path"`
|
||||
}
|
||||
|
||||
// CLIFlagHint holds CLI flag alias/shorthand metadata for a tool parameter.
|
||||
type CLIFlagHint struct {
|
||||
Shorthand string `json:"shorthand,omitempty"`
|
||||
Alias string `json:"alias,omitempty"`
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// CatalogLoader interface
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// CatalogLoader loads the canonical catalog.
|
||||
type CatalogLoader interface {
|
||||
Load(context.Context) (Catalog, error)
|
||||
}
|
||||
|
||||
// StaticLoader returns a pre-built catalog.
|
||||
type StaticLoader struct {
|
||||
Catalog Catalog
|
||||
}
|
||||
|
||||
func (l StaticLoader) Load(_ context.Context) (Catalog, error) {
|
||||
return l.Catalog, nil
|
||||
}
|
||||
|
||||
// CatalogLoaderFrom creates a CatalogLoader that returns a
|
||||
// pre-loaded catalog and error.
|
||||
func CatalogLoaderFrom(catalog Catalog, err error) CatalogLoader {
|
||||
return &preloadedLoader{catalog: catalog, err: err}
|
||||
}
|
||||
|
||||
type preloadedLoader struct {
|
||||
catalog Catalog
|
||||
err error
|
||||
}
|
||||
|
||||
func (l *preloadedLoader) Load(_ context.Context) (Catalog, error) {
|
||||
return l.catalog, l.err
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// EnvironmentLoader (static endpoint mode — no longer does live discovery)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// EnvironmentLoader provides catalog loading. In the post-discovery
|
||||
// architecture this always returns an empty catalog; endpoint resolution
|
||||
// is handled by the direct runtime path.
|
||||
type EnvironmentLoader struct {
|
||||
LookupEnv func(string) (string, bool)
|
||||
CatalogBaseURLOverride string
|
||||
AuthTokenFunc func(context.Context) string
|
||||
LoggerFunc func() *slog.Logger
|
||||
}
|
||||
|
||||
// NewEnvironmentLoader creates an EnvironmentLoader with default settings.
|
||||
func NewEnvironmentLoader() EnvironmentLoader {
|
||||
return EnvironmentLoader{}
|
||||
}
|
||||
|
||||
// Load returns an empty catalog. All endpoint resolution now uses the
|
||||
// direct runtime path (dynamic server registry).
|
||||
func (l EnvironmentLoader) Load(_ context.Context) (Catalog, error) {
|
||||
return Catalog{}, nil
|
||||
}
|
||||
|
||||
// Ensure unused imports are consumed.
|
||||
var _ = newCatalogDegraded
|
||||
@@ -159,7 +159,7 @@ type realFlag struct {
|
||||
// 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) {
|
||||
visitAgentExampleCommandFlags(leaf, func(flag *pflag.Flag) {
|
||||
if flag == nil || flag.Name == "help" {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -37,9 +38,7 @@ func conceptFixture() []Concept {
|
||||
|
||||
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 })
|
||||
testseam.Swap(t, &loadReviewedParamConcepts, func() (ParamConcepts, error) { return concepts, err })
|
||||
}
|
||||
|
||||
func TestReduceParamAliasesLoadsAndValidatesSourceTree(t *testing.T) {
|
||||
|
||||
@@ -21,7 +21,8 @@ const paramConceptsSchemaRef = "./param_concepts.schema.json"
|
||||
// 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.
|
||||
// be read back here. It is a reviewed input peer of the collected command
|
||||
// identity (argv synonyms vs identity) — keep the authorities separate.
|
||||
|
||||
//go:embed param_concepts.json
|
||||
var embeddedParamConceptsJSON []byte
|
||||
@@ -144,7 +145,7 @@ var (
|
||||
embeddedParamConceptsOnce sync.Once
|
||||
embeddedParamConceptsData ParamConcepts
|
||||
embeddedParamConceptsErr error
|
||||
loadReviewedParamConcepts = loadEmbeddedParamConcepts
|
||||
loadReviewedParamConcepts = loadParamConceptsFromEmbed
|
||||
)
|
||||
|
||||
// LoadParamConcepts decodes and validates the embedded reviewed concept
|
||||
@@ -153,7 +154,7 @@ func LoadParamConcepts() (ParamConcepts, error) {
|
||||
return loadReviewedParamConcepts()
|
||||
}
|
||||
|
||||
func loadEmbeddedParamConcepts() (ParamConcepts, error) {
|
||||
func loadParamConceptsFromEmbed() (ParamConcepts, error) {
|
||||
embeddedParamConceptsOnce.Do(func() {
|
||||
embeddedParamConceptsData, embeddedParamConceptsErr = decodeParamConcepts(embeddedParamConceptsJSON)
|
||||
})
|
||||
|
||||
+279
-738
File diff suppressed because it is too large
Load Diff
@@ -10,69 +10,42 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeSchemaLoaderAndAnnotationEdges(t *testing.T) {
|
||||
originalJSON := embeddedMCPMetadataJSON
|
||||
t.Cleanup(func() { embeddedMCPMetadataJSON = originalJSON })
|
||||
embeddedMCPMetadataJSON = []byte("{")
|
||||
if got := loadEmbeddedMCPMetadata(); got.Tools == nil || len(got.Tools) != 0 {
|
||||
t.Fatalf("invalid embedded metadata = %#v", got)
|
||||
}
|
||||
embeddedMCPMetadataJSON = []byte(`{"version":1}`)
|
||||
if got := loadEmbeddedMCPMetadata(); got.Tools == nil {
|
||||
t.Fatal("nil tools map was not normalized")
|
||||
if got := emptyPinnedMCPMetadata(); got.Tools == nil || len(got.Tools) != 0 {
|
||||
t.Fatalf("empty pinned metadata = %#v", got)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "run", Short: "short", Long: "long"}
|
||||
AnnotateRuntimeToolMetadata(cmd, " title ", " description ", " source ")
|
||||
if cmd.Annotations[runtimeSchemaTitleAnnotation] != "title" {
|
||||
t.Fatalf("annotations = %#v", cmd.Annotations)
|
||||
}
|
||||
AnnotateRuntimePositionals(cmd, RuntimeSchemaPositional{Name: " ", Index: -1})
|
||||
AnnotateRuntimePositionals(cmd, contract.RuntimeSchemaPositional{Name: " ", Index: -1})
|
||||
if _, ok := cmd.Annotations[runtimeSchemaArgsAnnotation]; ok {
|
||||
t.Fatal("invalid positional should not be annotated")
|
||||
}
|
||||
if runtimeCommandTitle(nil) != "" || runtimeCommandDescription(nil) != "" {
|
||||
t.Fatal("nil command text should be empty")
|
||||
}
|
||||
cmd.Annotations[runtimeSchemaTitleAnnotation] = " annotated title "
|
||||
cmd.Annotations[runtimeSchemaDescAnnotation] = " annotated description "
|
||||
if runtimeCommandTitle(cmd) != "annotated title" || runtimeCommandDescription(cmd) != "annotated description" {
|
||||
t.Fatalf("annotated text = %q / %q", runtimeCommandTitle(cmd), runtimeCommandDescription(cmd))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCollectRuntimeSchemaEntriesErrorsAndOrdering(t *testing.T) {
|
||||
originalValidate := validateReviewedParameterBindings
|
||||
originalRegistry := loadReviewedCommandRegistry
|
||||
originalManual := loadReviewedManualSchemaHints
|
||||
t.Cleanup(func() {
|
||||
validateReviewedParameterBindings = originalValidate
|
||||
loadReviewedCommandRegistry = originalRegistry
|
||||
loadReviewedManualSchemaHints = originalManual
|
||||
})
|
||||
|
||||
validateReviewedParameterBindings = func() error { return errors.New("bindings failed") }
|
||||
testseam.Swap(t, &bindValidateParameterBindings, func() error { return errors.New("bindings failed") })
|
||||
if _, err := collectRuntimeSchemaEntries(&cobra.Command{Use: "dws"}); err == nil || !strings.Contains(err.Error(), "bindings failed") {
|
||||
t.Fatalf("validation error = %v", err)
|
||||
}
|
||||
|
||||
validateReviewedParameterBindings = func() error { return nil }
|
||||
loadReviewedCommandRegistry = func() (CommandRegistry, error) {
|
||||
return CommandRegistry{Commands: []CommandSpec{{
|
||||
CanonicalPath: "sample.run", PrimaryCLIPath: "sample run",
|
||||
Visibility: SchemaVisibilityPublic, Source: "reviewed_registry", ReviewReason: "test binding failure",
|
||||
}}}, nil
|
||||
}
|
||||
loadReviewedManualSchemaHints = func() (ManualSchemaHintSnapshot, error) {
|
||||
return ManualSchemaHintSnapshot{Schema: manualSchemaHintSchemaRef, Version: manualSchemaHintVersion}, nil
|
||||
}
|
||||
testseam.Swap(t, &bindValidateParameterBindings, func() error { return nil })
|
||||
prevGroups := reviewedRuntimeSchemaExclusionGroups
|
||||
t.Cleanup(func() { reviewedRuntimeSchemaExclusionGroups = prevGroups })
|
||||
reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{{
|
||||
ID: "", Reason: "x", Reviewed: true, Commands: []string{"x"},
|
||||
}}
|
||||
if _, err := collectRuntimeSchemaEntries(&cobra.Command{Use: "dws"}); err == nil {
|
||||
t.Fatal("missing Cobra path should fail binding")
|
||||
t.Fatal("invalid reviewed exclusions should fail identity collection")
|
||||
}
|
||||
reviewedRuntimeSchemaExclusionGroups = prevGroups
|
||||
|
||||
if _, err := collectRuntimeSchemaEntriesFromBound(BoundCommandRegistry{Commands: []BoundCommandSpec{{
|
||||
CommandSpec: CommandSpec{CanonicalPath: "invalid", Visibility: SchemaVisibilityPublic},
|
||||
@@ -90,20 +63,39 @@ func TestCrossPlatformCoverageCollectRuntimeSchemaEntriesErrorsAndOrdering(t *te
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeSchemaMetadataLookupEdges(t *testing.T) {
|
||||
originalHints := defaultSchemaHintRegistry
|
||||
t.Cleanup(func() { defaultSchemaHintRegistry = originalHints })
|
||||
defaultSchemaHintRegistry = newSchemaHintRegistry()
|
||||
defaultSchemaHintRegistry.RegisterProduct("source", map[string]ToolSchemaHint{
|
||||
"run": {Title: "source title"},
|
||||
})
|
||||
hint := runtimeSchemaHintForEntry(runtimeSchemaEntry{ProductID: "target", SourceProductID: "source", ToolName: "run"})
|
||||
if hint.Title != "source title" {
|
||||
t.Fatalf("source product hint = %#v", hint)
|
||||
}
|
||||
if _, ok := embeddedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, embeddedAgentMetadata{}, embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}); ok {
|
||||
if _, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, agentMetadata{}, embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}); ok {
|
||||
t.Fatal("empty lookup unexpectedly matched")
|
||||
}
|
||||
|
||||
leaf := &cobra.Command{Use: "reply"}
|
||||
contractfinal.RegisterRuntimeContractFinal(leaf, contract.ContractFinalPayload{
|
||||
Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "sample", Name: "run", CanonicalPath: "sample.run",
|
||||
CLIPath: "sample run", PrimaryCLIPath: "sample run",
|
||||
},
|
||||
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeMCP,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "send_personal_message"},
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { ClearRuntimeContractFinalForTest(leaf) })
|
||||
mcp := embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
|
||||
"chat.send_personal_message": {
|
||||
Parameters: map[string]embeddedMCPParamMeta{
|
||||
"clawType": {Type: "string"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
got, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{Command: leaf, ProductID: "chat", ToolName: "reply_personal_message"}, agentMetadata{}, mcp)
|
||||
if !ok || got.Parameters["clawType"].Type != "string" {
|
||||
t.Fatalf("ContractFinal Interface.Ref MCP remap = %#v ok=%v", got, ok)
|
||||
}
|
||||
if got.InterfaceRef == nil || got.InterfaceRef.RPCName != "send_personal_message" {
|
||||
t.Fatalf("InterfaceRef = %#v", got.InterfaceRef)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
value any
|
||||
want int
|
||||
@@ -118,28 +110,6 @@ func TestCrossPlatformCoverageRuntimeSchemaMetadataLookupEdges(t *testing.T) {
|
||||
t.Fatalf("tool count = %d, want %d", got, test.want)
|
||||
}
|
||||
}
|
||||
|
||||
originalResolver := resolveRuntimeSchemaField
|
||||
t.Cleanup(func() { resolveRuntimeSchemaField = originalResolver })
|
||||
for _, field := range []string{"title", "description"} {
|
||||
resolveRuntimeSchemaField = func(got string, candidates ...runtimeSchemaFieldCandidate) (runtimeSchemaFieldCandidate, error) {
|
||||
if got == field {
|
||||
return runtimeSchemaFieldCandidate{}, errors.New("forced " + field)
|
||||
}
|
||||
return resolveRuntimeSchemaCandidate(got, candidates...)
|
||||
}
|
||||
_, _, _, _, err := runtimeToolTextMetadataFromMetadata(runtimeSchemaEntry{Title: "title", Description: "description", MetadataSource: "native"}, runtimeSchemaMetadataSources{})
|
||||
if err == nil || !strings.Contains(err.Error(), field) {
|
||||
t.Fatalf("%s error = %v", field, err)
|
||||
}
|
||||
}
|
||||
resolveRuntimeSchemaField = func(field string, candidates ...runtimeSchemaFieldCandidate) (runtimeSchemaFieldCandidate, error) {
|
||||
return runtimeSchemaStringCandidate("selected", "mcp_metadata"), nil
|
||||
}
|
||||
_, _, source, _, err := runtimeToolTextMetadataFromMetadata(runtimeSchemaEntry{}, runtimeSchemaMetadataSources{})
|
||||
if err != nil || source != "embedded-mcp-metadata" {
|
||||
t.Fatalf("MCP metadata source = %q, err = %v", source, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeSchemaCandidateAndProvenanceEdges(t *testing.T) {
|
||||
@@ -149,7 +119,7 @@ func TestCrossPlatformCoverageRuntimeSchemaCandidateAndProvenanceEdges(t *testin
|
||||
if err != nil || winner.Precedence != "a" {
|
||||
t.Fatalf("precedence tie ordering winner = %#v, err = %v", winner, err)
|
||||
}
|
||||
if got := runtimeSchemaFieldProvenance(runtimeSchemaFieldCandidate{}); !reflect.DeepEqual(got, FieldProvenance{}) {
|
||||
if got := runtimeSchemaFieldProvenance(runtimeSchemaFieldCandidate{}); !reflect.DeepEqual(got, contract.FieldProvenance{}) {
|
||||
t.Fatalf("absent provenance = %#v", got)
|
||||
}
|
||||
bad := runtimeSchemaCandidate(func() {}, true, "custom")
|
||||
@@ -185,81 +155,61 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
|
||||
cmd.Flags().String("value", "", "value")
|
||||
flag := cmd.Flags().Lookup("value")
|
||||
|
||||
originalBindingData := schemaParameterBindingData
|
||||
originalResolver := resolveRuntimeSchemaField
|
||||
originalPayloadSpecs := runtimeCommandParameterSpecsForPayload
|
||||
t.Cleanup(func() {
|
||||
schemaParameterBindingData = originalBindingData
|
||||
resolveRuntimeSchemaField = originalResolver
|
||||
runtimeCommandParameterSpecsForPayload = originalPayloadSpecs
|
||||
})
|
||||
|
||||
if specs, err := runtimeCommandParameterSpecs(nil, "sample.run", nil, nil, RuntimeSchemaConstraints{}); err != nil || specs != nil {
|
||||
if specs, err := runtimeCommandParameterSpecs(nil, "sample.run", nil, RuntimeSchemaConstraints{}); err != nil || specs != nil {
|
||||
t.Fatalf("nil command specs = %#v, err = %v", specs, err)
|
||||
}
|
||||
schemaParameterBindingData = func() (schemaParameterBindingSnapshot, error) {
|
||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||
return schemaParameterBindingSnapshot{}, errors.New("load failed")
|
||||
}
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "load failed") {
|
||||
})
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "load failed") {
|
||||
t.Fatalf("binding load error = %v", err)
|
||||
}
|
||||
schemaParameterBindingData = func() (schemaParameterBindingSnapshot, error) {
|
||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||
return schemaParameterBindingSnapshot{Bindings: map[string]map[string]string{}, MappingExclusions: map[string]string{}}, nil
|
||||
}
|
||||
})
|
||||
if _, _, err := runtimeSchemaParameterMappingCandidates(schemaParameterBindingSnapshot{
|
||||
Bindings: map[string]map[string]string{},
|
||||
MappingExclusions: map[string]string{"sample.run --value": " "},
|
||||
}, "sample.run", "value"); err == nil {
|
||||
t.Fatal("empty mapping exclusion reason should fail")
|
||||
}
|
||||
schemaParameterBindingData = func() (schemaParameterBindingSnapshot, error) {
|
||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||
return schemaParameterBindingSnapshot{
|
||||
Bindings: map[string]map[string]string{},
|
||||
MappingExclusions: map[string]string{"sample.run --value": " "},
|
||||
}, nil
|
||||
}
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "mapping exclusion") {
|
||||
})
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "mapping exclusion") {
|
||||
t.Fatalf("mapping exclusion error = %v", err)
|
||||
}
|
||||
schemaParameterBindingData = func() (schemaParameterBindingSnapshot, error) {
|
||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||
return schemaParameterBindingSnapshot{Bindings: map[string]map[string]string{}, MappingExclusions: map[string]string{}}, nil
|
||||
}
|
||||
|
||||
cmd.Annotations = map[string]string{
|
||||
runtimeManualSchemaParameterKey(runtimeSchemaManualParameterAnnotation, "value"): "{",
|
||||
}
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "reviewed manual") {
|
||||
t.Fatalf("manual hint error = %v", err)
|
||||
}
|
||||
cmd.Annotations = nil
|
||||
})
|
||||
|
||||
realResolver := resolveRuntimeSchemaField
|
||||
for _, target := range []string{"property", "interface_type", "description", "required", "required_when", "format", "enum", "example"} {
|
||||
resolveRuntimeSchemaField = func(field string, candidates ...runtimeSchemaFieldCandidate) (runtimeSchemaFieldCandidate, error) {
|
||||
testseam.Swap(t, &resolveRuntimeSchemaField, func(field string, candidates ...runtimeSchemaFieldCandidate) (runtimeSchemaFieldCandidate, error) {
|
||||
if field == target {
|
||||
return runtimeSchemaFieldCandidate{}, errors.New("forced " + target)
|
||||
}
|
||||
return resolveRuntimeSchemaCandidate(field, candidates...)
|
||||
}
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), target) {
|
||||
})
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), target) {
|
||||
t.Fatalf("%s resolution error = %v", target, err)
|
||||
}
|
||||
}
|
||||
resolveRuntimeSchemaField = originalResolver
|
||||
resolveRuntimeSchemaField = realResolver
|
||||
|
||||
badHint := map[string]ParameterSchemaHint{"value": {FlagName: "other"}}
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", badHint, nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "does not identify") {
|
||||
t.Fatalf("hint flag mismatch error = %v", err)
|
||||
}
|
||||
if specs, err := runtimeCommandParameterSpecs(&cobra.Command{Use: "empty"}, "sample.empty", nil, nil, RuntimeSchemaConstraints{}); err != nil || specs != nil {
|
||||
if specs, err := runtimeCommandParameterSpecs(&cobra.Command{Use: "empty"}, "sample.empty", nil, RuntimeSchemaConstraints{}); err != nil || specs != nil {
|
||||
t.Fatalf("empty specs = %#v, err = %v", specs, err)
|
||||
}
|
||||
if payload, err := runtimeCommandParameters(nil, "", nil, nil, RuntimeSchemaConstraints{}); err != nil || payload != nil {
|
||||
if payload, err := runtimeCommandParameters(nil, "", nil, RuntimeSchemaConstraints{}); err != nil || payload != nil {
|
||||
t.Fatalf("empty payload = %#v, err = %v", payload, err)
|
||||
}
|
||||
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, map[string]ParameterSchemaHint, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
testseam.Swap(t, &runtimeCommandParameterSpecsForPayload, func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
return []ParameterSpec{{Name: "bad", Example: json.RawMessage("{")}}, nil
|
||||
}
|
||||
if _, err := runtimeCommandParameters(cmd, "sample.run", nil, nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "serialize Schema parameter") {
|
||||
})
|
||||
if _, err := runtimeCommandParameters(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "serialize Schema parameter") {
|
||||
t.Fatalf("payload serialization error = %v", err)
|
||||
}
|
||||
|
||||
@@ -267,6 +217,34 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
|
||||
if required, present := runtimeFlagRequiredState(flag); !required || !present {
|
||||
t.Fatalf("required annotation = %v/%v", required, present)
|
||||
}
|
||||
|
||||
// Fixture MCP-shaped maps still participate when explicitly injected.
|
||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||
return schemaParameterBindingSnapshot{
|
||||
Bindings: map[string]map[string]string{"sample.run": {"value": "clawType"}},
|
||||
}, nil
|
||||
})
|
||||
requiredTrue := true
|
||||
specs, err := runtimeCommandParameterSpecs(cmd, "sample.run", map[string]embeddedMCPParamMeta{
|
||||
"clawType": {
|
||||
Type: "string",
|
||||
Description: "fixture description",
|
||||
Required: &requiredTrue,
|
||||
Default: "fixture-default",
|
||||
},
|
||||
}, RuntimeSchemaConstraints{})
|
||||
if err != nil {
|
||||
t.Fatalf("fixture pinned parameter specs error = %v", err)
|
||||
}
|
||||
if len(specs) != 1 || specs[0].Property != "clawType" || specs[0].InterfaceDescription != "fixture description" {
|
||||
t.Fatalf("fixture pinned parameter specs = %#v", specs)
|
||||
}
|
||||
if len(specs[0].InterfaceDefault) == 0 {
|
||||
t.Fatalf("fixture interface_default missing: %#v", specs[0])
|
||||
}
|
||||
if prov := specs[0].FieldProvenance["required"]; prov.Source == "" {
|
||||
t.Fatalf("fixture required provenance missing: %#v", specs[0].FieldProvenance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeSchemaPureHelperEdges(t *testing.T) {
|
||||
@@ -289,11 +267,13 @@ func TestCrossPlatformCoverageRuntimeSchemaPureHelperEdges(t *testing.T) {
|
||||
if got := runtimeCommandPositionals(cmd); len(got) != 2 || got[0].Name != "first" {
|
||||
t.Fatalf("sorted positionals = %#v", got)
|
||||
}
|
||||
groups := normalizeRuntimeSchemaGroups([][]string{{" "}, {" ", "one", "one"}, {"one"}, {"one"}}, 1)
|
||||
groups := runtimeannotate.NormalizeConstraints(runtimeannotate.RuntimeSchemaConstraints{
|
||||
RequireOneOf: [][]string{{" "}, {" ", "one", "one"}, {"one"}, {"one"}},
|
||||
}).RequireOneOf
|
||||
if !reflect.DeepEqual(groups, [][]string{{"one"}}) {
|
||||
t.Fatalf("normalized groups = %#v", groups)
|
||||
}
|
||||
if meta, ok := lookupEmbeddedMCPParam(map[string]embeddedMCPParamMeta{"flag": {Type: "string"}}, "property", "flag"); !ok || meta.Type != "string" {
|
||||
if meta, ok := lookupPinnedMCPParam(map[string]embeddedMCPParamMeta{"flag": {Type: "string"}}, "property", "flag"); !ok || meta.Type != "string" {
|
||||
t.Fatalf("flag fallback metadata = %#v/%v", meta, ok)
|
||||
}
|
||||
if isGenericPayloadFlag(nil) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// 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 (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
)
|
||||
|
||||
// The declaration model and its runtime stores live under internal/corecmd/*
|
||||
// and every package imports them directly from there. This file only keeps
|
||||
// the cli.* convenience aliases so the delivery root's own code reads flat;
|
||||
// no separate cli shim packages exist.
|
||||
|
||||
type RuntimeSchemaConstraints = runtimeannotate.RuntimeSchemaConstraints
|
||||
|
||||
var (
|
||||
AttachRuntimeSchema = runtimeannotate.AttachRuntimeSchema
|
||||
AnnotateRuntimeFlag = runtimeannotate.AnnotateRuntimeFlag
|
||||
AnnotateRuntimeFlagProperty = runtimeannotate.AnnotateRuntimeFlagProperty
|
||||
AnnotateRuntimeRequiredFlags = runtimeannotate.AnnotateRuntimeRequiredFlags
|
||||
AnnotateRuntimeFlagRequiredValue = runtimeannotate.AnnotateRuntimeFlagRequiredValue
|
||||
AnnotateRuntimeFlagRequiredWhen = runtimeannotate.AnnotateRuntimeFlagRequiredWhen
|
||||
AnnotateRuntimeFlagFormat = runtimeannotate.AnnotateRuntimeFlagFormat
|
||||
AnnotateRuntimeFlagInterfaceType = runtimeannotate.AnnotateRuntimeFlagInterfaceType
|
||||
AnnotateRuntimeFlagEnum = runtimeannotate.AnnotateRuntimeFlagEnum
|
||||
AnnotateRuntimeFlagExample = runtimeannotate.AnnotateRuntimeFlagExample
|
||||
RuntimeContractRisk = runtimeannotate.RuntimeContractRisk
|
||||
RuntimeContractGate = runtimeannotate.RuntimeContractGate
|
||||
AnnotateRuntimeConstraints = runtimeannotate.AnnotateRuntimeConstraints
|
||||
AnnotateRuntimePositionals = runtimeannotate.AnnotateRuntimePositionals
|
||||
HasDeclaredOrAnnotatedConfirmation = contractfinal.HasDeclaredOrAnnotatedConfirmation
|
||||
RuntimeContractFinal = contractfinal.RuntimeContractFinal
|
||||
HasRuntimeContractFinal = contractfinal.HasRuntimeContractFinal
|
||||
ClearRuntimeContractFinalForTest = contractfinal.ClearRuntimeContractFinalForTest
|
||||
ApplyParamDecls = contractfinal.ApplyParamDecls
|
||||
)
|
||||
|
||||
func resolvedFieldProvenance(value any, source, sourceRef, precedence, resolution, reviewReason string) contract.FieldProvenance {
|
||||
return contract.ResolvedFieldProvenance(value, source, sourceRef, precedence, resolution, reviewReason)
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
// 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/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// AgentExampleMode controls only how an already contract-validated example is
|
||||
// exercised. Contract validation is the default. dry_run is used only when the
|
||||
// final ToolSpec publishes an explicit reviewed capability; contract_only
|
||||
// remains a precise reviewed exception for such a capability whose runtime
|
||||
// preconditions cannot be exercised safely and deterministically in the
|
||||
// isolated test process.
|
||||
type AgentExampleMode string
|
||||
|
||||
const (
|
||||
AgentExampleModeContract AgentExampleMode = "contract"
|
||||
AgentExampleModeDryRun AgentExampleMode = "dry_run"
|
||||
AgentExampleModeContractOnly AgentExampleMode = "contract_only"
|
||||
)
|
||||
|
||||
// AgentExampleReasonCode is a closed taxonomy for reviewed contract-only
|
||||
// exceptions to an explicit dry-run capability.
|
||||
type AgentExampleReasonCode string
|
||||
|
||||
const (
|
||||
AgentExampleReasonLocalState AgentExampleReasonCode = "local_state"
|
||||
AgentExampleReasonStatefulPreflight AgentExampleReasonCode = "stateful_preflight"
|
||||
)
|
||||
|
||||
// AgentExampleDisposition narrows one exact example with an explicit
|
||||
// typed dry-run capability to contract-only. Index is a pointer so a missing
|
||||
// field cannot silently select example zero.
|
||||
//
|
||||
// Dispositions are authored as an in-test / future ContractFinal extension
|
||||
// surface; production ContractFinal Selection currently does not declare them,
|
||||
// so the delivery plan treats every example as default-typed (contract or
|
||||
// dry_run from ToolSpec.DryRun).
|
||||
type AgentExampleDisposition struct {
|
||||
Index *int `json:"index"`
|
||||
Mode AgentExampleMode `json:"mode"`
|
||||
ReasonCode AgentExampleReasonCode `json:"reason_code"`
|
||||
Reason string `json:"reason"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
}
|
||||
|
||||
// AgentExampleExecution is one resolved example and its effective test mode.
|
||||
type AgentExampleExecution struct {
|
||||
CanonicalPath string
|
||||
Index int
|
||||
Example string
|
||||
Mode AgentExampleMode
|
||||
DryRun *contract.DryRunSpec
|
||||
ReasonCode AgentExampleReasonCode
|
||||
Reason string
|
||||
Source AgentExampleDispositionSource
|
||||
}
|
||||
|
||||
// AgentExampleDispositionSource distinguishes the normal typed-contract
|
||||
// classification from narrow reviewed exceptions.
|
||||
type AgentExampleDispositionSource string
|
||||
|
||||
const (
|
||||
AgentExampleDispositionDefault AgentExampleDispositionSource = "default"
|
||||
AgentExampleDispositionReviewed AgentExampleDispositionSource = ProvenanceReviewedManual
|
||||
)
|
||||
|
||||
// AgentExampleExecutionPlan is a stable, typed report used by the
|
||||
// exhaustive real-Cobra dry-run test.
|
||||
type AgentExampleExecutionPlan struct {
|
||||
Examples []AgentExampleExecution
|
||||
Total int
|
||||
Contract int
|
||||
DryRun int
|
||||
ContractOnly int
|
||||
ReviewedContractOnly int
|
||||
ContractOnlyByReason map[AgentExampleReasonCode]int
|
||||
}
|
||||
|
||||
// ValidateAgentExampleDelivery is the final generation gate. It validates every
|
||||
// bound tool's ContractFinal Selection.Examples against the assembled typed
|
||||
// SchemaRegistry.
|
||||
func ValidateAgentExampleDelivery(bound BoundCommandRegistry, registry SchemaRegistry) (AgentExampleExecutionPlan, error) {
|
||||
return BuildAgentExampleExecutionPlan(bound, registry)
|
||||
}
|
||||
|
||||
// agentExampleSelectionFn is the selection source for example planning.
|
||||
// Tests may override it to inject ExampleDispositions without changing
|
||||
// ContractFinal production wiring.
|
||||
var agentExampleSelectionFn = contractFinalToolSelection
|
||||
|
||||
// BuildAgentExampleExecutionPlan validates every ContractFinal example against
|
||||
// its real BoundCommand/Cobra contract. Runtime dry-run execution is opt-in and
|
||||
// comes only from the final typed ToolSpec.
|
||||
func BuildAgentExampleExecutionPlan(bound BoundCommandRegistry, registry SchemaRegistry) (AgentExampleExecutionPlan, error) {
|
||||
tools := make(map[string]ToolSpec, len(bound.Commands))
|
||||
for _, product := range registry.Products {
|
||||
for _, tool := range product.Tools {
|
||||
canonical := strings.TrimSpace(tool.Identity.CanonicalPath)
|
||||
if canonical == "" {
|
||||
return AgentExampleExecutionPlan{}, fmt.Errorf("typed SchemaRegistry contains a tool with empty canonical path")
|
||||
}
|
||||
if _, duplicate := tools[canonical]; duplicate {
|
||||
return AgentExampleExecutionPlan{}, fmt.Errorf("typed SchemaRegistry contains duplicate tool %q", canonical)
|
||||
}
|
||||
tools[canonical] = tool
|
||||
}
|
||||
}
|
||||
return buildAgentExampleExecutionPlan(bound, tools)
|
||||
}
|
||||
|
||||
// FixtureAgentSelectionSet is a transitional Agent selection fixture type
|
||||
// retained only so live-selection call sites can project candidate tables.
|
||||
// Production paths must keep it empty; example planning reads ContractFinal.
|
||||
type FixtureAgentSelectionSet struct {
|
||||
Revisions map[string]FixtureAgentSelectionRevision `json:"revisions,omitempty"`
|
||||
Products map[string]FixtureAgentProductSelection `json:"products,omitempty"`
|
||||
Tools map[string]AgentToolSelection `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
// FixtureAgentSelectionRevision is retained for transitional fixtures only.
|
||||
type FixtureAgentSelectionRevision struct {
|
||||
GeneratedBy string `json:"generated_by"`
|
||||
Model string `json:"model,omitempty"`
|
||||
PromptVersion string `json:"prompt_version,omitempty"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// FixtureAgentProductSelection is retained for transitional fixtures only.
|
||||
type FixtureAgentProductSelection struct {
|
||||
AgentSummary string `json:"agent_summary"`
|
||||
UseWhen []string `json:"use_when"`
|
||||
AvoidWhen []string `json:"avoid_when"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
Revision string `json:"revision"`
|
||||
Reason string `json:"reason"`
|
||||
Evidence []string `json:"evidence"`
|
||||
}
|
||||
|
||||
// AgentToolSelection is the tool-level selection projection used by example
|
||||
// planning and live-selection fixtures. Production authority remains
|
||||
// ContractFinal Selection on the owning leaf.
|
||||
type AgentToolSelection struct {
|
||||
AgentSummary string `json:"agent_summary"`
|
||||
UseWhen []string `json:"use_when"`
|
||||
AvoidWhen []string `json:"avoid_when"`
|
||||
Examples []string `json:"examples"`
|
||||
ExampleDispositions []AgentExampleDisposition `json:"example_dispositions,omitempty"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
Revision string `json:"revision"`
|
||||
Reason string `json:"reason"`
|
||||
Evidence []string `json:"evidence"`
|
||||
}
|
||||
|
||||
func buildAgentExampleExecutionPlan(bound BoundCommandRegistry, typedTools map[string]ToolSpec) (AgentExampleExecutionPlan, error) {
|
||||
plan := AgentExampleExecutionPlan{
|
||||
ContractOnlyByReason: map[AgentExampleReasonCode]int{},
|
||||
}
|
||||
canonicalPaths := make([]string, 0, len(bound.Commands))
|
||||
for _, command := range bound.Commands {
|
||||
canonical := strings.TrimSpace(command.CanonicalPath)
|
||||
if !contractfinal.HasRuntimeContractFinal(command.PrimaryCommand) {
|
||||
return AgentExampleExecutionPlan{}, fmt.Errorf("bound tool %q has no ContractFinal declaration; Schema examples require leaf Schema.Selection", canonical)
|
||||
}
|
||||
canonicalPaths = append(canonicalPaths, canonical)
|
||||
}
|
||||
sort.Strings(canonicalPaths)
|
||||
for _, canonical := range canonicalPaths {
|
||||
spec, ok := bound.ByCanonical[canonical]
|
||||
if !ok {
|
||||
return AgentExampleExecutionPlan{}, fmt.Errorf("example plan references unknown canonical tool %q", canonical)
|
||||
}
|
||||
selection := agentExampleSelectionFn(spec.PrimaryCommand)
|
||||
var typedTool ToolSpec
|
||||
if typedTools != nil {
|
||||
var found bool
|
||||
typedTool, found = typedTools[canonical]
|
||||
if !found {
|
||||
return AgentExampleExecutionPlan{}, fmt.Errorf("example tool %q is missing from final typed SchemaRegistry", canonical)
|
||||
}
|
||||
}
|
||||
if len(selection.Examples) == 0 {
|
||||
return AgentExampleExecutionPlan{}, fmt.Errorf("ContractFinal tool %s requires non-empty Selection.Examples", canonical)
|
||||
}
|
||||
if len(selection.Examples) > 2 {
|
||||
return AgentExampleExecutionPlan{}, fmt.Errorf("ContractFinal tool %s has %d examples; maximum is 2", canonical, len(selection.Examples))
|
||||
}
|
||||
if err := validateAgentExampleDispositions(canonical, selection.Examples, selection.ExampleDispositions); err != nil {
|
||||
return AgentExampleExecutionPlan{}, err
|
||||
}
|
||||
dispositions := make(map[int]AgentExampleDisposition, len(selection.ExampleDispositions))
|
||||
for _, disposition := range selection.ExampleDispositions {
|
||||
dispositions[*disposition.Index] = disposition
|
||||
}
|
||||
paths := []agentExamplePath{{Path: spec.PrimaryCLIPath, Argv: strings.Fields(spec.PrimaryCLIPath), Command: spec.PrimaryCommand}}
|
||||
for _, alias := range spec.AliasCommands {
|
||||
paths = append(paths, agentExamplePath{Path: alias.Path, Argv: strings.Fields(alias.Path), Command: alias.Command})
|
||||
}
|
||||
sort.Slice(paths, func(i, j int) bool {
|
||||
if len(paths[i].Argv) != len(paths[j].Argv) {
|
||||
return len(paths[i].Argv) > len(paths[j].Argv)
|
||||
}
|
||||
return paths[i].Path < paths[j].Path
|
||||
})
|
||||
for index, example := range selection.Examples {
|
||||
argv, err := tokenizeAgentExample(example)
|
||||
if err != nil {
|
||||
return AgentExampleExecutionPlan{}, fmt.Errorf("tool %s example has invalid argv syntax: %w", canonical, err)
|
||||
}
|
||||
if len(argv) < 2 || argv[0] != "dws" {
|
||||
return AgentExampleExecutionPlan{}, fmt.Errorf("tool %s example must start with dws: %q", canonical, example)
|
||||
}
|
||||
for _, argument := range argv[1:] {
|
||||
if argument == "--yes" || strings.HasPrefix(argument, "--yes=") {
|
||||
return AgentExampleExecutionPlan{}, fmt.Errorf("tool %s example must not bypass confirmation with --yes", canonical)
|
||||
}
|
||||
if argument == "--help" || strings.HasPrefix(argument, "--help=") || argument == "-h" || strings.HasPrefix(argument, "-h=") {
|
||||
return AgentExampleExecutionPlan{}, fmt.Errorf("tool %s example must demonstrate execution, not only --help", canonical)
|
||||
}
|
||||
}
|
||||
remainder, matched, ok := matchAgentExamplePath(argv, paths)
|
||||
if !ok {
|
||||
return AgentExampleExecutionPlan{}, fmt.Errorf("tool %s example does not use its reviewed primary/alias path: %q", canonical, example)
|
||||
}
|
||||
if matched.Command == nil {
|
||||
return AgentExampleExecutionPlan{}, fmt.Errorf("tool %s reviewed path %q has no bound Cobra command", canonical, matched.Path)
|
||||
}
|
||||
constraints, err := strictCompatibilityConstraints(matched.Command, canonical)
|
||||
if err != nil {
|
||||
return AgentExampleExecutionPlan{}, fmt.Errorf("tool %s example for %q has invalid executable constraints: %w", canonical, matched.Path, err)
|
||||
}
|
||||
if typedTools != nil {
|
||||
constraints.MutuallyExclusive = append(constraints.MutuallyExclusive, typedTool.Constraints.MutuallyExclusive...)
|
||||
constraints.RequireOneOf = append(constraints.RequireOneOf, typedTool.Constraints.RequireOneOf...)
|
||||
constraints.RequireTogether = append(constraints.RequireTogether, typedTool.Constraints.RequireTogether...)
|
||||
constraints = normalizeRuntimeSchemaConstraints(constraints)
|
||||
}
|
||||
positionals, err := strictCompatibilityPositionals(matched.Command)
|
||||
if err != nil {
|
||||
return AgentExampleExecutionPlan{}, fmt.Errorf("tool %s example for %q has invalid executable positionals: %w", canonical, matched.Path, err)
|
||||
}
|
||||
if typedTools != nil {
|
||||
positionals = mergeAgentExamplePositionals(positionals, typedTool.Positionals)
|
||||
}
|
||||
if err := validateAgentExampleCobraContract(matched.Command, remainder, constraints, positionals); err != nil {
|
||||
return AgentExampleExecutionPlan{}, fmt.Errorf("tool %s example for %q: %w", canonical, matched.Path, err)
|
||||
}
|
||||
|
||||
execution := AgentExampleExecution{
|
||||
CanonicalPath: canonical,
|
||||
Index: index,
|
||||
Example: example,
|
||||
Mode: AgentExampleModeContract,
|
||||
Source: AgentExampleDispositionDefault,
|
||||
}
|
||||
if typedTool.DryRun != nil {
|
||||
dryRun := *typedTool.DryRun
|
||||
execution.DryRun = &dryRun
|
||||
execution.Mode = AgentExampleModeDryRun
|
||||
}
|
||||
disposition, hasDisposition := dispositions[index]
|
||||
if hasDisposition {
|
||||
if typedTools != nil && execution.DryRun == nil {
|
||||
return AgentExampleExecutionPlan{}, fmt.Errorf("tool %s example disposition index %d narrows no explicit dry_run capability", canonical, index)
|
||||
}
|
||||
execution.Mode = disposition.Mode
|
||||
execution.ReasonCode = disposition.ReasonCode
|
||||
execution.Reason = strings.TrimSpace(disposition.Reason)
|
||||
execution.Source = AgentExampleDispositionReviewed
|
||||
plan.ContractOnly++
|
||||
plan.ReviewedContractOnly++
|
||||
plan.ContractOnlyByReason[disposition.ReasonCode]++
|
||||
} else if execution.DryRun != nil {
|
||||
plan.DryRun++
|
||||
} else {
|
||||
plan.Contract++
|
||||
}
|
||||
plan.Examples = append(plan.Examples, execution)
|
||||
plan.Total++
|
||||
}
|
||||
}
|
||||
for canonical := range typedTools {
|
||||
found := false
|
||||
for _, command := range bound.Commands {
|
||||
if strings.TrimSpace(command.CanonicalPath) == canonical {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return AgentExampleExecutionPlan{}, fmt.Errorf("final typed SchemaRegistry tool %q is missing from BoundCommandRegistry", canonical)
|
||||
}
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
type agentExamplePath struct {
|
||||
Path string
|
||||
Argv []string
|
||||
Command *cobra.Command
|
||||
}
|
||||
|
||||
func matchAgentExamplePath(argv []string, paths []agentExamplePath) ([]string, agentExamplePath, bool) {
|
||||
if len(argv) == 0 || argv[0] != "dws" {
|
||||
return nil, agentExamplePath{}, false
|
||||
}
|
||||
for index := range paths {
|
||||
if paths[index].Argv == nil {
|
||||
paths[index].Argv = strings.Fields(strings.TrimSpace(paths[index].Path))
|
||||
}
|
||||
pathArgv := paths[index].Argv
|
||||
if len(pathArgv) == 0 || len(argv) < len(pathArgv)+1 {
|
||||
continue
|
||||
}
|
||||
matches := true
|
||||
for offset := range pathArgv {
|
||||
if argv[offset+1] != pathArgv[offset] {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matches {
|
||||
return argv[len(pathArgv)+1:], paths[index], true
|
||||
}
|
||||
}
|
||||
return nil, agentExamplePath{}, false
|
||||
}
|
||||
|
||||
func validateAgentExampleDispositions(canonical string, examples []string, dispositions []AgentExampleDisposition) error {
|
||||
seen := make(map[int]bool, len(dispositions))
|
||||
for _, disposition := range dispositions {
|
||||
if disposition.Index == nil {
|
||||
return fmt.Errorf("tool %s example disposition requires index", canonical)
|
||||
}
|
||||
index := *disposition.Index
|
||||
if index < 0 || index >= len(examples) {
|
||||
return fmt.Errorf("tool %s example disposition index %d is out of range for %d examples", canonical, index, len(examples))
|
||||
}
|
||||
if seen[index] {
|
||||
return fmt.Errorf("tool %s has duplicate example disposition index %d", canonical, index)
|
||||
}
|
||||
seen[index] = true
|
||||
if !disposition.Reviewed {
|
||||
return fmt.Errorf("tool %s example disposition index %d must be reviewed", canonical, index)
|
||||
}
|
||||
if disposition.Mode != AgentExampleModeContractOnly {
|
||||
return fmt.Errorf("tool %s example disposition index %d has invalid mode %q; only %q may narrow an explicit dry_run capability", canonical, index, disposition.Mode, AgentExampleModeContractOnly)
|
||||
}
|
||||
if !validAgentExampleReasonCode(disposition.ReasonCode) {
|
||||
return fmt.Errorf("tool %s example disposition index %d has invalid reason_code %q", canonical, index, disposition.ReasonCode)
|
||||
}
|
||||
if strings.TrimSpace(disposition.Reason) == "" {
|
||||
return fmt.Errorf("tool %s example disposition index %d requires a non-empty reason", canonical, index)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validAgentExampleReasonCode(code AgentExampleReasonCode) bool {
|
||||
switch code {
|
||||
case AgentExampleReasonLocalState, AgentExampleReasonStatefulPreflight:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func tokenizeAgentExample(input string) ([]string, error) {
|
||||
var (
|
||||
argv []string
|
||||
current strings.Builder
|
||||
quote byte
|
||||
tokenStarted bool
|
||||
)
|
||||
flush := func() {
|
||||
if tokenStarted {
|
||||
argv = append(argv, current.String())
|
||||
current.Reset()
|
||||
tokenStarted = false
|
||||
}
|
||||
}
|
||||
for index := 0; index < len(input); index++ {
|
||||
character := input[index]
|
||||
if quote != 0 {
|
||||
if character == quote {
|
||||
quote = 0
|
||||
continue
|
||||
}
|
||||
if quote == '"' && character == '\\' {
|
||||
if index+1 >= len(input) {
|
||||
return nil, fmt.Errorf("trailing escape in double-quoted value")
|
||||
}
|
||||
index++
|
||||
current.WriteByte(input[index])
|
||||
continue
|
||||
}
|
||||
if quote == '"' && (character == '`' || character == '$') {
|
||||
return nil, fmt.Errorf("shell expansion is not allowed")
|
||||
}
|
||||
current.WriteByte(character)
|
||||
continue
|
||||
}
|
||||
|
||||
switch character {
|
||||
case ' ', '\t':
|
||||
flush()
|
||||
case '\r', '\n':
|
||||
return nil, fmt.Errorf("unquoted newline shell operator is not allowed")
|
||||
case '\'', '"':
|
||||
quote = character
|
||||
tokenStarted = true
|
||||
case '\\':
|
||||
if index+1 >= len(input) {
|
||||
return nil, fmt.Errorf("trailing escape")
|
||||
}
|
||||
index++
|
||||
current.WriteByte(input[index])
|
||||
tokenStarted = true
|
||||
case '<':
|
||||
placeholder, next, ok := agentExamplePlaceholderAt(input, index)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("shell redirection operator %q is not allowed", string(character))
|
||||
}
|
||||
current.WriteString(placeholder)
|
||||
tokenStarted = true
|
||||
index = next
|
||||
case '>', ';', '|', '&', '(', ')':
|
||||
return nil, fmt.Errorf("shell operator %q is not allowed", string(character))
|
||||
case '`', '$':
|
||||
return nil, fmt.Errorf("shell expansion is not allowed")
|
||||
case '#':
|
||||
return nil, fmt.Errorf("shell comments are not allowed")
|
||||
default:
|
||||
current.WriteByte(character)
|
||||
tokenStarted = true
|
||||
}
|
||||
}
|
||||
if quote != 0 {
|
||||
return nil, fmt.Errorf("unterminated quoted value")
|
||||
}
|
||||
flush()
|
||||
for _, argument := range argv {
|
||||
if argument == "--" {
|
||||
return nil, fmt.Errorf("the -- argument terminator is not allowed")
|
||||
}
|
||||
}
|
||||
return argv, nil
|
||||
}
|
||||
|
||||
// ParseAgentExampleArgv exposes the shell-free argv parser used by
|
||||
// example validation and dry-run tests.
|
||||
func ParseAgentExampleArgv(input string) ([]string, error) {
|
||||
return tokenizeAgentExample(input)
|
||||
}
|
||||
|
||||
func agentExamplePlaceholderAt(input string, start int) (string, int, bool) {
|
||||
endOffset := strings.IndexByte(input[start+1:], '>')
|
||||
if endOffset < 1 {
|
||||
return "", start, false
|
||||
}
|
||||
end := start + 1 + endOffset
|
||||
body := input[start+1 : end]
|
||||
for index := 0; index < len(body); index++ {
|
||||
character := body[index]
|
||||
if strings.ContainsRune(" \t\r\n<>;&|`$()#'\"\\", rune(character)) {
|
||||
return "", start, false
|
||||
}
|
||||
}
|
||||
return input[start : end+1], end, true
|
||||
}
|
||||
|
||||
func validateAgentExampleCobraContract(command *cobra.Command, arguments []string, constraints RuntimeSchemaConstraints, positionalSpecs []contract.RuntimeSchemaPositional) error {
|
||||
if command == nil {
|
||||
return fmt.Errorf("bound Cobra command is nil")
|
||||
}
|
||||
providedFacts := map[string]bool{}
|
||||
positionals := make([]string, 0)
|
||||
for index := 0; index < len(arguments); index++ {
|
||||
argument := arguments[index]
|
||||
if argument == "--" {
|
||||
return fmt.Errorf("the -- argument terminator is not allowed")
|
||||
}
|
||||
if strings.HasPrefix(argument, "--") {
|
||||
nameAndValue := strings.TrimPrefix(argument, "--")
|
||||
name, _, hasValue := strings.Cut(nameAndValue, "=")
|
||||
if name == "" {
|
||||
return fmt.Errorf("invalid empty long flag")
|
||||
}
|
||||
if name == "help" {
|
||||
return fmt.Errorf("must demonstrate execution, not only --help")
|
||||
}
|
||||
flag := runtimeCommandFlag(command, name)
|
||||
if flag == nil {
|
||||
return fmt.Errorf("uses unknown flag --%s", name)
|
||||
}
|
||||
providedFacts[flag.Name] = true
|
||||
if !hasValue && flag.NoOptDefVal == "" {
|
||||
if index+1 >= len(arguments) {
|
||||
return fmt.Errorf("flag --%s requires a value", name)
|
||||
}
|
||||
index++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(argument, "-") || argument == "-" {
|
||||
positionals = append(positionals, argument)
|
||||
continue
|
||||
}
|
||||
|
||||
shorthandsAndValue := strings.TrimPrefix(argument, "-")
|
||||
shorthands, _, hasExplicitValue := strings.Cut(shorthandsAndValue, "=")
|
||||
for offset := 0; offset < len(shorthands); offset++ {
|
||||
shorthand := shorthands[offset : offset+1]
|
||||
if shorthand[0] >= 0x80 {
|
||||
return fmt.Errorf("uses invalid non-ASCII shorthand flag")
|
||||
}
|
||||
if shorthand == "h" {
|
||||
return fmt.Errorf("must demonstrate execution, not only -h")
|
||||
}
|
||||
flag := runtimeCommandFlagByShorthand(command, shorthand)
|
||||
if flag == nil {
|
||||
return fmt.Errorf("uses unknown shorthand flag -%s", shorthand)
|
||||
}
|
||||
providedFacts[flag.Name] = true
|
||||
if flag.NoOptDefVal == "" {
|
||||
if offset+1 < len(shorthands) || hasExplicitValue {
|
||||
break
|
||||
}
|
||||
if index+1 >= len(arguments) {
|
||||
return fmt.Errorf("shorthand flag -%s requires a value", shorthand)
|
||||
}
|
||||
index++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
missingRequired := make([]string, 0)
|
||||
visitAgentExampleCommandFlags(command, func(flag *pflag.Flag) {
|
||||
if flag != nil && len(flag.Annotations[cobra.BashCompOneRequiredFlag]) > 0 && !providedFacts[flag.Name] {
|
||||
missingRequired = append(missingRequired, "--"+flag.Name)
|
||||
}
|
||||
})
|
||||
if len(missingRequired) != 0 {
|
||||
sort.Strings(missingRequired)
|
||||
return fmt.Errorf("missing required flag(s): %s", strings.Join(missingRequired, ", "))
|
||||
}
|
||||
missingPositionals := make([]string, 0)
|
||||
for _, positional := range positionalSpecs {
|
||||
provided := positional.Index >= 0 && positional.Index < len(positionals)
|
||||
if positional.Variadic {
|
||||
provided = positional.Index >= 0 && len(positionals) > positional.Index
|
||||
}
|
||||
if provided && strings.TrimSpace(positional.Name) != "" {
|
||||
providedFacts[strings.TrimLeft(strings.TrimSpace(positional.Name), "-")] = true
|
||||
}
|
||||
if positional.Required && !provided {
|
||||
missingPositionals = append(missingPositionals, strings.TrimSpace(positional.Name))
|
||||
}
|
||||
}
|
||||
if len(missingPositionals) != 0 {
|
||||
sort.Strings(missingPositionals)
|
||||
return fmt.Errorf("missing required positional argument(s): %s", strings.Join(missingPositionals, ", "))
|
||||
}
|
||||
if err := validateAgentExampleConstraints(providedFacts, constraints); err != nil {
|
||||
return err
|
||||
}
|
||||
if command.Args != nil && len(positionalSpecs) == 0 {
|
||||
if err := command.Args(command, positionals); err != nil {
|
||||
return fmt.Errorf("invalid positional arguments: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeAgentExamplePositionals(groups ...[]contract.RuntimeSchemaPositional) []contract.RuntimeSchemaPositional {
|
||||
byIdentity := map[string]contract.RuntimeSchemaPositional{}
|
||||
for _, group := range groups {
|
||||
for _, positional := range group {
|
||||
key := fmt.Sprintf("%d\x00%s", positional.Index, strings.TrimSpace(positional.Name))
|
||||
if _, exists := byIdentity[key]; !exists {
|
||||
byIdentity[key] = positional
|
||||
}
|
||||
}
|
||||
}
|
||||
result := make([]contract.RuntimeSchemaPositional, 0, len(byIdentity))
|
||||
for _, positional := range byIdentity {
|
||||
result = append(result, positional)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].Index != result[j].Index {
|
||||
return result[i].Index < result[j].Index
|
||||
}
|
||||
return result[i].Name < result[j].Name
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func validateAgentExampleConstraints(provided map[string]bool, constraints RuntimeSchemaConstraints) error {
|
||||
for _, group := range constraints.RequireOneOf {
|
||||
if agentExampleProvidedFlagCount(provided, group) == 0 {
|
||||
return fmt.Errorf("missing require_one_of flags: %s", agentExampleFlagGroup(group))
|
||||
}
|
||||
}
|
||||
for _, group := range constraints.RequireTogether {
|
||||
providedCount := agentExampleProvidedFlagCount(provided, group)
|
||||
if providedCount != 0 && providedCount != len(group) {
|
||||
return fmt.Errorf("incomplete require_together flags: %s", agentExampleFlagGroup(group))
|
||||
}
|
||||
}
|
||||
for _, group := range constraints.MutuallyExclusive {
|
||||
if agentExampleProvidedFlagCount(provided, group) > 1 {
|
||||
return fmt.Errorf("mutually_exclusive flags used together: %s", agentExampleFlagGroup(group))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func agentExampleProvidedFlagCount(provided map[string]bool, group []string) int {
|
||||
count := 0
|
||||
seen := map[string]bool{}
|
||||
for _, raw := range group {
|
||||
name := strings.TrimLeft(strings.TrimSpace(raw), "-")
|
||||
if name != "" && !seen[name] && provided[name] {
|
||||
seen[name] = true
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func agentExampleFlagGroup(group []string) string {
|
||||
flags := make([]string, 0, len(group))
|
||||
for _, raw := range group {
|
||||
if name := strings.TrimLeft(strings.TrimSpace(raw), "-"); name != "" {
|
||||
flags = append(flags, "--"+name)
|
||||
}
|
||||
}
|
||||
sort.Strings(flags)
|
||||
return strings.Join(flags, ", ")
|
||||
}
|
||||
|
||||
func visitAgentExampleCommandFlags(command *cobra.Command, visit func(*pflag.Flag)) {
|
||||
seen := map[string]bool{}
|
||||
visitSet := func(flags *pflag.FlagSet) {
|
||||
flags.VisitAll(func(flag *pflag.Flag) {
|
||||
if seen[flag.Name] {
|
||||
return
|
||||
}
|
||||
seen[flag.Name] = true
|
||||
visit(flag)
|
||||
})
|
||||
}
|
||||
visitSet(command.Flags())
|
||||
for current := command; current != nil; current = current.Parent() {
|
||||
visitSet(current.PersistentFlags())
|
||||
}
|
||||
}
|
||||
|
||||
func runtimeCommandFlagByShorthand(command *cobra.Command, shorthand string) *pflag.Flag {
|
||||
if command == nil || len(shorthand) != 1 {
|
||||
return nil
|
||||
}
|
||||
if flag := command.Flags().ShorthandLookup(shorthand); flag != nil {
|
||||
return flag
|
||||
}
|
||||
for current := command; current != nil; current = current.Parent() {
|
||||
if flag := current.PersistentFlags().ShorthandLookup(shorthand); flag != nil {
|
||||
return flag
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -14,30 +14,39 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
//go:embed schema_agent_metadata/*.json
|
||||
var embeddedAgentMetadataFS embed.FS
|
||||
// schema_agent_metadata/*.json is retired as a shipped artifact. Production
|
||||
// Agent selection / safety / interface authority is leaf ContractFinal and
|
||||
// ProductDecl via RegisterSchemaSourceRoot → ResolveSchemaBuild.
|
||||
// InstallBuildTimeAgentMetadataJSON is a CI/local dump helper for
|
||||
// cmd_schema_catalog only; it is not a production source.
|
||||
|
||||
const embeddedAgentMetadataSource = "embedded-skill-metadata"
|
||||
// Wire provenance / metadata_source labels (#602 Catalog contract).
|
||||
// Const identifiers may be renamed; the string values must not change.
|
||||
const (
|
||||
ProvenanceEmbeddedSkillMetadata = "embedded-skill-metadata"
|
||||
ProvenanceReviewedManual = "reviewed_manual"
|
||||
)
|
||||
|
||||
type embeddedAgentMetadata struct {
|
||||
type agentMetadata struct {
|
||||
Version int `json:"version"`
|
||||
SourceHash string `json:"source_hash"`
|
||||
SurfaceHash string `json:"surface_hash,omitempty"`
|
||||
Coverage embeddedAgentMetadataCoverage `json:"coverage"`
|
||||
Coverage agentMetadataCoverage `json:"coverage"`
|
||||
Products map[string]agentProductMetadata `json:"products"`
|
||||
Domains []string `json:"domains"`
|
||||
Tools map[string]agentToolMetadata `json:"tools"`
|
||||
}
|
||||
|
||||
type embeddedAgentMetadataCoverage struct {
|
||||
type agentMetadataCoverage struct {
|
||||
SurfaceProducts int `json:"surface_products,omitempty"`
|
||||
ProductsWithMetadata int `json:"products_with_metadata"`
|
||||
SurfaceTools int `json:"surface_tools,omitempty"`
|
||||
@@ -52,249 +61,109 @@ type embeddedAgentMetadataCoverage struct {
|
||||
}
|
||||
|
||||
type agentProductMetadata struct {
|
||||
AgentSummary string `json:"agent_summary,omitempty"`
|
||||
AgentSummarySource string `json:"agent_summary_source,omitempty"`
|
||||
UseWhen []string `json:"use_when,omitempty"`
|
||||
AvoidWhen []string `json:"avoid_when,omitempty"`
|
||||
SourceRefs []string `json:"source_refs,omitempty"`
|
||||
FieldProvenance map[string]FieldProvenance `json:"field_provenance,omitempty"`
|
||||
AgentSummary string `json:"agent_summary,omitempty"`
|
||||
AgentSummarySource string `json:"agent_summary_source,omitempty"`
|
||||
UseWhen []string `json:"use_when,omitempty"`
|
||||
AvoidWhen []string `json:"avoid_when,omitempty"`
|
||||
SourceRefs []string `json:"source_refs,omitempty"`
|
||||
FieldProvenance map[string]contract.FieldProvenance `json:"field_provenance,omitempty"`
|
||||
}
|
||||
|
||||
type agentToolMetadata struct {
|
||||
AgentSummary string `json:"agent_summary,omitempty"`
|
||||
AgentSummarySource string `json:"agent_summary_source,omitempty"`
|
||||
UseWhen []string `json:"use_when,omitempty"`
|
||||
AvoidWhen []string `json:"avoid_when,omitempty"`
|
||||
Prerequisites []string `json:"prerequisites,omitempty"`
|
||||
Tips []string `json:"tips,omitempty"`
|
||||
Effect string `json:"effect,omitempty"`
|
||||
EffectSource string `json:"effect_source,omitempty"`
|
||||
Risk string `json:"risk,omitempty"`
|
||||
Confirmation string `json:"confirmation,omitempty"`
|
||||
Idempotency string `json:"idempotency,omitempty"`
|
||||
WorkflowRefs []string `json:"workflow_refs,omitempty"`
|
||||
Examples []string `json:"examples,omitempty"`
|
||||
Reviewed *bool `json:"reviewed,omitempty"`
|
||||
SourceRefs []string `json:"source_refs,omitempty"`
|
||||
InterfaceRef *embeddedMCPInterfaceRef `json:"interface_ref,omitempty"`
|
||||
InterfaceMode string `json:"interface_mode,omitempty"`
|
||||
Availability string `json:"availability,omitempty"`
|
||||
InterfaceReason string `json:"interface_reason,omitempty"`
|
||||
FieldProvenance map[string]FieldProvenance `json:"field_provenance,omitempty"`
|
||||
AgentSummary string `json:"agent_summary,omitempty"`
|
||||
AgentSummarySource string `json:"agent_summary_source,omitempty"`
|
||||
UseWhen []string `json:"use_when,omitempty"`
|
||||
AvoidWhen []string `json:"avoid_when,omitempty"`
|
||||
Prerequisites []string `json:"prerequisites,omitempty"`
|
||||
Tips []string `json:"tips,omitempty"`
|
||||
Effect string `json:"effect,omitempty"`
|
||||
EffectSource string `json:"effect_source,omitempty"`
|
||||
Risk string `json:"risk,omitempty"`
|
||||
Confirmation string `json:"confirmation,omitempty"`
|
||||
Idempotency string `json:"idempotency,omitempty"`
|
||||
WorkflowRefs []string `json:"workflow_refs,omitempty"`
|
||||
Examples []string `json:"examples,omitempty"`
|
||||
Reviewed *bool `json:"reviewed,omitempty"`
|
||||
SourceRefs []string `json:"source_refs,omitempty"`
|
||||
InterfaceRef *embeddedMCPInterfaceRef `json:"interface_ref,omitempty"`
|
||||
InterfaceMode string `json:"interface_mode,omitempty"`
|
||||
Availability string `json:"availability,omitempty"`
|
||||
InterfaceReason string `json:"interface_reason,omitempty"`
|
||||
FieldProvenance map[string]contract.FieldProvenance `json:"field_provenance,omitempty"`
|
||||
}
|
||||
|
||||
type embeddedAgentMetadataDomain struct {
|
||||
ProductID string `json:"product_id"`
|
||||
Tools map[string]agentToolMetadata `json:"tools"`
|
||||
}
|
||||
|
||||
var runtimeEmbeddedAgentMetadataLazy struct {
|
||||
var runtimeAgentMetadataLazy struct {
|
||||
once sync.Once
|
||||
metadata embeddedAgentMetadata
|
||||
metadata agentMetadata
|
||||
}
|
||||
|
||||
var runtimeEmbeddedAgentMetadataLazyLoadCount atomic.Uint64
|
||||
var runtimeAgentMetadataLazyLoadCount atomic.Uint64
|
||||
|
||||
// runtimeAgentMetadata parses the generated Agent metadata on first Schema
|
||||
// assembly only. Keeping the sync.Once at the access boundary ensures package
|
||||
// initialization and ordinary CLI commands never deserialize the embedded
|
||||
// fragments.
|
||||
func runtimeAgentMetadata() embeddedAgentMetadata {
|
||||
runtimeEmbeddedAgentMetadataLazy.once.Do(func() {
|
||||
runtimeEmbeddedAgentMetadataLazyLoadCount.Add(1)
|
||||
runtimeEmbeddedAgentMetadataLazy.metadata = loadEmbeddedAgentMetadata()
|
||||
})
|
||||
return runtimeEmbeddedAgentMetadataLazy.metadata
|
||||
}
|
||||
var (
|
||||
buildTimeAgentMetadataMu sync.Mutex
|
||||
buildTimeAgentMetadataOverride *agentMetadata
|
||||
)
|
||||
|
||||
func loadEmbeddedAgentMetadata() embeddedAgentMetadata {
|
||||
return loadEmbeddedAgentMetadataFrom(embeddedAgentMetadataFS)
|
||||
}
|
||||
|
||||
func loadEmbeddedAgentMetadataFrom(source fs.FS) embeddedAgentMetadata {
|
||||
var metadata embeddedAgentMetadata
|
||||
index, err := fs.ReadFile(source, "schema_agent_metadata/index.json")
|
||||
if err != nil || json.Unmarshal(index, &metadata) != nil {
|
||||
return emptyEmbeddedAgentMetadata()
|
||||
}
|
||||
metadata.Tools = map[string]agentToolMetadata{}
|
||||
for _, domain := range metadata.Domains {
|
||||
domain = strings.TrimSpace(domain)
|
||||
if domain == "" || strings.Contains(domain, "/") || strings.Contains(domain, "\\") {
|
||||
return emptyEmbeddedAgentMetadata()
|
||||
}
|
||||
data, err := fs.ReadFile(source, "schema_agent_metadata/"+domain+".json")
|
||||
if err != nil {
|
||||
return emptyEmbeddedAgentMetadata()
|
||||
}
|
||||
var fragment embeddedAgentMetadataDomain
|
||||
if err := json.Unmarshal(data, &fragment); err != nil || strings.TrimSpace(fragment.ProductID) != domain {
|
||||
return emptyEmbeddedAgentMetadata()
|
||||
}
|
||||
for path, tool := range fragment.Tools {
|
||||
metadata.Tools[path] = tool
|
||||
}
|
||||
// InstallBuildTimeAgentMetadataJSON installs generator-produced Agent metadata
|
||||
// for cmd_schema_catalog CI/local dump assembly only. Production binaries never
|
||||
// call this; production authority remains leaf ContractFinal / ProductDecl.
|
||||
// The dump helper injects an in-memory snapshot so schema_agent_metadata/ is
|
||||
// neither committed nor embedded.
|
||||
func InstallBuildTimeAgentMetadataJSON(data []byte) error {
|
||||
var metadata agentMetadata
|
||||
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||
return fmt.Errorf("decode build-time Agent metadata: %w", err)
|
||||
}
|
||||
if metadata.Products == nil {
|
||||
metadata.Products = map[string]agentProductMetadata{}
|
||||
}
|
||||
return metadata
|
||||
if metadata.Tools == nil {
|
||||
metadata.Tools = map[string]agentToolMetadata{}
|
||||
}
|
||||
buildTimeAgentMetadataMu.Lock()
|
||||
defer buildTimeAgentMetadataMu.Unlock()
|
||||
copied := metadata
|
||||
buildTimeAgentMetadataOverride = &copied
|
||||
return nil
|
||||
}
|
||||
|
||||
func emptyEmbeddedAgentMetadata() embeddedAgentMetadata {
|
||||
return embeddedAgentMetadata{
|
||||
// ClearBuildTimeAgentMetadata removes a cmd_schema_catalog dump-helper injection.
|
||||
func ClearBuildTimeAgentMetadata() {
|
||||
buildTimeAgentMetadataMu.Lock()
|
||||
defer buildTimeAgentMetadataMu.Unlock()
|
||||
buildTimeAgentMetadataOverride = nil
|
||||
}
|
||||
|
||||
// runtimeAgentMetadata returns build-time injected Agent metadata when the
|
||||
// CI dump helper installed one; otherwise an empty snapshot. Shipped
|
||||
// binaries no longer embed schema_agent_metadata/*.json; production Agent
|
||||
// facts come from ContractFinal / ProductDecl.
|
||||
func runtimeAgentMetadata() agentMetadata {
|
||||
buildTimeAgentMetadataMu.Lock()
|
||||
override := buildTimeAgentMetadataOverride
|
||||
buildTimeAgentMetadataMu.Unlock()
|
||||
if override != nil {
|
||||
return *override
|
||||
}
|
||||
runtimeAgentMetadataLazy.once.Do(func() {
|
||||
runtimeAgentMetadataLazyLoadCount.Add(1)
|
||||
runtimeAgentMetadataLazy.metadata = emptyAgentMetadata()
|
||||
})
|
||||
return runtimeAgentMetadataLazy.metadata
|
||||
}
|
||||
|
||||
func emptyAgentMetadata() agentMetadata {
|
||||
return agentMetadata{
|
||||
Products: map[string]agentProductMetadata{},
|
||||
Tools: map[string]agentToolMetadata{},
|
||||
}
|
||||
}
|
||||
|
||||
// agentToolContractForPathsFromMetadata is the sole typed adapter from generated Agent
|
||||
// metadata to runtime contract assembly. Path resolution happens once; all
|
||||
// consumers receive the same resolved safety, interface, selection and
|
||||
// provenance values without performing downstream map merges.
|
||||
func agentToolContractForPathsFromMetadata(source embeddedAgentMetadata, paths ...string) (SafetySpec, InterfaceSpec, SelectionSpec, map[string]FieldProvenance, bool) {
|
||||
metadata, ok := lookupAgentToolMetadataFrom(source, paths...)
|
||||
if !ok {
|
||||
return SafetySpec{}, InterfaceSpec{}, SelectionSpec{}, nil, false
|
||||
}
|
||||
safety := SafetySpec{
|
||||
Effect: strings.TrimSpace(metadata.Effect),
|
||||
EffectSource: strings.TrimSpace(metadata.EffectSource),
|
||||
Risk: strings.TrimSpace(metadata.Risk),
|
||||
Confirmation: strings.TrimSpace(metadata.Confirmation),
|
||||
Idempotency: strings.TrimSpace(metadata.Idempotency),
|
||||
}
|
||||
interfaceSpec := InterfaceSpec{
|
||||
Mode: strings.TrimSpace(metadata.InterfaceMode),
|
||||
Availability: strings.TrimSpace(metadata.Availability),
|
||||
Reason: strings.TrimSpace(metadata.InterfaceReason),
|
||||
}
|
||||
if metadata.InterfaceRef != nil {
|
||||
interfaceSpec.Ref = &InterfaceRefSpec{
|
||||
ProductID: strings.TrimSpace(metadata.InterfaceRef.ProductID),
|
||||
RPCName: strings.TrimSpace(metadata.InterfaceRef.RPCName),
|
||||
}
|
||||
}
|
||||
selection := agentToolSelection(metadata)
|
||||
provenance := resolvedAgentToolProvenance(metadata.FieldProvenance, interfaceSpec, selection)
|
||||
return safety, interfaceSpec, selection, provenance, true
|
||||
}
|
||||
|
||||
// resolvedAgentToolProvenance is the typed source adapter for generated Agent
|
||||
// metadata. The generator stores concrete interface refs in its compact
|
||||
// "product.rpc" identity form and stores reviewed no-ref dispositions as JSON
|
||||
// null. The final typed contract stores an object or JSON null, so project the
|
||||
// concrete compact identity here. Missing provenance is never synthesized:
|
||||
// constructors and snapshot loaders remain validate-only and fail closed.
|
||||
func resolvedAgentToolProvenance(source map[string]FieldProvenance, interfaceSpec InterfaceSpec, selection SelectionSpec) map[string]FieldProvenance {
|
||||
out := cloneFieldProvenance(source)
|
||||
if provenance, ok := out["interface_ref"]; ok {
|
||||
out["interface_ref"] = projectAgentInterfaceRefProvenance(provenance, interfaceSpec.Ref)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func projectAgentInterfaceRefProvenance(provenance FieldProvenance, ref *InterfaceRefSpec) FieldProvenance {
|
||||
finalValue, _ := json.Marshal(ref)
|
||||
legacy := "<none>"
|
||||
if ref != nil {
|
||||
legacy = strings.TrimSpace(ref.ProductID) + "." + strings.TrimSpace(ref.RPCName)
|
||||
}
|
||||
legacyValue, _ := json.Marshal(legacy)
|
||||
project := func(value json.RawMessage) json.RawMessage {
|
||||
if string(value) == string(legacyValue) || string(value) == string(finalValue) {
|
||||
return append(json.RawMessage(nil), finalValue...)
|
||||
}
|
||||
// Keep a disagreeing source value untouched. ToolSpec validation will
|
||||
// then fail instead of this adapter laundering a resolver conflict.
|
||||
return value
|
||||
}
|
||||
provenance.Value = project(provenance.Value)
|
||||
for index := range provenance.Candidates {
|
||||
candidate := &provenance.Candidates[index]
|
||||
if candidate.Selected != nil && *candidate.Selected {
|
||||
candidate.Value = project(candidate.Value)
|
||||
}
|
||||
}
|
||||
return provenance
|
||||
}
|
||||
|
||||
func resolvedFieldProvenance(value any, source, sourceRef, precedence, resolution, reviewReason string) FieldProvenance {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
raw = json.RawMessage("null")
|
||||
}
|
||||
selected := true
|
||||
return FieldProvenance{
|
||||
Value: append(json.RawMessage(nil), raw...),
|
||||
Source: strings.TrimSpace(source),
|
||||
SourceRef: strings.TrimSpace(sourceRef),
|
||||
Precedence: strings.TrimSpace(precedence),
|
||||
Resolution: strings.TrimSpace(resolution),
|
||||
ReviewReason: strings.TrimSpace(reviewReason),
|
||||
Candidates: []FieldCandidateProvenance{{
|
||||
Value: append(json.RawMessage(nil), raw...),
|
||||
Source: strings.TrimSpace(source),
|
||||
SourceRef: strings.TrimSpace(sourceRef),
|
||||
Precedence: strings.TrimSpace(precedence),
|
||||
ReviewReason: strings.TrimSpace(reviewReason),
|
||||
Selected: &selected,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// agentProductSelectionForIDsFromMetadata exposes generated product routing
|
||||
// through the same typed SelectionSpec used by ToolSpec.
|
||||
func agentProductSelectionForIDsFromMetadata(source embeddedAgentMetadata, ids ...string) (SelectionSpec, bool) {
|
||||
selection, _, ok := agentProductContractForIDsFromMetadata(source, ids...)
|
||||
return selection, ok
|
||||
}
|
||||
|
||||
func agentProductContractForIDsFromMetadata(source embeddedAgentMetadata, ids ...string) (SelectionSpec, map[string]FieldProvenance, bool) {
|
||||
for _, id := range ids {
|
||||
metadata, ok := source.Products[strings.TrimSpace(id)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
selection := SelectionSpec{
|
||||
AgentSummary: strings.TrimSpace(metadata.AgentSummary),
|
||||
AgentSummarySource: strings.TrimSpace(metadata.AgentSummarySource),
|
||||
UseWhen: cloneOptionalStrings(metadata.UseWhen),
|
||||
AvoidWhen: cloneOptionalStrings(metadata.AvoidWhen),
|
||||
SourceRefs: cloneOptionalStrings(metadata.SourceRefs),
|
||||
MetadataSource: embeddedAgentMetadataSource,
|
||||
}.normalized()
|
||||
return selection, cloneFieldProvenance(metadata.FieldProvenance), true
|
||||
}
|
||||
return SelectionSpec{}, nil, false
|
||||
}
|
||||
|
||||
func agentToolSelection(metadata agentToolMetadata) SelectionSpec {
|
||||
var reviewed *bool
|
||||
if metadata.Reviewed != nil {
|
||||
value := *metadata.Reviewed
|
||||
reviewed = &value
|
||||
}
|
||||
return SelectionSpec{
|
||||
AgentSummary: strings.TrimSpace(metadata.AgentSummary),
|
||||
AgentSummarySource: strings.TrimSpace(metadata.AgentSummarySource),
|
||||
UseWhen: cloneOptionalStrings(metadata.UseWhen),
|
||||
AvoidWhen: cloneOptionalStrings(metadata.AvoidWhen),
|
||||
Prerequisites: cloneOptionalStrings(metadata.Prerequisites),
|
||||
Tips: cloneOptionalStrings(metadata.Tips),
|
||||
WorkflowRefs: cloneOptionalStrings(metadata.WorkflowRefs),
|
||||
Examples: cloneOptionalStrings(metadata.Examples),
|
||||
Reviewed: reviewed,
|
||||
SourceRefs: cloneOptionalStrings(metadata.SourceRefs),
|
||||
MetadataSource: embeddedAgentMetadataSource,
|
||||
}.normalized()
|
||||
}
|
||||
|
||||
func cloneFieldProvenance(source map[string]FieldProvenance) map[string]FieldProvenance {
|
||||
func cloneFieldProvenance(source map[string]contract.FieldProvenance) map[string]contract.FieldProvenance {
|
||||
if len(source) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]FieldProvenance, len(source))
|
||||
out := make(map[string]contract.FieldProvenance, len(source))
|
||||
for field, provenance := range source {
|
||||
provenance.Value = append(json.RawMessage(nil), provenance.Value...)
|
||||
provenance.Candidates = cloneFieldCandidates(provenance.Candidates)
|
||||
@@ -304,11 +173,11 @@ func cloneFieldProvenance(source map[string]FieldProvenance) map[string]FieldPro
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneFieldCandidates(source []FieldCandidateProvenance) []FieldCandidateProvenance {
|
||||
func cloneFieldCandidates(source []contract.FieldCandidateProvenance) []contract.FieldCandidateProvenance {
|
||||
if len(source) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]FieldCandidateProvenance, len(source))
|
||||
out := make([]contract.FieldCandidateProvenance, len(source))
|
||||
for index, candidate := range source {
|
||||
candidate.Value = append(json.RawMessage(nil), candidate.Value...)
|
||||
if candidate.Selected != nil {
|
||||
@@ -320,7 +189,7 @@ func cloneFieldCandidates(source []FieldCandidateProvenance) []FieldCandidatePro
|
||||
return out
|
||||
}
|
||||
|
||||
func lookupAgentToolMetadataFrom(source embeddedAgentMetadata, paths ...string) (agentToolMetadata, bool) {
|
||||
func lookupAgentToolMetadataFrom(source agentMetadata, paths ...string) (agentToolMetadata, bool) {
|
||||
seen := map[string]bool{}
|
||||
for _, path := range paths {
|
||||
for _, candidate := range []string{
|
||||
@@ -339,29 +208,57 @@ func lookupAgentToolMetadataFrom(source embeddedAgentMetadata, paths ...string)
|
||||
return agentToolMetadata{}, false
|
||||
}
|
||||
|
||||
func agentMetadataSummaryFrom(metadata embeddedAgentMetadata) map[string]any {
|
||||
// agentMetadataSummaryFromProducts publishes Catalog-level Agent coverage from
|
||||
// the assembled Schema surface (ContractFinal / ProductDecl). This keeps
|
||||
// runtime delivery and CI dumps hash-aligned without requiring build-time
|
||||
// Agent metadata inject as a published summary source.
|
||||
func agentMetadataSummaryFromProducts(products []ProductSpec) map[string]any {
|
||||
productsWith := 0
|
||||
toolsWith := 0
|
||||
toolsWithSummary := 0
|
||||
surfaceTools := 0
|
||||
for _, product := range products {
|
||||
if productHasPublishedAgentMetadata(product) {
|
||||
productsWith++
|
||||
}
|
||||
for _, tool := range product.Tools {
|
||||
surfaceTools++
|
||||
if toolHasPublishedAgentMetadata(tool) {
|
||||
toolsWith++
|
||||
}
|
||||
if strings.TrimSpace(tool.Selection.AgentSummary) != "" {
|
||||
toolsWithSummary++
|
||||
}
|
||||
}
|
||||
}
|
||||
summary := map[string]any{
|
||||
"source": embeddedAgentMetadataSource,
|
||||
"version": metadata.Version,
|
||||
"source_hash": strings.TrimSpace(metadata.SourceHash),
|
||||
"products_with_metadata": len(metadata.Products),
|
||||
"tools_with_metadata": len(metadata.Tools),
|
||||
"source": ProvenanceEmbeddedSkillMetadata,
|
||||
"version": 1,
|
||||
"source_hash": "",
|
||||
"products_with_metadata": productsWith,
|
||||
"tools_with_metadata": toolsWith,
|
||||
}
|
||||
if metadata.SurfaceHash != "" {
|
||||
summary["surface_hash"] = metadata.SurfaceHash
|
||||
if len(products) > 0 {
|
||||
summary["surface_products"] = len(products)
|
||||
}
|
||||
coverage := metadata.Coverage
|
||||
if coverage.SurfaceProducts > 0 {
|
||||
summary["surface_products"] = coverage.SurfaceProducts
|
||||
if surfaceTools > 0 {
|
||||
summary["surface_tools"] = surfaceTools
|
||||
}
|
||||
if coverage.SurfaceTools > 0 {
|
||||
summary["surface_tools"] = coverage.SurfaceTools
|
||||
}
|
||||
if coverage.ToolsWithSummary > 0 {
|
||||
summary["tools_with_agent_summary"] = coverage.ToolsWithSummary
|
||||
}
|
||||
if coverage.UnmatchedSkillTools > 0 {
|
||||
summary["unmatched_skill_tools"] = coverage.UnmatchedSkillTools
|
||||
if toolsWithSummary > 0 {
|
||||
summary["tools_with_agent_summary"] = toolsWithSummary
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func productHasPublishedAgentMetadata(product ProductSpec) bool {
|
||||
return strings.TrimSpace(product.Selection.AgentSummary) != "" ||
|
||||
len(product.Selection.UseWhen) > 0 ||
|
||||
len(product.Selection.AvoidWhen) > 0
|
||||
}
|
||||
|
||||
func toolHasPublishedAgentMetadata(tool ToolSpec) bool {
|
||||
return strings.TrimSpace(tool.Selection.AgentSummary) != "" ||
|
||||
len(tool.Selection.UseWhen) > 0 ||
|
||||
len(tool.Selection.AvoidWhen) > 0 ||
|
||||
len(tool.Selection.Examples) > 0
|
||||
}
|
||||
|
||||
@@ -1,797 +0,0 @@
|
||||
{
|
||||
"product_id": "aisearch",
|
||||
"tools": {
|
||||
"aisearch behavior": {
|
||||
"agent_summary": "搜索发送/创建/分享/编辑/接收等明确行为记录",
|
||||
"agent_summary_source": "dws-agent-selection/aisearch",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"只按主题找内容本身时用 aisearch enterprise",
|
||||
"没有行为动作词时不要选用本工具"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "command-verb",
|
||||
"examples": [
|
||||
"dws aisearch behavior --types mail --behavior-type send --direction \"我-\u003e汐峰\" --format json",
|
||||
"dws aisearch behavior --queries \"智能化方案\" --types document --behavior-type create --format json"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "搜索发送/创建/分享/编辑/接收等明确行为记录",
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise_behavior.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"只按主题找内容本身时用 aisearch enterprise",
|
||||
"没有行为动作词时不要选用本工具"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "risk-default",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "risk-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "command-verb",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "command-verb",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"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",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "mcp",
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"value": "mcp",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise_behavior.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": "aisearch.search_enterprise_behavior",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise_behavior.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "aisearch.search_enterprise_behavior",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise_behavior.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "low",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"用户明确问我/某人发过、发给、收到、创建、分享、编辑过什么"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "mcp",
|
||||
"interface_ref": {
|
||||
"product_id": "aisearch",
|
||||
"rpc_name": "search_enterprise_behavior"
|
||||
},
|
||||
"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",
|
||||
"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"
|
||||
],
|
||||
"use_when": [
|
||||
"用户明确问我/某人发过、发给、收到、创建、分享、编辑过什么"
|
||||
]
|
||||
},
|
||||
"aisearch enterprise": {
|
||||
"agent_summary": "搜索企业内部知识与相关内容(文档/IM/日历/待办/纪要/日志/邮件等)",
|
||||
"agent_summary_source": "dws-agent-selection/aisearch",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"问“我发给谁/谁发给我/我创建过”等行为追溯时用 aisearch behavior",
|
||||
"企业找人时用 aisearch person",
|
||||
"已知具体资源 ID 要读写时改用对应产品命令"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "command-verb",
|
||||
"examples": [
|
||||
"dws aisearch enterprise --queries \"智能化方案\" --types document --format json",
|
||||
"dws aisearch enterprise --queries \"OKR\" --types mail --time-range \"最近\" --format json"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "搜索企业内部知识与相关内容(文档/IM/日历/待办/纪要/日志/邮件等)",
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"问“我发给谁/谁发给我/我创建过”等行为追溯时用 aisearch behavior",
|
||||
"企业找人时用 aisearch person",
|
||||
"已知具体资源 ID 要读写时改用对应产品命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"问“我发给谁/谁发给我/我创建过”等行为追溯时用 aisearch behavior",
|
||||
"企业找人时用 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。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "risk-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "command-verb",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "command-verb",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"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",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "mcp",
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"value": "mcp",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": "aisearch.search_enterprise",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "aisearch.search_enterprise",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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": "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。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "low",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"按主题找资料、方案、文档、消息、邮件等内容,且关注“有什么内容”",
|
||||
"用户显式给出时间词或类型词时分别映射到 --time-range / --types"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "mcp",
|
||||
"interface_ref": {
|
||||
"product_id": "aisearch",
|
||||
"rpc_name": "search_enterprise"
|
||||
},
|
||||
"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",
|
||||
"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"
|
||||
],
|
||||
"use_when": [
|
||||
"按主题找资料、方案、文档、消息、邮件等内容,且关注“有什么内容”",
|
||||
"用户显式给出时间词或类型词时分别映射到 --time-range / --types"
|
||||
]
|
||||
},
|
||||
"aisearch person": {
|
||||
"agent_summary": "企业内找人:按姓名/部门/职位/职责/上下级/手机号/工号筛选",
|
||||
"agent_summary_source": "dws-agent-selection/aisearch",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"已有 userId 只需详情时用 contact user get",
|
||||
"精确通讯录关键词搜同事/好友且不涉及职责语义时可用 contact user search",
|
||||
"搜企业知识内容时用 aisearch enterprise;搜行为记录时用 aisearch behavior"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "command-verb",
|
||||
"examples": [
|
||||
"dws aisearch person --keyword \"张三\" --dimension name --format json",
|
||||
"dws aisearch person --keyword \"五道\" --dimension supervisor --format json"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "企业内找人:按姓名/部门/职位/职责/上下级/手机号/工号筛选",
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.enterprise_person_search.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"已有 userId 只需详情时用 contact user get",
|
||||
"精确通讯录关键词搜同事/好友且不涉及职责语义时可用 contact user search",
|
||||
"搜企业知识内容时用 aisearch enterprise;搜行为记录时用 aisearch behavior"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"已有 userId 只需详情时用 contact user get",
|
||||
"精确通讯录关键词搜同事/好友且不涉及职责语义时可用 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。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "risk-default",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "risk-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "command-verb",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "command-verb",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"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",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "mcp",
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"value": "mcp",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.enterprise_person_search.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": "aisearch.enterprise_person_search",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.enterprise_person_search.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "aisearch.enterprise_person_search",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.enterprise_person_search.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "low",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"找人、谁负责某事、查上级/下级、按手机号或工号定位人员",
|
||||
"需要把维度词映射到 --dimension,关键词只保留目标实体"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "mcp",
|
||||
"interface_ref": {
|
||||
"product_id": "aisearch",
|
||||
"rpc_name": "enterprise_person_search"
|
||||
},
|
||||
"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",
|
||||
"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"
|
||||
],
|
||||
"use_when": [
|
||||
"找人、谁负责某事、查上级/下级、按手机号或工号定位人员",
|
||||
"需要把维度词映射到 --dimension,关键词只保留目标实体"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,794 +0,0 @@
|
||||
{
|
||||
"product_id": "audit",
|
||||
"tools": {
|
||||
"audit export": {
|
||||
"agent_summary": "按日期范围导出本地操作审计日志(jsonl 或 csv)",
|
||||
"agent_summary_source": "dws-agent-selection/audit",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"只看最近几条用 audit tail",
|
||||
"只校验哈希链完整性用 audit verify"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws audit export --format jsonl",
|
||||
"dws audit export --since 2026-07-01 --until 2026-07-14 --format csv"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "按日期范围导出本地操作审计日志(jsonl 或 csv)",
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"只看最近几条用 audit tail",
|
||||
"只校验哈希链完整性用 audit verify"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"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",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "local",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_reason": {
|
||||
"value": "命令读取并导出本地审计日志文件,不绑定 pinned MCP RPC",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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",
|
||||
"selected": true,
|
||||
"review_reason": "final interface mode local forbids a direct MCP interface_ref"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"需要把本地审计日志导出为 jsonl/csv,或按 --since/--until 取一段时间"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "local",
|
||||
"interface_reason": "命令读取并导出本地审计日志文件,不绑定 pinned MCP RPC",
|
||||
"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"
|
||||
],
|
||||
"use_when": [
|
||||
"需要把本地审计日志导出为 jsonl/csv,或按 --since/--until 取一段时间"
|
||||
]
|
||||
},
|
||||
"audit tail": {
|
||||
"agent_summary": "查看本地操作审计日志最近 N 条记录",
|
||||
"agent_summary_source": "dws-agent-selection/audit",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"需要按日期范围整段导出用 audit export",
|
||||
"需要校验哈希链用 audit verify"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws audit tail",
|
||||
"dws audit tail --lines 50"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "查看本地操作审计日志最近 N 条记录",
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"需要按日期范围整段导出用 audit export",
|
||||
"需要校验哈希链用 audit verify"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws audit tail",
|
||||
"dws audit tail --lines 50"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "local",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_reason": {
|
||||
"value": "命令读取本地审计日志尾部,不绑定 pinned MCP RPC",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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",
|
||||
"selected": true,
|
||||
"review_reason": "final interface mode local forbids a direct MCP interface_ref"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"需要快速查看最近写入的审计记录(默认最近 20 条)"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "local",
|
||||
"interface_reason": "命令读取本地审计日志尾部,不绑定 pinned MCP RPC",
|
||||
"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"
|
||||
],
|
||||
"use_when": [
|
||||
"需要快速查看最近写入的审计记录(默认最近 20 条)"
|
||||
]
|
||||
},
|
||||
"audit verify": {
|
||||
"agent_summary": "校验本地审计日志文件的哈希链完整性",
|
||||
"agent_summary_source": "dws-agent-selection/audit",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"只浏览或导出日志内容时用 audit tail / audit export"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws audit verify",
|
||||
"dws audit verify --file /path/to/audit.jsonl"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "校验本地审计日志文件的哈希链完整性",
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"只浏览或导出日志内容时用 audit tail / audit export"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws audit verify",
|
||||
"dws audit verify --file /path/to/audit.jsonl"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "local",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_reason": {
|
||||
"value": "命令校验本地审计日志哈希链,不绑定 pinned MCP RPC",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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",
|
||||
"selected": true,
|
||||
"review_reason": "final interface mode local forbids a direct MCP interface_ref"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"怀疑审计文件被篡改,或需要确认最新/指定文件哈希链是否完整"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"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。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "local",
|
||||
"interface_reason": "命令校验本地审计日志哈希链,不绑定 pinned MCP RPC",
|
||||
"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"
|
||||
],
|
||||
"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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user