Skip to content

Commit e14a028

Browse files
committed
Require golden fixture cutover gates
1 parent 459c81a commit e14a028

4 files changed

Lines changed: 138 additions & 24 deletions

File tree

.crane/scripts/score.go

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ type CutoverGates struct {
6363
FunctionalContracts float64 `json:"functional_contracts"`
6464
StateDiffContracts float64 `json:"state_diff_contracts"`
6565
PythonBehaviorContracts float64 `json:"python_behavior_contracts"`
66+
GoldenFixtureCorpus string `json:"golden_fixture_corpus"`
67+
AllGoGoldenTests string `json:"all_go_golden_tests"`
68+
NoPythonRuntime string `json:"no_python_runtime_dependency"`
6669
KnownExceptions int `json:"known_exceptions"`
6770
GoTests string `json:"go_tests"`
6871
PythonTests string `json:"python_tests"`
@@ -99,6 +102,9 @@ type Score struct {
99102
PythonTestsPassing bool `json:"python_tests_passing"`
100103
GoTestsPassing bool `json:"go_tests_passing"`
101104
BenchmarksPassing bool `json:"benchmarks_passing"`
105+
GoldenFixtureCorpus bool `json:"golden_fixture_corpus"`
106+
AllGoGoldenTests bool `json:"all_go_golden_tests"`
107+
NoPythonRuntime bool `json:"no_python_runtime_dependency"`
102108
ParityPassing int `json:"parity_passing"`
103109
ParityTotal int `json:"parity_total"`
104110
SourceTestsPassing int `json:"source_tests_passing"`
@@ -143,6 +149,9 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
143149
functional := RatioGate{}
144150
stateDiff := RatioGate{}
145151
behaviorContracts := RatioGate{}
152+
goldenFixtureCorpus := BoolGate{}
153+
allGoGoldenTests := BoolGate{}
154+
noPythonRuntime := BoolGate{}
146155

147156
for scanner.Scan() {
148157
line := scanner.Text()
@@ -151,7 +160,21 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
151160
}
152161
if gate, ok := parseGateEvent(line); ok {
153162
eventsSeen++
154-
applyGateEvent(gate, &pythonReference, &surface, &help, &functional, &stateDiff, &behaviorContracts, &knownExceptions, &pythonTests, &benchmarks)
163+
applyGateEvent(
164+
gate,
165+
&pythonReference,
166+
&surface,
167+
&help,
168+
&functional,
169+
&stateDiff,
170+
&behaviorContracts,
171+
&goldenFixtureCorpus,
172+
&allGoGoldenTests,
173+
&noPythonRuntime,
174+
&knownExceptions,
175+
&pythonTests,
176+
&benchmarks,
177+
)
155178
continue
156179
}
157180

@@ -163,7 +186,21 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
163186

164187
if ev.Output != "" {
165188
if gate, ok := parseGateEvent(ev.Output); ok {
166-
applyGateEvent(gate, &pythonReference, &surface, &help, &functional, &stateDiff, &behaviorContracts, &knownExceptions, &pythonTests, &benchmarks)
189+
applyGateEvent(
190+
gate,
191+
&pythonReference,
192+
&surface,
193+
&help,
194+
&functional,
195+
&stateDiff,
196+
&behaviorContracts,
197+
&goldenFixtureCorpus,
198+
&allGoGoldenTests,
199+
&noPythonRuntime,
200+
&knownExceptions,
201+
&pythonTests,
202+
&benchmarks,
203+
)
167204
}
168205
if n, ok := approvedExceptionCount(ev.Output); ok && n > knownExceptions {
169206
knownExceptions = n
@@ -253,6 +290,9 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
253290
FunctionalContracts: functional.Percent(),
254291
StateDiffContracts: stateDiff.Percent(),
255292
PythonBehaviorContracts: behaviorContracts.Percent(),
293+
GoldenFixtureCorpus: passFail(goldenFixtureCorpus.OK()),
294+
AllGoGoldenTests: passFail(allGoGoldenTests.OK()),
295+
NoPythonRuntime: passFail(noPythonRuntime.OK()),
256296
KnownExceptions: knownExceptions,
257297
GoTests: passFail(goTestsPass),
258298
PythonTests: passFail(pythonTests.OK()),
@@ -275,6 +315,9 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
275315
gates.FunctionalContracts == 1.0 &&
276316
gates.StateDiffContracts == 1.0 &&
277317
gates.PythonBehaviorContracts == 1.0 &&
318+
gates.GoldenFixtureCorpus == "pass" &&
319+
gates.AllGoGoldenTests == "pass" &&
320+
gates.NoPythonRuntime == "pass" &&
278321
gates.KnownExceptions == 0 &&
279322
gates.GoTests == "pass" &&
280323
gates.PythonTests == "pass" &&
@@ -315,6 +358,9 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) {
315358
PythonTestsPassing: gates.PythonTests == "pass",
316359
GoTestsPassing: gates.GoTests == "pass",
317360
BenchmarksPassing: gates.Benchmarks == "pass",
361+
GoldenFixtureCorpus: gates.GoldenFixtureCorpus == "pass",
362+
AllGoGoldenTests: gates.AllGoGoldenTests == "pass",
363+
NoPythonRuntime: gates.NoPythonRuntime == "pass",
318364
ParityPassing: metrics.ParityPassing,
319365
ParityTotal: metrics.ParityTotal,
320366
SourceTestsPassing: metrics.SourceTestsPassing,
@@ -344,6 +390,9 @@ func applyGateEvent(
344390
functional *RatioGate,
345391
stateDiff *RatioGate,
346392
behaviorContracts *RatioGate,
393+
goldenFixtureCorpus *BoolGate,
394+
allGoGoldenTests *BoolGate,
395+
noPythonRuntime *BoolGate,
347396
knownExceptions *int,
348397
pythonTests *BoolGate,
349398
benchmarks *BoolGate,
@@ -361,6 +410,12 @@ func applyGateEvent(
361410
*stateDiff = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total}
362411
case "python_behavior_contracts":
363412
*behaviorContracts = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total}
413+
case "golden_fixture_corpus":
414+
*goldenFixtureCorpus = BoolGate{Seen: true, Passed: gate.Passed}
415+
case "all_go_golden_tests":
416+
*allGoGoldenTests = BoolGate{Seen: true, Passed: gate.Passed}
417+
case "no_python_runtime_dependency":
418+
*noPythonRuntime = BoolGate{Seen: true, Passed: gate.Passed}
364419
case "known_exceptions":
365420
*knownExceptions = gate.Count
366421
case "python_tests":
@@ -435,6 +490,9 @@ func gateResults(gates CutoverGates) []GateResult {
435490
{Name: "functional_contracts", Passing: gates.FunctionalContracts == 1.0},
436491
{Name: "state_diff_contracts", Passing: gates.StateDiffContracts == 1.0},
437492
{Name: "python_behavior_contracts", Passing: gates.PythonBehaviorContracts == 1.0},
493+
{Name: "golden_fixture_corpus", Passing: gates.GoldenFixtureCorpus == "pass"},
494+
{Name: "all_go_golden_tests", Passing: gates.AllGoGoldenTests == "pass"},
495+
{Name: "no_python_runtime_dependency", Passing: gates.NoPythonRuntime == "pass"},
438496
{Name: "python_tests_pass", Passing: gates.PythonTests == "pass"},
439497
{Name: "benchmarks_pass", Passing: gates.Benchmarks == "pass"},
440498
{Name: "no_known_exceptions", Passing: gates.KnownExceptions == 0},

cmd/apm/CUTOVER.md

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ via PyInstaller packaging and `pip install apm-cli`.
1313
The Go CLI currently implements:
1414
- `apm --help` / `apm --version` (full parity with Python)
1515
- `apm init [--yes] [PROJECT_NAME]` (functional, creates apm.yml)
16-
- Per-command `--help` for all 26 commands (golden-file verified)
16+
- Per-command `--help` for all 26 commands (initial golden-file coverage)
17+
18+
The checked-in `cmd/apm/testdata/golden/` files are the start of the
19+
cutover corpus, not final completion proof. Final completion requires the
20+
full command matrix below to be represented as committed fixtures and replayed
21+
by Go without invoking the Python runtime.
1722

1823
Remaining commands return a "not yet fully implemented" message.
1924

@@ -28,8 +33,18 @@ are true:
2833
`policy`, `mcp`, `runtime`, `targets`, `list`, `view`, `cache`,
2934
`deps`, `marketplace`, `uninstall`, `prune`
3035
3. Python-vs-Go parity tests pass for all commands in the matrix
31-
4. `go build ./cmd/apm` produces a single static binary
32-
5. CI passes on the crane PR branch (`crane/crane-migration-python-to-go-full-apm-cli-rewrite`)
36+
4. The final Python-reference parity run has been frozen into a committed,
37+
versioned golden fixture corpus. The corpus must include CLI inventory,
38+
help and usage output, error output, exit codes, generated files, lockfiles,
39+
config files, managed-file manifests, deterministic cache/config layout, and
40+
audit artifacts for the full command matrix.
41+
5. An all-Go golden replay passes against that corpus with no live Python
42+
oracle. The replay must build `cmd/apm` and compare only the Go binary
43+
against checked-in fixtures.
44+
6. A no-Python-runtime check passes: `APM_PYTHON_BIN` is unset, the Python CLI
45+
is hidden or unavailable to the replay, and the golden replay still passes.
46+
7. `go build ./cmd/apm` produces a single static binary
47+
8. CI passes on the crane PR branch (`crane/crane-migration-python-to-go-full-apm-cli-rewrite`)
3348

3449
## Cutover Steps
3550

@@ -46,13 +61,14 @@ When conditions are met:
4661

4762
## Python Compatibility Shim
4863

49-
Until all commands are implemented in Go, the Python CLI remains the
50-
authoritative `apm` command. The Go binary is available as `apm-go`
51-
for testing.
64+
Until all commands are implemented in Go and the golden replay gate passes, the
65+
Python CLI remains the authoritative `apm` command. The Go binary is available
66+
as `apm-go` for testing.
5267

53-
The shim removal plan: once the command matrix passes functional tests,
54-
the Python entrypoint is replaced by the Go binary in the same PR that
55-
passes the final parity tests.
68+
The shim removal plan: once the command matrix passes functional tests, the
69+
final Python-reference behavior is frozen into golden fixtures. Only after the
70+
all-Go replay passes without a Python runtime can the Python entrypoint be
71+
replaced by the Go binary.
5672

5773
## Timeline
5874

cmd/apm/cli_parity_test.go

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,8 @@ func runPython(args ...string) (stdout, stderr string, exitCode int) {
102102
}
103103

104104
// noPython returns true when the Python CLI is not available.
105-
// Tests that require Python use this to return a vacuous pass rather than skip,
106-
// so they do not reduce the correctness gate score.
105+
// These optional progress tests skip Python comparison when it is unavailable;
106+
// final completion is enforced by the explicit scorer gates instead.
107107
func noPython() bool {
108108
return pythonBin() == ""
109109
}
@@ -248,10 +248,10 @@ func TestParityCLISelfUpdateAlias(t *testing.T) {
248248
// --- Python-vs-Go parity tests (require APM_PYTHON_BIN) ---
249249

250250
// TestPythonVsGoVersionExitCode compares exit codes for --version.
251-
// When APM_PYTHON_BIN is not set the test passes vacuously (no Python to compare).
251+
// When APM_PYTHON_BIN is not set, this optional comparison is not completion evidence.
252252
func TestPythonVsGoVersionExitCode(t *testing.T) {
253253
if noPython() {
254-
t.Log("APM_PYTHON_BIN not set; skipping Python-vs-Go comparison (vacuous pass)")
254+
t.Log("APM_PYTHON_BIN not set; skipping optional Python-vs-Go comparison")
255255
return
256256
}
257257
_, _, pyCode := runPython("--version")
@@ -264,7 +264,7 @@ func TestPythonVsGoVersionExitCode(t *testing.T) {
264264
// TestParityPythonVsGoHelpExitCode compares --help exit codes.
265265
func TestPythonVsGoHelpExitCode(t *testing.T) {
266266
if noPython() {
267-
t.Log("APM_PYTHON_BIN not set; skipping Python-vs-Go comparison (vacuous pass)")
267+
t.Log("APM_PYTHON_BIN not set; skipping optional Python-vs-Go comparison")
268268
return
269269
}
270270
_, _, pyCode := runPython("--help")
@@ -277,7 +277,7 @@ func TestPythonVsGoHelpExitCode(t *testing.T) {
277277
// TestParityPythonVsGoUnknownCommandExitCode verifies both fail on unknown cmd.
278278
func TestPythonVsGoUnknownCommandExitCode(t *testing.T) {
279279
if noPython() {
280-
t.Log("APM_PYTHON_BIN not set; skipping Python-vs-Go comparison (vacuous pass)")
280+
t.Log("APM_PYTHON_BIN not set; skipping optional Python-vs-Go comparison")
281281
return
282282
}
283283
_, _, pyCode := runPython("totally-unknown-xyz")
@@ -290,7 +290,7 @@ func TestPythonVsGoUnknownCommandExitCode(t *testing.T) {
290290
// TestParityPythonVsGoHelpCommandList verifies Go help lists all Python commands.
291291
func TestPythonVsGoHelpCommandList(t *testing.T) {
292292
if noPython() {
293-
t.Log("APM_PYTHON_BIN not set; skipping Python-vs-Go comparison (vacuous pass)")
293+
t.Log("APM_PYTHON_BIN not set; skipping optional Python-vs-Go comparison")
294294
return
295295
}
296296
pyOut, _, _ := runPython("--help")
@@ -324,7 +324,7 @@ func TestPythonVsGoHelpCommandList(t *testing.T) {
324324
// TestParityPythonVsGoSubcommandHelpExitCodes compares <cmd> --help exit codes.
325325
func TestPythonVsGoSubcommandHelpExitCodes(t *testing.T) {
326326
if noPython() {
327-
t.Log("APM_PYTHON_BIN not set; skipping Python-vs-Go comparison (vacuous pass)")
327+
t.Log("APM_PYTHON_BIN not set; skipping optional Python-vs-Go comparison")
328328
return
329329
}
330330
cmds := []string{
@@ -359,17 +359,20 @@ func goldenDir(t *testing.T) string {
359359
}
360360

361361
// readGolden reads a golden file and returns its contents.
362-
// Returns "" if the file does not exist (test passes vacuously).
362+
// Golden fixtures are cutover evidence; missing fixtures must fail instead of
363+
// passing without evidence.
363364
func readGolden(t *testing.T, name string) string {
364365
t.Helper()
365366
p := filepath.Join(goldenDir(t), name)
366367
b, err := os.ReadFile(p)
367368
if err != nil {
368-
// Golden file absent: vacuous pass (framework not yet set up).
369-
t.Logf("golden file %s not found; skipping comparison", name)
370-
return ""
369+
t.Fatalf("golden fixture %s is required but was not found: %v", name, err)
371370
}
372-
return string(b)
371+
content := string(b)
372+
if strings.TrimSpace(content) == "" {
373+
t.Fatalf("golden fixture %s is empty", name)
374+
}
375+
return content
373376
}
374377

375378
// normalizeHelpOutput removes lines that vary between runs or versions:

tests/unit/test_crane_score.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ def _deletion_gates() -> list[str]:
7474
'{"crane":"gate","name":"functional","passing":1,"total":1}',
7575
'{"crane":"gate","name":"state_diff","passing":1,"total":1}',
7676
'{"crane":"gate","name":"python_behavior_contracts","passing":1,"total":1}',
77+
'{"crane":"gate","name":"golden_fixture_corpus","passed":true}',
78+
'{"crane":"gate","name":"all_go_golden_tests","passed":true}',
79+
'{"crane":"gate","name":"no_python_runtime_dependency","passed":true}',
7780
'{"crane":"gate","name":"known_exceptions","count":0}',
7881
'{"crane":"gate","name":"python_tests","passed":true}',
7982
'{"crane":"gate","name":"benchmarks","passed":true}',
@@ -166,6 +169,9 @@ def test_crane_score_can_reach_one_with_all_deletion_grade_gates() -> None:
166169
"state_diff_contracts": 1.0,
167170
"python_behavior_contracts": 1.0,
168171
"known_exceptions": 0,
172+
"golden_fixture_corpus": "pass",
173+
"all_go_golden_tests": "pass",
174+
"no_python_runtime_dependency": "pass",
169175
"go_tests": "pass",
170176
"python_tests": "pass",
171177
"benchmarks": "pass",
@@ -181,6 +187,9 @@ def test_crane_score_can_reach_one_with_all_deletion_grade_gates() -> None:
181187
'{"crane":"gate","name":"functional","passing":0,"total":1}',
182188
'{"crane":"gate","name":"state_diff","passing":0,"total":1}',
183189
'{"crane":"gate","name":"python_behavior_contracts","passing":0,"total":1}',
190+
'{"crane":"gate","name":"golden_fixture_corpus","passed":false}',
191+
'{"crane":"gate","name":"all_go_golden_tests","passed":false}',
192+
'{"crane":"gate","name":"no_python_runtime_dependency","passed":false}',
184193
'{"crane":"gate","name":"known_exceptions","count":1}',
185194
'{"crane":"gate","name":"python_tests","passed":false}',
186195
'{"crane":"gate","name":"benchmarks","passed":false}',
@@ -205,6 +214,31 @@ def test_crane_score_full_parity_but_missing_deletion_gates_cannot_reach_one() -
205214
assert score["deletion_grade_ready"] is False
206215

207216

217+
def test_crane_score_full_parity_without_golden_cutover_gates_cannot_reach_one() -> None:
218+
omitted_gates = {
219+
"golden_fixture_corpus",
220+
"all_go_golden_tests",
221+
"no_python_runtime_dependency",
222+
}
223+
gates = [
224+
line
225+
for line in _deletion_gates()
226+
if json.loads(line)["name"] not in omitted_gates
227+
]
228+
229+
score = _run_score([*_parity_passes(302), _package_pass(), *gates])
230+
gates_by_name = _gates(score)
231+
232+
assert score["migration_score"] < 1.0
233+
assert score["deletion_grade_ready"] is False
234+
assert score["golden_fixture_corpus"] is False
235+
assert score["all_go_golden_tests"] is False
236+
assert score["no_python_runtime_dependency"] is False
237+
assert gates_by_name["golden_fixture_corpus"]["passing"] is False
238+
assert gates_by_name["all_go_golden_tests"]["passing"] is False
239+
assert gates_by_name["no_python_runtime_dependency"]["passing"] is False
240+
241+
208242
def test_crane_score_package_level_go_failure_blocks_one() -> None:
209243
score = _run_score([*_parity_passes(302), _package_fail(), *_deletion_gates()])
210244

@@ -242,6 +276,9 @@ def test_crane_score_reaches_one_with_completion_tests_and_explicit_behavior_gat
242276
*_parity_passes(293),
243277
*_completion_gate_events(),
244278
_behavior_contract_gate_output(1, 1),
279+
'{"crane":"gate","name":"golden_fixture_corpus","passed":true}',
280+
'{"crane":"gate","name":"all_go_golden_tests","passed":true}',
281+
'{"crane":"gate","name":"no_python_runtime_dependency","passed":true}',
245282
_package_pass(),
246283
]
247284
)

0 commit comments

Comments
 (0)