package apigen import ( "bytes" "errors" "fmt" "go/ast" "go/build" "go/format" goparser "go/parser" "go/scanner" gotoken "go/token" "os" "path/filepath" "sort" "strconv" "strings" ) type logicMethod struct { path, signature string line int known bool } // checkLogic inspects source only: no project code, init hooks, Go commands or // dependency downloads are executed. Seeds participate only when files do not // already exist, matching apply's ownership rules. func checkLogic(root, module string, contract *contractSnapshot, seeds map[string][]byte) ([]Diagnostic, map[string]logicMethod) { groups := map[string][]routeContract{} knownTypes := map[string]bool{} for _, r := range contract.Routes { if !r.Manual { groups[r.Group] = append(groups[r.Group], r) } for name := range r.Types { knownTypes[name] = true } } // Project-owned aliases may make differently named DTOs identical. Let // the compiler handle such cases rather than rejecting valid Go aliases. typeExtensions := false if entries, err := os.ReadDir(filepath.Join(root, "internal/types")); err == nil { for _, e := range entries { if sourceFile(e.Name()) && !ownedPath("internal/types/"+e.Name()) { typeExtensions = true } } } var diagnostics []Diagnostic locations := map[string]logicMethod{} for group, routes := range groups { directory := "internal/logic/" + group files := map[string][]byte{} uncertain := false entries, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(directory))) if err != nil && !errors.Is(err, os.ErrNotExist) { diagnostics = append(diagnostics, Diagnostic{Severity: "error", Code: "logicUnreadable", Path: directory, Message: err.Error()}) continue } for _, e := range entries { if e.IsDir() || !sourceFile(e.Name()) { continue } name := directory + "/" + e.Name() if err := safePath(root, name); err != nil { diagnostics = append(diagnostics, Diagnostic{Severity: "error", Code: "logicUnreadable", Path: name, Message: err.Error()}) uncertain = true continue } data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(name))) if err != nil { diagnostics = append(diagnostics, Diagnostic{Severity: "error", Code: "logicUnreadable", Path: name, Message: err.Error()}) uncertain = true continue } files[name] = data } for name, data := range seeds { if filepath.ToSlash(filepath.Dir(name)) == directory && sourceFile(filepath.Base(name)) { if _, exists := files[name]; !exists { files[name] = data } } } methods := map[string][]logicMethod{} var names []string for name := range files { names = append(names, name) } sort.Strings(names) for _, name := range names { data := files[name] // Do not guess the user's GOFLAGS/build-tag combination. Flag the // group for compiler verification if conditional files are present. if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(name))); err == nil { match, err := build.Default.MatchFile(filepath.Join(root, filepath.FromSlash(directory)), filepath.Base(name)) if err != nil || !match { uncertain = true continue } } positions := gotoken.NewFileSet() file, err := goparser.ParseFile(positions, name, data, goparser.SkipObjectResolution|goparser.ParseComments) if err != nil { d := Diagnostic{Severity: "error", Code: "logicSyntax", Path: name, Message: "cannot parse Logic source: " + err.Error()} if list, ok := err.(scanner.ErrorList); ok && len(list) > 0 { d.Line = list[0].Pos.Line } diagnostics = append(diagnostics, d) uncertain = true continue } conditional := false for _, group := range file.Comments { for _, comment := range group.List { if comment.Pos() < file.Package && (strings.HasPrefix(comment.Text, "//go:build ") || strings.HasPrefix(comment.Text, "// +build ")) { conditional = true } } } if conditional { uncertain = true continue } imports := map[string]string{} for _, imp := range file.Imports { path, err := strconv.Unquote(imp.Path.Value) if err != nil { continue } alias := filepath.Base(path) if imp.Name != nil { alias = imp.Name.Name } imports[alias] = path } for _, decl := range file.Decls { fn, ok := decl.(*ast.FuncDecl) if !ok || fn.Recv == nil || len(fn.Recv.List) != 1 { continue } receiver := fn.Recv.List[0].Type if pointer, ok := receiver.(*ast.StarExpr); ok { receiver = pointer.X } id, ok := receiver.(*ast.Ident) if !ok || id.Name != "Logic" { continue } params, a := canonicalFields(fn.Type.Params, imports, module, knownTypes, typeExtensions) results, b := canonicalFields(fn.Type.Results, imports, module, knownTypes, typeExtensions) methods[fn.Name.Name] = append(methods[fn.Name.Name], logicMethod{name, displaySignature(fn.Name.Name, params, results), positions.Position(fn.Pos()).Line, a && b}) } } for _, r := range routes { found := methods[upper(r.Handler)] if len(found) == 1 && !uncertain { locations[r.file()] = found[0] } d := Diagnostic{Path: r.file(), Operation: r.operation(), After: r.Signature} switch { case uncertain: d.Severity, d.Code, d.Message = "review", "logicUnchecked", "conditional or unreadable source prevents a complete signature check; run go test ./... with your build settings" case len(found) == 0: d.Severity, d.Code, d.Message = "review", "logicNotFound", "no direct Logic method found; check embedded/aliased implementations with go test ./..." case len(found) > 1: d.Severity, d.Code, d.Message = "error", "logicDuplicate", "multiple Logic methods have this name" for _, m := range found { d.Details = append(d.Details, fmt.Sprintf("%s:%d", m.path, m.line)) } case !found[0].known: d.Severity, d.Code, d.Message = "review", "logicUnchecked", "signature uses project-specific type names; verify aliases with go test ./..." d.Path, d.Line, d.Before = found[0].path, found[0].line, found[0].signature case found[0].signature != r.Signature: d.Severity, d.Code, d.Message = "error", "logicSignature", "preserved Logic signature differs from the generated call; update this method" d.Path, d.Line, d.Before = found[0].path, found[0].line, found[0].signature default: continue } diagnostics = append(diagnostics, d) } } return diagnostics, locations } func sourceFile(name string) bool { return strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, "_test.go") && !strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "_") } func canonicalFields(fields *ast.FieldList, imports map[string]string, module string, knownTypes map[string]bool, extensions bool) ([]string, bool) { var values []string known := true if fields != nil { for _, field := range fields.List { value, ok := canonicalType(field.Type, imports, module, knownTypes, extensions) known = known && ok count := len(field.Names) if count == 0 { count = 1 } for i := 0; i < count; i++ { values = append(values, value) } } } return values, known } func canonicalType(expr ast.Expr, imports map[string]string, module string, knownTypes map[string]bool, extensions bool) (string, bool) { switch e := expr.(type) { case *ast.ParenExpr: return canonicalType(e.X, imports, module, knownTypes, extensions) case *ast.StarExpr: value, ok := canonicalType(e.X, imports, module, knownTypes, extensions) return "*" + value, ok case *ast.Ellipsis: value, ok := canonicalType(e.Elt, imports, module, knownTypes, extensions) return "..." + value, ok case *ast.Ident: switch e.Name { case "error", "bool", "string", "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64", "uintptr", "float32", "float64", "complex64", "complex128", "any": return e.Name, true } case *ast.SelectorExpr: if pkg, ok := e.X.(*ast.Ident); ok { switch imports[pkg.Name] { case "context": return "context." + e.Sel.Name, true case frameworkModule + "/httpx": return "httpx." + e.Sel.Name, true case module + "/internal/types": return "types." + e.Sel.Name, knownTypes[e.Sel.Name] || !extensions } } } var b bytes.Buffer _ = format.Node(&b, gotoken.NewFileSet(), expr) return b.String(), false }