Compare commits

..
Author SHA1 Message Date
修雨 bc332133a2 fix(ci): sync public interface baseline 2026-07-16 10:32:38 +08:00
修雨 e615bd433c fix: surface invalid sheet and todo targets (#623)
* fix: surface invalid sheet and todo targets

* docs: record invalid target fixes

* fix: expose todo attachment listing schema

* fix: make Windows helper coverage portable

* test: run quality regressions in platform coverage
2026-07-16 10:24:58 +08:00
修雨 474ce88d47 fix(ci): harden CLI smoke and Schema compatibility gates (#629)
* fix(ci): harden PR gate enforcement

* fix(schema): allow compatible positional evolution
2026-07-16 09:59:21 +08:00
7 changed files with 4021 additions and 872 deletions
+10 -3
View File
@@ -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
View File
@@ -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
+83 -9
View File
@@ -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
+32 -5
View File
@@ -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
}
}
+89
View File
@@ -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(&current, 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
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -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)
}
}
}