ci: govern exact CLI flag migrations
This commit is contained in:
+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,22 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Compare the candidate's complete Schema contract with the Git-owned PR
|
||||
# merge-base (or previous main SHA on push). The candidate branch cannot
|
||||
# weaken this authority by editing a checked-in fixture.
|
||||
# Compare the committed candidate's complete Schema contract with the Git-owned
|
||||
# PR merge-base (or previous main SHA on push). The merge-base owns the Schema
|
||||
# checker and, after bootstrap, the approved flag-migration ledger. Candidate
|
||||
# code is lifecycle input only and cannot replace either authority.
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
BIN="${DWS_BIN:-$ROOT/dws}"
|
||||
BASE_REF=""
|
||||
STABLE_REF=""
|
||||
CANDIDATE_REF="HEAD"
|
||||
SCHEMA_CHECKER_REL="scripts/policy/schema-compat/main.go"
|
||||
MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-flag-migrations-v1.json"
|
||||
MIGRATIONS_REL="internal/interfacesnapshot/migrations.go"
|
||||
ALIAS_CONTRACT_REL="internal/corecmd/runtimeannotate/interface_alias.go"
|
||||
|
||||
usage() {
|
||||
printf '%s\n' "usage: $0 --base-ref <ref>" >&2
|
||||
printf '%s\n' "usage: $0 --base-ref <ref> [--stable-ref <ref>] [--candidate-ref <ref>]" >&2
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
@@ -20,6 +26,16 @@ while [ "$#" -gt 0 ]; do
|
||||
BASE_REF="$2"
|
||||
shift 2
|
||||
;;
|
||||
--stable-ref)
|
||||
[ "$#" -ge 2 ] || { usage; exit 2; }
|
||||
STABLE_REF="$2"
|
||||
shift 2
|
||||
;;
|
||||
--candidate-ref)
|
||||
[ "$#" -ge 2 ] || { usage; exit 2; }
|
||||
CANDIDATE_REF="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
@@ -36,27 +52,62 @@ done
|
||||
|
||||
cd "$ROOT"
|
||||
. "$ROOT/scripts/policy/policy-runtime.sh"
|
||||
. "$ROOT/scripts/release/release-lib.sh"
|
||||
policy_prepare_runtime "$ROOT"
|
||||
|
||||
git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || {
|
||||
BASE_COMMIT="$(git rev-parse --verify "${BASE_REF}^{commit}")" || {
|
||||
printf 'error: Schema authority is not available locally: %s\n' "$BASE_REF" >&2
|
||||
exit 2
|
||||
}
|
||||
if [ ! -x "$BIN" ]; then
|
||||
printf 'error: dws binary not found at %s (run make build first)\n' "$BIN" >&2
|
||||
CANDIDATE_COMMIT="$(git rev-parse --verify "${CANDIDATE_REF}^{commit}")" || {
|
||||
printf 'error: Schema candidate is not available locally: %s\n' "$CANDIDATE_REF" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
EXPECTED_STABLE_REF=""
|
||||
for tag in $(git tag --merged "$BASE_REF" --list 'v*' --sort=-version:refname); do
|
||||
release_is_stable_version "$tag" || continue
|
||||
if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then
|
||||
continue
|
||||
fi
|
||||
EXPECTED_STABLE_REF="$tag"
|
||||
break
|
||||
done
|
||||
if [ -z "$EXPECTED_STABLE_REF" ]; then
|
||||
printf 'error: no stable release tag is reachable from Schema authority %s\n' "$BASE_REF" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ -z "$STABLE_REF" ]; then
|
||||
STABLE_REF="$EXPECTED_STABLE_REF"
|
||||
fi
|
||||
STABLE_COMMIT="$(git rev-parse --verify "${STABLE_REF}^{commit}")" || {
|
||||
printf 'error: Schema stable authority is not available locally: %s\n' "$STABLE_REF" >&2
|
||||
exit 2
|
||||
}
|
||||
if [ "$(git rev-parse "${STABLE_REF}^{commit}")" != "$(git rev-parse "${EXPECTED_STABLE_REF}^{commit}")" ]; then
|
||||
printf 'error: stable ref %s is not the expected highest GA tag %s reachable from %s\n' \
|
||||
"$STABLE_REF" "$EXPECTED_STABLE_REF" "$BASE_REF" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
TMP_ROOT="$(policy_runtime_mktemp_dir dws-schema-authority)"
|
||||
BASE_WORKTREE="$TMP_ROOT/base-worktree"
|
||||
BASE_BIN="$TMP_ROOT/base-dws"
|
||||
BASE_RAW="$TMP_ROOT/base-schema.json"
|
||||
BASELINE="$TMP_ROOT/base-contract.json"
|
||||
CANDIDATE_RAW="$TMP_ROOT/candidate-schema.json"
|
||||
CHECKER="$TMP_ROOT/schema-compat"
|
||||
STABLE_WORKTREE="$TMP_ROOT/stable-worktree"
|
||||
CANDIDATE_WORKTREE="$TMP_ROOT/candidate-worktree"
|
||||
BASE_WORKTREE_ADDED=false
|
||||
STABLE_WORKTREE_ADDED=false
|
||||
CANDIDATE_WORKTREE_ADDED=false
|
||||
|
||||
cleanup() {
|
||||
git -C "$ROOT" worktree remove --force "$BASE_WORKTREE" >/dev/null 2>&1 || true
|
||||
if [ "$CANDIDATE_WORKTREE_ADDED" = true ]; then
|
||||
git -C "$ROOT" worktree remove --force "$CANDIDATE_WORKTREE" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ "$STABLE_WORKTREE_ADDED" = true ]; then
|
||||
git -C "$ROOT" worktree remove --force "$STABLE_WORKTREE" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ "$BASE_WORKTREE_ADDED" = true ]; then
|
||||
git -C "$ROOT" worktree remove --force "$BASE_WORKTREE" >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -rf "$TMP_ROOT"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
@@ -64,19 +115,213 @@ trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
git -C "$ROOT" worktree add --detach "$BASE_WORKTREE" "$BASE_REF" >/dev/null
|
||||
git -C "$ROOT" worktree add --detach "$BASE_WORKTREE" "$BASE_COMMIT" >/dev/null
|
||||
BASE_WORKTREE_ADDED=true
|
||||
git -C "$ROOT" worktree add --detach "$STABLE_WORKTREE" "$STABLE_COMMIT" >/dev/null
|
||||
STABLE_WORKTREE_ADDED=true
|
||||
git -C "$ROOT" worktree add --detach "$CANDIDATE_WORKTREE" "$CANDIDATE_COMMIT" >/dev/null
|
||||
CANDIDATE_WORKTREE_ADDED=true
|
||||
|
||||
commit_path_exists() {
|
||||
tree_commit="$1"
|
||||
tree_path="$2"
|
||||
tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")"
|
||||
[ -n "$tree_entry" ]
|
||||
}
|
||||
|
||||
commit_path_is_regular_file() {
|
||||
tree_commit="$1"
|
||||
tree_path="$2"
|
||||
tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")"
|
||||
[ -n "$tree_entry" ] || return 1
|
||||
set -- $tree_entry
|
||||
[ "$#" -eq 4 ] || return 1
|
||||
case "$1" in
|
||||
100644|100755) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
[ "$2" = blob ] && [ "$4" = "$tree_path" ]
|
||||
}
|
||||
|
||||
has_modern_interface_helper() {
|
||||
governance_commit="$1"
|
||||
commit_path_is_regular_file "$governance_commit" cmd/interface-snapshot/main.go &&
|
||||
commit_path_is_regular_file "$governance_commit" internal/interfacesnapshot/snapshot.go &&
|
||||
commit_path_is_regular_file "$governance_commit" internal/interfacesnapshot/compare.go
|
||||
}
|
||||
|
||||
has_complete_schema_migration_governance() {
|
||||
governance_commit="$1"
|
||||
commit_path_is_regular_file "$governance_commit" "$SCHEMA_CHECKER_REL" &&
|
||||
has_modern_interface_helper "$governance_commit" &&
|
||||
commit_path_is_regular_file "$governance_commit" "$MIGRATIONS_REL" &&
|
||||
commit_path_is_regular_file "$governance_commit" "$ALIAS_CONTRACT_REL" &&
|
||||
commit_path_is_regular_file "$governance_commit" "$MIGRATION_MANIFEST_REL"
|
||||
}
|
||||
|
||||
has_any_schema_migration_governance_artifact() {
|
||||
governance_commit="$1"
|
||||
for relative_path in \
|
||||
"$MIGRATIONS_REL" \
|
||||
"$ALIAS_CONTRACT_REL" \
|
||||
"$MIGRATION_MANIFEST_REL"; do
|
||||
if commit_path_exists "$governance_commit" "$relative_path"; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
check_candidate_alias_source_policy() {
|
||||
source_policy_failed=false
|
||||
for token in \
|
||||
dws.compat.alias_of \
|
||||
dws.compat.alias_origin \
|
||||
corecmd.flag_spec_aliases.v1 \
|
||||
AnnotationFlagAliasOf \
|
||||
AnnotationFlagAliasOrigin \
|
||||
FlagAliasOriginCorecmdV1; do
|
||||
matches="$(git -C "$CANDIDATE_WORKTREE" grep -l -F -e "$token" -- '*.go' || true)"
|
||||
[ -z "$matches" ] && continue
|
||||
while IFS= read -r relative_path; do
|
||||
[ -n "$relative_path" ] || continue
|
||||
case "$relative_path" in
|
||||
*_test.go)
|
||||
continue
|
||||
;;
|
||||
internal/corecmd/corecmd.go|\
|
||||
internal/corecmd/runtimeannotate/interface_alias.go|\
|
||||
internal/interfacesnapshot/snapshot.go)
|
||||
;;
|
||||
*)
|
||||
if [ "$source_policy_failed" = false ]; then
|
||||
printf 'error: candidate production source may not forge framework-owned flag alias evidence:\n' >&2
|
||||
fi
|
||||
printf ' %s: protected alias evidence token %s\n' "$relative_path" "$token" >&2
|
||||
source_policy_failed=true
|
||||
;;
|
||||
esac
|
||||
done <<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"
|
||||
CANDIDATE_BIN="$TMP_ROOT/candidate-dws"
|
||||
CHECKER="$TMP_ROOT/schema-compat"
|
||||
BASE_RAW="$TMP_ROOT/base-schema.json"
|
||||
BASELINE="$TMP_ROOT/base-contract.json"
|
||||
CANDIDATE_RAW="$TMP_ROOT/candidate-schema.json"
|
||||
|
||||
(
|
||||
cd "$BASE_WORKTREE"
|
||||
go build -o "$BASE_BIN" ./cmd
|
||||
go build -o "$CHECKER" ./scripts/policy/schema-compat
|
||||
)
|
||||
(
|
||||
cd "$CANDIDATE_WORKTREE"
|
||||
go build -o "$CANDIDATE_BIN" ./cmd
|
||||
)
|
||||
go build -o "$CHECKER" ./scripts/policy/schema-compat
|
||||
|
||||
mkdir -p "$TMP_ROOT/base-home" "$TMP_ROOT/candidate-home"
|
||||
HOME="$TMP_ROOT/base-home" DWS_LANG=zh \
|
||||
"$BASE_BIN" schema --all --format json >"$BASE_RAW"
|
||||
HOME="$TMP_ROOT/candidate-home" DWS_LANG=zh \
|
||||
"$BIN" schema --all --format json >"$CANDIDATE_RAW"
|
||||
"$CANDIDATE_BIN" schema --all --format json >"$CANDIDATE_RAW"
|
||||
|
||||
"$CHECKER" --normalize "$BASE_RAW" >"$BASELINE"
|
||||
printf 'checking complete Schema contract against PR merge-base %s\n' "$BASE_REF"
|
||||
"$CHECKER" --check "$BASELINE" --current "$CANDIDATE_RAW"
|
||||
printf 'checking complete Schema contract against PR merge-base %s and stable %s\n' \
|
||||
"$BASE_REF" "$STABLE_REF"
|
||||
|
||||
if [ "$USE_MIGRATION_GOVERNANCE" = false ]; then
|
||||
"$CHECKER" --check "$BASELINE" --current "$CANDIDATE_RAW"
|
||||
exit $?
|
||||
fi
|
||||
|
||||
install_authority_interface_helper() {
|
||||
worktree="$1"
|
||||
for helper_path in cmd/interface-snapshot internal/interfacesnapshot; do
|
||||
rm -rf "$worktree/$helper_path"
|
||||
mkdir -p "$worktree/$helper_path"
|
||||
cp -R "$BASE_WORKTREE/$helper_path/." "$worktree/$helper_path/"
|
||||
done
|
||||
mkdir -p "$worktree/$(dirname "$ALIAS_CONTRACT_REL")"
|
||||
rm -f "$worktree/$ALIAS_CONTRACT_REL"
|
||||
cp "$BASE_WORKTREE/$ALIAS_CONTRACT_REL" "$worktree/$ALIAS_CONTRACT_REL"
|
||||
}
|
||||
|
||||
# The candidate and historical stable trees provide only the command surface.
|
||||
# The merge-base owns the snapshot generator, comparator, and alias contract.
|
||||
install_authority_interface_helper "$CANDIDATE_WORKTREE"
|
||||
install_authority_interface_helper "$STABLE_WORKTREE"
|
||||
|
||||
generate_interface_snapshot() {
|
||||
worktree="$1"
|
||||
output="$2"
|
||||
(
|
||||
cd "$worktree"
|
||||
go run ./cmd/interface-snapshot generate --output "$output"
|
||||
)
|
||||
}
|
||||
|
||||
CURRENT_INTERFACE_SNAPSHOT="$TMP_ROOT/candidate-interface-snapshot.json"
|
||||
BASE_INTERFACE_SNAPSHOT="$TMP_ROOT/base-interface-snapshot.json"
|
||||
STABLE_INTERFACE_SNAPSHOT="$TMP_ROOT/stable-interface-snapshot.json"
|
||||
|
||||
generate_interface_snapshot "$CANDIDATE_WORKTREE" "$CURRENT_INTERFACE_SNAPSHOT"
|
||||
generate_interface_snapshot "$BASE_WORKTREE" "$BASE_INTERFACE_SNAPSHOT"
|
||||
generate_interface_snapshot "$STABLE_WORKTREE" "$STABLE_INTERFACE_SNAPSHOT"
|
||||
|
||||
"$CHECKER" \
|
||||
--check "$BASELINE" \
|
||||
--current "$CANDIDATE_RAW" \
|
||||
--approved-flag-migrations "$APPROVED_MANIFEST" \
|
||||
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
|
||||
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
|
||||
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
|
||||
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
|
||||
|
||||
@@ -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":[]}
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
|
||||
)
|
||||
|
||||
const schemaContractVersion = 3
|
||||
@@ -82,12 +84,19 @@ func main() {
|
||||
|
||||
func run(args []string, stdout, stderr io.Writer) int {
|
||||
var normalizePath, checkPath, mergePath, currentPath string
|
||||
var approvedFlagMigrationsPath, candidateFlagMigrationsPath string
|
||||
var migrationCurrentSnapshotPath, migrationBaseSnapshotPath, migrationStableSnapshotPath string
|
||||
flags := flag.NewFlagSet("schema-compat", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
flags.StringVar(&normalizePath, "normalize", "", "normalize a raw complete Schema response")
|
||||
flags.StringVar(&checkPath, "check", "", "check against a normalized historical baseline")
|
||||
flags.StringVar(&mergePath, "merge", "", "merge additions into a normalized historical baseline")
|
||||
flags.StringVar(¤tPath, "current", "", "raw current complete Schema response")
|
||||
flags.StringVar(&approvedFlagMigrationsPath, "approved-flag-migrations", "", "base-owned approved flag migration manifest")
|
||||
flags.StringVar(&candidateFlagMigrationsPath, "candidate-flag-migrations", "", "detached candidate flag migration manifest")
|
||||
flags.StringVar(&migrationCurrentSnapshotPath, "migration-current-snapshot", "", "current interface snapshot used for migration authorization")
|
||||
flags.StringVar(&migrationBaseSnapshotPath, "migration-base-snapshot", "", "merge-base interface snapshot used for migration authorization")
|
||||
flags.StringVar(&migrationStableSnapshotPath, "migration-stable-snapshot", "", "stable interface snapshot used for migration authorization")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
@@ -102,6 +111,27 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
fmt.Fprintln(stderr, "exactly one of --normalize, --check, or --merge is required")
|
||||
return 2
|
||||
}
|
||||
migrationInputs := []string{
|
||||
approvedFlagMigrationsPath,
|
||||
candidateFlagMigrationsPath,
|
||||
migrationCurrentSnapshotPath,
|
||||
migrationBaseSnapshotPath,
|
||||
migrationStableSnapshotPath,
|
||||
}
|
||||
migrationInputCount := 0
|
||||
for _, path := range migrationInputs {
|
||||
if path != "" {
|
||||
migrationInputCount++
|
||||
}
|
||||
}
|
||||
if migrationInputCount != 0 && migrationInputCount != len(migrationInputs) {
|
||||
fmt.Fprintln(stderr, "Schema flag migration authorization requires all five migration inputs")
|
||||
return 2
|
||||
}
|
||||
if migrationInputCount != 0 && checkPath == "" {
|
||||
fmt.Fprintln(stderr, "Schema flag migration authorization is only valid with --check")
|
||||
return 2
|
||||
}
|
||||
|
||||
if normalizePath != "" {
|
||||
currentPath = normalizePath
|
||||
@@ -128,6 +158,24 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
fmt.Fprintf(stderr, "read schema baseline: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
if migrationInputCount != 0 {
|
||||
migrations, err := authorizeSchemaFlagMigrations(
|
||||
approvedFlagMigrationsPath,
|
||||
candidateFlagMigrationsPath,
|
||||
migrationCurrentSnapshotPath,
|
||||
migrationBaseSnapshotPath,
|
||||
migrationStableSnapshotPath,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "authorize Schema flag migrations: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
baseline, err = normalizeSchemaFlagMigrations(baseline, current, migrations)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "normalize approved Schema flag migrations: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
failures := checkCompatibility(baseline, current)
|
||||
if len(failures) > 0 {
|
||||
fmt.Fprintln(stderr, "Schema backwards-compatibility check failed:")
|
||||
@@ -159,6 +207,57 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func authorizeSchemaFlagMigrations(
|
||||
approvedManifestPath string,
|
||||
candidateManifestPath string,
|
||||
currentSnapshotPath string,
|
||||
baseSnapshotPath string,
|
||||
stableSnapshotPath string,
|
||||
) ([]interfacesnapshot.FlagMigration, error) {
|
||||
approved, err := readFlagMigrationManifestFile(approvedManifestPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read approved flag migrations: %w", err)
|
||||
}
|
||||
candidate, err := readFlagMigrationManifestFile(candidateManifestPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read candidate flag migrations: %w", err)
|
||||
}
|
||||
current, err := readInterfaceSnapshotFile(currentSnapshotPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migration current snapshot: %w", err)
|
||||
}
|
||||
base, err := readInterfaceSnapshotFile(baseSnapshotPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migration base snapshot: %w", err)
|
||||
}
|
||||
stable, err := readInterfaceSnapshotFile(stableSnapshotPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migration stable snapshot: %w", err)
|
||||
}
|
||||
return interfacesnapshot.AuthorizeFlagMigrations(
|
||||
current,
|
||||
map[string]interfacesnapshot.Snapshot{"merge-base": base, "stable": stable},
|
||||
approved,
|
||||
candidate,
|
||||
)
|
||||
}
|
||||
|
||||
func readFlagMigrationManifestFile(path string) (interfacesnapshot.FlagMigrationManifest, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return interfacesnapshot.FlagMigrationManifest{}, err
|
||||
}
|
||||
return interfacesnapshot.ReadFlagMigrationManifest(bytes.NewReader(data))
|
||||
}
|
||||
|
||||
func readInterfaceSnapshotFile(path string) (interfacesnapshot.Snapshot, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return interfacesnapshot.Snapshot{}, err
|
||||
}
|
||||
return interfacesnapshot.Read(bytes.NewReader(data))
|
||||
}
|
||||
|
||||
func normalizeRawFile(path string) (schemaContract, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -919,6 +1018,334 @@ func cloneContract(source schemaContract) schemaContract {
|
||||
return cloned
|
||||
}
|
||||
|
||||
type schemaToolRef struct {
|
||||
productID string
|
||||
toolID string
|
||||
}
|
||||
|
||||
// normalizeSchemaFlagMigrations projects only an already-authorized CLI flag
|
||||
// rename onto a cloned historical Schema contract. The ordinary compatibility
|
||||
// checker still makes the final decision; this adapter never drops findings by
|
||||
// matching their rendered text.
|
||||
func normalizeSchemaFlagMigrations(
|
||||
baseline schemaContract,
|
||||
current schemaContract,
|
||||
migrations []interfacesnapshot.FlagMigration,
|
||||
) (schemaContract, error) {
|
||||
normalized := cloneContract(baseline)
|
||||
renamesByTool := map[schemaToolRef]map[string]string{}
|
||||
|
||||
for _, migration := range migrations {
|
||||
primaryPath := strings.TrimPrefix(migration.Command, "dws ")
|
||||
matches := schemaToolsByPrimaryPath(baseline, primaryPath)
|
||||
if len(matches) == 0 {
|
||||
// A reviewed CLI-only command has no Schema compatibility surface.
|
||||
continue
|
||||
}
|
||||
if len(matches) != 1 {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved flag migration %q matches %d historical Schema tools",
|
||||
migration.Command,
|
||||
len(matches),
|
||||
)
|
||||
}
|
||||
|
||||
ref := matches[0]
|
||||
oldTool := baseline.Products[ref.productID].Tools[ref.toolID]
|
||||
newProduct, productExists := current.Products[ref.productID]
|
||||
newTool, toolExists := newProduct.Tools[ref.toolID]
|
||||
if !productExists || !toolExists || newTool.PrimaryCLIPath != primaryPath {
|
||||
// Preserve the original baseline so the ordinary checker reports the
|
||||
// missing tool or changed primary_cli_path.
|
||||
continue
|
||||
}
|
||||
oldLegacy, legacyExisted := oldTool.Parameters[migration.Legacy.Name]
|
||||
oldCanonical, canonicalExisted := oldTool.Parameters[migration.Canonical.Name]
|
||||
if !legacyExisted && !canonicalExisted {
|
||||
// A CLI migration is not evidence that an unrelated Schema surface
|
||||
// published either name. In particular, it must not authorize a
|
||||
// constraints rewrite with no historical parameter to rename.
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := newTool.Parameters[migration.Legacy.Name]; exists {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved flag migration %q still publishes legacy Schema parameter %q",
|
||||
migration.Command,
|
||||
migration.Legacy.Name,
|
||||
)
|
||||
}
|
||||
newCanonical, exists := newTool.Parameters[migration.Canonical.Name]
|
||||
if !exists {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved flag migration %q does not publish canonical Schema parameter %q",
|
||||
migration.Command,
|
||||
migration.Canonical.Name,
|
||||
)
|
||||
}
|
||||
if migration.Canonical.After.Required && (!newCanonical.Required || !newCanonical.CLIRequired) {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved flag migration %q canonical Schema parameter %q must be required and cli_required",
|
||||
migration.Command,
|
||||
migration.Canonical.Name,
|
||||
)
|
||||
}
|
||||
if !legacyExisted {
|
||||
// A consumed receipt may be evaluated after the merge-base Schema has
|
||||
// already reached the canonical-only state. In that case the ordinary
|
||||
// checker needs no projection. More importantly, a CLI migration is not
|
||||
// authority to promote a canonical-only historical Schema parameter from
|
||||
// optional to required: without legacy Schema evidence there is no rename.
|
||||
continue
|
||||
}
|
||||
|
||||
normalizedProduct := normalized.Products[ref.productID]
|
||||
normalizedTool := normalizedProduct.Tools[ref.toolID]
|
||||
if err := validateRenamedSchemaParameter(migration, oldLegacy, newCanonical); err != nil {
|
||||
return schemaContract{}, err
|
||||
}
|
||||
delete(normalizedTool.Parameters, migration.Legacy.Name)
|
||||
if canonicalExisted {
|
||||
if err := validateRenamedSchemaParameter(migration, oldCanonical, newCanonical); err != nil {
|
||||
return schemaContract{}, err
|
||||
}
|
||||
normalizedCanonical := normalizedTool.Parameters[migration.Canonical.Name]
|
||||
normalizedCanonical.Required = newCanonical.Required
|
||||
normalizedCanonical.CLIRequired = newCanonical.CLIRequired
|
||||
normalizedTool.Parameters[migration.Canonical.Name] = normalizedCanonical
|
||||
} else {
|
||||
normalizedTool.Parameters[migration.Canonical.Name] = newCanonical
|
||||
}
|
||||
normalizedProduct.Tools[ref.toolID] = normalizedTool
|
||||
normalized.Products[ref.productID] = normalizedProduct
|
||||
|
||||
if renamesByTool[ref] == nil {
|
||||
renamesByTool[ref] = map[string]string{}
|
||||
}
|
||||
if existing, ok := renamesByTool[ref][migration.Legacy.Name]; ok && existing != migration.Canonical.Name {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved flag migration %q maps Schema parameter %q to both %q and %q",
|
||||
migration.Command,
|
||||
migration.Legacy.Name,
|
||||
existing,
|
||||
migration.Canonical.Name,
|
||||
)
|
||||
}
|
||||
renamesByTool[ref][migration.Legacy.Name] = migration.Canonical.Name
|
||||
}
|
||||
|
||||
for ref, renames := range renamesByTool {
|
||||
oldTool := baseline.Products[ref.productID].Tools[ref.toolID]
|
||||
newTool := current.Products[ref.productID].Tools[ref.toolID]
|
||||
if oldTool.Constraints == newTool.Constraints {
|
||||
continue
|
||||
}
|
||||
oldConstraints, oldOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames)
|
||||
newConstraints, newOK := canonicalizeMigratedConstraints(newTool.Constraints, nil)
|
||||
if !oldOK || !newOK || oldConstraints != newConstraints {
|
||||
// Keep the historical constraints unchanged. The ordinary checker
|
||||
// will report every non-rename constraint change.
|
||||
continue
|
||||
}
|
||||
product := normalized.Products[ref.productID]
|
||||
tool := product.Tools[ref.toolID]
|
||||
tool.Constraints = newTool.Constraints
|
||||
product.Tools[ref.toolID] = tool
|
||||
normalized.Products[ref.productID] = product
|
||||
}
|
||||
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func schemaToolsByPrimaryPath(contract schemaContract, primaryPath string) []schemaToolRef {
|
||||
var matches []schemaToolRef
|
||||
for productID, product := range contract.Products {
|
||||
for toolID, tool := range product.Tools {
|
||||
if tool.PrimaryCLIPath == primaryPath {
|
||||
matches = append(matches, schemaToolRef{productID: productID, toolID: toolID})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(matches, func(i, j int) bool {
|
||||
if matches[i].productID != matches[j].productID {
|
||||
return matches[i].productID < matches[j].productID
|
||||
}
|
||||
return matches[i].toolID < matches[j].toolID
|
||||
})
|
||||
return matches
|
||||
}
|
||||
|
||||
func validateRenamedSchemaParameter(
|
||||
migration interfacesnapshot.FlagMigration,
|
||||
oldParameter parameterSchema,
|
||||
newParameter parameterSchema,
|
||||
) error {
|
||||
if oldParameter.Type != newParameter.Type ||
|
||||
oldParameter.Property != newParameter.Property ||
|
||||
oldParameter.InterfaceType != newParameter.InterfaceType ||
|
||||
oldParameter.RequiredWhen != newParameter.RequiredWhen ||
|
||||
oldParameter.Default != newParameter.Default ||
|
||||
oldParameter.InterfaceDefault != newParameter.InterfaceDefault ||
|
||||
oldParameter.Format != newParameter.Format ||
|
||||
!equalStringSlices(oldParameter.Enum, newParameter.Enum) {
|
||||
return fmt.Errorf(
|
||||
"approved flag migration %q Schema parameter %q -> %q changed a non-migration field",
|
||||
migration.Command,
|
||||
migration.Legacy.Name,
|
||||
migration.Canonical.Name,
|
||||
)
|
||||
}
|
||||
if oldParameter.Required && !newParameter.Required {
|
||||
return fmt.Errorf(
|
||||
"approved flag migration %q Schema parameter %q -> %q became optional",
|
||||
migration.Command,
|
||||
migration.Legacy.Name,
|
||||
migration.Canonical.Name,
|
||||
)
|
||||
}
|
||||
if oldParameter.CLIRequired && !newParameter.CLIRequired {
|
||||
return fmt.Errorf(
|
||||
"approved flag migration %q Schema parameter %q -> %q stopped being cli_required",
|
||||
migration.Command,
|
||||
migration.Legacy.Name,
|
||||
migration.Canonical.Name,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func equalStringSlices(left, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func canonicalizeMigratedConstraints(raw string, renames map[string]string) (string, bool) {
|
||||
groups, ok := parseMigrationConstraintsStrict(raw)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
normalized := map[string][][]string{}
|
||||
for _, kind := range []string{"mutually_exclusive", "require_one_of", "require_together"} {
|
||||
for _, group := range groups[kind] {
|
||||
members := make([]string, 0, len(group))
|
||||
for _, member := range group {
|
||||
if replacement := renames[member]; replacement != "" {
|
||||
member = replacement
|
||||
}
|
||||
members = append(members, member)
|
||||
}
|
||||
sort.Strings(members)
|
||||
members = compactConstraintStrings(members)
|
||||
normalized[kind] = append(normalized[kind], members)
|
||||
}
|
||||
sort.Slice(normalized[kind], func(i, j int) bool {
|
||||
return strings.Join(normalized[kind][i], "\x00") < strings.Join(normalized[kind][j], "\x00")
|
||||
})
|
||||
normalized[kind] = compactConstraintGroups(normalized[kind])
|
||||
if len(normalized[kind]) == 0 {
|
||||
delete(normalized, kind)
|
||||
}
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return "", true
|
||||
}
|
||||
encoded, err := json.Marshal(normalized)
|
||||
return string(encoded), err == nil
|
||||
}
|
||||
|
||||
func parseMigrationConstraintsStrict(raw string) (map[string][][]string, bool) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return map[string][][]string{}, true
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(trimmed))
|
||||
opening, err := decoder.Token()
|
||||
if err != nil || opening != json.Delim('{') {
|
||||
return nil, false
|
||||
}
|
||||
groups := map[string][][]string{}
|
||||
for decoder.More() {
|
||||
fieldToken, err := decoder.Token()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
// encoding/json guarantees that an object member name is a string token.
|
||||
field := fieldToken.(string)
|
||||
switch field {
|
||||
case "mutually_exclusive", "require_one_of", "require_together":
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
if _, duplicate := groups[field]; duplicate {
|
||||
return nil, false
|
||||
}
|
||||
var encodedGroups json.RawMessage
|
||||
if err := decoder.Decode(&encodedGroups); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
encodedGroups = bytes.TrimSpace(encodedGroups)
|
||||
if len(encodedGroups) == 0 || encodedGroups[0] != '[' {
|
||||
return nil, false
|
||||
}
|
||||
var fieldGroups [][]string
|
||||
if err := json.Unmarshal(encodedGroups, &fieldGroups); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
for _, group := range fieldGroups {
|
||||
if len(group) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
for _, member := range group {
|
||||
if member == "" || member != strings.TrimSpace(member) {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
}
|
||||
groups[field] = fieldGroups
|
||||
}
|
||||
closing, err := decoder.Token()
|
||||
if err != nil || closing != json.Delim('}') {
|
||||
return nil, false
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
return nil, false
|
||||
}
|
||||
return groups, true
|
||||
}
|
||||
|
||||
func compactConstraintStrings(values []string) []string {
|
||||
if len(values) < 2 {
|
||||
return values
|
||||
}
|
||||
out := values[:1]
|
||||
for _, value := range values[1:] {
|
||||
if value != out[len(out)-1] {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func compactConstraintGroups(groups [][]string) [][]string {
|
||||
if len(groups) < 2 {
|
||||
return groups
|
||||
}
|
||||
out := groups[:1]
|
||||
for _, group := range groups[1:] {
|
||||
if !equalStringSlices(group, out[len(out)-1]) {
|
||||
out = append(out, group)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeContract(w io.Writer, contract schemaContract) error {
|
||||
contract.Version = schemaContractVersion
|
||||
if contract.Products == nil {
|
||||
|
||||
@@ -9,8 +9,12 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
|
||||
)
|
||||
|
||||
const completeSchemaJSON = `{
|
||||
@@ -931,3 +935,804 @@ func TestCrossPlatformCoverageSchemaCompatInterfaceRefRedirect(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaFlagMigrationNormalizesExactRename(t *testing.T) {
|
||||
baseline := schemaFlagMigrationContract(false)
|
||||
current := schemaFlagMigrationContract(true)
|
||||
|
||||
normalized, err := normalizeSchemaFlagMigrations(baseline, current, schemaFlagMigrationAuthorizations())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failures := checkCompatibility(normalized, current); len(failures) != 0 {
|
||||
t.Fatalf("exact flag migration should pass after normalization: %v", failures)
|
||||
}
|
||||
|
||||
tool := normalized.Products["chat"].Tools["chat.edit_message"]
|
||||
for _, legacy := range []string{"group", "id", "msg-id"} {
|
||||
if _, exists := tool.Parameters[legacy]; exists {
|
||||
t.Fatalf("normalized baseline retained legacy parameter %q", legacy)
|
||||
}
|
||||
}
|
||||
if canonical := tool.Parameters["conversation-id"]; !canonical.Required || !canonical.CLIRequired {
|
||||
t.Fatalf("canonical required transition was not normalized: %#v", canonical)
|
||||
}
|
||||
if tool.Constraints != current.Products["chat"].Tools["chat.edit_message"].Constraints {
|
||||
t.Fatalf("constraints were not normalized: %s", tool.Constraints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaFlagMigrationRejectsSemanticDrift(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want string
|
||||
mutate func(*parameterSchema)
|
||||
}{
|
||||
{name: "required decline", want: "must be required and cli_required", mutate: func(parameter *parameterSchema) {
|
||||
parameter.Required = false
|
||||
}},
|
||||
{name: "cli required decline", want: "must be required and cli_required", mutate: func(parameter *parameterSchema) {
|
||||
parameter.CLIRequired = false
|
||||
}},
|
||||
{name: "type", want: "non-migration field", mutate: func(parameter *parameterSchema) { parameter.Type = `"integer"` }},
|
||||
{name: "property", want: "non-migration field", mutate: func(parameter *parameterSchema) { parameter.Property = "other" }},
|
||||
{name: "interface type", want: "non-migration field", mutate: func(parameter *parameterSchema) { parameter.InterfaceType = "integer" }},
|
||||
{name: "required when", want: "non-migration field", mutate: func(parameter *parameterSchema) { parameter.RequiredWhen = "mode=other" }},
|
||||
{name: "default", want: "non-migration field", mutate: func(parameter *parameterSchema) { parameter.Default = `"other"` }},
|
||||
{name: "interface default", want: "non-migration field", mutate: func(parameter *parameterSchema) { parameter.InterfaceDefault = `"other"` }},
|
||||
{name: "format", want: "non-migration field", mutate: func(parameter *parameterSchema) { parameter.Format = "uri" }},
|
||||
{name: "enum", want: "non-migration field", mutate: func(parameter *parameterSchema) { parameter.Enum = []string{"a"} }},
|
||||
{name: "enum same length", want: "non-migration field", mutate: func(parameter *parameterSchema) { parameter.Enum = []string{"a", "c"} }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
current := schemaFlagMigrationContract(true)
|
||||
product := current.Products["chat"]
|
||||
tool := product.Tools["chat.edit_message"]
|
||||
canonical := tool.Parameters["message-id"]
|
||||
test.mutate(&canonical)
|
||||
tool.Parameters["message-id"] = canonical
|
||||
product.Tools["chat.edit_message"] = tool
|
||||
current.Products["chat"] = product
|
||||
|
||||
_, err := normalizeSchemaFlagMigrations(
|
||||
schemaFlagMigrationContract(false),
|
||||
current,
|
||||
schemaFlagMigrationAuthorizations(),
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("normalizeSchemaFlagMigrations() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaFlagMigrationAdapterBranches(t *testing.T) {
|
||||
baseline := schemaFlagMigrationContract(false)
|
||||
current := schemaFlagMigrationContract(true)
|
||||
noConstraintsBaseline := cloneContract(baseline)
|
||||
product := noConstraintsBaseline.Products["chat"]
|
||||
tool := product.Tools["chat.edit_message"]
|
||||
tool.Constraints = ""
|
||||
product.Tools["chat.edit_message"] = tool
|
||||
noConstraintsBaseline.Products["chat"] = product
|
||||
noConstraintsCurrent := cloneContract(current)
|
||||
product = noConstraintsCurrent.Products["chat"]
|
||||
tool = product.Tools["chat.edit_message"]
|
||||
tool.Constraints = ""
|
||||
product.Tools["chat.edit_message"] = tool
|
||||
noConstraintsCurrent.Products["chat"] = product
|
||||
if _, err := normalizeSchemaFlagMigrations(noConstraintsBaseline, noConstraintsCurrent, schemaFlagMigrationAuthorizations()); err != nil {
|
||||
t.Fatalf("unchanged empty constraints: %v", err)
|
||||
}
|
||||
|
||||
cliOnly := schemaFlagMigrationAuthorizations()[:1]
|
||||
cliOnly[0].Command = "dws chat cli-only"
|
||||
normalized, err := normalizeSchemaFlagMigrations(baseline, current, cliOnly)
|
||||
if err != nil || !reflect.DeepEqual(normalized, baseline) {
|
||||
t.Fatalf("CLI-only migration = %#v, %v", normalized, err)
|
||||
}
|
||||
|
||||
duplicate := cloneContract(baseline)
|
||||
product = duplicate.Products["chat"]
|
||||
product.Tools["chat.another"] = product.Tools["chat.edit_message"]
|
||||
duplicate.Products["chat"] = product
|
||||
duplicate.Products["chat2"] = productSchema{Tools: map[string]toolSchema{
|
||||
"chat.third": product.Tools["chat.edit_message"],
|
||||
}}
|
||||
if matches := schemaToolsByPrimaryPath(duplicate, "chat message edit"); len(matches) != 3 || matches[0].productID != "chat" || matches[0].toolID != "chat.another" {
|
||||
t.Fatalf("sorted duplicate Schema matches = %#v", matches)
|
||||
}
|
||||
if _, err := normalizeSchemaFlagMigrations(duplicate, current, schemaFlagMigrationAuthorizations()[:1]); err == nil || !strings.Contains(err.Error(), "matches 3") {
|
||||
t.Fatalf("duplicate Schema path error = %v", err)
|
||||
}
|
||||
|
||||
missingCurrent := cloneContract(current)
|
||||
delete(missingCurrent.Products, "chat")
|
||||
normalized, err = normalizeSchemaFlagMigrations(baseline, missingCurrent, schemaFlagMigrationAuthorizations())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failures := checkCompatibility(normalized, missingCurrent); len(failures) == 0 {
|
||||
t.Fatal("missing candidate product was normalized away")
|
||||
}
|
||||
|
||||
canonicalOnly := schemaFlagMigrationAuthorizations()[0]
|
||||
canonicalOnly.Legacy.Name = "legacy-not-published-in-schema"
|
||||
driftedCanonical := cloneContract(current)
|
||||
product = driftedCanonical.Products["chat"]
|
||||
tool = product.Tools["chat.edit_message"]
|
||||
canonical := tool.Parameters["conversation-id"]
|
||||
canonical.Property = "different"
|
||||
tool.Parameters["conversation-id"] = canonical
|
||||
product.Tools["chat.edit_message"] = tool
|
||||
driftedCanonical.Products["chat"] = product
|
||||
normalized, err = normalizeSchemaFlagMigrations(baseline, driftedCanonical, []interfacesnapshot.FlagMigration{canonicalOnly})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failures := strings.Join(checkCompatibility(normalized, driftedCanonical), "\n"); !strings.Contains(failures, "changed property") {
|
||||
t.Fatalf("canonical-only Schema drift was hidden: %s", failures)
|
||||
}
|
||||
|
||||
canonicalOptional := schemaFlagMigrationContract(true)
|
||||
product = canonicalOptional.Products["chat"]
|
||||
tool = product.Tools["chat.edit_message"]
|
||||
canonical = tool.Parameters["conversation-id"]
|
||||
canonical.Required = false
|
||||
canonical.CLIRequired = false
|
||||
tool.Parameters["conversation-id"] = canonical
|
||||
product.Tools["chat.edit_message"] = tool
|
||||
canonicalOptional.Products["chat"] = product
|
||||
normalized, err = normalizeSchemaFlagMigrations(
|
||||
canonicalOptional,
|
||||
schemaFlagMigrationContract(true),
|
||||
[]interfacesnapshot.FlagMigration{canonicalOnly},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failures := strings.Join(checkCompatibility(normalized, schemaFlagMigrationContract(true)), "\n"); !strings.Contains(failures, "newly required") || !strings.Contains(failures, "newly cli_required") {
|
||||
t.Fatalf("canonical-only required promotion was hidden: %s", failures)
|
||||
}
|
||||
|
||||
canonicalConflictBaseline := schemaFlagMigrationContract(false)
|
||||
product = canonicalConflictBaseline.Products["chat"]
|
||||
tool = product.Tools["chat.edit_message"]
|
||||
canonical = tool.Parameters["conversation-id"]
|
||||
canonical.Property = "historicalCanonicalProperty"
|
||||
tool.Parameters["conversation-id"] = canonical
|
||||
product.Tools["chat.edit_message"] = tool
|
||||
canonicalConflictBaseline.Products["chat"] = product
|
||||
if _, err := normalizeSchemaFlagMigrations(
|
||||
canonicalConflictBaseline,
|
||||
schemaFlagMigrationContract(true),
|
||||
schemaFlagMigrationAuthorizations()[:1],
|
||||
); err == nil || !strings.Contains(err.Error(), "non-migration field") {
|
||||
t.Fatalf("historical canonical drift error = %v", err)
|
||||
}
|
||||
|
||||
conflicting := schemaFlagMigrationAuthorizations()[0]
|
||||
conflicting.Canonical.Name = "other-conversation-id"
|
||||
conflicting.Legacy.After.AliasOf = conflicting.Canonical.Name
|
||||
product = current.Products["chat"]
|
||||
tool = product.Tools["chat.edit_message"]
|
||||
tool.Parameters[conflicting.Canonical.Name] = tool.Parameters["conversation-id"]
|
||||
product.Tools["chat.edit_message"] = tool
|
||||
current.Products["chat"] = product
|
||||
if _, err := normalizeSchemaFlagMigrations(baseline, current, []interfacesnapshot.FlagMigration{
|
||||
schemaFlagMigrationAuthorizations()[0],
|
||||
conflicting,
|
||||
}); err == nil || !strings.Contains(err.Error(), "maps Schema parameter") {
|
||||
t.Fatalf("conflicting Schema mapping error = %v", err)
|
||||
}
|
||||
|
||||
old := parameterSchema{Required: true, CLIRequired: true}
|
||||
if err := validateRenamedSchemaParameter(schemaFlagMigrationAuthorizations()[0], old, parameterSchema{CLIRequired: true}); err == nil || !strings.Contains(err.Error(), "became optional") {
|
||||
t.Fatalf("direct required decline error = %v", err)
|
||||
}
|
||||
if err := validateRenamedSchemaParameter(schemaFlagMigrationAuthorizations()[0], old, parameterSchema{Required: true}); err == nil || !strings.Contains(err.Error(), "stopped being cli_required") {
|
||||
t.Fatalf("direct cli_required decline error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaFlagMigrationRejectsPartialAndUnrelatedChanges(t *testing.T) {
|
||||
baseline := schemaFlagMigrationContract(false)
|
||||
migrations := schemaFlagMigrationAuthorizations()
|
||||
|
||||
current := schemaFlagMigrationContract(true)
|
||||
product := current.Products["chat"]
|
||||
tool := product.Tools["chat.edit_message"]
|
||||
delete(tool.Parameters, "message-id")
|
||||
product.Tools["chat.edit_message"] = tool
|
||||
current.Products["chat"] = product
|
||||
if _, err := normalizeSchemaFlagMigrations(baseline, current, migrations); err == nil || !strings.Contains(err.Error(), "does not publish canonical") {
|
||||
t.Fatalf("missing canonical error = %v", err)
|
||||
}
|
||||
|
||||
current = schemaFlagMigrationContract(true)
|
||||
product = current.Products["chat"]
|
||||
tool = product.Tools["chat.edit_message"]
|
||||
tool.Parameters["msg-id"] = schemaFlagMigrationContract(false).Products["chat"].Tools["chat.edit_message"].Parameters["msg-id"]
|
||||
product.Tools["chat.edit_message"] = tool
|
||||
current.Products["chat"] = product
|
||||
if _, err := normalizeSchemaFlagMigrations(baseline, current, migrations); err == nil || !strings.Contains(err.Error(), "still publishes legacy") {
|
||||
t.Fatalf("published legacy error = %v", err)
|
||||
}
|
||||
|
||||
current = schemaFlagMigrationContract(true)
|
||||
product = current.Products["chat"]
|
||||
tool = product.Tools["chat.edit_message"]
|
||||
delete(tool.Parameters, "unrelated")
|
||||
product.Tools["chat.edit_message"] = tool
|
||||
current.Products["chat"] = product
|
||||
normalized, err := normalizeSchemaFlagMigrations(baseline, current, migrations)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failures := strings.Join(checkCompatibility(normalized, current), "\n"); !strings.Contains(failures, `lost parameter "unrelated"`) {
|
||||
t.Fatalf("unrelated parameter removal was hidden: %s", failures)
|
||||
}
|
||||
|
||||
current = schemaFlagMigrationContract(true)
|
||||
product = current.Products["chat"]
|
||||
tool = product.Tools["chat.edit_message"]
|
||||
tool.Constraints = `{"require_one_of":[["conversation-id"]],"require_together":[["conversation-id","unrelated"]]}`
|
||||
product.Tools["chat.edit_message"] = tool
|
||||
current.Products["chat"] = product
|
||||
normalized, err = normalizeSchemaFlagMigrations(baseline, current, migrations)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failures := strings.Join(checkCompatibility(normalized, current), "\n"); !strings.Contains(failures, "changed constraints") {
|
||||
t.Fatalf("unrelated constraint change was hidden: %s", failures)
|
||||
}
|
||||
|
||||
// A CLI ledger entry alone cannot authorize a Schema constraint rewrite
|
||||
// when the historical Schema published neither the legacy nor canonical
|
||||
// parameter involved in that entry.
|
||||
noSchemaEvidence := schemaFlagMigrationContract(false)
|
||||
product = noSchemaEvidence.Products["chat"]
|
||||
tool = product.Tools["chat.edit_message"]
|
||||
for _, name := range []string{"conversation-id", "group", "id", "msg-id"} {
|
||||
delete(tool.Parameters, name)
|
||||
}
|
||||
product.Tools["chat.edit_message"] = tool
|
||||
noSchemaEvidence.Products["chat"] = product
|
||||
normalized, err = normalizeSchemaFlagMigrations(noSchemaEvidence, schemaFlagMigrationContract(true), migrations)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failures := strings.Join(checkCompatibility(normalized, schemaFlagMigrationContract(true)), "\n"); !strings.Contains(failures, "changed constraints") {
|
||||
t.Fatalf("constraint rewrite without Schema parameter evidence was hidden: %s", failures)
|
||||
}
|
||||
|
||||
// A baseline that already contains only the canonical parameter may receive
|
||||
// a required promotion, but that is not evidence that a stray legacy name in
|
||||
// constraints belongs to the migration.
|
||||
canonicalOnly := schemaFlagMigrationContract(true)
|
||||
product = canonicalOnly.Products["chat"]
|
||||
tool = product.Tools["chat.edit_message"]
|
||||
tool.Constraints = `{"require_one_of":[["group"]]}`
|
||||
product.Tools["chat.edit_message"] = tool
|
||||
canonicalOnly.Products["chat"] = product
|
||||
normalized, err = normalizeSchemaFlagMigrations(canonicalOnly, schemaFlagMigrationContract(true), migrations)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failures := strings.Join(checkCompatibility(normalized, schemaFlagMigrationContract(true)), "\n"); !strings.Contains(failures, "changed constraints") {
|
||||
t.Fatalf("canonical-only baseline authorized a legacy constraint rewrite: %s", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaFlagMigrationConstraintsFailClosed(t *testing.T) {
|
||||
for _, constraints := range []string{
|
||||
`{`,
|
||||
`{"`,
|
||||
`{"require_one_of":`,
|
||||
`{"unknown_kind":[["conversation-id"]]}`,
|
||||
`{"require_one_of":"conversation-id"}`,
|
||||
`{"require_one_of":["conversation-id"]}`,
|
||||
`{"require_one_of":null}`,
|
||||
`{"require_one_of":[[]]}`,
|
||||
`{"require_one_of":[[" conversation-id"]]}`,
|
||||
`{"require_one_of":[["conversation-id"]],"require_one_of":[["conversation-id"]]}`,
|
||||
`{"require_one_of":[]`,
|
||||
`{} {}`,
|
||||
`[]`,
|
||||
} {
|
||||
current := schemaFlagMigrationContract(true)
|
||||
product := current.Products["chat"]
|
||||
tool := product.Tools["chat.edit_message"]
|
||||
tool.Constraints = constraints
|
||||
product.Tools["chat.edit_message"] = tool
|
||||
current.Products["chat"] = product
|
||||
|
||||
normalized, err := normalizeSchemaFlagMigrations(
|
||||
schemaFlagMigrationContract(false),
|
||||
current,
|
||||
schemaFlagMigrationAuthorizations(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failures := strings.Join(checkCompatibility(normalized, current), "\n"); !strings.Contains(failures, "changed constraints") {
|
||||
t.Fatalf("malformed constraints %s were authorized: %s", constraints, failures)
|
||||
}
|
||||
}
|
||||
|
||||
if encoded, ok := canonicalizeMigratedConstraints("", nil); !ok || encoded != "" {
|
||||
t.Fatalf("empty migrated constraints = %q, %v", encoded, ok)
|
||||
}
|
||||
encoded, ok := canonicalizeMigratedConstraints(
|
||||
`{"require_one_of":[["b"],["a"],["a"],["c","c"]]}`,
|
||||
nil,
|
||||
)
|
||||
if !ok || encoded != `{"require_one_of":[["a"],["b"],["c"]]}` {
|
||||
t.Fatalf("canonical migrated constraint groups = %q, %v", encoded, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaMigrationCLIRequiresCompleteCheckInputs(t *testing.T) {
|
||||
allInputs := []string{
|
||||
"--approved-flag-migrations", "approved.json",
|
||||
"--candidate-flag-migrations", "candidate.json",
|
||||
"--migration-current-snapshot", "current.json",
|
||||
"--migration-base-snapshot", "base.json",
|
||||
"--migration-stable-snapshot", "stable.json",
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run([]string{"--check", "baseline.json", "--current", "schema.json", "--approved-flag-migrations", "approved.json"}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "all five") {
|
||||
t.Fatalf("partial migration inputs code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
|
||||
for _, mode := range []string{"--normalize", "--merge"} {
|
||||
stderr.Reset()
|
||||
args := append([]string{mode, "schema.json"}, allInputs...)
|
||||
if code := run(args, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "only valid with --check") {
|
||||
t.Fatalf("%s migration inputs code=%d stderr=%q", mode, code, stderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaMigrationLifecycleAndRun(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
baselinePath := filepath.Join(directory, "schema-baseline.json")
|
||||
currentSchemaPath := filepath.Join(directory, "schema-current.json")
|
||||
writeSchemaContractFile(t, baselinePath, schemaFlagMigrationContract(false))
|
||||
writeRawSchemaContractFile(t, currentSchemaPath, schemaFlagMigrationContract(true))
|
||||
|
||||
approvedPath := filepath.Join(directory, "approved.json")
|
||||
candidatePath := filepath.Join(directory, "candidate.json")
|
||||
currentSnapshotPath := filepath.Join(directory, "interface-current.json")
|
||||
baseSnapshotPath := filepath.Join(directory, "interface-base.json")
|
||||
stableSnapshotPath := filepath.Join(directory, "interface-stable.json")
|
||||
writeFlagMigrationManifestFile(t, approvedPath, schemaFlagMigrationManifest(interfacesnapshot.FlagMigrationPending))
|
||||
writeFlagMigrationManifestFile(t, candidatePath, schemaFlagMigrationManifest(interfacesnapshot.FlagMigrationConsumed))
|
||||
writeInterfaceSnapshotFile(t, currentSnapshotPath, schemaFlagMigrationSnapshot(true))
|
||||
writeInterfaceSnapshotFile(t, baseSnapshotPath, schemaFlagMigrationSnapshot(false))
|
||||
writeInterfaceSnapshotFile(t, stableSnapshotPath, schemaFlagMigrationSnapshot(false))
|
||||
|
||||
args := []string{
|
||||
"--check", baselinePath,
|
||||
"--current", currentSchemaPath,
|
||||
"--approved-flag-migrations", approvedPath,
|
||||
"--candidate-flag-migrations", candidatePath,
|
||||
"--migration-current-snapshot", currentSnapshotPath,
|
||||
"--migration-base-snapshot", baseSnapshotPath,
|
||||
"--migration-stable-snapshot", stableSnapshotPath,
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run(args, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("governed Schema check code=%d stderr=%s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "compatibility check: ok") {
|
||||
t.Fatalf("governed Schema check output = %q", stdout.String())
|
||||
}
|
||||
|
||||
badArgs := append([]string(nil), args...)
|
||||
badArgs[5] = filepath.Join(directory, "missing-approved.json")
|
||||
stderr.Reset()
|
||||
if code := run(badArgs, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "authorize Schema flag migrations") {
|
||||
t.Fatalf("authorization error code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
|
||||
legacyCurrent := schemaFlagMigrationContract(true)
|
||||
product := legacyCurrent.Products["chat"]
|
||||
tool := product.Tools["chat.edit_message"]
|
||||
tool.Parameters["msg-id"] = schemaFlagMigrationContract(false).Products["chat"].Tools["chat.edit_message"].Parameters["msg-id"]
|
||||
product.Tools["chat.edit_message"] = tool
|
||||
legacyCurrent.Products["chat"] = product
|
||||
writeRawSchemaContractFile(t, currentSchemaPath, legacyCurrent)
|
||||
stderr.Reset()
|
||||
if code := run(args, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "normalize approved Schema flag migrations") {
|
||||
t.Fatalf("normalization error code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
|
||||
// Candidate-owned pending records cannot approve their own after-state.
|
||||
writeFlagMigrationManifestFile(t, approvedPath, interfacesnapshot.FlagMigrationManifest{
|
||||
Version: interfacesnapshot.FlagMigrationManifestVersion,
|
||||
Migrations: []interfacesnapshot.FlagMigration{},
|
||||
})
|
||||
writeFlagMigrationManifestFile(t, candidatePath, schemaFlagMigrationManifest(interfacesnapshot.FlagMigrationPending))
|
||||
if _, err := authorizeSchemaFlagMigrations(approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") {
|
||||
t.Fatalf("self-approval error = %v", err)
|
||||
}
|
||||
|
||||
// An incomplete after-state fails lifecycle authorization before Schema is normalized.
|
||||
writeFlagMigrationManifestFile(t, approvedPath, schemaFlagMigrationManifest(interfacesnapshot.FlagMigrationPending))
|
||||
writeFlagMigrationManifestFile(t, candidatePath, schemaFlagMigrationManifest(interfacesnapshot.FlagMigrationConsumed))
|
||||
partial := schemaFlagMigrationSnapshot(true)
|
||||
partial.Commands[0].LocalFlags = partial.Commands[0].LocalFlags[:len(partial.Commands[0].LocalFlags)-1]
|
||||
writeInterfaceSnapshotFile(t, currentSnapshotPath, partial)
|
||||
if _, err := authorizeSchemaFlagMigrations(approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath); err == nil || !strings.Contains(err.Error(), "partially applied") {
|
||||
t.Fatalf("partial migration error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaMigrationFileErrors(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
approvedPath := filepath.Join(directory, "approved.json")
|
||||
candidatePath := filepath.Join(directory, "candidate.json")
|
||||
currentPath := filepath.Join(directory, "current.json")
|
||||
basePath := filepath.Join(directory, "base.json")
|
||||
stablePath := filepath.Join(directory, "stable.json")
|
||||
writeFlagMigrationManifestFile(t, approvedPath, schemaFlagMigrationManifest(interfacesnapshot.FlagMigrationPending))
|
||||
writeFlagMigrationManifestFile(t, candidatePath, schemaFlagMigrationManifest(interfacesnapshot.FlagMigrationConsumed))
|
||||
writeInterfaceSnapshotFile(t, currentPath, schemaFlagMigrationSnapshot(true))
|
||||
writeInterfaceSnapshotFile(t, basePath, schemaFlagMigrationSnapshot(false))
|
||||
writeInterfaceSnapshotFile(t, stablePath, schemaFlagMigrationSnapshot(false))
|
||||
|
||||
paths := []string{approvedPath, candidatePath, currentPath, basePath, stablePath}
|
||||
wants := []string{
|
||||
"read approved flag migrations",
|
||||
"read candidate flag migrations",
|
||||
"read migration current snapshot",
|
||||
"read migration base snapshot",
|
||||
"read migration stable snapshot",
|
||||
}
|
||||
for index, want := range wants {
|
||||
t.Run(want, func(t *testing.T) {
|
||||
invalid := append([]string(nil), paths...)
|
||||
invalid[index] = filepath.Join(directory, "missing.json")
|
||||
_, err := authorizeSchemaFlagMigrations(invalid[0], invalid[1], invalid[2], invalid[3], invalid[4])
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("authorizeSchemaFlagMigrations() error = %v, want %q", err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaConsumedReceiptIsNoOpForAfterBaseline(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
approvedPath := filepath.Join(directory, "approved.json")
|
||||
candidatePath := filepath.Join(directory, "candidate.json")
|
||||
currentSnapshotPath := filepath.Join(directory, "current.json")
|
||||
baseSnapshotPath := filepath.Join(directory, "base.json")
|
||||
stableSnapshotPath := filepath.Join(directory, "stable.json")
|
||||
writeFlagMigrationManifestFile(t, approvedPath, schemaFlagMigrationManifest(interfacesnapshot.FlagMigrationConsumed))
|
||||
writeFlagMigrationManifestFile(t, candidatePath, schemaFlagMigrationManifest(interfacesnapshot.FlagMigrationConsumed))
|
||||
writeInterfaceSnapshotFile(t, currentSnapshotPath, schemaFlagMigrationSnapshot(true))
|
||||
writeInterfaceSnapshotFile(t, baseSnapshotPath, schemaFlagMigrationSnapshot(true))
|
||||
writeInterfaceSnapshotFile(t, stableSnapshotPath, schemaFlagMigrationSnapshot(false))
|
||||
|
||||
migrations, err := authorizeSchemaFlagMigrations(approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
baseline := schemaFlagMigrationContract(true)
|
||||
normalized, err := normalizeSchemaFlagMigrations(baseline, schemaFlagMigrationContract(true), migrations)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(normalized, baseline) {
|
||||
t.Fatalf("consumed receipt changed an already-after Schema baseline: %#v", normalized)
|
||||
}
|
||||
if failures := checkCompatibility(normalized, schemaFlagMigrationContract(true)); len(failures) != 0 {
|
||||
t.Fatalf("consumed receipt did not preserve compatibility: %v", failures)
|
||||
}
|
||||
|
||||
// Once stable also reaches after, retaining the consumed receipt is stale;
|
||||
// deleting it yields no authorization and the already-after Schema passes.
|
||||
writeInterfaceSnapshotFile(t, stableSnapshotPath, schemaFlagMigrationSnapshot(true))
|
||||
if _, err := authorizeSchemaFlagMigrations(approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath); err == nil || !strings.Contains(err.Error(), "stale after all references reached the after state") {
|
||||
t.Fatalf("stale consumed receipt error = %v", err)
|
||||
}
|
||||
emptyManifest := interfacesnapshot.FlagMigrationManifest{
|
||||
Version: interfacesnapshot.FlagMigrationManifestVersion,
|
||||
Migrations: []interfacesnapshot.FlagMigration{},
|
||||
}
|
||||
writeFlagMigrationManifestFile(t, candidatePath, emptyManifest)
|
||||
migrations, err = authorizeSchemaFlagMigrations(approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath)
|
||||
if err != nil || len(migrations) != 0 {
|
||||
t.Fatalf("cleaned consumed receipt authorizations = %#v, %v", migrations, err)
|
||||
}
|
||||
|
||||
baselinePath := filepath.Join(directory, "schema-baseline.json")
|
||||
currentSchemaPath := filepath.Join(directory, "schema-current.json")
|
||||
writeSchemaContractFile(t, baselinePath, baseline)
|
||||
writeRawSchemaContractFile(t, currentSchemaPath, schemaFlagMigrationContract(true))
|
||||
args := []string{
|
||||
"--check", baselinePath,
|
||||
"--current", currentSchemaPath,
|
||||
"--approved-flag-migrations", approvedPath,
|
||||
"--candidate-flag-migrations", candidatePath,
|
||||
"--migration-current-snapshot", currentSnapshotPath,
|
||||
"--migration-base-snapshot", baseSnapshotPath,
|
||||
"--migration-stable-snapshot", stableSnapshotPath,
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run(args, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("cleaned consumed receipt check code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func schemaFlagMigrationContract(after bool) schemaContract {
|
||||
legacyMessage := parameterSchema{
|
||||
Type: `"string"`,
|
||||
Property: "openMessageId",
|
||||
InterfaceType: "string",
|
||||
Required: true,
|
||||
CLIRequired: true,
|
||||
Default: `"default-message"`,
|
||||
InterfaceDefault: `"default-message"`,
|
||||
Format: "id",
|
||||
Enum: []string{"a", "b"},
|
||||
}
|
||||
conversation := parameterSchema{
|
||||
Type: `"string"`,
|
||||
Property: "openConversationId",
|
||||
InterfaceType: "string",
|
||||
}
|
||||
parameters := map[string]parameterSchema{
|
||||
"conversation-id": conversation,
|
||||
"unrelated": {Type: `"string"`, Property: "unrelated"},
|
||||
}
|
||||
constraints := `{"require_one_of":[["conversation-id","group","id"]]}`
|
||||
if after {
|
||||
conversation.Required = true
|
||||
conversation.CLIRequired = true
|
||||
parameters["conversation-id"] = conversation
|
||||
parameters["message-id"] = legacyMessage
|
||||
constraints = `{"require_one_of":[["conversation-id"]]}`
|
||||
} else {
|
||||
parameters["group"] = conversation
|
||||
parameters["id"] = conversation
|
||||
parameters["msg-id"] = legacyMessage
|
||||
}
|
||||
return schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{
|
||||
"chat": {Tools: map[string]toolSchema{
|
||||
"chat.edit_message": {
|
||||
PrimaryCLIPath: "chat message edit",
|
||||
InterfaceMode: "mcp",
|
||||
Availability: "available",
|
||||
Parameters: parameters,
|
||||
Constraints: constraints,
|
||||
Effect: "write",
|
||||
Risk: "medium",
|
||||
Confirmation: "not_required",
|
||||
Idempotency: "unknown",
|
||||
},
|
||||
}},
|
||||
}}
|
||||
}
|
||||
|
||||
func schemaFlagMigrationAuthorizations() []interfacesnapshot.FlagMigration {
|
||||
conversationBefore := interfacesnapshot.FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Scope: "local",
|
||||
}
|
||||
conversationAfter := conversationBefore
|
||||
conversationAfter.Required = true
|
||||
messageBefore := interfacesnapshot.FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Required: true,
|
||||
Scope: "local",
|
||||
}
|
||||
messageAfter := messageBefore
|
||||
return []interfacesnapshot.FlagMigration{
|
||||
{
|
||||
Command: "dws chat message edit",
|
||||
Legacy: interfacesnapshot.FlagMigrationSide{
|
||||
Name: "group",
|
||||
Before: conversationBefore,
|
||||
After: interfacesnapshot.FlagMigrationState{
|
||||
Present: true, Type: "string", Hidden: true, Scope: "local", AliasOf: "conversation-id",
|
||||
},
|
||||
},
|
||||
Canonical: interfacesnapshot.FlagMigrationSide{
|
||||
Name: "conversation-id",
|
||||
Before: conversationBefore,
|
||||
After: conversationAfter,
|
||||
},
|
||||
},
|
||||
{
|
||||
Command: "dws chat message edit",
|
||||
Legacy: interfacesnapshot.FlagMigrationSide{
|
||||
Name: "id",
|
||||
Before: conversationBefore,
|
||||
After: interfacesnapshot.FlagMigrationState{
|
||||
Present: true, Type: "string", Hidden: true, Scope: "local", AliasOf: "conversation-id",
|
||||
},
|
||||
},
|
||||
Canonical: interfacesnapshot.FlagMigrationSide{
|
||||
Name: "conversation-id",
|
||||
Before: conversationBefore,
|
||||
After: conversationAfter,
|
||||
},
|
||||
},
|
||||
{
|
||||
Command: "dws chat message edit",
|
||||
Legacy: interfacesnapshot.FlagMigrationSide{
|
||||
Name: "msg-id",
|
||||
Before: messageBefore,
|
||||
After: interfacesnapshot.FlagMigrationState{
|
||||
Present: true, Type: "string", Hidden: true, Scope: "local", AliasOf: "message-id",
|
||||
},
|
||||
},
|
||||
Canonical: interfacesnapshot.FlagMigrationSide{
|
||||
Name: "message-id",
|
||||
Before: interfacesnapshot.FlagMigrationState{},
|
||||
After: messageAfter,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func schemaFlagMigrationManifest(state string) interfacesnapshot.FlagMigrationManifest {
|
||||
migrations := schemaFlagMigrationAuthorizations()
|
||||
for index := range migrations {
|
||||
migrations[index].State = state
|
||||
migrations[index].Reason = "canonicalize historical public flag names"
|
||||
}
|
||||
return interfacesnapshot.FlagMigrationManifest{
|
||||
Version: interfacesnapshot.FlagMigrationManifestVersion,
|
||||
Migrations: migrations,
|
||||
}
|
||||
}
|
||||
|
||||
func schemaFlagMigrationSnapshot(after bool) interfacesnapshot.Snapshot {
|
||||
states := map[string]interfacesnapshot.FlagMigrationState{}
|
||||
for _, migration := range schemaFlagMigrationAuthorizations() {
|
||||
if after {
|
||||
states[migration.Legacy.Name] = migration.Legacy.After
|
||||
states[migration.Canonical.Name] = migration.Canonical.After
|
||||
} else {
|
||||
states[migration.Legacy.Name] = migration.Legacy.Before
|
||||
states[migration.Canonical.Name] = migration.Canonical.Before
|
||||
}
|
||||
}
|
||||
names := make([]string, 0, len(states))
|
||||
for name, state := range states {
|
||||
if state.Present {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
flags := make([]interfacesnapshot.Flag, 0, len(names))
|
||||
for _, name := range names {
|
||||
state := states[name]
|
||||
flags = append(flags, interfacesnapshot.Flag{
|
||||
Name: name,
|
||||
Type: state.Type,
|
||||
Required: state.Required,
|
||||
Hidden: state.Hidden,
|
||||
Shorthand: state.Shorthand,
|
||||
NoOpt: state.NoOpt,
|
||||
AliasOf: state.AliasOf,
|
||||
})
|
||||
}
|
||||
return interfacesnapshot.Snapshot{
|
||||
SchemaVersion: interfacesnapshot.SchemaVersion,
|
||||
Rules: interfacesnapshot.Rules{
|
||||
ExcludedCommandSubtrees: []string{},
|
||||
ExcludedFlags: []string{},
|
||||
},
|
||||
Commands: []interfacesnapshot.Command{{
|
||||
Path: "dws chat message edit",
|
||||
Runnable: true,
|
||||
Aliases: []string{},
|
||||
LocalFlags: flags,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func writeFlagMigrationManifestFile(t *testing.T, path string, manifest interfacesnapshot.FlagMigrationManifest) {
|
||||
t.Helper()
|
||||
data, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeInterfaceSnapshotFile(t *testing.T, path string, snapshot interfacesnapshot.Snapshot) {
|
||||
t.Helper()
|
||||
var encoded bytes.Buffer
|
||||
if err := interfacesnapshot.Write(&encoded, snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeSchemaContractFile(t *testing.T, path string, contract schemaContract) {
|
||||
t.Helper()
|
||||
var encoded bytes.Buffer
|
||||
if err := writeContract(&encoded, contract); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeRawSchemaContractFile(t *testing.T, path string, contract schemaContract) {
|
||||
t.Helper()
|
||||
products := make([]map[string]any, 0, len(contract.Products))
|
||||
for productID, product := range contract.Products {
|
||||
tools := make([]map[string]any, 0, len(product.Tools))
|
||||
for toolID, tool := range product.Tools {
|
||||
parameters := map[string]any{}
|
||||
for name, parameter := range tool.Parameters {
|
||||
raw := map[string]any{
|
||||
"type": json.RawMessage(parameter.Type),
|
||||
"required": parameter.Required,
|
||||
"cli_required": parameter.CLIRequired,
|
||||
"property": parameter.Property,
|
||||
"interface_type": parameter.InterfaceType,
|
||||
"required_when": parameter.RequiredWhen,
|
||||
"format": parameter.Format,
|
||||
"enum": parameter.Enum,
|
||||
"field_provenance": map[string]any{},
|
||||
}
|
||||
if parameter.Default != "" {
|
||||
raw["default"] = json.RawMessage(parameter.Default)
|
||||
}
|
||||
if parameter.InterfaceDefault != "" {
|
||||
raw["interface_default"] = json.RawMessage(parameter.InterfaceDefault)
|
||||
}
|
||||
parameters[name] = raw
|
||||
}
|
||||
rawTool := map[string]any{
|
||||
"canonical_path": toolID,
|
||||
"primary_cli_path": tool.PrimaryCLIPath,
|
||||
"interface_mode": tool.InterfaceMode,
|
||||
"availability": tool.Availability,
|
||||
"parameters": parameters,
|
||||
"effect": tool.Effect,
|
||||
"risk": tool.Risk,
|
||||
"confirmation": tool.Confirmation,
|
||||
"idempotency": tool.Idempotency,
|
||||
"field_provenance": map[string]any{},
|
||||
}
|
||||
if tool.InterfaceRef != "" {
|
||||
rawTool["interface_ref"] = json.RawMessage(tool.InterfaceRef)
|
||||
}
|
||||
if tool.Constraints != "" {
|
||||
rawTool["constraints"] = json.RawMessage(tool.Constraints)
|
||||
}
|
||||
if len(tool.Positionals) > 0 {
|
||||
rawTool["positionals"] = tool.Positionals
|
||||
}
|
||||
if tool.DryRun != "" {
|
||||
rawTool["dry_run"] = json.RawMessage(tool.DryRun)
|
||||
}
|
||||
tools = append(tools, rawTool)
|
||||
}
|
||||
products = append(products, map[string]any{"id": productID, "tools": tools})
|
||||
}
|
||||
payload := map[string]any{"kind": "schema", "products": products}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package schemacompat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const governedSchemaCheckerSource = `package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
normalize := flag.String("normalize", "", "raw schema")
|
||||
check := flag.String("check", "", "base schema")
|
||||
current := flag.String("current", "", "candidate schema")
|
||||
approved := flag.String("approved-flag-migrations", "", "base ledger")
|
||||
candidate := flag.String("candidate-flag-migrations", "", "candidate ledger")
|
||||
currentSnapshot := flag.String("migration-current-snapshot", "", "candidate interface snapshot")
|
||||
baseSnapshot := flag.String("migration-base-snapshot", "", "base interface snapshot")
|
||||
stableSnapshot := flag.String("migration-stable-snapshot", "", "stable interface snapshot")
|
||||
flag.Parse()
|
||||
|
||||
if *normalize != "" {
|
||||
data, err := os.ReadFile(*normalize)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
fmt.Print(string(data))
|
||||
return
|
||||
}
|
||||
if *check == "" || *current == "" {
|
||||
fmt.Fprintln(os.Stderr, "missing check inputs")
|
||||
os.Exit(2)
|
||||
}
|
||||
currentData, err := os.ReadFile(*current)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if !strings.Contains(string(currentData), "COMMITTED_CANDIDATE") {
|
||||
fmt.Fprintf(os.Stderr, "candidate schema did not come from detached commit: %s\n", currentData)
|
||||
os.Exit(2)
|
||||
}
|
||||
paths := []string{*approved, *candidate, *currentSnapshot, *baseSnapshot, *stableSnapshot}
|
||||
for _, path := range paths {
|
||||
if path == "" {
|
||||
fmt.Fprintln(os.Stderr, "governed checker did not receive all migration inputs")
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
for _, fixture := range []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{*approved, "{\"version\":1,\"migrations\":[{\"authority\":\"BASE\"}]}\n"},
|
||||
{*candidate, "{\"version\":1,\"migrations\":[{\"candidate\":\"COMMITTED\"}]}\n"},
|
||||
{*currentSnapshot, "COMMITTED_CANDIDATE"},
|
||||
{*baseSnapshot, "BASE_AUTHORITY"},
|
||||
{*stableSnapshot, "STABLE_RELEASE"},
|
||||
} {
|
||||
data, err := os.ReadFile(fixture.path)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if !strings.Contains(string(data), fixture.want) {
|
||||
fmt.Fprintf(os.Stderr, "migration input %s does not contain %q: %s\n", fixture.path, fixture.want, data)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{*currentSnapshot, *baseSnapshot, *stableSnapshot} {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if !strings.Contains(string(data), "INTERFACE_HELPER=BASE") {
|
||||
fmt.Fprintf(os.Stderr, "interface snapshot did not use base internal helper: %s\n", data)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "BASE_SCHEMA_CHECKER_ENFORCED")
|
||||
os.Exit(23)
|
||||
}
|
||||
`
|
||||
|
||||
const bootstrapSchemaCheckerSource = `package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
normalize := flag.String("normalize", "", "raw schema")
|
||||
check := flag.String("check", "", "base schema")
|
||||
current := flag.String("current", "", "candidate schema")
|
||||
flag.Parse()
|
||||
if *normalize != "" {
|
||||
data, err := os.ReadFile(*normalize)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
fmt.Print(string(data))
|
||||
return
|
||||
}
|
||||
if *check == "" || *current == "" {
|
||||
fmt.Fprintln(os.Stderr, "missing check inputs")
|
||||
os.Exit(2)
|
||||
}
|
||||
data, err := os.ReadFile(*current)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if !strings.Contains(string(data), "COMMITTED_CANDIDATE") {
|
||||
fmt.Fprintf(os.Stderr, "candidate schema did not come from detached commit: %s\n", data)
|
||||
os.Exit(2)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "OLD_BASE_SCHEMA_CHECKER_ENFORCED")
|
||||
os.Exit(23)
|
||||
}
|
||||
`
|
||||
|
||||
const authorityInterfaceHelperSource = `package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"example.com/dws-schema-compat-authority-test/internal/interfacesnapshot"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 || os.Args[1] != "generate" {
|
||||
fmt.Fprintln(os.Stderr, "expected generate")
|
||||
os.Exit(2)
|
||||
}
|
||||
flags := flag.NewFlagSet("generate", flag.ExitOnError)
|
||||
output := flags.String("output", "", "snapshot output")
|
||||
_ = flags.Parse(os.Args[2:])
|
||||
marker, err := os.ReadFile("SNAPSHOT_MARKER")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
marker = append(marker, []byte("INTERFACE_HELPER="+interfacesnapshot.AuthorityMarker()+"\n")...)
|
||||
if err := os.WriteFile(*output, marker, 0o600); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
fmt.Printf("BASE_INTERFACE_HELPER_GENERATE=%s\n", interfacesnapshot.AuthorityMarker())
|
||||
}
|
||||
`
|
||||
|
||||
const schemaGovernedSnapshotSource = `package interfacesnapshot
|
||||
|
||||
import "example.com/dws-schema-compat-authority-test/internal/corecmd/runtimeannotate"
|
||||
|
||||
var (
|
||||
_ = runtimeannotate.AnnotationFlagAliasOf
|
||||
_ = runtimeannotate.AnnotationFlagAliasOrigin
|
||||
_ = runtimeannotate.FlagAliasOriginCorecmdV1
|
||||
)
|
||||
|
||||
func AuthorityMarker() string { return "BASE" }
|
||||
`
|
||||
|
||||
const schemaBaseSnapshotSource = `package interfacesnapshot
|
||||
|
||||
func AuthorityMarker() string { return "BASE" }
|
||||
`
|
||||
|
||||
const schemaStableSnapshotSource = `package interfacesnapshot
|
||||
|
||||
func AuthorityMarker() string { return "STABLE" }
|
||||
`
|
||||
|
||||
const schemaCandidateSnapshotSource = `package interfacesnapshot
|
||||
|
||||
func AuthorityMarker() string { return "CANDIDATE" }
|
||||
`
|
||||
|
||||
const schemaAliasContractSource = `package runtimeannotate
|
||||
|
||||
const (
|
||||
AnnotationFlagAliasOf = "dws.compat.alias_of"
|
||||
AnnotationFlagAliasOrigin = "dws.compat.alias_origin"
|
||||
FlagAliasOriginCorecmdV1 = "corecmd.flag_spec_aliases.v1"
|
||||
)
|
||||
`
|
||||
|
||||
const candidateNoopSource = `package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
fmt.Println("CANDIDATE_NOOP")
|
||||
}
|
||||
`
|
||||
|
||||
func schemaBinarySource(marker string) string {
|
||||
return fmt.Sprintf(`package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
fmt.Println(%q)
|
||||
}
|
||||
`, `{"kind":"schema","marker":"`+marker+`"}`)
|
||||
}
|
||||
|
||||
type authorityScenario struct {
|
||||
name string
|
||||
baseGovernance string
|
||||
candidateMutation string
|
||||
want string
|
||||
authorityMarker string
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testing.T) {
|
||||
tests := []authorityScenario{
|
||||
{
|
||||
name: "governed base rejects candidate checker and live workspace",
|
||||
baseGovernance: "complete",
|
||||
authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED",
|
||||
},
|
||||
{
|
||||
name: "bootstrap uses old base checker and committed canonical empty ledger",
|
||||
baseGovernance: "none",
|
||||
authorityMarker: "OLD_BASE_SCHEMA_CHECKER_ENFORCED",
|
||||
},
|
||||
{
|
||||
name: "partial base fails closed",
|
||||
baseGovernance: "partial",
|
||||
want: "incomplete Schema flag migration governance artifact set",
|
||||
},
|
||||
{
|
||||
name: "base ledger ancestor symlink fails closed",
|
||||
baseGovernance: "ancestor-symlink",
|
||||
want: "incomplete Schema flag migration governance artifact set",
|
||||
},
|
||||
{
|
||||
name: "governed candidate must preserve schema checker",
|
||||
baseGovernance: "complete",
|
||||
candidateMutation: "delete-checker",
|
||||
want: "must preserve the complete Schema flag migration governance artifact set",
|
||||
},
|
||||
{
|
||||
name: "governed candidate must preserve migrations implementation",
|
||||
baseGovernance: "complete",
|
||||
candidateMutation: "delete-migrations",
|
||||
want: "must preserve the complete Schema flag migration governance artifact set",
|
||||
},
|
||||
{
|
||||
name: "governed candidate ledger must be a regular file",
|
||||
baseGovernance: "complete",
|
||||
candidateMutation: "symlink-ledger",
|
||||
want: "must preserve the complete Schema flag migration governance artifact set",
|
||||
},
|
||||
{
|
||||
name: "governed candidate helper parent must be a Git tree",
|
||||
baseGovernance: "complete",
|
||||
candidateMutation: "symlink-helper-parent",
|
||||
want: "must preserve the complete Schema flag migration governance artifact set",
|
||||
},
|
||||
{
|
||||
name: "candidate cannot forge alias evidence token",
|
||||
baseGovernance: "complete",
|
||||
candidateMutation: "forge-alias-token",
|
||||
want: "may not forge framework-owned flag alias evidence",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
runSchemaAuthorityCase(t, test)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runSchemaAuthorityCase(t *testing.T, test authorityScenario) {
|
||||
t.Helper()
|
||||
repoRoot := schemaRepositoryRoot(t)
|
||||
managedRoot := filepath.Join(repoRoot, ".worktrees", "schemacompat-authority-integration")
|
||||
if err := os.MkdirAll(managedRoot, 0o755); err != nil {
|
||||
t.Fatalf("create managed test root: %v", err)
|
||||
}
|
||||
fixtureRoot, err := os.MkdirTemp(managedRoot, fmt.Sprintf("case-%d-", time.Now().UnixNano()))
|
||||
if err != nil {
|
||||
t.Fatalf("create managed fixture repo: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := os.RemoveAll(fixtureRoot); err != nil {
|
||||
t.Errorf("remove managed fixture repo: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
schemaCopyFile(t,
|
||||
filepath.Join(repoRoot, "scripts", "policy", "check-authoritative-schema-compatibility.sh"),
|
||||
filepath.Join(fixtureRoot, "scripts", "policy", "check-authoritative-schema-compatibility.sh"),
|
||||
0o755,
|
||||
)
|
||||
schemaCopyFile(t,
|
||||
filepath.Join(repoRoot, "scripts", "policy", "policy-runtime.sh"),
|
||||
filepath.Join(fixtureRoot, "scripts", "policy", "policy-runtime.sh"),
|
||||
0o755,
|
||||
)
|
||||
schemaCopyFile(t,
|
||||
filepath.Join(repoRoot, "scripts", "release", "release-lib.sh"),
|
||||
filepath.Join(fixtureRoot, "scripts", "release", "release-lib.sh"),
|
||||
0o755,
|
||||
)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "go.mod"), "module example.com/dws-schema-compat-authority-test\n\ngo 1.23\n", 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "main.go"), schemaBinarySource("STABLE_RELEASE"), 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "interface-snapshot", "main.go"), candidateNoopSource, 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), schemaStableSnapshotSource, 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "compare.go"), "package interfacesnapshot\n", 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), bootstrapSchemaCheckerSource, 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "SNAPSHOT_MARKER"), "STABLE_RELEASE\n", 0o644)
|
||||
|
||||
schemaRun(t, fixtureRoot, "git", "init", "-b", "main")
|
||||
schemaRun(t, fixtureRoot, "git", "config", "user.name", "Schema Compatibility Test")
|
||||
schemaRun(t, fixtureRoot, "git", "config", "user.email", "schema-compat-test@example.invalid")
|
||||
schemaRun(t, fixtureRoot, "git", "add", ".")
|
||||
schemaRun(t, fixtureRoot, "git", "commit", "-m", "stable release")
|
||||
stableCommit := strings.TrimSpace(schemaRun(t, fixtureRoot, "git", "rev-parse", "HEAD"))
|
||||
schemaRun(t, fixtureRoot, "git", "tag", "v1.0.0", stableCommit)
|
||||
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "main.go"), schemaBinarySource("BASE_AUTHORITY"), 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "interface-snapshot", "main.go"), authorityInterfaceHelperSource, 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "SNAPSHOT_MARKER"), "BASE_AUTHORITY\n", 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), schemaBaseSnapshotSource, 0o644)
|
||||
switch test.baseGovernance {
|
||||
case "complete":
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), governedSchemaCheckerSource, 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), schemaGovernedSnapshotSource, 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go"), "package interfacesnapshot\n", 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go"), schemaAliasContractSource, 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"), "{\"version\":1,\"migrations\":[{\"authority\":\"BASE\"}]}\n", 0o644)
|
||||
case "partial":
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"), "{\"version\":1,\"migrations\":[]}\n", 0o644)
|
||||
case "ancestor-symlink":
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), governedSchemaCheckerSource, 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go"), "package interfacesnapshot\n", 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go"), "package runtimeannotate\n", 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "authority-ledger", "approved-flag-migrations-v1.json"), "{\"version\":1,\"migrations\":[]}\n", 0o644)
|
||||
if err := os.Symlink("authority-ledger", filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations")); err != nil {
|
||||
t.Fatalf("symlink base Schema ledger parent: %v", err)
|
||||
}
|
||||
case "none":
|
||||
default:
|
||||
t.Fatalf("unknown base governance state %q", test.baseGovernance)
|
||||
}
|
||||
schemaRun(t, fixtureRoot, "git", "add", ".")
|
||||
schemaRun(t, fixtureRoot, "git", "commit", "-m", "base authority")
|
||||
baseRef := strings.TrimSpace(schemaRun(t, fixtureRoot, "git", "rev-parse", "HEAD"))
|
||||
schemaRun(t, fixtureRoot, "git", "tag", "v1.1.0", baseRef)
|
||||
schemaRun(t, fixtureRoot, "git", "update-ref", "refs/tags/withdrawn/v1.1.0", baseRef)
|
||||
if test.baseGovernance == "ancestor-symlink" {
|
||||
ledgerPath := filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations")
|
||||
if err := os.Remove(ledgerPath); err != nil {
|
||||
t.Fatalf("remove inherited base ledger parent symlink: %v", err)
|
||||
}
|
||||
if err := os.Rename(
|
||||
filepath.Join(fixtureRoot, "scripts", "policy", "authority-ledger"),
|
||||
ledgerPath,
|
||||
); err != nil {
|
||||
t.Fatalf("restore regular candidate ledger tree: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "main.go"), schemaBinarySource("COMMITTED_CANDIDATE"), 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "interface-snapshot", "main.go"), candidateNoopSource, 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), candidateNoopSource, 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), schemaCandidateSnapshotSource, 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go"), "package interfacesnapshot\n", 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go"), "package runtimeannotate\n", 0o644)
|
||||
candidateLedger := "{\"version\":1,\"migrations\":[]}\n"
|
||||
if test.baseGovernance == "complete" {
|
||||
candidateLedger = "{\"version\":1,\"migrations\":[{\"candidate\":\"COMMITTED\"}]}\n"
|
||||
}
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"), candidateLedger, 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "SNAPSHOT_MARKER"), "COMMITTED_CANDIDATE\n", 0o644)
|
||||
switch test.candidateMutation {
|
||||
case "":
|
||||
case "delete-checker":
|
||||
if err := os.Remove(filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go")); err != nil {
|
||||
t.Fatalf("remove candidate schema checker: %v", err)
|
||||
}
|
||||
case "delete-migrations":
|
||||
if err := os.Remove(filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go")); err != nil {
|
||||
t.Fatalf("remove candidate migrations implementation: %v", err)
|
||||
}
|
||||
case "symlink-ledger":
|
||||
ledger := filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json")
|
||||
if err := os.Remove(ledger); err != nil {
|
||||
t.Fatalf("remove candidate ledger: %v", err)
|
||||
}
|
||||
if err := os.Symlink("../../../CANONICAL_EMPTY", ledger); err != nil {
|
||||
t.Fatalf("symlink candidate ledger: %v", err)
|
||||
}
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "CANONICAL_EMPTY"), "{\"version\":1,\"migrations\":[]}\n", 0o644)
|
||||
case "forge-alias-token":
|
||||
schemaWriteFile(t,
|
||||
filepath.Join(fixtureRoot, "internal", "evil", "forged.go"),
|
||||
"package evil\n\nconst forged = \"dws.compat.alias_of\"\n",
|
||||
0o644,
|
||||
)
|
||||
case "symlink-helper-parent":
|
||||
helperPath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot")
|
||||
alternatePath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot-alt")
|
||||
if err := os.Rename(helperPath, alternatePath); err != nil {
|
||||
t.Fatalf("move candidate Schema helper behind ancestor symlink: %v", err)
|
||||
}
|
||||
if err := os.Symlink("interfacesnapshot-alt", helperPath); err != nil {
|
||||
t.Fatalf("symlink candidate Schema helper parent: %v", err)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unknown candidate mutation %q", test.candidateMutation)
|
||||
}
|
||||
schemaRun(t, fixtureRoot, "git", "add", ".")
|
||||
schemaRun(t, fixtureRoot, "git", "commit", "-m", "candidate revision")
|
||||
candidateRef := strings.TrimSpace(schemaRun(t, fixtureRoot, "git", "rev-parse", "HEAD"))
|
||||
|
||||
// 让调用者工作区前进到另一提交,并放入可自批的二进制与 ledger;显式
|
||||
// candidate ref 必须隔离这些内容。
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "main.go"), schemaBinarySource("LIVE_WORKTREE_BINARY"), 0o644)
|
||||
liveLedger := filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json")
|
||||
if err := os.Remove(liveLedger); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatalf("remove selected candidate ledger from live revision: %v", err)
|
||||
}
|
||||
schemaWriteFile(t, liveLedger, "{\"version\":1,\"migrations\":[{\"live\":true}]}\n", 0o644)
|
||||
schemaRun(t, fixtureRoot, "git", "add", ".")
|
||||
schemaRun(t, fixtureRoot, "git", "commit", "-m", "unselected live revision")
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "dws"), "#!/bin/sh\nprintf '%s\\n' 'LIVE_WORKTREE_BINARY'\n", 0o755)
|
||||
|
||||
cmd := exec.Command(
|
||||
"sh",
|
||||
"scripts/policy/check-authoritative-schema-compatibility.sh",
|
||||
"--base-ref", baseRef,
|
||||
"--stable-ref", "v1.0.0",
|
||||
"--candidate-ref", candidateRef,
|
||||
)
|
||||
cmd.Dir = fixtureRoot
|
||||
cmd.Env = schemaIntegrationEnvironment(fixtureRoot)
|
||||
output, runErr := cmd.CombinedOutput()
|
||||
got := string(output)
|
||||
if runErr == nil {
|
||||
t.Fatalf("Schema compatibility script unexpectedly succeeded; output:\n%s", got)
|
||||
}
|
||||
for _, forbidden := range []string{"CANDIDATE_NOOP", "LIVE_WORKTREE_BINARY"} {
|
||||
if strings.Contains(got, forbidden) {
|
||||
t.Fatalf("candidate-controlled authority %q took effect; output:\n%s", forbidden, got)
|
||||
}
|
||||
}
|
||||
if test.authorityMarker != "" {
|
||||
if !strings.Contains(got, test.authorityMarker) {
|
||||
t.Fatalf("Schema compatibility script did not use base authority %q; output:\n%s", test.authorityMarker, got)
|
||||
}
|
||||
if test.baseGovernance == "complete" && strings.Count(got, "BASE_INTERFACE_HELPER_GENERATE=BASE") != 3 {
|
||||
t.Fatalf("governed Schema check did not generate three base-owned interface snapshots; output:\n%s", got)
|
||||
}
|
||||
|
||||
wrongStable := exec.Command(
|
||||
"sh",
|
||||
"scripts/policy/check-authoritative-schema-compatibility.sh",
|
||||
"--base-ref", baseRef,
|
||||
"--stable-ref", "HEAD",
|
||||
"--candidate-ref", candidateRef,
|
||||
)
|
||||
wrongStable.Dir = fixtureRoot
|
||||
wrongStable.Env = schemaIntegrationEnvironment(fixtureRoot)
|
||||
wrongOutput, wrongErr := wrongStable.CombinedOutput()
|
||||
if wrongErr == nil || !strings.Contains(string(wrongOutput), "is not the expected highest GA tag") {
|
||||
t.Fatalf("arbitrary Schema stable ref was not rejected: err=%v output:\n%s", wrongErr, wrongOutput)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !strings.Contains(got, test.want) {
|
||||
t.Fatalf("Schema compatibility script output missing %q:\n%s", test.want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func schemaRepositoryRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, sourceFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("resolve test source path")
|
||||
}
|
||||
root := filepath.Clean(filepath.Join(filepath.Dir(sourceFile), "..", "..", ".."))
|
||||
if _, err := os.Stat(filepath.Join(root, "scripts", "policy", "check-authoritative-schema-compatibility.sh")); err != nil {
|
||||
t.Fatalf("resolve repository root %s: %v", root, err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func schemaCopyFile(t *testing.T, source, destination string, mode os.FileMode) {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile(source)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", source, err)
|
||||
}
|
||||
schemaWriteFile(t, destination, string(content), mode)
|
||||
}
|
||||
|
||||
func schemaWriteFile(t *testing.T, path, content string, mode os.FileMode) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("create parent for %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), mode); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func schemaRun(t *testing.T, dir, name string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Dir = dir
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s failed: %v\n%s", name, strings.Join(args, " "), err, output)
|
||||
}
|
||||
return string(output)
|
||||
}
|
||||
|
||||
func schemaIntegrationEnvironment(fixtureRoot string) []string {
|
||||
overrides := map[string]string{
|
||||
"DWS_BIN": filepath.Join(fixtureRoot, "dws"),
|
||||
"DWS_POLICY_TMPDIR": "",
|
||||
"GOCACHE": filepath.Join(fixtureRoot, ".cache", "go-build"),
|
||||
"GOMODCACHE": filepath.Join(fixtureRoot, ".cache", "go-mod"),
|
||||
"GOTMPDIR": "",
|
||||
"GOTOOLCHAIN": "local",
|
||||
"GOWORK": "off",
|
||||
}
|
||||
environment := make([]string, 0, len(os.Environ())+len(overrides))
|
||||
for _, item := range os.Environ() {
|
||||
key, _, _ := strings.Cut(item, "=")
|
||||
if _, replaced := overrides[key]; !replaced {
|
||||
environment = append(environment, item)
|
||||
}
|
||||
}
|
||||
for key, value := range overrides {
|
||||
environment = append(environment, key+"="+value)
|
||||
}
|
||||
return environment
|
||||
}
|
||||
Reference in New Issue
Block a user