Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9850a2e49 | ||
|
|
8097943e3a | ||
|
|
d5a9a72fa6 | ||
|
|
b7918be6f3 | ||
|
|
b84b56d9f8 | ||
|
|
64e1dcc150 | ||
|
|
f3ecac1ad1 | ||
|
|
46ae1c50fe |
+28
-17
@@ -1370,36 +1370,47 @@ 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
|
||||
fi
|
||||
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"
|
||||
printf '%s\n' \
|
||||
"COMPATIBILITY_BASE_REF=$base_ref" \
|
||||
"COMPATIBILITY_STABLE_REF=$stable_ref" \
|
||||
"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') }}
|
||||
|
||||
+11
-3
@@ -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 <main-ref> --stable-ref <latest-GA-tag>`
|
||||
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=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`.
|
||||
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=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`;
|
||||
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
|
||||
|
||||
@@ -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=<ref> - Check the Git-owned PR merge-base\n"
|
||||
@printf " make interface-integrity [BASE_REF=<ref>] [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check authoritative CLI history\n"
|
||||
@printf " make authoritative-interface-integrity BASE_REF=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check Git-owned CLI history\n"
|
||||
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and 100%% changed-code coverage\n"
|
||||
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - 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=<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=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<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
|
||||
|
||||
@@ -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,14 @@ 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",
|
||||
)
|
||||
}
|
||||
if *approvedMigrationsPath != "" && (*basePath == "" || *stablePath == "") {
|
||||
return false, fmt.Errorf("flag migration compare requires both --base and --stable")
|
||||
}
|
||||
|
||||
current, err := readSnapshot(*currentPath)
|
||||
if err != nil {
|
||||
@@ -170,6 +197,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 +225,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 +280,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]")
|
||||
}
|
||||
|
||||
@@ -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,368 @@ 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 TestCrossPlatformCoverageRunCompareRequiresBothReferencesForFlagMigrations(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
|
||||
basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws"))
|
||||
stablePath := writeSnapshot(t, dir, "stable.json", commandSnapshot("dws"))
|
||||
approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`)
|
||||
candidatePath := writeManifest(t, dir, "candidate.json", `{"version":1,"migrations":[]}`)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "missing stable",
|
||||
args: []string{"--base", basePath},
|
||||
},
|
||||
{
|
||||
name: "missing base",
|
||||
args: []string{"--stable", stablePath},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
args := []string{"compare", "--current", currentPath}
|
||||
args = append(args, test.args...)
|
||||
args = append(args,
|
||||
"--approved-flag-migrations", approvedPath,
|
||||
"--candidate-flag-migrations", candidatePath,
|
||||
)
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run(args, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "requires both --base and --stable") {
|
||||
t.Fatalf("one-reference migration compare 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")
|
||||
for _, name := range []string{"TMPDIR", "TMP", "TEMP"} {
|
||||
t.Setenv(name, 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 temporary root 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,
|
||||
"--stable", 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 +505,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 {
|
||||
|
||||
+39
-8
@@ -157,15 +157,33 @@ expected to repeat every CI job locally:
|
||||
```sh
|
||||
make build
|
||||
make policy
|
||||
make interface-integrity
|
||||
make authoritative-interface-integrity BASE_REF=<merge-base>
|
||||
make schema-compatibility BASE_REF=<merge-base>
|
||||
make interface-integrity BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
|
||||
make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
|
||||
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。merge-base-owned checker 分别规范化 merge-base 与 stable 的完整
|
||||
Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 Interface
|
||||
lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会
|
||||
放宽其他 Schema 历史字段。
|
||||
|
||||
For an exact CHANGELOG-only branch:
|
||||
|
||||
```sh
|
||||
@@ -186,11 +204,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
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# CLI flag 兼容迁移治理
|
||||
|
||||
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 提升为必填。它只解决这一种精确变更,不是通用 breaking-change 豁免。
|
||||
|
||||
同名 flag 的精确类型迁移属于另一类评审机制,只能进入
|
||||
`internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename
|
||||
只能进入本文的 JSON lifecycle ledger。一项迁移不得跨两种机制组合授权。
|
||||
|
||||
## 唯一比较入口与信任边界
|
||||
|
||||
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。merge-base-owned checker 会分别规范化 merge-base 与 stable 的完整
|
||||
Schema,并让 candidate 对两份历史 contract 独立执行检查;授权的 flag rename 只会
|
||||
精确投影到当前被检查的历史副本。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,不参与迁移审批。
|
||||
|
||||
直接调用 `interface-snapshot compare` 时,只要提供 migration manifest 参数,就必须
|
||||
同时提供 `--base` 与 `--stable`;核心 lifecycle 也拒绝缺失 stable 的非空清单,避免
|
||||
调用方因漏传历史参考而提前清理 consumed receipt。
|
||||
|
||||
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=<merge-base> \
|
||||
STABLE_REF=<stable-tag> \
|
||||
CANDIDATE_REF=<candidate-sha>
|
||||
|
||||
make schema-compatibility \
|
||||
BASE_REF=<merge-base> \
|
||||
STABLE_REF=<stable-tag> \
|
||||
CANDIDATE_REF=<candidate-sha>
|
||||
```
|
||||
|
||||
`STABLE_REF` 必须解析到从该 merge-base 可达的最高未撤回 stable GA tag;primary checker 会按 release contract 独立核对,不能用任意 after commit 或已撤回版本提前清理回执。包装脚本可以在省略时自动解析。`CANDIDATE_REF` 省略时固定为命令启动时的已提交 `HEAD`;评审和复现 CI 时应显式传入 candidate SHA,避免 surface 与清单来自不同 revision。
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -229,6 +229,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",
|
||||
|
||||
@@ -251,6 +251,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)),
|
||||
@@ -314,6 +348,58 @@ 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 TestCrossPlatformCoverageCompareAllWithFlagMigrationsComposesWithReviewedTypeChange(t *testing.T) {
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
|
||||
|
||||
before := testFlagMigrationSnapshot(false, true)
|
||||
before.Commands = append(before.Commands, testCommand("dws search", testFlag("query", "string", false)))
|
||||
after := testFlagMigrationSnapshot(true, true)
|
||||
after.Commands = append(after.Commands, testCommand("dws search", testFlag("query", "int", false)))
|
||||
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{
|
||||
"merge-base": before,
|
||||
"stable": before,
|
||||
},
|
||||
testFlagMigrationManifest(FlagMigrationPending),
|
||||
testFlagMigrationManifest(FlagMigrationConsumed),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("composed reviewed migrations were rejected: %v", err)
|
||||
}
|
||||
if !report.Compatible {
|
||||
t.Fatalf("composed reviewed migrations remained incompatible: %#v", report.Comparisons)
|
||||
}
|
||||
}
|
||||
|
||||
// registerReviewedFixture puts a fixture command in the reviewed table for the
|
||||
// duration of one test, so the behaviour tests exercise the real lookup instead
|
||||
// of depending on whichever production entries happen to exist.
|
||||
@@ -426,6 +512,12 @@ func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeRejectsBundledRegress
|
||||
next: Flag{Name: "query", Type: "int", Hidden: true},
|
||||
kind: "flag_became_hidden",
|
||||
},
|
||||
{
|
||||
name: "changed alias target",
|
||||
base: Flag{Name: "query", Type: "string", AliasOf: "old-query"},
|
||||
next: Flag{Name: "query", Type: "int", AliasOf: "new-query"},
|
||||
kind: "flag_alias_target_changed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
@@ -501,8 +593,8 @@ var reviewedEntryPattern = regexp.MustCompile(
|
||||
|
||||
const interfaceBaselineReviewedPath = "../../scripts/policy/interface-baseline/reviewed.go"
|
||||
|
||||
// The two copies of the table must stay identical, otherwise a migration passes
|
||||
// one gate and is blocked by the other in the same CI job.
|
||||
// The modern authority and legacy smoke helper keep mirrored tables so local
|
||||
// smoke checks cannot disagree with the authoritative comparison.
|
||||
func TestCrossPlatformCoverageReviewedFlagTypeTableMatchesInterfaceBaseline(t *testing.T) {
|
||||
source, err := os.ReadFile(interfaceBaselineReviewedPath)
|
||||
if err != nil {
|
||||
@@ -533,6 +625,234 @@ func TestCrossPlatformCoverageReviewedFlagTypeTableMatchesInterfaceBaseline(t *t
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -571,6 +891,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 {
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
// 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. A non-empty lifecycle requires both a
|
||||
// main/merge-base authority reference and a stable reference. 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
|
||||
}
|
||||
if _, ok := references["stable"]; !ok {
|
||||
return nil, fmt.Errorf("flag migration lifecycle requires a stable reference")
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,966 @@
|
||||
// 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, "stable": 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, "stable": 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(), "stable": before},
|
||||
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, "stable": 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, "stable": 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, "stable": 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, "stable": before},
|
||||
authority: pending,
|
||||
candidate: pending,
|
||||
wantErr: "without marking it consumed",
|
||||
},
|
||||
{
|
||||
name: "candidate rejects partial application",
|
||||
current: partial,
|
||||
references: map[string]Snapshot{"merge-base": before, "stable": 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, "stable": 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, "stable": 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, "stable": 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, "stable": 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, "stable": 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, "stable": before},
|
||||
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, "stable": before},
|
||||
empty,
|
||||
pending,
|
||||
)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("pending receipt creation = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresStableForReceiptCleanup(t *testing.T) {
|
||||
consumed := coverageManifest(FlagMigrationConsumed)
|
||||
empty := coverageEmptyManifest()
|
||||
after := coverageMigrationSnapshot(consumed.Migrations[0], true, false)
|
||||
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": after},
|
||||
consumed,
|
||||
empty,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires a stable reference") {
|
||||
t.Fatalf("receipt cleanup without stable = (%#v, %v), want stable-reference error", 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, "stable": 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, "stable": 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, "stable": 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, "stable": 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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -117,5 +117,8 @@ func flagContractOtherwiseChanged(oldFlag, newFlag Flag) bool {
|
||||
if !oldFlag.Hidden && newFlag.Hidden {
|
||||
return true
|
||||
}
|
||||
if oldFlag.AliasOf != "" && newFlag.AliasOf != oldFlag.AliasOf {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
+1
-1
@@ -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 <main-ref> --stable-ref <latest-GA-tag>`, `./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 <main-ref> --stable-ref <latest-GA-tag> --candidate-ref <candidate-sha>`, `./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`
|
||||
|
||||
@@ -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 <ref>" >&2
|
||||
printf '%s\n' "usage: $0 --base-ref <ref> [--stable-ref <ref>] [--candidate-ref <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"
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
#!/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 independently with
|
||||
# the Git-owned PR merge-base (or previous main SHA on push) and the latest
|
||||
# reachable stable GA. 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 <ref>" >&2
|
||||
printf '%s\n' "usage: $0 --base-ref <ref> [--stable-ref <ref>] [--candidate-ref <ref>]" >&2
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
@@ -20,6 +27,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 +53,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 +116,237 @@ 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 <<EOF
|
||||
$matches
|
||||
EOF
|
||||
done
|
||||
if [ "$source_policy_failed" = true ]; then
|
||||
exit 2
|
||||
fi
|
||||
}
|
||||
|
||||
if ! commit_path_is_regular_file "$BASE_COMMIT" "$SCHEMA_CHECKER_REL"; then
|
||||
printf 'error: merge-base lacks the base-owned Schema checker: %s\n' "$BASE_REF" >&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"
|
||||
STABLE_BIN="$TMP_ROOT/stable-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"
|
||||
STABLE_RAW="$TMP_ROOT/stable-schema.json"
|
||||
STABLE_BASELINE="$TMP_ROOT/stable-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 "$STABLE_WORKTREE"
|
||||
go build -o "$STABLE_BIN" ./cmd
|
||||
)
|
||||
(
|
||||
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"
|
||||
mkdir -p "$TMP_ROOT/base-home" "$TMP_ROOT/stable-home" "$TMP_ROOT/candidate-home"
|
||||
HOME="$TMP_ROOT/base-home" DWS_LANG=zh \
|
||||
"$BASE_BIN" schema --all --format json >"$BASE_RAW"
|
||||
HOME="$TMP_ROOT/stable-home" DWS_LANG=zh \
|
||||
"$STABLE_BIN" schema --all --format json >"$STABLE_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"
|
||||
"$CHECKER" --normalize "$STABLE_RAW" >"$STABLE_BASELINE"
|
||||
|
||||
check_schema_contract() {
|
||||
historical_kind="$1"
|
||||
historical_ref="$2"
|
||||
historical_baseline="$3"
|
||||
shift 3
|
||||
printf 'checking complete Schema contract against %s %s\n' \
|
||||
"$historical_kind" "$historical_ref"
|
||||
"$CHECKER" --check "$historical_baseline" --current "$CANDIDATE_RAW" "$@"
|
||||
}
|
||||
|
||||
if [ "$USE_MIGRATION_GOVERNANCE" = false ]; then
|
||||
check_schema_contract "PR merge-base" "$BASE_REF" "$BASELINE"
|
||||
check_schema_contract "stable" "$STABLE_REF" "$STABLE_BASELINE"
|
||||
exit 0
|
||||
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"
|
||||
|
||||
check_schema_contract "PR merge-base" "$BASE_REF" "$BASELINE" \
|
||||
--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"
|
||||
|
||||
check_schema_contract "stable" "$STABLE_REF" "$STABLE_BASELINE" \
|
||||
--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"
|
||||
|
||||
@@ -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 <ref> --stable-ref <ref>" >&2
|
||||
printf '%s\n' "usage: $0 --base-ref <ref> --stable-ref <ref> [--candidate-ref <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 <<EOF
|
||||
$matches
|
||||
EOF
|
||||
done
|
||||
if [ "$source_policy_failed" = true ]; then
|
||||
exit 2
|
||||
fi
|
||||
}
|
||||
|
||||
install_authority_helper() {
|
||||
source_root="$1"
|
||||
worktree="$2"
|
||||
include_alias_contract="$3"
|
||||
|
||||
[ "$source_root" = "$worktree" ] && return
|
||||
for helper_path in cmd/interface-snapshot internal/interfacesnapshot; do
|
||||
rm -rf "$worktree/$helper_path"
|
||||
mkdir -p "$worktree/$helper_path"
|
||||
cp -R "$source_root/$helper_path/." "$worktree/$helper_path/"
|
||||
done
|
||||
if [ "$include_alias_contract" = true ]; then
|
||||
mkdir -p "$worktree/$(dirname "$ALIAS_CONTRACT_REL")"
|
||||
rm -f "$worktree/$ALIAS_CONTRACT_REL"
|
||||
cp "$source_root/$ALIAS_CONTRACT_REL" "$worktree/$ALIAS_CONTRACT_REL"
|
||||
fi
|
||||
}
|
||||
|
||||
EMPTY_MANIFEST="$TMP_ROOT/empty-migrations.json"
|
||||
printf '%s\n' '{"version":1,"migrations":[]}' >"$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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"version":1,"migrations":[]}
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
|
||||
)
|
||||
|
||||
const schemaContractVersion = 3
|
||||
@@ -83,12 +85,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
|
||||
}
|
||||
@@ -103,6 +112,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
|
||||
@@ -129,6 +159,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:")
|
||||
@@ -160,6 +208,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 {
|
||||
@@ -1052,6 +1151,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 {
|
||||
|
||||
@@ -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 = `{
|
||||
@@ -1195,3 +1199,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
// 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)
|
||||
}
|
||||
baselineData, err := os.ReadFile(*check)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(string(baselineData), "BASE_AUTHORITY"):
|
||||
fmt.Fprintln(os.Stdout, "OLD_BASE_SCHEMA_CONTRACT_CHECKED")
|
||||
case strings.Contains(string(baselineData), "STABLE_RELEASE"):
|
||||
fmt.Fprintln(os.Stderr, "OLD_BASE_STABLE_SCHEMA_CHECKER_ENFORCED")
|
||||
os.Exit(23)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unexpected historical Schema contract: %s\n", baselineData)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const stableSchemaGuardCheckerSource = `package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
normalize := flag.String("normalize", "", "raw schema")
|
||||
check := flag.String("check", "", "historical 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)
|
||||
}
|
||||
for _, fixture := range []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{*approved, "\"authority\":\"BASE\""},
|
||||
{*candidate, "\"candidate\":\"COMMITTED\""},
|
||||
{*currentSnapshot, "COMMITTED_CANDIDATE"},
|
||||
{*baseSnapshot, "BASE_AUTHORITY"},
|
||||
{*stableSnapshot, "STABLE_RELEASE"},
|
||||
} {
|
||||
if fixture.path == "" {
|
||||
fmt.Fprintln(os.Stderr, "stable Schema guard did not receive all migration inputs")
|
||||
os.Exit(2)
|
||||
}
|
||||
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, "stable Schema guard input %s does not contain %q: %s\n", fixture.path, fixture.want, data)
|
||||
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)
|
||||
}
|
||||
baselineData, err := os.ReadFile(*check)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(string(baselineData), "BASE_AUTHORITY"):
|
||||
fmt.Fprintln(os.Stdout, "BASE_SCHEMA_CONTRACT_CHECKED")
|
||||
case strings.Contains(string(baselineData), "STABLE_RELEASE"):
|
||||
fmt.Fprintln(os.Stderr, "STABLE_SCHEMA_CONTRACT_ENFORCED")
|
||||
os.Exit(23)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unexpected historical Schema contract: %s\n", baselineData)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
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
|
||||
checkerMode 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: "governed checker protects stable Schema contract",
|
||||
baseGovernance: "complete",
|
||||
checkerMode: "stable-schema-guard",
|
||||
authorityMarker: "STABLE_SCHEMA_CONTRACT_ENFORCED",
|
||||
},
|
||||
{
|
||||
name: "bootstrap uses old base checker and committed canonical empty ledger",
|
||||
baseGovernance: "none",
|
||||
authorityMarker: "OLD_BASE_STABLE_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":
|
||||
checkerSource := governedSchemaCheckerSource
|
||||
if test.checkerMode == "stable-schema-guard" {
|
||||
checkerSource = stableSchemaGuardCheckerSource
|
||||
}
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), checkerSource, 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
|
||||
}
|
||||
@@ -401,15 +401,154 @@ 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" ]`,
|
||||
`"COMPATIBILITY_BASE_REF=$base_ref"`,
|
||||
`"COMPATIBILITY_STABLE_REF=$stable_ref"`,
|
||||
`"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 got := strings.Count(interfaceJob, `>> "$GITHUB_ENV"`); got != 1 {
|
||||
t.Errorf("Interface Integrity job must append its compatibility refs to GITHUB_ENV once, got %d writes", 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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user