// 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(¤tPath, "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, ¶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), 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 ("/"), 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 "/" 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 }