Pulse/internal/api/router_decomposition_contract_test.go
2026-03-18 16:06:30 +00:00

373 lines
8.9 KiB
Go

package api
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"testing"
)
func TestRouterDecompositionSetupRoutes(t *testing.T) {
fset, fileAST := parseAPISourceFile(t, "router.go")
setup := findMethodDecl(t, fileAST, "Router", "setupRoutes")
directRegs := collectRouteRegistrations(setup.Body)
const maxDirectRegistrations = 3
if len(directRegs) > maxDirectRegistrations {
t.Fatalf(
"setupRoutes appears re-centralized: found %d direct route registrations (max %d). registrations: %s",
len(directRegs),
maxDirectRegistrations,
strings.Join(directRegs, ", "),
)
}
delegates := collectRouteDelegationCalls(setup.Body)
const minRouteDelegations = 4
if len(delegates) < minRouteDelegations {
t.Fatalf(
"setupRoutes should delegate route wiring to register*Routes helpers: found %d delegations (min %d). found: %s",
len(delegates),
minRouteDelegations,
strings.Join(delegates, ", "),
)
}
_ = fset
}
func TestRouterDecompositionRouteRegistrationDistribution(t *testing.T) {
minByFile := map[string]int{
"router_routes_auth_security.go": 3,
"router_routes_monitoring.go": 3,
"router_routes_ai_relay.go": 3,
"router_routes_licensing.go": 3,
}
for fileName, minCount := range minByFile {
_, fileAST := parseAPISourceFile(t, fileName)
count := countRouteRegistrations(fileAST)
if count < minCount {
t.Fatalf(
"%s has too few route registrations (%d < %d). this suggests route ownership drift or re-centralization",
fileName,
count,
minCount,
)
}
}
}
func TestConfigHandlersDecompositionDelegationBoundaries(t *testing.T) {
fset, fileAST := parseAPISourceFile(t, "config_handlers.go")
methods := exportedHandleMethods(fileAST, "ConfigHandlers")
if len(methods) == 0 {
t.Fatal("no exported ConfigHandlers Handle* methods found in config_handlers.go")
}
const maxMethodLines = 22
const maxTopLevelStatements = 6
for _, method := range methods {
lines := nodeLineSpan(fset, method)
if lines > maxMethodLines {
t.Errorf(
"%s is too large (%d lines > %d). exported Handle* methods in config_handlers.go should remain delegation-focused",
method.Name.Name,
lines,
maxMethodLines,
)
}
if method.Body == nil {
t.Errorf("%s has no body", method.Name.Name)
continue
}
if len(method.Body.List) > maxTopLevelStatements {
t.Errorf(
"%s has too many top-level statements (%d > %d). keep exported Handle* methods thin",
method.Name.Name,
len(method.Body.List),
maxTopLevelStatements,
)
}
if hasHeavyControlFlow(method.Body) {
t.Errorf(
"%s contains control-flow-heavy logic. move implementation into internal helpers and keep Handle* as delegators",
method.Name.Name,
)
}
}
}
func TestRouteInventoryContractCoversAllRouteModules(t *testing.T) {
apiDir := apiDirPath(t)
routeModules := discoverRouteModulesWithRegistrations(t, apiDir)
if len(routeModules) == 0 {
t.Fatal("no route module files with registrations discovered")
}
_, inventoryAST := parseAPISourceFile(t, "route_inventory_test.go")
mentioned := collectStringLiterals(inventoryAST)
var missing []string
for _, module := range routeModules {
if _, ok := mentioned[module]; !ok {
missing = append(missing, module)
}
}
if len(missing) > 0 {
sort.Strings(missing)
t.Fatalf(
"route_inventory_test.go does not reference route module files: %s. update parseRouterRoutes file coverage and allowlists together",
strings.Join(missing, ", "),
)
}
}
func parseAPISourceFile(t *testing.T, name string) (*token.FileSet, *ast.File) {
t.Helper()
filePath := filepath.Join(apiDirPath(t), name)
fset := token.NewFileSet()
fileAST, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments)
if err != nil {
t.Fatalf("parse %s: %v", name, err)
}
return fset, fileAST
}
func apiDirPath(t *testing.T) string {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("failed to resolve test file path")
}
return filepath.Dir(thisFile)
}
func findMethodDecl(t *testing.T, fileAST *ast.File, recvType, methodName string) *ast.FuncDecl {
t.Helper()
for _, decl := range fileAST.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Recv == nil || fn.Name == nil || fn.Name.Name != methodName || len(fn.Recv.List) != 1 {
continue
}
if receiverName(fn.Recv.List[0].Type) == recvType {
return fn
}
}
t.Fatalf("method %s.%s not found", recvType, methodName)
return nil
}
func receiverName(expr ast.Expr) string {
switch v := expr.(type) {
case *ast.Ident:
return v.Name
case *ast.StarExpr:
return receiverName(v.X)
default:
return ""
}
}
func collectRouteRegistrations(node ast.Node) []string {
seen := map[string]struct{}{}
var out []string
ast.Inspect(node, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || !isMuxRouteRegistrationCall(call) {
return true
}
site := registrationSite(call)
if _, exists := seen[site]; exists {
return true
}
seen[site] = struct{}{}
out = append(out, site)
return true
})
sort.Strings(out)
return out
}
func registrationSite(call *ast.CallExpr) string {
if len(call.Args) > 0 {
if lit, ok := call.Args[0].(*ast.BasicLit); ok && lit.Kind == token.STRING {
if s, err := strconv.Unquote(lit.Value); err == nil {
return s
}
}
}
return "dynamic-route"
}
func collectRouteDelegationCalls(body *ast.BlockStmt) []string {
if body == nil {
return nil
}
delegations := map[string]struct{}{}
ast.Inspect(body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel == nil {
return true
}
ident, ok := sel.X.(*ast.Ident)
if !ok || ident.Name != "r" {
return true
}
name := sel.Sel.Name
if strings.HasPrefix(name, "register") && strings.Contains(name, "Routes") {
delegations[name] = struct{}{}
}
return true
})
out := make([]string, 0, len(delegations))
for name := range delegations {
out = append(out, name)
}
sort.Strings(out)
return out
}
func countRouteRegistrations(fileAST *ast.File) int {
total := 0
ast.Inspect(fileAST, func(node ast.Node) bool {
call, ok := node.(*ast.CallExpr)
if ok && isMuxRouteRegistrationCall(call) {
total++
}
return true
})
return total
}
func isMuxRouteRegistrationCall(call *ast.CallExpr) bool {
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel == nil {
return false
}
if sel.Sel.Name != "Handle" && sel.Sel.Name != "HandleFunc" {
return false
}
if len(call.Args) < 2 {
return false
}
receiver := selectorChain(sel.X)
return receiver == "mux" || strings.HasSuffix(receiver, ".mux")
}
func selectorChain(expr ast.Expr) string {
switch v := expr.(type) {
case *ast.Ident:
return v.Name
case *ast.SelectorExpr:
base := selectorChain(v.X)
if base == "" {
return v.Sel.Name
}
return base + "." + v.Sel.Name
default:
return ""
}
}
func exportedHandleMethods(fileAST *ast.File, recvType string) []*ast.FuncDecl {
var methods []*ast.FuncDecl
for _, decl := range fileAST.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Name == nil || fn.Recv == nil || len(fn.Recv.List) != 1 {
continue
}
if !ast.IsExported(fn.Name.Name) || !strings.HasPrefix(fn.Name.Name, "Handle") {
continue
}
if receiverName(fn.Recv.List[0].Type) != recvType {
continue
}
methods = append(methods, fn)
}
sort.Slice(methods, func(i, j int) bool {
return methods[i].Name.Name < methods[j].Name.Name
})
return methods
}
func nodeLineSpan(fset *token.FileSet, node ast.Node) int {
start := fset.Position(node.Pos()).Line
end := fset.Position(node.End()).Line
if end < start {
return 0
}
return end - start + 1
}
func hasHeavyControlFlow(node ast.Node) bool {
heavy := false
ast.Inspect(node, func(n ast.Node) bool {
switch n.(type) {
case *ast.ForStmt, *ast.RangeStmt, *ast.SwitchStmt, *ast.TypeSwitchStmt, *ast.SelectStmt, *ast.GoStmt:
heavy = true
return false
}
return true
})
return heavy
}
func discoverRouteModulesWithRegistrations(t *testing.T, dir string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read api directory: %v", err)
}
var modules []string
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasPrefix(name, "router_routes") || filepath.Ext(name) != ".go" || strings.HasSuffix(name, "_test.go") {
continue
}
_, fileAST := parseAPISourceFile(t, name)
if countRouteRegistrations(fileAST) > 0 {
modules = append(modules, name)
}
}
sort.Strings(modules)
return modules
}
func collectStringLiterals(fileAST *ast.File) map[string]struct{} {
values := map[string]struct{}{}
ast.Inspect(fileAST, func(node ast.Node) bool {
lit, ok := node.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
value, err := strconv.Unquote(lit.Value)
if err != nil {
return true
}
values[value] = struct{}{}
return true
})
return values
}