diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97e71d3f..d8933058 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1370,8 +1370,22 @@ jobs: if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then base_ref="$(git rev-parse HEAD^)" fi + candidate_ref="$(git rev-parse HEAD^{commit})" + if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$candidate_ref" != "$PR_HEAD_SHA" ]; then + echo "Compatibility checkout $candidate_ref does not match PR head $PR_HEAD_SHA" >&2 + exit 1 + fi git rev-parse --verify "${base_ref}^{commit}" >/dev/null - stable_ref="$(git tag --merged "$base_ref" --list 'v[0-9]*' --sort=-version:refname | awk 'index($0, "-") == 0 { print; exit }')" + . ./scripts/release/release-lib.sh + stable_ref="" + for tag in $(git tag --merged "$base_ref" --list 'v*' --sort=-version:refname); do + release_is_stable_version "$tag" || continue + if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then + continue + fi + stable_ref="$tag" + break + done if [ -z "$stable_ref" ]; then echo "No stable release tag is reachable from compatibility base $base_ref" >&2 exit 1 @@ -1379,27 +1393,23 @@ jobs: git rev-parse --verify "${stable_ref}^{commit}" >/dev/null echo "COMPATIBILITY_BASE_REF=$base_ref" >> "$GITHUB_ENV" echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV" + echo "COMPATIBILITY_CANDIDATE_REF=$candidate_ref" >> "$GITHUB_ENV" - - name: Check historical commands and help compatibility + - name: Check historical commands, help, and complete CLI compatibility if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }} run: | make authoritative-interface-integrity \ - BASE_REF="$COMPATIBILITY_BASE_REF" - if [ "$(git rev-parse "${COMPATIBILITY_BASE_REF}^{commit}")" != "$(git rev-parse "${COMPATIBILITY_STABLE_REF}^{commit}")" ]; then - make authoritative-interface-integrity \ - BASE_REF="$COMPATIBILITY_STABLE_REF" - fi - - - name: Check complete CLI command compatibility - if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }} - run: | - ./scripts/policy/check-command-compatibility.sh \ - --base-ref "$COMPATIBILITY_BASE_REF" \ - --stable-ref "$COMPATIBILITY_STABLE_REF" + BASE_REF="$COMPATIBILITY_BASE_REF" \ + STABLE_REF="$COMPATIBILITY_STABLE_REF" \ + CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF" - name: Check complete Schema compatibility if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }} - run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF" + run: | + make schema-compatibility \ + BASE_REF="$COMPATIBILITY_BASE_REF" \ + STABLE_REF="$COMPATIBILITY_STABLE_REF" \ + CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF" - name: Check skill command references if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a17dcee..55b6e41a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,9 +68,17 @@ coverage is additionally selected for platform-sensitive code. 3. Include both the commands/results and user-visible or contract-level behavior evidence in the PR description. 4. Run `./scripts/policy/check-command-surface.sh --strict` when command - paths/flags change. CI also runs - `./scripts/policy/check-command-compatibility.sh --base-ref --stable-ref ` - against both the target branch and latest stable release. + paths/flags change. CI resolves the exact merge-base, latest reachable + non-withdrawn stable GA tag, and committed candidate SHA, then enters the single compatibility + decision seam through + `make authoritative-interface-integrity BASE_REF= STABLE_REF= CANDIDATE_REF=`. + The Make target delegates to the authoritative wrapper; CI does not invoke a + second comparator or the legacy fixture checker. See + [CLI flag compatibility migration governance](docs/cli-interface-flag-migrations.md) + for the reviewed two-stage `pending` → `consumed` lifecycle. + Agent-visible flag migrations must also run + `make schema-compatibility BASE_REF= STABLE_REF= CANDIDATE_REF=`; + it consumes the same base-owned ledger rather than a second exception list. 5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may change. 6. Run `./scripts/release/verify-package-managers.sh` when packaging or diff --git a/Makefile b/Makefile index d388de88..75b4356a 100644 --- a/Makefile +++ b/Makefile @@ -24,13 +24,13 @@ help: @printf " make format-check - Check all repository Go source files with gofmt\n" @printf " make fmt - Format all repository Go source files\n" @printf " make policy - Check the built dws plus open-source and Schema policies\n" - @printf " make interface-integrity - Check historical commands and help contracts still work\n" - @printf " make authoritative-interface-integrity BASE_REF= - Check the Git-owned PR merge-base\n" + @printf " make interface-integrity [BASE_REF=] [STABLE_REF=] [CANDIDATE_REF=] - Check authoritative CLI history\n" + @printf " make authoritative-interface-integrity BASE_REF= [STABLE_REF=] [CANDIDATE_REF=] - Check Git-owned CLI history\n" @printf " make coverage-gate BASE_REF= - Enforce overall non-regression and 100%% changed-code coverage\n" @printf " make coverage-gate-platform BASE_REF= PROFILE= - Enforce 100%% native changed-code coverage\n" - @printf " make update-interface-baseline - Add new CLI contracts without removing history\n" - @printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n" - @printf " make schema-compatibility BASE_REF= - Check the complete Schema contract against the PR merge-base\n" + @printf " make update-interface-baseline - Update the non-authoritative CLI smoke fixture\n" + @printf " make reset-interface-baseline - DANGEROUS: replace the non-authoritative CLI smoke fixture\n" + @printf " make schema-compatibility BASE_REF= [STABLE_REF=] [CANDIDATE_REF=] - Check the authoritative Schema history\n" @printf " make skill-command-integrity - Check dws commands referenced by skills exist\n" @printf " make skill-context-budget - Check generated Skill drift and common-path context budgets\n" @printf " make multi-im-skill-chain-integrity - Check reviewed IM intents keep one default Skill route\n" @@ -103,10 +103,22 @@ edition-test: $(GO) test -v -count=1 ./pkg/editiontest/... interface-integrity: - @./scripts/policy/check-interface-baseline.sh + @base_ref="$(BASE_REF)"; \ + candidate_ref="$(CANDIDATE_REF)"; \ + if [ -z "$$base_ref" ]; then base_ref="origin/main"; fi; \ + if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \ + ./scripts/policy/check-authoritative-interface-baselines.sh \ + --base-ref "$$base_ref" \ + --stable-ref "$(STABLE_REF)" \ + --candidate-ref "$$candidate_ref" authoritative-interface-integrity: - @./scripts/policy/check-authoritative-interface-baselines.sh --base-ref "$(BASE_REF)" + @candidate_ref="$(CANDIDATE_REF)"; \ + if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \ + ./scripts/policy/check-authoritative-interface-baselines.sh \ + --base-ref "$(BASE_REF)" \ + --stable-ref "$(STABLE_REF)" \ + --candidate-ref "$$candidate_ref" coverage-gate: @./scripts/policy/check-coverage-gate.sh --base-ref "$(BASE_REF)" --scope-buildable @@ -121,7 +133,12 @@ reset-interface-baseline: @./scripts/policy/check-interface-baseline.sh --reset schema-compatibility: - @./scripts/policy/check-authoritative-schema-compatibility.sh --base-ref "$(BASE_REF)" + @candidate_ref="$(CANDIDATE_REF)"; \ + if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \ + ./scripts/policy/check-authoritative-schema-compatibility.sh \ + --base-ref "$(BASE_REF)" \ + --stable-ref "$(STABLE_REF)" \ + --candidate-ref "$$candidate_ref" skill-command-integrity: @./scripts/policy/check-skill-commands.sh diff --git a/cmd/interface-snapshot/main.go b/cmd/interface-snapshot/main.go index d921bff9..0dfd2ca1 100644 --- a/cmd/interface-snapshot/main.go +++ b/cmd/interface-snapshot/main.go @@ -17,18 +17,23 @@ package main import ( + "bytes" "encoding/json" "flag" "fmt" "io" "os" "path/filepath" + "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot" + "github.com/spf13/cobra" ) +var newRootCommand = func() *cobra.Command { return app.NewRootCommand() } + func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } @@ -112,7 +117,11 @@ func runGenerate(args []string, stdout, stderr io.Writer) error { defer i18n.SetLang(previousLang) i18n.SetLang("en") - snapshot := interfacesnapshot.Capture(app.NewRootCommand()) + root := newRootCommand() + snapshot := interfacesnapshot.Capture(root) + if err := validateHelpRendering(root, snapshot); err != nil { + return err + } if *output == "-" { return interfacesnapshot.Write(stdout, snapshot) } @@ -138,6 +147,16 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) { currentPath := flags.String("current", "", "candidate snapshot path") basePath := flags.String("base", "", "target main/development baseline snapshot path") stablePath := flags.String("stable", "", "latest stable GA snapshot path") + approvedMigrationsPath := flags.String( + "approved-flag-migrations", + "", + "merge-base-owned approved flag migration manifest", + ) + candidateMigrationsPath := flags.String( + "candidate-flag-migrations", + "", + "candidate flag migration manifest", + ) if err := flags.Parse(args); err != nil { return false, err } @@ -150,6 +169,11 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) { if *basePath == "" && *stablePath == "" { return false, fmt.Errorf("compare requires --base, --stable, or both") } + if (*approvedMigrationsPath == "") != (*candidateMigrationsPath == "") { + return false, fmt.Errorf( + "--approved-flag-migrations and --candidate-flag-migrations must be provided together", + ) + } current, err := readSnapshot(*currentPath) if err != nil { @@ -170,6 +194,25 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) { } report := interfacesnapshot.CompareAll(current, references) + if *approvedMigrationsPath != "" { + approved, readErr := readFlagMigrationManifest(*approvedMigrationsPath) + if readErr != nil { + return false, fmt.Errorf("read approved flag migrations: %w", readErr) + } + candidate, readErr := readFlagMigrationManifest(*candidateMigrationsPath) + if readErr != nil { + return false, fmt.Errorf("read candidate flag migrations: %w", readErr) + } + report, err = interfacesnapshot.CompareAllWithFlagMigrations( + current, + references, + approved, + candidate, + ) + if err != nil { + return false, fmt.Errorf("validate flag migration lifecycle: %w", err) + } + } encoder := json.NewEncoder(stdout) encoder.SetEscapeHTML(false) encoder.SetIndent("", " ") @@ -179,6 +222,49 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) { return report.Compatible, nil } +func readFlagMigrationManifest(path string) (interfacesnapshot.FlagMigrationManifest, error) { + file, err := os.Open(filepath.Clean(path)) + if err != nil { + return interfacesnapshot.FlagMigrationManifest{}, err + } + defer file.Close() + return interfacesnapshot.ReadFlagMigrationManifest(file) +} + +func validateHelpRendering(root *cobra.Command, snapshot interfacesnapshot.Snapshot) error { + for _, command := range snapshot.Commands { + path := strings.TrimPrefix(command.Path, "dws") + resolved, remaining, err := root.Find(strings.Fields(path)) + if err != nil || len(remaining) != 0 || resolved == nil { + return fmt.Errorf("resolve %q before help rendering: remaining=%v error=%v", command.Path, remaining, err) + } + if err := renderCommandHelp(resolved); err != nil { + return fmt.Errorf("render %q help: %w", command.Path, err) + } + } + return nil +} + +func renderCommandHelp(command *cobra.Command) (err error) { + var stdout, stderr bytes.Buffer + command.InitDefaultHelpFlag() + command.SetOut(&stdout) + command.SetErr(&stderr) + defer func() { + if recovered := recover(); recovered != nil { + err = fmt.Errorf("help renderer panicked: %v", recovered) + } + }() + command.HelpFunc()(command, []string{}) + if stderr.Len() > 0 { + return fmt.Errorf("help renderer wrote an error: %s", strings.TrimSpace(stderr.String())) + } + if stdout.Len() == 0 { + return fmt.Errorf("help renderer produced empty output") + } + return nil +} + func readSnapshot(path string) (interfacesnapshot.Snapshot, error) { file, err := os.Open(filepath.Clean(path)) if err != nil { @@ -191,5 +277,5 @@ func readSnapshot(path string) (interfacesnapshot.Snapshot, error) { func printUsage(w io.Writer) { fmt.Fprintln(w, "usage:") fmt.Fprintln(w, " interface-snapshot generate [--output FILE]") - fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE]") + fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE]") } diff --git a/cmd/interface-snapshot/main_test.go b/cmd/interface-snapshot/main_test.go index 1f6de835..87a09536 100644 --- a/cmd/interface-snapshot/main_test.go +++ b/cmd/interface-snapshot/main_test.go @@ -15,11 +15,16 @@ package main import ( "bytes" + "errors" + "io" "os" "path/filepath" + "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/spf13/cobra" ) func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T) { @@ -56,6 +61,24 @@ func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T) } } +func TestCrossPlatformCoverageRunGenerateRejectsHelpRenderingFailure(t *testing.T) { + testseam.Swap(t, &newRootCommand, func() *cobra.Command { + root := &cobra.Command{Use: "dws"} + root.SetHelpFunc(func(command *cobra.Command, _ []string) { + _, _ = io.WriteString(command.ErrOrStderr(), "injected help failure") + }) + return root + }) + + var stdout, stderr bytes.Buffer + if exitCode := run([]string{"generate"}, &stdout, &stderr); exitCode != 2 { + t.Fatalf("run(generate) exit=%d stderr=%s", exitCode, stderr.String()) + } + if !strings.Contains(stderr.String(), "injected help failure") { + t.Fatalf("run(generate) stderr=%q", stderr.String()) + } +} + func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *testing.T) { current := commandSnapshot("dws") mergeBase := commandSnapshot("dws") @@ -83,6 +106,327 @@ func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *tes } } +func TestCrossPlatformCoverageRunCompareEnforcesBaseOwnedFlagMigrationLifecycle(t *testing.T) { + dir := t.TempDir() + before := flagMigrationSnapshot(false) + after := flagMigrationSnapshot(true) + currentPath := writeSnapshot(t, dir, "current.json", after) + basePath := writeSnapshot(t, dir, "base.json", before) + stablePath := writeSnapshot(t, dir, "stable.json", before) + approvedPath := writeManifest(t, dir, "approved.json", flagMigrationManifestJSON("pending")) + candidatePath := writeManifest(t, dir, "candidate.json", flagMigrationManifestJSON("consumed")) + + var stdout, stderr bytes.Buffer + exitCode := run([]string{ + "compare", + "--current", currentPath, + "--base", basePath, + "--stable", stablePath, + "--approved-flag-migrations", approvedPath, + "--candidate-flag-migrations", candidatePath, + }, &stdout, &stderr) + if exitCode != 0 { + t.Fatalf("exact base-owned migration exit=%d stderr=%s", exitCode, stderr.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"compatible": true`)) { + t.Fatalf("exact migration report is not compatible:\n%s", stdout.String()) + } + + stdout.Reset() + stderr.Reset() + emptyApproved := writeManifest(t, dir, "empty-approved.json", `{"version":1,"migrations":[]}`) + exitCode = run([]string{ + "compare", + "--current", currentPath, + "--base", basePath, + "--stable", stablePath, + "--approved-flag-migrations", emptyApproved, + "--candidate-flag-migrations", candidatePath, + }, &stdout, &stderr) + if exitCode != 2 || !strings.Contains(stderr.String(), "must start pending") { + t.Fatalf("candidate self-approval exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String()) + } +} + +func TestCrossPlatformCoverageRunCompareRequiresBothFlagMigrationInputs(t *testing.T) { + dir := t.TempDir() + currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws")) + basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws")) + approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`) + + var stdout, stderr bytes.Buffer + exitCode := run([]string{ + "compare", + "--current", currentPath, + "--base", basePath, + "--approved-flag-migrations", approvedPath, + }, &stdout, &stderr) + if exitCode != 2 || !strings.Contains(stderr.String(), "must be provided together") { + t.Fatalf("one-sided migration input exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String()) + } +} + +func TestCrossPlatformCoverageRunPrintsUsageForMissingAndUnknownCommands(t *testing.T) { + tests := []struct { + name string + args []string + wantStderr []string + }{ + { + name: "missing command", + args: nil, + wantStderr: []string{"usage:", "interface-snapshot generate", "--approved-flag-migrations"}, + }, + { + name: "unknown command", + args: []string{"unknown"}, + wantStderr: []string{`unknown command "unknown"`, "usage:", "interface-snapshot compare"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 { + t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode) + } + if stdout.Len() != 0 { + t.Fatalf("run(%v) unexpectedly wrote stdout: %s", test.args, stdout.String()) + } + for _, want := range test.wantStderr { + if !strings.Contains(stderr.String(), want) { + t.Errorf("run(%v) stderr missing %q:\n%s", test.args, want, stderr.String()) + } + } + }) + } +} + +func TestCrossPlatformCoverageRunRejectsInvalidSubcommandArguments(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "generate unknown flag", args: []string{"generate", "--unknown"}, want: "flag provided but not defined"}, + {name: "generate positional", args: []string{"generate", "unexpected"}, want: "generate accepts no positional arguments"}, + {name: "compare unknown flag", args: []string{"compare", "--unknown"}, want: "flag provided but not defined"}, + {name: "compare positional", args: []string{"compare", "unexpected"}, want: "compare accepts no positional arguments"}, + {name: "compare missing current", args: []string{"compare", "--base", "base.json"}, want: "compare requires --current"}, + {name: "compare missing reference", args: []string{"compare", "--current", "current.json"}, want: "compare requires --base, --stable, or both"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 { + t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode) + } + if !strings.Contains(stderr.String(), test.want) { + t.Fatalf("run(%v) stderr missing %q:\n%s", test.args, test.want, stderr.String()) + } + }) + } +} + +func TestCrossPlatformCoverageRunGenerateRejectsUnsafeOutputPath(t *testing.T) { + outputDirectory := t.TempDir() + var stdout, stderr bytes.Buffer + exitCode := run([]string{"generate", "--output", outputDirectory}, &stdout, &stderr) + if exitCode != 2 || !strings.Contains(stderr.String(), "create snapshot") { + t.Fatalf("directory output exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String()) + } +} + +func TestCrossPlatformCoverageRunGenerateReportsTemporaryDirectoryFailure(t *testing.T) { + missingTempRoot := filepath.Join(t.TempDir(), "missing") + t.Setenv("TMPDIR", missingTempRoot) + + var stdout, stderr bytes.Buffer + exitCode := run([]string{"generate"}, &stdout, &stderr) + if exitCode != 2 || !strings.Contains(stderr.String(), "create isolated home") { + t.Fatalf("invalid TMPDIR exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String()) + } +} + +func TestCrossPlatformCoverageRunCompareReportsSnapshotReadFailures(t *testing.T) { + dir := t.TempDir() + validPath := writeSnapshot(t, dir, "valid.json", commandSnapshot("dws")) + missingPath := filepath.Join(dir, "missing.json") + tests := []struct { + name string + args []string + want string + }{ + { + name: "current", + args: []string{"compare", "--current", missingPath, "--base", validPath}, + want: "read current snapshot", + }, + { + name: "main", + args: []string{"compare", "--current", validPath, "--base", missingPath}, + want: "read main/development baseline snapshot", + }, + { + name: "stable", + args: []string{"compare", "--current", validPath, "--stable", missingPath}, + want: "read stable snapshot", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 { + t.Fatalf("run(compare) exit=%d, want 2", exitCode) + } + if !strings.Contains(stderr.String(), test.want) { + t.Fatalf("stderr missing %q:\n%s", test.want, stderr.String()) + } + }) + } +} + +func TestCrossPlatformCoverageRunCompareReportsEachManifestReadFailure(t *testing.T) { + dir := t.TempDir() + snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws")) + validManifest := writeManifest(t, dir, "valid-manifest.json", `{"version":1,"migrations":[]}`) + invalidManifest := writeManifest(t, dir, "invalid-manifest.json", `{`) + tests := []struct { + name string + approved string + candidate string + want string + }{ + { + name: "approved manifest", + approved: invalidManifest, + candidate: validManifest, + want: "read approved flag migrations", + }, + { + name: "candidate manifest", + approved: validManifest, + candidate: invalidManifest, + want: "read candidate flag migrations", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + exitCode := run([]string{ + "compare", + "--current", snapshotPath, + "--base", snapshotPath, + "--approved-flag-migrations", test.approved, + "--candidate-flag-migrations", test.candidate, + }, &stdout, &stderr) + if exitCode != 2 || !strings.Contains(stderr.String(), test.want) { + t.Fatalf("%s exit=%d stdout=%s stderr=%s", test.name, exitCode, stdout.String(), stderr.String()) + } + }) + } +} + +func TestCrossPlatformCoverageRunCompareReportsOutputFailure(t *testing.T) { + dir := t.TempDir() + snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws")) + var stderr bytes.Buffer + exitCode := run([]string{ + "compare", + "--current", snapshotPath, + "--base", snapshotPath, + }, failingWriter{}, &stderr) + if exitCode != 2 || !strings.Contains(stderr.String(), "write comparison report") { + t.Fatalf("comparison output failure exit=%d stderr=%s", exitCode, stderr.String()) + } +} + +func TestCrossPlatformCoverageReadHelpersRejectMissingAndInvalidInputs(t *testing.T) { + dir := t.TempDir() + missingPath := filepath.Join(dir, "missing.json") + invalidPath := filepath.Join(dir, "invalid.json") + if err := os.WriteFile(invalidPath, []byte(`{`), 0o600); err != nil { + t.Fatalf("write invalid fixture: %v", err) + } + + if _, err := readSnapshot(missingPath); err == nil { + t.Fatal("readSnapshot(missing) unexpectedly succeeded") + } + if _, err := readSnapshot(invalidPath); err == nil { + t.Fatal("readSnapshot(invalid) unexpectedly succeeded") + } + if _, err := readFlagMigrationManifest(missingPath); err == nil { + t.Fatal("readFlagMigrationManifest(missing) unexpectedly succeeded") + } + if _, err := readFlagMigrationManifest(invalidPath); err == nil { + t.Fatal("readFlagMigrationManifest(invalid) unexpectedly succeeded") + } +} + +func TestCrossPlatformCoverageValidateHelpRenderingReportsResolveError(t *testing.T) { + root := &cobra.Command{Use: "dws"} + err := validateHelpRendering(root, commandSnapshot("dws missing")) + if err == nil || !strings.Contains(err.Error(), `resolve "dws missing" before help rendering`) { + t.Fatalf("validateHelpRendering resolve error = %v", err) + } +} + +func TestCrossPlatformCoverageValidateHelpRenderingReportsTemplateError(t *testing.T) { + root := &cobra.Command{Use: "dws"} + root.SetHelpTemplate(`{{index .Commands 99}}`) + err := validateHelpRendering(root, commandSnapshot("dws")) + if err == nil || !strings.Contains(err.Error(), `render "dws" help`) { + t.Fatalf("validateHelpRendering template error = %v", err) + } +} + +func TestCrossPlatformCoverageValidateHelpRenderingRecoversTemplatePanic(t *testing.T) { + root := &cobra.Command{Use: "dws"} + root.SetHelpTemplate("{{") + err := validateHelpRendering(root, commandSnapshot("dws")) + if err == nil || !strings.Contains(err.Error(), `render "dws" help`) { + t.Fatalf("validateHelpRendering template panic = %v", err) + } +} + +func TestCrossPlatformCoverageValidateHelpRenderingRejectsCustomHelpStderr(t *testing.T) { + root := &cobra.Command{Use: "dws"} + root.SetHelpFunc(func(command *cobra.Command, _ []string) { + _, _ = io.WriteString(command.ErrOrStderr(), "injected help failure") + }) + err := validateHelpRendering(root, commandSnapshot("dws")) + if err == nil || !strings.Contains(err.Error(), "injected help failure") { + t.Fatalf("validateHelpRendering custom stderr = %v", err) + } +} + +func TestCrossPlatformCoverageValidateHelpRenderingRejectsEmptyOutput(t *testing.T) { + root := &cobra.Command{Use: "dws"} + root.SetHelpFunc(func(*cobra.Command, []string) {}) + err := validateHelpRendering(root, commandSnapshot("dws")) + if err == nil || !strings.Contains(err.Error(), "empty") { + t.Fatalf("validateHelpRendering empty output = %v", err) + } +} + +func TestCrossPlatformCoverageValidateHelpRenderingAcceptsNormalOutput(t *testing.T) { + root := &cobra.Command{Use: "dws", Short: "root command"} + if err := validateHelpRendering(root, commandSnapshot("dws")); err != nil { + t.Fatalf("validateHelpRendering normal output: %v", err) + } +} + +type failingWriter struct{} + +func (failingWriter) Write([]byte) (int, error) { + return 0, errors.New("injected write failure") +} + +var _ io.Writer = failingWriter{} + func commandSnapshot(paths ...string) interfacesnapshot.Snapshot { commands := make([]interfacesnapshot.Command, 0, len(paths)) for _, path := range paths { @@ -120,6 +464,68 @@ func writeSnapshot(t *testing.T, dir, name string, snapshot interfacesnapshot.Sn return path } +func writeManifest(t *testing.T, dir, name, contents string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } + return path +} + +func flagMigrationSnapshot(after bool) interfacesnapshot.Snapshot { + legacy := interfacesnapshot.Flag{ + Name: "legacy-id", + Shorthand: "l", + Type: "string", + Default: "", + NoOpt: "auto", + Required: true, + } + flags := []interfacesnapshot.Flag{legacy} + if after { + legacy.Required = false + legacy.Hidden = true + legacy.AliasOf = "message-id" + flags = []interfacesnapshot.Flag{ + legacy, + {Name: "message-id", Type: "string", Default: "", Required: true}, + } + } + return interfacesnapshot.Snapshot{ + SchemaVersion: interfacesnapshot.SchemaVersion, + Rules: interfacesnapshot.Rules{ + ExcludedCommandSubtrees: []string{}, + ExcludedFlags: []string{}, + }, + Commands: []interfacesnapshot.Command{ + {Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}}, + {Path: "dws chat send", Runnable: true, Aliases: []string{}, LocalFlags: flags, InheritedFlags: []interfacesnapshot.Flag{}}, + }, + } +} + +func flagMigrationManifestJSON(state string) string { + return strings.Replace(`{ + "version": 1, + "migrations": [{ + "command": "dws chat send", + "legacy": { + "name": "legacy-id", + "before": {"present": true, "type": "string", "required": true, "shorthand": "l", "no_opt": "auto", "scope": "local"}, + "after": {"present": true, "type": "string", "hidden": true, "shorthand": "l", "no_opt": "auto", "scope": "local", "alias_of": "message-id"} + }, + "canonical": { + "name": "message-id", + "before": {"present": false}, + "after": {"present": true, "type": "string", "required": true, "scope": "local"} + }, + "state": "STATE", + "reason": "reviewed exact migration" + }] +}`, "STATE", state, 1) +} + func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool { for _, flag := range flags { if flag.Name == name && flag.Type == flagType { diff --git a/docs/ci-pr-gates.md b/docs/ci-pr-gates.md index 69f9a6e3..2d386d99 100644 --- a/docs/ci-pr-gates.md +++ b/docs/ci-pr-gates.md @@ -157,15 +157,31 @@ expected to repeat every CI job locally: ```sh make build make policy -make interface-integrity -make authoritative-interface-integrity BASE_REF= -make schema-compatibility BASE_REF= +make interface-integrity BASE_REF= STABLE_REF= CANDIDATE_REF= +make schema-compatibility BASE_REF= STABLE_REF= CANDIDATE_REF= make skill-command-integrity make cli-smoke make mock-mcp-smoke go test -v -count=1 ./pkg/editiontest/... ``` +CI 先解析并核对精确的 merge-base、最近可达且未撤回的 stable GA tag 和已提交的 candidate +SHA,再调用 `make authoritative-interface-integrity`。本地 `make interface-integrity` +与该 CI target 都只委托给同一个 modern authoritative wrapper,不存在第二个比较 +入口。省略 `BASE_REF` 时本地 target 默认比较 `origin/main`,省略 `STABLE_REF` 时自动 +选择该 base 可达且未撤回的最近 stable GA tag,省略 `CANDIDATE_REF` 时比较已提交的 `HEAD`。 +需要逐字复现某次 CI 时,应显式传入该次运行记录的 merge-base、stable tag 和 +candidate SHA。 + +`make update-interface-baseline` / `make reset-interface-baseline` 只维护 +`test/fixtures/cli-interface-baseline.txt` 这一份非权威 CLI Smoke fixture。底层旧 +`check-interface-baseline.sh` 不再作为本地或 CI 的兼容性审批入口,也不能用于批准 +flag 迁移。 + +Schema compatibility 使用同一组 base、stable、candidate refs 和同一份 base-owned flag +migration ledger。它只把已通过 Interface lifecycle 的 exact rename 规范化到历史 Schema +副本后再运行原 checker;不会维护第二份 allowlist,也不会放宽其他 Schema 历史字段。 + For an exact CHANGELOG-only branch: ```sh @@ -186,11 +202,24 @@ are evaluated by the same block-deduplicating checker; supporting policy and shortcut profiles contribute to changed-code coverage only. The checked-in badge is presentation only and is never read as a gate input. -Compatibility checks derive authoritative Interface snapshots from the PR -merge-base and the latest reachable stable release. The candidate cannot bless -a breaking change by editing a fixture. Schema additions are allowed; -historical products, tools, parameters, mappings, positional execution fields, -constraints, and safety semantics remain protected. +CLI 兼容检查只使用 modern Interface Snapshot 这一处权威比较 seam,并从 PR +merge-base 和最近的可达 stable release 生成权威快照。本治理机制合入后, +merge-base 拥有生成器、比较器和已审批迁移清单,因此 candidate 不能通过修改 +helper、fixture 或在同一 PR 新增 self-approval 记录来放行 breaking change。首次 +bootstrap 仍由 merge-base 已有的 modern helper 做无豁免比较,并只接受 candidate +提交中的规范空清单;完整边界见下方治理文档。 + +精确的两阶段 flag 迁移生命周期见 +[CLI flag 兼容迁移治理](cli-interface-flag-migrations.md)。治理 PR 只能在 +surface 未变化时新增 `pending`;后续产品 PR 达到审批的精确 surface 后,才能 +消费 base-owned 记录并改为 `consumed`。在 main 与 stable 都达到 after 状态前 +必须保留该回执,之后再由单独 PR 清理。机制只放行记录中的 legacy +visible-to-hidden,以及 canonical required 新增或提升;删除、type、scope、 +shorthand、no-opt 和任何无关漂移仍然阻塞。Schema 可以新增;历史 product、 +tool、parameter、mapping、positional execution、constraint 与 safety 语义继续 +受保护。`alias_of` 只是一项由 `FlagSpec.Aliases` 产生的框架关系证据,不是 payload +等价证明;产品 PR 仍须证明 canonical 与 legacy 的最终运行 payload 等价并在 transport +前拒绝冲突输入。当前迁移清单为空,不授权 PR #904。 ## Required GitHub repository settings diff --git a/docs/cli-interface-flag-migrations.md b/docs/cli-interface-flag-migrations.md new file mode 100644 index 00000000..df5613e5 --- /dev/null +++ b/docs/cli-interface-flag-migrations.md @@ -0,0 +1,167 @@ +# CLI flag 兼容迁移治理 + +本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 提升为必填。它只解决这一种精确变更,不是通用 breaking-change 豁免。 + +## 唯一比较入口与信任边界 + +PR 与本地兼容性审批的唯一权威比较入口是 modern Interface Snapshot: + +- `cmd/interface-snapshot` 生成和比较快照; +- `internal/interfacesnapshot` 实现兼容规则和迁移生命周期; +- `scripts/policy/check-command-compatibility.sh` 组装 candidate、PR merge-base 和最近可达且未撤回的 stable GA 三份快照; +- `scripts/policy/check-authoritative-interface-baselines.sh` 只保留为 Makefile 的兼容包装,不再维护第二套判断逻辑。 + +紧随其后的 Schema compatibility 不是第二份审批清单。它从同一 merge-base-owned +ledger 和同一组三方 Interface Snapshot 取得已经完成 lifecycle 校验的 +authorization,只把授权的 flag rename 精确投影到历史 Schema 后,再运行原有 Schema +checker。candidate 不能为 CLI 与 Schema 分别提供两套例外。 + +`make interface-integrity` 也调用上述 authoritative wrapper;默认 base 为 +`origin/main`,stable 可由包装脚本自动解析,candidate 默认为已提交的 `HEAD`。旧 +`scripts/policy/check-interface-baseline.sh` 只供 +`make update-interface-baseline` / `make reset-interface-baseline` 维护非权威 CLI +Smoke fixture,不参与迁移审批。 + +PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入本机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空清单,不会让 candidate 新增的 comparator 决定本 PR 是否兼容。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。 + +这条边界保护比较规则和审批数据,不是任意代码沙箱。GitHub workflow / launcher 的变更仍由仓库保护规则和真人评审负责;candidate Cobra 构建也会执行 candidate 代码,因此对同一 runner 上的主动恶意代码,需要独立进程或文件系统隔离,不能把本门禁描述成已经解决。 + +已审批清单固定为: + +```text +scripts/policy/interface-migrations/approved-flag-migrations-v1.json +``` + +清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单为空,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。 + +## 两阶段迁移与回执清理 + +每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期: + +| 阶段 | PR 可以做什么 | 必须满足的快照状态 | +|---|---|---| +| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 | +| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 达到记录的必填状态 | +| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` | +| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 | + +因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。 + +下面只是清单结构示例,不代表已审批命令;实际字段必须从 Interface Snapshot 核对: + +```json +{ + "version": 1, + "migrations": [ + { + "command": "dws chat message recall", + "legacy": { + "name": "msg-id", + "before": { + "present": true, + "type": "string", + "required": true, + "scope": "local" + }, + "after": { + "present": true, + "type": "string", + "hidden": true, + "scope": "local", + "alias_of": "message-id" + } + }, + "canonical": { + "name": "message-id", + "before": { "present": false }, + "after": { + "present": true, + "type": "string", + "required": true, + "scope": "local" + } + }, + "state": "pending", + "reason": "保留旧 argv 兼容性,并将规范 flag 设为唯一可见入口" + } + ] +} +``` + +产品迁移 PR 必须保持同一条记录的命令、flag、before/after 和 reason 不变,只把 `pending` 改成 `consumed`。 + +## `alias_of` 是框架来源的受评审关系证据 + +`alias_of` 不是 Schema 同义词、参数概念词典或任意文字声明。它只能由 `FlagSpec.Aliases` 写入,并与内部 origin `corecmd.flag_spec_aliases.v1` 成对出现;每次 Interface Integrity 都会在已提交的 detached candidate 上执行源码门禁,禁止其他生产文件写入或复刻这些 evidence token。Interface Snapshot 会验证: + +- legacy 与 canonical 位于同一个可执行命令; +- canonical flag 确实存在; +- legacy 与 canonical 类型一致; +- legacy 不是指向自身,也不存在 alias chain; +- legacy 的 after 状态精确指向该记录中的 canonical flag。 + +通过命令框架声明 `FlagSpec.Aliases` 时,框架会自动注册隐藏的兼容 flag,并写入两项 relation annotation;仅手写 `alias_of`、伪造 origin、重复值或不精确值都会让快照生成失败。不要用 Schema overlay、迁移清单或手写 Cobra annotation 伪造关系。 + +这项关系证据只证明 legacy/canonical 经过受控框架路径建立关系,不证明最终 transport payload 等价,也不会替产品代码实现命令特有的值同步。当前框架还禁止把 +`MarkRequired` 与 `FlagSpec.Aliases` 直接组合,因为 Cobra 的 hard-required +校验只识别 canonical spelling。若产品迁移同时需要 canonical 的 Cobra required +标记和 legacy spelling,产品 PR 必须提供明确的运行时方案,并通过 canonical / legacy +最终 payload 等价、同值输入一致、冲突输入在 transport 前失败、legacy 仍可调用但 Help 隐藏等测试;迁移清单和 relation evidence 都不能替代这些证明。 + +## 豁免边界 + +一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding: + +1. legacy flag 的 `flag_became_hidden`(visible → hidden); +2. canonical flag 的 `required_flag_added`(新增时即必填)或 `flag_became_required`(已有 flag 从可选变必填)。 + +以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖: + +- 删除 legacy、canonical、命令或其他 flag; +- flag 类型或迁移记录中的 scope、shorthand、`no_opt` 漂移; +- `alias_of` 缺失、指向变化或 alias chain; +- 命令路径及任何无关的阻塞性接口变化; +- 不精确、部分完成、超出记录范围的 surface 变化。 + +## Schema 投影边界 + +Agent-visible command 会把 visible Cobra flag 投影为 Schema parameter,因此合法的 +legacy hidden 迁移会同时表现为历史 parameter 消失,constraint member 也可能从 +legacy 名改为 canonical 名。Schema adapter 只接受已经由三方 Interface Snapshot +判定为 authorized 的迁移,并按 tool 的精确 `primary_cli_path` 绑定: + +- reference 仍处于 `before` 且该 flag 有 Schema surface 时,baseline legacy parameter + 必须存在,candidate legacy parameter 必须消失,candidate canonical parameter 必须存在; + 如果 baseline 只有 canonical、没有 legacy,则 adapter 不得借 CLI ledger 提升 + `required` / `cli_required` 或重写 constraint; +- rename 前后的 `type`、`property`、`interface_type`、default、format、enum 与 + `required_when` 必须完全一致; +- `required` / `cli_required` 只能保持不变或按审批从 `false` 提升为 `true`,禁止降低; +- constraint 只允许在同一 tool 内按已枚举的 legacy → canonical map 做 member 替换、 + 排序与去重;group kind、非迁移 member 或 group 增删仍然阻塞; +- 多个 legacy 指向同一 canonical 时,所有历史 parameter signature 必须一致,否则 + fail closed。 + +adapter 先构造经过上述验证的历史 contract 副本,再调用原 Schema checker;它不会按 +错误字符串删除 finding。这样既能处理纯 rename,也能阻止“旧 required 参数改名后意外 +变为 optional”或 property 漂移等伪兼容。`consumed` 回执在 merge-base Schema 已经处于 +canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline 不变,由原 checker +验证 candidate 是否仍与该 canonical contract 兼容。 + +## 本地验证 + +先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁: + +```sh +make interface-integrity \ + BASE_REF= \ + STABLE_REF= \ + CANDIDATE_REF= + +make schema-compatibility \ + BASE_REF= \ + STABLE_REF= \ + CANDIDATE_REF= +``` + +`STABLE_REF` 必须解析到从该 merge-base 可达的最高未撤回 stable GA tag;primary checker 会按 release contract 独立核对,不能用任意 after commit 或已撤回版本提前清理回执。包装脚本可以在省略时自动解析。`CANDIDATE_REF` 省略时固定为命令启动时的已提交 `HEAD`;评审和复现 CI 时应显式传入 candidate SHA,避免 surface 与清单来自不同 revision。 diff --git a/internal/corecmd/corecmd.go b/internal/corecmd/corecmd.go index 56fc1b1c..87af3683 100644 --- a/internal/corecmd/corecmd.go +++ b/internal/corecmd/corecmd.go @@ -558,6 +558,18 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) { for _, alias := range flag.Aliases { RegisterFlag(cmd, flag.Kind, alias, "", flag.Usage+" (alias)") _ = cmd.Flags().MarkHidden(alias) + if registered := cmd.Flags().Lookup(alias); registered != nil { + runtimeannotate.SetFlagAnnotation( + registered, + runtimeannotate.AnnotationFlagAliasOf, + flag.Name, + ) + runtimeannotate.SetFlagAnnotation( + registered, + runtimeannotate.AnnotationFlagAliasOrigin, + runtimeannotate.FlagAliasOriginCorecmdV1, + ) + } } if flag.MarkRequired { _ = cmd.MarkFlagRequired(flag.Name) diff --git a/internal/corecmd/corecmd_test.go b/internal/corecmd/corecmd_test.go index b82a9b57..570b268a 100644 --- a/internal/corecmd/corecmd_test.go +++ b/internal/corecmd/corecmd_test.go @@ -90,11 +90,17 @@ func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) { } } // Aliases are registered with the main kind and hidden. - for _, alias := range []string{"i-alias", "sl-alias"} { + for alias, canonical := range map[string]string{"i-alias": "i", "sl-alias": "sl"} { f := cmd.Flags().Lookup(alias) if f == nil || !f.Hidden { t.Fatalf("alias %q = %#v, want registered+hidden", alias, f) } + if got := f.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != canonical { + t.Fatalf("alias %q annotation = %#v, want alias_of %q", alias, got, canonical) + } + if got := f.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 { + t.Fatalf("alias %q origin = %#v, want corecmd FlagSpec marker", alias, got) + } } if cmd.Flags().Lookup("i-alias").Value.Type() != "int" { t.Fatal("int alias must be registered as int") diff --git a/internal/corecmd/runtimeannotate/interface_alias.go b/internal/corecmd/runtimeannotate/interface_alias.go new file mode 100644 index 00000000..37a2cc9d --- /dev/null +++ b/internal/corecmd/runtimeannotate/interface_alias.go @@ -0,0 +1,25 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtimeannotate + +// CLI flag alias evidence is kept in this narrow file so the base-owned +// Interface Snapshot helper can add the protocol constants to an older stable +// worktree without replacing that revision's complete runtimeannotate package. +// Only corecmd.FlagSpec.Aliases writes the exact origin; neither field is a +// Schema synonym or final payload-equivalence proof. +const ( + AnnotationFlagAliasOf = "dws.compat.alias_of" + AnnotationFlagAliasOrigin = "dws.compat.alias_origin" + FlagAliasOriginCorecmdV1 = "corecmd.flag_spec_aliases.v1" +) diff --git a/internal/interfacesnapshot/compare.go b/internal/interfacesnapshot/compare.go index 79fcd2d3..7072c067 100644 --- a/internal/interfacesnapshot/compare.go +++ b/internal/interfacesnapshot/compare.go @@ -218,6 +218,15 @@ func compareEffectiveFlags(result *Comparison, acceptedPath string, oldCommand, After: newFlag.NoOpt, }) } + if oldFlag.AliasOf != "" && newFlag.AliasOf != oldFlag.AliasOf { + result.Blocking = append(result.Blocking, Change{ + Kind: "flag_alias_target_changed", + Path: acceptedPath, + Flag: name, + Before: oldFlag.AliasOf, + After: newFlag.AliasOf, + }) + } if !oldFlag.Hidden && newFlag.Hidden { result.Blocking = append(result.Blocking, Change{ Kind: "flag_became_hidden", diff --git a/internal/interfacesnapshot/compare_test.go b/internal/interfacesnapshot/compare_test.go index 5e5b0ba0..0afde685 100644 --- a/internal/interfacesnapshot/compare_test.go +++ b/internal/interfacesnapshot/compare_test.go @@ -247,6 +247,40 @@ func TestCrossPlatformCoverageCompareBlocksCallableMetadataRegressions(t *testin } } +func TestCrossPlatformCoverageCompareBlocksFlagAliasTargetRegression(t *testing.T) { + baseLegacy := testFlag("legacy-id", "string", false) + baseLegacy.AliasOf = "message-id" + base := testSnapshot( + testCommand("dws"), + testCommand("dws send", baseLegacy, testFlag("message-id", "string", false)), + ) + + for _, target := range []string{"", "other-id"} { + t.Run("target_"+target, func(t *testing.T) { + currentLegacy := baseLegacy + currentLegacy.AliasOf = target + current := testSnapshot( + testCommand("dws"), + testCommand( + "dws send", + currentLegacy, + testFlag("message-id", "string", false), + testFlag("other-id", "string", false), + ), + ) + comparison := Compare(current, base, "base") + if comparison.Compatible || !hasFlagChange( + comparison.Blocking, + "flag_alias_target_changed", + "dws send", + "legacy-id", + ) { + t.Fatalf("alias target regression was not blocked: %#v", comparison) + } + }) + } +} + func TestCrossPlatformCoverageCompareUsesEffectiveFlagsAtEveryCommandPath(t *testing.T) { base := testSnapshot( testCommand("dws", testFlag("profile", "string", false)), @@ -310,6 +344,261 @@ func TestCrossPlatformCoverageCompareAllRequiresBothReferencesToPass(t *testing. } } +func TestCrossPlatformCoverageCompareAllWithFlagMigrationsAuthorizesOnlyTheExactTransition(t *testing.T) { + before := testFlagMigrationSnapshot(false, true) + authority := testFlagMigrationManifest(FlagMigrationPending) + candidate := testFlagMigrationManifest(FlagMigrationConsumed) + + report, err := CompareAllWithFlagMigrations( + testFlagMigrationSnapshot(true, true), + map[string]Snapshot{ + "merge-base": before, + "stable": before, + }, + authority, + candidate, + ) + if err != nil { + t.Fatalf("exact base-owned migration was rejected: %v", err) + } + if !report.Compatible { + t.Fatalf("exact migration remained incompatible: %#v", report.Comparisons) + } + for _, comparison := range report.Comparisons { + if len(comparison.Blocking) != 0 { + t.Fatalf("reference %q retained blocking changes: %#v", comparison.Reference, comparison.Blocking) + } + } +} + +func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRejectsInvalidLifecycleTransitions(t *testing.T) { + empty := testEmptyFlagMigrationManifest() + pending := testFlagMigrationManifest(FlagMigrationPending) + consumed := testFlagMigrationManifest(FlagMigrationConsumed) + before := testFlagMigrationSnapshot(false, true) + after := testFlagMigrationSnapshot(true, true) + + partial := testFlagMigrationSnapshot(false, true) + partial.Commands[1].LocalFlags[0].Hidden = true + partial.Commands[1].LocalFlags[0].Required = false + partial.Commands[1].LocalFlags[0].AliasOf = "message-id" + + typeDrift := testFlagMigrationSnapshot(true, true) + typeDrift.Commands[1].LocalFlags[0].Type = "stringSlice" + noOptDrift := testFlagMigrationSnapshot(true, true) + noOptDrift.Commands[1].LocalFlags[0].NoOpt = "" + shorthandDrift := testFlagMigrationSnapshot(true, true) + shorthandDrift.Commands[1].LocalFlags[0].Shorthand = "x" + + tests := []struct { + name string + current Snapshot + authority FlagMigrationManifest + candidate FlagMigrationManifest + }{ + { + name: "candidate-added pending record cannot approve its own surface change", + current: after, + authority: empty, + candidate: pending, + }, + { + name: "candidate-added consumed record cannot approve its own surface change", + current: after, + authority: empty, + candidate: consumed, + }, + { + name: "partial migration is rejected", + current: partial, + authority: pending, + candidate: consumed, + }, + { + name: "pending record cannot be falsely consumed before the surface changes", + current: before, + authority: pending, + candidate: consumed, + }, + { + name: "completed surface change must consume its pending record", + current: after, + authority: pending, + candidate: pending, + }, + { + name: "legacy type drift is outside the approval", + current: typeDrift, + authority: pending, + candidate: consumed, + }, + { + name: "legacy no-opt drift is outside the approval", + current: noOptDrift, + authority: pending, + candidate: consumed, + }, + { + name: "legacy shorthand drift is outside the approval", + current: shorthandDrift, + authority: pending, + candidate: consumed, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + report, err := CompareAllWithFlagMigrations( + test.current, + map[string]Snapshot{ + "merge-base": before, + "stable": before, + }, + test.authority, + test.candidate, + ) + if err == nil { + t.Fatalf("invalid transition passed: %#v", report) + } + }) + } +} + +func TestCrossPlatformCoverageCompareAllWithFlagMigrationsAllowsApprovalCreationBeforeSurfaceChange(t *testing.T) { + before := testFlagMigrationSnapshot(false, true) + report, err := CompareAllWithFlagMigrations( + before, + map[string]Snapshot{ + "merge-base": before, + "stable": before, + }, + testEmptyFlagMigrationManifest(), + testFlagMigrationManifest(FlagMigrationPending), + ) + if err != nil { + t.Fatalf("pending approval creation was rejected before the surface changed: %v", err) + } + if !report.Compatible { + t.Fatalf("pending approval creation changed compatibility: %#v", report.Comparisons) + } +} + +func TestCrossPlatformCoverageCompareAllWithFlagMigrationsKeepsUnrelatedBreakageBlocking(t *testing.T) { + before := testFlagMigrationSnapshot(false, true) + pending := testFlagMigrationManifest(FlagMigrationPending) + consumed := testFlagMigrationManifest(FlagMigrationConsumed) + + tests := []struct { + name string + mutate func(*Snapshot) + kind string + flag string + }{ + { + name: "unrelated flag removal", + mutate: func(snapshot *Snapshot) { + snapshot.Commands[1].LocalFlags = snapshot.Commands[1].LocalFlags[:2] + }, + kind: "flag_removed", + flag: "format", + }, + { + name: "unrelated flag type change", + mutate: func(snapshot *Snapshot) { + snapshot.Commands[1].LocalFlags[2].Type = "stringSlice" + }, + kind: "flag_type_changed", + flag: "format", + }, + { + name: "unrelated flag no-opt change", + mutate: func(snapshot *Snapshot) { + snapshot.Commands[1].LocalFlags[2].NoOpt = "" + }, + kind: "flag_no_opt_changed", + flag: "format", + }, + { + name: "unrelated flag shorthand change", + mutate: func(snapshot *Snapshot) { + snapshot.Commands[1].LocalFlags[2].Shorthand = "" + }, + kind: "flag_shorthand_changed", + flag: "format", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + current := testFlagMigrationSnapshot(true, true) + test.mutate(¤t) + report, err := CompareAllWithFlagMigrations( + current, + map[string]Snapshot{ + "merge-base": before, + "stable": before, + }, + pending, + consumed, + ) + if err != nil { + t.Fatalf("valid migration with unrelated breakage returned a lifecycle error: %v", err) + } + if report.Compatible { + t.Fatalf("unrelated breakage was hidden by migration approval: %#v", report) + } + for _, comparison := range report.Comparisons { + if !hasFlagChange(comparison.Blocking, test.kind, "dws chat send", test.flag) { + t.Fatalf("reference %q blocking=%#v, want %s for --%s", comparison.Reference, comparison.Blocking, test.kind, test.flag) + } + } + }) + } +} + +func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRetainsConsumedReceiptUntilStableCatchesUp(t *testing.T) { + before := testFlagMigrationSnapshot(false, true) + after := testFlagMigrationSnapshot(true, true) + consumed := testFlagMigrationManifest(FlagMigrationConsumed) + + report, err := CompareAllWithFlagMigrations( + after, + map[string]Snapshot{ + "merge-base": after, + "stable": before, + }, + consumed, + consumed, + ) + if err != nil { + t.Fatalf("consumed receipt was rejected while stable still needed it: %v", err) + } + if !report.Compatible { + t.Fatalf("stable comparison did not honor the consumed receipt: %#v", report.Comparisons) + } +} + +func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresCleanupAfterAllReferencesCatchUp(t *testing.T) { + after := testFlagMigrationSnapshot(true, true) + consumed := testFlagMigrationManifest(FlagMigrationConsumed) + references := map[string]Snapshot{ + "merge-base": after, + "stable": after, + } + + if report, err := CompareAllWithFlagMigrations(after, references, consumed, consumed); err == nil { + t.Fatalf("stale consumed receipt was accepted after every reference caught up: %#v", report) + } + + report, err := CompareAllWithFlagMigrations(after, references, consumed, testEmptyFlagMigrationManifest()) + if err != nil { + t.Fatalf("cleanup of stale consumed receipt was rejected: %v", err) + } + if !report.Compatible { + t.Fatalf("cleanup-only candidate was incompatible: %#v", report.Comparisons) + } +} + func testSnapshot(commands ...Command) Snapshot { return Snapshot{ SchemaVersion: SchemaVersion, @@ -348,6 +637,92 @@ func testFlag(name, flagType string, required bool) Flag { return Flag{Name: name, Type: flagType, Required: required} } +func testFlagMigrationSnapshot(after, includeUnrelated bool) Snapshot { + legacy := Flag{ + Name: "legacy-id", + Shorthand: "l", + Type: "string", + NoOpt: "auto", + Required: true, + } + flags := []Flag{legacy} + if after { + legacy.Required = false + legacy.Hidden = true + legacy.AliasOf = "message-id" + flags = []Flag{ + legacy, + { + Name: "message-id", + Type: "string", + Required: true, + }, + } + } + if includeUnrelated { + flags = append(flags, Flag{ + Name: "format", + Shorthand: "f", + Type: "string", + NoOpt: "json", + }) + } + return testSnapshot( + testCommand("dws"), + testCommand("dws chat send", flags...), + ) +} + +func testFlagMigrationManifest(state string) FlagMigrationManifest { + return FlagMigrationManifest{ + Version: FlagMigrationManifestVersion, + Migrations: []FlagMigration{ + { + Command: "dws chat send", + Legacy: FlagMigrationSide{ + Name: "legacy-id", + Before: FlagMigrationState{ + Present: true, + Type: "string", + Required: true, + Shorthand: "l", + NoOpt: "auto", + Scope: "local", + }, + After: FlagMigrationState{ + Present: true, + Type: "string", + Hidden: true, + Shorthand: "l", + NoOpt: "auto", + Scope: "local", + AliasOf: "message-id", + }, + }, + Canonical: FlagMigrationSide{ + Name: "message-id", + Before: FlagMigrationState{}, + After: FlagMigrationState{ + Present: true, + Type: "string", + Required: true, + Scope: "local", + }, + }, + State: state, + Reason: "rename the public flag while preserving the executable legacy alias", + }, + }, + } +} + +func testEmptyFlagMigrationManifest() FlagMigrationManifest { + return FlagMigrationManifest{ + Version: FlagMigrationManifestVersion, + Migrations: []FlagMigration{}, + } +} + func hasChangeKind(changes []Change, kind string) bool { for _, change := range changes { if change.Kind == kind { diff --git a/internal/interfacesnapshot/migrations.go b/internal/interfacesnapshot/migrations.go new file mode 100644 index 00000000..5bea2adb --- /dev/null +++ b/internal/interfacesnapshot/migrations.go @@ -0,0 +1,692 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package interfacesnapshot + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "reflect" + "strings" +) + +const FlagMigrationManifestVersion = 1 + +const ( + FlagMigrationPending = "pending" + FlagMigrationConsumed = "consumed" +) + +type FlagMigrationManifest struct { + Version int `json:"version"` + Migrations []FlagMigration `json:"migrations"` +} + +type FlagMigration struct { + Command string `json:"command"` + Legacy FlagMigrationSide `json:"legacy"` + Canonical FlagMigrationSide `json:"canonical"` + State string `json:"state"` + Reason string `json:"reason"` +} + +type FlagMigrationSide struct { + Name string `json:"name"` + Before FlagMigrationState `json:"before"` + After FlagMigrationState `json:"after"` +} + +type FlagMigrationState struct { + Present bool `json:"present"` + Type string `json:"type,omitempty"` + Required bool `json:"required,omitempty"` + Hidden bool `json:"hidden,omitempty"` + Shorthand string `json:"shorthand,omitempty"` + NoOpt string `json:"no_opt,omitempty"` + Scope string `json:"scope,omitempty"` + AliasOf string `json:"alias_of,omitempty"` +} + +func ReadFlagMigrationManifest(r io.Reader) (FlagMigrationManifest, error) { + data, err := io.ReadAll(r) + if err != nil { + return FlagMigrationManifest{}, fmt.Errorf("read flag migration manifest: %w", err) + } + var manifest FlagMigrationManifest + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&manifest); err != nil { + return FlagMigrationManifest{}, err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + return FlagMigrationManifest{}, fmt.Errorf("flag migration manifest contains trailing multiple JSON values") + } + return FlagMigrationManifest{}, fmt.Errorf("read trailing flag migration manifest data: %w", err) + } + if err := validateFlagMigrationJSONSchema(data); err != nil { + return FlagMigrationManifest{}, err + } + if err := manifest.Validate(); err != nil { + return FlagMigrationManifest{}, err + } + return manifest, nil +} + +func validateFlagMigrationJSONSchema(data []byte) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := validateMigrationJSONValue(decoder, "$", reflect.TypeOf(FlagMigrationManifest{})); err != nil { + return err + } + if token, err := decoder.Token(); err != io.EOF { + if err == nil { + return fmt.Errorf("flag migration manifest contains trailing JSON value %v", token) + } + return fmt.Errorf("read trailing flag migration manifest data: %w", err) + } + return nil +} + +func validateMigrationJSONValue(decoder *json.Decoder, path string, schema reflect.Type) error { + token, err := decoder.Token() + if err != nil { + return fmt.Errorf("read flag migration manifest value at %s: %w", path, err) + } + + switch schema.Kind() { + case reflect.Struct: + if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' { + return migrationJSONTypeError(path, schema, token) + } + fields := migrationJSONFields(schema) + seen := make(map[string]bool, len(fields)) + for decoder.More() { + keyToken, keyErr := decoder.Token() + if keyErr != nil { + return fmt.Errorf("read flag migration manifest field at %s: %w", path, keyErr) + } + // encoding/json guarantees object member names are string tokens. + key := keyToken.(string) + fieldSchema, exists := fields[key] + if !exists { + for canonical := range fields { + if strings.EqualFold(key, canonical) { + return fmt.Errorf( + "flag migration manifest contains non-canonical field %q at %s (want %q)", + key, + path, + canonical, + ) + } + } + return fmt.Errorf("flag migration manifest contains unknown field %q at %s", key, path) + } + if seen[key] { + return fmt.Errorf("flag migration manifest contains duplicate field %q at %s", key, path) + } + seen[key] = true + if err := validateMigrationJSONValue(decoder, path+"."+key, fieldSchema); err != nil { + return err + } + } + if _, closeErr := decoder.Token(); closeErr != nil { + return fmt.Errorf("close flag migration manifest object at %s: %w", path, closeErr) + } + return nil + case reflect.Slice: + if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' { + return migrationJSONTypeError(path, schema, token) + } + for index := 0; decoder.More(); index++ { + if err := validateMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem()); err != nil { + return err + } + } + if _, closeErr := decoder.Token(); closeErr != nil { + return fmt.Errorf("close flag migration manifest array at %s: %w", path, closeErr) + } + return nil + case reflect.String: + if _, ok := token.(string); !ok { + return migrationJSONTypeError(path, schema, token) + } + return nil + case reflect.Int: + if _, ok := token.(json.Number); !ok { + return migrationJSONTypeError(path, schema, token) + } + return nil + case reflect.Bool: + if _, ok := token.(bool); !ok { + return migrationJSONTypeError(path, schema, token) + } + return nil + default: + return fmt.Errorf("flag migration manifest value at %s has unsupported Go schema type %s", path, schema) + } +} + +func migrationJSONFields(schema reflect.Type) map[string]reflect.Type { + fields := make(map[string]reflect.Type, schema.NumField()) + for index := 0; index < schema.NumField(); index++ { + field := schema.Field(index) + name := field.Tag.Get("json") + if comma := strings.IndexByte(name, ','); comma >= 0 { + name = name[:comma] + } + if name == "-" { + continue + } + if name == "" { + name = field.Name + } + fields[name] = field.Type + } + return fields +} + +func migrationJSONTypeError(path string, want reflect.Type, token json.Token) error { + return fmt.Errorf( + "flag migration manifest value at %s must be %s, got %s", + path, + migrationJSONKindDescription(want), + migrationJSONTokenDescription(token), + ) +} + +func migrationJSONKindDescription(schema reflect.Type) string { + switch schema.Kind() { + case reflect.Struct: + return "an object" + case reflect.Slice: + return "an array" + case reflect.String: + return "a string" + case reflect.Int: + return "a number" + case reflect.Bool: + return "a boolean" + default: + return "the declared JSON type" + } +} + +func migrationJSONTokenDescription(token json.Token) string { + if token == nil { + return "null" + } + if delimiter, ok := token.(json.Delim); ok { + switch delimiter { + case '{': + return "object" + case '[': + return "array" + default: + return fmt.Sprintf("delimiter %q", delimiter) + } + } + switch token.(type) { + case string: + return "string" + case json.Number, float64: + return "number" + case bool: + return "boolean" + default: + return fmt.Sprintf("%T", token) + } +} + +func (m FlagMigrationManifest) Validate() error { + if m.Version != FlagMigrationManifestVersion { + return fmt.Errorf( + "unsupported flag migration manifest version %d (want %d)", + m.Version, + FlagMigrationManifestVersion, + ) + } + if m.Migrations == nil { + return fmt.Errorf("flag migration manifest migrations must be an array") + } + seen := make(map[string]bool, len(m.Migrations)) + legacyTargets := make(map[string]string, len(m.Migrations)) + for index, migration := range m.Migrations { + if err := migration.validate(); err != nil { + return fmt.Errorf("flag migration %d: %w", index, err) + } + key := migration.key() + if seen[key] { + return fmt.Errorf("flag migration %d duplicates %s", index, key) + } + seen[key] = true + legacyKey := migration.Command + "\x00" + migration.Legacy.Name + if canonical, exists := legacyTargets[legacyKey]; exists && canonical != migration.Canonical.Name { + return fmt.Errorf( + "flag migration %d maps %s --%s to both --%s and --%s", + index, + migration.Command, + migration.Legacy.Name, + canonical, + migration.Canonical.Name, + ) + } + legacyTargets[legacyKey] = migration.Canonical.Name + } + return nil +} + +func (m FlagMigration) validate() error { + if !isExactCommandPath(m.Command) { + return fmt.Errorf("command must be an exact command path rooted at dws: %q", m.Command) + } + if !isExactFlagName(m.Legacy.Name) { + return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name) + } + if !isExactFlagName(m.Canonical.Name) { + return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name) + } + if m.Legacy.Name == m.Canonical.Name { + return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name) + } + if strings.TrimSpace(m.Reason) == "" { + return fmt.Errorf("migration must include a non-empty reason") + } + if m.Reason != strings.TrimSpace(m.Reason) { + return fmt.Errorf("migration reason must be trimmed") + } + if m.State != FlagMigrationPending && m.State != FlagMigrationConsumed { + return fmt.Errorf("invalid state %q", m.State) + } + if err := m.Legacy.Before.validate("legacy before"); err != nil { + return err + } + if err := m.Legacy.After.validate("legacy after"); err != nil { + return err + } + if err := m.Canonical.Before.validate("canonical before"); err != nil { + return err + } + if err := m.Canonical.After.validate("canonical after"); err != nil { + return err + } + if !m.Legacy.Before.Present || !m.Legacy.After.Present { + return fmt.Errorf("legacy flag must remain present before and after migration") + } + if m.Legacy.Before.Hidden || !m.Legacy.After.Hidden { + return fmt.Errorf("legacy flag must migrate exactly from visible to hidden") + } + if !m.Canonical.After.Present { + return fmt.Errorf("canonical flag must be present after migration") + } + if m.Canonical.After.Hidden { + return fmt.Errorf("canonical flag must remain visible") + } + if !m.Canonical.After.Required { + return fmt.Errorf("canonical flag must be required after migration") + } + if m.Canonical.Before.Present && m.Canonical.Before.Required { + return fmt.Errorf("canonical flag must be absent or optional before migration") + } + if m.Legacy.After.AliasOf != m.Canonical.Name { + return fmt.Errorf( + "legacy flag after state must declare alias_of %q", + m.Canonical.Name, + ) + } + if m.Legacy.Before.AliasOf != "" || m.Canonical.Before.AliasOf != "" || m.Canonical.After.AliasOf != "" { + return fmt.Errorf("alias_of is only valid on the legacy after state") + } + if m.Canonical.Before.Present { + if m.Canonical.Before.Type != m.Canonical.After.Type { + return fmt.Errorf("canonical flag type must remain unchanged") + } + if m.Canonical.Before.Shorthand != m.Canonical.After.Shorthand { + return fmt.Errorf("canonical flag shorthand must remain unchanged") + } + if m.Canonical.Before.NoOpt != m.Canonical.After.NoOpt { + return fmt.Errorf("canonical flag no_opt must remain unchanged") + } + if m.Canonical.Before.Scope != m.Canonical.After.Scope { + return fmt.Errorf("canonical flag scope must remain unchanged") + } + } + if m.Legacy.Before.Type != m.Legacy.After.Type || m.Legacy.After.Type != m.Canonical.After.Type { + return fmt.Errorf("legacy and canonical flag types must match exactly") + } + if m.Legacy.Before.Shorthand != m.Legacy.After.Shorthand { + return fmt.Errorf("legacy flag shorthand must remain unchanged") + } + if m.Legacy.Before.NoOpt != m.Legacy.After.NoOpt { + return fmt.Errorf("legacy flag no_opt must remain unchanged") + } + if m.Legacy.Before.Scope != m.Legacy.After.Scope { + return fmt.Errorf("legacy flag scope must remain unchanged") + } + return nil +} + +func (s FlagMigrationState) validate(label string) error { + if !s.Present { + if s.Type != "" || s.Required || s.Hidden || s.Shorthand != "" || s.NoOpt != "" || s.Scope != "" || s.AliasOf != "" { + return fmt.Errorf("%s absent state must not declare flag attributes", label) + } + return nil + } + if strings.TrimSpace(s.Type) == "" { + return fmt.Errorf("%s present state requires type", label) + } + if s.Scope != "local" && s.Scope != "inherited" { + return fmt.Errorf("%s present state has invalid scope %q", label, s.Scope) + } + return nil +} + +func (m FlagMigration) key() string { + return m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name +} + +func isExactCommandPath(path string) bool { + return path != "" && + path == strings.TrimSpace(path) && + strings.Join(strings.Fields(path), " ") == path && + (path == "dws" || strings.HasPrefix(path, "dws ")) && + !strings.ContainsAny(path, "*?[]{}") +} + +func isExactFlagName(name string) bool { + return name != "" && + name == strings.TrimSpace(name) && + !strings.HasPrefix(name, "-") && + !strings.ContainsAny(name, "*?[]{} /\t\r\n") +} + +// CompareAllWithFlagMigrations applies the ordinary compatibility policy and +// then consumes only exact, merge-base-owned flag migrations. Candidate-owned +// records participate in the lifecycle check, but never authorize their own +// interface change. +func CompareAllWithFlagMigrations( + current Snapshot, + references map[string]Snapshot, + authority FlagMigrationManifest, + candidate FlagMigrationManifest, +) (Report, error) { + authorizations, err := AuthorizeFlagMigrations(current, references, authority, candidate) + if err != nil { + return Report{}, err + } + report := CompareAll(current, references) + if len(authorizations) == 0 { + return report, nil + } + + for index := range report.Comparisons { + comparison := &report.Comparisons[index] + reference := references[comparison.Reference] + filtered := comparison.Blocking[:0] + for _, change := range comparison.Blocking { + if flagMigrationAuthorizesChange(current, reference, change, authorizations) { + continue + } + filtered = append(filtered, change) + } + comparison.Blocking = filtered + comparison.Compatible = len(filtered) == 0 + } + report.Compatible = true + for _, comparison := range report.Comparisons { + if !comparison.Compatible { + report.Compatible = false + break + } + } + return report, nil +} + +// AuthorizeFlagMigrations validates both snapshots and manifests, enforces the +// base-owned migration lifecycle, and returns only approvals that may authorize +// the current exact interface transition. Candidate-added records never appear +// in the returned authorization set. +func AuthorizeFlagMigrations( + current Snapshot, + references map[string]Snapshot, + authority FlagMigrationManifest, + candidate FlagMigrationManifest, +) ([]FlagMigration, error) { + if err := current.Validate(); err != nil { + return nil, fmt.Errorf("validate current interface snapshot: %w", err) + } + for label, snapshot := range references { + if err := snapshot.Validate(); err != nil { + return nil, fmt.Errorf("validate %s interface snapshot: %w", label, err) + } + } + if err := authority.Validate(); err != nil { + return nil, fmt.Errorf("validate approved flag migrations: %w", err) + } + if err := candidate.Validate(); err != nil { + return nil, fmt.Errorf("validate candidate flag migrations: %w", err) + } + return evaluateFlagMigrationLifecycle(current, references, authority, candidate) +} + +type flagMigrationPhase string + +const ( + flagMigrationBefore flagMigrationPhase = "before" + flagMigrationAfter flagMigrationPhase = "after" + flagMigrationPartial flagMigrationPhase = "partial" +) + +func evaluateFlagMigrationLifecycle( + current Snapshot, + references map[string]Snapshot, + authority FlagMigrationManifest, + candidate FlagMigrationManifest, +) ([]FlagMigration, error) { + if len(authority.Migrations) == 0 && len(candidate.Migrations) == 0 { + return nil, nil + } + mergeBase, label, ok := flagMigrationAuthoritySnapshot(references) + if !ok { + return nil, fmt.Errorf("flag migration lifecycle requires a main or merge-base reference") + } + + authorityByKey := flagMigrationIndex(authority) + candidateByKey := flagMigrationIndex(candidate) + authorizations := make([]FlagMigration, 0, len(authority.Migrations)) + + for _, approved := range authority.Migrations { + basePhase := matchFlagMigrationPhase(mergeBase, approved) + wantBasePhase := flagMigrationBefore + if approved.State == FlagMigrationConsumed { + wantBasePhase = flagMigrationAfter + } + if basePhase != wantBasePhase { + return nil, fmt.Errorf( + "approved flag migration %s is %s in %s, want exact %s state for %s", + approved.displayKey(), + basePhase, + label, + wantBasePhase, + approved.State, + ) + } + + proposed, exists := candidateByKey[approved.key()] + if exists && !sameFlagMigrationApproval(approved, proposed) { + return nil, fmt.Errorf("candidate modified base-owned flag migration %s", approved.displayKey()) + } + + currentPhase := matchFlagMigrationPhase(current, approved) + switch approved.State { + case FlagMigrationPending: + if !exists { + return nil, fmt.Errorf("candidate removed pending flag migration %s", approved.displayKey()) + } + switch currentPhase { + case flagMigrationBefore: + if proposed.State != FlagMigrationPending { + return nil, fmt.Errorf("candidate falsely consumed unchanged flag migration %s", approved.displayKey()) + } + case flagMigrationAfter: + if proposed.State != FlagMigrationConsumed { + return nil, fmt.Errorf("candidate completed flag migration %s without marking it consumed", approved.displayKey()) + } + authorizations = append(authorizations, approved) + default: + return nil, fmt.Errorf("candidate partially applied flag migration %s", approved.displayKey()) + } + case FlagMigrationConsumed: + if currentPhase != flagMigrationAfter { + return nil, fmt.Errorf("candidate drifted from consumed flag migration %s", approved.displayKey()) + } + allReferencesAfter := true + for _, reference := range references { + if matchFlagMigrationPhase(reference, approved) != flagMigrationAfter { + allReferencesAfter = false + break + } + } + if allReferencesAfter { + if exists { + return nil, fmt.Errorf("consumed flag migration %s is stale after all references reached the after state", approved.displayKey()) + } + continue + } + if !exists { + return nil, fmt.Errorf("candidate removed consumed flag migration %s before every reference reached the after state", approved.displayKey()) + } + if proposed.State != FlagMigrationConsumed { + return nil, fmt.Errorf("candidate changed consumed flag migration %s back to pending", approved.displayKey()) + } + authorizations = append(authorizations, approved) + } + } + + for _, proposed := range candidate.Migrations { + if _, exists := authorityByKey[proposed.key()]; exists { + continue + } + if proposed.State != FlagMigrationPending { + return nil, fmt.Errorf("candidate-added flag migration %s must start pending", proposed.displayKey()) + } + if matchFlagMigrationPhase(mergeBase, proposed) != flagMigrationBefore { + return nil, fmt.Errorf("candidate-added flag migration %s does not match the merge-base before state", proposed.displayKey()) + } + if matchFlagMigrationPhase(current, proposed) != flagMigrationBefore { + return nil, fmt.Errorf("candidate-added flag migration %s cannot authorize its own interface change", proposed.displayKey()) + } + } + + return authorizations, nil +} + +func flagMigrationAuthoritySnapshot(references map[string]Snapshot) (Snapshot, string, bool) { + for _, label := range []string{"merge-base", "main"} { + if snapshot, ok := references[label]; ok { + return snapshot, label, true + } + } + return Snapshot{}, "", false +} + +func flagMigrationIndex(manifest FlagMigrationManifest) map[string]FlagMigration { + index := make(map[string]FlagMigration, len(manifest.Migrations)) + for _, migration := range manifest.Migrations { + index[migration.key()] = migration + } + return index +} + +func sameFlagMigrationApproval(left, right FlagMigration) bool { + left.State = "" + right.State = "" + return reflect.DeepEqual(left, right) +} + +func matchFlagMigrationPhase(snapshot Snapshot, migration FlagMigration) flagMigrationPhase { + command, exists := commandIndex(snapshot)[migration.Command] + if !exists { + return flagMigrationPartial + } + legacy := flagMigrationStateForCommand(command, migration.Legacy.Name) + canonical := flagMigrationStateForCommand(command, migration.Canonical.Name) + if legacy == migration.Legacy.Before && canonical == migration.Canonical.Before { + return flagMigrationBefore + } + if legacy == migration.Legacy.After && canonical == migration.Canonical.After { + return flagMigrationAfter + } + return flagMigrationPartial +} + +func flagMigrationStateForCommand(command Command, name string) FlagMigrationState { + for _, flag := range command.LocalFlags { + if flag.Name == name { + return flagMigrationState(flag, "local") + } + } + for _, flag := range command.InheritedFlags { + if flag.Name == name { + return flagMigrationState(flag, "inherited") + } + } + return FlagMigrationState{} +} + +func flagMigrationState(flag Flag, scope string) FlagMigrationState { + return FlagMigrationState{ + Present: true, + Type: flag.Type, + Required: flag.Required, + Hidden: flag.Hidden, + Shorthand: flag.Shorthand, + NoOpt: flag.NoOpt, + Scope: scope, + AliasOf: flag.AliasOf, + } +} + +func flagMigrationAuthorizesChange( + current Snapshot, + reference Snapshot, + change Change, + authorizations []FlagMigration, +) bool { + canonicalPath := acceptedPathIndex(reference)[change.Path] + if canonicalPath == "" { + canonicalPath = change.Path + } + for _, migration := range authorizations { + if canonicalPath != migration.Command || + matchFlagMigrationPhase(reference, migration) != flagMigrationBefore || + matchFlagMigrationPhase(current, migration) != flagMigrationAfter { + continue + } + if change.Flag == migration.Legacy.Name && change.Kind == "flag_became_hidden" { + return true + } + if change.Flag != migration.Canonical.Name { + continue + } + if !migration.Canonical.Before.Present && + migration.Canonical.After.Required && + change.Kind == "required_flag_added" { + return true + } + if migration.Canonical.Before.Present && + !migration.Canonical.Before.Required && + migration.Canonical.After.Required && + change.Kind == "flag_became_required" { + return true + } + } + return false +} + +func (m FlagMigration) displayKey() string { + return fmt.Sprintf("%q --%s -> --%s", m.Command, m.Legacy.Name, m.Canonical.Name) +} diff --git a/internal/interfacesnapshot/migrations_coverage_test.go b/internal/interfacesnapshot/migrations_coverage_test.go new file mode 100644 index 00000000..839b2f40 --- /dev/null +++ b/internal/interfacesnapshot/migrations_coverage_test.go @@ -0,0 +1,950 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package interfacesnapshot + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +func TestCrossPlatformCoverageFlagMigrationStrictJSONSchemaErrors(t *testing.T) { + tests := []struct { + name string + input string + wantErr string + }{ + { + name: "schema validator rejects a trailing value", + input: coverageManifestJSON() + ` {}`, + wantErr: "trailing JSON value", + }, + { + name: "schema validator reports malformed trailing data", + input: coverageManifestJSON() + ` ?`, + wantErr: "read trailing flag migration manifest data", + }, + { + name: "schema validator reports an empty document", + input: "", + wantErr: "read flag migration manifest value at $", + }, + { + name: "schema validator rejects an unknown field", + input: `{"version":1,"migrations":[],"unexpected":true}`, + wantErr: `unknown field "unexpected"`, + }, + { + name: "schema validator reports a malformed field token", + input: `{"`, + wantErr: "flag migration manifest", + }, + { + name: "schema validator reports an unclosed object", + input: `{"version":1`, + wantErr: "close flag migration manifest object", + }, + { + name: "schema validator reports an unclosed array", + input: `{"version":1,"migrations":[`, + wantErr: "close flag migration manifest array", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateFlagMigrationJSONSchema([]byte(test.input)) + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("validateFlagMigrationJSONSchema() error = %v, want %q", err, test.wantErr) + } + }) + } +} + +func TestCrossPlatformCoverageFlagMigrationStrictJSONTypeDiagnostics(t *testing.T) { + tests := []struct { + name string + input string + schema reflect.Type + wantErr string + }{ + { + name: "manifest must be an object", + input: `[]`, + schema: reflect.TypeOf(FlagMigrationManifest{}), + wantErr: "must be an object, got array", + }, + { + name: "migrations must be an array", + input: `{}`, + schema: reflect.TypeOf([]FlagMigration{}), + wantErr: "must be an array, got object", + }, + { + name: "integer rejects string", + input: `"one"`, + schema: reflect.TypeOf(int(0)), + wantErr: "must be a number, got string", + }, + { + name: "string rejects number", + input: `1`, + schema: reflect.TypeOf(""), + wantErr: "must be a string, got number", + }, + { + name: "string rejects boolean", + input: `false`, + schema: reflect.TypeOf(""), + wantErr: "must be a string, got boolean", + }, + { + name: "unsupported schema fails closed", + input: `1`, + schema: reflect.TypeOf(float64(0)), + wantErr: "unsupported Go schema type float64", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + decoder := json.NewDecoder(strings.NewReader(test.input)) + decoder.UseNumber() + err := validateMigrationJSONValue(decoder, "$", test.schema) + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("validateMigrationJSONValue() error = %v, want %q", err, test.wantErr) + } + }) + } +} + +func TestCrossPlatformCoverageFlagMigrationStrictJSONSchemaHelpers(t *testing.T) { + type fieldFixture struct { + DefaultName string + Ignored string `json:"-"` + Tagged string `json:"tagged,omitempty"` + } + fields := migrationJSONFields(reflect.TypeOf(fieldFixture{})) + if _, exists := fields["Ignored"]; exists { + t.Fatal("migrationJSONFields() retained a json:- field") + } + if fields["DefaultName"] != reflect.TypeOf("") || fields["tagged"] != reflect.TypeOf("") { + t.Fatalf("migrationJSONFields() = %#v, want default and tagged string fields", fields) + } + + if got := migrationJSONKindDescription(reflect.TypeOf(float64(0))); got != "the declared JSON type" { + t.Fatalf("migrationJSONKindDescription(float64) = %q", got) + } + for _, test := range []struct { + name string + token json.Token + want string + }{ + {name: "closing delimiter", token: json.Delim('}'), want: `delimiter "}"`}, + {name: "unsupported token", token: struct{}{}, want: "struct {}"}, + } { + t.Run(test.name, func(t *testing.T) { + if got := migrationJSONTokenDescription(test.token); got != test.want { + t.Fatalf("migrationJSONTokenDescription() = %q, want %q", got, test.want) + } + }) + } +} + +func TestCrossPlatformCoverageFlagMigrationManifestParserEdges(t *testing.T) { + tests := []struct { + name string + input string + wantErr string + }{ + { + name: "initial malformed JSON", + input: `{`, + wantErr: "unexpected EOF", + }, + { + name: "multiple JSON values", + input: coverageManifestJSON() + ` {}`, + wantErr: "trailing multiple JSON values", + }, + { + name: "malformed trailing JSON", + input: coverageManifestJSON() + ` {`, + wantErr: "read trailing flag migration manifest data", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := ReadFlagMigrationManifest(strings.NewReader(test.input)) + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("ReadFlagMigrationManifest() error = %v, want %q", err, test.wantErr) + } + }) + } +} + +func TestCrossPlatformCoverageFlagMigrationManifestRejectsEveryContractDrift(t *testing.T) { + optionalCanonical := func() FlagMigrationManifest { + manifest := coverageManifest(FlagMigrationPending) + manifest.Migrations[0].Canonical.Before = FlagMigrationState{ + Present: true, + Type: "string", + Scope: "local", + } + return manifest + } + + tests := []struct { + name string + make func() FlagMigrationManifest + mutate func(*FlagMigrationManifest) + wantErr string + }{ + { + name: "canonical flag name is exact", + make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) }, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Name = "--message-id" }, + wantErr: "canonical name must be an exact canonical flag", + }, + { + name: "legacy and canonical names differ", + make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) }, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Name = m.Migrations[0].Legacy.Name }, + wantErr: "legacy and canonical flags must differ", + }, + { + name: "legacy before state validates", + make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) }, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.Scope = "global" }, + wantErr: "legacy before present state has invalid scope", + }, + { + name: "legacy after state validates", + make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) }, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Type = "" }, + wantErr: "legacy after present state requires type", + }, + { + name: "canonical before state validates", + make: optionalCanonical, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Type = "" }, + wantErr: "canonical before present state requires type", + }, + { + name: "canonical after state validates", + make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) }, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.Scope = "" }, + wantErr: "canonical after present state has invalid scope", + }, + { + name: "legacy before remains present", + make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) }, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before = FlagMigrationState{} }, + wantErr: "legacy flag must remain present before and after migration", + }, + { + name: "legacy after remains present", + make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) }, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After = FlagMigrationState{} }, + wantErr: "legacy flag must remain present before and after migration", + }, + { + name: "legacy starts visible", + make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) }, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.Hidden = true }, + wantErr: "legacy flag must migrate exactly from visible to hidden", + }, + { + name: "legacy ends hidden", + make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) }, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Hidden = false }, + wantErr: "legacy flag must migrate exactly from visible to hidden", + }, + { + name: "canonical after remains present", + make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) }, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After = FlagMigrationState{} }, + wantErr: "canonical flag must be present after migration", + }, + { + name: "legacy after declares alias target", + make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) }, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.AliasOf = "other-id" }, + wantErr: `legacy flag after state must declare alias_of "message-id"`, + }, + { + name: "legacy before cannot declare alias", + make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) }, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.AliasOf = "message-id" }, + wantErr: "alias_of is only valid on the legacy after state", + }, + { + name: "canonical before cannot declare alias", + make: optionalCanonical, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.AliasOf = "message-id" }, + wantErr: "alias_of is only valid on the legacy after state", + }, + { + name: "canonical after cannot declare alias", + make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) }, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.AliasOf = "message-id" }, + wantErr: "alias_of is only valid on the legacy after state", + }, + { + name: "canonical type remains stable", + make: optionalCanonical, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Type = "stringSlice" }, + wantErr: "canonical flag type must remain unchanged", + }, + { + name: "canonical shorthand remains stable", + make: optionalCanonical, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Shorthand = "m" }, + wantErr: "canonical flag shorthand must remain unchanged", + }, + { + name: "canonical no-opt remains stable", + make: optionalCanonical, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.NoOpt = "auto" }, + wantErr: "canonical flag no_opt must remain unchanged", + }, + { + name: "canonical scope remains stable", + make: optionalCanonical, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Scope = "inherited" }, + wantErr: "canonical flag scope must remain unchanged", + }, + { + name: "legacy before type matches legacy after", + make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) }, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.Type = "stringSlice" }, + wantErr: "legacy and canonical flag types must match exactly", + }, + { + name: "legacy after type matches canonical after", + make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) }, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.Type = "stringSlice" }, + wantErr: "legacy and canonical flag types must match exactly", + }, + { + name: "legacy shorthand remains stable", + make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) }, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Shorthand = "x" }, + wantErr: "legacy flag shorthand must remain unchanged", + }, + { + name: "legacy no-opt remains stable", + make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) }, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.NoOpt = "" }, + wantErr: "legacy flag no_opt must remain unchanged", + }, + { + name: "legacy scope remains stable", + make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) }, + mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Scope = "inherited" }, + wantErr: "legacy flag scope must remain unchanged", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + manifest := test.make() + test.mutate(&manifest) + if err := manifest.Validate(); err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("Validate() error = %v, want %q", err, test.wantErr) + } + }) + } +} + +func TestCrossPlatformCoverageFlagMigrationManifestRejectsAbsentAttributesAndTargetConflicts(t *testing.T) { + stateMutations := []struct { + name string + mutate func(*FlagMigrationState) + }{ + {name: "type", mutate: func(s *FlagMigrationState) { s.Type = "string" }}, + {name: "required", mutate: func(s *FlagMigrationState) { s.Required = true }}, + {name: "hidden", mutate: func(s *FlagMigrationState) { s.Hidden = true }}, + {name: "shorthand", mutate: func(s *FlagMigrationState) { s.Shorthand = "m" }}, + {name: "no-opt", mutate: func(s *FlagMigrationState) { s.NoOpt = "auto" }}, + {name: "scope", mutate: func(s *FlagMigrationState) { s.Scope = "local" }}, + {name: "alias", mutate: func(s *FlagMigrationState) { s.AliasOf = "message-id" }}, + } + for _, test := range stateMutations { + t.Run("absent state rejects "+test.name, func(t *testing.T) { + manifest := coverageManifest(FlagMigrationPending) + test.mutate(&manifest.Migrations[0].Canonical.Before) + if err := manifest.Validate(); err == nil || !strings.Contains(err.Error(), "absent state must not declare flag attributes") { + t.Fatalf("Validate() error = %v, want absent-state rejection", err) + } + }) + } + + manifest := coverageManifest(FlagMigrationPending) + second := coverageManifest(FlagMigrationPending).Migrations[0] + second.Canonical.Name = "canonical-two" + second.Legacy.After.AliasOf = second.Canonical.Name + manifest.Migrations = append(manifest.Migrations, second) + if err := manifest.Validate(); err == nil || !strings.Contains(err.Error(), "to both --message-id and --canonical-two") { + t.Fatalf("Validate() error = %v, want one legacy to two canonical targets rejected", err) + } +} + +func TestCrossPlatformCoverageCompareAllWithFlagMigrationsValidatesEveryInput(t *testing.T) { + before := coverageMigrationSnapshot(coverageManifest(FlagMigrationPending).Migrations[0], false, false) + empty := coverageEmptyManifest() + + tests := []struct { + name string + current Snapshot + references map[string]Snapshot + authority FlagMigrationManifest + candidate FlagMigrationManifest + wantErr string + }{ + { + name: "current snapshot", + current: Snapshot{}, + references: map[string]Snapshot{"merge-base": before}, + authority: empty, + candidate: empty, + wantErr: "validate current interface snapshot", + }, + { + name: "reference snapshot", + current: before, + references: map[string]Snapshot{"merge-base": {}}, + authority: empty, + candidate: empty, + wantErr: "validate merge-base interface snapshot", + }, + { + name: "authority manifest", + current: before, + references: map[string]Snapshot{"merge-base": before}, + authority: FlagMigrationManifest{}, + candidate: empty, + wantErr: "validate approved flag migrations", + }, + { + name: "candidate manifest", + current: before, + references: map[string]Snapshot{"merge-base": before}, + authority: empty, + candidate: FlagMigrationManifest{}, + wantErr: "validate candidate flag migrations", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := CompareAllWithFlagMigrations(test.current, test.references, test.authority, test.candidate) + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("CompareAllWithFlagMigrations() error = %v, want %q", err, test.wantErr) + } + }) + } + + report, err := CompareAllWithFlagMigrations(before, map[string]Snapshot{"merge-base": before}, empty, empty) + if err != nil || !report.Compatible { + t.Fatalf("empty migration lifecycle = (%#v, %v), want compatible", report, err) + } +} + +func TestCrossPlatformCoverageCompareAllWithFlagMigrationsLifecycleErrors(t *testing.T) { + pending := coverageManifest(FlagMigrationPending) + consumed := coverageManifest(FlagMigrationConsumed) + empty := coverageEmptyManifest() + migration := pending.Migrations[0] + before := coverageMigrationSnapshot(migration, false, false) + after := coverageMigrationSnapshot(migration, true, false) + partial := coverageMigrationSnapshot(migration, false, false) + partial.Commands[len(partial.Commands)-1].LocalFlags[0].Hidden = true + + modified := coverageManifest(FlagMigrationPending) + modified.Migrations[0].Reason = "A different reviewed reason." + + tests := []struct { + name string + current Snapshot + references map[string]Snapshot + authority FlagMigrationManifest + candidate FlagMigrationManifest + wantErr string + }{ + { + name: "requires merge-base or main", + current: before, + references: map[string]Snapshot{"stable": before}, + authority: pending, + candidate: pending, + wantErr: "requires a main or merge-base reference", + }, + { + name: "pending authority requires before base", + current: after, + references: map[string]Snapshot{"merge-base": after}, + authority: pending, + candidate: pending, + wantErr: "want exact before state for pending", + }, + { + name: "consumed authority requires after base", + current: before, + references: map[string]Snapshot{"merge-base": before}, + authority: consumed, + candidate: consumed, + wantErr: "want exact after state for consumed", + }, + { + name: "missing base command is partial", + current: before, + references: map[string]Snapshot{"merge-base": coverageRootOnlySnapshot()}, + authority: pending, + candidate: pending, + wantErr: "is partial in merge-base", + }, + { + name: "candidate cannot modify base receipt", + current: before, + references: map[string]Snapshot{"merge-base": before}, + authority: pending, + candidate: modified, + wantErr: "candidate modified base-owned flag migration", + }, + { + name: "candidate cannot remove pending receipt", + current: before, + references: map[string]Snapshot{"merge-base": before}, + authority: pending, + candidate: empty, + wantErr: "candidate removed pending flag migration", + }, + { + name: "candidate cannot falsely consume pending receipt", + current: before, + references: map[string]Snapshot{"merge-base": before}, + authority: pending, + candidate: consumed, + wantErr: "candidate falsely consumed unchanged flag migration", + }, + { + name: "candidate must consume completed receipt", + current: after, + references: map[string]Snapshot{"merge-base": before}, + authority: pending, + candidate: pending, + wantErr: "without marking it consumed", + }, + { + name: "candidate rejects partial application", + current: partial, + references: map[string]Snapshot{"merge-base": before}, + authority: pending, + candidate: consumed, + wantErr: "candidate partially applied flag migration", + }, + { + name: "consumed receipt rejects current revert", + current: before, + references: map[string]Snapshot{"merge-base": after, "stable": before}, + authority: consumed, + candidate: consumed, + wantErr: "candidate drifted from consumed flag migration", + }, + { + name: "consumed receipt cannot be removed early", + current: after, + references: map[string]Snapshot{"merge-base": after, "stable": before}, + authority: consumed, + candidate: empty, + wantErr: "before every reference reached the after state", + }, + { + name: "consumed receipt cannot revert to pending", + current: after, + references: map[string]Snapshot{"merge-base": after, "stable": before}, + authority: consumed, + candidate: pending, + wantErr: "back to pending", + }, + { + name: "consumed receipt becomes stale", + current: after, + references: map[string]Snapshot{"merge-base": after, "stable": after}, + authority: consumed, + candidate: consumed, + wantErr: "is stale after all references reached the after state", + }, + { + name: "candidate-added receipt starts pending", + current: before, + references: map[string]Snapshot{"merge-base": before}, + authority: empty, + candidate: consumed, + wantErr: "must start pending", + }, + { + name: "candidate-added receipt matches base before", + current: before, + references: map[string]Snapshot{"merge-base": after}, + authority: empty, + candidate: pending, + wantErr: "does not match the merge-base before state", + }, + { + name: "candidate-added receipt cannot self-authorize", + current: after, + references: map[string]Snapshot{"merge-base": before}, + authority: empty, + candidate: pending, + wantErr: "cannot authorize its own interface change", + }, + { + name: "missing current command is partial", + current: coverageRootOnlySnapshot(), + references: map[string]Snapshot{"merge-base": before}, + authority: pending, + candidate: pending, + wantErr: "candidate partially applied flag migration", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := CompareAllWithFlagMigrations(test.current, test.references, test.authority, test.candidate) + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("CompareAllWithFlagMigrations() error = %v, want %q", err, test.wantErr) + } + }) + } +} + +func TestCrossPlatformCoverageCompareAllWithFlagMigrationsMainAuthorityAndCleanup(t *testing.T) { + pending := coverageManifest(FlagMigrationPending) + consumed := coverageManifest(FlagMigrationConsumed) + empty := coverageEmptyManifest() + migration := pending.Migrations[0] + before := coverageMigrationSnapshot(migration, false, false) + after := coverageMigrationSnapshot(migration, true, false) + + report, err := CompareAllWithFlagMigrations( + after, + map[string]Snapshot{"main": before}, + pending, + consumed, + ) + if err != nil || !report.Compatible { + t.Fatalf("main-owned migration = (%#v, %v), want compatible", report, err) + } + + report, err = CompareAllWithFlagMigrations( + after, + map[string]Snapshot{"merge-base": before, "main": after}, + pending, + consumed, + ) + if err != nil || !report.Compatible { + t.Fatalf("merge-base precedence = (%#v, %v), want compatible", report, err) + } + + report, err = CompareAllWithFlagMigrations( + after, + map[string]Snapshot{"merge-base": after, "stable": after}, + consumed, + empty, + ) + if err != nil || !report.Compatible { + t.Fatalf("consumed receipt cleanup = (%#v, %v), want compatible", report, err) + } + + report, err = CompareAllWithFlagMigrations( + before, + map[string]Snapshot{"merge-base": before}, + empty, + pending, + ) + if err != nil || !report.Compatible { + t.Fatalf("pending receipt creation = (%#v, %v), want compatible", report, err) + } +} + +func TestCrossPlatformCoverageCompareAllWithFlagMigrationsInheritedAndOptionalCanonical(t *testing.T) { + t.Run("inherited exact state", func(t *testing.T) { + pending := coverageManifest(FlagMigrationPending) + pending.Migrations[0].Legacy.Before.Scope = "inherited" + pending.Migrations[0].Legacy.After.Scope = "inherited" + pending.Migrations[0].Canonical.After.Scope = "inherited" + 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) + + report, err := CompareAllWithFlagMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + pending, + consumed, + ) + if err != nil || !report.Compatible { + t.Fatalf("inherited migration = (%#v, %v), want compatible", report, err) + } + }) + + t.Run("existing optional canonical becomes required", func(t *testing.T) { + pending := coverageManifest(FlagMigrationPending) + pending.Migrations[0].Canonical.Before = FlagMigrationState{ + Present: true, + Type: "string", + 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 create flag_became_required: %#v", ordinary.Blocking) + } + report, err := CompareAllWithFlagMigrations( + after, + map[string]Snapshot{"merge-base": before}, + pending, + consumed, + ) + if err != nil || !report.Compatible { + t.Fatalf("optional-to-required migration = (%#v, %v), want compatible", report, err) + } + }) +} + +func TestCrossPlatformCoverageCompareAllWithFlagMigrationsNormalizesAliasesAndKeepsOtherBreakage(t *testing.T) { + pending := coverageManifest(FlagMigrationPending) + consumed := coverageManifest(FlagMigrationConsumed) + migration := pending.Migrations[0] + before := coverageMigrationSnapshot(migration, false, true) + after := coverageMigrationSnapshot(migration, true, true) + + ordinary := Compare(after, before, "merge-base") + if !hasFlagChange(ordinary.Blocking, "flag_became_hidden", "dws chat deliver", migration.Legacy.Name) { + t.Fatalf("alias fixture did not create alias-path finding: %#v", ordinary.Blocking) + } + report, err := CompareAllWithFlagMigrations( + after, + map[string]Snapshot{"merge-base": before}, + pending, + consumed, + ) + if err != nil || !report.Compatible { + t.Fatalf("alias-path migration = (%#v, %v), want compatible", report, err) + } + + beforeWithOther := before + afterWithOther := after + beforeWithOther.Commands = append(beforeWithOther.Commands, coverageCommand("dws files", nil, []Flag{{Name: "format", Type: "string"}}, nil)) + afterWithOther.Commands = append(afterWithOther.Commands, coverageCommand("dws files", nil, nil, nil)) + report, err = CompareAllWithFlagMigrations( + afterWithOther, + map[string]Snapshot{"merge-base": beforeWithOther}, + pending, + consumed, + ) + if err != nil { + t.Fatalf("unrelated breakage returned lifecycle error: %v", err) + } + if report.Compatible || !hasFlagChange(report.Comparisons[0].Blocking, "flag_removed", "dws files", "format") { + t.Fatalf("unrelated breakage was hidden: %#v", report) + } +} + +func TestCrossPlatformCoverageCompareAllWithFlagMigrationsDoesNotAuthorizePartialReferenceOrRuleDrift(t *testing.T) { + pending := coverageManifest(FlagMigrationPending) + consumed := coverageManifest(FlagMigrationConsumed) + migration := pending.Migrations[0] + before := coverageMigrationSnapshot(migration, false, false) + after := coverageMigrationSnapshot(migration, true, false) + + partialReference := coverageMigrationSnapshot(migration, false, false) + partialReference.Commands[len(partialReference.Commands)-1].LocalFlags[0].Hidden = true + report, err := CompareAllWithFlagMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": partialReference}, + pending, + consumed, + ) + if err != nil { + t.Fatalf("partial non-authority reference returned lifecycle error: %v", err) + } + if report.Compatible { + t.Fatalf("partial reference was incorrectly authorized: %#v", report) + } + + beforeWithoutRoot := coverageMigrationSnapshot(migration, false, false) + afterWithoutRoot := coverageMigrationSnapshot(migration, true, false) + beforeWithoutRoot.Commands = beforeWithoutRoot.Commands[len(beforeWithoutRoot.Commands)-1:] + afterWithoutRoot.Commands = afterWithoutRoot.Commands[len(afterWithoutRoot.Commands)-1:] + afterWithoutRoot.Rules.ExcludedFlags = []string{"help", "version"} + report, err = CompareAllWithFlagMigrations( + afterWithoutRoot, + map[string]Snapshot{"merge-base": beforeWithoutRoot}, + pending, + consumed, + ) + if err != nil { + t.Fatalf("rule drift returned lifecycle error: %v", err) + } + if report.Compatible || !hasChangeKind(report.Comparisons[0].Blocking, "snapshot_rules_changed") { + t.Fatalf("snapshot rule drift was incorrectly authorized: %#v", report) + } +} + +func coverageManifestJSON() string { + return `{ + "version": 1, + "migrations": [ + { + "command": "dws chat send", + "legacy": { + "name": "legacy-id", + "before": {"present": true, "type": "string", "required": true, "shorthand": "l", "no_opt": "auto", "scope": "local"}, + "after": {"present": true, "type": "string", "hidden": true, "shorthand": "l", "no_opt": "auto", "scope": "local", "alias_of": "message-id"} + }, + "canonical": { + "name": "message-id", + "before": {"present": false}, + "after": {"present": true, "type": "string", "required": true, "scope": "local"} + }, + "state": "pending", + "reason": "Reviewed exact flag migration." + } + ] +}` +} + +func coverageManifest(state string) FlagMigrationManifest { + return FlagMigrationManifest{ + Version: FlagMigrationManifestVersion, + Migrations: []FlagMigration{ + { + Command: "dws chat send", + Legacy: FlagMigrationSide{ + Name: "legacy-id", + Before: FlagMigrationState{ + Present: true, + Type: "string", + Required: true, + Shorthand: "l", + NoOpt: "auto", + Scope: "local", + }, + After: FlagMigrationState{ + Present: true, + Type: "string", + Hidden: true, + Shorthand: "l", + NoOpt: "auto", + Scope: "local", + AliasOf: "message-id", + }, + }, + Canonical: FlagMigrationSide{ + Name: "message-id", + Before: FlagMigrationState{}, + After: FlagMigrationState{ + Present: true, + Type: "string", + Required: true, + Scope: "local", + }, + }, + State: state, + Reason: "Reviewed exact flag migration.", + }, + }, + } +} + +func coverageEmptyManifest() FlagMigrationManifest { + return FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}} +} + +func coverageMigrationSnapshot(migration FlagMigration, after, commandAlias bool) Snapshot { + legacyState := migration.Legacy.Before + canonicalState := migration.Canonical.Before + if after { + legacyState = migration.Legacy.After + canonicalState = migration.Canonical.After + } + local := []Flag{} + inherited := []Flag{} + add := func(name string, state FlagMigrationState) { + if !state.Present { + return + } + flag := Flag{ + Name: name, + Shorthand: state.Shorthand, + Type: state.Type, + NoOpt: state.NoOpt, + Required: state.Required, + Hidden: state.Hidden, + AliasOf: state.AliasOf, + } + if state.Scope == "inherited" { + inherited = append(inherited, flag) + return + } + local = append(local, flag) + } + add(migration.Legacy.Name, legacyState) + add(migration.Canonical.Name, canonicalState) + aliases := []string{} + if commandAlias { + aliases = []string{"deliver"} + } + return coverageSnapshot( + coverageCommand("dws", nil, nil, nil), + coverageCommand("dws chat", nil, nil, nil), + coverageCommand(migration.Command, aliases, local, inherited), + ) +} + +func coverageRootOnlySnapshot() Snapshot { + return coverageSnapshot(coverageCommand("dws", nil, nil, nil)) +} + +func coverageSnapshot(commands ...Command) Snapshot { + return Snapshot{ + SchemaVersion: SchemaVersion, + Rules: Rules{ + ExcludedCommandSubtrees: append([]string(nil), excludedCommandSubtrees...), + ExcludedFlags: []string{"help"}, + }, + Commands: commands, + } +} + +func coverageCommand(path string, aliases []string, local, inherited []Flag) Command { + if aliases == nil { + aliases = []string{} + } + if local == nil { + local = []Flag{} + } + if inherited == nil { + inherited = []Flag{} + } + return Command{ + Path: path, + Runnable: true, + Aliases: aliases, + LocalFlags: local, + InheritedFlags: inherited, + } +} diff --git a/internal/interfacesnapshot/migrations_test.go b/internal/interfacesnapshot/migrations_test.go new file mode 100644 index 00000000..01fc03a9 --- /dev/null +++ b/internal/interfacesnapshot/migrations_test.go @@ -0,0 +1,252 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package interfacesnapshot + +import ( + "errors" + "reflect" + "strings" + "testing" +) + +type flagMigrationErrorReader struct{} + +func (flagMigrationErrorReader) Read([]byte) (int, error) { + return 0, errors.New("injected read failure") +} + +const validFlagMigrationManifestJSON = `{ + "version": 1, + "migrations": [ + { + "command": "dws chat message recall", + "legacy": { + "name": "msg-id", + "before": {"present": true, "type": "string", "required": true, "scope": "local"}, + "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "message-id"} + }, + "canonical": { + "name": "message-id", + "before": {"present": false}, + "after": {"present": true, "type": "string", "required": true, "scope": "local"} + }, + "state": "pending", + "reason": "Reviewed message identifier flag migration." + } + ] +}` + +func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsUnknownFields(t *testing.T) { + _, err := ReadFlagMigrationManifest(strings.NewReader(`{ + "version": 1, + "migrations": [], + "allow_all_chat_flags": true +}`)) + if err == nil || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("ReadFlagMigrationManifest() error = %v, want unknown field rejection", err) + } +} + +func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsDuplicateFields(t *testing.T) { + for _, input := range []string{ + `{"version": 999, "version": 1, "migrations": []}`, + strings.Replace(validFlagMigrationManifestJSON, `"state": "pending"`, `"state": "consumed", "state": "pending"`, 1), + } { + _, err := ReadFlagMigrationManifest(strings.NewReader(input)) + if err == nil || !strings.Contains(err.Error(), "duplicate field") { + t.Fatalf("ReadFlagMigrationManifest() error = %v, want duplicate field rejection", err) + } + } +} + +func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsNonCanonicalFields(t *testing.T) { + for _, input := range []string{ + `{"Version": 1, "migrations": []}`, + `{"version": 1, "Version": 1, "migrations": []}`, + strings.Replace(validFlagMigrationManifestJSON, `"state": "pending"`, `"State": "pending"`, 1), + } { + _, err := ReadFlagMigrationManifest(strings.NewReader(input)) + if err == nil || !strings.Contains(err.Error(), "non-canonical field") { + t.Fatalf("ReadFlagMigrationManifest() error = %v, want non-canonical field rejection", err) + } + } +} + +func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsNullScalars(t *testing.T) { + for _, input := range []string{ + strings.Replace(validFlagMigrationManifestJSON, `"required": true`, `"required": null`, 1), + strings.Replace(validFlagMigrationManifestJSON, `"scope": "local"`, `"scope": "local", "shorthand": null`, 1), + `{"version": null, "migrations": []}`, + } { + _, err := ReadFlagMigrationManifest(strings.NewReader(input)) + if err == nil || !strings.Contains(err.Error(), "must be") || !strings.Contains(err.Error(), "null") { + t.Fatalf("ReadFlagMigrationManifest() error = %v, want scalar null rejection", err) + } + } +} + +func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsWrongJSONTypes(t *testing.T) { + for _, input := range []string{ + strings.Replace(validFlagMigrationManifestJSON, `"required": true`, `"required": "true"`, 1), + strings.Replace(validFlagMigrationManifestJSON, `"scope": "local"`, `"scope": false`, 1), + `{"version": "1", "migrations": []}`, + `{"version": 1, "migrations": {}}`, + } { + if _, err := ReadFlagMigrationManifest(strings.NewReader(input)); err == nil { + t.Fatal("ReadFlagMigrationManifest() accepted a field with the wrong JSON type") + } + } +} + +func TestCrossPlatformCoverageReadFlagMigrationManifestReportsReaderFailure(t *testing.T) { + _, err := ReadFlagMigrationManifest(flagMigrationErrorReader{}) + if err == nil || !strings.Contains(err.Error(), "injected read failure") { + t.Fatalf("ReadFlagMigrationManifest() error = %v, want reader failure", err) + } +} + +func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *testing.T) { + if _, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON)); err != nil { + t.Fatalf("ReadFlagMigrationManifest(valid) error = %v", err) + } + + tests := []struct { + name string + input string + wantErr string + }{ + { + name: "unsupported version", + input: strings.Replace(validFlagMigrationManifestJSON, `"version": 1`, `"version": 2`, 1), + wantErr: "unsupported flag migration manifest version", + }, + { + name: "missing migrations array", + input: `{"version": 1}`, + wantErr: "migrations must be an array", + }, + { + name: "null migrations array", + input: `{"version": 1, "migrations": null}`, + wantErr: "migrations must be an array", + }, + { + name: "wildcard command", + input: strings.Replace(validFlagMigrationManifestJSON, "dws chat message recall", "dws chat *", 1), + wantErr: "exact command path", + }, + { + name: "wildcard flag", + input: strings.Replace(validFlagMigrationManifestJSON, `"name": "msg-id"`, `"name": "msg-*"`, 1), + wantErr: "exact legacy flag", + }, + { + name: "empty reason", + input: strings.Replace(validFlagMigrationManifestJSON, "Reviewed message identifier flag migration.", "", 1), + wantErr: "non-empty reason", + }, + { + name: "invalid state", + input: strings.Replace(validFlagMigrationManifestJSON, `"state": "pending"`, `"state": "approved"`, 1), + wantErr: "invalid state", + }, + { + name: "legacy removed", + input: strings.Replace(validFlagMigrationManifestJSON, `"after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "message-id"}`, `"after": {"present": false}`, 1), + wantErr: "legacy flag must remain present", + }, + { + name: "canonical hidden", + input: strings.Replace(validFlagMigrationManifestJSON, `"after": {"present": true, "type": "string", "required": true, "scope": "local"}`, `"after": {"present": true, "type": "string", "required": true, "hidden": true, "scope": "local"}`, 1), + wantErr: "canonical flag must remain visible", + }, + { + name: "canonical remains optional", + input: strings.Replace( + validFlagMigrationManifestJSON, + `"after": {"present": true, "type": "string", "required": true, "scope": "local"}`, + `"after": {"present": true, "type": "string", "scope": "local"}`, + 1, + ), + wantErr: "canonical flag must be required after migration", + }, + { + name: "canonical was already required", + input: strings.Replace( + validFlagMigrationManifestJSON, + `"before": {"present": false}`, + `"before": {"present": true, "type": "string", "required": true, "scope": "local"}`, + 1, + ), + wantErr: "canonical flag must be absent or optional before migration", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := ReadFlagMigrationManifest(strings.NewReader(test.input)) + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("ReadFlagMigrationManifest() error = %v, want %q", err, test.wantErr) + } + }) + } +} + +func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactContracts(t *testing.T) { + manifest, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON)) + if err != nil { + t.Fatal(err) + } + + duplicate := manifest + duplicate.Migrations = append(duplicate.Migrations, duplicate.Migrations[0]) + if err := duplicate.Validate(); err == nil || !strings.Contains(err.Error(), "duplicates") { + t.Fatalf("duplicate migration error = %v", err) + } + + whitespaceReason := manifest + whitespaceReason.Migrations = append([]FlagMigration(nil), manifest.Migrations...) + whitespaceReason.Migrations[0].Reason = " reviewed " + if err := whitespaceReason.Validate(); err == nil || !strings.Contains(err.Error(), "trimmed") { + t.Fatalf("whitespace reason error = %v", err) + } + + canonicalDrift := manifest + canonicalDrift.Migrations = append([]FlagMigration(nil), manifest.Migrations...) + canonicalDrift.Migrations[0].Canonical.Before = FlagMigrationState{ + Present: true, + Type: "string", + Scope: "local", + } + canonicalDrift.Migrations[0].Canonical.After.Type = "stringSlice" + if err := canonicalDrift.Validate(); err == nil || !strings.Contains(err.Error(), "canonical flag type") { + t.Fatalf("canonical type drift error = %v", err) + } + + _, err = ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON + ` {}`)) + if err == nil || !strings.Contains(err.Error(), "trailing") { + t.Fatalf("trailing JSON error = %v", err) + } +} + +func TestCrossPlatformCoverageAuthorizeFlagMigrationsReturnsExactBaseOwnedApproval(t *testing.T) { + pending := coverageManifest(FlagMigrationPending) + consumed := coverageManifest(FlagMigrationConsumed) + migration := pending.Migrations[0] + before := coverageMigrationSnapshot(migration, false, false) + after := coverageMigrationSnapshot(migration, true, false) + + got, err := AuthorizeFlagMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + pending, + consumed, + ) + if err != nil { + t.Fatalf("AuthorizeFlagMigrations() error = %v", err) + } + if !reflect.DeepEqual(got, pending.Migrations) { + t.Fatalf("AuthorizeFlagMigrations() = %#v, want exact base-owned approval %#v", got, pending.Migrations) + } +} diff --git a/internal/interfacesnapshot/snapshot.go b/internal/interfacesnapshot/snapshot.go index 58b26c54..408805d3 100644 --- a/internal/interfacesnapshot/snapshot.go +++ b/internal/interfacesnapshot/snapshot.go @@ -22,11 +22,16 @@ import ( "sort" "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate" "github.com/spf13/cobra" "github.com/spf13/pflag" ) -const SchemaVersion = 1 +const SchemaVersion = 2 + +// FlagAliasOfAnnotation records a framework-originated reviewed relationship +// between a retained compatibility flag and an exact canonical Cobra flag. +const FlagAliasOfAnnotation = runtimeannotate.AnnotationFlagAliasOf var ( // Framework-owned command subtrees are deliberately excluded. They are @@ -80,6 +85,12 @@ type Flag struct { Required bool `json:"required"` Hidden bool `json:"hidden"` Deprecated string `json:"deprecated,omitempty"` + AliasOf string `json:"alias_of,omitempty"` + + // Capture cannot return an error, so it preserves malformed or non-framework + // relation evidence here for Validate/Write to reject. Valid snapshots never + // serialize this implementation detail. + malformedAliasEvidence bool } // Capture walks root without rendering help or executing any command. Both @@ -193,6 +204,9 @@ func (s Snapshot) Validate() error { if err := validateFlags(command.Path, "inherited", command.InheritedFlags); err != nil { return err } + if err := validateFlagAliases(command); err != nil { + return err + } } return nil } @@ -206,21 +220,46 @@ func captureFlags(set *pflag.FlagSet) []Flag { if flag == nil || excludedFlags[flag.Name] { return } + aliasOf, malformedAliasEvidence := captureFlagAliasOf(flag) flags = append(flags, Flag{ - Name: flag.Name, - Shorthand: flag.Shorthand, - Type: flag.Value.Type(), - Default: flag.DefValue, - NoOpt: flag.NoOptDefVal, - Required: isRequired(flag), - Hidden: flag.Hidden, - Deprecated: strings.TrimSpace(flag.Deprecated), + Name: flag.Name, + Shorthand: flag.Shorthand, + Type: flag.Value.Type(), + Default: flag.DefValue, + NoOpt: flag.NoOptDefVal, + Required: isRequired(flag), + Hidden: flag.Hidden, + Deprecated: strings.TrimSpace(flag.Deprecated), + AliasOf: aliasOf, + malformedAliasEvidence: malformedAliasEvidence, }) }) sort.Slice(flags, func(i, j int) bool { return flags[i].Name < flags[j].Name }) return flags } +func captureFlagAliasOf(flag *pflag.Flag) (string, bool) { + aliasOf, aliasMalformed := exactFlagAnnotation(flag, FlagAliasOfAnnotation) + origin, originMalformed := exactFlagAnnotation(flag, runtimeannotate.AnnotationFlagAliasOrigin) + if aliasOf == "" && origin == "" && !aliasMalformed && !originMalformed { + return "", false + } + malformed := aliasMalformed || originMalformed || aliasOf == "" || + origin != runtimeannotate.FlagAliasOriginCorecmdV1 + return aliasOf, malformed +} + +func exactFlagAnnotation(flag *pflag.Flag, key string) (string, bool) { + values, exists := flag.Annotations[key] + if !exists { + return "", false + } + if len(values) != 1 || values[0] == "" || values[0] != strings.TrimSpace(values[0]) { + return "", true + } + return values[0], false +} + func isRequired(flag *pflag.Flag) bool { for _, value := range flag.Annotations[cobra.BashCompOneRequiredFlag] { if value == "true" { @@ -268,3 +307,73 @@ func validateFlags(path, scope string, flags []Flag) error { } return nil } + +func validateFlagAliases(command Command) error { + flags := make(map[string]Flag, len(command.LocalFlags)+len(command.InheritedFlags)) + ordered := make([]Flag, 0, len(command.LocalFlags)+len(command.InheritedFlags)) + for _, flag := range command.LocalFlags { + flags[flag.Name] = flag + ordered = append(ordered, flag) + } + for _, flag := range command.InheritedFlags { + if _, exists := flags[flag.Name]; exists { + return fmt.Errorf("command %q contains flag %q in both local and inherited scopes", command.Path, flag.Name) + } + flags[flag.Name] = flag + ordered = append(ordered, flag) + } + + for _, flag := range ordered { + if flag.malformedAliasEvidence { + return fmt.Errorf( + "command %q flag --%s must declare one exact %q value and framework origin %q", + command.Path, + flag.Name, + FlagAliasOfAnnotation, + runtimeannotate.FlagAliasOriginCorecmdV1, + ) + } + if flag.AliasOf == "" { + continue + } + if !isExactFlagName(flag.AliasOf) { + return fmt.Errorf( + "command %q flag --%s has non-exact alias_of target %q", + command.Path, + flag.Name, + flag.AliasOf, + ) + } + if flag.AliasOf == flag.Name { + return fmt.Errorf("command %q flag --%s cannot alias itself", command.Path, flag.Name) + } + target, exists := flags[flag.AliasOf] + if !exists { + return fmt.Errorf( + "command %q flag --%s aliases missing flag --%s", + command.Path, + flag.Name, + flag.AliasOf, + ) + } + if target.AliasOf != "" { + return fmt.Errorf( + "command %q flag --%s aliases --%s, which is itself an alias", + command.Path, + flag.Name, + flag.AliasOf, + ) + } + if flag.Type != target.Type { + return fmt.Errorf( + "command %q alias flags --%s and --%s have different types %q and %q", + command.Path, + flag.Name, + flag.AliasOf, + flag.Type, + target.Type, + ) + } + } + return nil +} diff --git a/internal/interfacesnapshot/snapshot_test.go b/internal/interfacesnapshot/snapshot_test.go index 1d08dccc..eb27274e 100644 --- a/internal/interfacesnapshot/snapshot_test.go +++ b/internal/interfacesnapshot/snapshot_test.go @@ -15,9 +15,12 @@ package interfacesnapshot import ( "bytes" + "encoding/json" "reflect" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate" "github.com/spf13/cobra" ) @@ -95,7 +98,7 @@ func TestCrossPlatformCoverageCaptureUsesStableNoiseRulesAndFlagScopes(t *testin func TestCrossPlatformCoverageReadRejectsUnknownSnapshotFields(t *testing.T) { input := bytes.NewBufferString(`{ - "schema_version": 1, + "schema_version": 2, "rules": {"excluded_command_subtrees": [], "excluded_flags": []}, "commands": [], "future_field": true @@ -105,6 +108,166 @@ func TestCrossPlatformCoverageReadRejectsUnknownSnapshotFields(t *testing.T) { } } +func TestCrossPlatformCoverageCaptureRoundTripsFrameworkFlagAlias(t *testing.T) { + if FlagAliasOfAnnotation != runtimeannotate.AnnotationFlagAliasOf { + t.Fatalf( + "flag alias annotation key drifted: snapshot=%q runtime=%q", + FlagAliasOfAnnotation, + runtimeannotate.AnnotationFlagAliasOf, + ) + } + root := &cobra.Command{Use: "dws"} + leaf := &cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}} + corecmd.RegisterFlags(leaf, []corecmd.FlagSpec{{ + Name: "message-id", + Usage: "message ID", + Aliases: []string{"open-conversation-id"}, + }}) + root.AddCommand(leaf) + + snapshot := Capture(root) + flag := flagIndex(commandIndex(snapshot)["dws send"].LocalFlags)["open-conversation-id"] + if flag.AliasOf != "message-id" { + t.Fatalf("captured alias_of = %q, want message-id", flag.AliasOf) + } + + var encoded bytes.Buffer + if err := Write(&encoded, snapshot); err != nil { + t.Fatalf("Write: %v", err) + } + decoded, err := Read(bytes.NewReader(encoded.Bytes())) + if err != nil { + t.Fatalf("Read: %v", err) + } + decodedFlag := flagIndex(commandIndex(decoded)["dws send"].LocalFlags)["open-conversation-id"] + if decodedFlag.AliasOf != "message-id" { + t.Fatalf("round-tripped alias_of = %q, want message-id", decodedFlag.AliasOf) + } +} + +func TestCrossPlatformCoverageCaptureRejectsUntrustedFlagAliasEvidence(t *testing.T) { + for _, origin := range []string{"", "handwritten.alias.v1"} { + t.Run(origin, func(t *testing.T) { + root := &cobra.Command{Use: "dws"} + leaf := &cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}} + leaf.Flags().String("message-id", "", "canonical message ID") + leaf.Flags().String("legacy-id", "", "legacy message ID") + legacy := leaf.Flags().Lookup("legacy-id") + legacy.Annotations = map[string][]string{ + FlagAliasOfAnnotation: {"message-id"}, + } + if origin != "" { + legacy.Annotations[runtimeannotate.AnnotationFlagAliasOrigin] = []string{origin} + } + root.AddCommand(leaf) + + if err := Capture(root).Validate(); err == nil { + t.Fatal("Validate accepted alias evidence outside corecmd FlagSpec.Aliases") + } + }) + } +} + +func TestCrossPlatformCoverageCaptureRejectsMultipleNonEmptyFlagAliases(t *testing.T) { + root := &cobra.Command{Use: "dws"} + leaf := &cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}} + leaf.Flags().String("message-id", "", "canonical message ID") + leaf.Flags().String("other-id", "", "other ID") + leaf.Flags().String("legacy-id", "", "legacy ID") + legacy := leaf.Flags().Lookup("legacy-id") + legacy.Annotations = map[string][]string{ + FlagAliasOfAnnotation: {"message-id", "other-id"}, + } + root.AddCommand(leaf) + + snapshot := Capture(root) + if err := snapshot.Validate(); err == nil { + t.Fatal("Validate accepted multiple non-empty alias annotation values") + } + var encoded bytes.Buffer + if err := Write(&encoded, snapshot); err == nil { + t.Fatal("Write accepted multiple non-empty alias annotation values") + } +} + +func TestCrossPlatformCoverageValidateRejectsMalformedFlagAliasState(t *testing.T) { + tests := []struct { + name string + aliasOf string + }{ + {name: "self", aliasOf: "legacy-id"}, + {name: "leading dashes", aliasOf: "--message-id"}, + {name: "non exact", aliasOf: "message id"}, + {name: "missing target", aliasOf: "missing-id"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + snapshot := testSnapshot( + testCommand("dws"), + testCommandWithFlagScopes( + "dws send", + []Flag{ + {Name: "legacy-id", Type: "string", AliasOf: test.aliasOf}, + {Name: "message-id", Type: "string"}, + }, + nil, + ), + ) + if err := snapshot.Validate(); err == nil { + t.Fatalf("Validate accepted alias_of %q", test.aliasOf) + } + encoded, err := json.Marshal(snapshot) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + if _, err := Read(bytes.NewReader(encoded)); err == nil { + t.Fatalf("Read accepted alias_of %q", test.aliasOf) + } + }) + } +} + +func TestCrossPlatformCoverageValidateRejectsFlagAliasChainTypeAndScopeConflicts(t *testing.T) { + tests := []struct { + name string + local []Flag + inherited []Flag + }{ + { + name: "alias chain", + local: []Flag{ + {Name: "legacy-id", Type: "string", AliasOf: "middle-id"}, + {Name: "middle-id", Type: "string", AliasOf: "message-id"}, + {Name: "message-id", Type: "string"}, + }, + }, + { + name: "different types", + local: []Flag{ + {Name: "legacy-id", Type: "string", AliasOf: "message-id"}, + {Name: "message-id", Type: "int"}, + }, + }, + { + name: "same flag in local and inherited scopes", + local: []Flag{{Name: "legacy-id", Type: "string"}}, + inherited: []Flag{{Name: "legacy-id", Type: "string"}}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + snapshot := testSnapshot( + testCommand("dws"), + testCommandWithFlagScopes("dws send", test.local, test.inherited), + ) + if err := snapshot.Validate(); err == nil { + t.Fatalf("Validate accepted invalid alias contract: %#v", snapshot.Commands[1]) + } + }) + } +} + func TestCrossPlatformCoverageCompareBlocksCandidateSiblingAliasCollision(t *testing.T) { base := testSnapshot(testCommand("dws")) current := testSnapshot( diff --git a/scripts/README.md b/scripts/README.md index 8ff44a17..ea19ceac 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -19,7 +19,7 @@ Script groups: - Product convenience installers: `./scripts/install-devapp.sh`, `./scripts/install-devapp.ps1`, `./scripts/install-event.sh` - CI package plan: `./scripts/ci/test-packages.sh` - Dev helpers: `./scripts/dev/build.sh`, `./scripts/dev/lint.sh`, `./scripts/dev/ci-local.sh`, `./scripts/dev/run-mock-e2e.sh`, `./scripts/dev/coverage.sh` -- Policy checks: `./scripts/policy/check-generated-drift.sh`, `./scripts/policy/check-command-surface.sh`, `./scripts/policy/check-command-compatibility.sh --base-ref --stable-ref `, `./scripts/policy/check-schema-catalog.sh`, `./scripts/policy/check-schema-binary.sh`, `./scripts/policy/check-open-source-assets.sh` +- Policy checks: `./scripts/policy/check-generated-drift.sh`, `./scripts/policy/check-command-surface.sh`, `./scripts/policy/check-command-compatibility.sh --base-ref --stable-ref --candidate-ref `, `./scripts/policy/check-schema-catalog.sh`, `./scripts/policy/check-schema-binary.sh`, `./scripts/policy/check-open-source-assets.sh` - Policy runtime files: set `DWS_POLICY_TMPDIR` to override the default `.worktrees/policy-tmp` workspace - Release entrypoint and contract: `./scripts/release/release.sh`, `./scripts/release/release-contract.sh`; operator guide: [`docs/releasing.md`](../docs/releasing.md) - Release packaging helpers: `./scripts/release/post-goreleaser.sh`, `./scripts/release/stage-npm-package.sh`, `./scripts/release/pack-npm-package.sh`, `./scripts/release/verify-delivered-stable.sh`, `./scripts/release/verify-release-artifacts.sh`, `./scripts/release/verify-github-release-assets.sh`, `./scripts/release/verify-github-tag-authority.sh`, `./scripts/release/verify-package-managers.sh`, `./scripts/release/publish-homebrew-formula.sh` diff --git a/scripts/policy/check-authoritative-interface-baselines.sh b/scripts/policy/check-authoritative-interface-baselines.sh index 9db4f9cb..c3f92898 100755 --- a/scripts/policy/check-authoritative-interface-baselines.sh +++ b/scripts/policy/check-authoritative-interface-baselines.sh @@ -1,15 +1,16 @@ #!/bin/sh set -eu -# Compare the candidate command tree against a Git-owned historical source -# revision. CI invokes this for both the PR merge-base and latest stable tag; a -# fixture from the candidate branch is never used as authority. +# Compatibility decisions are centralized in check-command-compatibility.sh. +# This historical entry point remains as the Makefile/user-facing wrapper. ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" BASE_REF="" +STABLE_REF="" +CANDIDATE_REF="HEAD" usage() { - printf '%s\n' "usage: $0 --base-ref " >&2 + printf '%s\n' "usage: $0 --base-ref [--stable-ref ] [--candidate-ref ]" >&2 } while [ "$#" -gt 0 ]; do @@ -19,6 +20,16 @@ while [ "$#" -gt 0 ]; do BASE_REF="$2" shift 2 ;; + --stable-ref) + [ "$#" -ge 2 ] || { usage; exit 2; } + STABLE_REF="$2" + shift 2 + ;; + --candidate-ref) + [ "$#" -ge 2 ] || { usage; exit 2; } + CANDIDATE_REF="$2" + shift 2 + ;; -h|--help) usage exit 0 @@ -32,63 +43,30 @@ while [ "$#" -gt 0 ]; do done [ -n "$BASE_REF" ] || { usage; exit 2; } - cd "$ROOT" -. "$ROOT/scripts/policy/policy-runtime.sh" -policy_prepare_runtime "$ROOT" +. "$ROOT/scripts/release/release-lib.sh" git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || { printf 'error: interface authority is not available locally: %s\n' "$BASE_REF" >&2 exit 2 } -TMP_ROOT="$(policy_runtime_mktemp_dir dws-interface-authority)" -BASE_WORKTREE="$TMP_ROOT/base-worktree" -BASELINE="$TMP_ROOT/merge-base.txt" -CANDIDATE_BIN="$TMP_ROOT/interface-baseline" -CANDIDATE_HOME="$TMP_ROOT/candidate-home" +if [ -z "$STABLE_REF" ]; then + for tag in $(git tag --merged "$BASE_REF" --list 'v*' --sort=-version:refname); do + release_is_stable_version "$tag" || continue + if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then + continue + fi + STABLE_REF="$tag" + break + done +fi +if [ -z "$STABLE_REF" ]; then + printf 'error: no stable release tag is reachable from interface authority %s\n' "$BASE_REF" >&2 + exit 2 +fi -cleanup() { - git -C "$ROOT" worktree remove --force "$BASE_WORKTREE" >/dev/null 2>&1 || true - rm -rf "$TMP_ROOT" -} -trap cleanup EXIT -trap 'exit 129' HUP -trap 'exit 130' INT -trap 'exit 143' TERM - -install_snapshot_helper() { - worktree="$1" - helper="$worktree/scripts/policy/interface-baseline" - - # Use one contract format on both sides while constructing the command tree - # from the authoritative historical source revision. - rm -rf "$helper" - mkdir -p "$helper" - cp -R "$ROOT/scripts/policy/interface-baseline/." "$helper/" -} - -generate_reference() { - ref="$1" - worktree="$2" - output="$3" - home="$4" - bin="$TMP_ROOT/reference-$(basename "$worktree")" - - git -C "$ROOT" worktree add --detach "$worktree" "$ref" >/dev/null - install_snapshot_helper "$worktree" - mkdir -p "$home" - ( - cd "$worktree" - go build -o "$bin" ./scripts/policy/interface-baseline - HOME="$home" DWS_LANG=zh "$bin" >"$output" - ) -} - -generate_reference "$BASE_REF" "$BASE_WORKTREE" "$BASELINE" "$TMP_ROOT/base-home" - -mkdir -p "$CANDIDATE_HOME" -go build -o "$CANDIDATE_BIN" ./scripts/policy/interface-baseline - -printf 'checking candidate against authoritative interface ref %s\n' "$BASE_REF" -HOME="$CANDIDATE_HOME" DWS_LANG=zh "$CANDIDATE_BIN" --check "$BASELINE" +exec "$ROOT/scripts/policy/check-command-compatibility.sh" \ + --base-ref "$BASE_REF" \ + --stable-ref "$STABLE_REF" \ + --candidate-ref "$CANDIDATE_REF" diff --git a/scripts/policy/check-authoritative-schema-compatibility.sh b/scripts/policy/check-authoritative-schema-compatibility.sh index aeb37aa6..0778db37 100755 --- a/scripts/policy/check-authoritative-schema-compatibility.sh +++ b/scripts/policy/check-authoritative-schema-compatibility.sh @@ -1,16 +1,22 @@ #!/bin/sh set -eu -# Compare the candidate's complete Schema contract with the Git-owned PR -# merge-base (or previous main SHA on push). The candidate branch cannot -# weaken this authority by editing a checked-in fixture. +# Compare the committed candidate's complete Schema contract with the Git-owned +# PR merge-base (or previous main SHA on push). The merge-base owns the Schema +# checker and, after bootstrap, the approved flag-migration ledger. Candidate +# code is lifecycle input only and cannot replace either authority. ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" -BIN="${DWS_BIN:-$ROOT/dws}" BASE_REF="" +STABLE_REF="" +CANDIDATE_REF="HEAD" +SCHEMA_CHECKER_REL="scripts/policy/schema-compat/main.go" +MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-flag-migrations-v1.json" +MIGRATIONS_REL="internal/interfacesnapshot/migrations.go" +ALIAS_CONTRACT_REL="internal/corecmd/runtimeannotate/interface_alias.go" usage() { - printf '%s\n' "usage: $0 --base-ref " >&2 + printf '%s\n' "usage: $0 --base-ref [--stable-ref ] [--candidate-ref ]" >&2 } while [ "$#" -gt 0 ]; do @@ -20,6 +26,16 @@ while [ "$#" -gt 0 ]; do BASE_REF="$2" shift 2 ;; + --stable-ref) + [ "$#" -ge 2 ] || { usage; exit 2; } + STABLE_REF="$2" + shift 2 + ;; + --candidate-ref) + [ "$#" -ge 2 ] || { usage; exit 2; } + CANDIDATE_REF="$2" + shift 2 + ;; -h|--help) usage exit 0 @@ -36,27 +52,62 @@ done cd "$ROOT" . "$ROOT/scripts/policy/policy-runtime.sh" +. "$ROOT/scripts/release/release-lib.sh" policy_prepare_runtime "$ROOT" -git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || { +BASE_COMMIT="$(git rev-parse --verify "${BASE_REF}^{commit}")" || { printf 'error: Schema authority is not available locally: %s\n' "$BASE_REF" >&2 exit 2 } -if [ ! -x "$BIN" ]; then - printf 'error: dws binary not found at %s (run make build first)\n' "$BIN" >&2 +CANDIDATE_COMMIT="$(git rev-parse --verify "${CANDIDATE_REF}^{commit}")" || { + printf 'error: Schema candidate is not available locally: %s\n' "$CANDIDATE_REF" >&2 + exit 2 +} + +EXPECTED_STABLE_REF="" +for tag in $(git tag --merged "$BASE_REF" --list 'v*' --sort=-version:refname); do + release_is_stable_version "$tag" || continue + if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then + continue + fi + EXPECTED_STABLE_REF="$tag" + break +done +if [ -z "$EXPECTED_STABLE_REF" ]; then + printf 'error: no stable release tag is reachable from Schema authority %s\n' "$BASE_REF" >&2 + exit 2 +fi +if [ -z "$STABLE_REF" ]; then + STABLE_REF="$EXPECTED_STABLE_REF" +fi +STABLE_COMMIT="$(git rev-parse --verify "${STABLE_REF}^{commit}")" || { + printf 'error: Schema stable authority is not available locally: %s\n' "$STABLE_REF" >&2 + exit 2 +} +if [ "$(git rev-parse "${STABLE_REF}^{commit}")" != "$(git rev-parse "${EXPECTED_STABLE_REF}^{commit}")" ]; then + printf 'error: stable ref %s is not the expected highest GA tag %s reachable from %s\n' \ + "$STABLE_REF" "$EXPECTED_STABLE_REF" "$BASE_REF" >&2 exit 2 fi TMP_ROOT="$(policy_runtime_mktemp_dir dws-schema-authority)" BASE_WORKTREE="$TMP_ROOT/base-worktree" -BASE_BIN="$TMP_ROOT/base-dws" -BASE_RAW="$TMP_ROOT/base-schema.json" -BASELINE="$TMP_ROOT/base-contract.json" -CANDIDATE_RAW="$TMP_ROOT/candidate-schema.json" -CHECKER="$TMP_ROOT/schema-compat" +STABLE_WORKTREE="$TMP_ROOT/stable-worktree" +CANDIDATE_WORKTREE="$TMP_ROOT/candidate-worktree" +BASE_WORKTREE_ADDED=false +STABLE_WORKTREE_ADDED=false +CANDIDATE_WORKTREE_ADDED=false cleanup() { - git -C "$ROOT" worktree remove --force "$BASE_WORKTREE" >/dev/null 2>&1 || true + if [ "$CANDIDATE_WORKTREE_ADDED" = true ]; then + git -C "$ROOT" worktree remove --force "$CANDIDATE_WORKTREE" >/dev/null 2>&1 || true + fi + if [ "$STABLE_WORKTREE_ADDED" = true ]; then + git -C "$ROOT" worktree remove --force "$STABLE_WORKTREE" >/dev/null 2>&1 || true + fi + if [ "$BASE_WORKTREE_ADDED" = true ]; then + git -C "$ROOT" worktree remove --force "$BASE_WORKTREE" >/dev/null 2>&1 || true + fi rm -rf "$TMP_ROOT" } trap cleanup EXIT @@ -64,19 +115,213 @@ trap 'exit 129' HUP trap 'exit 130' INT trap 'exit 143' TERM -git -C "$ROOT" worktree add --detach "$BASE_WORKTREE" "$BASE_REF" >/dev/null +git -C "$ROOT" worktree add --detach "$BASE_WORKTREE" "$BASE_COMMIT" >/dev/null +BASE_WORKTREE_ADDED=true +git -C "$ROOT" worktree add --detach "$STABLE_WORKTREE" "$STABLE_COMMIT" >/dev/null +STABLE_WORKTREE_ADDED=true +git -C "$ROOT" worktree add --detach "$CANDIDATE_WORKTREE" "$CANDIDATE_COMMIT" >/dev/null +CANDIDATE_WORKTREE_ADDED=true + +commit_path_exists() { + tree_commit="$1" + tree_path="$2" + tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")" + [ -n "$tree_entry" ] +} + +commit_path_is_regular_file() { + tree_commit="$1" + tree_path="$2" + tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")" + [ -n "$tree_entry" ] || return 1 + set -- $tree_entry + [ "$#" -eq 4 ] || return 1 + case "$1" in + 100644|100755) ;; + *) return 1 ;; + esac + [ "$2" = blob ] && [ "$4" = "$tree_path" ] +} + +has_modern_interface_helper() { + governance_commit="$1" + commit_path_is_regular_file "$governance_commit" cmd/interface-snapshot/main.go && + commit_path_is_regular_file "$governance_commit" internal/interfacesnapshot/snapshot.go && + commit_path_is_regular_file "$governance_commit" internal/interfacesnapshot/compare.go +} + +has_complete_schema_migration_governance() { + governance_commit="$1" + commit_path_is_regular_file "$governance_commit" "$SCHEMA_CHECKER_REL" && + has_modern_interface_helper "$governance_commit" && + commit_path_is_regular_file "$governance_commit" "$MIGRATIONS_REL" && + commit_path_is_regular_file "$governance_commit" "$ALIAS_CONTRACT_REL" && + commit_path_is_regular_file "$governance_commit" "$MIGRATION_MANIFEST_REL" +} + +has_any_schema_migration_governance_artifact() { + governance_commit="$1" + for relative_path in \ + "$MIGRATIONS_REL" \ + "$ALIAS_CONTRACT_REL" \ + "$MIGRATION_MANIFEST_REL"; do + if commit_path_exists "$governance_commit" "$relative_path"; then + return 0 + fi + done + return 1 +} + +check_candidate_alias_source_policy() { + source_policy_failed=false + for token in \ + dws.compat.alias_of \ + dws.compat.alias_origin \ + corecmd.flag_spec_aliases.v1 \ + AnnotationFlagAliasOf \ + AnnotationFlagAliasOrigin \ + FlagAliasOriginCorecmdV1; do + matches="$(git -C "$CANDIDATE_WORKTREE" grep -l -F -e "$token" -- '*.go' || true)" + [ -z "$matches" ] && continue + while IFS= read -r relative_path; do + [ -n "$relative_path" ] || continue + case "$relative_path" in + *_test.go) + continue + ;; + internal/corecmd/corecmd.go|\ + internal/corecmd/runtimeannotate/interface_alias.go|\ + internal/interfacesnapshot/snapshot.go) + ;; + *) + if [ "$source_policy_failed" = false ]; then + printf 'error: candidate production source may not forge framework-owned flag alias evidence:\n' >&2 + fi + printf ' %s: protected alias evidence token %s\n' "$relative_path" "$token" >&2 + source_policy_failed=true + ;; + esac + done <&2 + exit 2 +fi +if ! has_modern_interface_helper "$BASE_COMMIT"; then + printf 'error: merge-base lacks the modern interface snapshot helper required for Schema governance: %s\n' "$BASE_REF" >&2 + exit 2 +fi +if ! has_complete_schema_migration_governance "$CANDIDATE_COMMIT"; then + printf 'error: candidate must preserve the complete Schema flag migration governance artifact set at %s\n' \ + "$CANDIDATE_COMMIT" >&2 + exit 2 +fi +check_candidate_alias_source_policy + +EMPTY_MANIFEST="$TMP_ROOT/empty-migrations.json" +printf '%s\n' '{"version":1,"migrations":[]}' >"$EMPTY_MANIFEST" +USE_MIGRATION_GOVERNANCE=false + +if has_complete_schema_migration_governance "$BASE_COMMIT"; then + USE_MIGRATION_GOVERNANCE=true + APPROVED_MANIFEST="$BASE_WORKTREE/$MIGRATION_MANIFEST_REL" + CANDIDATE_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL" +else + # One-time bootstrap for the governance PR itself. The old base checker owns + # the decision and receives no flags it does not understand. With no base + # approval authority, only the exact canonical empty candidate ledger is + # admissible. + if has_any_schema_migration_governance_artifact "$BASE_COMMIT"; then + printf 'error: merge-base contains an incomplete Schema flag migration governance artifact set: %s\n' \ + "$BASE_REF" >&2 + exit 2 + fi + BOOTSTRAP_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL" + if ! cmp -s "$BOOTSTRAP_MANIFEST" "$EMPTY_MANIFEST"; then + printf 'error: initial Schema flag migration governance requires the canonical empty manifest: %s\n' \ + "$BOOTSTRAP_MANIFEST" >&2 + exit 2 + fi +fi + +BASE_BIN="$TMP_ROOT/base-dws" +CANDIDATE_BIN="$TMP_ROOT/candidate-dws" +CHECKER="$TMP_ROOT/schema-compat" +BASE_RAW="$TMP_ROOT/base-schema.json" +BASELINE="$TMP_ROOT/base-contract.json" +CANDIDATE_RAW="$TMP_ROOT/candidate-schema.json" + ( cd "$BASE_WORKTREE" go build -o "$BASE_BIN" ./cmd + go build -o "$CHECKER" ./scripts/policy/schema-compat +) +( + cd "$CANDIDATE_WORKTREE" + go build -o "$CANDIDATE_BIN" ./cmd ) -go build -o "$CHECKER" ./scripts/policy/schema-compat mkdir -p "$TMP_ROOT/base-home" "$TMP_ROOT/candidate-home" HOME="$TMP_ROOT/base-home" DWS_LANG=zh \ "$BASE_BIN" schema --all --format json >"$BASE_RAW" HOME="$TMP_ROOT/candidate-home" DWS_LANG=zh \ - "$BIN" schema --all --format json >"$CANDIDATE_RAW" + "$CANDIDATE_BIN" schema --all --format json >"$CANDIDATE_RAW" "$CHECKER" --normalize "$BASE_RAW" >"$BASELINE" -printf 'checking complete Schema contract against PR merge-base %s\n' "$BASE_REF" -"$CHECKER" --check "$BASELINE" --current "$CANDIDATE_RAW" +printf 'checking complete Schema contract against PR merge-base %s and stable %s\n' \ + "$BASE_REF" "$STABLE_REF" + +if [ "$USE_MIGRATION_GOVERNANCE" = false ]; then + "$CHECKER" --check "$BASELINE" --current "$CANDIDATE_RAW" + exit $? +fi + +install_authority_interface_helper() { + worktree="$1" + for helper_path in cmd/interface-snapshot internal/interfacesnapshot; do + rm -rf "$worktree/$helper_path" + mkdir -p "$worktree/$helper_path" + cp -R "$BASE_WORKTREE/$helper_path/." "$worktree/$helper_path/" + done + mkdir -p "$worktree/$(dirname "$ALIAS_CONTRACT_REL")" + rm -f "$worktree/$ALIAS_CONTRACT_REL" + cp "$BASE_WORKTREE/$ALIAS_CONTRACT_REL" "$worktree/$ALIAS_CONTRACT_REL" +} + +# The candidate and historical stable trees provide only the command surface. +# The merge-base owns the snapshot generator, comparator, and alias contract. +install_authority_interface_helper "$CANDIDATE_WORKTREE" +install_authority_interface_helper "$STABLE_WORKTREE" + +generate_interface_snapshot() { + worktree="$1" + output="$2" + ( + cd "$worktree" + go run ./cmd/interface-snapshot generate --output "$output" + ) +} + +CURRENT_INTERFACE_SNAPSHOT="$TMP_ROOT/candidate-interface-snapshot.json" +BASE_INTERFACE_SNAPSHOT="$TMP_ROOT/base-interface-snapshot.json" +STABLE_INTERFACE_SNAPSHOT="$TMP_ROOT/stable-interface-snapshot.json" + +generate_interface_snapshot "$CANDIDATE_WORKTREE" "$CURRENT_INTERFACE_SNAPSHOT" +generate_interface_snapshot "$BASE_WORKTREE" "$BASE_INTERFACE_SNAPSHOT" +generate_interface_snapshot "$STABLE_WORKTREE" "$STABLE_INTERFACE_SNAPSHOT" + +"$CHECKER" \ + --check "$BASELINE" \ + --current "$CANDIDATE_RAW" \ + --approved-flag-migrations "$APPROVED_MANIFEST" \ + --candidate-flag-migrations "$CANDIDATE_MANIFEST" \ + --migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \ + --migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \ + --migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT" diff --git a/scripts/policy/check-command-compatibility.sh b/scripts/policy/check-command-compatibility.sh index a15a69bb..d26a7abd 100755 --- a/scripts/policy/check-command-compatibility.sh +++ b/scripts/policy/check-command-compatibility.sh @@ -1,23 +1,21 @@ #!/bin/sh set -eu -# Compare the current Cobra command surface with two local Git revisions: -# the PR merge-base/main reference and the latest stable GA tag. The caller must -# pass the highest non-prerelease SemVer tag (excluding beta/rc tags). Tag -# governance treats that tag as proof of a successfully published GA release; -# release automation must not leave a GA tag behind when publication fails. -# -# The script never fetches refs or snapshots from the network. With checkout -# fetch-depth=0, both references are materialized in temporary worktrees. A -# reference uses its own snapshot helper when available; older revisions are -# bootstrapped with the candidate helper. +# Compare the committed candidate Cobra surface with the PR merge-base and the +# latest stable GA tag. Once the merge-base contains the migration governance +# helper, that revision owns the generator, comparator, and approved manifest. +# The candidate manifest is lifecycle input only and cannot authorize a change +# introduced by the same PR. ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" BASE_REF="" STABLE_REF="" +CANDIDATE_REF="HEAD" +MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-flag-migrations-v1.json" +ALIAS_CONTRACT_REL="internal/corecmd/runtimeannotate/interface_alias.go" usage() { - printf '%s\n' "usage: $0 --base-ref --stable-ref " >&2 + printf '%s\n' "usage: $0 --base-ref --stable-ref [--candidate-ref ]" >&2 } while [ "$#" -gt 0 ]; do @@ -32,6 +30,11 @@ while [ "$#" -gt 0 ]; do STABLE_REF="$2" shift 2 ;; + --candidate-ref) + [ "$#" -ge 2 ] || { usage; exit 2; } + CANDIDATE_REF="$2" + shift 2 + ;; -h|--help) usage exit 0 @@ -47,20 +50,51 @@ done [ -n "$BASE_REF" ] && [ -n "$STABLE_REF" ] || { usage; exit 2; } cd "$ROOT" -git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || { +. "$ROOT/scripts/policy/policy-runtime.sh" +. "$ROOT/scripts/release/release-lib.sh" +policy_prepare_runtime "$ROOT" + +BASE_COMMIT="$(git rev-parse --verify "${BASE_REF}^{commit}")" || { printf 'base ref is not available locally: %s\n' "$BASE_REF" >&2 exit 2 } -git rev-parse --verify --quiet "${STABLE_REF}^{commit}" >/dev/null || { +STABLE_COMMIT="$(git rev-parse --verify "${STABLE_REF}^{commit}")" || { printf 'stable ref is not available locally: %s\n' "$STABLE_REF" >&2 exit 2 } +CANDIDATE_COMMIT="$(git rev-parse --verify "${CANDIDATE_REF}^{commit}")" || { + printf 'candidate ref is not available locally: %s\n' "$CANDIDATE_REF" >&2 + exit 2 +} +EXPECTED_STABLE_REF="" +for tag in $(git tag --merged "$BASE_REF" --list 'v*' --sort=-version:refname); do + release_is_stable_version "$tag" || continue + if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then + continue + fi + EXPECTED_STABLE_REF="$tag" + break +done +if [ -z "$EXPECTED_STABLE_REF" ]; then + printf 'no stable GA tag is reachable from compatibility base %s\n' "$BASE_REF" >&2 + exit 2 +fi +if [ "$(git rev-parse "${STABLE_REF}^{commit}")" != "$(git rev-parse "${EXPECTED_STABLE_REF}^{commit}")" ]; then + printf 'stable ref %s is not the expected highest GA tag %s reachable from %s\n' \ + "$STABLE_REF" "$EXPECTED_STABLE_REF" "$BASE_REF" >&2 + exit 2 +fi -TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-command-compat.XXXXXX")" +TMP_ROOT="$(policy_runtime_mktemp_dir dws-command-compat)" BASE_WORKTREE="$TMP_ROOT/base-worktree" STABLE_WORKTREE="$TMP_ROOT/stable-worktree" +CANDIDATE_WORKTREE="$TMP_ROOT/candidate-worktree" +CANDIDATE_WORKTREE_ADDED=false cleanup() { + if [ "$CANDIDATE_WORKTREE_ADDED" = true ]; then + git -C "$ROOT" worktree remove --force "$CANDIDATE_WORKTREE" >/dev/null 2>&1 || true + fi git -C "$ROOT" worktree remove --force "$BASE_WORKTREE" >/dev/null 2>&1 || true git -C "$ROOT" worktree remove --force "$STABLE_WORKTREE" >/dev/null 2>&1 || true rm -rf "$TMP_ROOT" @@ -70,38 +104,168 @@ trap 'exit 129' HUP trap 'exit 130' INT trap 'exit 143' TERM -ensure_snapshot_helper() { - worktree="$1" +git -C "$ROOT" worktree add --detach "$BASE_WORKTREE" "$BASE_COMMIT" >/dev/null +git -C "$ROOT" worktree add --detach "$STABLE_WORKTREE" "$STABLE_COMMIT" >/dev/null +git -C "$ROOT" worktree add --detach "$CANDIDATE_WORKTREE" "$CANDIDATE_COMMIT" >/dev/null +CANDIDATE_WORKTREE_ADDED=true - # Once a baseline contains the helper, use that revision's own capture - # rules. This makes a rule/filter change in the candidate visible as a - # snapshot_rules_changed failure instead of applying the new exclusion to - # both sides and accidentally hiding a removed command. Older revisions - # created before this helper existed are bootstrapped with the candidate - # implementation. - if [ -f "$worktree/cmd/interface-snapshot/main.go" ] && \ - [ -f "$worktree/internal/interfacesnapshot/snapshot.go" ] && \ - [ -f "$worktree/internal/interfacesnapshot/compare.go" ]; then - return - fi - if [ -e "$worktree/cmd/interface-snapshot" ] || \ - [ -e "$worktree/internal/interfacesnapshot" ]; then - printf 'baseline contains an incomplete interface snapshot helper: %s\n' "$worktree" >&2 - exit 2 - fi - - mkdir -p "$worktree/cmd/interface-snapshot" "$worktree/internal/interfacesnapshot" - cp -R "$ROOT/cmd/interface-snapshot/." "$worktree/cmd/interface-snapshot/" - cp -R "$ROOT/internal/interfacesnapshot/." "$worktree/internal/interfacesnapshot/" +commit_path_exists() { + tree_commit="$1" + tree_path="$2" + tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")" + [ -n "$tree_entry" ] } -generate_ref_snapshot() { - ref="$1" - worktree="$2" - output="$3" +commit_path_is_regular_file() { + tree_commit="$1" + tree_path="$2" + tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")" + [ -n "$tree_entry" ] || return 1 + set -- $tree_entry + [ "$#" -eq 4 ] || return 1 + case "$1" in + 100644|100755) ;; + *) return 1 ;; + esac + [ "$2" = blob ] && [ "$4" = "$tree_path" ] +} - git -C "$ROOT" worktree add --detach "$worktree" "$ref" >/dev/null - ensure_snapshot_helper "$worktree" +has_modern_interface_helper() { + governance_commit="$1" + commit_path_is_regular_file "$governance_commit" cmd/interface-snapshot/main.go && + commit_path_is_regular_file "$governance_commit" internal/interfacesnapshot/snapshot.go && + commit_path_is_regular_file "$governance_commit" internal/interfacesnapshot/compare.go +} + +has_complete_migration_governance() { + governance_commit="$1" + has_modern_interface_helper "$governance_commit" && + commit_path_is_regular_file "$governance_commit" internal/interfacesnapshot/migrations.go && + commit_path_is_regular_file "$governance_commit" "$ALIAS_CONTRACT_REL" && + commit_path_is_regular_file "$governance_commit" "$MIGRATION_MANIFEST_REL" +} + +has_any_migration_governance_artifact() { + governance_commit="$1" + for relative_path in \ + internal/interfacesnapshot/migrations.go \ + "$ALIAS_CONTRACT_REL" \ + "$MIGRATION_MANIFEST_REL"; do + if commit_path_exists "$governance_commit" "$relative_path"; then + return 0 + fi + done + return 1 +} + +require_complete_candidate_governance() { + if ! has_complete_migration_governance "$CANDIDATE_COMMIT"; then + printf 'candidate must preserve the complete flag migration governance artifact set at %s\n' \ + "$CANDIDATE_COMMIT" >&2 + exit 2 + fi +} + +check_candidate_alias_source_policy() { + source_policy_failed=false + for token in \ + dws.compat.alias_of \ + dws.compat.alias_origin \ + corecmd.flag_spec_aliases.v1 \ + AnnotationFlagAliasOf \ + AnnotationFlagAliasOrigin \ + FlagAliasOriginCorecmdV1; do + matches="$(git -C "$CANDIDATE_WORKTREE" grep -l -F -e "$token" -- '*.go' || true)" + [ -z "$matches" ] && continue + while IFS= read -r relative_path; do + [ -n "$relative_path" ] || continue + case "$relative_path" in + *_test.go) + continue + ;; + internal/corecmd/corecmd.go|\ + internal/corecmd/runtimeannotate/interface_alias.go|\ + internal/interfacesnapshot/snapshot.go) + ;; + *) + if [ "$source_policy_failed" = false ]; then + printf 'candidate production source may not forge framework-owned flag alias evidence:\n' >&2 + fi + printf ' %s: protected alias evidence token %s\n' "$relative_path" "$token" >&2 + source_policy_failed=true + ;; + esac + done <"$EMPTY_MANIFEST" +USE_MIGRATION_GOVERNANCE=false + +if has_complete_migration_governance "$BASE_COMMIT"; then + USE_MIGRATION_GOVERNANCE=true + AUTHORITY_ROOT="$BASE_WORKTREE" + APPROVED_MANIFEST="$BASE_WORKTREE/$MIGRATION_MANIFEST_REL" + CANDIDATE_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL" + require_complete_candidate_governance + check_candidate_alias_source_policy + + install_authority_helper "$AUTHORITY_ROOT" "$CANDIDATE_WORKTREE" true +else + # One-time bootstrap for the governance PR itself. No base approval exists, + # so the existing base-owned modern helper performs an ordinary comparison + # and this path admits only the canonical empty ledger. The candidate's new + # comparator never participates in the bootstrap decision. + if has_any_migration_governance_artifact "$BASE_COMMIT"; then + printf 'merge-base contains an incomplete flag migration governance artifact set: %s\n' \ + "$BASE_REF" >&2 + exit 2 + fi + if ! has_modern_interface_helper "$BASE_COMMIT"; then + printf 'merge-base lacks the modern interface snapshot helper required for governance bootstrap: %s\n' "$BASE_REF" >&2 + exit 2 + fi + AUTHORITY_ROOT="$BASE_WORKTREE" + require_complete_candidate_governance + check_candidate_alias_source_policy + BOOTSTRAP_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL" + if ! cmp -s "$BOOTSTRAP_MANIFEST" "$EMPTY_MANIFEST"; then + printf 'initial flag migration governance requires the canonical empty manifest: %s\n' "$BOOTSTRAP_MANIFEST" >&2 + exit 2 + fi + install_authority_helper "$AUTHORITY_ROOT" "$CANDIDATE_WORKTREE" false +fi + +install_authority_helper "$AUTHORITY_ROOT" "$BASE_WORKTREE" "$USE_MIGRATION_GOVERNANCE" +install_authority_helper "$AUTHORITY_ROOT" "$STABLE_WORKTREE" "$USE_MIGRATION_GOVERNANCE" + +generate_snapshot() { + worktree="$1" + output="$2" ( cd "$worktree" go run ./cmd/interface-snapshot generate --output "$output" @@ -112,11 +276,26 @@ CANDIDATE="$TMP_ROOT/candidate.json" BASELINE="$TMP_ROOT/merge-base.json" STABLE="$TMP_ROOT/stable.json" -go run ./cmd/interface-snapshot generate --output "$CANDIDATE" -generate_ref_snapshot "$BASE_REF" "$BASE_WORKTREE" "$BASELINE" -generate_ref_snapshot "$STABLE_REF" "$STABLE_WORKTREE" "$STABLE" +generate_snapshot "$CANDIDATE_WORKTREE" "$CANDIDATE" +generate_snapshot "$BASE_WORKTREE" "$BASELINE" +generate_snapshot "$STABLE_WORKTREE" "$STABLE" -go run ./cmd/interface-snapshot compare \ - --current "$CANDIDATE" \ - --base "$BASELINE" \ - --stable "$STABLE" +if [ "$USE_MIGRATION_GOVERNANCE" = true ]; then + ( + cd "$AUTHORITY_ROOT" + go run ./cmd/interface-snapshot compare \ + --current "$CANDIDATE" \ + --base "$BASELINE" \ + --stable "$STABLE" \ + --approved-flag-migrations "$APPROVED_MANIFEST" \ + --candidate-flag-migrations "$CANDIDATE_MANIFEST" + ) +else + ( + cd "$AUTHORITY_ROOT" + go run ./cmd/interface-snapshot compare \ + --current "$CANDIDATE" \ + --base "$BASELINE" \ + --stable "$STABLE" + ) +fi diff --git a/scripts/policy/commandcompat/authority_integration_test.go b/scripts/policy/commandcompat/authority_integration_test.go new file mode 100644 index 00000000..9f1ddd8b --- /dev/null +++ b/scripts/policy/commandcompat/authority_integration_test.go @@ -0,0 +1,454 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package commandcompat + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +const authorityHelperSource = `package main + +import ( + "flag" + "fmt" + "os" + + "example.com/dws-command-compat-authority-test/internal/interfacesnapshot" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "missing command") + os.Exit(2) + } + switch os.Args[1] { + case "generate": + flags := flag.NewFlagSet("generate", flag.ExitOnError) + output := flags.String("output", "", "snapshot output") + _ = flags.Parse(os.Args[2:]) + if *output == "" { + fmt.Fprintln(os.Stderr, "missing --output") + os.Exit(2) + } + marker := interfacesnapshot.AuthorityMarker() + if err := os.WriteFile(*output, []byte("AUTHORITY="+marker+"\n"), 0o600); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + fmt.Printf("BASE_HELPER_GENERATE=%s\n", marker) + case "compare": + flags := flag.NewFlagSet("compare", flag.ExitOnError) + candidateManifest := flags.String("candidate-flag-migrations", "", "candidate manifest") + current := flags.String("current", "", "current snapshot") + base := flags.String("base", "", "base snapshot") + stable := flags.String("stable", "", "stable snapshot") + _ = flags.String("approved-flag-migrations", "", "approved manifest") + _ = flags.Parse(os.Args[2:]) + for _, path := range []string{*current, *base, *stable} { + content, err := os.ReadFile(path) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + if string(content) != "AUTHORITY=BASE\n" { + fmt.Fprintf(os.Stderr, "snapshot did not use base internal helper: %s", content) + os.Exit(2) + } + } + if *candidateManifest != "" { + content, err := os.ReadFile(*candidateManifest) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + fmt.Printf("CANDIDATE_MANIFEST=%s\n", *candidateManifest) + if string(content) != "{\"version\":1,\"migrations\":[]}\n" { + fmt.Println("UNCOMMITTED_MANIFEST_USED") + return + } + } + fmt.Fprintln(os.Stderr, "BASE_COMPARATOR_ENFORCED") + os.Exit(23) + default: + fmt.Fprintln(os.Stderr, "unknown command") + os.Exit(2) + } +} +` + +const governedSnapshotSource = `package interfacesnapshot + +import "example.com/dws-command-compat-authority-test/internal/corecmd/runtimeannotate" + +var ( + _ = runtimeannotate.AnnotationFlagAliasOf + _ = runtimeannotate.AnnotationFlagAliasOrigin + _ = runtimeannotate.FlagAliasOriginCorecmdV1 +) + +func AuthorityMarker() string { return "BASE" } +` + +const baseSnapshotSource = `package interfacesnapshot + +func AuthorityMarker() string { return "BASE" } +` + +const stableSnapshotSource = `package interfacesnapshot + +func AuthorityMarker() string { return "STABLE" } +` + +const candidateSnapshotSource = `package interfacesnapshot + +func AuthorityMarker() string { return "CANDIDATE" } +` + +const aliasContractSource = `package runtimeannotate + +const ( + AnnotationFlagAliasOf = "dws.compat.alias_of" + AnnotationFlagAliasOrigin = "dws.compat.alias_origin" + FlagAliasOriginCorecmdV1 = "corecmd.flag_spec_aliases.v1" +) +` + +const candidateNoopSource = `package main + +import "fmt" + +func main() { + fmt.Println("CANDIDATE_NOOP") +} +` + +type authorityScenario struct { + name string + baseGovernance string + candidateMutation string + want string + wantAuthorityDecision bool +} + +func TestCrossPlatformCoverageCommandCompatibilityUsesBaseOwnedAuthority(t *testing.T) { + tests := []authorityScenario{ + { + name: "governed base rejects candidate helper and live manifest", + baseGovernance: "complete", + wantAuthorityDecision: true, + }, + { + name: "bootstrap uses old base helper", + baseGovernance: "none", + wantAuthorityDecision: true, + }, + { + name: "partial base fails closed", + baseGovernance: "partial", + want: "incomplete flag migration governance artifact set", + }, + { + name: "base ledger ancestor symlink fails closed", + baseGovernance: "ancestor-symlink", + want: "incomplete flag migration governance artifact set", + }, + { + name: "governed candidate cannot delete alias contract", + baseGovernance: "complete", + candidateMutation: "delete-alias-contract", + want: "must preserve the complete flag migration governance artifact set", + }, + { + name: "governed candidate cannot replace helper parent with symlink", + baseGovernance: "complete", + candidateMutation: "symlink-helper-parent", + want: "must preserve the complete flag migration governance artifact set", + }, + { + name: "candidate cannot forge alias evidence token", + baseGovernance: "complete", + candidateMutation: "forge-alias-token", + want: "may not forge framework-owned flag alias evidence", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + runBaseOwnedAuthorityCase(t, test) + }) + } +} + +func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) { + t.Helper() + repoRoot := repositoryRoot(t) + managedRoot := filepath.Join(repoRoot, ".worktrees", "commandcompat-authority-integration") + if err := os.MkdirAll(managedRoot, 0o755); err != nil { + t.Fatalf("create managed test root: %v", err) + } + + fixtureRoot, err := os.MkdirTemp(managedRoot, fmt.Sprintf("case-%d-", time.Now().UnixNano())) + if err != nil { + t.Fatalf("create managed fixture repo: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(fixtureRoot); err != nil { + t.Errorf("remove managed fixture repo: %v", err) + } + }) + + copyFile(t, + filepath.Join(repoRoot, "scripts", "policy", "check-command-compatibility.sh"), + filepath.Join(fixtureRoot, "scripts", "policy", "check-command-compatibility.sh"), + 0o755, + ) + copyFile(t, + filepath.Join(repoRoot, "scripts", "policy", "policy-runtime.sh"), + filepath.Join(fixtureRoot, "scripts", "policy", "policy-runtime.sh"), + 0o755, + ) + copyFile(t, + filepath.Join(repoRoot, "scripts", "release", "release-lib.sh"), + filepath.Join(fixtureRoot, "scripts", "release", "release-lib.sh"), + 0o755, + ) + writeFile(t, filepath.Join(fixtureRoot, "go.mod"), "module example.com/dws-command-compat-authority-test\n\ngo 1.23\n", 0o644) + writeFile(t, filepath.Join(fixtureRoot, "cmd", "interface-snapshot", "main.go"), authorityHelperSource, 0o644) + writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), stableSnapshotSource, 0o644) + writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "compare.go"), "package interfacesnapshot\n", 0o644) + + run(t, fixtureRoot, "git", "init", "-b", "main") + run(t, fixtureRoot, "git", "config", "user.name", "Command Compatibility Test") + run(t, fixtureRoot, "git", "config", "user.email", "command-compat-test@example.invalid") + run(t, fixtureRoot, "git", "add", ".") + run(t, fixtureRoot, "git", "commit", "-m", "old stable without migration governance") + stableCommit := strings.TrimSpace(run(t, fixtureRoot, "git", "rev-parse", "HEAD")) + run(t, fixtureRoot, "git", "tag", "v1.0.0", stableCommit) + + writeFile(t, filepath.Join(fixtureRoot, "BASE_MARKER"), "newer merge-base\n", 0o644) + writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), baseSnapshotSource, 0o644) + switch test.baseGovernance { + case "complete": + writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), governedSnapshotSource, 0o644) + writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go"), "package interfacesnapshot\n", 0o644) + writeFile(t, + filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go"), + aliasContractSource, + 0o644, + ) + writeFile(t, + filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"), + "{\"version\":1,\"migrations\":[]}\n", + 0o644, + ) + case "partial": + writeFile(t, + filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"), + "{\"version\":1,\"migrations\":[]}\n", + 0o644, + ) + case "ancestor-symlink": + writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go"), "package interfacesnapshot\n", 0o644) + writeFile(t, + filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go"), + aliasContractSource, + 0o644, + ) + writeFile(t, + filepath.Join(fixtureRoot, "scripts", "policy", "authority-ledger", "approved-flag-migrations-v1.json"), + "{\"version\":1,\"migrations\":[]}\n", + 0o644, + ) + if err := os.Symlink("authority-ledger", filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations")); err != nil { + t.Fatalf("symlink base ledger parent: %v", err) + } + case "none": + default: + t.Fatalf("unknown base governance state %q", test.baseGovernance) + } + run(t, fixtureRoot, "git", "add", ".") + run(t, fixtureRoot, "git", "commit", "-m", "base authority") + baseRef := strings.TrimSpace(run(t, fixtureRoot, "git", "rev-parse", "HEAD")) + if baseRef == stableCommit { + t.Fatal("fixture stable tag must predate the governed merge-base") + } + // A withdrawn higher GA tag is reserved history, not the active stable + // compatibility baseline. The checker must agree with release-contract.sh. + run(t, fixtureRoot, "git", "tag", "v1.1.0", baseRef) + run(t, fixtureRoot, "git", "update-ref", "refs/tags/withdrawn/v1.1.0", baseRef) + + // 候选提交故意把生成器与比较器改成无条件成功;若脚本错误地信任候选代码, + // 整个检查会输出 CANDIDATE_NOOP 并以 0 退出。 + writeFile(t, filepath.Join(fixtureRoot, "cmd", "interface-snapshot", "main.go"), candidateNoopSource, 0o644) + writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), candidateSnapshotSource, 0o644) + if test.baseGovernance != "complete" { + writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go"), "package interfacesnapshot\n", 0o644) + writeFile(t, + filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go"), + aliasContractSource, + 0o644, + ) + writeFile(t, + filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"), + "{\"version\":1,\"migrations\":[]}\n", + 0o644, + ) + } + switch test.candidateMutation { + case "": + case "delete-alias-contract": + if err := os.Remove(filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go")); err != nil { + t.Fatalf("remove candidate alias contract: %v", err) + } + case "forge-alias-token": + writeFile(t, + filepath.Join(fixtureRoot, "internal", "evil", "forged.go"), + "package evil\n\nconst forged = \"dws.compat.alias_of\"\n", + 0o644, + ) + case "symlink-helper-parent": + helperPath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot") + alternatePath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot-alt") + if err := os.Rename(helperPath, alternatePath); err != nil { + t.Fatalf("move candidate helper behind ancestor symlink: %v", err) + } + if err := os.Symlink("interfacesnapshot-alt", helperPath); err != nil { + t.Fatalf("symlink candidate helper parent: %v", err) + } + default: + t.Fatalf("unknown candidate mutation %q", test.candidateMutation) + } + run(t, fixtureRoot, "git", "add", ".") + run(t, fixtureRoot, "git", "commit", "-m", "malicious candidate comparator") + if test.baseGovernance == "complete" && test.candidateMutation == "" { + writeFile(t, + filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"), + "{\"version\":1,\"migrations\":[{\"uncommitted\":true}]}\n", + 0o644, + ) + } + + cmd := exec.Command( + "sh", + "scripts/policy/check-command-compatibility.sh", + "--base-ref", baseRef, + "--stable-ref", "v1.0.0", + "--candidate-ref", "HEAD", + ) + cmd.Dir = fixtureRoot + cmd.Env = integrationEnvironment(fixtureRoot) + output, runErr := cmd.CombinedOutput() + got := string(output) + if runErr == nil { + t.Fatalf("compatibility script unexpectedly succeeded; output:\n%s", got) + } + if strings.Contains(got, "CANDIDATE_NOOP") { + t.Fatalf("candidate-owned no-op helper took control; output:\n%s", got) + } + if strings.Contains(got, "UNCOMMITTED_MANIFEST_USED") { + t.Fatalf("compatibility script mixed the live manifest with the committed candidate surface; output:\n%s", got) + } + if test.wantAuthorityDecision { + if count := strings.Count(got, "BASE_HELPER_GENERATE=BASE"); count != 3 { + t.Fatalf("compatibility script generated %d/3 snapshots with the base-owned internal helper; output:\n%s", count, got) + } + if !strings.Contains(got, "BASE_COMPARATOR_ENFORCED") { + t.Fatalf("compatibility script did not use the base-owned comparator; output:\n%s", got) + } + if test.baseGovernance == "complete" && !strings.Contains(got, "CANDIDATE_MANIFEST=") { + t.Fatalf("base comparator did not receive the committed candidate manifest; output:\n%s", got) + } + } else if !strings.Contains(got, test.want) { + t.Fatalf("compatibility script output missing %q:\n%s", test.want, got) + } + + if !test.wantAuthorityDecision { + return + } + wrongStable := exec.Command( + "sh", + "scripts/policy/check-command-compatibility.sh", + "--base-ref", baseRef, + "--stable-ref", "HEAD", + "--candidate-ref", "HEAD", + ) + wrongStable.Dir = fixtureRoot + wrongStable.Env = integrationEnvironment(fixtureRoot) + wrongOutput, wrongErr := wrongStable.CombinedOutput() + if wrongErr == nil || !strings.Contains(string(wrongOutput), "is not the expected highest GA tag") { + t.Fatalf("arbitrary stable ref was not rejected: err=%v output:\n%s", wrongErr, wrongOutput) + } +} + +func repositoryRoot(t *testing.T) string { + t.Helper() + _, sourceFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve test source path") + } + root := filepath.Clean(filepath.Join(filepath.Dir(sourceFile), "..", "..", "..")) + if _, err := os.Stat(filepath.Join(root, "scripts", "policy", "check-command-compatibility.sh")); err != nil { + t.Fatalf("resolve repository root %s: %v", root, err) + } + return root +} + +func copyFile(t *testing.T, source, destination string, mode os.FileMode) { + t.Helper() + content, err := os.ReadFile(source) + if err != nil { + t.Fatalf("read %s: %v", source, err) + } + writeFile(t, destination, string(content), mode) +} + +func writeFile(t *testing.T, path, content string, mode os.FileMode) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("create parent for %s: %v", path, err) + } + if err := os.WriteFile(path, []byte(content), mode); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func run(t *testing.T, dir, name string, args ...string) string { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s %s failed: %v\n%s", name, strings.Join(args, " "), err, output) + } + return string(output) +} + +func integrationEnvironment(fixtureRoot string) []string { + overrides := map[string]string{ + "DWS_POLICY_TMPDIR": "", + "GOCACHE": filepath.Join(fixtureRoot, ".cache", "go-build"), + "GOMODCACHE": filepath.Join(fixtureRoot, ".cache", "go-mod"), + "GOTMPDIR": "", + "GOTOOLCHAIN": "local", + "GOWORK": "off", + } + environment := make([]string, 0, len(os.Environ())+len(overrides)) + for _, item := range os.Environ() { + key, _, _ := strings.Cut(item, "=") + if _, replaced := overrides[key]; !replaced { + environment = append(environment, item) + } + } + for key, value := range overrides { + environment = append(environment, key+"="+value) + } + return environment +} diff --git a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json new file mode 100644 index 00000000..5ad17072 --- /dev/null +++ b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json @@ -0,0 +1 @@ +{"version":1,"migrations":[]} diff --git a/scripts/policy/schema-compat/main.go b/scripts/policy/schema-compat/main.go index d186a6c1..42ad4559 100644 --- a/scripts/policy/schema-compat/main.go +++ b/scripts/policy/schema-compat/main.go @@ -14,6 +14,8 @@ import ( "os" "sort" "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot" ) const schemaContractVersion = 3 @@ -82,12 +84,19 @@ func main() { func run(args []string, stdout, stderr io.Writer) int { var normalizePath, checkPath, mergePath, currentPath string + var approvedFlagMigrationsPath, candidateFlagMigrationsPath string + var migrationCurrentSnapshotPath, migrationBaseSnapshotPath, migrationStableSnapshotPath string flags := flag.NewFlagSet("schema-compat", flag.ContinueOnError) flags.SetOutput(stderr) flags.StringVar(&normalizePath, "normalize", "", "normalize a raw complete Schema response") flags.StringVar(&checkPath, "check", "", "check against a normalized historical baseline") flags.StringVar(&mergePath, "merge", "", "merge additions into a normalized historical baseline") flags.StringVar(¤tPath, "current", "", "raw current complete Schema response") + flags.StringVar(&approvedFlagMigrationsPath, "approved-flag-migrations", "", "base-owned approved flag migration manifest") + flags.StringVar(&candidateFlagMigrationsPath, "candidate-flag-migrations", "", "detached candidate flag migration manifest") + flags.StringVar(&migrationCurrentSnapshotPath, "migration-current-snapshot", "", "current interface snapshot used for migration authorization") + flags.StringVar(&migrationBaseSnapshotPath, "migration-base-snapshot", "", "merge-base interface snapshot used for migration authorization") + flags.StringVar(&migrationStableSnapshotPath, "migration-stable-snapshot", "", "stable interface snapshot used for migration authorization") if err := flags.Parse(args); err != nil { return 2 } @@ -102,6 +111,27 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "exactly one of --normalize, --check, or --merge is required") return 2 } + migrationInputs := []string{ + approvedFlagMigrationsPath, + candidateFlagMigrationsPath, + migrationCurrentSnapshotPath, + migrationBaseSnapshotPath, + migrationStableSnapshotPath, + } + migrationInputCount := 0 + for _, path := range migrationInputs { + if path != "" { + migrationInputCount++ + } + } + if migrationInputCount != 0 && migrationInputCount != len(migrationInputs) { + fmt.Fprintln(stderr, "Schema flag migration authorization requires all five migration inputs") + return 2 + } + if migrationInputCount != 0 && checkPath == "" { + fmt.Fprintln(stderr, "Schema flag migration authorization is only valid with --check") + return 2 + } if normalizePath != "" { currentPath = normalizePath @@ -128,6 +158,24 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "read schema baseline: %v\n", err) return 2 } + if migrationInputCount != 0 { + migrations, err := authorizeSchemaFlagMigrations( + approvedFlagMigrationsPath, + candidateFlagMigrationsPath, + migrationCurrentSnapshotPath, + migrationBaseSnapshotPath, + migrationStableSnapshotPath, + ) + if err != nil { + fmt.Fprintf(stderr, "authorize Schema flag migrations: %v\n", err) + return 2 + } + baseline, err = normalizeSchemaFlagMigrations(baseline, current, migrations) + if err != nil { + fmt.Fprintf(stderr, "normalize approved Schema flag migrations: %v\n", err) + return 2 + } + } failures := checkCompatibility(baseline, current) if len(failures) > 0 { fmt.Fprintln(stderr, "Schema backwards-compatibility check failed:") @@ -159,6 +207,57 @@ func run(args []string, stdout, stderr io.Writer) int { return 0 } +func authorizeSchemaFlagMigrations( + approvedManifestPath string, + candidateManifestPath string, + currentSnapshotPath string, + baseSnapshotPath string, + stableSnapshotPath string, +) ([]interfacesnapshot.FlagMigration, error) { + approved, err := readFlagMigrationManifestFile(approvedManifestPath) + if err != nil { + return nil, fmt.Errorf("read approved flag migrations: %w", err) + } + candidate, err := readFlagMigrationManifestFile(candidateManifestPath) + if err != nil { + return nil, fmt.Errorf("read candidate flag migrations: %w", err) + } + current, err := readInterfaceSnapshotFile(currentSnapshotPath) + if err != nil { + return nil, fmt.Errorf("read migration current snapshot: %w", err) + } + base, err := readInterfaceSnapshotFile(baseSnapshotPath) + if err != nil { + return nil, fmt.Errorf("read migration base snapshot: %w", err) + } + stable, err := readInterfaceSnapshotFile(stableSnapshotPath) + if err != nil { + return nil, fmt.Errorf("read migration stable snapshot: %w", err) + } + return interfacesnapshot.AuthorizeFlagMigrations( + current, + map[string]interfacesnapshot.Snapshot{"merge-base": base, "stable": stable}, + approved, + candidate, + ) +} + +func readFlagMigrationManifestFile(path string) (interfacesnapshot.FlagMigrationManifest, error) { + data, err := os.ReadFile(path) + if err != nil { + return interfacesnapshot.FlagMigrationManifest{}, err + } + return interfacesnapshot.ReadFlagMigrationManifest(bytes.NewReader(data)) +} + +func readInterfaceSnapshotFile(path string) (interfacesnapshot.Snapshot, error) { + data, err := os.ReadFile(path) + if err != nil { + return interfacesnapshot.Snapshot{}, err + } + return interfacesnapshot.Read(bytes.NewReader(data)) +} + func normalizeRawFile(path string) (schemaContract, error) { data, err := os.ReadFile(path) if err != nil { @@ -919,6 +1018,334 @@ func cloneContract(source schemaContract) schemaContract { return cloned } +type schemaToolRef struct { + productID string + toolID string +} + +// normalizeSchemaFlagMigrations projects only an already-authorized CLI flag +// rename onto a cloned historical Schema contract. The ordinary compatibility +// checker still makes the final decision; this adapter never drops findings by +// matching their rendered text. +func normalizeSchemaFlagMigrations( + baseline schemaContract, + current schemaContract, + migrations []interfacesnapshot.FlagMigration, +) (schemaContract, error) { + normalized := cloneContract(baseline) + renamesByTool := map[schemaToolRef]map[string]string{} + + for _, migration := range migrations { + primaryPath := strings.TrimPrefix(migration.Command, "dws ") + matches := schemaToolsByPrimaryPath(baseline, primaryPath) + if len(matches) == 0 { + // A reviewed CLI-only command has no Schema compatibility surface. + continue + } + if len(matches) != 1 { + return schemaContract{}, fmt.Errorf( + "approved flag migration %q matches %d historical Schema tools", + migration.Command, + len(matches), + ) + } + + ref := matches[0] + oldTool := baseline.Products[ref.productID].Tools[ref.toolID] + newProduct, productExists := current.Products[ref.productID] + newTool, toolExists := newProduct.Tools[ref.toolID] + if !productExists || !toolExists || newTool.PrimaryCLIPath != primaryPath { + // Preserve the original baseline so the ordinary checker reports the + // missing tool or changed primary_cli_path. + continue + } + oldLegacy, legacyExisted := oldTool.Parameters[migration.Legacy.Name] + oldCanonical, canonicalExisted := oldTool.Parameters[migration.Canonical.Name] + if !legacyExisted && !canonicalExisted { + // A CLI migration is not evidence that an unrelated Schema surface + // published either name. In particular, it must not authorize a + // constraints rewrite with no historical parameter to rename. + continue + } + + if _, exists := newTool.Parameters[migration.Legacy.Name]; exists { + return schemaContract{}, fmt.Errorf( + "approved flag migration %q still publishes legacy Schema parameter %q", + migration.Command, + migration.Legacy.Name, + ) + } + newCanonical, exists := newTool.Parameters[migration.Canonical.Name] + if !exists { + return schemaContract{}, fmt.Errorf( + "approved flag migration %q does not publish canonical Schema parameter %q", + migration.Command, + migration.Canonical.Name, + ) + } + if migration.Canonical.After.Required && (!newCanonical.Required || !newCanonical.CLIRequired) { + return schemaContract{}, fmt.Errorf( + "approved flag migration %q canonical Schema parameter %q must be required and cli_required", + migration.Command, + migration.Canonical.Name, + ) + } + if !legacyExisted { + // A consumed receipt may be evaluated after the merge-base Schema has + // already reached the canonical-only state. In that case the ordinary + // checker needs no projection. More importantly, a CLI migration is not + // authority to promote a canonical-only historical Schema parameter from + // optional to required: without legacy Schema evidence there is no rename. + continue + } + + normalizedProduct := normalized.Products[ref.productID] + normalizedTool := normalizedProduct.Tools[ref.toolID] + if err := validateRenamedSchemaParameter(migration, oldLegacy, newCanonical); err != nil { + return schemaContract{}, err + } + delete(normalizedTool.Parameters, migration.Legacy.Name) + if canonicalExisted { + if err := validateRenamedSchemaParameter(migration, oldCanonical, newCanonical); err != nil { + return schemaContract{}, err + } + normalizedCanonical := normalizedTool.Parameters[migration.Canonical.Name] + normalizedCanonical.Required = newCanonical.Required + normalizedCanonical.CLIRequired = newCanonical.CLIRequired + normalizedTool.Parameters[migration.Canonical.Name] = normalizedCanonical + } else { + normalizedTool.Parameters[migration.Canonical.Name] = newCanonical + } + normalizedProduct.Tools[ref.toolID] = normalizedTool + normalized.Products[ref.productID] = normalizedProduct + + if renamesByTool[ref] == nil { + renamesByTool[ref] = map[string]string{} + } + if existing, ok := renamesByTool[ref][migration.Legacy.Name]; ok && existing != migration.Canonical.Name { + return schemaContract{}, fmt.Errorf( + "approved flag migration %q maps Schema parameter %q to both %q and %q", + migration.Command, + migration.Legacy.Name, + existing, + migration.Canonical.Name, + ) + } + renamesByTool[ref][migration.Legacy.Name] = migration.Canonical.Name + } + + for ref, renames := range renamesByTool { + oldTool := baseline.Products[ref.productID].Tools[ref.toolID] + newTool := current.Products[ref.productID].Tools[ref.toolID] + if oldTool.Constraints == newTool.Constraints { + continue + } + oldConstraints, oldOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames) + newConstraints, newOK := canonicalizeMigratedConstraints(newTool.Constraints, nil) + if !oldOK || !newOK || oldConstraints != newConstraints { + // Keep the historical constraints unchanged. The ordinary checker + // will report every non-rename constraint change. + continue + } + product := normalized.Products[ref.productID] + tool := product.Tools[ref.toolID] + tool.Constraints = newTool.Constraints + product.Tools[ref.toolID] = tool + normalized.Products[ref.productID] = product + } + + return normalized, nil +} + +func schemaToolsByPrimaryPath(contract schemaContract, primaryPath string) []schemaToolRef { + var matches []schemaToolRef + for productID, product := range contract.Products { + for toolID, tool := range product.Tools { + if tool.PrimaryCLIPath == primaryPath { + matches = append(matches, schemaToolRef{productID: productID, toolID: toolID}) + } + } + } + sort.Slice(matches, func(i, j int) bool { + if matches[i].productID != matches[j].productID { + return matches[i].productID < matches[j].productID + } + return matches[i].toolID < matches[j].toolID + }) + return matches +} + +func validateRenamedSchemaParameter( + migration interfacesnapshot.FlagMigration, + oldParameter parameterSchema, + newParameter parameterSchema, +) error { + if oldParameter.Type != newParameter.Type || + oldParameter.Property != newParameter.Property || + oldParameter.InterfaceType != newParameter.InterfaceType || + oldParameter.RequiredWhen != newParameter.RequiredWhen || + oldParameter.Default != newParameter.Default || + oldParameter.InterfaceDefault != newParameter.InterfaceDefault || + oldParameter.Format != newParameter.Format || + !equalStringSlices(oldParameter.Enum, newParameter.Enum) { + return fmt.Errorf( + "approved flag migration %q Schema parameter %q -> %q changed a non-migration field", + migration.Command, + migration.Legacy.Name, + migration.Canonical.Name, + ) + } + if oldParameter.Required && !newParameter.Required { + return fmt.Errorf( + "approved flag migration %q Schema parameter %q -> %q became optional", + migration.Command, + migration.Legacy.Name, + migration.Canonical.Name, + ) + } + if oldParameter.CLIRequired && !newParameter.CLIRequired { + return fmt.Errorf( + "approved flag migration %q Schema parameter %q -> %q stopped being cli_required", + migration.Command, + migration.Legacy.Name, + migration.Canonical.Name, + ) + } + return nil +} + +func equalStringSlices(left, right []string) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + +func canonicalizeMigratedConstraints(raw string, renames map[string]string) (string, bool) { + groups, ok := parseMigrationConstraintsStrict(raw) + if !ok { + return "", false + } + normalized := map[string][][]string{} + for _, kind := range []string{"mutually_exclusive", "require_one_of", "require_together"} { + for _, group := range groups[kind] { + members := make([]string, 0, len(group)) + for _, member := range group { + if replacement := renames[member]; replacement != "" { + member = replacement + } + members = append(members, member) + } + sort.Strings(members) + members = compactConstraintStrings(members) + normalized[kind] = append(normalized[kind], members) + } + sort.Slice(normalized[kind], func(i, j int) bool { + return strings.Join(normalized[kind][i], "\x00") < strings.Join(normalized[kind][j], "\x00") + }) + normalized[kind] = compactConstraintGroups(normalized[kind]) + if len(normalized[kind]) == 0 { + delete(normalized, kind) + } + } + if len(normalized) == 0 { + return "", true + } + encoded, err := json.Marshal(normalized) + return string(encoded), err == nil +} + +func parseMigrationConstraintsStrict(raw string) (map[string][][]string, bool) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return map[string][][]string{}, true + } + decoder := json.NewDecoder(strings.NewReader(trimmed)) + opening, err := decoder.Token() + if err != nil || opening != json.Delim('{') { + return nil, false + } + groups := map[string][][]string{} + for decoder.More() { + fieldToken, err := decoder.Token() + if err != nil { + return nil, false + } + // encoding/json guarantees that an object member name is a string token. + field := fieldToken.(string) + switch field { + case "mutually_exclusive", "require_one_of", "require_together": + default: + return nil, false + } + if _, duplicate := groups[field]; duplicate { + return nil, false + } + var encodedGroups json.RawMessage + if err := decoder.Decode(&encodedGroups); err != nil { + return nil, false + } + encodedGroups = bytes.TrimSpace(encodedGroups) + if len(encodedGroups) == 0 || encodedGroups[0] != '[' { + return nil, false + } + var fieldGroups [][]string + if err := json.Unmarshal(encodedGroups, &fieldGroups); err != nil { + return nil, false + } + for _, group := range fieldGroups { + if len(group) == 0 { + return nil, false + } + for _, member := range group { + if member == "" || member != strings.TrimSpace(member) { + return nil, false + } + } + } + groups[field] = fieldGroups + } + closing, err := decoder.Token() + if err != nil || closing != json.Delim('}') { + return nil, false + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return nil, false + } + return groups, true +} + +func compactConstraintStrings(values []string) []string { + if len(values) < 2 { + return values + } + out := values[:1] + for _, value := range values[1:] { + if value != out[len(out)-1] { + out = append(out, value) + } + } + return out +} + +func compactConstraintGroups(groups [][]string) [][]string { + if len(groups) < 2 { + return groups + } + out := groups[:1] + for _, group := range groups[1:] { + if !equalStringSlices(group, out[len(out)-1]) { + out = append(out, group) + } + } + return out +} + func writeContract(w io.Writer, contract schemaContract) error { contract.Version = schemaContractVersion if contract.Products == nil { diff --git a/scripts/policy/schema-compat/main_test.go b/scripts/policy/schema-compat/main_test.go index 2d10fa53..ddefda2e 100644 --- a/scripts/policy/schema-compat/main_test.go +++ b/scripts/policy/schema-compat/main_test.go @@ -9,8 +9,12 @@ import ( "errors" "os" "path/filepath" + "reflect" + "sort" "strings" "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot" ) const completeSchemaJSON = `{ @@ -931,3 +935,804 @@ func TestCrossPlatformCoverageSchemaCompatInterfaceRefRedirect(t *testing.T) { } }) } + +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("canonical required transition was not normalized: %#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) + } + }) + } +} + +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") + } + + canonicalOnly := schemaFlagMigrationAuthorizations()[0] + canonicalOnly.Legacy.Name = "legacy-not-published-in-schema" + driftedCanonical := cloneContract(current) + 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(baseline, driftedCanonical, []interfacesnapshot.FlagMigration{canonicalOnly}) + 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) + } + + canonicalOptional := schemaFlagMigrationContract(true) + product = canonicalOptional.Products["chat"] + tool = product.Tools["chat.edit_message"] + canonical = tool.Parameters["conversation-id"] + canonical.Required = false + canonical.CLIRequired = false + tool.Parameters["conversation-id"] = canonical + product.Tools["chat.edit_message"] = tool + canonicalOptional.Products["chat"] = product + normalized, err = normalizeSchemaFlagMigrations( + canonicalOptional, + schemaFlagMigrationContract(true), + []interfacesnapshot.FlagMigration{canonicalOnly}, + ) + if err != nil { + t.Fatal(err) + } + if failures := strings.Join(checkCompatibility(normalized, schemaFlagMigrationContract(true)), "\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(), "became optional") { + t.Fatalf("direct required decline error = %v", err) + } + if err := validateRenamedSchemaParameter(schemaFlagMigrationAuthorizations()[0], old, parameterSchema{Required: true}); err == nil || !strings.Contains(err.Error(), "stopped being cli_required") { + t.Fatalf("direct cli_required decline 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 may receive + // a required promotion, but that 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(), "all five") { + 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{ + "conversation-id": conversation, + "unrelated": {Type: `"string"`, Property: "unrelated"}, + } + constraints := `{"require_one_of":[["conversation-id","group","id"]]}` + if after { + conversation.Required = true + conversation.CLIRequired = true + 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 + conversationAfter.Required = true + 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: conversationBefore, + 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: conversationBefore, + 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) + } +} diff --git a/scripts/policy/schemacompat/authority_integration_test.go b/scripts/policy/schemacompat/authority_integration_test.go new file mode 100644 index 00000000..b2b4f4d5 --- /dev/null +++ b/scripts/policy/schemacompat/authority_integration_test.go @@ -0,0 +1,568 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package schemacompat + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +const governedSchemaCheckerSource = `package main + +import ( + "flag" + "fmt" + "os" + "strings" +) + +func main() { + normalize := flag.String("normalize", "", "raw schema") + check := flag.String("check", "", "base schema") + current := flag.String("current", "", "candidate schema") + approved := flag.String("approved-flag-migrations", "", "base ledger") + candidate := flag.String("candidate-flag-migrations", "", "candidate ledger") + currentSnapshot := flag.String("migration-current-snapshot", "", "candidate interface snapshot") + baseSnapshot := flag.String("migration-base-snapshot", "", "base interface snapshot") + stableSnapshot := flag.String("migration-stable-snapshot", "", "stable interface snapshot") + flag.Parse() + + if *normalize != "" { + data, err := os.ReadFile(*normalize) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + fmt.Print(string(data)) + return + } + if *check == "" || *current == "" { + fmt.Fprintln(os.Stderr, "missing check inputs") + os.Exit(2) + } + currentData, err := os.ReadFile(*current) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + if !strings.Contains(string(currentData), "COMMITTED_CANDIDATE") { + fmt.Fprintf(os.Stderr, "candidate schema did not come from detached commit: %s\n", currentData) + os.Exit(2) + } + paths := []string{*approved, *candidate, *currentSnapshot, *baseSnapshot, *stableSnapshot} + for _, path := range paths { + if path == "" { + fmt.Fprintln(os.Stderr, "governed checker did not receive all migration inputs") + os.Exit(2) + } + } + for _, fixture := range []struct { + path string + want string + }{ + {*approved, "{\"version\":1,\"migrations\":[{\"authority\":\"BASE\"}]}\n"}, + {*candidate, "{\"version\":1,\"migrations\":[{\"candidate\":\"COMMITTED\"}]}\n"}, + {*currentSnapshot, "COMMITTED_CANDIDATE"}, + {*baseSnapshot, "BASE_AUTHORITY"}, + {*stableSnapshot, "STABLE_RELEASE"}, + } { + data, err := os.ReadFile(fixture.path) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + if !strings.Contains(string(data), fixture.want) { + fmt.Fprintf(os.Stderr, "migration input %s does not contain %q: %s\n", fixture.path, fixture.want, data) + os.Exit(2) + } + } + for _, path := range []string{*currentSnapshot, *baseSnapshot, *stableSnapshot} { + data, err := os.ReadFile(path) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + if !strings.Contains(string(data), "INTERFACE_HELPER=BASE") { + fmt.Fprintf(os.Stderr, "interface snapshot did not use base internal helper: %s\n", data) + os.Exit(2) + } + } + fmt.Fprintln(os.Stderr, "BASE_SCHEMA_CHECKER_ENFORCED") + os.Exit(23) +} +` + +const bootstrapSchemaCheckerSource = `package main + +import ( + "flag" + "fmt" + "os" + "strings" +) + +func main() { + normalize := flag.String("normalize", "", "raw schema") + check := flag.String("check", "", "base schema") + current := flag.String("current", "", "candidate schema") + flag.Parse() + if *normalize != "" { + data, err := os.ReadFile(*normalize) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + fmt.Print(string(data)) + return + } + if *check == "" || *current == "" { + fmt.Fprintln(os.Stderr, "missing check inputs") + os.Exit(2) + } + data, err := os.ReadFile(*current) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + if !strings.Contains(string(data), "COMMITTED_CANDIDATE") { + fmt.Fprintf(os.Stderr, "candidate schema did not come from detached commit: %s\n", data) + os.Exit(2) + } + fmt.Fprintln(os.Stderr, "OLD_BASE_SCHEMA_CHECKER_ENFORCED") + os.Exit(23) +} +` + +const authorityInterfaceHelperSource = `package main + +import ( + "flag" + "fmt" + "os" + + "example.com/dws-schema-compat-authority-test/internal/interfacesnapshot" +) + +func main() { + if len(os.Args) < 2 || os.Args[1] != "generate" { + fmt.Fprintln(os.Stderr, "expected generate") + os.Exit(2) + } + flags := flag.NewFlagSet("generate", flag.ExitOnError) + output := flags.String("output", "", "snapshot output") + _ = flags.Parse(os.Args[2:]) + marker, err := os.ReadFile("SNAPSHOT_MARKER") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + marker = append(marker, []byte("INTERFACE_HELPER="+interfacesnapshot.AuthorityMarker()+"\n")...) + if err := os.WriteFile(*output, marker, 0o600); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + fmt.Printf("BASE_INTERFACE_HELPER_GENERATE=%s\n", interfacesnapshot.AuthorityMarker()) +} +` + +const schemaGovernedSnapshotSource = `package interfacesnapshot + +import "example.com/dws-schema-compat-authority-test/internal/corecmd/runtimeannotate" + +var ( + _ = runtimeannotate.AnnotationFlagAliasOf + _ = runtimeannotate.AnnotationFlagAliasOrigin + _ = runtimeannotate.FlagAliasOriginCorecmdV1 +) + +func AuthorityMarker() string { return "BASE" } +` + +const schemaBaseSnapshotSource = `package interfacesnapshot + +func AuthorityMarker() string { return "BASE" } +` + +const schemaStableSnapshotSource = `package interfacesnapshot + +func AuthorityMarker() string { return "STABLE" } +` + +const schemaCandidateSnapshotSource = `package interfacesnapshot + +func AuthorityMarker() string { return "CANDIDATE" } +` + +const schemaAliasContractSource = `package runtimeannotate + +const ( + AnnotationFlagAliasOf = "dws.compat.alias_of" + AnnotationFlagAliasOrigin = "dws.compat.alias_origin" + FlagAliasOriginCorecmdV1 = "corecmd.flag_spec_aliases.v1" +) +` + +const candidateNoopSource = `package main + +import "fmt" + +func main() { + fmt.Println("CANDIDATE_NOOP") +} +` + +func schemaBinarySource(marker string) string { + return fmt.Sprintf(`package main + +import "fmt" + +func main() { + fmt.Println(%q) +} +`, `{"kind":"schema","marker":"`+marker+`"}`) +} + +type authorityScenario struct { + name string + baseGovernance string + candidateMutation string + want string + authorityMarker string +} + +func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testing.T) { + tests := []authorityScenario{ + { + name: "governed base rejects candidate checker and live workspace", + baseGovernance: "complete", + authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED", + }, + { + name: "bootstrap uses old base checker and committed canonical empty ledger", + baseGovernance: "none", + authorityMarker: "OLD_BASE_SCHEMA_CHECKER_ENFORCED", + }, + { + name: "partial base fails closed", + baseGovernance: "partial", + want: "incomplete Schema flag migration governance artifact set", + }, + { + name: "base ledger ancestor symlink fails closed", + baseGovernance: "ancestor-symlink", + want: "incomplete Schema flag migration governance artifact set", + }, + { + name: "governed candidate must preserve schema checker", + baseGovernance: "complete", + candidateMutation: "delete-checker", + want: "must preserve the complete Schema flag migration governance artifact set", + }, + { + name: "governed candidate must preserve migrations implementation", + baseGovernance: "complete", + candidateMutation: "delete-migrations", + want: "must preserve the complete Schema flag migration governance artifact set", + }, + { + name: "governed candidate ledger must be a regular file", + baseGovernance: "complete", + candidateMutation: "symlink-ledger", + want: "must preserve the complete Schema flag migration governance artifact set", + }, + { + name: "governed candidate helper parent must be a Git tree", + baseGovernance: "complete", + candidateMutation: "symlink-helper-parent", + want: "must preserve the complete Schema flag migration governance artifact set", + }, + { + name: "candidate cannot forge alias evidence token", + baseGovernance: "complete", + candidateMutation: "forge-alias-token", + want: "may not forge framework-owned flag alias evidence", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + runSchemaAuthorityCase(t, test) + }) + } +} + +func runSchemaAuthorityCase(t *testing.T, test authorityScenario) { + t.Helper() + repoRoot := schemaRepositoryRoot(t) + managedRoot := filepath.Join(repoRoot, ".worktrees", "schemacompat-authority-integration") + if err := os.MkdirAll(managedRoot, 0o755); err != nil { + t.Fatalf("create managed test root: %v", err) + } + fixtureRoot, err := os.MkdirTemp(managedRoot, fmt.Sprintf("case-%d-", time.Now().UnixNano())) + if err != nil { + t.Fatalf("create managed fixture repo: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(fixtureRoot); err != nil { + t.Errorf("remove managed fixture repo: %v", err) + } + }) + + schemaCopyFile(t, + filepath.Join(repoRoot, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), + filepath.Join(fixtureRoot, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), + 0o755, + ) + schemaCopyFile(t, + filepath.Join(repoRoot, "scripts", "policy", "policy-runtime.sh"), + filepath.Join(fixtureRoot, "scripts", "policy", "policy-runtime.sh"), + 0o755, + ) + schemaCopyFile(t, + filepath.Join(repoRoot, "scripts", "release", "release-lib.sh"), + filepath.Join(fixtureRoot, "scripts", "release", "release-lib.sh"), + 0o755, + ) + schemaWriteFile(t, filepath.Join(fixtureRoot, "go.mod"), "module example.com/dws-schema-compat-authority-test\n\ngo 1.23\n", 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "main.go"), schemaBinarySource("STABLE_RELEASE"), 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "interface-snapshot", "main.go"), candidateNoopSource, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), schemaStableSnapshotSource, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "compare.go"), "package interfacesnapshot\n", 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), bootstrapSchemaCheckerSource, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "SNAPSHOT_MARKER"), "STABLE_RELEASE\n", 0o644) + + schemaRun(t, fixtureRoot, "git", "init", "-b", "main") + schemaRun(t, fixtureRoot, "git", "config", "user.name", "Schema Compatibility Test") + schemaRun(t, fixtureRoot, "git", "config", "user.email", "schema-compat-test@example.invalid") + schemaRun(t, fixtureRoot, "git", "add", ".") + schemaRun(t, fixtureRoot, "git", "commit", "-m", "stable release") + stableCommit := strings.TrimSpace(schemaRun(t, fixtureRoot, "git", "rev-parse", "HEAD")) + schemaRun(t, fixtureRoot, "git", "tag", "v1.0.0", stableCommit) + + schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "main.go"), schemaBinarySource("BASE_AUTHORITY"), 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "interface-snapshot", "main.go"), authorityInterfaceHelperSource, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "SNAPSHOT_MARKER"), "BASE_AUTHORITY\n", 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), schemaBaseSnapshotSource, 0o644) + switch test.baseGovernance { + case "complete": + schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), governedSchemaCheckerSource, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), schemaGovernedSnapshotSource, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go"), "package interfacesnapshot\n", 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go"), schemaAliasContractSource, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"), "{\"version\":1,\"migrations\":[{\"authority\":\"BASE\"}]}\n", 0o644) + case "partial": + schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"), "{\"version\":1,\"migrations\":[]}\n", 0o644) + case "ancestor-symlink": + schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), governedSchemaCheckerSource, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go"), "package interfacesnapshot\n", 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go"), "package runtimeannotate\n", 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "authority-ledger", "approved-flag-migrations-v1.json"), "{\"version\":1,\"migrations\":[]}\n", 0o644) + if err := os.Symlink("authority-ledger", filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations")); err != nil { + t.Fatalf("symlink base Schema ledger parent: %v", err) + } + case "none": + default: + t.Fatalf("unknown base governance state %q", test.baseGovernance) + } + schemaRun(t, fixtureRoot, "git", "add", ".") + schemaRun(t, fixtureRoot, "git", "commit", "-m", "base authority") + baseRef := strings.TrimSpace(schemaRun(t, fixtureRoot, "git", "rev-parse", "HEAD")) + schemaRun(t, fixtureRoot, "git", "tag", "v1.1.0", baseRef) + schemaRun(t, fixtureRoot, "git", "update-ref", "refs/tags/withdrawn/v1.1.0", baseRef) + if test.baseGovernance == "ancestor-symlink" { + ledgerPath := filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations") + if err := os.Remove(ledgerPath); err != nil { + t.Fatalf("remove inherited base ledger parent symlink: %v", err) + } + if err := os.Rename( + filepath.Join(fixtureRoot, "scripts", "policy", "authority-ledger"), + ledgerPath, + ); err != nil { + t.Fatalf("restore regular candidate ledger tree: %v", err) + } + } + + schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "main.go"), schemaBinarySource("COMMITTED_CANDIDATE"), 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "interface-snapshot", "main.go"), candidateNoopSource, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), candidateNoopSource, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), schemaCandidateSnapshotSource, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go"), "package interfacesnapshot\n", 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go"), "package runtimeannotate\n", 0o644) + candidateLedger := "{\"version\":1,\"migrations\":[]}\n" + if test.baseGovernance == "complete" { + candidateLedger = "{\"version\":1,\"migrations\":[{\"candidate\":\"COMMITTED\"}]}\n" + } + schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"), candidateLedger, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "SNAPSHOT_MARKER"), "COMMITTED_CANDIDATE\n", 0o644) + switch test.candidateMutation { + case "": + case "delete-checker": + if err := os.Remove(filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go")); err != nil { + t.Fatalf("remove candidate schema checker: %v", err) + } + case "delete-migrations": + if err := os.Remove(filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go")); err != nil { + t.Fatalf("remove candidate migrations implementation: %v", err) + } + case "symlink-ledger": + ledger := filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json") + if err := os.Remove(ledger); err != nil { + t.Fatalf("remove candidate ledger: %v", err) + } + if err := os.Symlink("../../../CANONICAL_EMPTY", ledger); err != nil { + t.Fatalf("symlink candidate ledger: %v", err) + } + schemaWriteFile(t, filepath.Join(fixtureRoot, "CANONICAL_EMPTY"), "{\"version\":1,\"migrations\":[]}\n", 0o644) + case "forge-alias-token": + schemaWriteFile(t, + filepath.Join(fixtureRoot, "internal", "evil", "forged.go"), + "package evil\n\nconst forged = \"dws.compat.alias_of\"\n", + 0o644, + ) + case "symlink-helper-parent": + helperPath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot") + alternatePath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot-alt") + if err := os.Rename(helperPath, alternatePath); err != nil { + t.Fatalf("move candidate Schema helper behind ancestor symlink: %v", err) + } + if err := os.Symlink("interfacesnapshot-alt", helperPath); err != nil { + t.Fatalf("symlink candidate Schema helper parent: %v", err) + } + default: + t.Fatalf("unknown candidate mutation %q", test.candidateMutation) + } + schemaRun(t, fixtureRoot, "git", "add", ".") + schemaRun(t, fixtureRoot, "git", "commit", "-m", "candidate revision") + candidateRef := strings.TrimSpace(schemaRun(t, fixtureRoot, "git", "rev-parse", "HEAD")) + + // 让调用者工作区前进到另一提交,并放入可自批的二进制与 ledger;显式 + // candidate ref 必须隔离这些内容。 + schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "main.go"), schemaBinarySource("LIVE_WORKTREE_BINARY"), 0o644) + liveLedger := filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json") + if err := os.Remove(liveLedger); err != nil && !os.IsNotExist(err) { + t.Fatalf("remove selected candidate ledger from live revision: %v", err) + } + schemaWriteFile(t, liveLedger, "{\"version\":1,\"migrations\":[{\"live\":true}]}\n", 0o644) + schemaRun(t, fixtureRoot, "git", "add", ".") + schemaRun(t, fixtureRoot, "git", "commit", "-m", "unselected live revision") + schemaWriteFile(t, filepath.Join(fixtureRoot, "dws"), "#!/bin/sh\nprintf '%s\\n' 'LIVE_WORKTREE_BINARY'\n", 0o755) + + cmd := exec.Command( + "sh", + "scripts/policy/check-authoritative-schema-compatibility.sh", + "--base-ref", baseRef, + "--stable-ref", "v1.0.0", + "--candidate-ref", candidateRef, + ) + cmd.Dir = fixtureRoot + cmd.Env = schemaIntegrationEnvironment(fixtureRoot) + output, runErr := cmd.CombinedOutput() + got := string(output) + if runErr == nil { + t.Fatalf("Schema compatibility script unexpectedly succeeded; output:\n%s", got) + } + for _, forbidden := range []string{"CANDIDATE_NOOP", "LIVE_WORKTREE_BINARY"} { + if strings.Contains(got, forbidden) { + t.Fatalf("candidate-controlled authority %q took effect; output:\n%s", forbidden, got) + } + } + if test.authorityMarker != "" { + if !strings.Contains(got, test.authorityMarker) { + t.Fatalf("Schema compatibility script did not use base authority %q; output:\n%s", test.authorityMarker, got) + } + if test.baseGovernance == "complete" && strings.Count(got, "BASE_INTERFACE_HELPER_GENERATE=BASE") != 3 { + t.Fatalf("governed Schema check did not generate three base-owned interface snapshots; output:\n%s", got) + } + + wrongStable := exec.Command( + "sh", + "scripts/policy/check-authoritative-schema-compatibility.sh", + "--base-ref", baseRef, + "--stable-ref", "HEAD", + "--candidate-ref", candidateRef, + ) + wrongStable.Dir = fixtureRoot + wrongStable.Env = schemaIntegrationEnvironment(fixtureRoot) + wrongOutput, wrongErr := wrongStable.CombinedOutput() + if wrongErr == nil || !strings.Contains(string(wrongOutput), "is not the expected highest GA tag") { + t.Fatalf("arbitrary Schema stable ref was not rejected: err=%v output:\n%s", wrongErr, wrongOutput) + } + return + } + if !strings.Contains(got, test.want) { + t.Fatalf("Schema compatibility script output missing %q:\n%s", test.want, got) + } +} + +func schemaRepositoryRoot(t *testing.T) string { + t.Helper() + _, sourceFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve test source path") + } + root := filepath.Clean(filepath.Join(filepath.Dir(sourceFile), "..", "..", "..")) + if _, err := os.Stat(filepath.Join(root, "scripts", "policy", "check-authoritative-schema-compatibility.sh")); err != nil { + t.Fatalf("resolve repository root %s: %v", root, err) + } + return root +} + +func schemaCopyFile(t *testing.T, source, destination string, mode os.FileMode) { + t.Helper() + content, err := os.ReadFile(source) + if err != nil { + t.Fatalf("read %s: %v", source, err) + } + schemaWriteFile(t, destination, string(content), mode) +} + +func schemaWriteFile(t *testing.T, path, content string, mode os.FileMode) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("create parent for %s: %v", path, err) + } + if err := os.WriteFile(path, []byte(content), mode); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func schemaRun(t *testing.T, dir, name string, args ...string) string { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s %s failed: %v\n%s", name, strings.Join(args, " "), err, output) + } + return string(output) +} + +func schemaIntegrationEnvironment(fixtureRoot string) []string { + overrides := map[string]string{ + "DWS_BIN": filepath.Join(fixtureRoot, "dws"), + "DWS_POLICY_TMPDIR": "", + "GOCACHE": filepath.Join(fixtureRoot, ".cache", "go-build"), + "GOMODCACHE": filepath.Join(fixtureRoot, ".cache", "go-mod"), + "GOTMPDIR": "", + "GOTOOLCHAIN": "local", + "GOWORK": "off", + } + environment := make([]string, 0, len(os.Environ())+len(overrides)) + for _, item := range os.Environ() { + key, _, _ := strings.Cut(item, "=") + if _, replaced := overrides[key]; !replaced { + environment = append(environment, item) + } + } + for key, value := range overrides { + environment = append(environment, key+"="+value) + } + return environment +} diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index 18d1d8d3..a69cf1a1 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -401,15 +401,151 @@ func TestInterfaceIntegrityWorkflowContract(t *testing.T) { } interfaceJob := workflow[interfaceStart:interfaceEnd] for _, want := range []string{ - "name: Check complete CLI command compatibility", - "./scripts/policy/check-command-compatibility.sh", - `--base-ref "$COMPATIBILITY_BASE_REF"`, - `--stable-ref "$COMPATIBILITY_STABLE_REF"`, + "name: Check historical commands, help, and complete CLI compatibility", + "make authoritative-interface-integrity", + `BASE_REF="$COMPATIBILITY_BASE_REF"`, + `STABLE_REF="$COMPATIBILITY_STABLE_REF"`, + `CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"`, } { if !strings.Contains(interfaceJob, want) { t.Errorf("Interface Integrity job missing release-equivalent compatibility contract %q", want) } } + for _, want := range []string{ + `candidate_ref="$(git rev-parse HEAD^{commit})"`, + `[ "$candidate_ref" != "$PR_HEAD_SHA" ]`, + `echo "COMPATIBILITY_BASE_REF=$base_ref" >> "$GITHUB_ENV"`, + `echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"`, + `echo "COMPATIBILITY_CANDIDATE_REF=$candidate_ref" >> "$GITHUB_ENV"`, + } { + if got := strings.Count(interfaceJob, want); got != 1 { + t.Errorf("Interface Integrity job contract %q count = %d, want exactly one definition", want, got) + } + } + if strings.Count(interfaceJob, "make authoritative-interface-integrity") != 1 { + t.Errorf("Interface Integrity job must have exactly one compatibility decision seam") + } + if strings.Contains(interfaceJob, "./scripts/policy/check-command-compatibility.sh") { + t.Error("Interface Integrity job must not bypass the authoritative Make seam") + } + if strings.Contains(interfaceJob, "check-interface-baseline.sh") { + t.Error("Interface Integrity job must not reintroduce the legacy fixture checker") + } + + schemaStart := strings.Index(interfaceJob, "\n - name: Check complete Schema compatibility\n") + if schemaStart < 0 { + t.Fatal("Interface Integrity job missing complete Schema compatibility step") + } + schemaRemainder := interfaceJob[schemaStart+1:] + schemaEnd := strings.Index(schemaRemainder, "\n - name:") + if schemaEnd < 0 { + t.Fatal("complete Schema compatibility step has no workflow boundary") + } + schemaStep := schemaRemainder[:schemaEnd] + for _, want := range []string{ + "make schema-compatibility", + `BASE_REF="$COMPATIBILITY_BASE_REF"`, + `STABLE_REF="$COMPATIBILITY_STABLE_REF"`, + `CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"`, + } { + if !strings.Contains(schemaStep, want) { + t.Errorf("Schema compatibility step missing authoritative contract %q", want) + } + } +} + +func TestLocalInterfaceIntegrityUsesAuthoritativeSeam(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + data, err := os.ReadFile(filepath.Join(root, "Makefile")) + if err != nil { + t.Fatalf("ReadFile(Makefile) error = %v", err) + } + makefile := string(data) + interfaceStart := strings.Index(makefile, "\ninterface-integrity:\n") + authoritativeStart := strings.Index(makefile, "\nauthoritative-interface-integrity:\n") + coverageStart := strings.Index(makefile, "\ncoverage-gate:\n") + if interfaceStart < 0 || authoritativeStart <= interfaceStart || coverageStart <= authoritativeStart { + t.Fatal("Makefile missing interface-integrity target boundaries") + } + interfaceTarget := makefile[interfaceStart:authoritativeStart] + for _, want := range []string{ + "./scripts/policy/check-authoritative-interface-baselines.sh", + `base_ref="origin/main"`, + `candidate_ref="HEAD"`, + `--base-ref "$$base_ref"`, + `--stable-ref "$(STABLE_REF)"`, + `--candidate-ref "$$candidate_ref"`, + } { + if !strings.Contains(interfaceTarget, want) { + t.Errorf("interface-integrity target missing authoritative contract %q", want) + } + } + if strings.Contains(interfaceTarget, "check-interface-baseline.sh") { + t.Error("interface-integrity target still invokes the legacy fixture checker") + } + authoritativeTarget := makefile[authoritativeStart:coverageStart] + for _, want := range []string{ + "./scripts/policy/check-authoritative-interface-baselines.sh", + `candidate_ref="HEAD"`, + `--base-ref "$(BASE_REF)"`, + `--stable-ref "$(STABLE_REF)"`, + `--candidate-ref "$$candidate_ref"`, + } { + if !strings.Contains(authoritativeTarget, want) { + t.Errorf("authoritative-interface-integrity target missing contract %q", want) + } + } + + if got := strings.Count(makefile, "./scripts/policy/check-interface-baseline.sh"); got != 2 { + t.Fatalf("legacy fixture helper invocation count = %d, want update/reset only", got) + } + for _, target := range []string{"update-interface-baseline", "reset-interface-baseline"} { + marker := "\n" + target + ":\n" + start := strings.Index(makefile, marker) + if start < 0 { + t.Fatalf("Makefile missing %s target", target) + } + end := strings.Index(makefile[start+len(marker):], "\n\n") + if end < 0 { + t.Fatalf("Makefile %s target has no boundary", target) + } + block := makefile[start : start+len(marker)+end] + if !strings.Contains(block, "./scripts/policy/check-interface-baseline.sh") { + t.Errorf("%s target must retain the legacy fixture helper", target) + } + } +} + +func TestLocalSchemaCompatibilityUsesAuthoritativeCandidateSeam(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + data, err := os.ReadFile(filepath.Join(root, "Makefile")) + if err != nil { + t.Fatalf("ReadFile(Makefile) error = %v", err) + } + makefile := string(data) + schemaStart := strings.Index(makefile, "\nschema-compatibility:\n") + skillStart := strings.Index(makefile, "\nskill-command-integrity:\n") + if schemaStart < 0 || skillStart <= schemaStart { + t.Fatal("Makefile missing schema-compatibility target boundaries") + } + schemaTarget := makefile[schemaStart:skillStart] + for _, want := range []string{ + "./scripts/policy/check-authoritative-schema-compatibility.sh", + `candidate_ref="HEAD"`, + `--base-ref "$(BASE_REF)"`, + `--stable-ref "$(STABLE_REF)"`, + `--candidate-ref "$$candidate_ref"`, + } { + if !strings.Contains(schemaTarget, want) { + t.Errorf("schema-compatibility target missing authoritative contract %q", want) + } + } } func TestInterfaceSensitiveClassificationCoversCoreCommandFramework(t *testing.T) { diff --git a/test/scripts/interface_flag_alias_policy_test.go b/test/scripts/interface_flag_alias_policy_test.go new file mode 100644 index 00000000..8af1c8d2 --- /dev/null +++ b/test/scripts/interface_flag_alias_policy_test.go @@ -0,0 +1,69 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package scripts_test + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFrameworkOwnsInterfaceFlagAliasEvidence(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + allowed := map[string]bool{ + "internal/corecmd/corecmd.go": true, + "internal/corecmd/runtimeannotate/interface_alias.go": true, + "internal/interfacesnapshot/snapshot.go": true, + } + protectedTokens := []string{ + "dws.compat.alias_of", + "dws.compat.alias_origin", + "corecmd.flag_spec_aliases.v1", + "AnnotationFlagAliasOf", + "AnnotationFlagAliasOrigin", + "FlagAliasOriginCorecmdV1", + } + + err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + switch entry.Name() { + case ".git", ".worktrees", "vendor": + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + relative = filepath.ToSlash(relative) + if allowed[relative] { + return nil + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + for _, token := range protectedTokens { + if strings.Contains(string(content), token) { + t.Errorf("production file %s uses framework-owned flag alias evidence token %q", relative, token) + } + } + return nil + }) + if err != nil { + t.Fatalf("scan production Go files: %v", err) + } +}