397 lines
16 KiB
Go
397 lines
16 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// ValidateSchemaDeliveryInvariants proves that the serialized snapshot which
|
|
// will be embedded in the release binary is an exact delivery of source and
|
|
// has one content-identical ToolSpec behind every public Schema view. The
|
|
// snapshot is deliberately encoded and decoded through the production loader
|
|
// before any comparison is made.
|
|
//
|
|
// This is a content gate, not a count gate: replacing one tool with another,
|
|
// consistently dropping a source field from every snapshot view, drifting a
|
|
// summary, or changing an alias payload while preserving all aggregate counts
|
|
// still fails.
|
|
func ValidateSchemaDeliveryInvariants(source SchemaRegistry, snapshot SchemaCatalogSnapshot) error {
|
|
problems := schemaSourceSnapshotInvariantErrors(source, snapshot)
|
|
encoded, err := json.Marshal(snapshot)
|
|
if err != nil {
|
|
return fmt.Errorf("encode final Schema Catalog for invariant validation: %w", err)
|
|
}
|
|
loaded, err := decodeSchemaCatalogSnapshot(encoded)
|
|
if err != nil {
|
|
return fmt.Errorf("load final Schema Catalog through production loader: %w", err)
|
|
}
|
|
problems = append(problems, schemaSourceDeliveryInvariantErrors(source, loaded.Registry)...)
|
|
problems = append(problems, schemaDeliveryInvariantErrors(loaded)...)
|
|
if problems = sortedUniqueSchemaStrings(problems); len(problems) > 0 {
|
|
return fmt.Errorf("invalid final Schema delivery invariants: %s", strings.Join(problems, "; "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// schemaSourceSnapshotInvariantErrors makes the pre-serialization -> raw
|
|
// snapshot boundary explicit. This catches mutation after rendering and gives
|
|
// a precise Catalog/full-tool diagnostic. The separate source -> decoded
|
|
// typed comparison below is still required because it catches a serializer
|
|
// that consistently omits the same field from every raw view.
|
|
func schemaSourceSnapshotInvariantErrors(source SchemaRegistry, snapshot SchemaCatalogSnapshot) []string {
|
|
expected, err := source.ToSnapshotPayload()
|
|
if err != nil {
|
|
return []string{fmt.Sprintf("render source SchemaRegistry snapshot: %v", err)}
|
|
}
|
|
var problems []string
|
|
if !schemaJSONEqual(expected.Catalog, snapshot.Catalog) {
|
|
problems = append(problems, "raw Catalog differs from source SchemaRegistry projection")
|
|
}
|
|
if !schemaJSONEqual(expected.Tools, snapshot.Tools) {
|
|
problems = append(problems, "raw Catalog full tools differ from source SchemaRegistry projection")
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// validateSchemaSnapshotDeliveryInvariants validates only relationships among
|
|
// delivered snapshot views. It is useful for loader-focused tests, but is not
|
|
// a generation gate because an internally consistent snapshot can still have
|
|
// consistently lost or changed source fields.
|
|
func validateSchemaSnapshotDeliveryInvariants(snapshot SchemaCatalogSnapshot) error {
|
|
encoded, err := json.Marshal(snapshot)
|
|
if err != nil {
|
|
return fmt.Errorf("encode final Schema Catalog for invariant validation: %w", err)
|
|
}
|
|
loaded, err := decodeSchemaCatalogSnapshot(encoded)
|
|
if err != nil {
|
|
return fmt.Errorf("load final Schema Catalog through production loader: %w", err)
|
|
}
|
|
if problems := schemaDeliveryInvariantErrors(loaded); len(problems) > 0 {
|
|
return fmt.Errorf("invalid final Schema delivery invariants: %s", strings.Join(problems, "; "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// schemaSourceDeliveryInvariantErrors compares the complete normalized typed
|
|
// source with the complete typed registry reconstructed by the production
|
|
// decoder. Comparing typed registries (rather than re-rendering source through
|
|
// ToSnapshotPayload) is what detects a serializer that consistently omits or
|
|
// mutates a field in both Catalog summaries and full tools.
|
|
func schemaSourceDeliveryInvariantErrors(source, delivered SchemaRegistry) []string {
|
|
sourceIndex, err := source.Index()
|
|
if err != nil {
|
|
return []string{fmt.Sprintf("normalize source SchemaRegistry: %v", err)}
|
|
}
|
|
deliveredIndex, err := delivered.Index()
|
|
if err != nil {
|
|
return []string{fmt.Sprintf("normalize production-decoded SchemaRegistry: %v", err)}
|
|
}
|
|
if !schemaJSONEqual(sourceIndex.Registry(), deliveredIndex.Registry()) {
|
|
path, left, right := firstSchemaJSONDifference(sourceIndex.Registry(), deliveredIndex.Registry())
|
|
return []string{fmt.Sprintf("production-decoded snapshot differs from complete source SchemaRegistry at %s: source=%s delivered=%s", path, left, right)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func firstSchemaJSONDifference(left, right any) (path, leftValue, rightValue string) {
|
|
decode := func(value any) any {
|
|
encoded, err := json.Marshal(value)
|
|
if err != nil {
|
|
return fmt.Sprintf("<encode error: %v>", err)
|
|
}
|
|
var decoded any
|
|
decoder := json.NewDecoder(strings.NewReader(string(encoded)))
|
|
decoder.UseNumber()
|
|
if err := decoder.Decode(&decoded); err != nil {
|
|
return fmt.Sprintf("<decode error: %v>", err)
|
|
}
|
|
return decoded
|
|
}
|
|
path, leftDecoded, rightDecoded, _ := firstSchemaJSONDifferenceAt("$", decode(left), decode(right))
|
|
return path, compactSchemaDiagnosticValue(leftDecoded), compactSchemaDiagnosticValue(rightDecoded)
|
|
}
|
|
|
|
func firstSchemaJSONDifferenceAt(path string, left, right any) (string, any, any, bool) {
|
|
leftMap, leftIsMap := left.(map[string]any)
|
|
rightMap, rightIsMap := right.(map[string]any)
|
|
if leftIsMap || rightIsMap {
|
|
if !leftIsMap || !rightIsMap {
|
|
return path, left, right, true
|
|
}
|
|
keys := make([]string, 0, len(leftMap)+len(rightMap))
|
|
seen := map[string]bool{}
|
|
for key := range leftMap {
|
|
seen[key] = true
|
|
keys = append(keys, key)
|
|
}
|
|
for key := range rightMap {
|
|
if !seen[key] {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
leftChild, leftOK := leftMap[key]
|
|
rightChild, rightOK := rightMap[key]
|
|
if !leftOK || !rightOK {
|
|
return path + "." + key, leftChild, rightChild, true
|
|
}
|
|
if childPath, childLeft, childRight, different := firstSchemaJSONDifferenceAt(path+"."+key, leftChild, rightChild); different {
|
|
return childPath, childLeft, childRight, true
|
|
}
|
|
}
|
|
return path, left, right, false
|
|
}
|
|
leftList, leftIsList := left.([]any)
|
|
rightList, rightIsList := right.([]any)
|
|
if leftIsList || rightIsList {
|
|
if !leftIsList || !rightIsList || len(leftList) != len(rightList) {
|
|
return path, left, right, true
|
|
}
|
|
for index := range leftList {
|
|
if childPath, childLeft, childRight, different := firstSchemaJSONDifferenceAt(fmt.Sprintf("%s[%d]", path, index), leftList[index], rightList[index]); different {
|
|
return childPath, childLeft, childRight, true
|
|
}
|
|
}
|
|
return path, left, right, false
|
|
}
|
|
if !reflect.DeepEqual(left, right) {
|
|
return path, left, right, true
|
|
}
|
|
return path, left, right, false
|
|
}
|
|
|
|
func compactSchemaDiagnosticValue(value any) string {
|
|
encoded, err := json.Marshal(value)
|
|
if err != nil {
|
|
return fmt.Sprintf("%v", value)
|
|
}
|
|
const limit = 240
|
|
if len(encoded) > limit {
|
|
return string(encoded[:limit]) + "..."
|
|
}
|
|
return string(encoded)
|
|
}
|
|
|
|
// schemaDeliveryInvariantErrors checks all projections available after the
|
|
// production loader boundary. Callers that already performed the round trip
|
|
// can use it without decoding the snapshot a second time.
|
|
func schemaDeliveryInvariantErrors(loaded loadedSchemaCatalog) []string {
|
|
problems := append([]string(nil), schemaRegistryProjectionErrors(loaded)...)
|
|
|
|
all, err := loaded.Registry.ToPayload()
|
|
if err != nil {
|
|
return sortedUniqueSchemaStrings(append(problems, fmt.Sprintf("render final Schema --all projection: %v", err)))
|
|
}
|
|
allTools, allProblems := schemaDeliveryToolsByCanonical(all, "Schema --all")
|
|
problems = append(problems, allProblems...)
|
|
catalogSummaries, summaryProblems := schemaDeliveryToolsByCanonical(loaded.Snapshot.Catalog, "Catalog summary")
|
|
problems = append(problems, summaryProblems...)
|
|
|
|
// The queryable Catalog must preserve the complete serialized Catalog,
|
|
// including source and metadata summaries. catalog_hash and surface_hash
|
|
// are the only allowed view-only fields because they belong to the release
|
|
// envelope rather than SchemaRegistry itself.
|
|
if list, queryErr := schemaPayloadFromLoadedCatalog(loaded, nil); queryErr != nil {
|
|
problems = append(problems, fmt.Sprintf("Schema list is not queryable: %v", queryErr))
|
|
} else {
|
|
if !schemaJSONEqual(schemaCatalogWithoutEnvelopeHashes(list), loaded.Snapshot.Catalog) {
|
|
problems = append(problems, "Schema list differs from complete Catalog content")
|
|
}
|
|
if got := strings.TrimSpace(schemaString(list["catalog_hash"])); got != loaded.Snapshot.SourceHash {
|
|
problems = append(problems, fmt.Sprintf("Schema list catalog_hash %q differs from snapshot source_hash %q", got, loaded.Snapshot.SourceHash))
|
|
}
|
|
gotSurface, hasSurface := list["surface_hash"]
|
|
if expected := strings.TrimSpace(loaded.Snapshot.SurfaceHash); expected == "" {
|
|
if hasSurface {
|
|
problems = append(problems, "Schema list has surface_hash but snapshot envelope does not")
|
|
}
|
|
} else if got := strings.TrimSpace(schemaString(gotSurface)); !hasSurface || got != expected {
|
|
problems = append(problems, fmt.Sprintf("Schema list surface_hash %q differs from snapshot surface_hash %q", got, expected))
|
|
}
|
|
}
|
|
if overview, overviewErr := loaded.Registry.ToOverviewPayload(); overviewErr != nil {
|
|
problems = append(problems, fmt.Sprintf("render final Schema product overview: %v", overviewErr))
|
|
} else if expectedOverview := schemaOverviewPayloadFromCatalog(loaded.Snapshot.Catalog); !schemaJSONEqual(overview, expectedOverview) {
|
|
problems = append(problems, "Schema list overview differs from complete Catalog content")
|
|
}
|
|
|
|
canonicals := loaded.Index.CanonicalPaths()
|
|
for _, canonical := range canonicals {
|
|
tool, ok := loaded.Index.Resolve(canonical)
|
|
if !ok {
|
|
problems = append(problems, fmt.Sprintf("typed Schema index lost canonical %q", canonical))
|
|
continue
|
|
}
|
|
expectedFull, renderErr := tool.ToPayload()
|
|
if renderErr != nil {
|
|
problems = append(problems, fmt.Sprintf("render final ToolSpec %s: %v", canonical, renderErr))
|
|
continue
|
|
}
|
|
storedFull, stored := loaded.Snapshot.Tools[canonical]
|
|
if !stored {
|
|
problems = append(problems, fmt.Sprintf("Catalog full tools are missing %s", canonical))
|
|
} else if !schemaJSONEqual(storedFull, expectedFull) {
|
|
problems = append(problems, fmt.Sprintf("Catalog full tool %s differs from final ToolSpec", canonical))
|
|
}
|
|
if allFull, exists := allTools[canonical]; !exists {
|
|
problems = append(problems, fmt.Sprintf("Schema --all is missing final ToolSpec %s", canonical))
|
|
} else if !schemaJSONEqual(allFull, expectedFull) {
|
|
problems = append(problems, fmt.Sprintf("Schema --all tool %s differs from final ToolSpec", canonical))
|
|
}
|
|
if leaf, queryErr := schemaPayloadFromLoadedCatalog(loaded, []string{canonical}); queryErr != nil {
|
|
problems = append(problems, fmt.Sprintf("canonical leaf %s is not queryable: %v", canonical, queryErr))
|
|
} else if !schemaJSONEqual(leaf, expectedFull) {
|
|
problems = append(problems, fmt.Sprintf("canonical leaf %s differs from final ToolSpec", canonical))
|
|
}
|
|
|
|
expectedSummary, summaryErr := tool.ToSummaryPayload()
|
|
if summaryErr != nil {
|
|
problems = append(problems, fmt.Sprintf("render final ToolSpec summary %s: %v", canonical, summaryErr))
|
|
} else if catalogSummary, exists := catalogSummaries[canonical]; !exists {
|
|
problems = append(problems, fmt.Sprintf("Catalog summary is missing final ToolSpec %s", canonical))
|
|
} else if !schemaJSONEqual(catalogSummary, expectedSummary) {
|
|
problems = append(problems, fmt.Sprintf("Catalog summary %s differs from final ToolSpec summary", canonical))
|
|
}
|
|
|
|
for _, rawAlias := range tool.Identity.Aliases {
|
|
alias := normalizeSchemaCLIPath(rawAlias)
|
|
if alias == "" {
|
|
continue
|
|
}
|
|
aliasPayload, queryErr := schemaPayloadFromLoadedCatalog(loaded, []string{alias})
|
|
if queryErr != nil {
|
|
problems = append(problems, fmt.Sprintf("alias %q for %s is not queryable: %v", alias, canonical, queryErr))
|
|
continue
|
|
}
|
|
if problem := schemaAliasViewProblem(expectedFull, aliasPayload, alias); problem != "" {
|
|
problems = append(problems, fmt.Sprintf("alias %q for %s %s", alias, canonical, problem))
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(allTools) != len(canonicals) {
|
|
problems = append(problems, fmt.Sprintf("Schema --all contains %d tools, typed index contains %d", len(allTools), len(canonicals)))
|
|
}
|
|
if len(catalogSummaries) != len(canonicals) {
|
|
problems = append(problems, fmt.Sprintf("Catalog summary contains %d tools, typed index contains %d", len(catalogSummaries), len(canonicals)))
|
|
}
|
|
if len(loaded.Snapshot.Tools) != len(canonicals) {
|
|
problems = append(problems, fmt.Sprintf("Catalog full tools contain %d tools, typed index contains %d", len(loaded.Snapshot.Tools), len(canonicals)))
|
|
}
|
|
return sortedUniqueSchemaStrings(problems)
|
|
}
|
|
|
|
// schemaOverviewPayloadFromCatalog independently projects the compact
|
|
// `schema list` view from the raw Catalog boundary. The production command
|
|
// renders its overview from the decoded SchemaRegistry, so comparing the two
|
|
// directions proves that list did not silently lose or re-resolve content.
|
|
func schemaOverviewPayloadFromCatalog(catalog map[string]any) map[string]any {
|
|
products := schemaMapSlice(catalog["products"])
|
|
overviewProducts := make([]map[string]any, 0, len(products))
|
|
toolCount := 0
|
|
for _, product := range products {
|
|
productToolCount := len(schemaMapSlice(product["tools"]))
|
|
entry := map[string]any{
|
|
"id": schemaString(product["id"]),
|
|
"tool_count": productToolCount,
|
|
"schema_path": schemaString(product["id"]),
|
|
}
|
|
switch {
|
|
case strings.TrimSpace(schemaString(product["agent_summary"])) != "":
|
|
entry["agent_summary"] = schemaString(product["agent_summary"])
|
|
case len(schemaStringSlice(product["use_when"])) > 0:
|
|
entry["use_when"] = []string{schemaStringSlice(product["use_when"])[0]}
|
|
case schemaString(product["description"]) != "":
|
|
entry["description"] = schemaString(product["description"])
|
|
}
|
|
overviewProducts = append(overviewProducts, entry)
|
|
toolCount += productToolCount
|
|
}
|
|
payload := map[string]any{
|
|
"kind": defaultString(schemaString(catalog["kind"]), "schema"),
|
|
"level": "products",
|
|
"count": len(overviewProducts),
|
|
"tool_count": toolCount,
|
|
"products": overviewProducts,
|
|
}
|
|
if source := schemaString(catalog["source"]); source != "" {
|
|
payload["source"] = source
|
|
}
|
|
for _, key := range []string{"interface_metadata", "agent_metadata"} {
|
|
if value, exists := catalog[key]; exists {
|
|
payload[key] = value
|
|
}
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func schemaCatalogWithoutEnvelopeHashes(payload map[string]any) map[string]any {
|
|
content := make(map[string]any, len(payload))
|
|
for key, value := range payload {
|
|
switch key {
|
|
case "catalog_hash", "surface_hash":
|
|
continue
|
|
default:
|
|
content[key] = value
|
|
}
|
|
}
|
|
return content
|
|
}
|
|
|
|
// schemaDeliveryToolsByCanonical extracts either full or summary tool payloads
|
|
// from a products/tools projection and reports duplicate or unkeyed entries.
|
|
func schemaDeliveryToolsByCanonical(payload map[string]any, view string) (map[string]map[string]any, []string) {
|
|
tools := map[string]map[string]any{}
|
|
var problems []string
|
|
for _, product := range schemaMapSlice(payload["products"]) {
|
|
for _, tool := range schemaMapSlice(product["tools"]) {
|
|
canonical := strings.TrimSpace(schemaString(tool["canonical_path"]))
|
|
if canonical == "" {
|
|
problems = append(problems, fmt.Sprintf("%s contains a tool without canonical_path", view))
|
|
continue
|
|
}
|
|
if _, exists := tools[canonical]; exists {
|
|
problems = append(problems, fmt.Sprintf("%s contains duplicate tool %s", view, canonical))
|
|
continue
|
|
}
|
|
tools[canonical] = tool
|
|
}
|
|
}
|
|
return tools, problems
|
|
}
|
|
|
|
// schemaAliasViewProblem enforces that resolving an alias is a view change,
|
|
// not a second ToolSpec. Only cli_path and is_alias may differ.
|
|
func schemaAliasViewProblem(canonical, alias map[string]any, expectedPath string) string {
|
|
if got := normalizeSchemaCLIPath(schemaString(alias["cli_path"])); got != normalizeSchemaCLIPath(expectedPath) {
|
|
return fmt.Sprintf("has cli_path %q, want %q", got, normalizeSchemaCLIPath(expectedPath))
|
|
}
|
|
if isAlias, ok := alias["is_alias"].(bool); !ok || !isAlias {
|
|
return "does not set is_alias=true"
|
|
}
|
|
canonicalContent := schemaPayloadWithoutAliasView(canonical)
|
|
aliasContent := schemaPayloadWithoutAliasView(alias)
|
|
if !schemaJSONEqual(canonicalContent, aliasContent) {
|
|
return "changes fields other than cli_path/is_alias"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func schemaPayloadWithoutAliasView(payload map[string]any) map[string]any {
|
|
copy := make(map[string]any, len(payload))
|
|
for key, value := range payload {
|
|
if key == "cli_path" || key == "is_alias" {
|
|
continue
|
|
}
|
|
copy[key] = value
|
|
}
|
|
return copy
|
|
}
|