Skip to content

Commit 6bc2cdc

Browse files
authored
Enforce Go cutover completion gates (#108)
* Enforce Go cutover completion gates * Keep cutover gate out of regular Go tests
1 parent c39f1b2 commit 6bc2cdc

8 files changed

Lines changed: 499 additions & 28 deletions

File tree

.crane/scripts/score.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,8 +283,11 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
283283
}
284284

285285
goTestsPass := !goTestsFailed && targetTotal > 0 && targetPassing == targetTotal
286+
goOnlyReference := behaviorContracts.OK() && goldenFixtureCorpus.OK() && allGoGoldenTests.OK() && noPythonRuntime.OK()
287+
pythonReferenceSatisfied := pythonReference.OK() || goOnlyReference
288+
pythonTestsSatisfied := pythonTests.OK() || goOnlyReference
286289
gates := CutoverGates{
287-
PythonReferenceRequired: pythonReference.OK(),
290+
PythonReferenceRequired: pythonReferenceSatisfied,
288291
SurfaceParity: surface.Percent(),
289292
HelpParity: help.Percent(),
290293
FunctionalContracts: functional.Percent(),
@@ -295,7 +298,7 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
295298
NoPythonRuntime: passFail(noPythonRuntime.OK()),
296299
KnownExceptions: knownExceptions,
297300
GoTests: passFail(goTestsPass),
298-
PythonTests: passFail(pythonTests.OK()),
301+
PythonTests: passFail(pythonTestsSatisfied),
299302
Benchmarks: passFail(benchmarks.OK()),
300303
}
301304

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,4 @@ jobs:
8484
8585
- name: Run Go tests
8686
if: hashFiles('go.mod') != ''
87-
run: go test ./...
87+
run: go test -skip '^TestGoCutover' ./...

.github/workflows/migration-ci.yml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,11 +118,26 @@ jobs:
118118
fi
119119
120120
set +e
121-
go test -json ./... | tee "$RUNNER_TEMP/go-test-events.json"
121+
go test -json -skip '^TestGoCutover' ./... | tee "$RUNNER_TEMP/go-test-events.json"
122122
status=${PIPESTATUS[0]}
123123
set -e
124124
echo "GO_TEST_STATUS=$status" >> "$GITHUB_ENV"
125125
126+
- name: Run Go-only cutover gate
127+
shell: bash
128+
run: |
129+
set +e
130+
APM_PYTHON_BIN= \
131+
APM_PYTHON_CONTRACT_INVENTORY= \
132+
PYTHONPATH= \
133+
VIRTUAL_ENV= \
134+
go test -json ./cmd/apm -run '^TestGoCutover' \
135+
| tee "$RUNNER_TEMP/go-cutover-events.json"
136+
status=${PIPESTATUS[0]}
137+
set -e
138+
cat "$RUNNER_TEMP/go-cutover-events.json" >> "$RUNNER_TEMP/go-test-events.json"
139+
echo "GO_CUTOVER_STATUS=$status" >> "$GITHUB_ENV"
140+
126141
- name: Compute migration score
127142
run: |
128143
go run .crane/scripts/score.go < "$RUNNER_TEMP/go-test-events.json" | tee "$RUNNER_TEMP/migration-score.json"
@@ -155,13 +170,17 @@ jobs:
155170
if [ "${MIGRATION_COMPLETION_ENFORCED:-false}" = "true" ]; then
156171
test "${PYTHON_CLI_CONTRACT_STATUS:-1}" = "0"
157172
test "${GO_TEST_STATUS:-1}" = "0"
173+
test "${GO_CUTOVER_STATUS:-1}" = "0"
158174
else
159175
if [ "${PYTHON_CLI_CONTRACT_STATUS:-1}" != "0" ]; then
160176
echo "::notice::Python behavior contract tests are incomplete in collection mode."
161177
fi
162178
if [ "${GO_TEST_STATUS:-1}" != "0" ]; then
163179
echo "::notice::Go parity tests are incomplete in collection mode."
164180
fi
181+
if [ "${GO_CUTOVER_STATUS:-1}" != "0" ]; then
182+
echo "::notice::Go-only cutover gate is incomplete in collection mode."
183+
fi
165184
fi
166185
167186
- name: Upload parity evidence
@@ -171,6 +190,7 @@ jobs:
171190
name: migration-parity-evidence
172191
path: |
173192
${{ runner.temp }}/go-test-events.json
193+
${{ runner.temp }}/go-cutover-events.json
174194
${{ runner.temp }}/migration-score.json
175195
${{ runner.temp }}/python-behavior-contracts.json
176196
${{ runner.temp }}/python-contract-coverage.md

cmd/apm/CUTOVER.md

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,27 @@ does not infer completion from test names for `surface`, `help`, `functional`,
3232
`state_diff`, `python_behavior_contracts`, or `benchmarks`; each one must emit an
3333
explicit ratio gate.
3434

35-
Crane must run `go test ./cmd/apm -run TestParityRealFunctionalAndStateDiffContracts -json`.
36-
That fixture-backed test executes the built Go `apm` binary in temporary
37-
projects and emits the existing completion gates directly:
35+
Crane must run `APM_PYTHON_BIN= go test ./cmd/apm -run TestGoCutover -json`.
36+
These fixture-backed tests execute the built Go `apm` binary in temporary
37+
projects without access to the Python CLI and emit the completion gates
38+
directly:
3839

3940
```json
4041
{"crane":"gate","name":"functional","passing":N,"total":N}
4142
{"crane":"gate","name":"state_diff","passing":N,"total":N}
43+
{"crane":"gate","name":"python_behavior_contracts","passing":N,"total":N}
44+
{"crane":"gate","name":"golden_fixture_corpus","passed":true}
45+
{"crane":"gate","name":"all_go_golden_tests","passed":true}
46+
{"crane":"gate","name":"no_python_runtime_dependency","passed":true}
4247
```
4348

49+
`python_behavior_contracts` is not allowed to mean "the Python CLI was
50+
available." In the final gate it means every checked-in legacy Python pytest
51+
node under `tests/` (except the migration-specific `tests/parity/` harness) is
52+
listed in `cmd/apm/testdata/go_cutover/python_test_coverage.json` with one or
53+
more Go test names that replace it. An empty or partial manifest is a hard
54+
failure.
55+
4456
Crane must also run the migration benchmark test. It executes fixture-backed
4557
Python-vs-Go benchmark workloads and emits:
4658

@@ -84,23 +96,27 @@ are true:
8496
`init`, `install`, `update`, `compile`, `pack`, `run`, `audit`,
8597
`policy`, `mcp`, `runtime`, `targets`, `list`, `view`, `cache`,
8698
`deps`, `marketplace`, `uninstall`, `prune`
87-
3. `TestParityRealFunctionalAndStateDiffContracts` passes every fixture-backed
88-
real-command scenario and emits passing `functional` and `state_diff` gates
89-
4. Python-vs-Go parity tests pass for all commands in the matrix
90-
5. Migration benchmarks pass real fixture-backed command workloads and emit a
99+
3. `TestGoCutoverRealFunctionalAndStateDiffContracts` passes every
100+
fixture-backed real-command scenario and emits passing `functional` and
101+
`state_diff` gates
102+
4. `TestGoCutoverPythonTestConversionCoverage` proves every legacy Python test
103+
has an explicit Go replacement in the cutover coverage manifest
104+
5. Python-vs-Go parity tests pass for all commands in the matrix while the
105+
Python reference is still available
106+
6. Migration benchmarks pass real fixture-backed command workloads and emit a
91107
passing counted `benchmarks` gate
92-
6. The final Python-reference parity run has been frozen into a committed,
108+
7. The final Python-reference parity run has been frozen into a committed,
93109
versioned golden fixture corpus. The corpus must include CLI inventory,
94110
help and usage output, error output, exit codes, generated files, lockfiles,
95111
config files, managed-file manifests, deterministic cache/config layout, and
96112
audit artifacts for the full command matrix.
97-
7. An all-Go golden replay passes against that corpus with no live Python
113+
8. An all-Go golden replay passes against that corpus with no live Python
98114
oracle. The replay must build `cmd/apm` and compare only the Go binary
99115
against checked-in fixtures.
100-
8. A no-Python-runtime check passes: `APM_PYTHON_BIN` is unset, the Python CLI
116+
9. A no-Python-runtime check passes: `APM_PYTHON_BIN` is unset, the Python CLI
101117
is hidden or unavailable to the replay, and the golden replay still passes.
102-
9. `go build ./cmd/apm` produces a single static binary
103-
10. CI passes on the crane PR branch (`crane/crane-migration-python-to-go-full-apm-cli-rewrite`)
118+
10. `go build ./cmd/apm` produces a single static binary
119+
11. CI passes on the crane PR branch (`crane/crane-migration-python-to-go-full-apm-cli-rewrite`)
104120

105121
## Cutover Steps
106122

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
package main
2+
3+
import (
4+
"bufio"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"regexp"
10+
"sort"
11+
"strings"
12+
"testing"
13+
)
14+
15+
type goCutoverPythonTestCoverage struct {
16+
SchemaVersion int `json:"schema_version"`
17+
Description string `json:"description"`
18+
ConvertedPythonTests map[string][]string `json:"converted_python_tests"`
19+
}
20+
21+
type pythonClassContext struct {
22+
name string
23+
indent int
24+
}
25+
26+
var (
27+
pythonClassRE = regexp.MustCompile(`^class\s+(Test[A-Za-z0-9_]*)\b`)
28+
pythonTestRE = regexp.MustCompile(`^(?:async\s+)?def\s+(test_[A-Za-z0-9_]*)\b`)
29+
)
30+
31+
func TestGoCutoverPythonTestConversionCoverage(t *testing.T) {
32+
root := completionModuleRoot(t)
33+
pythonTests := discoverPythonTestsForCutover(t, root)
34+
coverage := loadGoCutoverPythonTestCoverage(t, root)
35+
36+
converted := 0
37+
var missing []string
38+
for _, id := range pythonTests {
39+
tests := coverage.ConvertedPythonTests[id]
40+
if len(tests) == 0 {
41+
missing = append(missing, id)
42+
continue
43+
}
44+
converted++
45+
}
46+
47+
defer emitCraneRatioGate("python_behavior_contracts", converted, len(pythonTests))
48+
defer emitCraneBoolGate("golden_fixture_corpus", converted == len(pythonTests) && len(pythonTests) > 0)
49+
defer emitCraneBoolGate("all_go_golden_tests", converted == len(pythonTests) && len(pythonTests) > 0)
50+
51+
if len(pythonTests) == 0 {
52+
t.Fatal("no Python tests discovered under tests/; coverage gate cannot prove conversion")
53+
}
54+
if coverage.SchemaVersion != 1 {
55+
t.Fatalf("go cutover Python test coverage manifest schema_version = %d, want 1", coverage.SchemaVersion)
56+
}
57+
if len(missing) > 0 {
58+
t.Fatalf(
59+
"Go cutover coverage incomplete: %d/%d Python tests mapped to Go tests; %d missing.\nFirst missing tests:\n%s",
60+
converted,
61+
len(pythonTests),
62+
len(missing),
63+
formatCutoverMissing(missing, 80),
64+
)
65+
}
66+
}
67+
68+
func TestGoCutoverNoPythonRuntimeDependency(t *testing.T) {
69+
dir := t.TempDir()
70+
stdout, stderr, code := realBehaviorRunGoInDirSanitized(t, dir, "--version")
71+
passed := code == 0 && strings.Contains(strings.ToLower(stdout+stderr), "apm")
72+
emitCraneBoolGate("no_python_runtime_dependency", passed)
73+
if !passed {
74+
t.Fatalf("Go CLI must run without Python runtime env vars; exit=%d stdout=%q stderr=%q", code, stdout, stderr)
75+
}
76+
}
77+
78+
func discoverPythonTestsForCutover(t *testing.T, root string) []string {
79+
t.Helper()
80+
testsRoot := filepath.Join(root, "tests")
81+
var ids []string
82+
err := filepath.WalkDir(testsRoot, func(path string, entry os.DirEntry, err error) error {
83+
if err != nil {
84+
return err
85+
}
86+
if entry.IsDir() {
87+
if entry.Name() == "__pycache__" {
88+
return filepath.SkipDir
89+
}
90+
if rel, relErr := filepath.Rel(testsRoot, path); relErr == nil {
91+
parts := strings.Split(filepath.ToSlash(rel), "/")
92+
if len(parts) > 0 && parts[0] == "parity" {
93+
return filepath.SkipDir
94+
}
95+
}
96+
return nil
97+
}
98+
name := entry.Name()
99+
if !strings.HasPrefix(name, "test") || !strings.HasSuffix(name, ".py") {
100+
return nil
101+
}
102+
fileIDs, scanErr := scanPythonTestFile(t, root, path)
103+
if scanErr != nil {
104+
return scanErr
105+
}
106+
ids = append(ids, fileIDs...)
107+
return nil
108+
})
109+
if err != nil {
110+
t.Fatalf("discover Python tests: %v", err)
111+
}
112+
sort.Strings(ids)
113+
return ids
114+
}
115+
116+
func scanPythonTestFile(t *testing.T, root, path string) ([]string, error) {
117+
t.Helper()
118+
file, err := os.Open(path)
119+
if err != nil {
120+
return nil, err
121+
}
122+
defer file.Close()
123+
124+
rel, err := filepath.Rel(root, path)
125+
if err != nil {
126+
return nil, err
127+
}
128+
rel = filepath.ToSlash(rel)
129+
130+
var ids []string
131+
var classes []pythonClassContext
132+
scanner := bufio.NewScanner(file)
133+
for scanner.Scan() {
134+
line := scanner.Text()
135+
indent := leadingWhitespaceWidth(line)
136+
trimmed := strings.TrimSpace(line)
137+
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
138+
continue
139+
}
140+
141+
for len(classes) > 0 && indent <= classes[len(classes)-1].indent {
142+
classes = classes[:len(classes)-1]
143+
}
144+
145+
if match := pythonClassRE.FindStringSubmatch(trimmed); match != nil {
146+
classes = append(classes, pythonClassContext{name: match[1], indent: indent})
147+
continue
148+
}
149+
150+
match := pythonTestRE.FindStringSubmatch(trimmed)
151+
if match == nil {
152+
continue
153+
}
154+
name := match[1]
155+
if len(classes) > 0 && indent > classes[len(classes)-1].indent {
156+
ids = append(ids, fmt.Sprintf("%s::%s::%s", rel, classes[len(classes)-1].name, name))
157+
continue
158+
}
159+
if indent == 0 {
160+
ids = append(ids, fmt.Sprintf("%s::%s", rel, name))
161+
}
162+
}
163+
if err := scanner.Err(); err != nil {
164+
return nil, err
165+
}
166+
return ids, nil
167+
}
168+
169+
func loadGoCutoverPythonTestCoverage(t *testing.T, root string) goCutoverPythonTestCoverage {
170+
t.Helper()
171+
path := filepath.Join(root, "cmd", "apm", "testdata", "go_cutover", "python_test_coverage.json")
172+
data, err := os.ReadFile(path)
173+
if err != nil {
174+
t.Fatalf("read Go cutover Python test coverage manifest %s: %v", path, err)
175+
}
176+
var coverage goCutoverPythonTestCoverage
177+
if err := json.Unmarshal(data, &coverage); err != nil {
178+
t.Fatalf("parse Go cutover Python test coverage manifest %s: %v", path, err)
179+
}
180+
if coverage.ConvertedPythonTests == nil {
181+
coverage.ConvertedPythonTests = map[string][]string{}
182+
}
183+
return coverage
184+
}
185+
186+
func formatCutoverMissing(missing []string, limit int) string {
187+
if limit > len(missing) {
188+
limit = len(missing)
189+
}
190+
lines := make([]string, 0, limit+1)
191+
for _, id := range missing[:limit] {
192+
lines = append(lines, " - "+id)
193+
}
194+
if limit < len(missing) {
195+
lines = append(lines, fmt.Sprintf(" ... %d more", len(missing)-limit))
196+
}
197+
return strings.Join(lines, "\n")
198+
}
199+
200+
func leadingWhitespaceWidth(line string) int {
201+
width := 0
202+
for _, r := range line {
203+
switch r {
204+
case ' ':
205+
width++
206+
case '\t':
207+
width += 4
208+
default:
209+
return width
210+
}
211+
}
212+
return width
213+
}
214+
215+
func realBehaviorRunGoInDirSanitized(t *testing.T, dir string, args ...string) (string, string, int) {
216+
t.Helper()
217+
cleared := map[string]string{
218+
"APM_PYTHON_BIN": "",
219+
"APM_PYTHON_CONTRACT_INVENTORY": "",
220+
"PYTHONPATH": "",
221+
"VIRTUAL_ENV": "",
222+
}
223+
return realBehaviorRunGoInDir(t, dir, cleared, args...)
224+
}

0 commit comments

Comments
 (0)