diff --git a/internal/interfacesnapshot/command_migrations.go b/internal/interfacesnapshot/command_migrations.go index 62205fba..19aed105 100644 --- a/internal/interfacesnapshot/command_migrations.go +++ b/internal/interfacesnapshot/command_migrations.go @@ -21,6 +21,7 @@ const ( CommandMigrationMove = "command_move" CommandMigrationFlagExtraction = "flag_extraction" + CommandMigrationAvailability = "schema_availability_hardening" ) // CommandMigrationManifest governs compatibility-preserving surface moves that @@ -64,6 +65,12 @@ type CommandMigrationSchema struct { SourceToolID string `json:"source_tool_id"` ReplacementToolID string `json:"replacement_tool_id"` Parameters []CommandParameterMigration `json:"parameters"` + Availability *CommandAvailabilityChange `json:"availability,omitempty"` +} + +type CommandAvailabilityChange struct { + Before string `json:"before"` + After string `json:"after"` } type CommandParameterMigration struct { @@ -126,14 +133,23 @@ func (m CommandMigrationManifest) Validate() error { } func (m CommandMigration) validate() error { - if m.Kind != CommandMigrationMove && m.Kind != CommandMigrationFlagExtraction { + if m.Kind != CommandMigrationMove && m.Kind != CommandMigrationFlagExtraction && m.Kind != CommandMigrationAvailability { return fmt.Errorf("invalid kind %q", m.Kind) } - if !isExactCommandPath(m.Legacy.Command) || !isExactCommandPath(m.Replacement.Command) { - return fmt.Errorf("legacy and replacement must be exact command paths rooted at dws") - } - if m.Legacy.Command == m.Replacement.Command { - return fmt.Errorf("legacy and replacement command paths must differ") + if m.Kind == CommandMigrationAvailability { + if !isExactCommandPath(m.Legacy.Command) { + return fmt.Errorf("legacy must be an exact command path rooted at dws") + } + if m.Replacement != (CommandMigrationSide{}) { + return fmt.Errorf("schema_availability_hardening must not declare replacement") + } + } else { + if !isExactCommandPath(m.Legacy.Command) || !isExactCommandPath(m.Replacement.Command) { + return fmt.Errorf("legacy and replacement must be exact command paths rooted at dws") + } + if m.Legacy.Command == m.Replacement.Command { + return fmt.Errorf("legacy and replacement command paths must differ") + } } if strings.TrimSpace(m.Reason) == "" || m.Reason != strings.TrimSpace(m.Reason) { return fmt.Errorf("migration must include a non-empty trimmed reason") @@ -141,12 +157,15 @@ func (m CommandMigration) validate() error { if m.State != CommandMigrationPending && m.State != CommandMigrationConsumed { return fmt.Errorf("invalid state %q", m.State) } - for label, state := range map[string]CommandMigrationState{ - "legacy before": m.Legacy.Before, - "legacy after": m.Legacy.After, - "replacement before": m.Replacement.Before, - "replacement after": m.Replacement.After, - } { + states := map[string]CommandMigrationState{ + "legacy before": m.Legacy.Before, + "legacy after": m.Legacy.After, + } + if m.Kind != CommandMigrationAvailability { + states["replacement before"] = m.Replacement.Before + states["replacement after"] = m.Replacement.After + } + for label, state := range states { if err := state.validate(label); err != nil { return err } @@ -155,7 +174,8 @@ func (m CommandMigration) validate() error { !m.Legacy.After.Present || !m.Legacy.After.Runnable { return fmt.Errorf("legacy command must remain runnable and start visible") } - if m.Replacement.Before.Present || !m.Replacement.After.Present || !m.Replacement.After.Runnable || m.Replacement.After.Hidden { + if m.Kind != CommandMigrationAvailability && + (m.Replacement.Before.Present || !m.Replacement.After.Present || !m.Replacement.After.Runnable || m.Replacement.After.Hidden) { return fmt.Errorf("replacement command must migrate exactly from absent to visible runnable") } if err := m.Schema.validate(m.Kind); err != nil { @@ -214,6 +234,13 @@ func (m CommandMigration) validate() error { if m.LegacyFlag.Before.NoOpt != wantNoOpt || m.LegacyFlag.After.NoOpt != wantNoOpt { return fmt.Errorf("flag_extraction legacy_flag no_opt must equal replacement constant %q", wantNoOpt) } + case CommandMigrationAvailability: + if !m.Legacy.After.Hidden { + return fmt.Errorf("schema_availability_hardening legacy command must migrate exactly from visible to hidden") + } + if m.LegacyFlag != (CommandMigrationFlag{}) { + return fmt.Errorf("schema_availability_hardening must not declare legacy_flag") + } } return nil } @@ -259,9 +286,8 @@ func (f CommandMigrationFlag) validate() error { func (s CommandMigrationSchema) validate(kind string) error { for label, value := range map[string]string{ - "product_id": s.ProductID, - "source_tool_id": s.SourceToolID, - "replacement_tool_id": s.ReplacementToolID, + "product_id": s.ProductID, + "source_tool_id": s.SourceToolID, } { if !isExactSchemaIdentifier(value) { return fmt.Errorf("schema %s must be an exact identifier", label) @@ -270,6 +296,21 @@ func (s CommandMigrationSchema) validate(kind string) error { if s.Parameters == nil { return fmt.Errorf("schema parameters must be an array") } + if kind == CommandMigrationAvailability { + if s.ReplacementToolID != "" || len(s.Parameters) != 0 { + return fmt.Errorf("schema_availability_hardening must not declare a replacement tool or parameter mappings") + } + if s.Availability == nil || s.Availability.Before != "available" || s.Availability.After != "unavailable" { + return fmt.Errorf("schema_availability_hardening requires availability available to unavailable") + } + return nil + } + if !isExactSchemaIdentifier(s.ReplacementToolID) { + return fmt.Errorf("schema replacement_tool_id must be an exact identifier") + } + if s.Availability != nil { + return fmt.Errorf("%s must not declare schema availability", kind) + } seenFrom := map[string]bool{} seenTo := map[string]bool{} seenConstantProperty := map[string]bool{} @@ -344,6 +385,9 @@ func (m CommandMigration) key() string { } func (m CommandMigration) displayKey() string { + if m.Kind == CommandMigrationAvailability { + return fmt.Sprintf("%s %q", m.Kind, m.Legacy.Command) + } return fmt.Sprintf("%s %q -> %q", m.Kind, m.Legacy.Command, m.Replacement.Command) } @@ -401,7 +445,7 @@ func AuthorizeCommandMigrations( func validateCommandMoveLegacyLeaves(snapshot Snapshot, manifest CommandMigrationManifest) error { commands := commandIndex(snapshot) for _, migration := range manifest.Migrations { - if migration.Kind != CommandMigrationMove { + if migration.Kind != CommandMigrationMove && migration.Kind != CommandMigrationAvailability { continue } if _, present := commands[migration.Legacy.Command]; !present { @@ -409,7 +453,7 @@ func validateCommandMoveLegacyLeaves(snapshot Snapshot, manifest CommandMigratio } for _, command := range snapshot.Commands { if strings.HasPrefix(command.Path, migration.Legacy.Command+" ") { - return fmt.Errorf("command_move legacy command %q must be a leaf", migration.Legacy.Command) + return fmt.Errorf("%s legacy command %q must be a leaf", migration.Kind, migration.Legacy.Command) } } } @@ -524,6 +568,15 @@ func sameCommandMigrationApproval(left, right CommandMigration) bool { func matchCommandMigrationPhase(snapshot Snapshot, migration CommandMigration) commandMigrationPhase { commands := commandIndex(snapshot) legacy := commandMigrationStateForCommand(commands, migration.Legacy.Command) + if migration.Kind == CommandMigrationAvailability { + if legacy == migration.Legacy.Before { + return commandMigrationBefore + } + if legacy == migration.Legacy.After { + return commandMigrationAfter + } + return commandMigrationPartial + } replacement := commandMigrationStateForCommand(commands, migration.Replacement.Command) before := legacy == migration.Legacy.Before && replacement == migration.Replacement.Before after := legacy == migration.Legacy.After && replacement == migration.Replacement.After @@ -629,6 +682,10 @@ func commandMigrationAuthorizesChange(current, reference Snapshot, change Change if change.Kind == "command_became_hidden" && change.Flag == "" { return true } + case CommandMigrationAvailability: + if change.Kind == "command_became_hidden" && change.Flag == "" { + return true + } case CommandMigrationFlagExtraction: if change.Kind == "flag_became_hidden" && change.Flag == migration.LegacyFlag.Name { return true diff --git a/internal/interfacesnapshot/command_migrations_test.go b/internal/interfacesnapshot/command_migrations_test.go index a7d5965e..77f382c6 100644 --- a/internal/interfacesnapshot/command_migrations_test.go +++ b/internal/interfacesnapshot/command_migrations_test.go @@ -186,6 +186,7 @@ func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) { want string }{ {"identifier", CommandMigrationMove, func(s *CommandMigrationSchema) { s.ProductID = "bad/id" }, "exact identifier"}, + {"replacement identifier", CommandMigrationMove, func(s *CommandMigrationSchema) { s.ReplacementToolID = "bad/id" }, "replacement_tool_id"}, {"nil parameters", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = nil }, "must be an array"}, {"bad from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters[0].From = "bad name" }, "exact parameter"}, {"duplicate from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = append(s.Parameters, s.Parameters[0]) }, "duplicates from"}, @@ -241,6 +242,96 @@ func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) { } } +func TestCrossPlatformCoverageSchemaAvailabilityMigrationLifecycle(t *testing.T) { + pending := availabilityCommandMigrationManifest(CommandMigrationPending) + consumed := availabilityCommandMigrationManifest(CommandMigrationConsumed) + empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}} + before := testSnapshot( + testCommand("dws"), + testCommand("dws devapp"), + testCommand("dws devapp +event-list"), + ) + after := testSnapshot( + testCommand("dws"), + testCommand("dws devapp"), + Command{Path: "dws devapp +event-list", Runnable: true, Hidden: true}, + ) + + got, err := AuthorizeCommandMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + pending, + consumed, + ) + if err != nil || len(got) != 1 { + t.Fatalf("availability lifecycle authorizations=%#v error=%v", got, err) + } + report, err := CompareAllWithInterfaceMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}, + FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}, + pending, + consumed, + ) + if err != nil || !report.Compatible { + t.Fatalf("availability interface report compatible=%v error=%v report=%#v", report.Compatible, err, report) + } + if got, err := AuthorizeCommandMigrations( + before, + map[string]Snapshot{"merge-base": before, "stable": before}, + empty, + pending, + ); err != nil || len(got) != 0 { + t.Fatalf("candidate-added pending migration must remain inert: %#v, %v", got, err) + } + + valid := pending.Migrations[0] + for _, test := range []struct { + name string + mutate func(*CommandMigration) + want string + }{ + {"legacy path", func(m *CommandMigration) { m.Legacy.Command = "dws devapp *" }, "legacy must be an exact"}, + {"replacement", func(m *CommandMigration) { m.Replacement.Command = "dws devapp other" }, "must not declare replacement"}, + {"visible after", func(m *CommandMigration) { m.Legacy.After.Hidden = false }, "visible to hidden"}, + {"legacy flag", func(m *CommandMigration) { m.LegacyFlag.Name = "legacy" }, "must not declare legacy_flag"}, + {"missing availability", func(m *CommandMigration) { m.Schema.Availability = nil }, "requires availability"}, + {"wrong availability", func(m *CommandMigration) { m.Schema.Availability.After = "available" }, "requires availability"}, + {"parameter mapping", func(m *CommandMigration) { + m.Schema.Parameters = []CommandParameterMigration{{From: "cursor", To: "next-token"}} + }, "must not declare"}, + } { + t.Run(test.name, func(t *testing.T) { + migration := cloneCommandMigration(valid) + if valid.Schema.Availability != nil { + change := *valid.Schema.Availability + migration.Schema.Availability = &change + } + test.mutate(&migration) + if err := migration.validate(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("availability migration validation error=%v, want %q", err, test.want) + } + }) + } + if got := valid.displayKey(); !strings.Contains(got, CommandMigrationAvailability) { + t.Fatalf("availability display key = %q", got) + } + if got := matchCommandMigrationPhase(testSnapshot(testCommand("dws")), valid); got != commandMigrationPartial { + t.Fatalf("missing availability command phase = %q", got) + } + parent := before + parent.Commands = append(append([]Command(nil), before.Commands...), testCommand("dws devapp +event-list detail")) + if err := validateCommandMoveLegacyLeaves(parent, pending); err == nil || !strings.Contains(err.Error(), "must be a leaf") { + t.Fatalf("availability parent command error = %v", err) + } + moveSchema := commandMigrationManifest(CommandMigrationPending).Migrations[0].Schema + moveSchema.Availability = &CommandAvailabilityChange{Before: "available", After: "unavailable"} + if err := moveSchema.validate(CommandMigrationMove); err == nil || !strings.Contains(err.Error(), "must not declare schema availability") { + t.Fatalf("ordinary move availability error = %v", err) + } +} + func TestCrossPlatformCoverageFlagExtractionRejectsRequiredLegacyFlag(t *testing.T) { for _, test := range []struct { name string @@ -686,7 +777,10 @@ func flagExtractionCommandMigrationManifest(state string) CommandMigrationManife func cloneCommandMigration(source CommandMigration) CommandMigration { cloned := source - cloned.Schema.Parameters = append([]CommandParameterMigration(nil), source.Schema.Parameters...) + if source.Schema.Parameters != nil { + cloned.Schema.Parameters = make([]CommandParameterMigration, len(source.Schema.Parameters)) + copy(cloned.Schema.Parameters, source.Schema.Parameters) + } for index, parameter := range source.Schema.Parameters { if parameter.ReplacementConstant == nil { continue @@ -703,6 +797,28 @@ func singleCommandMigrationManifest(state string) CommandMigrationManifest { return manifest } +func availabilityCommandMigrationManifest(state string) CommandMigrationManifest { + return CommandMigrationManifest{ + Version: CommandMigrationManifestVersion, + Migrations: []CommandMigration{{ + Kind: CommandMigrationAvailability, + Legacy: CommandMigrationSide{ + Command: "dws devapp +event-list", + Before: CommandMigrationState{Present: true, Runnable: true}, + After: CommandMigrationState{Present: true, Runnable: true, Hidden: true}, + }, + Schema: CommandMigrationSchema{ + ProductID: "devapp", + SourceToolID: "devapp.shortcut_event_list", + Parameters: []CommandParameterMigration{}, + Availability: &CommandAvailabilityChange{Before: "available", After: "unavailable"}, + }, + State: state, + Reason: "Fail closed until terminal pagination evidence is available.", + }}, + } +} + func modifiedCommandManifest(source CommandMigrationManifest) CommandMigrationManifest { modified := source modified.Migrations = append([]CommandMigration(nil), source.Migrations...) diff --git a/scripts/policy/schema-compat/command_migrations_test.go b/scripts/policy/schema-compat/command_migrations_test.go index 613d449d..c87c9672 100644 --- a/scripts/policy/schema-compat/command_migrations_test.go +++ b/scripts/policy/schema-compat/command_migrations_test.go @@ -13,6 +13,69 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot" ) +func TestCrossPlatformCoverageSchemaAvailabilityMigrationNormalizesOnlyAvailability(t *testing.T) { + baseline := baselineContract() + current := cloneContract(baseline) + mutateTool(¤t, func(tool *toolSchema) { tool.Availability = "unavailable" }) + migration := interfacesnapshot.CommandMigration{ + Kind: interfacesnapshot.CommandMigrationAvailability, + Legacy: interfacesnapshot.CommandMigrationSide{ + Command: "dws doc create", + Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true, Hidden: true}, + }, + Schema: interfacesnapshot.CommandMigrationSchema{ + ProductID: "doc", + SourceToolID: "doc.create", + Parameters: []interfacesnapshot.CommandParameterMigration{}, + Availability: &interfacesnapshot.CommandAvailabilityChange{Before: "available", After: "unavailable"}, + }, + State: interfacesnapshot.CommandMigrationPending, + Reason: "Reviewed fail-closed availability hardening.", + } + + normalized, err := normalizeSchemaCommandMigrations(baseline, current, []interfacesnapshot.CommandMigration{migration}) + if err != nil { + t.Fatal(err) + } + if got := normalized.Products["doc"].Tools["doc.create"].Availability; got != "unavailable" { + t.Fatalf("normalized availability = %q", got) + } + if failures := checkCompatibility(normalized, current); len(failures) != 0 { + t.Fatalf("availability hardening failures = %v", failures) + } + + unrelated := cloneContract(current) + mutateTool(&unrelated, func(tool *toolSchema) { tool.Risk = "high" }) + normalized, err = normalizeSchemaCommandMigrations(baseline, unrelated, []interfacesnapshot.CommandMigration{migration}) + if err != nil { + t.Fatal(err) + } + if failures := strings.Join(checkCompatibility(normalized, unrelated), "\n"); !strings.Contains(failures, "changed risk") { + t.Fatalf("unrelated risk drift was hidden: %q", failures) + } + + alreadyAfter := cloneContract(current) + if _, err := normalizeSchemaCommandMigrations(alreadyAfter, current, []interfacesnapshot.CommandMigration{migration}); err != nil { + t.Fatalf("consumed merge-base availability should remain inert: %v", err) + } + wrongPath := cloneContract(current) + mutateTool(&wrongPath, func(tool *toolSchema) { tool.PrimaryCLIPath = "doc other" }) + normalized, err = normalizeSchemaCommandMigrations(baseline, wrongPath, []interfacesnapshot.CommandMigration{migration}) + if err != nil { + t.Fatal(err) + } + if got := normalized.Products["doc"].Tools["doc.create"].Availability; got != "available" { + t.Fatalf("wrong-path migration changed availability to %q", got) + } + wrong := cloneContract(current) + mutateTool(&wrong, func(tool *toolSchema) { tool.Availability = "available" }) + if _, err := normalizeSchemaCommandMigrations(baseline, wrong, []interfacesnapshot.CommandMigration{migration}); err == nil || + !strings.Contains(err.Error(), "does not match Schema availability") { + t.Fatalf("wrong availability error = %v", err) + } +} + func TestCrossPlatformCoverageSchemaCommandMigrationsAuthorizeOnlyExactProjection(t *testing.T) { baseline := schemaCommandMigrationContract(false) current := schemaCommandMigrationContract(true) diff --git a/scripts/policy/schema-compat/main.go b/scripts/policy/schema-compat/main.go index 5040f626..c7d933ae 100644 --- a/scripts/policy/schema-compat/main.go +++ b/scripts/policy/schema-compat/main.go @@ -1875,6 +1875,24 @@ func normalizeSchemaCommandMigrations( normalizedProduct := normalized.Products[migration.Schema.ProductID] normalizedTool := normalizedProduct.Tools[migration.Schema.SourceToolID] switch migration.Kind { + case interfacesnapshot.CommandMigrationAvailability: + change := migration.Schema.Availability + if change != nil && oldTool.Availability == change.After && newSource.Availability == change.After { + continue + } + if change == nil || oldTool.Availability != change.Before || newSource.Availability != change.After { + return schemaContract{}, fmt.Errorf( + "approved availability hardening %q does not match Schema availability %q -> %q", + migration.Legacy.Command, + oldTool.Availability, + newSource.Availability, + ) + } + if newSource.PrimaryCLIPath != legacyPath { + continue + } + normalizedTool.Availability = newSource.Availability + case interfacesnapshot.CommandMigrationMove: if newSource.PrimaryCLIPath != replacementPath { continue