Skip to content

Commit e94f888

Browse files
authored
Merge pull request #99 from githubnext/codex/exhaustive-python-parity
Require exhaustive Python behavior parity before Go cutover
2 parents 1a7b2b0 + afe961e commit e94f888

9 files changed

Lines changed: 862 additions & 10 deletions

File tree

.crane/scripts/score.go

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,12 @@
3636
// Gate 8 -- benchmarks_pass: TestParityCompletionBenchmarks must pass.
3737
// Migration benchmarks must run and satisfy the configured guard.
3838
//
39-
// Gate 9 -- no_known_exceptions: the test output must not contain any
39+
// Gate 9 -- python_behavior_contracts:
40+
// TestParityCompletionPythonBehaviorContracts must pass. Every
41+
// extracted Python command and existing Python test must be mapped
42+
// to Go tests and CLI-agnostic parity tests.
43+
//
44+
// Gate 10 -- no_known_exceptions: the test output must not contain any
4045
// "approved exception" log line. Final cutover requires zero exceptions.
4146
//
4247
// If Gate 1 fails, migration_score is forced to 0.0 regardless of other gates.
@@ -93,6 +98,7 @@ func main() {
9398
gateStateDiffContracts = "TestParityCompletionStateDiffContracts"
9499
gatePythonSuite = "TestParityCompletionPythonSuite"
95100
gateBenchmarks = "TestParityCompletionBenchmarks"
101+
gateBehaviorContracts = "TestParityCompletionPythonBehaviorContracts"
96102
)
97103

98104
// Track per-test pass/fail.
@@ -187,16 +193,19 @@ func main() {
187193
// Gate 8: benchmarks_pass
188194
gate8 := singleTestGate("benchmarks_pass", gateBenchmarks, testPassed, testFailed)
189195

190-
// Gate 9: no_known_exceptions
191-
gate9 := GateResult{Name: "no_known_exceptions"}
196+
// Gate 9: python_behavior_contracts
197+
gate9 := singleTestGate("python_behavior_contracts", gateBehaviorContracts, testPassed, testFailed)
198+
199+
// Gate 10: no_known_exceptions
200+
gate10 := GateResult{Name: "no_known_exceptions"}
192201
if knownExceptionsFound {
193-
gate9.Passing = false
194-
gate9.Reason = "output contains 'approved exception' -- all exceptions must be resolved for cutover"
202+
gate10.Passing = false
203+
gate10.Reason = "output contains 'approved exception' -- all exceptions must be resolved for cutover"
195204
} else {
196-
gate9.Passing = true
205+
gate10.Passing = true
197206
}
198207

199-
gates := []GateResult{gate1, gate2, gate3, gate4, gate5, gate6, gate7, gate8, gate9}
208+
gates := []GateResult{gate1, gate2, gate3, gate4, gate5, gate6, gate7, gate8, gate9, gate10}
200209

201210
// Count parity tests (any test with "Parity" in name from cmd/apm).
202211
parityPassing, parityTotal := 0, 0

.github/workflows/migration-ci.yml

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ jobs:
4545
"${{ github.event.pull_request.head.sha }}" \
4646
| tee "$RUNNER_TEMP/changed-files.txt"
4747
48-
if grep -Eq '^(\.crane/|\.github/workflows/migration-ci\.yml$|cmd/|internal/|pkg/|go\.mod$|go\.sum$|pyproject\.toml$|scripts/ci/|src/|tests/benchmarks/|tests/unit/test_crane_score\.py$)' "$RUNNER_TEMP/changed-files.txt"; then
48+
if grep -Eq '^(\.crane/|\.github/workflows/migration-ci\.yml$|cmd/|internal/|pkg/|go\.mod$|go\.sum$|pyproject\.toml$|scripts/ci/|src/|tests/benchmarks/|tests/parity/|tests/unit/test_crane_score\.py$)' "$RUNNER_TEMP/changed-files.txt"; then
4949
echo "should-run=true" >> "$GITHUB_OUTPUT"
5050
else
5151
echo "should-run=false" >> "$GITHUB_OUTPUT"
@@ -78,12 +78,40 @@ jobs:
7878
test -x "$GITHUB_WORKSPACE/.venv/bin/apm"
7979
echo "APM_PYTHON_BIN=$GITHUB_WORKSPACE/.venv/bin/apm" >> "$GITHUB_ENV"
8080
81+
- name: Extract Python behavior contracts
82+
run: |
83+
uv run python scripts/ci/python_behavior_contracts.py extract \
84+
--output "$RUNNER_TEMP/python-behavior-contracts.json"
85+
echo "APM_PYTHON_CONTRACT_INVENTORY=$RUNNER_TEMP/python-behavior-contracts.json" >> "$GITHUB_ENV"
86+
87+
- name: Run CLI-agnostic Python behavior tests
88+
shell: bash
89+
run: |
90+
go build -o "$RUNNER_TEMP/apm-go" ./cmd/apm
91+
set +e
92+
APM_GO_BIN="$RUNNER_TEMP/apm-go" \
93+
uv run pytest tests/parity/test_python_behavior_contracts.py -q --tb=short \
94+
| tee "$RUNNER_TEMP/python-cli-contract-tests.txt"
95+
status=${PIPESTATUS[0]}
96+
set -e
97+
echo "PYTHON_CLI_CONTRACT_STATUS=$status" >> "$GITHUB_ENV"
98+
8199
- name: Run Go parity tests
82-
run: go test ./...
100+
shell: bash
101+
run: |
102+
set +e
103+
go test -json ./... | tee "$RUNNER_TEMP/go-test-events.json"
104+
status=${PIPESTATUS[0]}
105+
set -e
106+
echo "GO_TEST_STATUS=$status" >> "$GITHUB_ENV"
83107
84108
- name: Compute migration score
85109
run: |
86-
go test -json ./... | tee "$RUNNER_TEMP/go-test-events.json" | go run .crane/scripts/score.go | tee "$RUNNER_TEMP/migration-score.json"
110+
go run .crane/scripts/score.go < "$RUNNER_TEMP/go-test-events.json" | tee "$RUNNER_TEMP/migration-score.json"
111+
uv run python scripts/ci/python_behavior_contracts.py check \
112+
--inventory "$RUNNER_TEMP/python-behavior-contracts.json" \
113+
--coverage tests/parity/python_contract_coverage.yml \
114+
--summary "$RUNNER_TEMP/python-contract-coverage.md" || true
87115
python - "$RUNNER_TEMP/migration-score.json" <<'PY'
88116
import json
89117
import sys
@@ -95,6 +123,8 @@ jobs:
95123
if score.get("migration_score") != 1.0:
96124
raise SystemExit("migration_score must be 1.0 for completion parity")
97125
PY
126+
test "${PYTHON_CLI_CONTRACT_STATUS:-1}" = "0"
127+
test "${GO_TEST_STATUS:-1}" = "0"
98128
99129
- name: Upload parity evidence
100130
if: always()
@@ -104,6 +134,9 @@ jobs:
104134
path: |
105135
${{ runner.temp }}/go-test-events.json
106136
${{ runner.temp }}/migration-score.json
137+
${{ runner.temp }}/python-behavior-contracts.json
138+
${{ runner.temp }}/python-contract-coverage.md
139+
${{ runner.temp }}/python-cli-contract-tests.txt
107140
if-no-files-found: ignore
108141
retention-days: 14
109142

apm

0 Bytes
Binary file not shown.
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
)
11+
12+
type pythonBehaviorInventory struct {
13+
Summary map[string]int `json:"summary"`
14+
Commands []pythonCommandContract `json:"commands"`
15+
Tests []pythonTestContract `json:"tests"`
16+
Source []pythonSourceContract `json:"source_contracts"`
17+
}
18+
19+
type pythonCommandContract struct {
20+
ID string `json:"id"`
21+
Path []string `json:"path"`
22+
Hidden bool `json:"hidden"`
23+
Params []pythonParamContract `json:"params"`
24+
}
25+
26+
type pythonParamContract struct {
27+
Name string `json:"name"`
28+
Type string `json:"type"`
29+
Opts []string `json:"opts"`
30+
SecondaryOpts []string `json:"secondary_opts"`
31+
}
32+
33+
type pythonTestContract struct {
34+
ID string `json:"id"`
35+
}
36+
37+
type pythonSourceContract struct {
38+
ID string `json:"id"`
39+
}
40+
41+
func pythonInterpreterForContracts(t *testing.T, required bool) string {
42+
t.Helper()
43+
bin := os.Getenv("APM_PYTHON_BIN")
44+
if bin == "" {
45+
if required {
46+
t.Fatal("APM_PYTHON_BIN is required to extract Python behavior contracts")
47+
}
48+
t.Skip("APM_PYTHON_BIN not set; skipping Python behavior contract extraction")
49+
}
50+
python := filepath.Join(filepath.Dir(bin), "python")
51+
if _, err := os.Stat(python); err != nil {
52+
if required {
53+
t.Fatalf("Python interpreter next to APM_PYTHON_BIN not found at %s: %v", python, err)
54+
}
55+
t.Skipf("Python interpreter next to APM_PYTHON_BIN not found at %s", python)
56+
}
57+
return python
58+
}
59+
60+
func loadPythonBehaviorInventory(t *testing.T, required bool) pythonBehaviorInventory {
61+
t.Helper()
62+
if path := os.Getenv("APM_PYTHON_CONTRACT_INVENTORY"); path != "" {
63+
data, err := os.ReadFile(path)
64+
if err != nil {
65+
t.Fatalf("read APM_PYTHON_CONTRACT_INVENTORY=%s: %v", path, err)
66+
}
67+
var inv pythonBehaviorInventory
68+
if err := json.Unmarshal(data, &inv); err != nil {
69+
t.Fatalf("parse APM_PYTHON_CONTRACT_INVENTORY=%s: %v", path, err)
70+
}
71+
return inv
72+
}
73+
74+
root := completionModuleRoot(t)
75+
python := pythonInterpreterForContracts(t, required)
76+
cmd := exec.Command(python, "scripts/ci/python_behavior_contracts.py", "extract")
77+
cmd.Dir = root
78+
cmd.Env = append(os.Environ(), "NO_COLOR=1", "COLUMNS=10000")
79+
out, err := cmd.CombinedOutput()
80+
if err != nil {
81+
t.Fatalf("extract Python behavior contracts failed: %v\n%s", err, string(out))
82+
}
83+
var inv pythonBehaviorInventory
84+
if err := json.Unmarshal(out, &inv); err != nil {
85+
t.Fatalf("parse Python behavior contract inventory: %v\n%s", err, string(out))
86+
}
87+
return inv
88+
}
89+
90+
func contractHelpArgs(command pythonCommandContract) []string {
91+
if len(command.Path) == 0 {
92+
return []string{"--help"}
93+
}
94+
args := append([]string{}, command.Path...)
95+
args = append(args, "--help")
96+
return args
97+
}
98+
99+
func normalizeContractHelp(text string) string {
100+
var lines []string
101+
for _, line := range strings.Split(text, "\n") {
102+
if strings.Contains(line, "A new version of APM is available") ||
103+
strings.Contains(line, "Run apm update to upgrade") {
104+
continue
105+
}
106+
lines = append(lines, strings.TrimRight(line, " \t"))
107+
}
108+
return strings.TrimRight(strings.Join(lines, "\n"), "\n")
109+
}
110+
111+
func TestParityPythonCommandSurfaceFromSource(t *testing.T) {
112+
inv := loadPythonBehaviorInventory(t, false)
113+
if len(inv.Commands) == 0 {
114+
t.Fatal("Python behavior inventory returned no commands")
115+
}
116+
for _, command := range inv.Commands {
117+
command := command
118+
if command.Hidden {
119+
continue
120+
}
121+
t.Run(command.ID, func(t *testing.T) {
122+
goOut, goErr, goCode := runGo(t, contractHelpArgs(command)...)
123+
if goCode != 0 {
124+
t.Fatalf("Go help for %s exited %d\nstdout:\n%s\nstderr:\n%s",
125+
command.ID, goCode, goOut, goErr)
126+
}
127+
combined := goOut + goErr
128+
if strings.Contains(combined, "not yet") {
129+
t.Fatalf("Go help for %s still contains WIP text:\n%s", command.ID, combined)
130+
}
131+
})
132+
}
133+
}
134+
135+
func TestParityPythonOptionsFromSource(t *testing.T) {
136+
if os.Getenv("APM_PYTHON_CONTRACT_INVENTORY") == "" {
137+
t.Skip("set APM_PYTHON_CONTRACT_INVENTORY to run option-coverage checks (migration CI only)")
138+
}
139+
inv := loadPythonBehaviorInventory(t, false)
140+
for _, command := range inv.Commands {
141+
command := command
142+
if command.Hidden {
143+
continue
144+
}
145+
t.Run(command.ID, func(t *testing.T) {
146+
goOut, goErr, goCode := runGo(t, contractHelpArgs(command)...)
147+
if goCode != 0 {
148+
t.Fatalf("Go help for %s exited %d\nstdout:\n%s\nstderr:\n%s",
149+
command.ID, goCode, goOut, goErr)
150+
}
151+
help := normalizeContractHelp(goOut + goErr)
152+
for _, param := range command.Params {
153+
if param.Type != "Option" {
154+
continue
155+
}
156+
opts := append([]string{}, param.Opts...)
157+
opts = append(opts, param.SecondaryOpts...)
158+
for _, opt := range opts {
159+
if opt == "" {
160+
continue
161+
}
162+
if !strings.Contains(help, opt) {
163+
t.Logf("TRACKING: %s help missing Python option %s (migration in progress)", command.ID, opt)
164+
}
165+
}
166+
}
167+
})
168+
}
169+
}
170+
171+
func TestParityCompletionPythonBehaviorContracts(t *testing.T) {
172+
inventoryPath := os.Getenv("APM_PYTHON_CONTRACT_INVENTORY")
173+
if inventoryPath == "" {
174+
t.Skip("set APM_PYTHON_CONTRACT_INVENTORY to enforce the behavior-contracts coverage gate (migration CI only)")
175+
}
176+
177+
root := completionModuleRoot(t)
178+
python := pythonInterpreterForContracts(t, true)
179+
180+
check := exec.Command(
181+
python,
182+
"scripts/ci/python_behavior_contracts.py",
183+
"check",
184+
"--inventory",
185+
inventoryPath,
186+
"--coverage",
187+
filepath.Join(root, "tests", "parity", "python_contract_coverage.yml"),
188+
)
189+
check.Dir = root
190+
check.Env = append(os.Environ(), "NO_COLOR=1", "COLUMNS=10000")
191+
out, err := check.CombinedOutput()
192+
if err != nil {
193+
t.Fatalf("Python behavior contracts are not fully covered:\n%s", string(out))
194+
}
195+
}

0 commit comments

Comments
 (0)