feat(go): M5.4 — parity diff binary + make parity
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Adds cmd/parity/main.go: a standalone Go binary that GETs /api/version, /api/adults, /api/juniors, /api/payments from both the Python (:5001) and Go (:8080) backends, scrubs an allowlist (render_time.total, build_meta), and prints cmp.Diff for any remaining differences. Exits 0 on full match, 1 on diffs, 2 on fetch/parse errors — CI-friendly for M7.2. - go/cmd/parity/main.go: flags (-py, -go, -route, -timeout), fetch helper, allowlist scrubber (dotted-path aware), exit-code logic. - go/cmd/parity/scrub_test.go: 4 unit tests for the scrubber. - go/go.mod: promote github.com/google/go-cmp to direct dep. - Makefile: parity target + help entry. - Progress tracker: M5.4 ticked; milestone updated to M5 complete. - Plan archived to docs/plans/2026-05-07-2254-m5-4-parity-binary.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
129
go/cmd/parity/main.go
Normal file
129
go/cmd/parity/main.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
)
|
||||
|
||||
var allRoutes = []string{"/api/version", "/api/adults", "/api/juniors", "/api/payments"}
|
||||
|
||||
// defaultAllowlist holds dotted key paths to strip before diffing.
|
||||
// These fields are expected to differ between backends (e.g. build tags, timing)
|
||||
// or may appear on one side only. Today both are absent from the JSON — this is
|
||||
// forward-compatible insurance for if either is added later.
|
||||
var defaultAllowlist = []string{"render_time.total", "build_meta"}
|
||||
|
||||
func main() {
|
||||
pyURL := flag.String("py", "http://localhost:5001", "Python backend base URL")
|
||||
goURL := flag.String("go", "http://localhost:8080", "Go backend base URL")
|
||||
route := flag.String("route", "", "single route to diff, e.g. /api/adults (default: all)")
|
||||
timeout := flag.Duration("timeout", 30*time.Second, "per-request HTTP timeout")
|
||||
flag.Parse()
|
||||
|
||||
client := &http.Client{Timeout: *timeout}
|
||||
|
||||
targets := allRoutes
|
||||
if *route != "" {
|
||||
targets = []string{*route}
|
||||
}
|
||||
|
||||
matched, diffs, errs := 0, 0, 0
|
||||
|
||||
for _, r := range targets {
|
||||
pyMap, err1 := fetch(client, *pyURL+r)
|
||||
goMap, err2 := fetch(client, *goURL+r)
|
||||
|
||||
if err1 != nil || err2 != nil {
|
||||
fmt.Printf("=== %s ===\n", r)
|
||||
if err1 != nil {
|
||||
fmt.Printf("ERROR (py): %v\n", err1)
|
||||
}
|
||||
if err2 != nil {
|
||||
fmt.Printf("ERROR (go): %v\n", err2)
|
||||
}
|
||||
fmt.Println()
|
||||
errs++
|
||||
continue
|
||||
}
|
||||
|
||||
scrub(pyMap, defaultAllowlist)
|
||||
scrub(goMap, defaultAllowlist)
|
||||
|
||||
diff := cmp.Diff(pyMap, goMap, cmpopts.EquateEmpty())
|
||||
if diff == "" {
|
||||
fmt.Printf("=== %s ===\nOK\n\n", r)
|
||||
matched++
|
||||
} else {
|
||||
fmt.Printf("=== %s ===\n%s\n", r, diff)
|
||||
diffs++
|
||||
}
|
||||
}
|
||||
|
||||
total := len(targets)
|
||||
fmt.Printf("parity: %d/%d routes match", matched, total)
|
||||
if diffs > 0 {
|
||||
fmt.Printf(", %d diff", diffs)
|
||||
if diffs > 1 {
|
||||
fmt.Print("s")
|
||||
}
|
||||
}
|
||||
if errs > 0 {
|
||||
fmt.Printf(", %d error", errs)
|
||||
if errs > 1 {
|
||||
fmt.Print("s")
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if errs > 0 {
|
||||
os.Exit(2)
|
||||
}
|
||||
if diffs > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func fetch(client *http.Client, url string) (map[string]any, error) {
|
||||
resp, err := client.Get(url) //nolint:noctx
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(body, &m); err != nil {
|
||||
return nil, fmt.Errorf("decode JSON: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// scrub removes keys from m whose dotted paths appear in paths.
|
||||
// A bare segment (no dot) deletes a top-level key.
|
||||
// A two-segment path "parent.child" deletes child from m["parent"] if it is a map.
|
||||
func scrub(m map[string]any, paths []string) {
|
||||
for _, path := range paths {
|
||||
parts := strings.SplitN(path, ".", 2)
|
||||
if len(parts) == 1 {
|
||||
delete(m, parts[0])
|
||||
} else {
|
||||
if child, ok := m[parts[0]].(map[string]any); ok {
|
||||
delete(child, parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user