Skip to content

Commit 8eba1fd

Browse files
Copilotmrjf
andauthored
Resolve merge conflicts with main
Co-authored-by: mrjf <180956+mrjf@users.noreply.github.com>
2 parents f3b4dd0 + e94f888 commit 8eba1fd

9 files changed

Lines changed: 859 additions & 5 deletions

File tree

.crane/scripts/score.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ type CutoverGates struct {
6262
HelpParity float64 `json:"help_parity"`
6363
FunctionalContracts float64 `json:"functional_contracts"`
6464
StateDiffContracts float64 `json:"state_diff_contracts"`
65+
PythonBehaviorContracts float64 `json:"python_behavior_contracts"`
6566
KnownExceptions int `json:"known_exceptions"`
6667
GoTests string `json:"go_tests"`
6768
PythonTests string `json:"python_tests"`
@@ -141,6 +142,7 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
141142
help := RatioGate{}
142143
functional := RatioGate{}
143144
stateDiff := RatioGate{}
145+
behaviorContracts := RatioGate{}
144146

145147
for scanner.Scan() {
146148
line := scanner.Text()
@@ -161,6 +163,8 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
161163
functional = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total}
162164
case "state_diff":
163165
stateDiff = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total}
166+
case "python_behavior_contracts":
167+
behaviorContracts = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total}
164168
case "known_exceptions":
165169
knownExceptions = gate.Count
166170
case "python_tests":
@@ -248,6 +252,9 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
248252
if !stateDiff.Seen {
249253
stateDiff = inferredAnyRatioGate(passed, failed, "TestParityCompletionStateDiffContracts", "TestParityStateDiffContracts")
250254
}
255+
if !behaviorContracts.Seen {
256+
behaviorContracts = inferredAnyRatioGate(passed, failed, "TestParityCompletionPythonBehaviorContracts")
257+
}
251258
if !pythonTests.Seen {
252259
pythonTests = BoolGate{Seen: true, Passed: testPassed(passed, failed, "TestParityCompletionPythonSuite")}
253260
}
@@ -262,6 +269,7 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
262269
HelpParity: help.Percent(),
263270
FunctionalContracts: functional.Percent(),
264271
StateDiffContracts: stateDiff.Percent(),
272+
PythonBehaviorContracts: behaviorContracts.Percent(),
265273
KnownExceptions: knownExceptions,
266274
GoTests: passFail(goTestsPass),
267275
PythonTests: passFail(pythonTests.OK()),
@@ -283,6 +291,7 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
283291
gates.HelpParity == 1.0 &&
284292
gates.FunctionalContracts == 1.0 &&
285293
gates.StateDiffContracts == 1.0 &&
294+
gates.PythonBehaviorContracts == 1.0 &&
286295
gates.KnownExceptions == 0 &&
287296
gates.GoTests == "pass" &&
288297
gates.PythonTests == "pass" &&
@@ -396,6 +405,7 @@ func gateResults(gates CutoverGates) []GateResult {
396405
{Name: "help_parity", Passing: gates.HelpParity == 1.0},
397406
{Name: "functional_contracts", Passing: gates.FunctionalContracts == 1.0},
398407
{Name: "state_diff_contracts", Passing: gates.StateDiffContracts == 1.0},
408+
{Name: "python_behavior_contracts", Passing: gates.PythonBehaviorContracts == 1.0},
399409
{Name: "python_tests_pass", Passing: gates.PythonTests == "pass"},
400410
{Name: "benchmarks_pass", Passing: gates.Benchmarks == "pass"},
401411
{Name: "no_known_exceptions", Passing: gates.KnownExceptions == 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
@@ -97,6 +125,8 @@ jobs:
97125
if score.get("migration_score") == 1.0 and not score.get("deletion_grade_ready"):
98126
raise SystemExit("migration_score 1.0 requires deletion_grade_ready")
99127
PY
128+
test "${PYTHON_CLI_CONTRACT_STATUS:-1}" = "0"
129+
test "${GO_TEST_STATUS:-1}" = "0"
100130
101131
- name: Upload parity evidence
102132
if: always()
@@ -106,6 +136,9 @@ jobs:
106136
path: |
107137
${{ runner.temp }}/go-test-events.json
108138
${{ runner.temp }}/migration-score.json
139+
${{ runner.temp }}/python-behavior-contracts.json
140+
${{ runner.temp }}/python-contract-coverage.md
141+
${{ runner.temp }}/python-cli-contract-tests.txt
109142
if-no-files-found: ignore
110143
retention-days: 14
111144

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)