Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc332133a2 | ||
|
|
e615bd433c | ||
|
|
474ce88d47 |
@@ -60,14 +60,21 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
const protectedPaths = files
|
||||
.map(({ filename }) => filename)
|
||||
.filter((filename) =>
|
||||
const isProtectedPath = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename.startsWith('.github/workflows/') ||
|
||||
filename.startsWith('scripts/policy/') ||
|
||||
filename.startsWith('scripts/release/') ||
|
||||
filename === 'test/fixtures/cli-interface-baseline.txt' ||
|
||||
filename === '.goreleaser.yaml' ||
|
||||
filename === 'Makefile'
|
||||
);
|
||||
const protectedPaths = [...new Set(
|
||||
files
|
||||
.flatMap(({ filename, previous_filename }) => [filename, previous_filename])
|
||||
.filter(isProtectedPath)
|
||||
)];
|
||||
|
||||
if (protectedPaths.length > 0) {
|
||||
await setStatus('failure', 'Modifies protected release/CI infrastructure');
|
||||
|
||||
+37
-4
@@ -23,12 +23,15 @@ The repository defines five focused checks in addition to its existing CI:
|
||||
variance to avoid failing unchanged code on test-path noise. Set
|
||||
`COVERAGE_ENFORCE_OVERALL=true` once repository coverage reaches 80% to make
|
||||
the overall target fail closed as well.
|
||||
- **CLI Smoke** builds the release binary and renders offline help for the root
|
||||
and every public top-level command.
|
||||
- **CLI Smoke** builds the release binary, reads the root command list from the
|
||||
structured Interface contract, and renders offline help for every public
|
||||
top-level command. It rejects Cobra's unknown-command root-help fallback and
|
||||
fails when the checked-in development fixture is stale.
|
||||
- **Mock MCP Smoke** runs the existing HTTP and stdio MCP lifecycle tests
|
||||
(`Initialize -> ListTools -> CallTool`).
|
||||
- **AI Behavior Check** applies to pull requests labeled `ai-generated`. It
|
||||
limits the change to 30 files and blocks release/CI infrastructure changes.
|
||||
limits the change to 30 files and blocks release/CI infrastructure changes,
|
||||
including policy implementations and the checked-in Interface fixture.
|
||||
It uses `pull_request_target` without checking out PR code, so the policy
|
||||
cannot be bypassed by changing the workflow in the same pull request. The
|
||||
evaluator writes an `AI Behavior Check` commit status to the PR head SHA so
|
||||
@@ -45,11 +48,41 @@ make authoritative-interface-integrity BASE_REF=<merge-base>
|
||||
make schema-compatibility BASE_REF=<merge-base>
|
||||
make skill-command-integrity
|
||||
make cli-smoke
|
||||
make coverage-gate BASE_REF=<merge-base>
|
||||
# Run on the corresponding native runner with its generated profile:
|
||||
make coverage-gate-platform BASE_REF=<merge-base> PROFILE=<coverage-profile>
|
||||
```
|
||||
|
||||
`make coverage-gate` is the enforcement step, not a profile generator. It
|
||||
expects the candidate, policy, and merge-base profiles (`coverage.txt`,
|
||||
`coverage-policy.txt`, and `coverage-base.txt`) produced by the preceding CI
|
||||
steps. A clean local checkout can reproduce the Linux/overall CI gate with:
|
||||
|
||||
```sh
|
||||
base_ref=$(git merge-base HEAD origin/main)
|
||||
root=$(pwd)
|
||||
base_worktree=$(mktemp -d "${TMPDIR:-/tmp}/dws-coverage-base.XXXXXX")
|
||||
rmdir "$base_worktree"
|
||||
cleanup() { git worktree remove --force "$base_worktree" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
go test -count=1 -coverprofile=coverage.txt -covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic \
|
||||
./pkg/... ./scripts/policy/...
|
||||
git worktree add --detach "$base_worktree" "$base_ref"
|
||||
(
|
||||
cd "$base_worktree"
|
||||
go test -count=1 -coverprofile="$root/coverage-base.txt" -covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
)
|
||||
make coverage-gate BASE_REF="$base_ref"
|
||||
```
|
||||
|
||||
The native-platform target likewise expects `PROFILE` to have already been
|
||||
generated on that operating system. CI owns those generation steps; copying
|
||||
only either enforcement command into a clean checkout is intentionally an
|
||||
incomplete invocation.
|
||||
|
||||
CI derives the authoritative Interface snapshots from both the PR merge-base
|
||||
and the latest reachable stable release tag. The complete Schema snapshot comes
|
||||
from the PR merge-base, which contains the registry-first Schema introduced on
|
||||
|
||||
@@ -19,30 +19,104 @@ if [ ! -f "$BASELINE" ]; then
|
||||
fi
|
||||
|
||||
SNAPSHOT_HOME="$(mktemp -d)"
|
||||
trap 'rm -rf "$SNAPSHOT_HOME"' EXIT
|
||||
SNAPSHOT_BIN="$(mktemp)"
|
||||
CURRENT="$(mktemp)"
|
||||
trap 'rm -rf "$CURRENT" "$SNAPSHOT_HOME" "$SNAPSHOT_BIN"' EXIT
|
||||
|
||||
cd "$ROOT"
|
||||
go build -o "$SNAPSHOT_BIN" ./scripts/policy/interface-baseline
|
||||
HOME="$SNAPSHOT_HOME" DWS_DISABLE_KEYCHAIN=1 DWS_LANG=zh "$SNAPSHOT_BIN" >"$CURRENT"
|
||||
|
||||
root_commands() {
|
||||
awk '
|
||||
/^\[root\]$/ {
|
||||
in_root = 1
|
||||
next
|
||||
}
|
||||
/^\[/ {
|
||||
if (in_root) {
|
||||
exit
|
||||
}
|
||||
next
|
||||
}
|
||||
in_root {
|
||||
line = $0
|
||||
sub(/^[[:space:]]*/, "", line)
|
||||
if (line ~ /^commands:[[:space:]]*/) {
|
||||
sub(/^commands:[[:space:]]*/, "", line)
|
||||
gsub(/,[[:space:]]*/, " ", line)
|
||||
print line
|
||||
found = 1
|
||||
exit
|
||||
}
|
||||
}
|
||||
END {
|
||||
if (!found) {
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
' "$1"
|
||||
}
|
||||
|
||||
baseline_commands="$(root_commands "$BASELINE")" || {
|
||||
printf 'error: no root commands found in interface baseline\n' >&2
|
||||
exit 1
|
||||
}
|
||||
current_commands="$(root_commands "$CURRENT")" || {
|
||||
printf 'error: no root commands found in current interface contract\n' >&2
|
||||
exit 1
|
||||
}
|
||||
if [ "$baseline_commands" != "$current_commands" ]; then
|
||||
printf 'error: interface baseline root commands are stale (run make update-interface-baseline)\n' >&2
|
||||
printf ' baseline: %s\n current: %s\n' "$baseline_commands" "$current_commands" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# DWS_DISABLE_KEYCHAIN=1 routes the DEK to a file (the Linux scheme) instead of
|
||||
# the macOS system Keychain. Without it, running the binary under this fresh
|
||||
# HOME triggers a GUI Keychain authorization prompt that blocks the
|
||||
# non-interactive smoke test indefinitely. Linux CI already uses the file DEK.
|
||||
run_help() {
|
||||
capture_help() {
|
||||
HOME="$SNAPSHOT_HOME" DWS_DISABLE_KEYCHAIN=1 DWS_LANG=zh "$BIN" "$@" --help 2>&1
|
||||
}
|
||||
|
||||
root_help="$(capture_help)" || {
|
||||
printf 'error: dws --help exited non-zero\n%s\n' "$root_help" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
run_command_help() {
|
||||
output="$(HOME="$SNAPSHOT_HOME" DWS_DISABLE_KEYCHAIN=1 DWS_LANG=zh "$BIN" "$@" --help 2>&1)" || {
|
||||
printf 'error: dws %s --help exited non-zero\n%s\n' "$*" "$output" >&2
|
||||
return 1
|
||||
}
|
||||
if [ "$output" = "$root_help" ]; then
|
||||
printf 'error: dws %s --help resolved to root help instead of command-specific help\n' "$*" >&2
|
||||
return 1
|
||||
fi
|
||||
usage_line="$(printf '%s\n' "$output" | awk '/^Usage:$/ { getline; print; exit }')"
|
||||
expected_usage=" dws $*"
|
||||
case "$usage_line" in
|
||||
"$expected_usage"|"$expected_usage "*) ;;
|
||||
*)
|
||||
printf 'error: dws %s --help reported unexpected usage identity: %s\n' "$*" "$usage_line" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
run_help
|
||||
|
||||
command_line="$(sed -n '/^\[root\]$/{n;s/^ commands: //;p;q;}' "$BASELINE")"
|
||||
if [ -z "$command_line" ]; then
|
||||
printf 'error: no root commands found in interface baseline\n' >&2
|
||||
# Cobra currently treats an unknown command followed by --help as root help
|
||||
# with exit status 0. Keep a negative case here so exit status alone can never
|
||||
# make the smoke check pass for a command that did not resolve.
|
||||
unknown_command="__dws_cli_smoke_unknown_command__"
|
||||
if run_command_help "$unknown_command" >/dev/null 2>&1; then
|
||||
printf 'error: unknown command %s unexpectedly produced command-specific help\n' "$unknown_command" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
count=0
|
||||
for command in $(printf '%s\n' "$command_line" | tr -d ','); do
|
||||
run_help "$command"
|
||||
for command in $current_commands; do
|
||||
run_command_help "$command"
|
||||
count=$((count + 1))
|
||||
done
|
||||
|
||||
|
||||
@@ -491,7 +491,7 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri
|
||||
failures = append(failures, fmt.Sprintf("schema tool %q changed %s", toolPath, field.name))
|
||||
}
|
||||
}
|
||||
if !equalPositionals(oldTool.Positionals, newTool.Positionals) {
|
||||
if !compatiblePositionals(oldTool.Positionals, newTool.Positionals) {
|
||||
failures = append(failures, fmt.Sprintf("schema tool %q changed positionals", toolPath))
|
||||
}
|
||||
if oldTool.DryRun != "" && oldTool.DryRun != newTool.DryRun {
|
||||
@@ -510,12 +510,39 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri
|
||||
return failures
|
||||
}
|
||||
|
||||
func equalPositionals(oldPositionals, newPositionals []positionalSchema) bool {
|
||||
if len(oldPositionals) != len(newPositionals) {
|
||||
func compatiblePositionals(oldPositionals, newPositionals []positionalSchema) bool {
|
||||
if len(newPositionals) < len(oldPositionals) {
|
||||
return false
|
||||
}
|
||||
for index := range oldPositionals {
|
||||
if oldPositionals[index] != newPositionals[index] {
|
||||
for index, oldPositional := range oldPositionals {
|
||||
newPositional := newPositionals[index]
|
||||
if oldPositional.Name != newPositional.Name ||
|
||||
oldPositional.Index != newPositional.Index ||
|
||||
oldPositional.Type != newPositional.Type {
|
||||
return false
|
||||
}
|
||||
if !oldPositional.Required && newPositional.Required {
|
||||
return false
|
||||
}
|
||||
if oldPositional.Variadic && !newPositional.Variadic {
|
||||
return false
|
||||
}
|
||||
if !oldPositional.Variadic && newPositional.Variadic && index != len(newPositionals)-1 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if len(newPositionals) == len(oldPositionals) {
|
||||
return true
|
||||
}
|
||||
if len(oldPositionals) > 0 && newPositionals[len(oldPositionals)-1].Variadic {
|
||||
return false
|
||||
}
|
||||
for index := len(oldPositionals); index < len(newPositionals); index++ {
|
||||
if newPositionals[index].Required {
|
||||
return false
|
||||
}
|
||||
if index > len(oldPositionals) && newPositionals[index-1].Variadic {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +229,91 @@ func TestSchemaCompatibilityAllowsAdditionsAndLooserInputs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCompatibilityAllowsLooserAndAppendedOptionalPositionals(t *testing.T) {
|
||||
baseline := baselineContract()
|
||||
mutateTool(&baseline, func(tool *toolSchema) {
|
||||
tool.Positionals[0].Required = true
|
||||
})
|
||||
current := cloneContract(baseline)
|
||||
mutateTool(¤t, func(tool *toolSchema) {
|
||||
tool.Positionals[0].Required = false
|
||||
tool.Positionals = append(tool.Positionals, positionalSchema{
|
||||
Name: "template",
|
||||
Index: 1,
|
||||
Type: "string",
|
||||
})
|
||||
})
|
||||
|
||||
if failures := checkCompatibility(baseline, current); len(failures) != 0 {
|
||||
t.Fatalf("looser and appended optional positionals should pass: %v", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatiblePositionals(t *testing.T) {
|
||||
baseline := []positionalSchema{
|
||||
{Name: "content", Index: 0, Type: "string", Required: true},
|
||||
{Name: "format", Index: 1, Type: "string"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
old []positionalSchema
|
||||
current []positionalSchema
|
||||
compatible bool
|
||||
}{
|
||||
{name: "unchanged", old: baseline, current: clonePositionals(baseline), compatible: true},
|
||||
{name: "required becomes optional", old: baseline, current: []positionalSchema{
|
||||
{Name: "content", Index: 0, Type: "string"},
|
||||
{Name: "format", Index: 1, Type: "string"},
|
||||
}, compatible: true},
|
||||
{name: "append optional", old: baseline, current: append(clonePositionals(baseline), positionalSchema{
|
||||
Name: "template", Index: 2, Type: "string",
|
||||
}), compatible: true},
|
||||
{name: "last positional becomes variadic", old: baseline, current: []positionalSchema{
|
||||
{Name: "content", Index: 0, Type: "string", Required: true},
|
||||
{Name: "format", Index: 1, Type: "string", Variadic: true},
|
||||
}, compatible: true},
|
||||
{name: "removed", old: baseline, current: clonePositionals(baseline[:1])},
|
||||
{name: "renamed", old: baseline, current: []positionalSchema{
|
||||
{Name: "body", Index: 0, Type: "string", Required: true},
|
||||
{Name: "format", Index: 1, Type: "string"},
|
||||
}},
|
||||
{name: "reindexed", old: baseline, current: []positionalSchema{
|
||||
{Name: "content", Index: 1, Type: "string", Required: true},
|
||||
{Name: "format", Index: 2, Type: "string"},
|
||||
}},
|
||||
{name: "retyped", old: baseline, current: []positionalSchema{
|
||||
{Name: "content", Index: 0, Type: "number", Required: true},
|
||||
{Name: "format", Index: 1, Type: "string"},
|
||||
}},
|
||||
{name: "optional becomes required", old: baseline, current: []positionalSchema{
|
||||
{Name: "content", Index: 0, Type: "string", Required: true},
|
||||
{Name: "format", Index: 1, Type: "string", Required: true},
|
||||
}},
|
||||
{name: "append required", old: baseline, current: append(clonePositionals(baseline), positionalSchema{
|
||||
Name: "template", Index: 2, Type: "string", Required: true,
|
||||
})},
|
||||
{name: "variadic becomes fixed", old: []positionalSchema{
|
||||
{Name: "content", Index: 0, Type: "string", Variadic: true},
|
||||
}, current: []positionalSchema{
|
||||
{Name: "content", Index: 0, Type: "string"},
|
||||
}},
|
||||
{name: "append after variadic", old: []positionalSchema{
|
||||
{Name: "content", Index: 0, Type: "string", Variadic: true},
|
||||
}, current: []positionalSchema{
|
||||
{Name: "content", Index: 0, Type: "string", Variadic: true},
|
||||
{Name: "format", Index: 1, Type: "string"},
|
||||
}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := compatiblePositionals(test.old, test.current); got != test.compatible {
|
||||
t.Fatalf("compatiblePositionals() = %t, want %t", got, test.compatible)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCompatibilityRejectsContractDrift(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -386,6 +471,10 @@ func mutateParameter(contract *schemaContract, mutate func(*parameterSchema)) {
|
||||
})
|
||||
}
|
||||
|
||||
func clonePositionals(source []positionalSchema) []positionalSchema {
|
||||
return append([]positionalSchema(nil), source...)
|
||||
}
|
||||
|
||||
func writeTestFile(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
|
||||
+3735
-851
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAIBehaviorPolicyProtectsEnforcementInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "ai-behavior-check.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(ai-behavior-check.yml) error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", path, err)
|
||||
}
|
||||
|
||||
workflow := string(data)
|
||||
for _, want := range []string{
|
||||
"filename.startsWith('scripts/policy/')",
|
||||
"filename === 'test/fixtures/cli-interface-baseline.txt'",
|
||||
"previous_filename",
|
||||
} {
|
||||
if !strings.Contains(workflow, want) {
|
||||
t.Errorf("AI behavior policy does not protect %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user