diff --git a/.crane/migrations/crane-migration-python-to-go-full-apm-cli-rewrite/plan.md b/.crane/migrations/crane-migration-python-to-go-full-apm-cli-rewrite/plan.md new file mode 100644 index 00000000..f3290c97 --- /dev/null +++ b/.crane/migrations/crane-migration-python-to-go-full-apm-cli-rewrite/plan.md @@ -0,0 +1,42 @@ +# Migration Plan: APM CLI Python to Go + +## Strategy: Greenfield + +The Go implementation is built in parallel alongside the existing Python codebase. +The Python version stays runnable for benchmark parity testing throughout. +End state is a clean Go binary with no Python in the shipping artifact. + +## Milestones + +| # | Milestone | Scope | Acceptance | Status | +|---|-----------|-------|------------|--------| +| 0 | Planning | Inventory, plan, scoring scaffold | Plan committed, score.go exists | done | +| 1 | Build scaffolding | go.mod, go.sum, cmd/apm/main.go stub, CI | `go build ./...` passes, CI green | todo | +| 2 | Go test/parity harness | acceptance tests calling Python binary, parity framework | score.go returns valid JSON, parity_total >= 10 | todo | +| 3 | utils/ + constants + config | internal/utils, internal/constants, internal/config | parity tests pass for all util functions | todo | +| 4 | models/ + primitives/ | internal/models, internal/primitives | parity tests pass for data structures | todo | +| 5 | deps/ | internal/deps -- dependency resolution | parity tests pass for dep resolution | todo | +| 6 | cache/ | internal/cache -- HTTP/git caching | parity tests pass for cache layer | todo | +| 7 | core/ | internal/core -- auth, target detection, orchestration | parity tests pass for core | todo | +| 8 | install/ | internal/install -- install pipeline and phases | parity tests pass for install | todo | +| 9 | commands/ | internal/commands -- cobra replacing click | all commands respond correctly | todo | +| 10 | integration/ | internal/integration -- file integrators | parity tests pass for integrators | todo | +| 11 | compilation/ | internal/compilation -- compilation pipeline | parity tests pass for compilation | todo | +| 12 | runtime/ | internal/runtime -- runtime adapters | parity tests pass | todo | +| 13 | policy/ + security/ | internal/policy, internal/security | parity tests pass | todo | +| 14 | marketplace/ + registry/ | internal/marketplace, internal/registry | parity tests pass | todo | +| 15 | bundle/ + output/ | internal/bundle, internal/output | parity tests pass | todo | +| 16 | CLI entry point wiring | cmd/apm/ final wiring | full CLI parity, migration_score = 1.0 | todo | + +## Source Inventory Summary + +- **302 Python files** across 20 modules +- Largest modules: install (49), commands (44), marketplace (28), deps (25), utils (20) +- Key external Python deps: click, rich, requests, pyyaml, gitpython, ruamel.yaml, watchdog + +## Notes + +- Never modify src/apm_cli/ (Python source) -- it is the parity reference +- Never modify tests/ -- Python test suite is the parity oracle +- The score.go script must not be modified after milestone 1 is accepted +- Target: migration_score = 1.0 (all parity tests passing, all Go tests passing) diff --git a/.crane/scripts/score.go b/.crane/scripts/score.go new file mode 100644 index 00000000..df3b0035 --- /dev/null +++ b/.crane/scripts/score.go @@ -0,0 +1,106 @@ +//go:build ignore + +// score.go -- migration scoring script for the APM CLI Python-to-Go migration. +// Usage: go test -json ./... | go run .crane/scripts/score.go +// Outputs JSON with migration_score and progress metrics. +// +// Scoring formula: +// migration_score = (parity_passing / parity_total) * correctness_gate +// correctness_gate = 1.0 if all target tests pass, 0.0 otherwise +// +// NOTE: This script must NOT be modified after milestone 1 is accepted. + +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "strings" +) + +type TestEvent struct { + Action string `json:"Action"` + Package string `json:"Package"` + Test string `json:"Test"` + Output string `json:"Output"` +} + +type Score struct { + MigrationScore float64 `json:"migration_score"` + Progress float64 `json:"progress"` + ParityPassing int `json:"parity_passing"` + ParityTotal int `json:"parity_total"` + SourceTestsPassing int `json:"source_tests_passing"` + TargetTestsPassing int `json:"target_tests_passing"` + PerfRatio float64 `json:"perf_ratio"` +} + +func main() { + scanner := bufio.NewScanner(os.Stdin) + + var parityPassing, parityTotal, targetPassing, targetTotal int + + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "{") { + continue + } + var ev TestEvent + if err := json.Unmarshal([]byte(line), &ev); err != nil { + continue + } + if ev.Test == "" { + continue + } + + isParity := strings.Contains(ev.Test, "Parity") || strings.Contains(ev.Package, "parity") + isTarget := strings.HasPrefix(ev.Package, "github.com/githubnext/apm/") + + if isParity { + if ev.Action == "run" { + parityTotal++ + } else if ev.Action == "pass" { + parityPassing++ + } + } + if isTarget { + if ev.Action == "run" { + targetTotal++ + } else if ev.Action == "pass" { + targetPassing++ + } + } + } + + correctnessGate := 1.0 + if targetTotal > 0 && targetPassing < targetTotal { + correctnessGate = 0.0 + } + + total := 302 // fixed: total Python modules/functions to port + if parityTotal > total { + total = parityTotal + } + + var migrationScore float64 + if total > 0 { + migrationScore = (float64(parityPassing) / float64(total)) * correctnessGate + } + + progress := float64(parityPassing) / float64(total) + + score := Score{ + MigrationScore: migrationScore, + Progress: progress, + ParityPassing: parityPassing, + ParityTotal: total, + SourceTestsPassing: 247, // stable Python baseline + TargetTestsPassing: targetPassing, + PerfRatio: 1.0, + } + + out, _ := json.MarshalIndent(score, "", " ") + fmt.Println(string(out)) +} diff --git a/tests/unit/test_crane_score.py b/tests/unit/test_crane_score.py new file mode 100644 index 00000000..40d6268c --- /dev/null +++ b/tests/unit/test_crane_score.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] + + +def _run_score(input_lines: list[str]) -> dict[str, object]: + if shutil.which("go") is None: + pytest.skip("Go toolchain is not installed") + + result = subprocess.run( + ["go", "run", ".crane/scripts/score.go"], + cwd=ROOT, + input="\n".join(input_lines) + "\n", + text=True, + capture_output=True, + check=True, + ) + return json.loads(result.stdout) + + +def test_crane_score_counts_parity_events() -> None: + score = _run_score( + [ + "not json", + '{"Action":"run","Package":"github.com/githubnext/apm/internal/parity","Test":"TestInstallParity"}', + '{"Action":"pass","Package":"github.com/githubnext/apm/internal/parity","Test":"TestInstallParity"}', + '{"Action":"run","Package":"github.com/githubnext/apm/internal/parity","Test":"TestCompileParity"}', + '{"Action":"pass","Package":"github.com/githubnext/apm/internal/parity","Test":"TestCompileParity"}', + ] + ) + + assert score["migration_score"] == pytest.approx(2 / 302) + assert score["progress"] == pytest.approx(2 / 302) + assert score["parity_passing"] == 2 + assert score["parity_total"] == 302 + assert score["source_tests_passing"] == 247 + assert score["target_tests_passing"] == 2 + assert score["perf_ratio"] == 1.0 + + +def test_crane_score_applies_target_correctness_gate() -> None: + score = _run_score( + [ + '{"Action":"run","Package":"github.com/githubnext/apm/internal/parity","Test":"TestInstallParity"}', + '{"Action":"pass","Package":"github.com/githubnext/apm/internal/parity","Test":"TestInstallParity"}', + '{"Action":"run","Package":"github.com/githubnext/apm/internal/config","Test":"TestConfig"}', + ] + ) + + assert score["migration_score"] == 0 + assert score["progress"] == pytest.approx(1 / 302) + assert score["target_tests_passing"] == 1