818 lines
26 KiB
Go
818 lines
26 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
// Command schema-compat normalizes and checks the backwards-compatible
|
|
// execution contract returned by `dws schema --all --format json`.
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
const schemaContractVersion = 3
|
|
|
|
type schemaContract struct {
|
|
Version int `json:"version"`
|
|
Products map[string]productSchema `json:"products"`
|
|
}
|
|
|
|
type productSchema struct {
|
|
Tools map[string]toolSchema `json:"tools"`
|
|
}
|
|
|
|
type toolSchema struct {
|
|
PrimaryCLIPath string `json:"primary_cli_path"`
|
|
InterfaceMode string `json:"interface_mode"`
|
|
InterfaceRef string `json:"interface_ref,omitempty"`
|
|
Availability string `json:"availability"`
|
|
Parameters map[string]parameterSchema `json:"parameters"`
|
|
Constraints string `json:"constraints,omitempty"`
|
|
Positionals []positionalSchema `json:"positionals,omitempty"`
|
|
DryRun string `json:"dry_run,omitempty"`
|
|
Effect string `json:"effect"`
|
|
Risk string `json:"risk"`
|
|
Confirmation string `json:"confirmation"`
|
|
Idempotency string `json:"idempotency"`
|
|
}
|
|
|
|
type positionalSchema struct {
|
|
Name string `json:"name"`
|
|
Index int `json:"index"`
|
|
Type string `json:"type"`
|
|
Required bool `json:"required"`
|
|
Variadic bool `json:"variadic,omitempty"`
|
|
}
|
|
|
|
type parameterSchema struct {
|
|
Type string `json:"type"`
|
|
Property string `json:"property,omitempty"`
|
|
InterfaceType string `json:"interface_type,omitempty"`
|
|
Required bool `json:"required,omitempty"`
|
|
CLIRequired bool `json:"cli_required,omitempty"`
|
|
RequiredWhen string `json:"required_when,omitempty"`
|
|
Default string `json:"default,omitempty"`
|
|
InterfaceDefault string `json:"interface_default,omitempty"`
|
|
Format string `json:"format,omitempty"`
|
|
Enum []string `json:"enum,omitempty"`
|
|
}
|
|
|
|
func main() {
|
|
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
|
}
|
|
|
|
func run(args []string, stdout, stderr io.Writer) int {
|
|
var normalizePath, checkPath, mergePath, currentPath string
|
|
flags := flag.NewFlagSet("schema-compat", flag.ContinueOnError)
|
|
flags.SetOutput(stderr)
|
|
flags.StringVar(&normalizePath, "normalize", "", "normalize a raw complete Schema response")
|
|
flags.StringVar(&checkPath, "check", "", "check against a normalized historical baseline")
|
|
flags.StringVar(&mergePath, "merge", "", "merge additions into a normalized historical baseline")
|
|
flags.StringVar(¤tPath, "current", "", "raw current complete Schema response")
|
|
if err := flags.Parse(args); err != nil {
|
|
return 2
|
|
}
|
|
|
|
modes := 0
|
|
for _, value := range []string{normalizePath, checkPath, mergePath} {
|
|
if value != "" {
|
|
modes++
|
|
}
|
|
}
|
|
if modes != 1 {
|
|
fmt.Fprintln(stderr, "exactly one of --normalize, --check, or --merge is required")
|
|
return 2
|
|
}
|
|
|
|
if normalizePath != "" {
|
|
currentPath = normalizePath
|
|
}
|
|
if currentPath == "" {
|
|
fmt.Fprintln(stderr, "--current is required with --check or --merge")
|
|
return 2
|
|
}
|
|
current, err := normalizeRawFile(currentPath)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "normalize current Schema contract: %v\n", err)
|
|
return 2
|
|
}
|
|
|
|
switch {
|
|
case normalizePath != "":
|
|
if err := writeContract(stdout, current); err != nil {
|
|
fmt.Fprintf(stderr, "write schema contract: %v\n", err)
|
|
return 2
|
|
}
|
|
case checkPath != "":
|
|
baseline, err := readContract(checkPath)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "read schema baseline: %v\n", err)
|
|
return 2
|
|
}
|
|
failures := checkCompatibility(baseline, current)
|
|
if len(failures) > 0 {
|
|
fmt.Fprintln(stderr, "Schema backwards-compatibility check failed:")
|
|
for _, failure := range failures {
|
|
fmt.Fprintf(stderr, " - %s\n", failure)
|
|
}
|
|
return 1
|
|
}
|
|
fmt.Fprintf(stdout, "Schema compatibility check: ok (%d historical products; additions allowed)\n", len(baseline.Products))
|
|
case mergePath != "":
|
|
baseline, err := readContract(mergePath)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "read schema baseline: %v\n", err)
|
|
return 2
|
|
}
|
|
merged, failures := mergeContracts(baseline, current)
|
|
if len(failures) > 0 {
|
|
fmt.Fprintln(stderr, "cannot merge incompatible schema changes:")
|
|
for _, failure := range failures {
|
|
fmt.Fprintf(stderr, " - %s\n", failure)
|
|
}
|
|
return 1
|
|
}
|
|
if err := writeContract(stdout, merged); err != nil {
|
|
fmt.Fprintf(stderr, "write schema contract: %v\n", err)
|
|
return 2
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func normalizeRawFile(path string) (schemaContract, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return schemaContract{}, err
|
|
}
|
|
var payload struct {
|
|
Kind string `json:"kind"`
|
|
Products []json.RawMessage `json:"products"`
|
|
}
|
|
if err := json.Unmarshal(data, &payload); err != nil {
|
|
return schemaContract{}, err
|
|
}
|
|
if payload.Kind != "schema" {
|
|
return schemaContract{}, fmt.Errorf("unexpected kind %q", payload.Kind)
|
|
}
|
|
if payload.Products == nil {
|
|
return schemaContract{}, fmt.Errorf("products array is missing")
|
|
}
|
|
contract := schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{}}
|
|
for _, rawProduct := range payload.Products {
|
|
var product struct {
|
|
ID string `json:"id"`
|
|
Tools []json.RawMessage `json:"tools"`
|
|
}
|
|
if err := json.Unmarshal(rawProduct, &product); err != nil {
|
|
return schemaContract{}, err
|
|
}
|
|
if product.ID == "" {
|
|
return schemaContract{}, fmt.Errorf("product without id")
|
|
}
|
|
if _, exists := contract.Products[product.ID]; exists {
|
|
return schemaContract{}, fmt.Errorf("duplicate product id %q", product.ID)
|
|
}
|
|
normalized := productSchema{Tools: map[string]toolSchema{}}
|
|
for _, rawTool := range product.Tools {
|
|
id, tool, err := normalizeTool(rawTool)
|
|
if err != nil {
|
|
return schemaContract{}, fmt.Errorf("product %s: %w", product.ID, err)
|
|
}
|
|
if _, exists := normalized.Tools[id]; exists {
|
|
return schemaContract{}, fmt.Errorf("product %s: duplicate tool id %q", product.ID, id)
|
|
}
|
|
normalized.Tools[id] = tool
|
|
}
|
|
contract.Products[product.ID] = normalized
|
|
}
|
|
if len(contract.Products) == 0 {
|
|
return schemaContract{}, fmt.Errorf("complete Schema contract contains no products")
|
|
}
|
|
totalTools := 0
|
|
for _, product := range contract.Products {
|
|
totalTools += len(product.Tools)
|
|
}
|
|
if totalTools == 0 {
|
|
return schemaContract{}, fmt.Errorf("complete Schema contract contains no tools")
|
|
}
|
|
return contract, nil
|
|
}
|
|
|
|
func normalizeTool(raw json.RawMessage) (string, toolSchema, error) {
|
|
var fields map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &fields); err != nil {
|
|
return "", toolSchema{}, err
|
|
}
|
|
for _, field := range []string{
|
|
"canonical_path",
|
|
"primary_cli_path",
|
|
"parameters",
|
|
"effect",
|
|
"risk",
|
|
"confirmation",
|
|
"idempotency",
|
|
"interface_mode",
|
|
"availability",
|
|
"field_provenance",
|
|
} {
|
|
if _, ok := fields[field]; !ok {
|
|
return "", toolSchema{}, fmt.Errorf("tool is not a complete schema --all leaf: missing %s", field)
|
|
}
|
|
}
|
|
|
|
var tool struct {
|
|
CanonicalPath string `json:"canonical_path"`
|
|
PrimaryCLIPath string `json:"primary_cli_path"`
|
|
InterfaceMode string `json:"interface_mode"`
|
|
InterfaceRef json.RawMessage `json:"interface_ref"`
|
|
Availability string `json:"availability"`
|
|
Parameters map[string]json.RawMessage `json:"parameters"`
|
|
Required []string `json:"required"`
|
|
Constraints json.RawMessage `json:"constraints"`
|
|
Positionals json.RawMessage `json:"positionals"`
|
|
DryRun json.RawMessage `json:"dry_run"`
|
|
Effect string `json:"effect"`
|
|
Risk string `json:"risk"`
|
|
Confirmation string `json:"confirmation"`
|
|
Idempotency string `json:"idempotency"`
|
|
}
|
|
if err := json.Unmarshal(raw, &tool); err != nil {
|
|
return "", toolSchema{}, err
|
|
}
|
|
id := strings.TrimSpace(tool.CanonicalPath)
|
|
if id == "" {
|
|
return "", toolSchema{}, fmt.Errorf("tool without canonical_path")
|
|
}
|
|
if strings.TrimSpace(tool.PrimaryCLIPath) == "" {
|
|
return "", toolSchema{}, fmt.Errorf("tool %s without primary_cli_path", id)
|
|
}
|
|
if tool.Parameters == nil {
|
|
return "", toolSchema{}, fmt.Errorf("tool %s parameters must be an object", id)
|
|
}
|
|
requiredParameters := stringSet(tool.Required)
|
|
parameters := map[string]parameterSchema{}
|
|
for name, rawSchema := range tool.Parameters {
|
|
parameter, err := normalizeParameter(rawSchema)
|
|
if err != nil {
|
|
return "", toolSchema{}, fmt.Errorf("parameter %s: %w", name, err)
|
|
}
|
|
if requiredParameters[name] {
|
|
parameter.Required = true
|
|
}
|
|
parameters[name] = parameter
|
|
}
|
|
for required := range requiredParameters {
|
|
if _, ok := parameters[required]; !ok {
|
|
return "", toolSchema{}, fmt.Errorf("required parameter %q is missing", required)
|
|
}
|
|
}
|
|
|
|
interfaceRef, err := canonicalRawJSON(tool.InterfaceRef)
|
|
if err != nil {
|
|
return "", toolSchema{}, fmt.Errorf("interface_ref: %w", err)
|
|
}
|
|
constraints, err := canonicalRawJSON(tool.Constraints)
|
|
if err != nil {
|
|
return "", toolSchema{}, fmt.Errorf("constraints: %w", err)
|
|
}
|
|
positionals, err := normalizePositionals(tool.Positionals)
|
|
if err != nil {
|
|
return "", toolSchema{}, fmt.Errorf("positionals: %w", err)
|
|
}
|
|
dryRun, err := canonicalRawJSON(tool.DryRun)
|
|
if err != nil {
|
|
return "", toolSchema{}, fmt.Errorf("dry_run: %w", err)
|
|
}
|
|
|
|
return id, toolSchema{
|
|
PrimaryCLIPath: strings.TrimSpace(tool.PrimaryCLIPath),
|
|
InterfaceMode: strings.TrimSpace(tool.InterfaceMode),
|
|
InterfaceRef: interfaceRef,
|
|
Availability: strings.TrimSpace(tool.Availability),
|
|
Parameters: parameters,
|
|
Constraints: constraints,
|
|
Positionals: positionals,
|
|
DryRun: dryRun,
|
|
Effect: strings.TrimSpace(tool.Effect),
|
|
Risk: strings.TrimSpace(tool.Risk),
|
|
Confirmation: strings.TrimSpace(tool.Confirmation),
|
|
Idempotency: strings.TrimSpace(tool.Idempotency),
|
|
}, nil
|
|
}
|
|
|
|
func normalizePositionals(raw json.RawMessage) ([]positionalSchema, error) {
|
|
if len(bytes.TrimSpace(raw)) == 0 || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
|
return nil, nil
|
|
}
|
|
var positionals []positionalSchema
|
|
if err := json.Unmarshal(raw, &positionals); err != nil {
|
|
return nil, err
|
|
}
|
|
seenIndexes := map[int]bool{}
|
|
for index := range positionals {
|
|
positional := &positionals[index]
|
|
positional.Name = strings.TrimSpace(positional.Name)
|
|
positional.Type = strings.TrimSpace(positional.Type)
|
|
if positional.Name == "" {
|
|
return nil, fmt.Errorf("positional at index %d has no name", positional.Index)
|
|
}
|
|
if positional.Index < 0 {
|
|
return nil, fmt.Errorf("positional %q has negative index", positional.Name)
|
|
}
|
|
if positional.Type == "" {
|
|
return nil, fmt.Errorf("positional %q has no type", positional.Name)
|
|
}
|
|
if seenIndexes[positional.Index] {
|
|
return nil, fmt.Errorf("duplicate positional index %d", positional.Index)
|
|
}
|
|
seenIndexes[positional.Index] = true
|
|
}
|
|
sort.Slice(positionals, func(i, j int) bool {
|
|
return positionals[i].Index < positionals[j].Index
|
|
})
|
|
return positionals, nil
|
|
}
|
|
|
|
func normalizeParameter(raw json.RawMessage) (parameterSchema, error) {
|
|
var fields map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &fields); err != nil {
|
|
return parameterSchema{}, err
|
|
}
|
|
for _, field := range []string{"type", "required", "field_provenance"} {
|
|
if _, ok := fields[field]; !ok {
|
|
return parameterSchema{}, fmt.Errorf("not a complete schema --all parameter: missing %s", field)
|
|
}
|
|
}
|
|
|
|
var parameter struct {
|
|
Required bool `json:"required"`
|
|
CLIRequired bool `json:"cli_required"`
|
|
RequiredWhen string `json:"required_when"`
|
|
Property string `json:"property"`
|
|
InterfaceType string `json:"interface_type"`
|
|
Default json.RawMessage `json:"default"`
|
|
InterfaceDefault json.RawMessage `json:"interface_default"`
|
|
Format string `json:"format"`
|
|
Enum []string `json:"enum"`
|
|
}
|
|
if err := json.Unmarshal(raw, ¶meter); err != nil {
|
|
return parameterSchema{}, err
|
|
}
|
|
|
|
var schema map[string]any
|
|
if err := json.Unmarshal(raw, &schema); err != nil {
|
|
return parameterSchema{}, err
|
|
}
|
|
parameterType := schemaType(schema)
|
|
if parameterType == "unspecified" {
|
|
return parameterSchema{}, fmt.Errorf("type is missing")
|
|
}
|
|
defaultValue, err := canonicalRawJSON(parameter.Default)
|
|
if err != nil {
|
|
return parameterSchema{}, fmt.Errorf("default: %w", err)
|
|
}
|
|
interfaceDefault, err := canonicalRawJSON(parameter.InterfaceDefault)
|
|
if err != nil {
|
|
return parameterSchema{}, fmt.Errorf("interface_default: %w", err)
|
|
}
|
|
enum := append([]string(nil), parameter.Enum...)
|
|
sort.Strings(enum)
|
|
|
|
return parameterSchema{
|
|
Type: parameterType,
|
|
Property: strings.TrimSpace(parameter.Property),
|
|
InterfaceType: strings.TrimSpace(parameter.InterfaceType),
|
|
Required: parameter.Required,
|
|
CLIRequired: parameter.CLIRequired,
|
|
RequiredWhen: strings.TrimSpace(parameter.RequiredWhen),
|
|
Default: defaultValue,
|
|
InterfaceDefault: interfaceDefault,
|
|
Format: strings.TrimSpace(parameter.Format),
|
|
Enum: enum,
|
|
}, nil
|
|
}
|
|
|
|
func canonicalRawJSON(raw json.RawMessage) (string, error) {
|
|
if len(bytes.TrimSpace(raw)) == 0 {
|
|
return "", nil
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
decoder.UseNumber()
|
|
var value any
|
|
if err := decoder.Decode(&value); err != nil {
|
|
return "", err
|
|
}
|
|
encoded, err := json.Marshal(value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(encoded), nil
|
|
}
|
|
|
|
func schemaType(schema map[string]any) string {
|
|
if value, ok := schema["type"]; ok {
|
|
encoded, _ := json.Marshal(value)
|
|
return string(encoded)
|
|
}
|
|
for _, keyword := range []string{"oneOf", "anyOf", "allOf"} {
|
|
if value, ok := schema[keyword]; ok {
|
|
encoded, _ := json.Marshal(value)
|
|
return keyword + ":" + string(encoded)
|
|
}
|
|
}
|
|
return "unspecified"
|
|
}
|
|
|
|
func readContract(path string) (schemaContract, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return schemaContract{}, err
|
|
}
|
|
var contract schemaContract
|
|
if err := json.Unmarshal(data, &contract); err != nil {
|
|
return schemaContract{}, err
|
|
}
|
|
if contract.Version != schemaContractVersion {
|
|
return schemaContract{}, fmt.Errorf("unsupported schema contract version %d", contract.Version)
|
|
}
|
|
if len(contract.Products) == 0 {
|
|
return schemaContract{}, fmt.Errorf("historical schema contract contains no products")
|
|
}
|
|
return contract, nil
|
|
}
|
|
|
|
func checkCompatibility(baseline, current schemaContract) []string {
|
|
var failures []string
|
|
for productID, oldProduct := range baseline.Products {
|
|
newProduct, ok := current.Products[productID]
|
|
if !ok {
|
|
failures = append(failures, fmt.Sprintf("historical schema product %q is missing", productID))
|
|
continue
|
|
}
|
|
for toolID, oldTool := range oldProduct.Tools {
|
|
newTool, ok := newProduct.Tools[toolID]
|
|
if !ok {
|
|
failures = append(failures, fmt.Sprintf("historical schema tool %q is missing", productID+"/"+toolID))
|
|
continue
|
|
}
|
|
toolPath := productID + "/" + toolID
|
|
failures = append(failures, checkToolCompatibility(toolPath, oldTool, newTool)...)
|
|
}
|
|
}
|
|
sort.Strings(failures)
|
|
return failures
|
|
}
|
|
|
|
func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []string {
|
|
var failures []string
|
|
for _, field := range []struct {
|
|
name string
|
|
old string
|
|
new string
|
|
}{
|
|
{name: "primary_cli_path", old: oldTool.PrimaryCLIPath, new: newTool.PrimaryCLIPath},
|
|
{name: "interface_mode", old: oldTool.InterfaceMode, new: newTool.InterfaceMode},
|
|
{name: "interface_ref", old: oldTool.InterfaceRef, new: newTool.InterfaceRef},
|
|
{name: "availability", old: oldTool.Availability, new: newTool.Availability},
|
|
{name: "effect", old: oldTool.Effect, new: newTool.Effect},
|
|
{name: "risk", old: oldTool.Risk, new: newTool.Risk},
|
|
{name: "confirmation", old: oldTool.Confirmation, new: newTool.Confirmation},
|
|
{name: "idempotency", old: oldTool.Idempotency, new: newTool.Idempotency},
|
|
} {
|
|
if field.old != field.new {
|
|
failures = append(failures, fmt.Sprintf("schema tool %q changed %s", toolPath, field.name))
|
|
}
|
|
}
|
|
if oldTool.Constraints != newTool.Constraints &&
|
|
!compatibleHiddenSiblingConstraintExpansion(oldTool, newTool) &&
|
|
!compatibleAdditiveConstraintEvolution(oldTool, newTool) {
|
|
failures = append(failures, fmt.Sprintf("schema tool %q changed constraints", toolPath))
|
|
}
|
|
if !compatiblePositionals(oldTool.Positionals, newTool.Positionals) {
|
|
failures = append(failures, fmt.Sprintf("schema tool %q changed positionals", toolPath))
|
|
}
|
|
if oldTool.DryRun != "" && oldTool.DryRun != newTool.DryRun {
|
|
failures = append(failures, fmt.Sprintf("schema tool %q changed or removed dry_run", toolPath))
|
|
}
|
|
|
|
for parameter, oldParameter := range oldTool.Parameters {
|
|
newParameter, ok := newTool.Parameters[parameter]
|
|
if !ok {
|
|
failures = append(failures, fmt.Sprintf("schema tool %q lost parameter %q", toolPath, parameter))
|
|
continue
|
|
}
|
|
failures = append(failures, checkParameterCompatibility(toolPath, parameter, oldParameter, newParameter)...)
|
|
}
|
|
sort.Strings(failures)
|
|
return failures
|
|
}
|
|
|
|
// compatibleAdditiveConstraintEvolution accepts constraint evolution that
|
|
// cannot invalidate an invocation expressible by the historical public
|
|
// parameter contract. Existing groups may only gain members; additions to a
|
|
// mutually-exclusive or require-together group must not be historical public
|
|
// parameters, because that would reject an invocation expressible by the old
|
|
// contract. Adding a member to require-one-of only loosens the group. A newly
|
|
// added mutually-exclusive group is safe when it contains at most one
|
|
// historical public parameter: aliases and newly added parameters could not
|
|
// have appeared together in an old invocation. A new require-together group is
|
|
// safe only when it contains no historical public parameter. A new
|
|
// require-one-of group always adds a requirement and is therefore incompatible.
|
|
func compatibleAdditiveConstraintEvolution(oldTool, newTool toolSchema) bool {
|
|
oldGroups, okOld := parseConstraintGroups(oldTool.Constraints)
|
|
newGroups, okNew := parseConstraintGroups(newTool.Constraints)
|
|
if !okOld || !okNew {
|
|
return false
|
|
}
|
|
for _, key := range []string{"mutually_exclusive", "require_one_of", "require_together"} {
|
|
used := make([]bool, len(newGroups[key]))
|
|
for _, oldGroup := range oldGroups[key] {
|
|
oldSet := stringSet(oldGroup)
|
|
if len(oldSet) == 0 {
|
|
return false
|
|
}
|
|
matched := false
|
|
for index, newGroup := range newGroups[key] {
|
|
newSet := stringSet(newGroup)
|
|
if used[index] || !stringSetContainsAll(newSet, oldSet) {
|
|
continue
|
|
}
|
|
if key == "mutually_exclusive" || key == "require_together" {
|
|
safe := true
|
|
for member := range newSet {
|
|
if oldSet[member] {
|
|
continue
|
|
}
|
|
if _, historical := oldTool.Parameters[member]; historical {
|
|
safe = false
|
|
break
|
|
}
|
|
}
|
|
if !safe {
|
|
continue
|
|
}
|
|
}
|
|
used[index] = true
|
|
matched = true
|
|
break
|
|
}
|
|
if !matched {
|
|
return false
|
|
}
|
|
}
|
|
for index, newGroup := range newGroups[key] {
|
|
if used[index] {
|
|
continue
|
|
}
|
|
historicalMembers := 0
|
|
for member := range stringSet(newGroup) {
|
|
if _, existed := oldTool.Parameters[member]; existed {
|
|
historicalMembers++
|
|
}
|
|
}
|
|
switch key {
|
|
case "mutually_exclusive":
|
|
if historicalMembers > 1 {
|
|
return false
|
|
}
|
|
case "require_together":
|
|
if historicalMembers > 0 {
|
|
return false
|
|
}
|
|
default: // require_one_of
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// compatibleHiddenSiblingConstraintExpansion allows declare≡execute repairs:
|
|
// Schema may start projecting full constraint groups that include unpublished
|
|
// (hidden) execute-side siblings when the previous contract collapsed the sole
|
|
// published member to required and omitted constraints.
|
|
func compatibleHiddenSiblingConstraintExpansion(oldTool, newTool toolSchema) bool {
|
|
if strings.TrimSpace(oldTool.Constraints) != "" {
|
|
return false
|
|
}
|
|
var projected struct {
|
|
MutuallyExclusive [][]string `json:"mutually_exclusive"`
|
|
RequireOneOf [][]string `json:"require_one_of"`
|
|
RequireTogether [][]string `json:"require_together"`
|
|
}
|
|
if err := json.Unmarshal([]byte(newTool.Constraints), &projected); err != nil {
|
|
return false
|
|
}
|
|
if len(projected.RequireTogether) > 0 || len(projected.RequireOneOf) == 0 {
|
|
return false
|
|
}
|
|
groups := append([][]string(nil), projected.RequireOneOf...)
|
|
groups = append(groups, projected.MutuallyExclusive...)
|
|
for _, group := range groups {
|
|
if len(group) < 2 {
|
|
return false
|
|
}
|
|
published := 0
|
|
hidden := 0
|
|
for _, name := range group {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return false
|
|
}
|
|
if _, ok := newTool.Parameters[name]; ok {
|
|
published++
|
|
} else {
|
|
hidden++
|
|
}
|
|
}
|
|
if published == 0 || hidden == 0 {
|
|
return false
|
|
}
|
|
// Former collapse artifact: exactly one published member was required.
|
|
if published == 1 {
|
|
var sole string
|
|
for _, name := range group {
|
|
if _, ok := newTool.Parameters[name]; ok {
|
|
sole = name
|
|
break
|
|
}
|
|
}
|
|
oldParam, ok := oldTool.Parameters[sole]
|
|
newParam, okNew := newTool.Parameters[sole]
|
|
if !ok || !okNew || !oldParam.Required || newParam.Required {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func parseConstraintGroups(raw string) (map[string][][]string, bool) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return map[string][][]string{}, true
|
|
}
|
|
var projected struct {
|
|
MutuallyExclusive [][]string `json:"mutually_exclusive"`
|
|
RequireOneOf [][]string `json:"require_one_of"`
|
|
RequireTogether [][]string `json:"require_together"`
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &projected); err != nil {
|
|
return nil, false
|
|
}
|
|
return map[string][][]string{
|
|
"mutually_exclusive": projected.MutuallyExclusive,
|
|
"require_one_of": projected.RequireOneOf,
|
|
"require_together": projected.RequireTogether,
|
|
}, true
|
|
}
|
|
|
|
func stringSetContainsAll(superset, subset map[string]bool) bool {
|
|
for value := range subset {
|
|
if !superset[value] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func compatiblePositionals(oldPositionals, newPositionals []positionalSchema) bool {
|
|
if len(newPositionals) < len(oldPositionals) {
|
|
return false
|
|
}
|
|
for index, oldPositional := range oldPositionals {
|
|
newPositional := newPositionals[index]
|
|
if oldPositional.Name != newPositional.Name ||
|
|
oldPositional.Index != newPositional.Index ||
|
|
oldPositional.Type != newPositional.Type {
|
|
return false
|
|
}
|
|
if !oldPositional.Required && newPositional.Required {
|
|
return false
|
|
}
|
|
if oldPositional.Variadic && !newPositional.Variadic {
|
|
return false
|
|
}
|
|
if !oldPositional.Variadic && newPositional.Variadic && index != len(newPositionals)-1 {
|
|
return false
|
|
}
|
|
}
|
|
|
|
if len(newPositionals) == len(oldPositionals) {
|
|
return true
|
|
}
|
|
if len(oldPositionals) > 0 && newPositionals[len(oldPositionals)-1].Variadic {
|
|
return false
|
|
}
|
|
for index := len(oldPositionals); index < len(newPositionals); index++ {
|
|
if newPositionals[index].Required {
|
|
return false
|
|
}
|
|
if index > len(oldPositionals) && newPositionals[index-1].Variadic {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func checkParameterCompatibility(toolPath, name string, oldParameter, newParameter parameterSchema) []string {
|
|
var failures []string
|
|
for _, field := range []struct {
|
|
name string
|
|
old string
|
|
new string
|
|
}{
|
|
{name: "type", old: oldParameter.Type, new: newParameter.Type},
|
|
{name: "property", old: oldParameter.Property, new: newParameter.Property},
|
|
{name: "default", old: oldParameter.Default, new: newParameter.Default},
|
|
{name: "interface_default", old: oldParameter.InterfaceDefault, new: newParameter.InterfaceDefault},
|
|
{name: "format", old: oldParameter.Format, new: newParameter.Format},
|
|
} {
|
|
if field.old != field.new {
|
|
failures = append(failures, fmt.Sprintf("schema tool %q parameter %q changed %s", toolPath, name, field.name))
|
|
}
|
|
}
|
|
// Clearing interface_type is accepted as compatible: a deliberate,
|
|
// wire-visible policy decision taken with the pinned MCP metadata
|
|
// retirement. Production no longer projects MCP-sourced types unless
|
|
// ParamDecl declares them, so unverifiable pinned values are dropped
|
|
// rather than kept. Consumers that used interface_type for coercion must
|
|
// treat a missing value as "unknown" — re-populating a value requires an
|
|
// explicit ParamDecl declaration, not a new pin. Changing to a different
|
|
// non-empty value remains a contract break.
|
|
if oldParameter.InterfaceType != newParameter.InterfaceType &&
|
|
!(oldParameter.InterfaceType != "" && newParameter.InterfaceType == "") {
|
|
failures = append(failures, fmt.Sprintf("schema tool %q parameter %q changed interface_type", toolPath, name))
|
|
}
|
|
if !oldParameter.Required && newParameter.Required {
|
|
failures = append(failures, fmt.Sprintf("schema tool %q made parameter %q newly required", toolPath, name))
|
|
}
|
|
if !oldParameter.CLIRequired && newParameter.CLIRequired {
|
|
failures = append(failures, fmt.Sprintf("schema tool %q made parameter %q newly cli_required", toolPath, name))
|
|
}
|
|
if oldParameter.RequiredWhen != newParameter.RequiredWhen && newParameter.RequiredWhen != "" {
|
|
failures = append(failures, fmt.Sprintf("schema tool %q parameter %q changed required_when", toolPath, name))
|
|
}
|
|
if enumNarrowed(oldParameter.Enum, newParameter.Enum) {
|
|
failures = append(failures, fmt.Sprintf("schema tool %q parameter %q narrowed enum", toolPath, name))
|
|
}
|
|
sort.Strings(failures)
|
|
return failures
|
|
}
|
|
|
|
func enumNarrowed(oldValues, newValues []string) bool {
|
|
if len(oldValues) == 0 {
|
|
return len(newValues) > 0
|
|
}
|
|
if len(newValues) == 0 {
|
|
return false
|
|
}
|
|
current := stringSet(newValues)
|
|
for _, value := range oldValues {
|
|
if !current[value] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func mergeContracts(historical, current schemaContract) (schemaContract, []string) {
|
|
failures := checkCompatibility(historical, current)
|
|
if len(failures) > 0 {
|
|
return cloneContract(historical), failures
|
|
}
|
|
return cloneContract(current), nil
|
|
}
|
|
|
|
func cloneContract(source schemaContract) schemaContract {
|
|
data, _ := json.Marshal(source)
|
|
var cloned schemaContract
|
|
_ = json.Unmarshal(data, &cloned)
|
|
return cloned
|
|
}
|
|
|
|
func writeContract(w io.Writer, contract schemaContract) error {
|
|
contract.Version = schemaContractVersion
|
|
if contract.Products == nil {
|
|
contract.Products = map[string]productSchema{}
|
|
}
|
|
encoder := json.NewEncoder(w)
|
|
encoder.SetIndent("", " ")
|
|
return encoder.Encode(contract)
|
|
}
|
|
|
|
func stringSet(values []string) map[string]bool {
|
|
set := make(map[string]bool, len(values))
|
|
for _, value := range values {
|
|
set[value] = true
|
|
}
|
|
return set
|
|
}
|