Skip to content

Commit 9a070e4

Browse files
committed
ci: keep crane migration incomplete until deletion-grade gates pass
1 parent 7805d6c commit 9a070e4

2 files changed

Lines changed: 187 additions & 91 deletions

File tree

.crane/scripts/score.go

Lines changed: 85 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
// Usage:
66
// APM_PYTHON_BIN=/path/to/apm go test -count=1 -json ./... | go run .crane/scripts/score.go
77
//
8-
// This script implements the deletion-grade framework from issue #96.
8+
// This script implements the deletion-grade framework from issues #78 and #96.
99
// migration_score = 1.0 only when ALL of the following gates pass:
1010
//
1111
// Gate 1 -- python_reference_required: APM_PYTHON_BIN must be set and valid.
@@ -15,18 +15,28 @@
1515
// Gate 2 -- go_tests_pass: every Go test in the module must pass. A single
1616
// failing non-parity test voids the gate.
1717
//
18-
// Gate 3 -- help_parity: TestParityCompletionCommandMatrix must pass.
19-
// Every public command must respond to --help with exit 0.
18+
// Gate 3 -- surface_parity: TestParityCompletionSurfaceParity must pass.
19+
// Python and Go command/option/subcommand inventories must match.
2020
//
21-
// Gate 4 -- version_parity: TestParityCompletionVersionEquivalent must pass.
21+
// Gate 4 -- help_parity: TestParityCompletionCommandMatrix and
22+
// TestParityCompletionHelpIdentical must pass. Every public help
23+
// and invalid-usage path must match Python.
2224
//
23-
// Gate 5 -- init_parity: TestParityCompletionInitParity must pass.
24-
// The init command must produce apm.yml equivalent to Python.
25+
// Gate 5 -- functional_contracts: TestParityCompletionFunctionalContracts
26+
// must pass. Supported command behavior must be covered by
27+
// black-box Python-vs-Go contracts.
2528
//
26-
// Gate 6 -- error_parity: TestParityCompletionErrorParity must pass.
27-
// Unknown commands must produce matching non-zero exit codes.
29+
// Gate 6 -- state_diff_contracts: TestParityCompletionStateDiffContracts
30+
// must pass. Mutating commands must match Python filesystem,
31+
// lockfile, config, cache, and generated-artifact effects.
2832
//
29-
// Gate 7 -- no_known_exceptions: the test output must not contain any
33+
// Gate 7 -- python_tests_pass: TestParityCompletionPythonSuite must pass.
34+
// The original Python reference test suite must still be green.
35+
//
36+
// Gate 8 -- benchmarks_pass: TestParityCompletionBenchmarks must pass.
37+
// Migration benchmarks must run and satisfy the configured guard.
38+
//
39+
// Gate 9 -- no_known_exceptions: the test output must not contain any
3040
// "approved exception" log line. Final cutover requires zero exceptions.
3141
//
3242
// If Gate 1 fails, migration_score is forced to 0.0 regardless of other gates.
@@ -73,13 +83,16 @@ func main() {
7383
scanner := bufio.NewScanner(os.Stdin)
7484
scanner.Buffer(make([]byte, 4*1024*1024), 4*1024*1024)
7585

76-
// Deletion-grade gate test names (exact or prefix).
86+
// Deletion-grade gate test names.
7787
const (
78-
gateHardGate = "TestParityCompletionHardGate"
79-
gateCmdMatrix = "TestParityCompletionCommandMatrix"
80-
gateVersionParity = "TestParityCompletionVersionEquivalent"
81-
gateInitParity = "TestParityCompletionInitParity"
82-
gateErrorParity = "TestParityCompletionErrorParity"
88+
gateHardGate = "TestParityCompletionHardGate"
89+
gateSurfaceParity = "TestParityCompletionSurfaceParity"
90+
gateCommandMatrix = "TestParityCompletionCommandMatrix"
91+
gateHelpIdentical = "TestParityCompletionHelpIdentical"
92+
gateFunctionalContracts = "TestParityCompletionFunctionalContracts"
93+
gateStateDiffContracts = "TestParityCompletionStateDiffContracts"
94+
gatePythonSuite = "TestParityCompletionPythonSuite"
95+
gateBenchmarks = "TestParityCompletionBenchmarks"
8396
)
8497

8598
// Track per-test pass/fail.
@@ -151,64 +164,39 @@ func main() {
151164
gate2.Reason = fmt.Sprintf("%d/%d tests passing", passingTests, totalTests)
152165
}
153166

154-
// Gate 3: help_parity (command matrix)
155-
gate3 := GateResult{Name: "help_parity"}
156-
if testPassed[gateCmdMatrix] && !testFailed[gateCmdMatrix] {
157-
gate3.Passing = true
158-
} else if testFailed[gateCmdMatrix] {
159-
gate3.Passing = false
160-
gate3.Reason = "TestParityCompletionCommandMatrix failed"
161-
} else {
162-
gate3.Passing = false
163-
gate3.Reason = "TestParityCompletionCommandMatrix not found"
164-
}
167+
// Gate 3: surface_parity
168+
gate3 := singleTestGate("surface_parity", gateSurfaceParity, testPassed, testFailed)
165169

166-
// Gate 4: version_parity
167-
gate4 := GateResult{Name: "version_parity"}
168-
if testPassed[gateVersionParity] && !testFailed[gateVersionParity] {
169-
gate4.Passing = true
170-
} else if testFailed[gateVersionParity] {
171-
gate4.Passing = false
172-
gate4.Reason = "TestParityCompletionVersionEquivalent failed"
173-
} else {
174-
gate4.Passing = false
175-
gate4.Reason = "TestParityCompletionVersionEquivalent not found"
176-
}
170+
// Gate 4: help_parity
171+
gate4 := multiTestGate(
172+
"help_parity",
173+
[]string{gateCommandMatrix, gateHelpIdentical},
174+
testPassed,
175+
testFailed,
176+
)
177177

178-
// Gate 5: init_parity
179-
gate5 := GateResult{Name: "init_parity"}
180-
if testPassed[gateInitParity] && !testFailed[gateInitParity] {
181-
gate5.Passing = true
182-
} else if testFailed[gateInitParity] {
183-
gate5.Passing = false
184-
gate5.Reason = "TestParityCompletionInitParity failed"
185-
} else {
186-
gate5.Passing = false
187-
gate5.Reason = "TestParityCompletionInitParity not found"
188-
}
178+
// Gate 5: functional_contracts
179+
gate5 := singleTestGate("functional_contracts", gateFunctionalContracts, testPassed, testFailed)
189180

190-
// Gate 6: error_parity
191-
gate6 := GateResult{Name: "error_parity"}
192-
if testPassed[gateErrorParity] && !testFailed[gateErrorParity] {
193-
gate6.Passing = true
194-
} else if testFailed[gateErrorParity] {
195-
gate6.Passing = false
196-
gate6.Reason = "TestParityCompletionErrorParity failed"
197-
} else {
198-
gate6.Passing = false
199-
gate6.Reason = "TestParityCompletionErrorParity not found"
200-
}
181+
// Gate 6: state_diff_contracts
182+
gate6 := singleTestGate("state_diff_contracts", gateStateDiffContracts, testPassed, testFailed)
183+
184+
// Gate 7: python_tests_pass
185+
gate7 := singleTestGate("python_tests_pass", gatePythonSuite, testPassed, testFailed)
186+
187+
// Gate 8: benchmarks_pass
188+
gate8 := singleTestGate("benchmarks_pass", gateBenchmarks, testPassed, testFailed)
201189

202-
// Gate 7: no_known_exceptions
203-
gate7 := GateResult{Name: "no_known_exceptions"}
190+
// Gate 9: no_known_exceptions
191+
gate9 := GateResult{Name: "no_known_exceptions"}
204192
if knownExceptionsFound {
205-
gate7.Passing = false
206-
gate7.Reason = "output contains 'approved exception' -- all exceptions must be resolved for cutover"
193+
gate9.Passing = false
194+
gate9.Reason = "output contains 'approved exception' -- all exceptions must be resolved for cutover"
207195
} else {
208-
gate7.Passing = true
196+
gate9.Passing = true
209197
}
210198

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

213201
// Count parity tests (any test with "Parity" in name from cmd/apm).
214202
parityPassing, parityTotal := 0, 0
@@ -257,3 +245,35 @@ func main() {
257245
out, _ := json.MarshalIndent(score, "", " ")
258246
fmt.Println(string(out))
259247
}
248+
249+
func singleTestGate(name, testName string, testPassed, testFailed map[string]bool) GateResult {
250+
return multiTestGate(name, []string{testName}, testPassed, testFailed)
251+
}
252+
253+
func multiTestGate(name string, testNames []string, testPassed, testFailed map[string]bool) GateResult {
254+
for _, testName := range testNames {
255+
if testFailed[testName] {
256+
return GateResult{
257+
Name: name,
258+
Passing: false,
259+
Reason: testName + " failed",
260+
}
261+
}
262+
}
263+
264+
var missing []string
265+
for _, testName := range testNames {
266+
if !testPassed[testName] {
267+
missing = append(missing, testName)
268+
}
269+
}
270+
if len(missing) > 0 {
271+
return GateResult{
272+
Name: name,
273+
Passing: false,
274+
Reason: strings.Join(missing, ", ") + " not found",
275+
}
276+
}
277+
278+
return GateResult{Name: name, Passing: true}
279+
}

tests/unit/test_crane_score.py

Lines changed: 102 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from __future__ import annotations
22

33
import json
4+
import os
45
import shutil
56
import subprocess
7+
import tempfile
68
from pathlib import Path
79

810
import pytest
@@ -14,46 +16,120 @@ def _run_score(input_lines: list[str]) -> dict[str, object]:
1416
if shutil.which("go") is None:
1517
pytest.skip("Go toolchain is not installed")
1618

17-
result = subprocess.run(
18-
["go", "run", ".crane/scripts/score.go"],
19-
cwd=ROOT,
20-
input="\n".join(input_lines) + "\n",
21-
text=True,
22-
capture_output=True,
23-
check=True,
24-
)
19+
env = os.environ.copy()
20+
with tempfile.TemporaryDirectory(prefix="apm-go-cache-") as go_cache:
21+
env.setdefault("GOCACHE", go_cache)
22+
result = subprocess.run(
23+
["go", "run", ".crane/scripts/score.go"],
24+
cwd=ROOT,
25+
input="\n".join(input_lines) + "\n",
26+
text=True,
27+
capture_output=True,
28+
check=True,
29+
env=env,
30+
)
2531
return json.loads(result.stdout)
2632

2733

28-
def test_crane_score_counts_parity_events() -> None:
34+
def _event(action: str, test: str, *, output: str = "") -> str:
35+
return json.dumps(
36+
{
37+
"Action": action,
38+
"Package": "github.com/githubnext/apm/cmd/apm",
39+
"Test": test,
40+
"Output": output,
41+
}
42+
)
43+
44+
45+
def _pass(test: str) -> list[str]:
46+
return [_event("run", test), _event("pass", test)]
47+
48+
49+
def _gates(score: dict[str, object]) -> dict[str, dict[str, object]]:
50+
gates = score["gates"]
51+
assert isinstance(gates, list)
52+
return {gate["name"]: gate for gate in gates}
53+
54+
55+
def _all_required_gate_events() -> list[str]:
56+
tests = [
57+
"TestParityCompletionHardGate",
58+
"TestParityCompletionSurfaceParity",
59+
"TestParityCompletionCommandMatrix",
60+
"TestParityCompletionHelpIdentical",
61+
"TestParityCompletionFunctionalContracts",
62+
"TestParityCompletionStateDiffContracts",
63+
"TestParityCompletionPythonSuite",
64+
"TestParityCompletionBenchmarks",
65+
]
66+
return [line for test in tests for line in _pass(test)]
67+
68+
69+
def test_crane_score_blocks_help_only_completion() -> None:
2970
score = _run_score(
3071
[
3172
"not json",
32-
'{"Action":"run","Package":"github.com/githubnext/apm/internal/parity","Test":"TestInstallParity"}',
33-
'{"Action":"pass","Package":"github.com/githubnext/apm/internal/parity","Test":"TestInstallParity"}',
34-
'{"Action":"run","Package":"github.com/githubnext/apm/internal/parity","Test":"TestCompileParity"}',
35-
'{"Action":"pass","Package":"github.com/githubnext/apm/internal/parity","Test":"TestCompileParity"}',
73+
*_pass("TestParityCompletionHardGate"),
74+
*_pass("TestParityCompletionCommandMatrix"),
75+
*_pass("TestParityCompletionHelpIdentical"),
76+
*_pass("TestParityCompletionVersionEquivalent"),
77+
*_pass("TestParityCompletionInitParity"),
78+
*_pass("TestParityCompletionErrorParity"),
3679
]
3780
)
3881

39-
assert score["migration_score"] == pytest.approx(2 / 302)
40-
assert score["progress"] == pytest.approx(2 / 302)
41-
assert score["parity_passing"] == 2
42-
assert score["parity_total"] == 302
43-
assert score["source_tests_passing"] == 247
44-
assert score["target_tests_passing"] == 2
45-
assert score["perf_ratio"] == 1.0
82+
gates = _gates(score)
83+
84+
assert score["migration_score"] < 1.0
85+
assert gates["python_reference_required"]["passing"] is True
86+
assert gates["go_tests_pass"]["passing"] is True
87+
assert gates["help_parity"]["passing"] is True
88+
assert gates["surface_parity"]["passing"] is False
89+
assert gates["functional_contracts"]["passing"] is False
90+
assert gates["state_diff_contracts"]["passing"] is False
91+
assert gates["python_tests_pass"]["passing"] is False
92+
assert gates["benchmarks_pass"]["passing"] is False
93+
4694

95+
def test_crane_score_reaches_one_only_when_all_deletion_grade_gates_pass() -> None:
96+
score = _run_score(_all_required_gate_events())
4797

48-
def test_crane_score_applies_target_correctness_gate() -> None:
98+
assert score["migration_score"] == 1.0
99+
assert score["progress"] == 1.0
100+
assert all(gate["passing"] for gate in _gates(score).values())
101+
102+
103+
def test_crane_score_forces_zero_without_python_reference() -> None:
49104
score = _run_score(
50105
[
51-
'{"Action":"run","Package":"github.com/githubnext/apm/internal/parity","Test":"TestInstallParity"}',
52-
'{"Action":"pass","Package":"github.com/githubnext/apm/internal/parity","Test":"TestInstallParity"}',
53-
'{"Action":"run","Package":"github.com/githubnext/apm/internal/config","Test":"TestConfig"}',
106+
*_pass("TestParityCompletionSurfaceParity"),
107+
*_pass("TestParityCompletionCommandMatrix"),
108+
*_pass("TestParityCompletionHelpIdentical"),
109+
*_pass("TestParityCompletionFunctionalContracts"),
110+
*_pass("TestParityCompletionStateDiffContracts"),
111+
*_pass("TestParityCompletionPythonSuite"),
112+
*_pass("TestParityCompletionBenchmarks"),
54113
]
55114
)
56115

116+
gates = _gates(score)
117+
57118
assert score["migration_score"] == 0
58-
assert score["progress"] == pytest.approx(1 / 302)
59-
assert score["target_tests_passing"] == 1
119+
assert gates["python_reference_required"]["passing"] is False
120+
assert "TestParityCompletionHardGate not found" in gates["python_reference_required"]["reason"]
121+
122+
123+
def test_crane_score_blocks_known_exceptions() -> None:
124+
score = _run_score(
125+
[
126+
*_all_required_gate_events(),
127+
_event("output", "TestParityCompletionHelpIdentical", output="APPROVED-EXCEPTION: no"),
128+
]
129+
)
130+
131+
gates = _gates(score)
132+
133+
assert score["migration_score"] < 1.0
134+
assert gates["no_known_exceptions"]["passing"] is False
135+
assert "approved exception" in gates["no_known_exceptions"]["reason"]

0 commit comments

Comments
 (0)