diff --git a/docs/cli-interface-flag-migrations.md b/docs/cli-interface-flag-migrations.md index 74f4d577..76d11608 100644 --- a/docs/cli-interface-flag-migrations.md +++ b/docs/cli-interface-flag-migrations.md @@ -122,7 +122,7 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json 一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding: 1. legacy flag 的 `flag_became_hidden`(visible → hidden); -2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`。已有 canonical 的 requiredness 不允许借 rename 改变。 +2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`;如果 canonical 在 before 阶段只是 hidden 占位符,则允许它在转为公开拼写时继承 legacy 的 requiredness。已有的 visible canonical 不允许借 rename 改变 requiredness。 以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖: diff --git a/internal/interfacesnapshot/migrations.go b/internal/interfacesnapshot/migrations.go index 7bd04bcd..892a7c55 100644 --- a/internal/interfacesnapshot/migrations.go +++ b/internal/interfacesnapshot/migrations.go @@ -326,8 +326,9 @@ func (m FlagMigration) validate() error { } // Requiredness belongs to the one logical parameter. The hidden legacy // spelling must not remain independently required, while the canonical - // spelling inherits the exact before-state contract. If the canonical flag - // already existed, a rename receipt cannot authorize changing it either. + // spelling inherits the exact before-state contract. An already-visible + // canonical flag cannot change requiredness; an existing hidden canonical + // placeholder may inherit it when promoted to the public spelling. if m.Legacy.After.Required { return fmt.Errorf("legacy compatibility alias must not remain independently required after migration") } @@ -336,7 +337,9 @@ func (m FlagMigration) validate() error { "flag requiredness must be preserved from legacy before to canonical after", ) } - if m.Canonical.Before.Present && m.Canonical.Before.Required != m.Canonical.After.Required { + if m.Canonical.Before.Present && + !m.Canonical.Before.Hidden && + m.Canonical.Before.Required != m.Canonical.After.Required { return fmt.Errorf("canonical flag requiredness must remain unchanged when already present") } if m.Legacy.After.AliasOf != m.Canonical.Name { @@ -690,6 +693,13 @@ func flagMigrationAuthorizesChange( change.Kind == "required_flag_added" { return true } + if migration.Canonical.Before.Present && + migration.Canonical.Before.Hidden && + !migration.Canonical.Before.Required && + migration.Canonical.After.Required && + change.Kind == "flag_became_required" { + return true + } } return false } diff --git a/internal/interfacesnapshot/migrations_coverage_test.go b/internal/interfacesnapshot/migrations_coverage_test.go index bdee575f..fa4b239f 100644 --- a/internal/interfacesnapshot/migrations_coverage_test.go +++ b/internal/interfacesnapshot/migrations_coverage_test.go @@ -792,6 +792,35 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsInheritedAndOptionalCa t.Fatalf("existing optional canonical migration = (%#v, %v), want compatible", report, err) } }) + + t.Run("hidden canonical inherits requiredness when promoted", func(t *testing.T) { + pending := coverageManifest(FlagMigrationPending) + pending.Migrations[0].Canonical.Before = FlagMigrationState{ + Present: true, + Type: "string", + Hidden: true, + Scope: "local", + } + consumed := pending + consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...) + consumed.Migrations[0].State = FlagMigrationConsumed + before := coverageMigrationSnapshot(pending.Migrations[0], false, false) + after := coverageMigrationSnapshot(pending.Migrations[0], true, false) + + ordinary := Compare(after, before, "merge-base") + if !hasFlagChange(ordinary.Blocking, "flag_became_required", pending.Migrations[0].Command, pending.Migrations[0].Canonical.Name) { + t.Fatalf("fixture did not change canonical requiredness: %#v", ordinary.Blocking) + } + report, err := CompareAllWithFlagMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + pending, + consumed, + ) + if err != nil || !report.Compatible { + t.Fatalf("hidden canonical promotion = (%#v, %v), want compatible", report, err) + } + }) } func TestCrossPlatformCoverageOptionalFlagMigrationLifecycleRemainsHostile(t *testing.T) { diff --git a/internal/interfacesnapshot/migrations_test.go b/internal/interfacesnapshot/migrations_test.go index a7996dba..f9c406f7 100644 --- a/internal/interfacesnapshot/migrations_test.go +++ b/internal/interfacesnapshot/migrations_test.go @@ -5,6 +5,7 @@ package interfacesnapshot import ( "errors" + "os" "reflect" "strings" "testing" @@ -52,6 +53,27 @@ func optionalFlagMigrationManifestJSON() string { ) } +func hiddenCanonicalFlagMigrationManifestJSON() string { + return strings.Replace( + validFlagMigrationManifestJSON, + `"before": {"present": false}`, + `"before": {"present": true, "type": "string", "hidden": true, "scope": "local"}`, + 1, + ) +} + +func TestApprovedFlagMigrationManifestRemainsValid(t *testing.T) { + manifest, err := os.Open("../../scripts/policy/interface-migrations/approved-flag-migrations-v1.json") + if err != nil { + t.Fatalf("open approved flag migration manifest: %v", err) + } + defer manifest.Close() + + if _, err := ReadFlagMigrationManifest(manifest); err != nil { + t.Fatalf("approved flag migration manifest is invalid: %v", err) + } +} + func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsUnknownFields(t *testing.T) { _, err := ReadFlagMigrationManifest(strings.NewReader(`{ "version": 1, @@ -128,6 +150,9 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t * if _, err := ReadFlagMigrationManifest(strings.NewReader(optionalFlagMigrationManifestJSON())); err != nil { t.Fatalf("ReadFlagMigrationManifest(optional rename) error = %v", err) } + if _, err := ReadFlagMigrationManifest(strings.NewReader(hiddenCanonicalFlagMigrationManifestJSON())); err != nil { + t.Fatalf("ReadFlagMigrationManifest(hidden canonical promotion) error = %v", err) + } tests := []struct { name string