Files
dingtalk-workspace-cli/internal/cli/schema_catalog.go
T
玉澜 b4b4a31979 refactor(cli): tighten schema pipeline hygiene
Move the test-only runtimeCommandParameters adapter to the test helper
file; route snapshot-decoded optional slices through cloneOptionalStrings
so wire structs are never aliased into typed specs; drop three dead
annotation aliases; collapse the triplicated catalog/surface hash
stamping into stampSnapshotHashes; make registry-vs-command validation
iterate in sorted order; guard the enum mcp candidate with hasPinned
like its siblings; refresh stale discovery-era comments.
2026-08-04 09:34:51 +08:00

389 lines
15 KiB
Go

// 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 (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
const SchemaCatalogSnapshotVersion = 1
// Schema Catalog delivery is single-track: RegisterSchemaSourceRoot →
// ResolveSchemaBuild (see schema_source_root.go). There is no committed
// schema_catalog/ embed and no shard/envelope loading path left in the CLI;
// cmd_schema_catalog owns shard writing and re-merges its own dumps.
// SchemaCatalogSnapshot is the release-stable Agent contract. Catalog holds
// the progressive product/tool index; Tools holds full leaf parameter schemas.
// It intentionally contains no endpoint, credential, or runtime cache data.
type SchemaCatalogSnapshot struct {
Version int `json:"version"`
SourceHash string `json:"source_hash"`
SurfaceHash string `json:"surface_hash,omitempty"`
Catalog map[string]any `json:"catalog"`
Tools map[string]map[string]any `json:"tools"`
}
// SchemaCatalogBuildOptions carries release-envelope inputs which are checked
// against the effective reviewed CommandRegistry. The command set is not an
// option: visibility is resolved by EffectiveCommandRegistry before assembly,
// and every public command must be delivered.
type SchemaCatalogBuildOptions struct {
RegistryHash string
}
type loadedSchemaCatalog struct {
Snapshot SchemaCatalogSnapshot
Registry SchemaRegistry
Index SchemaIndex
}
func registryToSnapshotPayload(registry SchemaRegistry) (SchemaCatalogSnapshot, error) {
payload, err := registry.ToSnapshotPayload()
if err != nil {
return SchemaCatalogSnapshot{}, err
}
return SchemaCatalogSnapshot{Catalog: payload.Catalog, Tools: payload.Tools}, nil
}
var registryToSnapshotPayloadFn = registryToSnapshotPayload
var (
buildCatalogValidateParameterBindings = ValidateSchemaParameterBindingDelivery
buildCatalogValidateDryRun = ValidateReviewedDryRunCapabilityDelivery
buildCatalogValidateExamples = ValidateAgentExampleDelivery
buildCatalogValidateCompleteness = validateResolvedRuntimeSchemaCompleteness
buildCatalogValidateRegistry = validateSchemaRegistryAgainstCommandRegistry
buildCatalogValidateInterfaces = validateSchemaRegistryInterfaces
buildCatalogValidateAgentMetadata = validateSchemaRegistryAgentMetadata
buildCatalogValidateProvenance = validateFinalSchemaProvenanceCoverage
buildCatalogValidateDelivery = ValidateSchemaDeliveryInvariants
buildCatalogValidateFinalCompleteness = validateResolvedSchemaCatalogDeliveryCompleteness
loadCatalogValidateInterfaces = validateSchemaRegistryInterfaces
loadCatalogValidateProvenance = validateFinalSchemaProvenanceCoverage
renderDeliverySchemaAll = func(registry SchemaRegistry) (map[string]any, error) { return registry.ToPayload() }
renderDeliverySchemaOverview = func(registry SchemaRegistry) (map[string]any, error) { return registry.ToOverviewPayload() }
renderSchemaProductSummary = func(product ProductSpec) (map[string]any, error) { return product.ToSummaryPayload() }
renderSchemaToolSummary = func(tool ToolSpec) (map[string]any, error) { return tool.ToSummaryPayload() }
)
// BuildSchemaCatalogSnapshot renders a deterministic Catalog from one
// resolved source-to-delivery hand-off. It deliberately accepts no Cobra root:
// rebuilding SchemaRegistry or re-deriving identity at this boundary would
// allow generation gates to validate one candidate while publishing another.
func BuildSchemaCatalogSnapshot(resolved ResolvedSchemaBuild, options SchemaCatalogBuildOptions) (SchemaCatalogSnapshot, error) {
if resolved.root == nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("schema Catalog source was not created by ResolveSchemaBuild")
}
registry := resolved.registry
effectiveCommands := resolved.effective
registryHash := strings.TrimSpace(options.RegistryHash)
if registryHash == "" {
registryHash = effectiveCommands.SourceHash()
} else if registryHash != effectiveCommands.SourceHash() {
return SchemaCatalogSnapshot{}, fmt.Errorf("provided Registry hash %q disagrees with effective CommandRegistry %q", registryHash, effectiveCommands.SourceHash())
}
if err := buildCatalogValidateParameterBindings(resolved.bound, registry); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("validate final Schema parameter binding delivery: %w", err)
}
if err := buildCatalogValidateDryRun(registry); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("validate reviewed dry-run capability delivery: %w", err)
}
if _, err := buildCatalogValidateExamples(resolved.bound, registry); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("validate final Manual Agent example delivery: %w", err)
}
if err := buildCatalogValidateCompleteness(resolved.root, resolved.bound); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("validate reverse command-tree completeness: %w", err)
}
if err := buildCatalogValidateRegistry(registry, effectiveCommands); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("validate typed Schema registry against reviewed CommandRegistry: %w", err)
}
if err := buildCatalogValidateInterfaces(registry); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("validate final Schema interface disposition: %w", err)
}
if err := buildCatalogValidateAgentMetadata(registry); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("validate final Schema Agent metadata set: %w", err)
}
if err := buildCatalogValidateProvenance(registry); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("validate final Schema provenance: %w", err)
}
// Visibility has already selected the complete public set in
// collectRuntimeSchemaEntriesFromBound. Do not apply a post-assembly
// allowlist: doing so could silently erase an otherwise valid reviewed
// manual-only command after the exact-set validation above has passed.
registry.Source = SchemaSourceRuntimeAssembled
payload, err := registry.ToSnapshotPayload()
if err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("serialize typed Schema registry: %w", err)
}
snapshot := SchemaCatalogSnapshot{
Version: SchemaCatalogSnapshotVersion,
SurfaceHash: registryHash,
Catalog: payload.Catalog,
Tools: payload.Tools,
}
snapshot.SourceHash = schemaCatalogSnapshotHash(snapshot)
if err := buildCatalogValidateDelivery(registry, snapshot); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("validate final Schema delivery invariants: %w", err)
}
if err := buildCatalogValidateFinalCompleteness(resolved.root, resolved.bound, snapshot); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("validate final Catalog delivery completeness: %w", err)
}
return snapshot, nil
}
// decodeSchemaCatalogSnapshot decodes a single-document snapshot and loads it
// through loadSchemaCatalogSnapshot. Delivery validation round-trips generated
// JSON through this function so it cannot pass with data that the shared
// loader would reject.
func decodeSchemaCatalogSnapshot(data []byte) (loadedSchemaCatalog, error) {
var snapshot SchemaCatalogSnapshot
if err := decodeStrictSchemaJSON(data, &snapshot); err != nil {
return loadedSchemaCatalog{}, fmt.Errorf("decode Schema Catalog snapshot: %w", err)
}
return loadSchemaCatalogSnapshot(snapshot)
}
// loadSchemaCatalogSnapshot constructs the production lookup from an
// arbitrary decoded snapshot. It validates the progressive summaries against
// the full leaf store before publishing any lookup key.
func loadSchemaCatalogSnapshot(snapshot SchemaCatalogSnapshot) (loadedSchemaCatalog, error) {
if snapshot.Version != SchemaCatalogSnapshotVersion {
return loadedSchemaCatalog{}, fmt.Errorf("unsupported Schema Catalog snapshot version %d", snapshot.Version)
}
if len(snapshot.Catalog) == 0 || len(snapshot.Tools) == 0 {
return loadedSchemaCatalog{}, fmt.Errorf("schema Catalog snapshot is empty")
}
if snapshot.SourceHash == "" {
return loadedSchemaCatalog{}, fmt.Errorf("schema Catalog snapshot is missing source_hash")
}
// source_hash equality is a build-time property, enforced by
// check-generated-drift.sh, which regenerates and byte-compares the whole
// artifact set — strictly stronger than this hash, since it also catches a
// shard set that is self-consistent but stale. Recomputing it here re-marshals
// every decoded map (measured: 86.9ms, 65MB, 997k allocs) on each full
// Catalog load, to re-derive a fact about an embedded artifact that cannot
// change at runtime. ResolveMeta no longer loads this path.
registry, index, err := schemaRegistryFromSnapshot(snapshot)
if err != nil {
return loadedSchemaCatalog{}, fmt.Errorf("load typed Schema registry: %w", err)
}
if err := loadCatalogValidateInterfaces(registry); err != nil {
return loadedSchemaCatalog{}, fmt.Errorf("validate final Schema interface disposition: %w", err)
}
// The production loader validates delivered provenance exactly as encoded.
// ToolSpecFromRuntime deliberately does not synthesize candidates or
// rewrite winners, and this coverage gate applies to every snapshot source.
if err := loadCatalogValidateProvenance(registry); err != nil {
return loadedSchemaCatalog{}, fmt.Errorf("validate final Schema provenance: %w", err)
}
// Agent metadata is no longer a shipped embed. CI/local cmd_schema_catalog
// dumps may still inject Agent metadata for validation; production delivery
// reassembles via ResolveSchemaBuild and must not reopen retired
// schema_agent_metadata/ artifacts.
return loadedSchemaCatalog{Snapshot: snapshot, Registry: registry, Index: index}, nil
}
func deliverySchemaAllPayload() (map[string]any, error) {
if err := deliverySchemaCatalogError(); err != nil {
return nil, err
}
return schemaAllPayloadFromLoaded(deliverySchemaCatalog())
}
func deliverySchemaOverviewPayload() (map[string]any, error) {
if err := deliverySchemaCatalogError(); err != nil {
return nil, err
}
return schemaOverviewPayloadFromLoaded(deliverySchemaCatalog())
}
// stampSnapshotHashes writes the protected catalog_hash / surface_hash pair
// onto a delivery payload. Every envelope that exposes snapshot hashes must
// go through this helper so the two keys never drift apart.
func stampSnapshotHashes(payload map[string]any, loaded loadedSchemaCatalog) {
payload["catalog_hash"] = loaded.Snapshot.SourceHash
if loaded.Snapshot.SurfaceHash != "" {
payload["surface_hash"] = loaded.Snapshot.SurfaceHash
}
}
func schemaAllPayloadFromLoaded(loaded loadedSchemaCatalog) (map[string]any, error) {
payload, err := renderDeliverySchemaAll(loaded.Registry)
if err != nil {
return nil, err
}
stampSnapshotHashes(payload, loaded)
return payload, nil
}
func schemaOverviewPayloadFromLoaded(loaded loadedSchemaCatalog) (map[string]any, error) {
payload, err := renderDeliverySchemaOverview(loaded.Registry)
if err != nil {
return nil, err
}
stampSnapshotHashes(payload, loaded)
return payload, nil
}
// queryDeliverySchemaPayload serves dws schema queries through the production
// delivery loader. Completeness / invariant gates must call
// schemaPayloadFromLoadedCatalog (or the deliverySchemaPayload var alias) with
// an explicit loaded catalog — never this helper — to avoid an init cycle
// through assembleSchemaCatalogFromRoot → BuildSchemaCatalogSnapshot.
func queryDeliverySchemaPayload(args []string) (map[string]any, error) {
if err := deliverySchemaCatalogError(); err != nil {
return nil, err
}
return schemaPayloadFromLoadedCatalog(deliverySchemaCatalog(), args)
}
// schemaPayloadFromLoadedCatalog is shared by the shipped schema command and
// the final-delivery gate. Keeping lookup and payload rendering on one path
// prevents generation-only validation from accepting an unqueryable snapshot.
func schemaPayloadFromLoadedCatalog(loaded loadedSchemaCatalog, args []string) (map[string]any, error) {
if len(args) == 0 {
snapshot, err := loaded.Registry.ToSnapshotPayload()
if err != nil {
return nil, err
}
payload := snapshot.Catalog
stampSnapshotHashes(payload, loaded)
return payload, nil
}
raw := strings.TrimSpace(args[0])
if tool, ok := loaded.Index.ResolveQuery(raw); ok {
return schemaToolForResolvedPath(tool, raw).ToPayload()
}
tokens := splitSchemaPathTokens(raw)
if len(tokens) == 1 {
if product, ok := loaded.Index.Product(tokens[0]); ok {
payload, err := renderSchemaProductSummary(product)
if err != nil {
return nil, err
}
source := strings.TrimSpace(loaded.Registry.Source)
if source == "" {
source = SchemaSourceRuntimeAssembled
}
return map[string]any{
"kind": "schema",
"level": "product",
"count": len(product.Tools),
"product": payload,
"source": source,
}, nil
}
}
if len(tokens) > 1 {
path := strings.Join(tokens, " ")
if product, ok := loaded.Index.Product(tokens[0]); ok {
matched := make([]map[string]any, 0)
for _, tool := range product.Tools {
if schemaToolUnderGroup(tool, path) {
summary, err := renderSchemaToolSummary(tool)
if err != nil {
return nil, err
}
matched = append(matched, summary)
}
}
if len(matched) > 0 {
source := strings.TrimSpace(loaded.Registry.Source)
if source == "" {
source = SchemaSourceRuntimeAssembled
}
return map[string]any{
"kind": "schema",
"level": "group",
"path": path,
"count": len(matched),
"tools": matched,
"source": source,
}, nil
}
}
}
return nil, apperrors.NewValidation("unknown runtime schema path " + strconvQuote(raw))
}
func schemaCatalogSnapshotHash(snapshot SchemaCatalogSnapshot) string {
payload := struct {
Version int `json:"version"`
SurfaceHash string `json:"surface_hash,omitempty"`
Catalog map[string]any `json:"catalog"`
Tools map[string]map[string]any `json:"tools"`
}{snapshot.Version, snapshot.SurfaceHash, snapshot.Catalog, snapshot.Tools}
encoded, _ := json.Marshal(payload)
sum := sha256.Sum256(encoded)
return "sha256:" + hex.EncodeToString(sum[:])
}
func schemaMapSlice(value any) []map[string]any {
switch values := value.(type) {
case []map[string]any:
return values
case []any:
out := make([]map[string]any, 0, len(values))
for _, value := range values {
if item, ok := value.(map[string]any); ok {
out = append(out, item)
}
}
return out
default:
return nil
}
}
func schemaString(value any) string {
valueString, _ := value.(string)
return valueString
}
func schemaStringSlice(value any) []string {
switch values := value.(type) {
case []string:
return values
case []any:
out := make([]string, 0, len(values))
for _, value := range values {
if item, ok := value.(string); ok {
out = append(out, item)
}
}
return out
default:
return nil
}
}
func firstNonEmptySchemaString(values ...any) string {
for _, value := range values {
if text := strings.TrimSpace(schemaString(value)); text != "" {
return text
}
}
return ""
}