Files

2044 lines
73 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"
"reflect"
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
)
const schemaContractVersion = 3
// propertySourceReviewedMappingExclusion is the provenance source a parameter
// reports when its property is deliberately omitted through the reviewed
// mapping exclusion table (internal/cli/schema_parameter_mapping_ledger.go).
// Only this source qualifies for the property-clearing carve-out in
// checkParameterCompatibility.
const propertySourceReviewedMappingExclusion = "reviewed_mapping_exclusion"
// interfaceModeMCP is the interface_mode of a tool backed by exactly one RPC.
// Only a tool that is mcp on both sides can qualify for the interface_ref
// redirect carve-out in compatibleInterfaceRefRedirect.
const interfaceModeMCP = "mcp"
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"`
PropertySource string `json:"property_source,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
var approvedFlagMigrationsPath, candidateFlagMigrationsPath string
var approvedCommandMigrationsPath, candidateCommandMigrationsPath string
var migrationCurrentSnapshotPath, migrationBaseSnapshotPath, migrationStableSnapshotPath 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(&currentPath, "current", "", "raw current complete Schema response")
flags.StringVar(&approvedFlagMigrationsPath, "approved-flag-migrations", "", "base-owned approved flag migration manifest")
flags.StringVar(&candidateFlagMigrationsPath, "candidate-flag-migrations", "", "detached candidate flag migration manifest")
flags.StringVar(&approvedCommandMigrationsPath, "approved-command-migrations", "", "base-owned approved command migration manifest")
flags.StringVar(&candidateCommandMigrationsPath, "candidate-command-migrations", "", "detached candidate command migration manifest")
flags.StringVar(&migrationCurrentSnapshotPath, "migration-current-snapshot", "", "current interface snapshot used for migration authorization")
flags.StringVar(&migrationBaseSnapshotPath, "migration-base-snapshot", "", "merge-base interface snapshot used for migration authorization")
flags.StringVar(&migrationStableSnapshotPath, "migration-stable-snapshot", "", "stable interface snapshot used for migration authorization")
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
}
flagMigrationPair := approvedFlagMigrationsPath != "" || candidateFlagMigrationsPath != ""
commandMigrationPair := approvedCommandMigrationsPath != "" || candidateCommandMigrationsPath != ""
if flagMigrationPair && (approvedFlagMigrationsPath == "" || candidateFlagMigrationsPath == "") {
fmt.Fprintln(stderr, "Schema flag migration authorization requires both flag manifests")
return 2
}
if commandMigrationPair && (approvedCommandMigrationsPath == "" || candidateCommandMigrationsPath == "") {
fmt.Fprintln(stderr, "Schema command migration authorization requires both command manifests")
return 2
}
migrationSnapshots := []string{
migrationCurrentSnapshotPath,
migrationBaseSnapshotPath,
migrationStableSnapshotPath,
}
migrationSnapshotCount := 0
for _, path := range migrationSnapshots {
if path != "" {
migrationSnapshotCount++
}
}
migrationsEnabled := flagMigrationPair || commandMigrationPair
if migrationsEnabled && migrationSnapshotCount != len(migrationSnapshots) {
fmt.Fprintln(stderr, "Schema migration authorization requires all three interface snapshots")
return 2
}
if !migrationsEnabled && migrationSnapshotCount != 0 {
fmt.Fprintln(stderr, "Schema migration snapshots require a flag or command migration manifest pair")
return 2
}
if migrationsEnabled && checkPath == "" {
fmt.Fprintln(stderr, "Schema migration authorization is only valid with --check")
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
}
if flagMigrationPair {
migrations, err := authorizeSchemaFlagMigrations(
approvedFlagMigrationsPath,
candidateFlagMigrationsPath,
migrationCurrentSnapshotPath,
migrationBaseSnapshotPath,
migrationStableSnapshotPath,
)
if err != nil {
fmt.Fprintf(stderr, "authorize Schema flag migrations: %v\n", err)
return 2
}
baseline, err = normalizeSchemaFlagMigrations(baseline, current, migrations)
if err != nil {
fmt.Fprintf(stderr, "normalize approved Schema flag migrations: %v\n", err)
return 2
}
}
if commandMigrationPair {
migrations, err := authorizeSchemaCommandMigrations(
approvedCommandMigrationsPath,
candidateCommandMigrationsPath,
migrationCurrentSnapshotPath,
migrationBaseSnapshotPath,
migrationStableSnapshotPath,
)
if err != nil {
fmt.Fprintf(stderr, "authorize Schema command migrations: %v\n", err)
return 2
}
baseline, err = normalizeSchemaCommandMigrations(baseline, current, migrations)
if err != nil {
fmt.Fprintf(stderr, "normalize approved Schema command migrations: %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 authorizeSchemaFlagMigrations(
approvedManifestPath string,
candidateManifestPath string,
currentSnapshotPath string,
baseSnapshotPath string,
stableSnapshotPath string,
) ([]interfacesnapshot.FlagMigration, error) {
approved, err := readFlagMigrationManifestFile(approvedManifestPath)
if err != nil {
return nil, fmt.Errorf("read approved flag migrations: %w", err)
}
candidate, err := readFlagMigrationManifestFile(candidateManifestPath)
if err != nil {
return nil, fmt.Errorf("read candidate flag migrations: %w", err)
}
current, err := readInterfaceSnapshotFile(currentSnapshotPath)
if err != nil {
return nil, fmt.Errorf("read migration current snapshot: %w", err)
}
base, err := readInterfaceSnapshotFile(baseSnapshotPath)
if err != nil {
return nil, fmt.Errorf("read migration base snapshot: %w", err)
}
stable, err := readInterfaceSnapshotFile(stableSnapshotPath)
if err != nil {
return nil, fmt.Errorf("read migration stable snapshot: %w", err)
}
return interfacesnapshot.AuthorizeFlagMigrations(
current,
map[string]interfacesnapshot.Snapshot{"merge-base": base, "stable": stable},
approved,
candidate,
)
}
func readFlagMigrationManifestFile(path string) (interfacesnapshot.FlagMigrationManifest, error) {
data, err := os.ReadFile(path)
if err != nil {
return interfacesnapshot.FlagMigrationManifest{}, err
}
return interfacesnapshot.ReadFlagMigrationManifest(bytes.NewReader(data))
}
func authorizeSchemaCommandMigrations(
approvedManifestPath string,
candidateManifestPath string,
currentSnapshotPath string,
baseSnapshotPath string,
stableSnapshotPath string,
) ([]interfacesnapshot.CommandMigration, error) {
approved, err := readCommandMigrationManifestFile(approvedManifestPath)
if err != nil {
return nil, fmt.Errorf("read approved command migrations: %w", err)
}
candidate, err := readCommandMigrationManifestFile(candidateManifestPath)
if err != nil {
return nil, fmt.Errorf("read candidate command migrations: %w", err)
}
current, err := readInterfaceSnapshotFile(currentSnapshotPath)
if err != nil {
return nil, fmt.Errorf("read migration current snapshot: %w", err)
}
base, err := readInterfaceSnapshotFile(baseSnapshotPath)
if err != nil {
return nil, fmt.Errorf("read migration base snapshot: %w", err)
}
stable, err := readInterfaceSnapshotFile(stableSnapshotPath)
if err != nil {
return nil, fmt.Errorf("read migration stable snapshot: %w", err)
}
return interfacesnapshot.AuthorizeCommandMigrations(
current,
map[string]interfacesnapshot.Snapshot{"merge-base": base, "stable": stable},
approved,
candidate,
)
}
func readCommandMigrationManifestFile(path string) (interfacesnapshot.CommandMigrationManifest, error) {
data, err := os.ReadFile(path)
if err != nil {
return interfacesnapshot.CommandMigrationManifest{}, err
}
return interfacesnapshot.ReadCommandMigrationManifest(bytes.NewReader(data))
}
func readInterfaceSnapshotFile(path string) (interfacesnapshot.Snapshot, error) {
data, err := os.ReadFile(path)
if err != nil {
return interfacesnapshot.Snapshot{}, err
}
return interfacesnapshot.Read(bytes.NewReader(data))
}
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"`
FieldProvenance struct {
Property struct {
Source string `json:"source"`
} `json:"property"`
} `json:"field_provenance"`
}
if err := json.Unmarshal(raw, &parameter); 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),
PropertySource: strings.TrimSpace(parameter.FieldProvenance.Property.Source),
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: "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) &&
!compatibleReviewedConstraintTransition(toolPath, 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)...)
}
// interface_ref is evaluated last, because the redirect carve-out below is
// conditional on every other check for this tool having passed.
if oldTool.InterfaceRef != newTool.InterfaceRef &&
!compatibleInterfaceRefRedirect(toolPath, oldTool, newTool, failures) {
failures = append(failures, fmt.Sprintf("schema tool %q changed interface_ref", toolPath))
}
sort.Strings(failures)
return failures
}
// reviewedInterfaceRefRedirect enumerates the exact, individually reviewed
// backend RPC migrations this gate accepts. Schema shape alone cannot prove two
// RPCs share business semantics, permissions, error behaviour, or side effects,
// so a redirect is only accepted when the specific tool and the specific
// old→new pair appear here. Any other ref change is still reported.
//
// Keyed by tool path ("<product>/<tool id>"), then by the previous
// interface_ref, with the value being the single accepted new ref. Both refs are
// the canonicalized interface_ref JSON exactly as parseTool produces it (see
// canonicalRawJSON) — not a bare RPC name. Adding an entry is a contract
// decision and belongs in review, not in a feature change.
var reviewedInterfaceRefRedirect = map[string]map[string]string{
// The style surface moved from update_range (which writes values) to
// set_cell_range's cellStyles payload (style-only, preserving values). This
// is the only channel that can express italic / underline / strike-through /
// font family / borders. Same product, same target range semantics, same
// permission scope; the flat style properties it loses are accepted
// separately as reviewed mapping exclusions.
"sheet/sheet.range_set_style": {
`{"product_id":"sheet","rpc_name":"update_range"}`: `{"product_id":"sheet","rpc_name":"set_cell_range"}`,
},
}
// reviewedConstraintTransition enumerates exact constraint changes that were
// explicitly reviewed even though the compatibility gate cannot infer their
// business semantics. Keep this narrow: removing a constraint can expose a new
// runtime route, so arbitrary removals must not pass as harmless drift.
var reviewedConstraintTransition = map[string]map[string]string{
// PR #933 aligned the Shortcut and Schema with the existing doc import
// runtime: omitting both targets imports into the default root. Keeping the
// historical require_one_of made the documented Golden Route unreachable.
"doc/doc.shortcut_import": {
`{"require_one_of":[["folder","workspace"]]}`: "",
},
}
func compatibleReviewedConstraintTransition(toolPath string, oldTool, newTool toolSchema) bool {
transitions, ok := reviewedConstraintTransition[toolPath]
if !ok {
return false
}
want, ok := transitions[oldTool.Constraints]
if !ok {
return false
}
return want == newTool.Constraints
}
// compatibleInterfaceRefRedirect accepts repointing a tool at a different
// backing RPC when the migration is an explicitly reviewed entry in
// reviewedInterfaceRefRedirect **and** the CLI-facing contract is provably
// unchanged.
//
// interface_ref is audit and traceability metadata: it records which RPC backs a
// leaf. Nothing reads it at runtime — the tool a leaf invokes is decided in the
// CLI source, so a stale ref does not misroute a call, it only misinforms a
// reader. When the backing RPC genuinely moves, the honest options are to update
// the ref or to keep publishing a name that no longer matches the request being
// sent.
//
// Being audit-only is why a reviewed redirect can be accepted at all; it is not
// a reason to accept redirects in general. Two RPCs with compatible Schema
// parameters may still differ in permissions, quota, error taxonomy, or side
// effects, none of which this gate can see. Hence the allowlist below, plus:
//
// - interface_mode is unchanged and stays "mcp". A move to or from
// "composite" is a change in kind, not a redirect, and is still reported.
// - both refs are non-empty. Removing a ref is not a redirect.
// - no other compatibility failure was recorded for this tool. This is the
// operative meaning of "the CLI contract is unchanged": no parameter was
// lost, none became required, no type / default / format / enum moved, no
// constraint tightened, no positional or dry_run change. Any one of those
// re-reports the redirect, so the exemption cannot smuggle a surface change
// in behind a backend move.
//
// A cleared property that resolved through a reviewed mapping exclusion is
// already accepted by checkParameterCompatibility and so does not block this;
// that pairing is expected, since a leaf moving to a nested payload loses its
// flat property names in the same change.
func compatibleInterfaceRefRedirect(toolPath string, oldTool, newTool toolSchema, otherFailures []string) bool {
if oldTool.InterfaceMode != newTool.InterfaceMode || newTool.InterfaceMode != interfaceModeMCP {
return false
}
if oldTool.InterfaceRef == "" || newTool.InterfaceRef == "" {
return false
}
if reviewedInterfaceRefRedirect[toolPath][oldTool.InterfaceRef] != newTool.InterfaceRef {
return false
}
return len(otherFailures) == 0
}
// 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
}
// parameterTypeChange identifies one published parameter type migration
// exactly: which tool, which parameter, and in which direction. Nothing is
// matched by wildcard — an entry differing in any of the four fields does not
// apply.
//
// ToolPath is "<product id>/<tool id>" exactly as the comparison builds it (see
// where toolPath is assembled), e.g.
// "minutes/minutes.apply_minutes_permission". From and To are the canonical
// type values schemaType produces — the JSON encoding of the "type" keyword,
// so `"string"` **with** its quotes, not a bare string. Spelling either one any
// other way silently disables the exemption instead of failing loudly (exactly
// how reviewedInterfaceRefRedirect was broken twice), so
// TestCrossPlatformCoverageReviewedParameterTypeChangeKeysAreCanonical
// recomputes both through schemaType rather than trusting the table.
type parameterTypeChange struct {
ToolPath string
Parameter string
From string
To string
}
// reviewedParameterTypeChanges enumerates the individually reviewed parameter
// type migrations this gate accepts. A published type is part of the Schema
// contract and a swap cannot be proven safe from the type names alone, so a
// migration is accepted only when this exact tool, parameter and old→new pair
// appear here. Adding an entry is a contract decision and belongs in review,
// not in a feature change.
//
// Entries are direction-sensitive by construction: "string" → "integer" is a
// separate key from "integer" → "string", and only the reviewed direction is
// accepted.
//
// The table alone does not admit a change: the migration is rejected unless
// every other published field of the parameter is identical — not merely free
// of compatibility failures. See compatibleReviewedParameterTypeChange.
var reviewedParameterTypeChanges = map[parameterTypeChange]struct{}{
// "dws minutes permission apply --policy" moved from a String flag to a
// native Int flag, so the published type follows it from "string" to
// "integer". A parameter's type is projected from the Cobra flag type
// (provenance source cobra_flag_type), so it describes how the CLI accepts
// the value, not how the backing RPC receives it.
//
// Accepted because no historical caller is invalidated:
//
// - Consumers read this Schema to construct CLI invocations, and a command
// line is text either way: "--policy 4" is the same argv under both
// declarations, and a quoted "--policy \"4\"" still reaches pflag as 4.
// - The flag keeps enforcing the same [2,4] domain in RunE, and pflag's
// base-0 parse accepts every base-10 spelling the previous
// strconv.ParseInt(v, 10, 64) accepted, so the set of successful
// invocations only widens.
// - "integer" also matches what actually goes on the wire: this parameter
// maps to property "policyId", which the command has always sent as a
// number, so the new declaration is closer to the request than the old.
//
// The parameter's default stays absent on both sides here (a zero default is
// not published) and every other published field is identical, which is why
// the equality guard admits this entry. A migration that also moved the
// default — or relaxed required, cleared required_when, widened enum, cleared
// interface_type — would be rejected even though several of those are
// individually compatible, because none of them were reviewed under this
// entry.
{
ToolPath: "minutes/minutes.apply_minutes_permission",
Parameter: "policy",
From: `"string"`,
To: `"integer"`,
}: {},
}
// parameterContractUnchangedExceptType reports whether every published field of
// the parameter other than its type is identical.
//
// This is a full equality check rather than "the gate recorded no other
// failure", because several field changes are individually *compatible* and so
// produce no failure at all: relaxing required or cli_required, clearing
// required_when, widening enum, clearing interface_type, and clearing property
// through a reviewed mapping exclusion. Keying the carve-out on the failure list
// would let any of those ride along with a reviewed type migration without ever
// having been reviewed under that entry — the exemption would be wider than what
// it documents.
//
// Comparing the struct also means a field added to parameterSchema later is
// covered here automatically, instead of silently widening every existing entry.
func parameterContractUnchangedExceptType(oldParameter, newParameter parameterSchema) bool {
oldParameter.Type = ""
newParameter.Type = ""
return reflect.DeepEqual(oldParameter, newParameter)
}
// compatibleReviewedParameterTypeChange accepts a published parameter type
// migration when it is an explicitly reviewed entry **and** the rest of the
// parameter's published contract is unchanged.
func compatibleReviewedParameterTypeChange(toolPath, name string, oldParameter, newParameter parameterSchema) bool {
_, reviewed := reviewedParameterTypeChanges[parameterTypeChange{
ToolPath: toolPath,
Parameter: name,
From: oldParameter.Type,
To: newParameter.Type,
}]
return reviewed && parameterContractUnchangedExceptType(oldParameter, newParameter)
}
func checkParameterCompatibility(toolPath, name string, oldParameter, newParameter parameterSchema) []string {
var failures []string
if oldParameter.Type != newParameter.Type &&
!compatibleReviewedParameterTypeChange(toolPath, name, oldParameter, newParameter) {
failures = append(failures, fmt.Sprintf("schema tool %q parameter %q changed type", toolPath, name))
}
for _, field := range []struct {
name string
old string
new string
}{
{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 property is accepted as compatible only when the new value is
// omitted through a reviewed mapping exclusion. This is the same shape as
// the interface_type carve-out below: a declaration that the flag has no
// single top-level RPC property, replacing a value that is no longer true.
//
// It exists because a leaf whose backing RPC gains a nested payload has no
// honest flat property to publish. The alternative outcomes are both worse:
// keep naming a field the request no longer contains, or let assembly fall
// back to flag_name_inference and publish a name that appears in no request
// at all.
//
// The predicate is deliberately narrow:
// - old non-empty AND new empty (a redirect to a different non-empty
// value stays a contract break)
// - the new value resolved through reviewed_mapping_exclusion, so an
// accidental or silent drop is still reported
//
// The exclusion table cannot be abused to wave through arbitrary clearing:
// internal/cli/schema_parameter_bindings.go verifies that every parameter
// claiming an exclusion really does deliver an empty property, and every
// entry carries a non-empty reviewed reason.
//
// Consumers must treat a missing property as "no direct mapping" and read
// the provenance reason for where the value actually lands. Re-populating a
// property requires an explicit ParamDecl declaration.
if oldParameter.Property != newParameter.Property &&
!(oldParameter.Property != "" && newParameter.Property == "" &&
newParameter.PropertySource == propertySourceReviewedMappingExclusion) {
failures = append(failures, fmt.Sprintf("schema tool %q parameter %q changed property", toolPath, 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
}
type schemaToolRef struct {
productID string
toolID string
}
// normalizeSchemaFlagMigrations projects only an already-authorized CLI flag
// rename onto a cloned historical Schema contract. The ordinary compatibility
// checker still makes the final decision; this adapter never drops findings by
// matching their rendered text.
func normalizeSchemaFlagMigrations(
baseline schemaContract,
current schemaContract,
migrations []interfacesnapshot.FlagMigration,
) (schemaContract, error) {
normalized := cloneContract(baseline)
renamesByTool := map[schemaToolRef]map[string]string{}
for _, migration := range migrations {
primaryPath := strings.TrimPrefix(migration.Command, "dws ")
matches := schemaToolsByPrimaryPath(baseline, primaryPath)
if len(matches) == 0 {
// A reviewed CLI-only command has no Schema compatibility surface.
continue
}
if len(matches) != 1 {
return schemaContract{}, fmt.Errorf(
"approved flag migration %q matches %d historical Schema tools",
migration.Command,
len(matches),
)
}
ref := matches[0]
oldTool := baseline.Products[ref.productID].Tools[ref.toolID]
newProduct, productExists := current.Products[ref.productID]
newTool, toolExists := newProduct.Tools[ref.toolID]
if !productExists || !toolExists || newTool.PrimaryCLIPath != primaryPath {
// Preserve the original baseline so the ordinary checker reports the
// missing tool or changed primary_cli_path.
continue
}
oldLegacy, legacyExisted := oldTool.Parameters[migration.Legacy.Name]
oldCanonical, canonicalExisted := oldTool.Parameters[migration.Canonical.Name]
if !legacyExisted && !canonicalExisted {
// A CLI migration is not evidence that an unrelated Schema surface
// published either name. In particular, it must not authorize a
// constraints rewrite with no historical parameter to rename.
continue
}
if _, exists := newTool.Parameters[migration.Legacy.Name]; exists {
return schemaContract{}, fmt.Errorf(
"approved flag migration %q still publishes legacy Schema parameter %q",
migration.Command,
migration.Legacy.Name,
)
}
newCanonical, exists := newTool.Parameters[migration.Canonical.Name]
if !exists {
return schemaContract{}, fmt.Errorf(
"approved flag migration %q does not publish canonical Schema parameter %q",
migration.Command,
migration.Canonical.Name,
)
}
if migration.Canonical.After.Required && (!newCanonical.Required || !newCanonical.CLIRequired) {
return schemaContract{}, fmt.Errorf(
"approved flag migration %q canonical Schema parameter %q must be required and cli_required",
migration.Command,
migration.Canonical.Name,
)
}
if !legacyExisted {
// A consumed receipt may be evaluated after the merge-base Schema has
// already reached the canonical-only state. In that case the ordinary
// checker needs no projection. More importantly, a CLI migration is not
// authority to promote a canonical-only historical Schema parameter from
// optional to required: without legacy Schema evidence there is no rename.
continue
}
normalizedProduct := normalized.Products[ref.productID]
normalizedTool := normalizedProduct.Tools[ref.toolID]
if err := validateRenamedSchemaParameter(migration, oldLegacy, newCanonical); err != nil {
return schemaContract{}, err
}
delete(normalizedTool.Parameters, migration.Legacy.Name)
if canonicalExisted {
if err := validateRenamedSchemaParameter(migration, oldCanonical, newCanonical); err != nil {
return schemaContract{}, err
}
normalizedCanonical := normalizedTool.Parameters[migration.Canonical.Name]
normalizedCanonical.Required = newCanonical.Required
normalizedCanonical.CLIRequired = newCanonical.CLIRequired
normalizedTool.Parameters[migration.Canonical.Name] = normalizedCanonical
} else {
normalizedTool.Parameters[migration.Canonical.Name] = newCanonical
}
normalizedProduct.Tools[ref.toolID] = normalizedTool
normalized.Products[ref.productID] = normalizedProduct
if renamesByTool[ref] == nil {
renamesByTool[ref] = map[string]string{}
}
if existing, ok := renamesByTool[ref][migration.Legacy.Name]; ok && existing != migration.Canonical.Name {
return schemaContract{}, fmt.Errorf(
"approved flag migration %q maps Schema parameter %q to both %q and %q",
migration.Command,
migration.Legacy.Name,
existing,
migration.Canonical.Name,
)
}
renamesByTool[ref][migration.Legacy.Name] = migration.Canonical.Name
}
for ref, renames := range renamesByTool {
oldTool := baseline.Products[ref.productID].Tools[ref.toolID]
newTool := current.Products[ref.productID].Tools[ref.toolID]
if oldTool.Constraints == newTool.Constraints {
continue
}
oldConstraints, oldOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames)
newConstraints, newOK := canonicalizeMigratedConstraints(newTool.Constraints, nil)
if !oldOK || !newOK || oldConstraints != newConstraints {
// Keep the historical constraints unchanged. The ordinary checker
// will report every non-rename constraint change.
continue
}
product := normalized.Products[ref.productID]
tool := product.Tools[ref.toolID]
tool.Constraints = newTool.Constraints
product.Tools[ref.toolID] = tool
normalized.Products[ref.productID] = product
}
return normalized, nil
}
// normalizeSchemaCommandMigrations projects only the Schema consequences that
// are coupled to an already-authorized CLI command migration. It rewrites a
// cloned historical contract; the ordinary checker still rejects every field
// not proven equivalent here.
func normalizeSchemaCommandMigrations(
baseline schemaContract,
current schemaContract,
migrations []interfacesnapshot.CommandMigration,
) (schemaContract, error) {
normalized := cloneContract(baseline)
for _, migration := range migrations {
oldProduct, productExists := baseline.Products[migration.Schema.ProductID]
oldTool, toolExists := oldProduct.Tools[migration.Schema.SourceToolID]
if !productExists || !toolExists {
// The historical baseline predates this Schema tool, so it has no
// compatibility surface for this migration.
continue
}
newProduct, productExists := current.Products[migration.Schema.ProductID]
newSource, sourceExists := newProduct.Tools[migration.Schema.SourceToolID]
if !productExists || !sourceExists {
// Preserve the baseline so the ordinary checker reports the removal.
continue
}
legacyPath := strings.TrimPrefix(migration.Legacy.Command, "dws ")
replacementPath := strings.TrimPrefix(migration.Replacement.Command, "dws ")
if oldTool.PrimaryCLIPath == replacementPath {
// A consumed receipt can still be needed for the stable baseline after
// main has already reached the after state.
continue
}
if oldTool.PrimaryCLIPath != legacyPath {
return schemaContract{}, fmt.Errorf(
"approved command migration %s historical Schema tool %q has primary_cli_path %q",
migration.Kind,
migration.Schema.SourceToolID,
oldTool.PrimaryCLIPath,
)
}
normalizedProduct := normalized.Products[migration.Schema.ProductID]
normalizedTool := normalizedProduct.Tools[migration.Schema.SourceToolID]
switch migration.Kind {
case interfacesnapshot.CommandMigrationMove:
if newSource.PrimaryCLIPath != replacementPath {
continue
}
renames := make(map[string]string, len(migration.Schema.Parameters))
renameTargets := make(map[string]struct{}, len(migration.Schema.Parameters))
for _, parameter := range migration.Schema.Parameters {
oldParameter, existed := oldTool.Parameters[parameter.From]
if !existed {
return schemaContract{}, fmt.Errorf(
"approved command migration %q historical Schema tool lacks parameter %q",
migration.Legacy.Command,
parameter.From,
)
}
if _, exists := oldTool.Parameters[parameter.To]; exists {
return schemaContract{}, fmt.Errorf(
"approved command migration %q Schema parameter target %q already exists in historical Schema tool %q",
migration.Legacy.Command,
parameter.To,
migration.Schema.SourceToolID,
)
}
if _, exists := newSource.Parameters[parameter.From]; exists {
return schemaContract{}, fmt.Errorf(
"approved command migration %q still publishes legacy Schema parameter %q",
migration.Legacy.Command,
parameter.From,
)
}
newParameter, exists := newSource.Parameters[parameter.To]
if !exists {
return schemaContract{}, fmt.Errorf(
"approved command migration %q does not publish replacement Schema parameter %q",
migration.Replacement.Command,
parameter.To,
)
}
if err := validateEquivalentCommandSchemaParameter(migration, parameter, oldParameter, newParameter); err != nil {
return schemaContract{}, err
}
delete(normalizedTool.Parameters, parameter.From)
normalizedTool.Parameters[parameter.To] = newParameter
renames[parameter.From] = parameter.To
renameTargets[parameter.To] = struct{}{}
}
parameterNames := make([]string, 0, len(newSource.Parameters))
for name := range newSource.Parameters {
parameterNames = append(parameterNames, name)
}
sort.Strings(parameterNames)
for _, name := range parameterNames {
parameter := newSource.Parameters[name]
if _, existed := oldTool.Parameters[name]; existed {
continue
}
if _, approvedRename := renameTargets[name]; approvedRename {
continue
}
if parameter.Required || parameter.CLIRequired || parameter.RequiredWhen != "" {
return schemaContract{}, fmt.Errorf(
"approved command migration %q replacement Schema tool %q introduced unregistered required Schema parameter %q",
migration.Legacy.Command,
migration.Schema.SourceToolID,
name,
)
}
}
if oldTool.Constraints != newSource.Constraints {
oldConstraints, oldOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames)
newConstraints, newOK := canonicalizeMigratedConstraints(newSource.Constraints, nil)
if oldOK && newOK && oldConstraints == newConstraints {
normalizedTool.Constraints = newSource.Constraints
}
}
normalizedTool.PrimaryCLIPath = replacementPath
case interfacesnapshot.CommandMigrationFlagExtraction:
if newSource.PrimaryCLIPath != legacyPath {
continue
}
replacement, exists := newProduct.Tools[migration.Schema.ReplacementToolID]
if !exists || replacement.PrimaryCLIPath != replacementPath {
return schemaContract{}, fmt.Errorf(
"approved flag extraction %q lacks replacement Schema tool %q at %q",
migration.Legacy.Command,
migration.Schema.ReplacementToolID,
replacementPath,
)
}
if oldTool.InterfaceMode != replacement.InterfaceMode ||
oldTool.InterfaceRef != replacement.InterfaceRef ||
oldTool.Availability != replacement.Availability ||
oldTool.Effect != replacement.Effect ||
oldTool.Risk != replacement.Risk ||
oldTool.Confirmation != replacement.Confirmation ||
oldTool.Idempotency != replacement.Idempotency {
return schemaContract{}, fmt.Errorf(
"approved flag extraction %q replacement Schema tool changed interface or safety identity",
migration.Legacy.Command,
)
}
if oldTool.DryRun != "" && oldTool.DryRun != replacement.DryRun {
return schemaContract{}, fmt.Errorf(
"approved flag extraction %q replacement Schema tool changed or removed dry_run",
migration.Legacy.Command,
)
}
var err error
normalizedTool, err = normalizeFlagExtractionSchemaMigration(
migration,
oldTool,
newSource,
replacement,
normalizedTool,
)
if err != nil {
return schemaContract{}, err
}
}
normalizedProduct.Tools[migration.Schema.SourceToolID] = normalizedTool
normalized.Products[migration.Schema.ProductID] = normalizedProduct
}
return normalized, nil
}
func normalizeFlagExtractionSchemaMigration(
migration interfacesnapshot.CommandMigration,
oldTool toolSchema,
newSource toolSchema,
replacement toolSchema,
normalizedSource toolSchema,
) (toolSchema, error) {
mappingsBySource := make(map[string]interfacesnapshot.CommandParameterMigration, len(migration.Schema.Parameters))
for _, mapping := range migration.Schema.Parameters {
if _, duplicate := mappingsBySource[mapping.From]; duplicate {
return toolSchema{}, fmt.Errorf(
"approved flag extraction %q maps historical Schema parameter %q more than once",
migration.Legacy.Command,
mapping.From,
)
}
if _, exists := oldTool.Parameters[mapping.From]; !exists {
return toolSchema{}, fmt.Errorf(
"approved flag extraction %q historical Schema tool lacks parameter %q",
migration.Legacy.Command,
mapping.From,
)
}
mappingsBySource[mapping.From] = mapping
}
historicalNames := make([]string, 0, len(oldTool.Parameters))
for name := range oldTool.Parameters {
historicalNames = append(historicalNames, name)
}
sort.Strings(historicalNames)
for _, name := range historicalNames {
if _, mapped := mappingsBySource[name]; !mapped {
return toolSchema{}, fmt.Errorf(
"approved flag extraction %q does not map historical Schema parameter %q",
migration.Legacy.Command,
name,
)
}
}
legacyName := migration.LegacyFlag.Name
legacyMapping, mapped := mappingsBySource[legacyName]
if !mapped || legacyMapping.To != "" || legacyMapping.ReplacementConstant == nil {
return toolSchema{}, fmt.Errorf(
"approved flag extraction %q legacy Schema parameter %q must map to one replacement constant",
migration.Legacy.Command,
legacyName,
)
}
legacyParameter := oldTool.Parameters[legacyName]
if err := validateFlagExtractionBooleanConstant(migration, legacyParameter, legacyMapping.ReplacementConstant); err != nil {
return toolSchema{}, err
}
constantProperty := legacyMapping.ReplacementConstant.Property
if _, exists := newSource.Parameters[legacyName]; exists {
return toolSchema{}, fmt.Errorf(
"approved flag extraction %q still publishes extracted Schema parameter %q",
migration.Legacy.Command,
legacyName,
)
}
sourceNames := make([]string, 0, len(newSource.Parameters))
for name := range newSource.Parameters {
sourceNames = append(sourceNames, name)
}
sort.Strings(sourceNames)
for _, name := range sourceNames {
if newSource.Parameters[name].Property == constantProperty {
return toolSchema{}, fmt.Errorf(
"approved flag extraction %q source Schema tool still publishes replacement constant property %q through parameter %q on %q",
migration.Legacy.Command,
constantProperty,
name,
migration.Schema.SourceToolID,
)
}
}
replacementNames := make([]string, 0, len(replacement.Parameters))
for name := range replacement.Parameters {
replacementNames = append(replacementNames, name)
}
sort.Strings(replacementNames)
for _, name := range replacementNames {
if replacement.Parameters[name].Property == constantProperty {
return toolSchema{}, fmt.Errorf(
"approved flag extraction %q replacement Schema tool %q still publishes replacement constant property %q through parameter %q",
migration.Legacy.Command,
migration.Schema.ReplacementToolID,
constantProperty,
name,
)
}
}
replacementTargets := make(map[string]string, len(historicalNames)-1)
renames := make(map[string]string, len(historicalNames)-1)
for _, sourceName := range historicalNames {
if sourceName == legacyName {
continue
}
mapping := mappingsBySource[sourceName]
if mapping.To == "" || mapping.ReplacementConstant != nil {
return toolSchema{}, fmt.Errorf(
"approved flag extraction %q shared Schema parameter %q must map to one replacement parameter",
migration.Legacy.Command,
sourceName,
)
}
if previous, duplicate := replacementTargets[mapping.To]; duplicate {
return toolSchema{}, fmt.Errorf(
"approved flag extraction %q replacement Schema parameter %q is mapped from both %q and %q",
migration.Legacy.Command,
mapping.To,
previous,
sourceName,
)
}
replacementParameter, exists := replacement.Parameters[mapping.To]
if !exists {
return toolSchema{}, fmt.Errorf(
"approved flag extraction %q replacement Schema tool %q does not publish mapped Schema parameter %q",
migration.Legacy.Command,
migration.Schema.ReplacementToolID,
mapping.To,
)
}
if err := validateEquivalentCommandSchemaParameter(migration, mapping, oldTool.Parameters[sourceName], replacementParameter); err != nil {
return toolSchema{}, err
}
replacementTargets[mapping.To] = sourceName
renames[sourceName] = mapping.To
}
for _, name := range replacementNames {
if _, mapped := replacementTargets[name]; !mapped {
return toolSchema{}, fmt.Errorf(
"approved flag extraction %q replacement Schema tool %q publishes unmapped Schema parameter %q",
migration.Legacy.Command,
migration.Schema.ReplacementToolID,
name,
)
}
}
oldConstraints, oldConstraintsOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames)
replacementConstraints, replacementConstraintsOK := canonicalizeMigratedConstraints(replacement.Constraints, nil)
if !oldConstraintsOK || !replacementConstraintsOK || oldConstraints != replacementConstraints {
return toolSchema{}, fmt.Errorf(
"approved flag extraction %q replacement Schema tool changed constraints",
migration.Legacy.Command,
)
}
if !equivalentMigratedPositionals(oldTool.Positionals, replacement.Positionals, renames) {
return toolSchema{}, fmt.Errorf(
"approved flag extraction %q replacement Schema tool changed positionals",
migration.Legacy.Command,
)
}
delete(normalizedSource.Parameters, legacyName)
return normalizedSource, nil
}
func validateFlagExtractionBooleanConstant(
migration interfacesnapshot.CommandMigration,
parameter parameterSchema,
constant *interfacesnapshot.CommandReplacementConstant,
) error {
if strings.TrimSpace(constant.Property) == "" || parameter.Property != constant.Property {
return fmt.Errorf(
"approved flag extraction %q replacement constant property %q does not match historical Schema property %q",
migration.Legacy.Command,
constant.Property,
parameter.Property,
)
}
if !constant.Value {
return fmt.Errorf(
"approved flag extraction %q replacement constant for Schema property %q must be constant true",
migration.Legacy.Command,
constant.Property,
)
}
if parameter.Type != `"boolean"` {
return fmt.Errorf(
"approved flag extraction %q legacy Schema parameter %q must have boolean type",
migration.Legacy.Command,
migration.LegacyFlag.Name,
)
}
if parameter.Required || parameter.CLIRequired {
return fmt.Errorf(
"approved flag extraction %q legacy Schema parameter %q must remain optional",
migration.Legacy.Command,
migration.LegacyFlag.Name,
)
}
if parameter.RequiredWhen != "" {
return fmt.Errorf(
"approved flag extraction %q legacy Schema parameter %q must not declare required_when",
migration.Legacy.Command,
migration.LegacyFlag.Name,
)
}
if parameter.Default != "" && parameter.Default != "false" {
return fmt.Errorf(
"approved flag extraction %q legacy Schema parameter %q default must be absent or false",
migration.Legacy.Command,
migration.LegacyFlag.Name,
)
}
if parameter.InterfaceDefault != "" && parameter.InterfaceDefault != "false" {
return fmt.Errorf(
"approved flag extraction %q legacy Schema parameter %q interface_default must be absent or false",
migration.Legacy.Command,
migration.LegacyFlag.Name,
)
}
if parameter.InterfaceType != "" && parameter.InterfaceType != "boolean" {
return fmt.Errorf(
"approved flag extraction %q legacy Schema parameter %q interface_type must be empty or boolean",
migration.Legacy.Command,
migration.LegacyFlag.Name,
)
}
if parameter.Format != "" {
return fmt.Errorf(
"approved flag extraction %q legacy Schema parameter %q format must be empty",
migration.Legacy.Command,
migration.LegacyFlag.Name,
)
}
if len(parameter.Enum) > 0 {
allowsTrue := false
for _, value := range parameter.Enum {
if value == "true" {
allowsTrue = true
break
}
}
if !allowsTrue {
return fmt.Errorf(
"approved flag extraction %q legacy Schema parameter %q enum must allow true",
migration.Legacy.Command,
migration.LegacyFlag.Name,
)
}
}
return nil
}
func equivalentMigratedPositionals(
historical []positionalSchema,
replacement []positionalSchema,
renames map[string]string,
) bool {
if len(historical) != len(replacement) {
return false
}
for index, positional := range historical {
if renamed := renames[positional.Name]; renamed != "" {
positional.Name = renamed
}
if positional != replacement[index] {
return false
}
}
return true
}
func validateEquivalentCommandSchemaParameter(
migration interfacesnapshot.CommandMigration,
parameter interfacesnapshot.CommandParameterMigration,
oldParameter parameterSchema,
newParameter parameterSchema,
) error {
if oldParameter.Type != newParameter.Type ||
oldParameter.Property != newParameter.Property ||
oldParameter.InterfaceType != newParameter.InterfaceType ||
oldParameter.Required != newParameter.Required ||
oldParameter.CLIRequired != newParameter.CLIRequired ||
oldParameter.RequiredWhen != newParameter.RequiredWhen ||
oldParameter.Default != newParameter.Default ||
oldParameter.InterfaceDefault != newParameter.InterfaceDefault ||
oldParameter.Format != newParameter.Format ||
!equalStringSlices(oldParameter.Enum, newParameter.Enum) {
return fmt.Errorf(
"approved command migration %q Schema parameter %q -> %q changed a non-name field",
migration.Legacy.Command,
parameter.From,
parameter.To,
)
}
return nil
}
func schemaToolsByPrimaryPath(contract schemaContract, primaryPath string) []schemaToolRef {
var matches []schemaToolRef
for productID, product := range contract.Products {
for toolID, tool := range product.Tools {
if tool.PrimaryCLIPath == primaryPath {
matches = append(matches, schemaToolRef{productID: productID, toolID: toolID})
}
}
}
sort.Slice(matches, func(i, j int) bool {
if matches[i].productID != matches[j].productID {
return matches[i].productID < matches[j].productID
}
return matches[i].toolID < matches[j].toolID
})
return matches
}
func validateRenamedSchemaParameter(
migration interfacesnapshot.FlagMigration,
oldParameter parameterSchema,
newParameter parameterSchema,
) error {
// The migration authorizes only the CLI spelling change. Requiredness is
// part of the parameter contract in both projections and must remain exact.
if oldParameter.Type != newParameter.Type ||
oldParameter.Property != newParameter.Property ||
oldParameter.InterfaceType != newParameter.InterfaceType ||
oldParameter.RequiredWhen != newParameter.RequiredWhen ||
oldParameter.Default != newParameter.Default ||
oldParameter.InterfaceDefault != newParameter.InterfaceDefault ||
oldParameter.Format != newParameter.Format ||
!equalStringSlices(oldParameter.Enum, newParameter.Enum) {
return fmt.Errorf(
"approved flag migration %q Schema parameter %q -> %q changed a non-migration field",
migration.Command,
migration.Legacy.Name,
migration.Canonical.Name,
)
}
if oldParameter.Required != newParameter.Required {
return fmt.Errorf(
"approved flag migration %q Schema parameter %q -> %q changed requiredness",
migration.Command,
migration.Legacy.Name,
migration.Canonical.Name,
)
}
if oldParameter.CLIRequired != newParameter.CLIRequired {
return fmt.Errorf(
"approved flag migration %q Schema parameter %q -> %q changed cli_required",
migration.Command,
migration.Legacy.Name,
migration.Canonical.Name,
)
}
return nil
}
func equalStringSlices(left, right []string) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
func canonicalizeMigratedConstraints(raw string, renames map[string]string) (string, bool) {
groups, ok := parseMigrationConstraintsStrict(raw)
if !ok {
return "", false
}
normalized := map[string][][]string{}
for _, kind := range []string{"mutually_exclusive", "require_one_of", "require_together"} {
for _, group := range groups[kind] {
members := make([]string, 0, len(group))
for _, member := range group {
if replacement := renames[member]; replacement != "" {
member = replacement
}
members = append(members, member)
}
sort.Strings(members)
members = compactConstraintStrings(members)
normalized[kind] = append(normalized[kind], members)
}
sort.Slice(normalized[kind], func(i, j int) bool {
return strings.Join(normalized[kind][i], "\x00") < strings.Join(normalized[kind][j], "\x00")
})
normalized[kind] = compactConstraintGroups(normalized[kind])
if len(normalized[kind]) == 0 {
delete(normalized, kind)
}
}
if len(normalized) == 0 {
return "", true
}
encoded, err := json.Marshal(normalized)
return string(encoded), err == nil
}
func parseMigrationConstraintsStrict(raw string) (map[string][][]string, bool) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return map[string][][]string{}, true
}
decoder := json.NewDecoder(strings.NewReader(trimmed))
opening, err := decoder.Token()
if err != nil || opening != json.Delim('{') {
return nil, false
}
groups := map[string][][]string{}
for decoder.More() {
fieldToken, err := decoder.Token()
if err != nil {
return nil, false
}
// encoding/json guarantees that an object member name is a string token.
field := fieldToken.(string)
switch field {
case "mutually_exclusive", "require_one_of", "require_together":
default:
return nil, false
}
if _, duplicate := groups[field]; duplicate {
return nil, false
}
var encodedGroups json.RawMessage
if err := decoder.Decode(&encodedGroups); err != nil {
return nil, false
}
encodedGroups = bytes.TrimSpace(encodedGroups)
if len(encodedGroups) == 0 || encodedGroups[0] != '[' {
return nil, false
}
var fieldGroups [][]string
if err := json.Unmarshal(encodedGroups, &fieldGroups); err != nil {
return nil, false
}
for _, group := range fieldGroups {
if len(group) == 0 {
return nil, false
}
for _, member := range group {
if member == "" || member != strings.TrimSpace(member) {
return nil, false
}
}
}
groups[field] = fieldGroups
}
closing, err := decoder.Token()
if err != nil || closing != json.Delim('}') {
return nil, false
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
return nil, false
}
return groups, true
}
func compactConstraintStrings(values []string) []string {
if len(values) < 2 {
return values
}
out := values[:1]
for _, value := range values[1:] {
if value != out[len(out)-1] {
out = append(out, value)
}
}
return out
}
func compactConstraintGroups(groups [][]string) [][]string {
if len(groups) < 2 {
return groups
}
out := groups[:1]
for _, group := range groups[1:] {
if !equalStringSlices(group, out[len(out)-1]) {
out = append(out, group)
}
}
return out
}
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
}