2042 lines
85 KiB
Go
2042 lines
85 KiB
Go
// Copyright 2026 Alibaba Group
|
||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||
|
||
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"reflect"
|
||
"sort"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
|
||
)
|
||
|
||
const completeSchemaJSON = `{
|
||
"kind":"schema",
|
||
"level":"catalog",
|
||
"products":[{
|
||
"id":"doc",
|
||
"tools":[{
|
||
"canonical_path":"doc.create",
|
||
"primary_cli_path":"doc create",
|
||
"interface_mode":"local",
|
||
"interface_ref":{"transport":"local"},
|
||
"availability":"available",
|
||
"parameters":{
|
||
"title":{
|
||
"type":"string",
|
||
"property":"title",
|
||
"required":true,
|
||
"cli_required":true,
|
||
"interface_type":"string",
|
||
"default":null,
|
||
"field_provenance":{}
|
||
},
|
||
"format":{
|
||
"type":["string","null"],
|
||
"property":"format",
|
||
"required":false,
|
||
"interface_type":"string",
|
||
"default":"markdown",
|
||
"enum":["markdown","text"],
|
||
"field_provenance":{}
|
||
}
|
||
},
|
||
"constraints":{"require_one_of":[["title","format"]]},
|
||
"positionals":[{
|
||
"name":"content",
|
||
"index":0,
|
||
"type":"string",
|
||
"required":false,
|
||
"description":"original prose"
|
||
}],
|
||
"effect":"write",
|
||
"risk":"medium",
|
||
"confirmation":"not_required",
|
||
"idempotency":"unknown",
|
||
"field_provenance":{}
|
||
}]
|
||
}]
|
||
}`
|
||
|
||
type failingWriter struct{}
|
||
|
||
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
|
||
|
||
func TestRunSchemaModes(t *testing.T) {
|
||
directory := t.TempDir()
|
||
raw := filepath.Join(directory, "raw.json")
|
||
writeTestFile(t, raw, completeSchemaJSON)
|
||
|
||
var normalized, stderr bytes.Buffer
|
||
if code := run([]string{"--normalize", raw}, &normalized, &stderr); code != 0 {
|
||
t.Fatalf("normalize code=%d stderr=%s", code, stderr.String())
|
||
}
|
||
baseline := filepath.Join(directory, "baseline.json")
|
||
writeTestFile(t, baseline, normalized.String())
|
||
|
||
var stdout bytes.Buffer
|
||
stderr.Reset()
|
||
if code := run([]string{"--check", baseline, "--current", raw}, &stdout, &stderr); code != 0 {
|
||
t.Fatalf("check code=%d stderr=%s", code, stderr.String())
|
||
}
|
||
if !strings.Contains(stdout.String(), "compatibility check: ok") {
|
||
t.Fatalf("unexpected check output %q", stdout.String())
|
||
}
|
||
|
||
stdout.Reset()
|
||
stderr.Reset()
|
||
if code := run([]string{"--merge", baseline, "--current", raw}, &stdout, &stderr); code != 0 {
|
||
t.Fatalf("merge code=%d stderr=%s", code, stderr.String())
|
||
}
|
||
if !strings.Contains(stdout.String(), `"doc.create"`) {
|
||
t.Fatalf("unexpected merge output %q", stdout.String())
|
||
}
|
||
|
||
empty := filepath.Join(directory, "empty.json")
|
||
writeTestFile(t, empty, `{"kind":"schema","products":[]}`)
|
||
stderr.Reset()
|
||
if code := run([]string{"--check", baseline, "--current", empty}, &stdout, &stderr); code != 2 {
|
||
t.Fatalf("empty current contract code=%d, want 2", code)
|
||
}
|
||
|
||
for _, args := range [][]string{
|
||
nil,
|
||
{"--normalize", raw, "--check", baseline},
|
||
{"--check", baseline},
|
||
{"--normalize", filepath.Join(directory, "missing")},
|
||
{"--unknown"},
|
||
} {
|
||
stderr.Reset()
|
||
if code := run(args, &stdout, &stderr); code != 2 {
|
||
t.Errorf("run(%v) code=%d, want 2", args, code)
|
||
}
|
||
}
|
||
|
||
stderr.Reset()
|
||
if code := run([]string{"--normalize", raw}, failingWriter{}, &stderr); code != 2 {
|
||
t.Fatalf("write failure code=%d, want 2", code)
|
||
}
|
||
}
|
||
|
||
func TestNormalizeRawFileValidation(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
body string
|
||
want string
|
||
}{
|
||
{name: "invalid json", body: `{`, want: "unexpected end"},
|
||
{name: "wrong kind", body: `{"kind":"other","products":[]}`, want: "unexpected kind"},
|
||
{name: "missing products", body: `{"kind":"schema"}`, want: "products array is missing"},
|
||
{name: "empty products", body: `{"kind":"schema","products":[]}`, want: "contains no products"},
|
||
{name: "empty tools", body: `{"kind":"schema","products":[{"id":"doc","tools":[]}]}`, want: "contains no tools"},
|
||
{name: "missing product id", body: `{"kind":"schema","products":[{"tools":[]}]}`, want: "product without id"},
|
||
{name: "duplicate product", body: `{"kind":"schema","products":[{"id":"doc"},{"id":"doc"}]}`, want: "duplicate product"},
|
||
{name: "compact tool rejected", body: `{"kind":"schema","products":[{"id":"doc","tools":[{"canonical_path":"doc.create","parameters":{},"effect":"write","risk":"medium","confirmation":"not_required","idempotency":"unknown","interface_mode":"local","availability":"available"}]}]}`, want: "not a complete schema --all leaf"},
|
||
{name: "invalid required", body: strings.Replace(completeSchemaJSON, `"required":true`, `"required":"yes"`, 1), want: "cannot unmarshal string"},
|
||
{name: "incomplete parameter", body: strings.Replace(completeSchemaJSON, `"field_provenance":{}`, `"incomplete":true`, 1), want: "not a complete schema --all parameter"},
|
||
}
|
||
for _, test := range tests {
|
||
t.Run(test.name, func(t *testing.T) {
|
||
path := filepath.Join(t.TempDir(), "raw.json")
|
||
writeTestFile(t, path, test.body)
|
||
_, err := normalizeRawFile(path)
|
||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||
t.Fatalf("normalizeRawFile() error=%v, want %q", err, test.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestNormalizeCompleteSchemaPayload(t *testing.T) {
|
||
path := filepath.Join(t.TempDir(), "schema.json")
|
||
writeTestFile(t, path, completeSchemaJSON)
|
||
|
||
contract, err := normalizeRawFile(path)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
tool := contract.Products["doc"].Tools["doc.create"]
|
||
if tool.PrimaryCLIPath != "doc create" || tool.Constraints == "" || tool.Effect != "write" {
|
||
t.Fatalf("normalized tool contract is incomplete: %#v", tool)
|
||
}
|
||
if len(tool.Positionals) != 1 || tool.Positionals[0].Name != "content" {
|
||
t.Fatalf("normalized positionals = %#v", tool.Positionals)
|
||
}
|
||
if got := tool.Parameters["title"]; got.Type != `"string"` || !got.Required || got.Property != "title" || got.InterfaceType != "string" {
|
||
t.Fatalf("title parameter = %#v", got)
|
||
}
|
||
if got := tool.Parameters["format"]; got.Type != `["string","null"]` || got.Default != `"markdown"` {
|
||
t.Fatalf("format parameter = %#v", got)
|
||
}
|
||
}
|
||
|
||
func TestSchemaCompatibilityIgnoresPositionalDescription(t *testing.T) {
|
||
directory := t.TempDir()
|
||
baselinePath := filepath.Join(directory, "baseline.json")
|
||
currentPath := filepath.Join(directory, "current.json")
|
||
writeTestFile(t, baselinePath, completeSchemaJSON)
|
||
writeTestFile(t, currentPath, strings.Replace(completeSchemaJSON, "original prose", "edited prose only", 1))
|
||
|
||
baseline, err := normalizeRawFile(baselinePath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
current, err := normalizeRawFile(currentPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if failures := checkCompatibility(baseline, current); len(failures) != 0 {
|
||
t.Fatalf("positional description edit should be compatible: %v", failures)
|
||
}
|
||
}
|
||
|
||
func TestSchemaTypeAndHelpers(t *testing.T) {
|
||
if got := schemaType(map[string]any{"type": []any{"string", "null"}}); got != `["string","null"]` {
|
||
t.Fatalf("schemaType(type)=%q", got)
|
||
}
|
||
if got := schemaType(map[string]any{"oneOf": []any{"a"}}); got != `oneOf:["a"]` {
|
||
t.Fatalf("schemaType(oneOf)=%q", got)
|
||
}
|
||
if got := schemaType(map[string]any{}); got != "unspecified" {
|
||
t.Fatalf("schemaType(empty)=%q", got)
|
||
}
|
||
if !enumNarrowed([]string{"a", "b"}, []string{"a"}) || enumNarrowed([]string{"a"}, []string{"a", "b"}) {
|
||
t.Fatal("enum narrowing classification is incorrect")
|
||
}
|
||
}
|
||
|
||
func TestSchemaCompatibilityAllowsAdditionsAndLooserInputs(t *testing.T) {
|
||
baseline := baselineContract()
|
||
mutateTool(&baseline, func(tool *toolSchema) {
|
||
tool.DryRun = ""
|
||
})
|
||
current := cloneContract(baseline)
|
||
mutateParameter(¤t, func(parameter *parameterSchema) {
|
||
parameter.Required = false
|
||
parameter.CLIRequired = false
|
||
parameter.Enum = append(parameter.Enum, "html")
|
||
})
|
||
mutateTool(¤t, func(tool *toolSchema) {
|
||
tool.Parameters["folder"] = parameterSchema{Type: `"string"`}
|
||
tool.DryRun = `{"mode":"native"}`
|
||
})
|
||
current.Products["doc"].Tools["doc.read"] = toolSchema{Parameters: map[string]parameterSchema{}}
|
||
current.Products["sheet"] = productSchema{Tools: map[string]toolSchema{}}
|
||
if failures := checkCompatibility(baseline, current); len(failures) != 0 {
|
||
t.Fatalf("compatible additions should pass: %v", failures)
|
||
}
|
||
}
|
||
|
||
func TestSchemaCompatibilityAllowsLooserAndAppendedOptionalPositionals(t *testing.T) {
|
||
baseline := baselineContract()
|
||
mutateTool(&baseline, func(tool *toolSchema) {
|
||
tool.Positionals[0].Required = true
|
||
})
|
||
current := cloneContract(baseline)
|
||
mutateTool(¤t, func(tool *toolSchema) {
|
||
tool.Positionals[0].Required = false
|
||
tool.Positionals = append(tool.Positionals, positionalSchema{
|
||
Name: "template",
|
||
Index: 1,
|
||
Type: "string",
|
||
})
|
||
})
|
||
|
||
if failures := checkCompatibility(baseline, current); len(failures) != 0 {
|
||
t.Fatalf("looser and appended optional positionals should pass: %v", failures)
|
||
}
|
||
}
|
||
|
||
func TestCompatiblePositionals(t *testing.T) {
|
||
baseline := []positionalSchema{
|
||
{Name: "content", Index: 0, Type: "string", Required: true},
|
||
{Name: "format", Index: 1, Type: "string"},
|
||
}
|
||
tests := []struct {
|
||
name string
|
||
old []positionalSchema
|
||
current []positionalSchema
|
||
compatible bool
|
||
}{
|
||
{name: "unchanged", old: baseline, current: clonePositionals(baseline), compatible: true},
|
||
{name: "required becomes optional", old: baseline, current: []positionalSchema{
|
||
{Name: "content", Index: 0, Type: "string"},
|
||
{Name: "format", Index: 1, Type: "string"},
|
||
}, compatible: true},
|
||
{name: "append optional", old: baseline, current: append(clonePositionals(baseline), positionalSchema{
|
||
Name: "template", Index: 2, Type: "string",
|
||
}), compatible: true},
|
||
{name: "last positional becomes variadic", old: baseline, current: []positionalSchema{
|
||
{Name: "content", Index: 0, Type: "string", Required: true},
|
||
{Name: "format", Index: 1, Type: "string", Variadic: true},
|
||
}, compatible: true},
|
||
{name: "removed", old: baseline, current: clonePositionals(baseline[:1])},
|
||
{name: "renamed", old: baseline, current: []positionalSchema{
|
||
{Name: "body", Index: 0, Type: "string", Required: true},
|
||
{Name: "format", Index: 1, Type: "string"},
|
||
}},
|
||
{name: "reindexed", old: baseline, current: []positionalSchema{
|
||
{Name: "content", Index: 1, Type: "string", Required: true},
|
||
{Name: "format", Index: 2, Type: "string"},
|
||
}},
|
||
{name: "retyped", old: baseline, current: []positionalSchema{
|
||
{Name: "content", Index: 0, Type: "number", Required: true},
|
||
{Name: "format", Index: 1, Type: "string"},
|
||
}},
|
||
{name: "optional becomes required", old: baseline, current: []positionalSchema{
|
||
{Name: "content", Index: 0, Type: "string", Required: true},
|
||
{Name: "format", Index: 1, Type: "string", Required: true},
|
||
}},
|
||
{name: "append required", old: baseline, current: append(clonePositionals(baseline), positionalSchema{
|
||
Name: "template", Index: 2, Type: "string", Required: true,
|
||
})},
|
||
{name: "variadic becomes fixed", old: []positionalSchema{
|
||
{Name: "content", Index: 0, Type: "string", Variadic: true},
|
||
}, current: []positionalSchema{
|
||
{Name: "content", Index: 0, Type: "string"},
|
||
}},
|
||
{name: "append after variadic", old: []positionalSchema{
|
||
{Name: "content", Index: 0, Type: "string", Variadic: true},
|
||
}, current: []positionalSchema{
|
||
{Name: "content", Index: 0, Type: "string", Variadic: true},
|
||
{Name: "format", Index: 1, Type: "string"},
|
||
}},
|
||
}
|
||
|
||
for _, test := range tests {
|
||
t.Run(test.name, func(t *testing.T) {
|
||
if got := compatiblePositionals(test.old, test.current); got != test.compatible {
|
||
t.Fatalf("compatiblePositionals() = %t, want %t", got, test.compatible)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestSchemaCompatibilityRejectsContractDrift(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
want string
|
||
mutate func(*schemaContract)
|
||
}{
|
||
{name: "removed product", want: "historical schema product", mutate: func(contract *schemaContract) { delete(contract.Products, "doc") }},
|
||
{name: "removed tool", want: "historical schema tool", mutate: func(contract *schemaContract) { delete(contract.Products["doc"].Tools, "doc.create") }},
|
||
{name: "removed parameter", want: "lost parameter", mutate: func(contract *schemaContract) {
|
||
mutateTool(contract, func(tool *toolSchema) { delete(tool.Parameters, "title") })
|
||
}},
|
||
{name: "changed type", want: "changed type", mutate: func(contract *schemaContract) {
|
||
mutateParameter(contract, func(parameter *parameterSchema) { parameter.Type = `"number"` })
|
||
}},
|
||
{name: "new required", want: "newly required", mutate: func(contract *schemaContract) {
|
||
mutateParameter(contract, func(parameter *parameterSchema) { parameter.Required = true })
|
||
}},
|
||
{name: "new cli required", want: "newly cli_required", mutate: func(contract *schemaContract) {
|
||
mutateParameter(contract, func(parameter *parameterSchema) { parameter.CLIRequired = true })
|
||
}},
|
||
{name: "changed required when", want: "changed required_when", mutate: func(contract *schemaContract) {
|
||
mutateParameter(contract, func(parameter *parameterSchema) { parameter.RequiredWhen = "scope=team" })
|
||
}},
|
||
{name: "changed property", want: "changed property", mutate: func(contract *schemaContract) {
|
||
mutateParameter(contract, func(parameter *parameterSchema) { parameter.Property = "subject" })
|
||
}},
|
||
{name: "cleared property without a reviewed exclusion", want: "changed property", mutate: func(contract *schemaContract) {
|
||
mutateParameter(contract, func(parameter *parameterSchema) {
|
||
parameter.Property = ""
|
||
parameter.PropertySource = "flag_name_inference"
|
||
})
|
||
}},
|
||
{name: "redirected property despite a reviewed exclusion", want: "changed property", mutate: func(contract *schemaContract) {
|
||
mutateParameter(contract, func(parameter *parameterSchema) {
|
||
parameter.Property = "subject"
|
||
parameter.PropertySource = propertySourceReviewedMappingExclusion
|
||
})
|
||
}},
|
||
{name: "changed interface type", want: "changed interface_type", mutate: func(contract *schemaContract) {
|
||
mutateParameter(contract, func(parameter *parameterSchema) { parameter.InterfaceType = "integer" })
|
||
}},
|
||
{name: "changed default", want: "changed default", mutate: func(contract *schemaContract) {
|
||
mutateParameter(contract, func(parameter *parameterSchema) { parameter.Default = `"html"` })
|
||
}},
|
||
{name: "changed interface default", want: "changed interface_default", mutate: func(contract *schemaContract) {
|
||
mutateParameter(contract, func(parameter *parameterSchema) { parameter.InterfaceDefault = `"html"` })
|
||
}},
|
||
{name: "changed format", want: "changed format", mutate: func(contract *schemaContract) {
|
||
mutateParameter(contract, func(parameter *parameterSchema) { parameter.Format = "uri" })
|
||
}},
|
||
{name: "narrowed enum", want: "narrowed enum", mutate: func(contract *schemaContract) {
|
||
mutateParameter(contract, func(parameter *parameterSchema) { parameter.Enum = []string{"markdown"} })
|
||
}},
|
||
{name: "changed primary cli path", want: "changed primary_cli_path", mutate: func(contract *schemaContract) {
|
||
mutateTool(contract, func(tool *toolSchema) { tool.PrimaryCLIPath = "doc make" })
|
||
}},
|
||
{name: "changed interface mode", want: "changed interface_mode", mutate: func(contract *schemaContract) {
|
||
mutateTool(contract, func(tool *toolSchema) { tool.InterfaceMode = "mcp" })
|
||
}},
|
||
{name: "changed constraints", want: "changed constraints", mutate: func(contract *schemaContract) {
|
||
mutateTool(contract, func(tool *toolSchema) { tool.Constraints = `{}` })
|
||
}},
|
||
{name: "changed positionals", want: "changed positionals", mutate: func(contract *schemaContract) {
|
||
mutateTool(contract, func(tool *toolSchema) { tool.Positionals[0].Name = "id" })
|
||
}},
|
||
{name: "changed interface mapping", want: "changed interface_ref", mutate: func(contract *schemaContract) {
|
||
mutateTool(contract, func(tool *toolSchema) { tool.InterfaceRef = `{"transport":"mcp"}` })
|
||
}},
|
||
{name: "changed availability", want: "changed availability", mutate: func(contract *schemaContract) {
|
||
mutateTool(contract, func(tool *toolSchema) { tool.Availability = "unavailable" })
|
||
}},
|
||
{name: "changed confirmation", want: "changed confirmation", mutate: func(contract *schemaContract) {
|
||
mutateTool(contract, func(tool *toolSchema) { tool.Confirmation = "user_required" })
|
||
}},
|
||
{name: "changed risk", want: "changed risk", mutate: func(contract *schemaContract) { mutateTool(contract, func(tool *toolSchema) { tool.Risk = "high" }) }},
|
||
{name: "changed effect", want: "changed effect", mutate: func(contract *schemaContract) {
|
||
mutateTool(contract, func(tool *toolSchema) { tool.Effect = "destructive" })
|
||
}},
|
||
{name: "changed idempotency", want: "changed idempotency", mutate: func(contract *schemaContract) {
|
||
mutateTool(contract, func(tool *toolSchema) { tool.Idempotency = "idempotent" })
|
||
}},
|
||
{name: "removed dry run", want: "changed or removed dry_run", mutate: func(contract *schemaContract) { mutateTool(contract, func(tool *toolSchema) { tool.DryRun = "" }) }},
|
||
}
|
||
|
||
for _, test := range tests {
|
||
t.Run(test.name, func(t *testing.T) {
|
||
current := baselineContract()
|
||
test.mutate(¤t)
|
||
failures := strings.Join(checkCompatibility(baselineContract(), current), "\n")
|
||
if !strings.Contains(failures, test.want) {
|
||
t.Fatalf("failures=%q, want %q", failures, test.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestMergeContracts(t *testing.T) {
|
||
historical := baselineContract()
|
||
current := cloneContract(historical)
|
||
mutateTool(¤t, func(tool *toolSchema) {
|
||
tool.Parameters["folder"] = parameterSchema{Type: `"string"`}
|
||
})
|
||
merged, failures := mergeContracts(historical, current)
|
||
if len(failures) != 0 || merged.Products["doc"].Tools["doc.create"].Parameters["folder"].Type == "" {
|
||
t.Fatalf("merge=%v failures=%v", merged, failures)
|
||
}
|
||
|
||
mutateParameter(¤t, func(parameter *parameterSchema) {
|
||
parameter.Type = `"number"`
|
||
})
|
||
if _, failures := mergeContracts(historical, current); len(failures) == 0 {
|
||
t.Fatal("incompatible merge unexpectedly passed")
|
||
}
|
||
}
|
||
|
||
// registerReviewedParameterTypeFixture puts a fixture entry in the reviewed
|
||
// table for the duration of one test, so the behaviour tests exercise the real
|
||
// lookup instead of depending on whichever production entries happen to exist.
|
||
func registerReviewedParameterTypeFixture(t *testing.T, change parameterTypeChange) {
|
||
t.Helper()
|
||
if _, exists := reviewedParameterTypeChanges[change]; exists {
|
||
t.Fatalf("fixture %+v collides with a production entry", change)
|
||
}
|
||
reviewedParameterTypeChanges[change] = struct{}{}
|
||
t.Cleanup(func() { delete(reviewedParameterTypeChanges, change) })
|
||
}
|
||
|
||
func reviewedFormatTypeFixture() parameterTypeChange {
|
||
return parameterTypeChange{
|
||
ToolPath: "doc/doc.create",
|
||
Parameter: "format",
|
||
From: `"string"`,
|
||
To: `"integer"`,
|
||
}
|
||
}
|
||
|
||
const docCreateFormatTypeFailure = `schema tool "doc/doc.create" parameter "format" changed type`
|
||
|
||
func assertSchemaFailureContains(t *testing.T, failures []string, want string) {
|
||
t.Helper()
|
||
for _, failure := range failures {
|
||
if strings.Contains(failure, want) {
|
||
return
|
||
}
|
||
}
|
||
t.Fatalf("failures %v do not contain %q", failures, want)
|
||
}
|
||
|
||
func TestCrossPlatformCoverageSchemaCompatReviewedParameterTypeChange(t *testing.T) {
|
||
baseline := cloneContract(baselineContract())
|
||
current := cloneContract(baseline)
|
||
mutateParameter(¤t, func(parameter *parameterSchema) { parameter.Type = `"integer"` })
|
||
|
||
// Without an entry the migration is still a blocking change.
|
||
assertSchemaFailureContains(t, checkCompatibility(baseline, current), docCreateFormatTypeFailure)
|
||
|
||
registerReviewedParameterTypeFixture(t, reviewedFormatTypeFixture())
|
||
if failures := checkCompatibility(baseline, current); len(failures) != 0 {
|
||
t.Fatalf("a reviewed parameter type migration should pass: %v", failures)
|
||
}
|
||
}
|
||
|
||
func TestCrossPlatformCoverageSchemaCompatReviewedParameterTypeChangeIsDirectionSensitive(t *testing.T) {
|
||
registerReviewedParameterTypeFixture(t, reviewedFormatTypeFixture())
|
||
|
||
// The reverse migration shares tool and parameter but not direction, so the
|
||
// string->integer entry must not admit it.
|
||
baseline := cloneContract(baselineContract())
|
||
mutateParameter(&baseline, func(parameter *parameterSchema) { parameter.Type = `"integer"` })
|
||
current := cloneContract(baselineContract())
|
||
assertSchemaFailureContains(t, checkCompatibility(baseline, current), docCreateFormatTypeFailure)
|
||
}
|
||
|
||
func TestCrossPlatformCoverageSchemaCompatReviewedParameterTypeChangeRejectsOtherToolsAndParameters(t *testing.T) {
|
||
baseline := cloneContract(baselineContract())
|
||
current := cloneContract(baseline)
|
||
mutateParameter(¤t, func(parameter *parameterSchema) { parameter.Type = `"integer"` })
|
||
|
||
// Same parameter and direction, different tool.
|
||
other := reviewedFormatTypeFixture()
|
||
other.ToolPath = "doc/doc.elsewhere"
|
||
registerReviewedParameterTypeFixture(t, other)
|
||
assertSchemaFailureContains(t, checkCompatibility(baseline, current), docCreateFormatTypeFailure)
|
||
|
||
// Same tool and direction, different parameter.
|
||
elsewhere := reviewedFormatTypeFixture()
|
||
elsewhere.Parameter = "title"
|
||
registerReviewedParameterTypeFixture(t, elsewhere)
|
||
assertSchemaFailureContains(t, checkCompatibility(baseline, current), docCreateFormatTypeFailure)
|
||
}
|
||
|
||
// A reviewed migration is only accepted when the rest of the parameter's
|
||
// contract held still. Each case bundles one unrelated regression with the
|
||
// reviewed type change and requires the type failure to reappear, so the
|
||
// exemption cannot carry a second change in behind it.
|
||
func TestCrossPlatformCoverageSchemaCompatReviewedParameterTypeChangeRejectsBundledRegression(t *testing.T) {
|
||
registerReviewedParameterTypeFixture(t, reviewedFormatTypeFixture())
|
||
|
||
tests := []struct {
|
||
name string
|
||
mutate func(*parameterSchema)
|
||
}{
|
||
{name: "changed default", mutate: func(p *parameterSchema) { p.Default = `"text"` }},
|
||
{name: "changed interface_default", mutate: func(p *parameterSchema) { p.InterfaceDefault = `"text"` }},
|
||
{name: "changed format", mutate: func(p *parameterSchema) { p.Format = "uri" }},
|
||
{name: "redirected property", mutate: func(p *parameterSchema) { p.Property = "renamed" }},
|
||
{name: "redirected interface_type", mutate: func(p *parameterSchema) { p.InterfaceType = "integer" }},
|
||
{name: "newly required", mutate: func(p *parameterSchema) { p.Required = true }},
|
||
{name: "newly cli_required", mutate: func(p *parameterSchema) { p.CLIRequired = true }},
|
||
{name: "changed required_when", mutate: func(p *parameterSchema) { p.RequiredWhen = "always" }},
|
||
{name: "narrowed enum", mutate: func(p *parameterSchema) { p.Enum = []string{"markdown"} }},
|
||
}
|
||
|
||
for _, test := range tests {
|
||
t.Run(test.name, func(t *testing.T) {
|
||
baseline := cloneContract(baselineContract())
|
||
current := cloneContract(baseline)
|
||
mutateParameter(¤t, func(parameter *parameterSchema) {
|
||
parameter.Type = `"integer"`
|
||
test.mutate(parameter)
|
||
})
|
||
assertSchemaFailureContains(t, checkCompatibility(baseline, current), docCreateFormatTypeFailure)
|
||
})
|
||
}
|
||
}
|
||
|
||
// Every drift below is individually *compatible* — on its own it produces no
|
||
// failure at all — which is precisely why the carve-out cannot be keyed on "the
|
||
// gate recorded no other failure for this parameter". Bundled with a reviewed
|
||
// type migration they must still be rejected: none of them were reviewed under
|
||
// that entry, and admitting them would make the exemption wider than what the
|
||
// table documents.
|
||
//
|
||
// Each case first asserts that the drift alone really is compatible, so a future
|
||
// rule change that starts reporting it turns into an explicit failure here
|
||
// rather than quietly turning this test into a duplicate of the bundled-
|
||
// regression cases above.
|
||
func TestCrossPlatformCoverageSchemaCompatReviewedParameterTypeChangeRejectsIndividuallyCompatibleDrift(t *testing.T) {
|
||
registerReviewedParameterTypeFixture(t, reviewedFormatTypeFixture())
|
||
|
||
tests := []struct {
|
||
name string
|
||
baseline func(*parameterSchema)
|
||
current func(*parameterSchema)
|
||
}{
|
||
{
|
||
name: "relaxed required",
|
||
baseline: func(p *parameterSchema) { p.Required = true },
|
||
current: func(p *parameterSchema) { p.Required = false },
|
||
},
|
||
{
|
||
name: "relaxed cli_required",
|
||
baseline: func(p *parameterSchema) { p.CLIRequired = true },
|
||
current: func(p *parameterSchema) { p.CLIRequired = false },
|
||
},
|
||
{
|
||
name: "cleared required_when",
|
||
baseline: func(p *parameterSchema) { p.RequiredWhen = "always" },
|
||
current: func(p *parameterSchema) { p.RequiredWhen = "" },
|
||
},
|
||
{
|
||
name: "widened enum",
|
||
current: func(p *parameterSchema) { p.Enum = []string{"html", "markdown", "text"} },
|
||
},
|
||
{
|
||
name: "cleared interface_type",
|
||
baseline: func(p *parameterSchema) { p.InterfaceType = "string" },
|
||
current: func(p *parameterSchema) { p.InterfaceType = "" },
|
||
},
|
||
{
|
||
name: "cleared property through reviewed mapping exclusion",
|
||
current: func(p *parameterSchema) {
|
||
p.Property = ""
|
||
p.PropertySource = propertySourceReviewedMappingExclusion
|
||
},
|
||
},
|
||
}
|
||
|
||
for _, test := range tests {
|
||
t.Run(test.name, func(t *testing.T) {
|
||
baseline := cloneContract(baselineContract())
|
||
if test.baseline != nil {
|
||
mutateParameter(&baseline, test.baseline)
|
||
}
|
||
|
||
driftOnly := cloneContract(baseline)
|
||
mutateParameter(&driftOnly, test.current)
|
||
if failures := checkCompatibility(baseline, driftOnly); len(failures) != 0 {
|
||
t.Fatalf("前提不成立:该变化本身已不兼容,用例测不到相等性守卫: %v", failures)
|
||
}
|
||
|
||
bundled := cloneContract(baseline)
|
||
mutateParameter(&bundled, func(parameter *parameterSchema) {
|
||
parameter.Type = `"integer"`
|
||
test.current(parameter)
|
||
})
|
||
assertSchemaFailureContains(t, checkCompatibility(baseline, bundled), docCreateFormatTypeFailure)
|
||
})
|
||
}
|
||
}
|
||
|
||
// The behaviour tests above register their own fixtures, so they cannot catch a
|
||
// production entry whose type values are spelled in the wrong form. That mistake
|
||
// disables the exemption silently and only a real gate run reports it — exactly
|
||
// how reviewedInterfaceRefRedirect was broken twice. Recompute both values
|
||
// through schemaType instead of trusting the spelling in the table.
|
||
func TestCrossPlatformCoverageReviewedParameterTypeChangeKeysAreCanonical(t *testing.T) {
|
||
if len(reviewedParameterTypeChanges) == 0 {
|
||
t.Fatal("豁免表为空:若确已清空,请连这条守卫一起删除")
|
||
}
|
||
for change := range reviewedParameterTypeChanges {
|
||
if strings.Count(change.ToolPath, "/") != 1 ||
|
||
strings.HasPrefix(change.ToolPath, "/") || strings.HasSuffix(change.ToolPath, "/") {
|
||
t.Errorf("tool path %q 不是 \"<product id>/<tool id>\" 形态", change.ToolPath)
|
||
}
|
||
if strings.TrimSpace(change.Parameter) != change.Parameter || change.Parameter == "" ||
|
||
strings.HasPrefix(change.Parameter, "-") {
|
||
t.Errorf("%s: 参数名应是 Schema 里的裸名,登记为 %q", change.ToolPath, change.Parameter)
|
||
}
|
||
if change.From == change.To {
|
||
t.Errorf("%s %s: From 与 To 相同,不构成迁移", change.ToolPath, change.Parameter)
|
||
}
|
||
for _, side := range []struct {
|
||
label string
|
||
value string
|
||
}{{"From", change.From}, {"To", change.To}} {
|
||
var name string
|
||
if err := json.Unmarshal([]byte(side.value), &name); err != nil {
|
||
t.Errorf("%s %s 的 %s=%q 不是合法的 JSON type 值(schemaType 产出带引号的形态,如 `\"string\"`): %v",
|
||
change.ToolPath, change.Parameter, side.label, side.value, err)
|
||
continue
|
||
}
|
||
if got := schemaType(map[string]any{"type": name}); got != side.value {
|
||
t.Errorf("%s %s 的 %s 不是规范形态\n 登记: %s\n 规范: %s",
|
||
change.ToolPath, change.Parameter, side.label, side.value, got)
|
||
}
|
||
// 复算只能证明引号形态对,证明不了类型名本身没拼错——"integar" 同样
|
||
// 能通过复算。JSON Schema 的 type 取值是封闭集合,直接校验。
|
||
switch name {
|
||
case "string", "integer", "number", "boolean", "array", "object", "null":
|
||
default:
|
||
t.Errorf("%s %s 的 %s=%q 不是 JSON Schema 的合法 type 名",
|
||
change.ToolPath, change.Parameter, side.label, name)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func baselineContract() schemaContract {
|
||
return schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{
|
||
"doc": {Tools: map[string]toolSchema{
|
||
"doc.create": {
|
||
PrimaryCLIPath: "doc create",
|
||
InterfaceMode: "local",
|
||
InterfaceRef: `{"transport":"local"}`,
|
||
Availability: "available",
|
||
Parameters: map[string]parameterSchema{
|
||
"title": {
|
||
Type: `"string"`,
|
||
Property: "title",
|
||
InterfaceType: "string",
|
||
},
|
||
"format": {
|
||
Type: `"string"`,
|
||
Property: "format",
|
||
InterfaceType: "string",
|
||
Default: `"markdown"`,
|
||
Enum: []string{"markdown", "text"},
|
||
},
|
||
},
|
||
Constraints: `{"require_one_of":[["title","format"]]}`,
|
||
Positionals: []positionalSchema{{
|
||
Name: "content",
|
||
Index: 0,
|
||
Type: "string",
|
||
}},
|
||
DryRun: `{"mode":"native"}`,
|
||
Effect: "write",
|
||
Risk: "medium",
|
||
Confirmation: "not_required",
|
||
Idempotency: "unknown",
|
||
},
|
||
}},
|
||
}}
|
||
}
|
||
|
||
func mutateTool(contract *schemaContract, mutate func(*toolSchema)) {
|
||
product := contract.Products["doc"]
|
||
tool := product.Tools["doc.create"]
|
||
mutate(&tool)
|
||
product.Tools["doc.create"] = tool
|
||
contract.Products["doc"] = product
|
||
}
|
||
|
||
func mutateParameter(contract *schemaContract, mutate func(*parameterSchema)) {
|
||
mutateTool(contract, func(tool *toolSchema) {
|
||
parameter := tool.Parameters["format"]
|
||
mutate(¶meter)
|
||
tool.Parameters["format"] = parameter
|
||
})
|
||
}
|
||
|
||
func clonePositionals(source []positionalSchema) []positionalSchema {
|
||
return append([]positionalSchema(nil), source...)
|
||
}
|
||
|
||
func writeTestFile(t *testing.T, path, body string) {
|
||
t.Helper()
|
||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// TestCrossPlatformCoverageSchemaCompatMCPRetirementAndConstraintExpansion covers
|
||
// the MCP pin retirement allowance (clearing interface_type) and declare≡execute
|
||
// constraint member expansion used by the platform coverage gate.
|
||
func TestCrossPlatformCoverageSchemaCompatMCPRetirementAndConstraintExpansion(t *testing.T) {
|
||
baseline := baselineContract()
|
||
current := cloneContract(baseline)
|
||
mutateParameter(¤t, func(parameter *parameterSchema) {
|
||
parameter.InterfaceType = ""
|
||
})
|
||
mutateTool(¤t, func(tool *toolSchema) {
|
||
tool.Constraints = `{"require_one_of":[["title","format","legacy-format"]],"mutually_exclusive":[["title","format","legacy-title"]]}`
|
||
})
|
||
// Baseline has require_one_of only; a new mutually-exclusive group that
|
||
// restricts two historical public parameters must remain incompatible.
|
||
if failures := checkCompatibility(baseline, current); len(failures) == 0 {
|
||
t.Fatal("adding a new constraint group must fail compatibility")
|
||
}
|
||
|
||
// Match baseline shape: expand require_one_of members only, clear interface_type.
|
||
current = cloneContract(baseline)
|
||
mutateParameter(¤t, func(parameter *parameterSchema) {
|
||
parameter.InterfaceType = ""
|
||
})
|
||
mutateTool(¤t, func(tool *toolSchema) {
|
||
tool.Constraints = `{"require_one_of":[["title","format","legacy-format"]]}`
|
||
})
|
||
if failures := checkCompatibility(baseline, current); len(failures) != 0 {
|
||
t.Fatalf("clearing interface_type and expanding constraint members should pass: %v", failures)
|
||
}
|
||
|
||
// Hidden-sibling expansion from empty historical constraints remains allowed.
|
||
emptyConstraints := cloneContract(baseline)
|
||
mutateTool(&emptyConstraints, func(tool *toolSchema) {
|
||
tool.Constraints = ""
|
||
title := tool.Parameters["title"]
|
||
title.Required = true
|
||
tool.Parameters["title"] = title
|
||
delete(tool.Parameters, "format")
|
||
})
|
||
expanded := cloneContract(emptyConstraints)
|
||
mutateTool(&expanded, func(tool *toolSchema) {
|
||
tool.Constraints = `{"require_one_of":[["title","hidden-alias"]],"mutually_exclusive":[["title","hidden-alias"]]}`
|
||
title := tool.Parameters["title"]
|
||
title.Required = false
|
||
tool.Parameters["title"] = title
|
||
})
|
||
if failures := checkCompatibility(emptyConstraints, expanded); len(failures) != 0 {
|
||
t.Fatalf("hidden-sibling constraint expansion should pass: %v", failures)
|
||
}
|
||
|
||
if groups, ok := parseConstraintGroups(""); !ok || len(groups["require_one_of"]) != 0 {
|
||
t.Fatalf("empty constraints parse = %#v ok=%v", groups, ok)
|
||
}
|
||
if !stringSetContainsAll(map[string]bool{"a": true, "b": true}, map[string]bool{"a": true}) ||
|
||
stringSetContainsAll(map[string]bool{"a": true}, map[string]bool{"a": true, "b": true}) {
|
||
t.Fatal("stringSetContainsAll classification is incorrect")
|
||
}
|
||
|
||
// compatibleHiddenSiblingConstraintExpansion false branches.
|
||
if compatibleHiddenSiblingConstraintExpansion(
|
||
toolSchema{Constraints: "", Parameters: map[string]parameterSchema{"a": {Required: true}}},
|
||
toolSchema{Constraints: "{", Parameters: map[string]parameterSchema{"a": {}}},
|
||
) {
|
||
t.Fatal("invalid projected constraints must not expand")
|
||
}
|
||
oldRequired := toolSchema{
|
||
Constraints: "",
|
||
Parameters: map[string]parameterSchema{"a": {Required: true}},
|
||
}
|
||
if compatibleHiddenSiblingConstraintExpansion(oldRequired, toolSchema{
|
||
Constraints: `{"require_together":[["a","b"]],"require_one_of":[["a","hidden"]]}`,
|
||
Parameters: map[string]parameterSchema{"a": {}},
|
||
}) {
|
||
t.Fatal("require_together projection must not expand")
|
||
}
|
||
if compatibleHiddenSiblingConstraintExpansion(oldRequired, toolSchema{
|
||
Constraints: `{"require_one_of":[["a"]]}`,
|
||
Parameters: map[string]parameterSchema{"a": {}},
|
||
}) {
|
||
t.Fatal("single-member group must not expand")
|
||
}
|
||
if compatibleHiddenSiblingConstraintExpansion(oldRequired, toolSchema{
|
||
Constraints: `{"require_one_of":[["a","","hidden"]]}`,
|
||
Parameters: map[string]parameterSchema{"a": {}},
|
||
}) {
|
||
t.Fatal("empty constraint member must not expand")
|
||
}
|
||
if compatibleHiddenSiblingConstraintExpansion(oldRequired, toolSchema{
|
||
Constraints: `{"require_one_of":[["hidden","ghost"]]}`,
|
||
Parameters: map[string]parameterSchema{"a": {}},
|
||
}) {
|
||
t.Fatal("unpublished-only group must not expand")
|
||
}
|
||
if compatibleHiddenSiblingConstraintExpansion(oldRequired, toolSchema{
|
||
Constraints: `{"require_one_of":[["a","b"]]}`,
|
||
Parameters: map[string]parameterSchema{"a": {}, "b": {}},
|
||
}) {
|
||
t.Fatal("published-only group must not expand")
|
||
}
|
||
if compatibleHiddenSiblingConstraintExpansion(oldRequired, toolSchema{
|
||
Constraints: `{"require_one_of":[["a","hidden"]]}`,
|
||
Parameters: map[string]parameterSchema{"a": {Required: true}},
|
||
}) {
|
||
t.Fatal("still-required sole published member must not expand")
|
||
}
|
||
|
||
// Changing interface_type to a different non-empty value remains incompatible.
|
||
typed := cloneContract(baseline)
|
||
mutateParameter(&typed, func(parameter *parameterSchema) { parameter.InterfaceType = "integer" })
|
||
if failures := checkCompatibility(baseline, typed); !strings.Contains(strings.Join(failures, "\n"), "changed interface_type") {
|
||
t.Fatalf("non-empty interface_type change should fail: %v", failures)
|
||
}
|
||
}
|
||
|
||
func TestCrossPlatformCoverageSchemaCompatAdditiveConstraintEvolution(t *testing.T) {
|
||
oldTool := toolSchema{
|
||
Parameters: map[string]parameterSchema{
|
||
"target": {},
|
||
"limit": {},
|
||
},
|
||
Constraints: `{"require_one_of":[["target","legacy-target"]]}`,
|
||
}
|
||
compatible := oldTool
|
||
compatible.Constraints = `{"require_one_of":[["target","legacy-target","new-target"]],"mutually_exclusive":[["target","new-target"],["new-a","new-b"]],"require_together":[["new-a","new-b"]]}`
|
||
if !compatibleAdditiveConstraintEvolution(oldTool, compatible) {
|
||
t.Fatal("member expansion and additive alias-only groups must remain compatible")
|
||
}
|
||
|
||
twoHistorical := compatible
|
||
twoHistorical.Constraints = `{"require_one_of":[["target","legacy-target","new-target"]],"mutually_exclusive":[["target","limit","new-target"]]}`
|
||
if compatibleAdditiveConstraintEvolution(oldTool, twoHistorical) {
|
||
t.Fatal("new mutex group restricting two historical parameters must fail")
|
||
}
|
||
newRequirement := compatible
|
||
newRequirement.Constraints = `{"require_one_of":[["target","legacy-target","new-target"],["new-a","new-b"]]}`
|
||
if compatibleAdditiveConstraintEvolution(oldTool, newRequirement) {
|
||
t.Fatal("new require_one_of group must fail")
|
||
}
|
||
requireHistoricalTogether := compatible
|
||
requireHistoricalTogether.Constraints = `{"require_one_of":[["target","legacy-target","new-target"]],"require_together":[["limit","new-limit"]]}`
|
||
if compatibleAdditiveConstraintEvolution(oldTool, requireHistoricalTogether) {
|
||
t.Fatal("new require_together group containing a historical parameter must fail")
|
||
}
|
||
removedOldGroup := compatible
|
||
removedOldGroup.Constraints = `{"mutually_exclusive":[["new-a","new-b"]]}`
|
||
if compatibleAdditiveConstraintEvolution(oldTool, removedOldGroup) {
|
||
t.Fatal("removing a historical group must fail")
|
||
}
|
||
invalid := compatible
|
||
invalid.Constraints = "{"
|
||
if compatibleAdditiveConstraintEvolution(oldTool, invalid) {
|
||
t.Fatal("invalid constraints must fail closed")
|
||
}
|
||
emptyHistoricalGroup := oldTool
|
||
emptyHistoricalGroup.Constraints = `{"require_one_of":[[]]}`
|
||
if compatibleAdditiveConstraintEvolution(emptyHistoricalGroup, compatible) {
|
||
t.Fatal("empty historical constraint group must fail closed")
|
||
}
|
||
historicalMutex := oldTool
|
||
historicalMutex.Constraints = `{"mutually_exclusive":[["target","legacy-target"]]}`
|
||
historicalMutexExpanded := oldTool
|
||
historicalMutexExpanded.Constraints = `{"mutually_exclusive":[["target","legacy-target","limit"]]}`
|
||
if compatibleAdditiveConstraintEvolution(historicalMutex, historicalMutexExpanded) {
|
||
t.Fatal("adding a historical parameter to an existing mutex group must fail")
|
||
}
|
||
historicalTogether := oldTool
|
||
historicalTogether.Constraints = `{"require_together":[["target","legacy-target"]]}`
|
||
historicalTogetherExpanded := oldTool
|
||
historicalTogetherExpanded.Constraints = `{"require_together":[["target","legacy-target","limit"]]}`
|
||
if compatibleAdditiveConstraintEvolution(historicalTogether, historicalTogetherExpanded) {
|
||
t.Fatal("adding a historical parameter to an existing require-together group must fail")
|
||
}
|
||
historicalOneOf := oldTool
|
||
historicalOneOf.Constraints = `{"require_one_of":[["target","legacy-target","limit"]]}`
|
||
if !compatibleAdditiveConstraintEvolution(oldTool, historicalOneOf) {
|
||
t.Fatal("adding a historical parameter to require-one-of only loosens the contract")
|
||
}
|
||
gateBaseline := baselineContract()
|
||
mutateTool(&gateBaseline, func(tool *toolSchema) {
|
||
tool.Parameters["folder"] = parameterSchema{Type: `"string"`}
|
||
tool.Constraints = `{"mutually_exclusive":[["title","format"]]}`
|
||
})
|
||
gateCurrent := cloneContract(gateBaseline)
|
||
mutateTool(&gateCurrent, func(tool *toolSchema) {
|
||
tool.Constraints = `{"mutually_exclusive":[["title","format","folder"]]}`
|
||
})
|
||
if failures := checkCompatibility(gateBaseline, gateCurrent); len(failures) == 0 {
|
||
t.Fatal("compatibility gate must reject an existing mutex group gaining a historical parameter")
|
||
}
|
||
multipleHistoricalGroups := oldTool
|
||
multipleHistoricalGroups.Constraints = `{"require_one_of":[["target"],["limit"]]}`
|
||
multipleExpandedGroups := oldTool
|
||
multipleExpandedGroups.Constraints = `{"require_one_of":[["target","limit"],["limit","new-limit"]]}`
|
||
if !compatibleAdditiveConstraintEvolution(multipleHistoricalGroups, multipleExpandedGroups) {
|
||
t.Fatal("each historical group must match a distinct expanded group")
|
||
}
|
||
}
|
||
|
||
func TestCrossPlatformCoverageSchemaCompatReviewedConstraintTransition(t *testing.T) {
|
||
const toolPath = "doc/doc.shortcut_import"
|
||
oldTool := toolSchema{Constraints: `{"require_one_of":[["folder","workspace"]]}`}
|
||
newTool := oldTool
|
||
newTool.Constraints = ""
|
||
|
||
if !compatibleReviewedConstraintTransition(toolPath, oldTool, newTool) {
|
||
t.Fatal("reviewed doc import target removal must be accepted")
|
||
}
|
||
if failures := checkToolCompatibility(toolPath, oldTool, newTool); len(failures) != 0 {
|
||
t.Fatalf("reviewed doc import constraint transition failed: %v", failures)
|
||
}
|
||
|
||
for _, test := range []struct {
|
||
name string
|
||
path string
|
||
old string
|
||
new string
|
||
}{
|
||
{name: "unlisted tool", path: "doc/doc.other", old: oldTool.Constraints, new: ""},
|
||
{name: "unlisted source", path: toolPath, old: `{"require_one_of":[["folder","workspace","name"]]}`, new: ""},
|
||
{name: "unlisted target", path: toolPath, old: oldTool.Constraints, new: `{}`},
|
||
} {
|
||
t.Run(test.name, func(t *testing.T) {
|
||
if compatibleReviewedConstraintTransition(test.path, toolSchema{Constraints: test.old}, toolSchema{Constraints: test.new}) {
|
||
t.Fatal("unreviewed constraint transition unexpectedly passed")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// Clearing a property through the reviewed mapping exclusion table is the one
|
||
// accepted shape, mirroring the interface_type retirement allowance. A leaf
|
||
// whose backing RPC moved to a nested payload has no honest flat property to
|
||
// publish; the alternatives are naming a field the request no longer contains,
|
||
// or letting flag_name_inference publish a name that appears in no request.
|
||
func TestCrossPlatformCoverageSchemaCompatPropertyClearingExclusion(t *testing.T) {
|
||
baseline := baselineContract()
|
||
|
||
// Accepted: non-empty -> empty, resolved through a reviewed exclusion.
|
||
current := cloneContract(baseline)
|
||
mutateParameter(¤t, func(parameter *parameterSchema) {
|
||
parameter.Property = ""
|
||
parameter.PropertySource = propertySourceReviewedMappingExclusion
|
||
})
|
||
if failures := checkCompatibility(baseline, current); len(failures) != 0 {
|
||
t.Fatalf("clearing property through a reviewed exclusion should pass: %v", failures)
|
||
}
|
||
|
||
// Every neighbouring shape must stay incompatible, so the carve-out cannot
|
||
// be widened by accident.
|
||
for _, tc := range []struct {
|
||
name string
|
||
property string
|
||
source string
|
||
}{
|
||
{"cleared by inference", "", "flag_name_inference"},
|
||
{"cleared by a native annotation", "", "native_annotation"},
|
||
{"cleared with no recorded source", "", ""},
|
||
{"redirected to another non-empty value", "cellStyles", propertySourceReviewedMappingExclusion},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
drifted := cloneContract(baseline)
|
||
mutateParameter(&drifted, func(parameter *parameterSchema) {
|
||
parameter.Property = tc.property
|
||
parameter.PropertySource = tc.source
|
||
})
|
||
failures := checkCompatibility(baseline, drifted)
|
||
if len(failures) == 0 {
|
||
t.Fatal("must remain incompatible")
|
||
}
|
||
var sawProperty bool
|
||
for _, failure := range failures {
|
||
if strings.Contains(failure, "changed property") {
|
||
sawProperty = true
|
||
}
|
||
}
|
||
if !sawProperty {
|
||
t.Fatalf("expected a changed property failure, got %v", failures)
|
||
}
|
||
})
|
||
}
|
||
|
||
// A parameter that never published a property must not gain one silently
|
||
// just because it carries an exclusion source.
|
||
populated := cloneContract(baseline)
|
||
mutateParameter(&populated, func(parameter *parameterSchema) { parameter.Property = "" })
|
||
gained := cloneContract(populated)
|
||
mutateParameter(&gained, func(parameter *parameterSchema) {
|
||
parameter.Property = "backgroundColors"
|
||
parameter.PropertySource = propertySourceReviewedMappingExclusion
|
||
})
|
||
if failures := checkCompatibility(populated, gained); len(failures) == 0 {
|
||
t.Fatal("populating a previously empty property must stay incompatible")
|
||
}
|
||
}
|
||
|
||
// Repointing a leaf at a different backing RPC is accepted only when the
|
||
// CLI-facing contract is provably unchanged. interface_ref is audit metadata;
|
||
// nothing reads it at runtime, so a stale value misinforms a reader rather than
|
||
// misrouting a call. The gate is that no other compatibility check for the tool
|
||
// failed — that is the operative meaning of "the contract is unchanged".
|
||
// reviewedInterfaceRefRedirect 的键值必须是 parseTool 经 canonicalRawJSON 实际产出
|
||
// 的紧凑 JSON。这条守卫是必要的:该表先前误登记为裸 RPC 名("update_range"),后又
|
||
// 误登记为带空格的美化 JSON,两次都让豁免完全失效——而上面的用例是用测试自己注册
|
||
// 的值断言的,所以两次都通过了,只有真实门禁才报错。这里用 canonicalRawJSON 复算
|
||
// 期望值,把格式锚定到生产代码而不是作者的书写习惯。
|
||
func TestCrossPlatformCoverageReviewedRedirectKeysAreCanonicalJSON(t *testing.T) {
|
||
canon := func(raw string) string {
|
||
got, err := canonicalRawJSON(json.RawMessage(raw))
|
||
if err != nil {
|
||
t.Fatalf("canonicalRawJSON(%s): %v", raw, err)
|
||
}
|
||
return got
|
||
}
|
||
if len(reviewedInterfaceRefRedirect) == 0 {
|
||
t.Fatal("redirect allowlist 为空:若确已清空,请同时删除这条守卫")
|
||
}
|
||
for toolPath, pairs := range reviewedInterfaceRefRedirect {
|
||
if len(pairs) == 0 {
|
||
t.Errorf("%s: 空的 redirect 表项没有意义", toolPath)
|
||
}
|
||
for oldRef, newRef := range pairs {
|
||
if got := canon(oldRef); got != oldRef {
|
||
t.Errorf("%s: old ref 不是规范形态\n 登记: %s\n 规范: %s", toolPath, oldRef, got)
|
||
}
|
||
if got := canon(newRef); got != newRef {
|
||
t.Errorf("%s: new ref 不是规范形态\n 登记: %s\n 规范: %s", toolPath, newRef, got)
|
||
}
|
||
if oldRef == newRef {
|
||
t.Errorf("%s: old 与 new 相同,不构成 redirect", toolPath)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestCrossPlatformCoverageSchemaCompatInterfaceRefRedirect(t *testing.T) {
|
||
// The redirect carve-out only accepts an explicitly reviewed tool + old→new
|
||
// ref pair. Register the fixture's path for the duration of this test so the
|
||
// accepted shapes below exercise the allowlist rather than a blanket rule.
|
||
const fixturePath = "doc/doc.create"
|
||
oldRef := `{"product_id":"sheet","rpc_name":"update_range"}`
|
||
newRef := `{"product_id":"sheet","rpc_name":"set_cell_range"}`
|
||
reviewedInterfaceRefRedirect[fixturePath] = map[string]string{oldRef: newRef}
|
||
t.Cleanup(func() { delete(reviewedInterfaceRefRedirect, fixturePath) })
|
||
|
||
// The baseline fixture is interface_mode=local; the carve-out only applies to
|
||
// mcp-backed leaves, so establish an mcp baseline first.
|
||
baseline := cloneContract(baselineContract())
|
||
mutateTool(&baseline, func(tool *toolSchema) {
|
||
tool.InterfaceMode = interfaceModeMCP
|
||
tool.InterfaceRef = oldRef
|
||
})
|
||
|
||
// Accepted: mcp -> mcp, both refs non-empty, the pair is reviewed, nothing
|
||
// else changed.
|
||
redirected := cloneContract(baseline)
|
||
mutateTool(&redirected, func(tool *toolSchema) { tool.InterfaceRef = newRef })
|
||
if failures := checkCompatibility(baseline, redirected); len(failures) != 0 {
|
||
t.Fatalf("a reviewed interface_ref redirect should pass: %v", failures)
|
||
}
|
||
|
||
// Paired with a reviewed property clearing, which is the realistic shape:
|
||
// a leaf moving to a nested payload loses its flat property names.
|
||
withCleared := cloneContract(redirected)
|
||
mutateParameter(&withCleared, func(parameter *parameterSchema) {
|
||
parameter.Property = ""
|
||
parameter.PropertySource = propertySourceReviewedMappingExclusion
|
||
})
|
||
if failures := checkCompatibility(baseline, withCleared); len(failures) != 0 {
|
||
t.Fatalf("redirect plus reviewed property clearing should pass: %v", failures)
|
||
}
|
||
|
||
// Rejected shapes. Each must still report the redirect, so a surface change
|
||
// cannot ride along behind a backend move.
|
||
for _, tc := range []struct {
|
||
name string
|
||
mutate func(*schemaContract)
|
||
}{
|
||
{"ref removed rather than redirected", func(contract *schemaContract) {
|
||
mutateTool(contract, func(tool *toolSchema) { tool.InterfaceRef = "" })
|
||
}},
|
||
{"mode moved to composite", func(contract *schemaContract) {
|
||
mutateTool(contract, func(tool *toolSchema) {
|
||
tool.InterfaceMode = "composite"
|
||
tool.InterfaceRef = ""
|
||
})
|
||
}},
|
||
{"mode moved away from mcp while the ref is redirected", func(contract *schemaContract) {
|
||
mutateTool(contract, func(tool *toolSchema) {
|
||
tool.InterfaceMode = "local"
|
||
tool.InterfaceRef = `{"product_id":"sheet","rpc_name":"set_cell_range"}`
|
||
})
|
||
}},
|
||
{"a parameter became required", func(contract *schemaContract) {
|
||
mutateTool(contract, func(tool *toolSchema) { tool.InterfaceRef = `{"product_id":"sheet","rpc_name":"set_cell_range"}` })
|
||
mutateParameter(contract, func(parameter *parameterSchema) { parameter.Required = true })
|
||
}},
|
||
{"a parameter type moved", func(contract *schemaContract) {
|
||
mutateTool(contract, func(tool *toolSchema) { tool.InterfaceRef = `{"product_id":"sheet","rpc_name":"set_cell_range"}` })
|
||
mutateParameter(contract, func(parameter *parameterSchema) { parameter.Type = "integer" })
|
||
}},
|
||
{"a property was cleared without a reviewed exclusion", func(contract *schemaContract) {
|
||
mutateTool(contract, func(tool *toolSchema) { tool.InterfaceRef = `{"product_id":"sheet","rpc_name":"set_cell_range"}` })
|
||
mutateParameter(contract, func(parameter *parameterSchema) {
|
||
parameter.Property = ""
|
||
parameter.PropertySource = "flag_name_inference"
|
||
})
|
||
}},
|
||
{"a confirmation gate moved", func(contract *schemaContract) {
|
||
mutateTool(contract, func(tool *toolSchema) {
|
||
tool.InterfaceRef = `{"product_id":"sheet","rpc_name":"set_cell_range"}`
|
||
tool.Confirmation = "user_required"
|
||
})
|
||
}},
|
||
{"a parameter was dropped", func(contract *schemaContract) {
|
||
mutateTool(contract, func(tool *toolSchema) {
|
||
tool.InterfaceRef = newRef
|
||
delete(tool.Parameters, "title")
|
||
})
|
||
}},
|
||
// The allowlist itself must be load-bearing: a redirect that is not an
|
||
// exact reviewed tool + old→new pair stays incompatible even when the rest
|
||
// of the contract is untouched. Schema shape cannot prove two RPCs share
|
||
// permissions, error taxonomy, or side effects.
|
||
{"redirect to a ref that is not the reviewed target", func(contract *schemaContract) {
|
||
mutateTool(contract, func(tool *toolSchema) {
|
||
tool.InterfaceRef = `{"product_id":"sheet","rpc_name":"some_other_rpc"}`
|
||
})
|
||
}},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
drifted := cloneContract(baseline)
|
||
tc.mutate(&drifted)
|
||
failures := checkCompatibility(baseline, drifted)
|
||
var sawRef bool
|
||
for _, failure := range failures {
|
||
if strings.Contains(failure, "changed interface_ref") {
|
||
sawRef = true
|
||
}
|
||
}
|
||
if !sawRef {
|
||
t.Fatalf("expected the redirect to stay reported, got %v", failures)
|
||
}
|
||
})
|
||
}
|
||
|
||
// The allowlist must be load-bearing: the exact same old→new pair on a tool
|
||
// with no reviewed entry stays incompatible. Asserted outside the table
|
||
// because the registration has to survive until checkCompatibility runs.
|
||
t.Run("redirect on a tool absent from the reviewed allowlist", func(t *testing.T) {
|
||
saved := reviewedInterfaceRefRedirect[fixturePath]
|
||
delete(reviewedInterfaceRefRedirect, fixturePath)
|
||
defer func() { reviewedInterfaceRefRedirect[fixturePath] = saved }()
|
||
|
||
unlisted := cloneContract(baseline)
|
||
mutateTool(&unlisted, func(tool *toolSchema) { tool.InterfaceRef = newRef })
|
||
failures := checkCompatibility(baseline, unlisted)
|
||
var sawRef bool
|
||
for _, failure := range failures {
|
||
if strings.Contains(failure, "changed interface_ref") {
|
||
sawRef = true
|
||
}
|
||
}
|
||
if !sawRef {
|
||
t.Fatalf("an unlisted tool must not get the redirect carve-out, got %v", failures)
|
||
}
|
||
})
|
||
}
|
||
|
||
func TestCrossPlatformCoverageSchemaFlagMigrationNormalizesExactRename(t *testing.T) {
|
||
baseline := schemaFlagMigrationContract(false)
|
||
current := schemaFlagMigrationContract(true)
|
||
|
||
normalized, err := normalizeSchemaFlagMigrations(baseline, current, schemaFlagMigrationAuthorizations())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if failures := checkCompatibility(normalized, current); len(failures) != 0 {
|
||
t.Fatalf("exact flag migration should pass after normalization: %v", failures)
|
||
}
|
||
|
||
tool := normalized.Products["chat"].Tools["chat.edit_message"]
|
||
for _, legacy := range []string{"group", "id", "msg-id"} {
|
||
if _, exists := tool.Parameters[legacy]; exists {
|
||
t.Fatalf("normalized baseline retained legacy parameter %q", legacy)
|
||
}
|
||
}
|
||
if canonical := tool.Parameters["conversation-id"]; canonical.Required || canonical.CLIRequired {
|
||
t.Fatalf("optional canonical rename changed requiredness: %#v", canonical)
|
||
}
|
||
if canonical := tool.Parameters["message-id"]; !canonical.Required || !canonical.CLIRequired {
|
||
t.Fatalf("required canonical rename changed requiredness: %#v", canonical)
|
||
}
|
||
if tool.Constraints != current.Products["chat"].Tools["chat.edit_message"].Constraints {
|
||
t.Fatalf("constraints were not normalized: %s", tool.Constraints)
|
||
}
|
||
}
|
||
|
||
func TestCrossPlatformCoverageSchemaFlagMigrationRejectsSemanticDrift(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
want string
|
||
mutate func(*parameterSchema)
|
||
}{
|
||
{name: "required decline", want: "must be required and cli_required", mutate: func(parameter *parameterSchema) {
|
||
parameter.Required = false
|
||
}},
|
||
{name: "cli required decline", want: "must be required and cli_required", mutate: func(parameter *parameterSchema) {
|
||
parameter.CLIRequired = false
|
||
}},
|
||
{name: "type", want: "non-migration field", mutate: func(parameter *parameterSchema) { parameter.Type = `"integer"` }},
|
||
{name: "property", want: "non-migration field", mutate: func(parameter *parameterSchema) { parameter.Property = "other" }},
|
||
{name: "interface type", want: "non-migration field", mutate: func(parameter *parameterSchema) { parameter.InterfaceType = "integer" }},
|
||
{name: "required when", want: "non-migration field", mutate: func(parameter *parameterSchema) { parameter.RequiredWhen = "mode=other" }},
|
||
{name: "default", want: "non-migration field", mutate: func(parameter *parameterSchema) { parameter.Default = `"other"` }},
|
||
{name: "interface default", want: "non-migration field", mutate: func(parameter *parameterSchema) { parameter.InterfaceDefault = `"other"` }},
|
||
{name: "format", want: "non-migration field", mutate: func(parameter *parameterSchema) { parameter.Format = "uri" }},
|
||
{name: "enum", want: "non-migration field", mutate: func(parameter *parameterSchema) { parameter.Enum = []string{"a"} }},
|
||
{name: "enum same length", want: "non-migration field", mutate: func(parameter *parameterSchema) { parameter.Enum = []string{"a", "c"} }},
|
||
}
|
||
for _, test := range tests {
|
||
t.Run(test.name, func(t *testing.T) {
|
||
current := schemaFlagMigrationContract(true)
|
||
product := current.Products["chat"]
|
||
tool := product.Tools["chat.edit_message"]
|
||
canonical := tool.Parameters["message-id"]
|
||
test.mutate(&canonical)
|
||
tool.Parameters["message-id"] = canonical
|
||
product.Tools["chat.edit_message"] = tool
|
||
current.Products["chat"] = product
|
||
|
||
_, err := normalizeSchemaFlagMigrations(
|
||
schemaFlagMigrationContract(false),
|
||
current,
|
||
schemaFlagMigrationAuthorizations(),
|
||
)
|
||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||
t.Fatalf("normalizeSchemaFlagMigrations() error = %v, want %q", err, test.want)
|
||
}
|
||
})
|
||
}
|
||
|
||
for _, test := range []struct {
|
||
name string
|
||
want string
|
||
mutate func(*parameterSchema)
|
||
}{
|
||
{name: "optional required promotion", want: "changed requiredness", mutate: func(parameter *parameterSchema) {
|
||
parameter.Required = true
|
||
}},
|
||
{name: "optional cli_required promotion", want: "changed cli_required", mutate: func(parameter *parameterSchema) {
|
||
parameter.CLIRequired = true
|
||
}},
|
||
} {
|
||
t.Run(test.name, func(t *testing.T) {
|
||
current := schemaFlagMigrationContract(true)
|
||
product := current.Products["chat"]
|
||
tool := product.Tools["chat.edit_message"]
|
||
canonical := tool.Parameters["conversation-id"]
|
||
test.mutate(&canonical)
|
||
tool.Parameters["conversation-id"] = canonical
|
||
product.Tools["chat.edit_message"] = tool
|
||
current.Products["chat"] = product
|
||
|
||
_, err := normalizeSchemaFlagMigrations(
|
||
schemaFlagMigrationContract(false),
|
||
current,
|
||
schemaFlagMigrationAuthorizations(),
|
||
)
|
||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||
t.Fatalf("normalizeSchemaFlagMigrations() error = %v, want %q", err, test.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestCrossPlatformCoverageSchemaFlagMigrationAdapterBranches(t *testing.T) {
|
||
baseline := schemaFlagMigrationContract(false)
|
||
current := schemaFlagMigrationContract(true)
|
||
noConstraintsBaseline := cloneContract(baseline)
|
||
product := noConstraintsBaseline.Products["chat"]
|
||
tool := product.Tools["chat.edit_message"]
|
||
tool.Constraints = ""
|
||
product.Tools["chat.edit_message"] = tool
|
||
noConstraintsBaseline.Products["chat"] = product
|
||
noConstraintsCurrent := cloneContract(current)
|
||
product = noConstraintsCurrent.Products["chat"]
|
||
tool = product.Tools["chat.edit_message"]
|
||
tool.Constraints = ""
|
||
product.Tools["chat.edit_message"] = tool
|
||
noConstraintsCurrent.Products["chat"] = product
|
||
if _, err := normalizeSchemaFlagMigrations(noConstraintsBaseline, noConstraintsCurrent, schemaFlagMigrationAuthorizations()); err != nil {
|
||
t.Fatalf("unchanged empty constraints: %v", err)
|
||
}
|
||
|
||
cliOnly := schemaFlagMigrationAuthorizations()[:1]
|
||
cliOnly[0].Command = "dws chat cli-only"
|
||
normalized, err := normalizeSchemaFlagMigrations(baseline, current, cliOnly)
|
||
if err != nil || !reflect.DeepEqual(normalized, baseline) {
|
||
t.Fatalf("CLI-only migration = %#v, %v", normalized, err)
|
||
}
|
||
|
||
duplicate := cloneContract(baseline)
|
||
product = duplicate.Products["chat"]
|
||
product.Tools["chat.another"] = product.Tools["chat.edit_message"]
|
||
duplicate.Products["chat"] = product
|
||
duplicate.Products["chat2"] = productSchema{Tools: map[string]toolSchema{
|
||
"chat.third": product.Tools["chat.edit_message"],
|
||
}}
|
||
if matches := schemaToolsByPrimaryPath(duplicate, "chat message edit"); len(matches) != 3 || matches[0].productID != "chat" || matches[0].toolID != "chat.another" {
|
||
t.Fatalf("sorted duplicate Schema matches = %#v", matches)
|
||
}
|
||
if _, err := normalizeSchemaFlagMigrations(duplicate, current, schemaFlagMigrationAuthorizations()[:1]); err == nil || !strings.Contains(err.Error(), "matches 3") {
|
||
t.Fatalf("duplicate Schema path error = %v", err)
|
||
}
|
||
|
||
missingCurrent := cloneContract(current)
|
||
delete(missingCurrent.Products, "chat")
|
||
normalized, err = normalizeSchemaFlagMigrations(baseline, missingCurrent, schemaFlagMigrationAuthorizations())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if failures := checkCompatibility(normalized, missingCurrent); len(failures) == 0 {
|
||
t.Fatal("missing candidate product was normalized away")
|
||
}
|
||
|
||
canonicalOnlyMigration := schemaFlagMigrationAuthorizations()[0]
|
||
canonicalOnlyMigration.Legacy.Name = "legacy-not-published-in-schema"
|
||
canonicalOnlyBaseline := schemaFlagMigrationContract(true)
|
||
driftedCanonical := cloneContract(canonicalOnlyBaseline)
|
||
product = driftedCanonical.Products["chat"]
|
||
tool = product.Tools["chat.edit_message"]
|
||
canonical := tool.Parameters["conversation-id"]
|
||
canonical.Property = "different"
|
||
tool.Parameters["conversation-id"] = canonical
|
||
product.Tools["chat.edit_message"] = tool
|
||
driftedCanonical.Products["chat"] = product
|
||
normalized, err = normalizeSchemaFlagMigrations(canonicalOnlyBaseline, driftedCanonical, []interfacesnapshot.FlagMigration{canonicalOnlyMigration})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if failures := strings.Join(checkCompatibility(normalized, driftedCanonical), "\n"); !strings.Contains(failures, "changed property") {
|
||
t.Fatalf("canonical-only Schema drift was hidden: %s", failures)
|
||
}
|
||
|
||
promotedCanonical := cloneContract(canonicalOnlyBaseline)
|
||
product = promotedCanonical.Products["chat"]
|
||
tool = product.Tools["chat.edit_message"]
|
||
canonical = tool.Parameters["conversation-id"]
|
||
canonical.Required = true
|
||
canonical.CLIRequired = true
|
||
tool.Parameters["conversation-id"] = canonical
|
||
product.Tools["chat.edit_message"] = tool
|
||
promotedCanonical.Products["chat"] = product
|
||
normalized, err = normalizeSchemaFlagMigrations(
|
||
canonicalOnlyBaseline,
|
||
promotedCanonical,
|
||
[]interfacesnapshot.FlagMigration{canonicalOnlyMigration},
|
||
)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if failures := strings.Join(checkCompatibility(normalized, promotedCanonical), "\n"); !strings.Contains(failures, "newly required") || !strings.Contains(failures, "newly cli_required") {
|
||
t.Fatalf("canonical-only required promotion was hidden: %s", failures)
|
||
}
|
||
|
||
canonicalConflictBaseline := schemaFlagMigrationContract(false)
|
||
product = canonicalConflictBaseline.Products["chat"]
|
||
tool = product.Tools["chat.edit_message"]
|
||
canonical = tool.Parameters["conversation-id"]
|
||
canonical.Property = "historicalCanonicalProperty"
|
||
tool.Parameters["conversation-id"] = canonical
|
||
product.Tools["chat.edit_message"] = tool
|
||
canonicalConflictBaseline.Products["chat"] = product
|
||
if _, err := normalizeSchemaFlagMigrations(
|
||
canonicalConflictBaseline,
|
||
schemaFlagMigrationContract(true),
|
||
schemaFlagMigrationAuthorizations()[:1],
|
||
); err == nil || !strings.Contains(err.Error(), "non-migration field") {
|
||
t.Fatalf("historical canonical drift error = %v", err)
|
||
}
|
||
|
||
conflicting := schemaFlagMigrationAuthorizations()[0]
|
||
conflicting.Canonical.Name = "other-conversation-id"
|
||
conflicting.Legacy.After.AliasOf = conflicting.Canonical.Name
|
||
product = current.Products["chat"]
|
||
tool = product.Tools["chat.edit_message"]
|
||
tool.Parameters[conflicting.Canonical.Name] = tool.Parameters["conversation-id"]
|
||
product.Tools["chat.edit_message"] = tool
|
||
current.Products["chat"] = product
|
||
if _, err := normalizeSchemaFlagMigrations(baseline, current, []interfacesnapshot.FlagMigration{
|
||
schemaFlagMigrationAuthorizations()[0],
|
||
conflicting,
|
||
}); err == nil || !strings.Contains(err.Error(), "maps Schema parameter") {
|
||
t.Fatalf("conflicting Schema mapping error = %v", err)
|
||
}
|
||
|
||
old := parameterSchema{Required: true, CLIRequired: true}
|
||
if err := validateRenamedSchemaParameter(schemaFlagMigrationAuthorizations()[0], old, parameterSchema{CLIRequired: true}); err == nil || !strings.Contains(err.Error(), "changed requiredness") {
|
||
t.Fatalf("direct required decline error = %v", err)
|
||
}
|
||
if err := validateRenamedSchemaParameter(schemaFlagMigrationAuthorizations()[0], old, parameterSchema{Required: true}); err == nil || !strings.Contains(err.Error(), "changed cli_required") {
|
||
t.Fatalf("direct cli_required decline error = %v", err)
|
||
}
|
||
optional := parameterSchema{}
|
||
if err := validateRenamedSchemaParameter(schemaFlagMigrationAuthorizations()[0], optional, parameterSchema{Required: true}); err == nil || !strings.Contains(err.Error(), "changed requiredness") {
|
||
t.Fatalf("direct required promotion error = %v", err)
|
||
}
|
||
if err := validateRenamedSchemaParameter(schemaFlagMigrationAuthorizations()[0], optional, parameterSchema{CLIRequired: true}); err == nil || !strings.Contains(err.Error(), "changed cli_required") {
|
||
t.Fatalf("direct cli_required promotion error = %v", err)
|
||
}
|
||
}
|
||
|
||
func TestCrossPlatformCoverageSchemaFlagMigrationRejectsPartialAndUnrelatedChanges(t *testing.T) {
|
||
baseline := schemaFlagMigrationContract(false)
|
||
migrations := schemaFlagMigrationAuthorizations()
|
||
|
||
current := schemaFlagMigrationContract(true)
|
||
product := current.Products["chat"]
|
||
tool := product.Tools["chat.edit_message"]
|
||
delete(tool.Parameters, "message-id")
|
||
product.Tools["chat.edit_message"] = tool
|
||
current.Products["chat"] = product
|
||
if _, err := normalizeSchemaFlagMigrations(baseline, current, migrations); err == nil || !strings.Contains(err.Error(), "does not publish canonical") {
|
||
t.Fatalf("missing canonical error = %v", err)
|
||
}
|
||
|
||
current = schemaFlagMigrationContract(true)
|
||
product = current.Products["chat"]
|
||
tool = product.Tools["chat.edit_message"]
|
||
tool.Parameters["msg-id"] = schemaFlagMigrationContract(false).Products["chat"].Tools["chat.edit_message"].Parameters["msg-id"]
|
||
product.Tools["chat.edit_message"] = tool
|
||
current.Products["chat"] = product
|
||
if _, err := normalizeSchemaFlagMigrations(baseline, current, migrations); err == nil || !strings.Contains(err.Error(), "still publishes legacy") {
|
||
t.Fatalf("published legacy error = %v", err)
|
||
}
|
||
|
||
current = schemaFlagMigrationContract(true)
|
||
product = current.Products["chat"]
|
||
tool = product.Tools["chat.edit_message"]
|
||
delete(tool.Parameters, "unrelated")
|
||
product.Tools["chat.edit_message"] = tool
|
||
current.Products["chat"] = product
|
||
normalized, err := normalizeSchemaFlagMigrations(baseline, current, migrations)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if failures := strings.Join(checkCompatibility(normalized, current), "\n"); !strings.Contains(failures, `lost parameter "unrelated"`) {
|
||
t.Fatalf("unrelated parameter removal was hidden: %s", failures)
|
||
}
|
||
|
||
current = schemaFlagMigrationContract(true)
|
||
product = current.Products["chat"]
|
||
tool = product.Tools["chat.edit_message"]
|
||
tool.Constraints = `{"require_one_of":[["conversation-id"]],"require_together":[["conversation-id","unrelated"]]}`
|
||
product.Tools["chat.edit_message"] = tool
|
||
current.Products["chat"] = product
|
||
normalized, err = normalizeSchemaFlagMigrations(baseline, current, migrations)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if failures := strings.Join(checkCompatibility(normalized, current), "\n"); !strings.Contains(failures, "changed constraints") {
|
||
t.Fatalf("unrelated constraint change was hidden: %s", failures)
|
||
}
|
||
|
||
// A CLI ledger entry alone cannot authorize a Schema constraint rewrite
|
||
// when the historical Schema published neither the legacy nor canonical
|
||
// parameter involved in that entry.
|
||
noSchemaEvidence := schemaFlagMigrationContract(false)
|
||
product = noSchemaEvidence.Products["chat"]
|
||
tool = product.Tools["chat.edit_message"]
|
||
for _, name := range []string{"conversation-id", "group", "id", "msg-id"} {
|
||
delete(tool.Parameters, name)
|
||
}
|
||
product.Tools["chat.edit_message"] = tool
|
||
noSchemaEvidence.Products["chat"] = product
|
||
normalized, err = normalizeSchemaFlagMigrations(noSchemaEvidence, schemaFlagMigrationContract(true), migrations)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if failures := strings.Join(checkCompatibility(normalized, schemaFlagMigrationContract(true)), "\n"); !strings.Contains(failures, "changed constraints") {
|
||
t.Fatalf("constraint rewrite without Schema parameter evidence was hidden: %s", failures)
|
||
}
|
||
|
||
// A baseline that already contains only the canonical parameter is not
|
||
// evidence that a stray legacy name in constraints belongs to the migration.
|
||
canonicalOnly := schemaFlagMigrationContract(true)
|
||
product = canonicalOnly.Products["chat"]
|
||
tool = product.Tools["chat.edit_message"]
|
||
tool.Constraints = `{"require_one_of":[["group"]]}`
|
||
product.Tools["chat.edit_message"] = tool
|
||
canonicalOnly.Products["chat"] = product
|
||
normalized, err = normalizeSchemaFlagMigrations(canonicalOnly, schemaFlagMigrationContract(true), migrations)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if failures := strings.Join(checkCompatibility(normalized, schemaFlagMigrationContract(true)), "\n"); !strings.Contains(failures, "changed constraints") {
|
||
t.Fatalf("canonical-only baseline authorized a legacy constraint rewrite: %s", failures)
|
||
}
|
||
}
|
||
|
||
func TestCrossPlatformCoverageSchemaFlagMigrationConstraintsFailClosed(t *testing.T) {
|
||
for _, constraints := range []string{
|
||
`{`,
|
||
`{"`,
|
||
`{"require_one_of":`,
|
||
`{"unknown_kind":[["conversation-id"]]}`,
|
||
`{"require_one_of":"conversation-id"}`,
|
||
`{"require_one_of":["conversation-id"]}`,
|
||
`{"require_one_of":null}`,
|
||
`{"require_one_of":[[]]}`,
|
||
`{"require_one_of":[[" conversation-id"]]}`,
|
||
`{"require_one_of":[["conversation-id"]],"require_one_of":[["conversation-id"]]}`,
|
||
`{"require_one_of":[]`,
|
||
`{} {}`,
|
||
`[]`,
|
||
} {
|
||
current := schemaFlagMigrationContract(true)
|
||
product := current.Products["chat"]
|
||
tool := product.Tools["chat.edit_message"]
|
||
tool.Constraints = constraints
|
||
product.Tools["chat.edit_message"] = tool
|
||
current.Products["chat"] = product
|
||
|
||
normalized, err := normalizeSchemaFlagMigrations(
|
||
schemaFlagMigrationContract(false),
|
||
current,
|
||
schemaFlagMigrationAuthorizations(),
|
||
)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if failures := strings.Join(checkCompatibility(normalized, current), "\n"); !strings.Contains(failures, "changed constraints") {
|
||
t.Fatalf("malformed constraints %s were authorized: %s", constraints, failures)
|
||
}
|
||
}
|
||
|
||
if encoded, ok := canonicalizeMigratedConstraints("", nil); !ok || encoded != "" {
|
||
t.Fatalf("empty migrated constraints = %q, %v", encoded, ok)
|
||
}
|
||
encoded, ok := canonicalizeMigratedConstraints(
|
||
`{"require_one_of":[["b"],["a"],["a"],["c","c"]]}`,
|
||
nil,
|
||
)
|
||
if !ok || encoded != `{"require_one_of":[["a"],["b"],["c"]]}` {
|
||
t.Fatalf("canonical migrated constraint groups = %q, %v", encoded, ok)
|
||
}
|
||
}
|
||
|
||
func TestCrossPlatformCoverageSchemaMigrationCLIRequiresCompleteCheckInputs(t *testing.T) {
|
||
allInputs := []string{
|
||
"--approved-flag-migrations", "approved.json",
|
||
"--candidate-flag-migrations", "candidate.json",
|
||
"--migration-current-snapshot", "current.json",
|
||
"--migration-base-snapshot", "base.json",
|
||
"--migration-stable-snapshot", "stable.json",
|
||
}
|
||
var stdout, stderr bytes.Buffer
|
||
if code := run([]string{"--check", "baseline.json", "--current", "schema.json", "--approved-flag-migrations", "approved.json"}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "both flag manifests") {
|
||
t.Fatalf("partial migration inputs code=%d stderr=%q", code, stderr.String())
|
||
}
|
||
|
||
for _, mode := range []string{"--normalize", "--merge"} {
|
||
stderr.Reset()
|
||
args := append([]string{mode, "schema.json"}, allInputs...)
|
||
if code := run(args, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "only valid with --check") {
|
||
t.Fatalf("%s migration inputs code=%d stderr=%q", mode, code, stderr.String())
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestCrossPlatformCoverageSchemaMigrationLifecycleAndRun(t *testing.T) {
|
||
directory := t.TempDir()
|
||
baselinePath := filepath.Join(directory, "schema-baseline.json")
|
||
currentSchemaPath := filepath.Join(directory, "schema-current.json")
|
||
writeSchemaContractFile(t, baselinePath, schemaFlagMigrationContract(false))
|
||
writeRawSchemaContractFile(t, currentSchemaPath, schemaFlagMigrationContract(true))
|
||
|
||
approvedPath := filepath.Join(directory, "approved.json")
|
||
candidatePath := filepath.Join(directory, "candidate.json")
|
||
currentSnapshotPath := filepath.Join(directory, "interface-current.json")
|
||
baseSnapshotPath := filepath.Join(directory, "interface-base.json")
|
||
stableSnapshotPath := filepath.Join(directory, "interface-stable.json")
|
||
writeFlagMigrationManifestFile(t, approvedPath, schemaFlagMigrationManifest(interfacesnapshot.FlagMigrationPending))
|
||
writeFlagMigrationManifestFile(t, candidatePath, schemaFlagMigrationManifest(interfacesnapshot.FlagMigrationConsumed))
|
||
writeInterfaceSnapshotFile(t, currentSnapshotPath, schemaFlagMigrationSnapshot(true))
|
||
writeInterfaceSnapshotFile(t, baseSnapshotPath, schemaFlagMigrationSnapshot(false))
|
||
writeInterfaceSnapshotFile(t, stableSnapshotPath, schemaFlagMigrationSnapshot(false))
|
||
|
||
args := []string{
|
||
"--check", baselinePath,
|
||
"--current", currentSchemaPath,
|
||
"--approved-flag-migrations", approvedPath,
|
||
"--candidate-flag-migrations", candidatePath,
|
||
"--migration-current-snapshot", currentSnapshotPath,
|
||
"--migration-base-snapshot", baseSnapshotPath,
|
||
"--migration-stable-snapshot", stableSnapshotPath,
|
||
}
|
||
var stdout, stderr bytes.Buffer
|
||
if code := run(args, &stdout, &stderr); code != 0 {
|
||
t.Fatalf("governed Schema check code=%d stderr=%s", code, stderr.String())
|
||
}
|
||
if !strings.Contains(stdout.String(), "compatibility check: ok") {
|
||
t.Fatalf("governed Schema check output = %q", stdout.String())
|
||
}
|
||
|
||
badArgs := append([]string(nil), args...)
|
||
badArgs[5] = filepath.Join(directory, "missing-approved.json")
|
||
stderr.Reset()
|
||
if code := run(badArgs, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "authorize Schema flag migrations") {
|
||
t.Fatalf("authorization error code=%d stderr=%q", code, stderr.String())
|
||
}
|
||
|
||
legacyCurrent := schemaFlagMigrationContract(true)
|
||
product := legacyCurrent.Products["chat"]
|
||
tool := product.Tools["chat.edit_message"]
|
||
tool.Parameters["msg-id"] = schemaFlagMigrationContract(false).Products["chat"].Tools["chat.edit_message"].Parameters["msg-id"]
|
||
product.Tools["chat.edit_message"] = tool
|
||
legacyCurrent.Products["chat"] = product
|
||
writeRawSchemaContractFile(t, currentSchemaPath, legacyCurrent)
|
||
stderr.Reset()
|
||
if code := run(args, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "normalize approved Schema flag migrations") {
|
||
t.Fatalf("normalization error code=%d stderr=%q", code, stderr.String())
|
||
}
|
||
|
||
// Candidate-owned pending records cannot approve their own after-state.
|
||
writeFlagMigrationManifestFile(t, approvedPath, interfacesnapshot.FlagMigrationManifest{
|
||
Version: interfacesnapshot.FlagMigrationManifestVersion,
|
||
Migrations: []interfacesnapshot.FlagMigration{},
|
||
})
|
||
writeFlagMigrationManifestFile(t, candidatePath, schemaFlagMigrationManifest(interfacesnapshot.FlagMigrationPending))
|
||
if _, err := authorizeSchemaFlagMigrations(approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") {
|
||
t.Fatalf("self-approval error = %v", err)
|
||
}
|
||
|
||
// An incomplete after-state fails lifecycle authorization before Schema is normalized.
|
||
writeFlagMigrationManifestFile(t, approvedPath, schemaFlagMigrationManifest(interfacesnapshot.FlagMigrationPending))
|
||
writeFlagMigrationManifestFile(t, candidatePath, schemaFlagMigrationManifest(interfacesnapshot.FlagMigrationConsumed))
|
||
partial := schemaFlagMigrationSnapshot(true)
|
||
partial.Commands[0].LocalFlags = partial.Commands[0].LocalFlags[:len(partial.Commands[0].LocalFlags)-1]
|
||
writeInterfaceSnapshotFile(t, currentSnapshotPath, partial)
|
||
if _, err := authorizeSchemaFlagMigrations(approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath); err == nil || !strings.Contains(err.Error(), "partially applied") {
|
||
t.Fatalf("partial migration error = %v", err)
|
||
}
|
||
}
|
||
|
||
func TestCrossPlatformCoverageSchemaMigrationFileErrors(t *testing.T) {
|
||
directory := t.TempDir()
|
||
approvedPath := filepath.Join(directory, "approved.json")
|
||
candidatePath := filepath.Join(directory, "candidate.json")
|
||
currentPath := filepath.Join(directory, "current.json")
|
||
basePath := filepath.Join(directory, "base.json")
|
||
stablePath := filepath.Join(directory, "stable.json")
|
||
writeFlagMigrationManifestFile(t, approvedPath, schemaFlagMigrationManifest(interfacesnapshot.FlagMigrationPending))
|
||
writeFlagMigrationManifestFile(t, candidatePath, schemaFlagMigrationManifest(interfacesnapshot.FlagMigrationConsumed))
|
||
writeInterfaceSnapshotFile(t, currentPath, schemaFlagMigrationSnapshot(true))
|
||
writeInterfaceSnapshotFile(t, basePath, schemaFlagMigrationSnapshot(false))
|
||
writeInterfaceSnapshotFile(t, stablePath, schemaFlagMigrationSnapshot(false))
|
||
|
||
paths := []string{approvedPath, candidatePath, currentPath, basePath, stablePath}
|
||
wants := []string{
|
||
"read approved flag migrations",
|
||
"read candidate flag migrations",
|
||
"read migration current snapshot",
|
||
"read migration base snapshot",
|
||
"read migration stable snapshot",
|
||
}
|
||
for index, want := range wants {
|
||
t.Run(want, func(t *testing.T) {
|
||
invalid := append([]string(nil), paths...)
|
||
invalid[index] = filepath.Join(directory, "missing.json")
|
||
_, err := authorizeSchemaFlagMigrations(invalid[0], invalid[1], invalid[2], invalid[3], invalid[4])
|
||
if err == nil || !strings.Contains(err.Error(), want) {
|
||
t.Fatalf("authorizeSchemaFlagMigrations() error = %v, want %q", err, want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestCrossPlatformCoverageSchemaConsumedReceiptIsNoOpForAfterBaseline(t *testing.T) {
|
||
directory := t.TempDir()
|
||
approvedPath := filepath.Join(directory, "approved.json")
|
||
candidatePath := filepath.Join(directory, "candidate.json")
|
||
currentSnapshotPath := filepath.Join(directory, "current.json")
|
||
baseSnapshotPath := filepath.Join(directory, "base.json")
|
||
stableSnapshotPath := filepath.Join(directory, "stable.json")
|
||
writeFlagMigrationManifestFile(t, approvedPath, schemaFlagMigrationManifest(interfacesnapshot.FlagMigrationConsumed))
|
||
writeFlagMigrationManifestFile(t, candidatePath, schemaFlagMigrationManifest(interfacesnapshot.FlagMigrationConsumed))
|
||
writeInterfaceSnapshotFile(t, currentSnapshotPath, schemaFlagMigrationSnapshot(true))
|
||
writeInterfaceSnapshotFile(t, baseSnapshotPath, schemaFlagMigrationSnapshot(true))
|
||
writeInterfaceSnapshotFile(t, stableSnapshotPath, schemaFlagMigrationSnapshot(false))
|
||
|
||
migrations, err := authorizeSchemaFlagMigrations(approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
baseline := schemaFlagMigrationContract(true)
|
||
normalized, err := normalizeSchemaFlagMigrations(baseline, schemaFlagMigrationContract(true), migrations)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !reflect.DeepEqual(normalized, baseline) {
|
||
t.Fatalf("consumed receipt changed an already-after Schema baseline: %#v", normalized)
|
||
}
|
||
if failures := checkCompatibility(normalized, schemaFlagMigrationContract(true)); len(failures) != 0 {
|
||
t.Fatalf("consumed receipt did not preserve compatibility: %v", failures)
|
||
}
|
||
|
||
// Once stable also reaches after, retaining the consumed receipt is stale;
|
||
// deleting it yields no authorization and the already-after Schema passes.
|
||
writeInterfaceSnapshotFile(t, stableSnapshotPath, schemaFlagMigrationSnapshot(true))
|
||
if _, err := authorizeSchemaFlagMigrations(approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath); err == nil || !strings.Contains(err.Error(), "stale after all references reached the after state") {
|
||
t.Fatalf("stale consumed receipt error = %v", err)
|
||
}
|
||
emptyManifest := interfacesnapshot.FlagMigrationManifest{
|
||
Version: interfacesnapshot.FlagMigrationManifestVersion,
|
||
Migrations: []interfacesnapshot.FlagMigration{},
|
||
}
|
||
writeFlagMigrationManifestFile(t, candidatePath, emptyManifest)
|
||
migrations, err = authorizeSchemaFlagMigrations(approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath)
|
||
if err != nil || len(migrations) != 0 {
|
||
t.Fatalf("cleaned consumed receipt authorizations = %#v, %v", migrations, err)
|
||
}
|
||
|
||
baselinePath := filepath.Join(directory, "schema-baseline.json")
|
||
currentSchemaPath := filepath.Join(directory, "schema-current.json")
|
||
writeSchemaContractFile(t, baselinePath, baseline)
|
||
writeRawSchemaContractFile(t, currentSchemaPath, schemaFlagMigrationContract(true))
|
||
args := []string{
|
||
"--check", baselinePath,
|
||
"--current", currentSchemaPath,
|
||
"--approved-flag-migrations", approvedPath,
|
||
"--candidate-flag-migrations", candidatePath,
|
||
"--migration-current-snapshot", currentSnapshotPath,
|
||
"--migration-base-snapshot", baseSnapshotPath,
|
||
"--migration-stable-snapshot", stableSnapshotPath,
|
||
}
|
||
var stdout, stderr bytes.Buffer
|
||
if code := run(args, &stdout, &stderr); code != 0 {
|
||
t.Fatalf("cleaned consumed receipt check code=%d stderr=%q", code, stderr.String())
|
||
}
|
||
}
|
||
|
||
func schemaFlagMigrationContract(after bool) schemaContract {
|
||
legacyMessage := parameterSchema{
|
||
Type: `"string"`,
|
||
Property: "openMessageId",
|
||
InterfaceType: "string",
|
||
Required: true,
|
||
CLIRequired: true,
|
||
Default: `"default-message"`,
|
||
InterfaceDefault: `"default-message"`,
|
||
Format: "id",
|
||
Enum: []string{"a", "b"},
|
||
}
|
||
conversation := parameterSchema{
|
||
Type: `"string"`,
|
||
Property: "openConversationId",
|
||
InterfaceType: "string",
|
||
}
|
||
parameters := map[string]parameterSchema{
|
||
"unrelated": {Type: `"string"`, Property: "unrelated"},
|
||
}
|
||
constraints := `{"require_one_of":[["group","id"]]}`
|
||
if after {
|
||
parameters["conversation-id"] = conversation
|
||
parameters["message-id"] = legacyMessage
|
||
constraints = `{"require_one_of":[["conversation-id"]]}`
|
||
} else {
|
||
parameters["group"] = conversation
|
||
parameters["id"] = conversation
|
||
parameters["msg-id"] = legacyMessage
|
||
}
|
||
return schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{
|
||
"chat": {Tools: map[string]toolSchema{
|
||
"chat.edit_message": {
|
||
PrimaryCLIPath: "chat message edit",
|
||
InterfaceMode: "mcp",
|
||
Availability: "available",
|
||
Parameters: parameters,
|
||
Constraints: constraints,
|
||
Effect: "write",
|
||
Risk: "medium",
|
||
Confirmation: "not_required",
|
||
Idempotency: "unknown",
|
||
},
|
||
}},
|
||
}}
|
||
}
|
||
|
||
func schemaFlagMigrationAuthorizations() []interfacesnapshot.FlagMigration {
|
||
conversationBefore := interfacesnapshot.FlagMigrationState{
|
||
Present: true,
|
||
Type: "string",
|
||
Scope: "local",
|
||
}
|
||
conversationAfter := conversationBefore
|
||
messageBefore := interfacesnapshot.FlagMigrationState{
|
||
Present: true,
|
||
Type: "string",
|
||
Required: true,
|
||
Scope: "local",
|
||
}
|
||
messageAfter := messageBefore
|
||
return []interfacesnapshot.FlagMigration{
|
||
{
|
||
Command: "dws chat message edit",
|
||
Legacy: interfacesnapshot.FlagMigrationSide{
|
||
Name: "group",
|
||
Before: conversationBefore,
|
||
After: interfacesnapshot.FlagMigrationState{
|
||
Present: true, Type: "string", Hidden: true, Scope: "local", AliasOf: "conversation-id",
|
||
},
|
||
},
|
||
Canonical: interfacesnapshot.FlagMigrationSide{
|
||
Name: "conversation-id",
|
||
Before: interfacesnapshot.FlagMigrationState{},
|
||
After: conversationAfter,
|
||
},
|
||
},
|
||
{
|
||
Command: "dws chat message edit",
|
||
Legacy: interfacesnapshot.FlagMigrationSide{
|
||
Name: "id",
|
||
Before: conversationBefore,
|
||
After: interfacesnapshot.FlagMigrationState{
|
||
Present: true, Type: "string", Hidden: true, Scope: "local", AliasOf: "conversation-id",
|
||
},
|
||
},
|
||
Canonical: interfacesnapshot.FlagMigrationSide{
|
||
Name: "conversation-id",
|
||
Before: interfacesnapshot.FlagMigrationState{},
|
||
After: conversationAfter,
|
||
},
|
||
},
|
||
{
|
||
Command: "dws chat message edit",
|
||
Legacy: interfacesnapshot.FlagMigrationSide{
|
||
Name: "msg-id",
|
||
Before: messageBefore,
|
||
After: interfacesnapshot.FlagMigrationState{
|
||
Present: true, Type: "string", Hidden: true, Scope: "local", AliasOf: "message-id",
|
||
},
|
||
},
|
||
Canonical: interfacesnapshot.FlagMigrationSide{
|
||
Name: "message-id",
|
||
Before: interfacesnapshot.FlagMigrationState{},
|
||
After: messageAfter,
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
func schemaFlagMigrationManifest(state string) interfacesnapshot.FlagMigrationManifest {
|
||
migrations := schemaFlagMigrationAuthorizations()
|
||
for index := range migrations {
|
||
migrations[index].State = state
|
||
migrations[index].Reason = "canonicalize historical public flag names"
|
||
}
|
||
return interfacesnapshot.FlagMigrationManifest{
|
||
Version: interfacesnapshot.FlagMigrationManifestVersion,
|
||
Migrations: migrations,
|
||
}
|
||
}
|
||
|
||
func schemaFlagMigrationSnapshot(after bool) interfacesnapshot.Snapshot {
|
||
states := map[string]interfacesnapshot.FlagMigrationState{}
|
||
for _, migration := range schemaFlagMigrationAuthorizations() {
|
||
if after {
|
||
states[migration.Legacy.Name] = migration.Legacy.After
|
||
states[migration.Canonical.Name] = migration.Canonical.After
|
||
} else {
|
||
states[migration.Legacy.Name] = migration.Legacy.Before
|
||
states[migration.Canonical.Name] = migration.Canonical.Before
|
||
}
|
||
}
|
||
names := make([]string, 0, len(states))
|
||
for name, state := range states {
|
||
if state.Present {
|
||
names = append(names, name)
|
||
}
|
||
}
|
||
sort.Strings(names)
|
||
flags := make([]interfacesnapshot.Flag, 0, len(names))
|
||
for _, name := range names {
|
||
state := states[name]
|
||
flags = append(flags, interfacesnapshot.Flag{
|
||
Name: name,
|
||
Type: state.Type,
|
||
Required: state.Required,
|
||
Hidden: state.Hidden,
|
||
Shorthand: state.Shorthand,
|
||
NoOpt: state.NoOpt,
|
||
AliasOf: state.AliasOf,
|
||
})
|
||
}
|
||
return interfacesnapshot.Snapshot{
|
||
SchemaVersion: interfacesnapshot.SchemaVersion,
|
||
Rules: interfacesnapshot.Rules{
|
||
ExcludedCommandSubtrees: []string{},
|
||
ExcludedFlags: []string{},
|
||
},
|
||
Commands: []interfacesnapshot.Command{{
|
||
Path: "dws chat message edit",
|
||
Runnable: true,
|
||
Aliases: []string{},
|
||
LocalFlags: flags,
|
||
}},
|
||
}
|
||
}
|
||
|
||
func writeFlagMigrationManifestFile(t *testing.T, path string, manifest interfacesnapshot.FlagMigrationManifest) {
|
||
t.Helper()
|
||
data, err := json.MarshalIndent(manifest, "", " ")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
func writeInterfaceSnapshotFile(t *testing.T, path string, snapshot interfacesnapshot.Snapshot) {
|
||
t.Helper()
|
||
var encoded bytes.Buffer
|
||
if err := interfacesnapshot.Write(&encoded, snapshot); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
func writeSchemaContractFile(t *testing.T, path string, contract schemaContract) {
|
||
t.Helper()
|
||
var encoded bytes.Buffer
|
||
if err := writeContract(&encoded, contract); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
func writeRawSchemaContractFile(t *testing.T, path string, contract schemaContract) {
|
||
t.Helper()
|
||
products := make([]map[string]any, 0, len(contract.Products))
|
||
for productID, product := range contract.Products {
|
||
tools := make([]map[string]any, 0, len(product.Tools))
|
||
for toolID, tool := range product.Tools {
|
||
parameters := map[string]any{}
|
||
for name, parameter := range tool.Parameters {
|
||
raw := map[string]any{
|
||
"type": json.RawMessage(parameter.Type),
|
||
"required": parameter.Required,
|
||
"cli_required": parameter.CLIRequired,
|
||
"property": parameter.Property,
|
||
"interface_type": parameter.InterfaceType,
|
||
"required_when": parameter.RequiredWhen,
|
||
"format": parameter.Format,
|
||
"enum": parameter.Enum,
|
||
"field_provenance": map[string]any{},
|
||
}
|
||
if parameter.Default != "" {
|
||
raw["default"] = json.RawMessage(parameter.Default)
|
||
}
|
||
if parameter.InterfaceDefault != "" {
|
||
raw["interface_default"] = json.RawMessage(parameter.InterfaceDefault)
|
||
}
|
||
parameters[name] = raw
|
||
}
|
||
rawTool := map[string]any{
|
||
"canonical_path": toolID,
|
||
"primary_cli_path": tool.PrimaryCLIPath,
|
||
"interface_mode": tool.InterfaceMode,
|
||
"availability": tool.Availability,
|
||
"parameters": parameters,
|
||
"effect": tool.Effect,
|
||
"risk": tool.Risk,
|
||
"confirmation": tool.Confirmation,
|
||
"idempotency": tool.Idempotency,
|
||
"field_provenance": map[string]any{},
|
||
}
|
||
if tool.InterfaceRef != "" {
|
||
rawTool["interface_ref"] = json.RawMessage(tool.InterfaceRef)
|
||
}
|
||
if tool.Constraints != "" {
|
||
rawTool["constraints"] = json.RawMessage(tool.Constraints)
|
||
}
|
||
if len(tool.Positionals) > 0 {
|
||
rawTool["positionals"] = tool.Positionals
|
||
}
|
||
if tool.DryRun != "" {
|
||
rawTool["dry_run"] = json.RawMessage(tool.DryRun)
|
||
}
|
||
tools = append(tools, rawTool)
|
||
}
|
||
products = append(products, map[string]any{"id": productID, "tools": tools})
|
||
}
|
||
payload := map[string]any{"kind": "schema", "products": products}
|
||
data, err := json.Marshal(payload)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|