Files
dingtalk-workspace-cli/internal/cli/schema_meta_index.go

325 lines
12 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 (
"bytes"
"encoding/gob"
"encoding/json"
"fmt"
"reflect"
"sort"
"strings"
)
// SchemaMetaIndexVersion is the CommandMeta summary index format used by CI
// dumps (cmd_schema_catalog) and unit-test encode/decode fixtures. Production
// ResolveMeta projects from the runtime-assembled SchemaRegistry — there is
// no committed schema_meta_index.gob embed.
const SchemaMetaIndexVersion = 1
// SchemaMetaIndexSnapshot is the CommandMeta summary shape written by CI
// Catalog dumps for determinism checks. Runtime delivery does not embed or
// decode this artifact.
type SchemaMetaIndexSnapshot struct {
Version int `json:"version"`
SourceHash string `json:"source_hash"`
SurfaceHash string `json:"surface_hash,omitempty"`
Entries []SchemaMetaIndexEntry `json:"entries"`
}
// SchemaMetaIndexEntry is one primary-path CommandMeta record. Aliases are
// expanded into the ResolveMeta lookup at decode time.
type SchemaMetaIndexEntry struct {
CLIPath string `json:"cli_path"`
Canonical string `json:"canonical_path"`
Aliases []string `json:"aliases,omitempty"`
ProductID string `json:"product_id,omitempty"`
Title string `json:"title,omitempty"`
Effect string `json:"effect,omitempty"`
Risk string `json:"risk,omitempty"`
Confirmation string `json:"confirmation,omitempty"`
Idempotency string `json:"idempotency,omitempty"`
AgentSummary string `json:"agent_summary,omitempty"`
UseWhen []string `json:"use_when,omitempty"`
AvoidWhen []string `json:"avoid_when,omitempty"`
Examples []string `json:"examples,omitempty"`
}
// BuildSchemaMetaIndex extracts the ResolveMeta summary from a full Catalog
// snapshot. Entries are sorted by cli_path for deterministic generation.
func BuildSchemaMetaIndex(snapshot SchemaCatalogSnapshot) (SchemaMetaIndexSnapshot, error) {
if snapshot.Version != SchemaCatalogSnapshotVersion {
return SchemaMetaIndexSnapshot{}, fmt.Errorf("unsupported Schema Catalog snapshot version %d", snapshot.Version)
}
if strings.TrimSpace(snapshot.SourceHash) == "" {
return SchemaMetaIndexSnapshot{}, fmt.Errorf("schema Catalog snapshot is missing source_hash")
}
entries := make([]SchemaMetaIndexEntry, 0, len(snapshot.Tools))
seenCLI := make(map[string]string, len(snapshot.Tools))
for canonical, tool := range snapshot.Tools {
cliPath := strings.TrimSpace(schemaString(tool["cli_path"]))
if cliPath == "" {
return SchemaMetaIndexSnapshot{}, fmt.Errorf("tool %q is missing cli_path", canonical)
}
if prev, exists := seenCLI[cliPath]; exists {
return SchemaMetaIndexSnapshot{}, fmt.Errorf("duplicate cli_path %q for %q and %q", cliPath, prev, canonical)
}
seenCLI[cliPath] = canonical
toolCanonical := strings.TrimSpace(schemaString(tool["canonical_path"]))
if toolCanonical == "" {
toolCanonical = canonical
}
entries = append(entries, SchemaMetaIndexEntry{
CLIPath: cliPath,
Canonical: toolCanonical,
Aliases: schemaStringSlice(tool["aliases"]),
ProductID: schemaString(tool["product_id"]),
Title: schemaString(tool["title"]),
Effect: schemaString(tool["effect"]),
Risk: schemaString(tool["risk"]),
Confirmation: schemaString(tool["confirmation"]),
Idempotency: schemaString(tool["idempotency"]),
AgentSummary: schemaString(tool["agent_summary"]),
UseWhen: schemaStringSlice(tool["use_when"]),
AvoidWhen: schemaStringSlice(tool["avoid_when"]),
Examples: schemaStringSlice(tool["examples"]),
})
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].CLIPath < entries[j].CLIPath
})
return SchemaMetaIndexSnapshot{
Version: SchemaMetaIndexVersion,
SourceHash: snapshot.SourceHash,
SurfaceHash: snapshot.SurfaceHash,
Entries: entries,
}, nil
}
var (
encodeSchemaMetaIndexFn = encodeSchemaMetaIndexGob
gobEncodeSchemaMetaIndex = gobEncodeSchemaMetaIndexTo
jsonMarshalSchemaMetaIndex = json.MarshalIndent
)
// EncodeSchemaMetaIndex marshals a CI/test SchemaMetaIndex dump (gob).
// Production ResolveMeta does not embed or load this artifact.
func EncodeSchemaMetaIndex(index SchemaMetaIndexSnapshot) ([]byte, error) {
encoded, err := encodeSchemaMetaIndexFn(index)
if err != nil {
return nil, fmt.Errorf("encode schema meta index: %w", err)
}
return encoded, nil
}
func gobEncodeSchemaMetaIndexTo(index SchemaMetaIndexSnapshot, buf *bytes.Buffer) error {
return gob.NewEncoder(buf).Encode(index)
}
func encodeSchemaMetaIndexGob(index SchemaMetaIndexSnapshot) ([]byte, error) {
var buf bytes.Buffer
if err := gobEncodeSchemaMetaIndex(index, &buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// DecodeSchemaMetaIndex parses a CI/test gob meta index dump.
func DecodeSchemaMetaIndex(data []byte) (SchemaMetaIndexSnapshot, error) {
if len(data) == 0 {
return SchemaMetaIndexSnapshot{}, fmt.Errorf("decode schema meta index: empty payload")
}
var index SchemaMetaIndexSnapshot
if err := gob.NewDecoder(bytes.NewReader(data)).Decode(&index); err != nil {
return SchemaMetaIndexSnapshot{}, fmt.Errorf("decode schema meta index: %w", err)
}
if err := validateSchemaMetaIndexSnapshot(index); err != nil {
return SchemaMetaIndexSnapshot{}, err
}
return index, nil
}
// DecodeSchemaMetaIndexJSON parses a JSON meta index document. Kept for unit
// fixtures; runtime delivery uses gob via DecodeSchemaMetaIndex.
func DecodeSchemaMetaIndexJSON(data []byte) (SchemaMetaIndexSnapshot, error) {
var index SchemaMetaIndexSnapshot
if err := decodeStrictSchemaJSON(data, &index); err != nil {
return SchemaMetaIndexSnapshot{}, fmt.Errorf("decode schema meta index: %w", err)
}
if err := validateSchemaMetaIndexSnapshot(index); err != nil {
return SchemaMetaIndexSnapshot{}, err
}
return index, nil
}
func validateSchemaMetaIndexSnapshot(index SchemaMetaIndexSnapshot) error {
if index.Version != SchemaMetaIndexVersion {
return fmt.Errorf("unsupported schema meta index version %d", index.Version)
}
if strings.TrimSpace(index.SourceHash) == "" {
return fmt.Errorf("schema meta index is missing source_hash")
}
if len(index.Entries) == 0 {
return fmt.Errorf("schema meta index has no entries")
}
return nil
}
// commandMetaLookupFromIndex expands primary paths and aliases into the
// ResolveMeta O(1) map.
func commandMetaLookupFromIndex(index SchemaMetaIndexSnapshot) (map[string]CommandMeta, error) {
lookup := make(map[string]CommandMeta, len(index.Entries)*2)
metas := make([]CommandMeta, 0, len(index.Entries))
for _, entry := range index.Entries {
cliPath := strings.TrimSpace(entry.CLIPath)
if cliPath == "" {
return nil, fmt.Errorf("schema meta index entry is missing cli_path")
}
if _, exists := lookup[cliPath]; exists {
return nil, fmt.Errorf("schema meta index duplicate cli_path %q", cliPath)
}
meta := CommandMeta{
Identity: CommandIdentity{
CLIPath: cliPath,
Canonical: strings.TrimSpace(entry.Canonical),
Aliases: append([]string(nil), entry.Aliases...),
ProductID: entry.ProductID,
Title: entry.Title,
},
Safety: CommandSafety{
Effect: entry.Effect,
Risk: entry.Risk,
Confirmation: entry.Confirmation,
Idempotency: entry.Idempotency,
},
Selection: CommandSelection{
AgentSummary: entry.AgentSummary,
UseWhen: append([]string(nil), entry.UseWhen...),
AvoidWhen: append([]string(nil), entry.AvoidWhen...),
Examples: append([]string(nil), entry.Examples...),
},
}
lookup[cliPath] = meta
metas = append(metas, meta)
}
return registerCommandMetaAliases(lookup, metas), nil
}
// ValidateSchemaMetaIndexAgainstSnapshot proves the summary index matches the
// Identity / Safety / Selection projection of every ToolSpec in a Catalog
// snapshot (generation-time gate before writing the index).
func ValidateSchemaMetaIndexAgainstSnapshot(index SchemaMetaIndexSnapshot, snapshot SchemaCatalogSnapshot) error {
if index.SourceHash != snapshot.SourceHash {
return fmt.Errorf("schema meta index source_hash %q disagrees with catalog %q", index.SourceHash, snapshot.SourceHash)
}
if index.SurfaceHash != snapshot.SurfaceHash {
return fmt.Errorf("schema meta index surface_hash %q disagrees with catalog %q", index.SurfaceHash, snapshot.SurfaceHash)
}
want := buildMetaByCLIPathFromSnapshotTools(snapshot.Tools)
got, err := commandMetaLookupFromIndex(index)
if err != nil {
return err
}
if err := compareCommandMetaLookups(got, want); err != nil {
return err
}
if len(index.Entries) != len(snapshot.Tools) {
return fmt.Errorf("schema meta index entry count %d disagrees with catalog tools %d", len(index.Entries), len(snapshot.Tools))
}
return nil
}
// ValidateSchemaMetaIndexAgainstCatalog proves the summary index matches the
// Identity / Safety / Selection projection of every delivered ToolSpec.
func ValidateSchemaMetaIndexAgainstCatalog(index SchemaMetaIndexSnapshot, registry SchemaRegistry) error {
want := buildMetaByCLIPathFromRegistry(registry)
got, err := commandMetaLookupFromIndex(index)
if err != nil {
return err
}
if err := compareCommandMetaLookups(got, want); err != nil {
return err
}
primaryCount := 0
for _, product := range registry.Products {
primaryCount += len(product.Tools)
}
if len(index.Entries) != primaryCount {
return fmt.Errorf("schema meta index entry count %d disagrees with catalog tools %d", len(index.Entries), primaryCount)
}
return nil
}
func compareCommandMetaLookups(got, want map[string]CommandMeta) error {
if len(got) != len(want) {
return fmt.Errorf("schema meta index lookup size %d disagrees with catalog projection %d", len(got), len(want))
}
paths := make([]string, 0, len(want))
for path := range want {
paths = append(paths, path)
}
sort.Strings(paths)
for _, path := range paths {
wantMeta := want[path]
gotMeta, ok := got[path]
if !ok {
return fmt.Errorf("schema meta index missing path %q", path)
}
if err := commandMetaEqual(gotMeta, wantMeta); err != nil {
return fmt.Errorf("schema meta index path %q: %w", path, err)
}
}
// Equal lengths + every want key present in got implies identical key sets;
// no separate "unexpected path" pass is required.
return nil
}
func commandMetaEqual(got, want CommandMeta) error {
if got.Identity.CLIPath != want.Identity.CLIPath ||
got.Identity.Canonical != want.Identity.Canonical ||
got.Identity.ProductID != want.Identity.ProductID ||
got.Identity.Title != want.Identity.Title ||
!metaStringSlicesEqual(got.Identity.Aliases, want.Identity.Aliases) {
return fmt.Errorf("identity mismatch: got %+v want %+v", got.Identity, want.Identity)
}
if got.Safety != want.Safety {
return fmt.Errorf("safety mismatch: got %+v want %+v", got.Safety, want.Safety)
}
if got.Selection.AgentSummary != want.Selection.AgentSummary ||
!metaStringSlicesEqual(got.Selection.UseWhen, want.Selection.UseWhen) ||
!metaStringSlicesEqual(got.Selection.AvoidWhen, want.Selection.AvoidWhen) ||
!metaStringSlicesEqual(got.Selection.Examples, want.Selection.Examples) {
return fmt.Errorf("selection mismatch: got %+v want %+v", got.Selection, want.Selection)
}
return nil
}
func metaStringSlicesEqual(got, want []string) bool {
if len(got) == 0 && len(want) == 0 {
return true
}
return reflect.DeepEqual(got, want)
}
// EncodeSchemaMetaIndexJSON is retained for diagnostics / fixtures that need a
// human-readable projection of the same snapshot struct.
func EncodeSchemaMetaIndexJSON(index SchemaMetaIndexSnapshot) ([]byte, error) {
encoded, err := jsonMarshalSchemaMetaIndex(index, "", " ")
if err != nil {
return nil, fmt.Errorf("encode schema meta index json: %w", err)
}
return append(encoded, '\n'), nil
}