Skip to content

Commit e96b795

Browse files
authored
harden Go migration completion gates (#116)
1 parent 692a789 commit e96b795

13 files changed

Lines changed: 437 additions & 66 deletions

.crane/scripts/score.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ type CutoverGates struct {
6060
PythonReferenceRequired bool `json:"python_reference_required"`
6161
SurfaceParity float64 `json:"surface_parity"`
6262
HelpParity float64 `json:"help_parity"`
63+
OptionParity float64 `json:"option_parity"`
6364
FunctionalContracts float64 `json:"functional_contracts"`
6465
StateDiffContracts float64 `json:"state_diff_contracts"`
6566
PythonBehaviorContracts float64 `json:"python_behavior_contracts"`
@@ -96,6 +97,7 @@ type Score struct {
9697
PythonReferencePresent bool `json:"python_reference_present"`
9798
SurfaceParity float64 `json:"surface_parity"`
9899
HelpParity float64 `json:"help_parity"`
100+
OptionParity float64 `json:"option_parity"`
99101
FunctionalParity float64 `json:"functional_parity"`
100102
StateDiffParity float64 `json:"state_diff_parity"`
101103
KnownExceptions int `json:"known_exceptions"`
@@ -146,6 +148,7 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
146148
benchmarks := RatioGate{}
147149
surface := RatioGate{}
148150
help := RatioGate{}
151+
optionParity := RatioGate{}
149152
functional := RatioGate{}
150153
stateDiff := RatioGate{}
151154
behaviorContracts := RatioGate{}
@@ -165,6 +168,7 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
165168
&pythonReference,
166169
&surface,
167170
&help,
171+
&optionParity,
168172
&functional,
169173
&stateDiff,
170174
&behaviorContracts,
@@ -191,6 +195,7 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
191195
&pythonReference,
192196
&surface,
193197
&help,
198+
&optionParity,
194199
&functional,
195200
&stateDiff,
196201
&behaviorContracts,
@@ -266,6 +271,9 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
266271
if !help.Seen {
267272
help = missingRatioGate()
268273
}
274+
if !optionParity.Seen {
275+
optionParity = missingRatioGate()
276+
}
269277
if !functional.Seen {
270278
functional = missingRatioGate()
271279
}
@@ -290,6 +298,7 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
290298
PythonReferenceRequired: pythonReferenceSatisfied,
291299
SurfaceParity: surface.Percent(),
292300
HelpParity: help.Percent(),
301+
OptionParity: optionParity.Percent(),
293302
FunctionalContracts: functional.Percent(),
294303
StateDiffContracts: stateDiff.Percent(),
295304
PythonBehaviorContracts: behaviorContracts.Percent(),
@@ -315,6 +324,7 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
315324
cutoverReady := gates.PythonReferenceRequired &&
316325
gates.SurfaceParity == 1.0 &&
317326
gates.HelpParity == 1.0 &&
327+
gates.OptionParity == 1.0 &&
318328
gates.FunctionalContracts == 1.0 &&
319329
gates.StateDiffContracts == 1.0 &&
320330
gates.PythonBehaviorContracts == 1.0 &&
@@ -355,6 +365,7 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
355365
PythonReferencePresent: gates.PythonReferenceRequired,
356366
SurfaceParity: gates.SurfaceParity,
357367
HelpParity: gates.HelpParity,
368+
OptionParity: gates.OptionParity,
358369
FunctionalParity: gates.FunctionalContracts,
359370
StateDiffParity: gates.StateDiffContracts,
360371
KnownExceptions: gates.KnownExceptions,
@@ -390,6 +401,7 @@ func applyGateEvent(
390401
pythonReference *BoolGate,
391402
surface *RatioGate,
392403
help *RatioGate,
404+
optionParity *RatioGate,
393405
functional *RatioGate,
394406
stateDiff *RatioGate,
395407
behaviorContracts *RatioGate,
@@ -407,6 +419,8 @@ func applyGateEvent(
407419
*surface = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total}
408420
case "help":
409421
*help = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total}
422+
case "option_parity":
423+
*optionParity = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total}
410424
case "functional":
411425
*functional = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total}
412426
case "state_diff":
@@ -467,6 +481,7 @@ func gateResults(gates CutoverGates) []GateResult {
467481
{Name: "go_tests_pass", Passing: gates.GoTests == "pass"},
468482
{Name: "surface_parity", Passing: gates.SurfaceParity == 1.0},
469483
{Name: "help_parity", Passing: gates.HelpParity == 1.0},
484+
{Name: "option_parity", Passing: gates.OptionParity == 1.0},
470485
{Name: "functional_contracts", Passing: gates.FunctionalContracts == 1.0},
471486
{Name: "state_diff_contracts", Passing: gates.StateDiffContracts == 1.0},
472487
{Name: "python_behavior_contracts", Passing: gates.PythonBehaviorContracts == 1.0},

.github/workflows/migration-ci.yml

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,15 @@ jobs:
9494
shell: bash
9595
run: |
9696
go build -o "$RUNNER_TEMP/apm-go" ./cmd/apm
97+
enforce_behavior_contracts=false
98+
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.enforce_completion == true }}" = "true" ]; then
99+
enforce_behavior_contracts=true
100+
elif [ "${{ github.event_name }}" = "pull_request" ] && [[ "${{ github.event.pull_request.head.ref }}" == crane/* ]]; then
101+
enforce_behavior_contracts=true
102+
fi
103+
if [ "$enforce_behavior_contracts" = "true" ]; then
104+
export APM_ENFORCE_PYTHON_BEHAVIOR_CONTRACTS=1
105+
fi
97106
set +e
98107
APM_GO_BIN="$RUNNER_TEMP/apm-go" \
99108
uv run pytest tests/parity/test_python_behavior_contracts.py -q --tb=short \
@@ -141,11 +150,22 @@ jobs:
141150
- name: Compute migration score
142151
run: |
143152
go run .crane/scripts/score.go < "$RUNNER_TEMP/go-test-events.json" | tee "$RUNNER_TEMP/migration-score.json"
153+
coverage_args=(
154+
--inventory "$RUNNER_TEMP/python-behavior-contracts.json"
155+
--coverage tests/parity/python_contract_coverage.yml
156+
--summary "$RUNNER_TEMP/python-contract-coverage.md"
157+
)
158+
if [ "${MIGRATION_COMPLETION_ENFORCED:-false}" != "true" ]; then
159+
coverage_args+=(--allow-intentionally-incomplete --allow-obsolete-python-tests)
160+
fi
161+
set +e
144162
uv run python scripts/ci/python_behavior_contracts.py check \
145-
--inventory "$RUNNER_TEMP/python-behavior-contracts.json" \
146-
--coverage tests/parity/python_contract_coverage.yml \
147-
--allow-intentionally-incomplete \
148-
--summary "$RUNNER_TEMP/python-contract-coverage.md" || true
163+
"${coverage_args[@]}"
164+
coverage_status=$?
165+
set -e
166+
if [ "${MIGRATION_COMPLETION_ENFORCED:-false}" = "true" ] && [ "$coverage_status" != "0" ]; then
167+
exit "$coverage_status"
168+
fi
149169
python - "$RUNNER_TEMP/migration-score.json" "${MIGRATION_COMPLETION_ENFORCED:-false}" <<'PY'
150170
import json
151171
import sys

cmd/apm/CUTOVER.md

Lines changed: 46 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,32 +6,35 @@ framework in issue #78).
66

77
## Current State
88

9-
**Deletion-grade ready.** All 13 completion gates pass as of iteration 77.
9+
**Gate hardened; deletion-grade readiness is blocked.** The previous 13-gate
10+
score accepted representative behavior and help-only coverage mappings. The
11+
completion gate now requires strict option parity, behavior-backed Python test
12+
conversion mappings, and real Go-only command fixtures before the Go binary can
13+
be declared equivalent.
1014

11-
The Go binary (`cmd/apm`) has full functional parity with the Python CLI.
12-
The Python CLI remains as the reference oracle until the explicit cutover
13-
steps below are executed, but it is no longer required for correctness.
15+
The Go binary (`cmd/apm`) is still evaluated against the Python CLI. It must not
16+
be treated as the shipped `apm` command until the strict gates below pass.
1417

15-
Gate summary (all passing):
18+
Gate summary:
1619

1720
| Gate | Status |
1821
|------|--------|
19-
| python_reference_required | pass |
20-
| surface_parity | 100% (855/855) |
21-
| help_parity | 100% |
22-
| functional_contracts | 100% |
23-
| state_diff_contracts | 100% |
24-
| python_behavior_contracts | 100% |
25-
| golden_fixture_corpus | pass |
26-
| all_go_golden_tests | pass |
27-
| no_python_runtime_dependency | pass |
28-
| known_exceptions | 0 |
29-
| go_tests | pass (900 tests) |
30-
| python_tests | pass (247 tests) |
31-
| benchmarks | pass |
32-
33-
The Go binary is ready to replace Python as the shipped `apm` command once
34-
the cutover steps below are executed.
22+
| python_reference_required | required |
23+
| surface_parity | required |
24+
| help_parity | required |
25+
| option_parity | required; every Python CLI option must appear in Go help |
26+
| functional_contracts | required |
27+
| state_diff_contracts | required |
28+
| python_behavior_contracts | required; no obsolete or help-only mappings |
29+
| golden_fixture_corpus | required |
30+
| all_go_golden_tests | required |
31+
| no_python_runtime_dependency | required |
32+
| known_exceptions | must be 0 |
33+
| go_tests | required |
34+
| python_tests | required, or superseded by the all-Go replay |
35+
| benchmarks | required |
36+
37+
The Go binary is ready to replace Python only when all rows above pass in CI.
3538

3639
### Pre-Cutover Verification
3740

@@ -48,9 +51,9 @@ The output must show `"migration_score": 1` and `"cutover_ready": true`.
4851
## Real Criteria
4952

5053
Every completion criterion must be backed by real command execution. The scorer
51-
does not infer completion from test names for `surface`, `help`, `functional`,
52-
`state_diff`, `python_behavior_contracts`, or `benchmarks`; each one must emit an
53-
explicit ratio gate.
54+
does not infer completion from test names for `surface`, `help`,
55+
`option_parity`, `functional`, `state_diff`, `python_behavior_contracts`, or
56+
`benchmarks`; each one must emit an explicit ratio gate.
5457

5558
Crane must run `APM_PYTHON_BIN= go test ./cmd/apm -run TestGoCutover -json`.
5659
These fixture-backed tests execute the built Go `apm` binary in temporary
@@ -66,12 +69,20 @@ directly:
6669
{"crane":"gate","name":"no_python_runtime_dependency","passed":true}
6770
```
6871

72+
The Python-vs-Go inventory tests must also emit:
73+
74+
```json
75+
{"crane":"gate","name":"option_parity","passing":N,"total":N}
76+
```
77+
6978
`python_behavior_contracts` is not allowed to mean "the Python CLI was
70-
available." In the final gate it means every checked-in legacy Python pytest
71-
node under `tests/` (except the migration-specific `tests/parity/` harness) is
72-
listed in `cmd/apm/testdata/go_cutover/python_test_coverage.json` with one or
73-
more Go test names that replace it. An empty or partial manifest is a hard
74-
failure.
79+
available" or "the test was declared obsolete." In the final gate it means every
80+
checked-in legacy Python pytest node under `tests/` (except the
81+
migration-specific `tests/parity/` harness) is listed in
82+
`cmd/apm/testdata/go_cutover/python_test_coverage.json` with one or more
83+
existing real Go-only cutover behavior tests that replace it. Empty mappings,
84+
partial mappings, stale Go test names, `python_tests.obsolete`, Python-vs-Go
85+
completion tests, and help-only/surface-only mappings are hard failures.
7586

7687
Crane must also run the migration benchmark test. It executes fixture-backed
7788
Python-vs-Go benchmark workloads and emits:
@@ -111,7 +122,7 @@ completion.
111122
The Go binary becomes the shipped `apm` command when ALL of the following
112123
are true:
113124

114-
1. All 26 commands respond correctly to `--help` (done)
125+
1. All public Python commands and options are present in Go help output
115126
2. The representative command matrix passes functional tests:
116127
`init`, `install`, `update`, `compile`, `pack`, `run`, `audit`,
117128
`policy`, `mcp`, `runtime`, `targets`, `list`, `view`, `cache`,
@@ -120,9 +131,11 @@ are true:
120131
fixture-backed real-command scenario and emits passing `functional` and
121132
`state_diff` gates
122133
4. `TestGoCutoverPythonTestConversionCoverage` proves every legacy Python test
123-
has an explicit Go replacement in the cutover coverage manifest
124-
5. Python-vs-Go parity tests pass for all commands in the matrix while the
125-
Python reference is still available
134+
has an explicit existing Go-only behavior replacement in the cutover
135+
coverage manifest; help-only, surface-only, coverage-only, obsolete, stale,
136+
or Python-vs-Go completion mappings do not count
137+
5. Python-vs-Go parity tests pass for all commands, options, and unknown-option
138+
paths while the Python reference is still available
126139
6. Migration benchmarks pass real fixture-backed command workloads and emit a
127140
passing counted `benchmarks` gate
128141
7. The final Python-reference parity run has been frozen into a committed,

0 commit comments

Comments
 (0)