Files
go-omni/cmd/go-omni/apigen/logicCheck.go

241 lines
8.2 KiB
Go

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
}