325 lines
12 KiB
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
|
|
}
|