Skip to content

Commit 1a7b2b0

Browse files
authored
Merge pull request #98 from githubnext/crane/crane-migration-python-to-go-full-apm-cli-rewrite
[Crane: crane-migration-python-to-go-full-apm-cli-rewrite]
2 parents 7d2da66 + 37250bb commit 1a7b2b0

18 files changed

Lines changed: 868 additions & 184 deletions

.crane/scripts/score.go

Lines changed: 219 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,49 @@
11
//go:build ignore
22

3-
// score.go -- migration scoring script for the APM CLI Python-to-Go migration.
4-
// Usage: go test -json ./... | go run .crane/scripts/score.go
5-
// Outputs JSON with migration_score and progress metrics.
3+
// score.go -- deletion-grade migration scoring for the APM CLI Python-to-Go migration.
64
//
7-
// Scoring formula:
8-
// migration_score = (parity_passing / parity_total) * correctness_gate
9-
// correctness_gate = 1.0 if all target tests pass, 0.0 otherwise
5+
// Usage:
6+
// APM_PYTHON_BIN=/path/to/apm go test -count=1 -json ./... | go run .crane/scripts/score.go
107
//
11-
// NOTE: This script must NOT be modified after milestone 1 is accepted.
8+
// This script implements the deletion-grade framework from issues #78 and #96.
9+
// migration_score = 1.0 only when ALL of the following gates pass:
10+
//
11+
// Gate 1 -- python_reference_required: APM_PYTHON_BIN must be set and valid.
12+
// TestParityCompletionHardGate must PASS. A missing or invalid Python
13+
// binary is a hard failure -- never a warning or vacuous pass.
14+
//
15+
// Gate 2 -- go_tests_pass: every Go test in the module must pass. A single
16+
// failing non-parity test voids the gate.
17+
//
18+
// Gate 3 -- surface_parity: TestParityCompletionSurfaceParity must pass.
19+
// Python and Go command/option/subcommand inventories must match.
20+
//
21+
// Gate 4 -- help_parity: TestParityCompletionCommandMatrix and
22+
// TestParityCompletionHelpIdentical must pass. Every public help
23+
// and invalid-usage path must match Python.
24+
//
25+
// Gate 5 -- functional_contracts: TestParityCompletionFunctionalContracts
26+
// must pass. Supported command behavior must be covered by
27+
// black-box Python-vs-Go contracts.
28+
//
29+
// Gate 6 -- state_diff_contracts: TestParityCompletionStateDiffContracts
30+
// must pass. Mutating commands must match Python filesystem,
31+
// lockfile, config, cache, and generated-artifact effects.
32+
//
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
40+
// "approved exception" log line. Final cutover requires zero exceptions.
41+
//
42+
// If Gate 1 fails, migration_score is forced to 0.0 regardless of other gates.
43+
// Empty or all-skipped test streams also force migration_score to 0.0.
44+
//
45+
// The progress field shows the fraction of deletion-grade gates passing
46+
// (even when migration_score is 0 due to Gate 1 failure).
1247

1348
package main
1449

@@ -27,20 +62,45 @@ type TestEvent struct {
2762
Output string `json:"Output"`
2863
}
2964

65+
// GateResult tracks the pass/fail state of a single deletion-grade gate.
66+
type GateResult struct {
67+
Name string `json:"name"`
68+
Passing bool `json:"passing"`
69+
Reason string `json:"reason,omitempty"`
70+
}
71+
3072
type Score struct {
31-
MigrationScore float64 `json:"migration_score"`
32-
Progress float64 `json:"progress"`
33-
ParityPassing int `json:"parity_passing"`
34-
ParityTotal int `json:"parity_total"`
35-
SourceTestsPassing int `json:"source_tests_passing"`
36-
TargetTestsPassing int `json:"target_tests_passing"`
37-
PerfRatio float64 `json:"perf_ratio"`
73+
MigrationScore float64 `json:"migration_score"`
74+
Progress float64 `json:"progress"`
75+
ParityPassing int `json:"parity_passing"`
76+
ParityTotal int `json:"parity_total"`
77+
GoTestsTotal int `json:"go_tests_total"`
78+
GoTestsPassing int `json:"go_tests_passing"`
79+
Gates []GateResult `json:"gates"`
3880
}
3981

4082
func main() {
4183
scanner := bufio.NewScanner(os.Stdin)
84+
scanner.Buffer(make([]byte, 4*1024*1024), 4*1024*1024)
85+
86+
// Deletion-grade gate test names.
87+
const (
88+
gateHardGate = "TestParityCompletionHardGate"
89+
gateSurfaceParity = "TestParityCompletionSurfaceParity"
90+
gateCommandMatrix = "TestParityCompletionCommandMatrix"
91+
gateHelpIdentical = "TestParityCompletionHelpIdentical"
92+
gateFunctionalContracts = "TestParityCompletionFunctionalContracts"
93+
gateStateDiffContracts = "TestParityCompletionStateDiffContracts"
94+
gatePythonSuite = "TestParityCompletionPythonSuite"
95+
gateBenchmarks = "TestParityCompletionBenchmarks"
96+
)
4297

43-
var parityPassing, parityTotal, targetPassing, targetTotal int
98+
// Track per-test pass/fail.
99+
testPassed := map[string]bool{}
100+
testFailed := map[string]bool{}
101+
var totalTests, passingTests int
102+
knownExceptionsFound := false
103+
anyEvents := false
44104

45105
for scanner.Scan() {
46106
line := scanner.Text()
@@ -51,56 +111,169 @@ func main() {
51111
if err := json.Unmarshal([]byte(line), &ev); err != nil {
52112
continue
53113
}
114+
115+
anyEvents = true
116+
117+
// Scan output lines for approved-exception markers.
118+
// Tests log "APPROVED-EXCEPTION:" via t.Logf; final cutover requires zero.
119+
if ev.Action == "output" && ev.Output != "" {
120+
if strings.Contains(ev.Output, "APPROVED-EXCEPTION") {
121+
knownExceptionsFound = true
122+
}
123+
}
124+
54125
if ev.Test == "" {
55126
continue
56127
}
57128

58-
isParity := strings.Contains(ev.Test, "Parity") || strings.Contains(ev.Package, "parity")
59-
isTarget := strings.HasPrefix(ev.Package, "github.com/githubnext/apm/")
129+
switch ev.Action {
130+
case "run":
131+
totalTests++
132+
case "pass":
133+
passingTests++
134+
testPassed[ev.Test] = true
135+
case "fail":
136+
testFailed[ev.Test] = true
137+
}
138+
}
139+
140+
// Gate 1: python_reference_required
141+
gate1 := GateResult{Name: "python_reference_required"}
142+
if !anyEvents {
143+
gate1.Passing = false
144+
gate1.Reason = "empty test stream -- no test events received"
145+
} else if testFailed[gateHardGate] {
146+
gate1.Passing = false
147+
gate1.Reason = "TestParityCompletionHardGate failed -- APM_PYTHON_BIN missing or invalid"
148+
} else if testPassed[gateHardGate] {
149+
gate1.Passing = true
150+
} else {
151+
gate1.Passing = false
152+
gate1.Reason = "TestParityCompletionHardGate not found in test stream"
153+
}
154+
155+
// Gate 2: go_tests_pass
156+
gate2 := GateResult{Name: "go_tests_pass"}
157+
if totalTests == 0 {
158+
gate2.Passing = false
159+
gate2.Reason = "no tests ran"
160+
} else if passingTests == totalTests {
161+
gate2.Passing = true
162+
} else {
163+
gate2.Passing = false
164+
gate2.Reason = fmt.Sprintf("%d/%d tests passing", passingTests, totalTests)
165+
}
166+
167+
// Gate 3: surface_parity
168+
gate3 := singleTestGate("surface_parity", gateSurfaceParity, testPassed, testFailed)
169+
170+
// Gate 4: help_parity
171+
gate4 := multiTestGate(
172+
"help_parity",
173+
[]string{gateCommandMatrix, gateHelpIdentical},
174+
testPassed,
175+
testFailed,
176+
)
60177

61-
if isParity {
62-
if ev.Action == "run" {
63-
parityTotal++
64-
} else if ev.Action == "pass" {
178+
// Gate 5: functional_contracts
179+
gate5 := singleTestGate("functional_contracts", gateFunctionalContracts, testPassed, testFailed)
180+
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)
189+
190+
// Gate 9: no_known_exceptions
191+
gate9 := GateResult{Name: "no_known_exceptions"}
192+
if knownExceptionsFound {
193+
gate9.Passing = false
194+
gate9.Reason = "output contains 'approved exception' -- all exceptions must be resolved for cutover"
195+
} else {
196+
gate9.Passing = true
197+
}
198+
199+
gates := []GateResult{gate1, gate2, gate3, gate4, gate5, gate6, gate7, gate8, gate9}
200+
201+
// Count parity tests (any test with "Parity" in name from cmd/apm).
202+
parityPassing, parityTotal := 0, 0
203+
for name, passed := range testPassed {
204+
if strings.Contains(name, "Parity") {
205+
parityTotal++
206+
if passed {
65207
parityPassing++
66208
}
67209
}
68-
if isTarget {
69-
if ev.Action == "run" {
70-
targetTotal++
71-
} else if ev.Action == "pass" {
72-
targetPassing++
73-
}
74-
}
75210
}
76-
77-
correctnessGate := 1.0
78-
if targetTotal > 0 && targetPassing < targetTotal {
79-
correctnessGate = 0.0
211+
for name := range testFailed {
212+
if strings.Contains(name, "Parity") && !testPassed[name] {
213+
parityTotal++
214+
}
80215
}
81216

82-
total := 302 // fixed: total Python modules/functions to port
83-
if parityTotal > total {
84-
total = parityTotal
217+
// Compute migration score.
218+
gatesPassing := 0
219+
for _, g := range gates {
220+
if g.Passing {
221+
gatesPassing++
222+
}
85223
}
224+
progress := float64(gatesPassing) / float64(len(gates))
86225

87226
var migrationScore float64
88-
if total > 0 {
89-
migrationScore = (float64(parityPassing) / float64(total)) * correctnessGate
227+
if !gate1.Passing {
228+
// Hard gate: Python reference missing forces score to 0.
229+
migrationScore = 0.0
230+
} else {
231+
// All gates must pass for score 1.0; partial credit by gate fraction.
232+
migrationScore = progress
90233
}
91234

92-
progress := float64(parityPassing) / float64(total)
93-
94235
score := Score{
95-
MigrationScore: migrationScore,
96-
Progress: progress,
97-
ParityPassing: parityPassing,
98-
ParityTotal: total,
99-
SourceTestsPassing: 247, // stable Python baseline
100-
TargetTestsPassing: targetPassing,
101-
PerfRatio: 1.0,
236+
MigrationScore: migrationScore,
237+
Progress: progress,
238+
ParityPassing: parityPassing,
239+
ParityTotal: parityTotal,
240+
GoTestsTotal: totalTests,
241+
GoTestsPassing: passingTests,
242+
Gates: gates,
102243
}
103244

104245
out, _ := json.MarshalIndent(score, "", " ")
105246
fmt.Println(string(out))
106247
}
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+
}

.github/workflows/migration-ci.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ jobs:
109109

110110
benchmarks:
111111
name: Migration Benchmarks
112-
needs: [parity]
112+
needs: [detect-changes, parity]
113+
if: always() && needs.detect-changes.outputs.should-run == 'true'
113114
runs-on: ubuntu-24.04
114115
steps:
115116
- uses: actions/checkout@v4

apm

507 KB
Binary file not shown.

cmd/apm/cmd_config.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,17 @@ func configPath() string {
2424
func runConfig(args []string) int {
2525
for _, a := range args {
2626
if a == "--help" || a == "-h" {
27-
printCmdHelp("config")
27+
fmt.Println("Usage: apm config [OPTIONS] COMMAND [ARGS]...")
28+
fmt.Println()
29+
fmt.Println(" Configure APM CLI")
30+
fmt.Println()
31+
fmt.Println("Options:")
32+
fmt.Println(" --help Show this message and exit.")
33+
fmt.Println()
34+
fmt.Println("Commands:")
35+
fmt.Println(" get Get a configuration value")
36+
fmt.Println(" set Set a configuration value")
37+
fmt.Println(" unset Unset a configuration value")
2838
return 0
2939
}
3040
}

cmd/apm/cmd_marketplace.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ func printMarketplaceHelp() {
7575
fmt.Println(" validate Validate a marketplace manifest")
7676
fmt.Println()
7777
fmt.Println("Authoring commands:")
78-
fmt.Println(" init Add a 'marketplace:' block to apm.yml")
78+
fmt.Println(" init Add a 'marketplace:' block to apm.yml (scaffolds apm.yml if")
79+
fmt.Println(" missing)")
7980
fmt.Println(" check Validate marketplace entries are resolvable")
8081
fmt.Println(" outdated Show packages with available upgrades")
8182
fmt.Println(" doctor Run environment diagnostics for marketplace publishing")

cmd/apm/cmd_mcp.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ import (
88
)
99

1010
var mcpSubcommands = []struct{ name, desc string }{
11-
{"install", "Install an MCP server"},
12-
{"search", "Search MCP servers in registry"},
13-
{"inspect", "Show detailed MCP server information"},
11+
{"install", "Add an MCP server to apm.yml."},
1412
{"list", "List all available MCP servers"},
13+
{"search", "Search MCP servers in registry"},
14+
{"show", "Show detailed MCP server information"},
1515
}
1616

1717
func printMCPHelp() {
@@ -24,7 +24,7 @@ func printMCPHelp() {
2424
fmt.Println()
2525
fmt.Println("Commands:")
2626
for _, sub := range mcpSubcommands {
27-
fmt.Printf(" %-14s%s\n", sub.name, sub.desc)
27+
fmt.Printf(" %-9s%s\n", sub.name, sub.desc)
2828
}
2929
}
3030

@@ -42,7 +42,7 @@ func runMCP(args []string) int {
4242
return runMCPInstall(rest)
4343
case "search":
4444
return runMCPSearch(rest)
45-
case "inspect":
45+
case "inspect", "show":
4646
return runMCPInspect(rest)
4747
case "list":
4848
return runMCPList(rest)
@@ -106,7 +106,7 @@ func runMCPSearch(args []string) int {
106106
func runMCPInspect(args []string) int {
107107
for _, a := range args {
108108
if a == "--help" || a == "-h" {
109-
fmt.Println("Usage: apm mcp inspect [OPTIONS] NAME")
109+
fmt.Println("Usage: apm mcp show [OPTIONS] NAME")
110110
fmt.Println()
111111
fmt.Println(" Show detailed MCP server information")
112112
fmt.Println()

0 commit comments

Comments
 (0)