diff --git a/internal/cli/commands/index.go b/internal/cli/commands/index.go index 686f0ad..c862099 100644 --- a/internal/cli/commands/index.go +++ b/internal/cli/commands/index.go @@ -52,6 +52,13 @@ var indexCmd = &cobra.Command{ if modName != "" { goParser.SetModuleName(modName) } + + // Resolve to an absolute path for canonical package path computation + absRoot, err := filepath.Abs(path) + if err != nil { + absRoot = path + } + goParser.SetRootPath(absRoot) tsParser := treesitter.NewParser() ctx := context.Background() matcher := ignore.NewMatcher(path) diff --git a/internal/cli/commands/root.go b/internal/cli/commands/root.go index 4c1741a..b4beced 100644 --- a/internal/cli/commands/root.go +++ b/internal/cli/commands/root.go @@ -203,5 +203,3 @@ func resolveSymbolID(ctx context.Context, store contracts.Store, input string) ( return "", fmt.Errorf("symbol %q not found in the graph. Use 'lea symbols' to list available symbols", input) } - - diff --git a/internal/parser/calls.go b/internal/parser/calls.go index 3069968..37b469c 100644 --- a/internal/parser/calls.go +++ b/internal/parser/calls.go @@ -52,7 +52,7 @@ func (cp *CallParser) ExtractCalls(_ context.Context, path string) ([]*graph.Edg switch x := n.(type) { case *ast.FuncDecl: currentFunc = cp.funcID(x, cp.pkgPath) - // Track receiver variable + // Track receiver variable for deep selector resolution if x.Recv != nil { if recvType := getReceiverType(x.Recv); recvType != "" { if len(x.Recv.List) > 0 && len(x.Recv.List[0].Names) > 0 { @@ -63,6 +63,50 @@ func (cp *CallParser) ExtractCalls(_ context.Context, path string) ([]*graph.Edg } } } + + case *ast.GenDecl: + // Track variable assignments to build local type table + if cp.reg != nil { + for _, spec := range x.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok || len(vs.Names) == 0 || vs.Type != nil { + continue + } + for _, val := range vs.Values { + cp.trackAssignmentVar(val, vs.Names[0].Name) + } + } + } + + case *ast.TypeSpec: + // Register struct types and their fields for deep selector resolution + if cp.reg != nil { + if st, ok := x.Type.(*ast.StructType); ok { + typeName := x.Name.Name + var fields []StructFieldInfo + for _, field := range st.Fields.List { + if len(field.Names) == 0 { + continue + } + fieldName := field.Names[0].Name + fieldTypeStr := selectorChainString(field.Type) + fields = append(fields, StructFieldInfo{ + FieldName: fieldName, + FieldType: fieldTypeStr, + }) + } + cp.reg.RegisterStruct(cp.pkgPath, typeName, fields) + } + } + + case *ast.AssignStmt: + // Track short variable declarations like repo := repository.NewInMem() + if cp.reg != nil && x.Tok == token.DEFINE && len(x.Lhs) == 1 && len(x.Rhs) == 1 { + if ident, ok := x.Lhs[0].(*ast.Ident); ok { + cp.trackAssignmentVar(x.Rhs[0], ident.Name) + } + } + case *ast.CallExpr: if currentFunc == "" { return true @@ -93,6 +137,44 @@ func (cp *CallParser) ExtractControlFlow(_ context.Context, path string) ([]*gra cp.edges = nil cp.order = 0 + // Pre-pass: register struct types and top-level variables for deep selector resolution + ast.Inspect(f, func(n ast.Node) bool { + switch x := n.(type) { + case *ast.TypeSpec: + if cp.reg != nil { + if st, ok := x.Type.(*ast.StructType); ok { + typeName := x.Name.Name + var fields []StructFieldInfo + for _, field := range st.Fields.List { + if len(field.Names) == 0 { + continue + } + fieldName := field.Names[0].Name + fieldTypeStr := selectorChainString(field.Type) + fields = append(fields, StructFieldInfo{ + FieldName: fieldName, + FieldType: fieldTypeStr, + }) + } + cp.reg.RegisterStruct(cp.pkgPath, typeName, fields) + } + } + case *ast.GenDecl: + if cp.reg != nil { + for _, spec := range x.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok || len(vs.Names) == 0 || vs.Type != nil { + continue + } + for _, val := range vs.Values { + cp.trackAssignmentVar(val, vs.Names[0].Name) + } + } + } + } + return true + }) + for _, decl := range f.Decls { fn, ok := decl.(*ast.FuncDecl) if !ok || fn.Body == nil { @@ -197,13 +279,148 @@ func selectorChainString(expr ast.Expr) string { } } +// trackAssignmentVar records the inferred type of a variable from its right-hand side expression. +// This populates the TypeRegistry's LocalVarTypes table for resolving method calls on variables. +func (cp *CallParser) trackAssignmentVar(rhs ast.Expr, varName string) { + switch val := rhs.(type) { + case *ast.CallExpr: + // varName := NewConstructor() or varName := package.NewConstructor() + switch fun := val.Fun.(type) { + case *ast.Ident: + // NewConstructor() - infer type from function name (strip "New" prefix) + typeName := strings.TrimPrefix(fun.Name, "New") + if typeName != "" && typeName != fun.Name { + cp.reg.LocalVarTypes[varName] = fmt.Sprintf("%s:%s", cp.pkgPath, typeName) + } + case *ast.SelectorExpr: + // package.NewConstructor() - resolve alias to canonical path + if id, ok := fun.X.(*ast.Ident); ok { + pkgAlias := id.Name + typeName := strings.TrimPrefix(fun.Sel.Name, "New") + if typeName != "" && typeName != fun.Sel.Name { + // Resolve import alias to canonical package path + canonPkg := pkgAlias + if path, ok := cp.imports[pkgAlias]; ok { + if cp.reg.ModuleName != "" && strings.HasPrefix(path, cp.reg.ModuleName) { + canonPkg = strings.TrimPrefix(path, cp.reg.ModuleName) + canonPkg = strings.TrimPrefix(canonPkg, "/") + } else { + canonPkg = path + } + } + cp.reg.LocalVarTypes[varName] = fmt.Sprintf("%s:%s", canonPkg, typeName) + } + } + } + case *ast.UnaryExpr: + if val.Op == token.AND { + // varName := &Type{} + if comp, ok := val.X.(*ast.CompositeLit); ok { + if t, ok := comp.Type.(*ast.Ident); ok { + cp.reg.LocalVarTypes[varName] = fmt.Sprintf("%s:%s", cp.pkgPath, t.Name) + } + if t, ok := comp.Type.(*ast.SelectorExpr); ok { + if id, ok := t.X.(*ast.Ident); ok { + pkgAlias := id.Name + canonPkg := pkgAlias + if path, ok := cp.imports[pkgAlias]; ok { + if cp.reg.ModuleName != "" && strings.HasPrefix(path, cp.reg.ModuleName) { + canonPkg = strings.TrimPrefix(path, cp.reg.ModuleName) + canonPkg = strings.TrimPrefix(canonPkg, "/") + } else { + canonPkg = path + } + } + cp.reg.LocalVarTypes[varName] = fmt.Sprintf("%s:%s", canonPkg, t.Sel.Name) + } + } + } + } + case *ast.CompositeLit: + // varName := Type{} + if t, ok := val.Type.(*ast.Ident); ok { + cp.reg.LocalVarTypes[varName] = fmt.Sprintf("%s:%s", cp.pkgPath, t.Name) + } + } +} + +// trackAssignVarFromExpr records the inferred type of a variable from its right-hand side expression. +// This is a package-level standalone function for use from walkStmt. +func trackAssignVarFromExpr(rhs ast.Expr, varName string, reg *TypeRegistry, imports map[string]string, pkgPath string) { + if reg == nil { + return + } + switch val := rhs.(type) { + case *ast.CallExpr: + switch fun := val.Fun.(type) { + case *ast.Ident: + typeName := strings.TrimPrefix(fun.Name, "New") + if typeName != "" && typeName != fun.Name { + reg.LocalVarTypes[varName] = fmt.Sprintf("%s:%s", pkgPath, typeName) + } + case *ast.SelectorExpr: + if id, ok := fun.X.(*ast.Ident); ok { + pkgAlias := id.Name + typeName := strings.TrimPrefix(fun.Sel.Name, "New") + if typeName != "" && typeName != fun.Sel.Name { + canonPkg := pkgAlias + if path, ok := imports[pkgAlias]; ok { + if reg.ModuleName != "" && strings.HasPrefix(path, reg.ModuleName) { + canonPkg = strings.TrimPrefix(path, reg.ModuleName) + canonPkg = strings.TrimPrefix(canonPkg, "/") + } else { + canonPkg = path + } + } + reg.LocalVarTypes[varName] = fmt.Sprintf("%s:%s", canonPkg, typeName) + } + } + } + case *ast.UnaryExpr: + if val.Op == token.AND { + if comp, ok := val.X.(*ast.CompositeLit); ok { + if t, ok := comp.Type.(*ast.Ident); ok { + reg.LocalVarTypes[varName] = fmt.Sprintf("%s:%s", pkgPath, t.Name) + } + if t, ok := comp.Type.(*ast.SelectorExpr); ok { + if id, ok := t.X.(*ast.Ident); ok { + pkgAlias := id.Name + canonPkg := pkgAlias + if path, ok := imports[pkgAlias]; ok { + if reg.ModuleName != "" && strings.HasPrefix(path, reg.ModuleName) { + canonPkg = strings.TrimPrefix(path, reg.ModuleName) + canonPkg = strings.TrimPrefix(canonPkg, "/") + } else { + canonPkg = path + } + } + reg.LocalVarTypes[varName] = fmt.Sprintf("%s:%s", canonPkg, t.Sel.Name) + } + } + } + } + case *ast.CompositeLit: + if t, ok := val.Type.(*ast.Ident); ok { + reg.LocalVarTypes[varName] = fmt.Sprintf("%s:%s", pkgPath, t.Name) + } + } +} + // resolveCallTarget is a fallback resolution without TypeRegistry. func resolveCallTarget(target string, imports map[string]string, pkgPath string) string { + // Handle built-in functions (no dot, no import resolution needed) if !strings.Contains(target, ".") { + if isBuiltinFunc(target) { + return fmt.Sprintf("stdlib:builtin:%s", target) + } return fmt.Sprintf("func:%s:%s", pkgPath, target) } parts := strings.SplitN(target, ".", 2) if path, ok := imports[parts[0]]; ok { + // Go standard library package (fmt, os, context, etc.) + if isStdlibImport(path) { + return fmt.Sprintf("stdlib:%s:%s", path, parts[1]) + } return fmt.Sprintf("func:%s:%s", path, parts[1]) } return fmt.Sprintf("unknown:%s", target) @@ -223,6 +440,14 @@ type flowContext struct { func walkStmt(stmt ast.Stmt, pkgPath, currentFunc string, order *int, ctxStack []flowContext, imports map[string]string, reg *TypeRegistry, fset *token.FileSet, edges *[]*graph.Edge) { switch s := stmt.(type) { + case *ast.AssignStmt: + // Track short variable declarations like calc := &Calculator{} + if reg != nil && s.Tok == token.DEFINE && len(s.Lhs) == 1 && len(s.Rhs) == 1 { + if ident, ok := s.Lhs[0].(*ast.Ident); ok { + trackAssignVarFromExpr(s.Rhs[0], ident.Name, reg, imports, pkgPath) + } + } + collectCallsFromNode(s, pkgPath, currentFunc, order, ctxStack, imports, reg, fset, edges) case *ast.BlockStmt: walkStmtList(s.List, pkgPath, currentFunc, order, ctxStack, imports, reg, fset, edges) case *ast.IfStmt: diff --git a/internal/parser/golang/parser.go b/internal/parser/golang/parser.go index 0e50c06..a981937 100644 --- a/internal/parser/golang/parser.go +++ b/internal/parser/golang/parser.go @@ -15,6 +15,37 @@ import ( graph "github.com/PizenLabs/lea/internal/graph/contracts" ) +// builtinFuncs is the set of Go built-in functions that should be labeled as stdlib:builtin:. +var builtinFuncs = map[string]bool{ + "make": true, "new": true, "panic": true, "append": true, + "len": true, "cap": true, "delete": true, "close": true, + "copy": true, "print": true, "println": true, "recover": true, + "complex": true, "real": true, "imag": true, +} + +// isBuiltinFunc returns true if name is a Go built-in function. +func isBuiltinFunc(name string) bool { + return builtinFuncs[name] +} + +// isStdlibImport returns true if importPath belongs to Go's standard library. +// Go stdlib packages never have a dot in the first path segment (before the first "/"). +func isStdlibImport(importPath string) bool { + if importPath == "" { + return false + } + firstSeg := importPath + if idx := strings.Index(importPath, "/"); idx >= 0 { + firstSeg = importPath[:idx] + } + return !strings.Contains(firstSeg, ".") +} + +// isInternalModulePath checks if the import path belongs to the current module. +func isInternalModulePath(path, moduleName string) bool { + return moduleName != "" && strings.HasPrefix(path, moduleName) +} + // StructFieldInfo holds the type information for a struct field. type StructFieldInfo struct { FieldName string // The name of the field @@ -32,6 +63,7 @@ type StructInfo struct { type Parser struct { fset *token.FileSet moduleName string + moduleRoot string // Absolute path to module root for computing canonical package paths structIndex map[string]*StructInfo // key: "pkgPath:StructName" localVarTypes map[string]string // key: varName -> "pkgPath:TypeName" (per-file scope) funcReturnTypes map[string]string // key: "pkgPath:FuncName" -> "TypeName" (constructor return types) @@ -50,6 +82,26 @@ func (p *Parser) SetModuleName(name string) { p.moduleName = name } +// SetRootPath sets the module root directory for computing canonical package paths. +// This ensures nodes are stored with module-relative paths (e.g., "cmd/server") +// instead of absolute filesystem paths. +func (p *Parser) SetRootPath(root string) { + p.moduleRoot = root +} + +// canonicalPkgPath computes the canonical package path from a file path. +// If moduleRoot is set, returns the module-relative directory path. +// Otherwise falls back to the filesystem directory path. +func (p *Parser) canonicalPkgPath(filePath string) string { + if p.moduleRoot != "" { + rel, err := filepath.Rel(p.moduleRoot, filePath) + if err == nil { + return filepath.Dir(rel) + } + } + return filepath.Dir(filePath) +} + func (p *Parser) extractImports(f *ast.File) map[string]string { imports := make(map[string]string) for _, imp := range f.Imports { @@ -84,7 +136,7 @@ func (p *Parser) ParseFile(_ context.Context, path string) ([]*graph.Node, []*gr imports := p.extractImports(f) // Use directory as package path for now - pkgPath := filepath.Dir(path) + pkgPath := p.canonicalPkgPath(path) pkgID := fmt.Sprintf("pkg:%s", pkgPath) nodes = append(nodes, &graph.Node{ @@ -403,7 +455,11 @@ func (p *Parser) trackAssignmentVar(rhs ast.Expr, varName string, pkgPath string // When a local variable type key uses an import alias as the package path, // the alias is resolved through the imports table to produce a canonical path. func (p *Parser) resolveID(target string, imports map[string]string, pkgPath string) string { + // Handle built-in functions (no dot, no import resolution needed) if !strings.Contains(target, ".") { + if isBuiltinFunc(target) { + return fmt.Sprintf("stdlib:builtin:%s", target) + } return fmt.Sprintf("func:%s:%s", pkgPath, target) } @@ -426,6 +482,9 @@ func (p *Parser) resolveID(target string, imports map[string]string, pkgPath str rel := strings.TrimPrefix(canonicalPath, p.moduleName) rel = strings.TrimPrefix(rel, "/") pkgPart = rel + } else if isStdlibImport(canonicalPath) { + // Keep as-is for stdlib + pkgPart = canonicalPath } else { pkgPart = canonicalPath } @@ -441,6 +500,9 @@ func (p *Parser) resolveID(target string, imports map[string]string, pkgPath str rel = strings.TrimPrefix(rel, "/") return fmt.Sprintf("method:%s:%s.%s", rel, subParts[1], name) } + if isStdlibImport(pkgPath2) { + return fmt.Sprintf("method:%s:%s.%s", pkgPath2, subParts[1], name) + } return fmt.Sprintf("method:%s:%s.%s", pkgPath2, subParts[1], name) } return fmt.Sprintf("method:%s:%s.%s", pkgPart, typeNamePart, name) @@ -454,11 +516,16 @@ func (p *Parser) resolveID(target string, imports map[string]string, pkgPath str if path, ok := imports[prefix]; ok { // It's a package call if p.moduleName != "" && strings.HasPrefix(path, p.moduleName) { + // Internal module package relPath := strings.TrimPrefix(path, p.moduleName) relPath = strings.TrimPrefix(relPath, "/") return fmt.Sprintf("func:%s:%s", relPath, name) } - // External package + // Go standard library package (fmt, os, context, etc.) + if isStdlibImport(path) { + return fmt.Sprintf("stdlib:%s:%s", path, name) + } + // External third-party package return fmt.Sprintf("func:%s:%s", path, name) } @@ -473,8 +540,11 @@ func (p *Parser) ExtractCalls(_ context.Context, path string) ([]*graph.Edge, er return nil, err } + // Reset per-file local type tracking + p.localVarTypes = make(map[string]string) + var edges []*graph.Edge - pkgPath := filepath.Dir(path) + pkgPath := p.canonicalPkgPath(path) imports := p.extractImports(f) var currentFunc string @@ -493,6 +563,27 @@ func (p *Parser) ExtractCalls(_ context.Context, path string) ([]*graph.Edge, er } else { currentFunc = fmt.Sprintf("func:%s:%s", pkgPath, x.Name.Name) } + + case *ast.GenDecl: + // Track variable assignments to build local type table + for _, spec := range x.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok || len(vs.Names) == 0 || vs.Type != nil { + continue + } + for _, val := range vs.Values { + p.trackAssignmentVar(val, vs.Names[0].Name, pkgPath, imports) + } + } + + case *ast.AssignStmt: + // Track short variable declarations like repo := repository.NewInMem() + if x.Tok == token.DEFINE && len(x.Lhs) == 1 && len(x.Rhs) == 1 { + if ident, ok := x.Lhs[0].(*ast.Ident); ok { + p.trackAssignmentVar(x.Rhs[0], ident.Name, pkgPath, imports) + } + } + case *ast.CallExpr: if currentFunc == "" { return true @@ -519,8 +610,11 @@ func (p *Parser) ExtractControlFlow(_ context.Context, path string) ([]*graph.Ed return nil, err } + // Reset per-file local type tracking + p.localVarTypes = make(map[string]string) + var edges []*graph.Edge - pkgPath := filepath.Dir(path) + pkgPath := p.canonicalPkgPath(path) imports := p.extractImports(f) for _, decl := range f.Decls { @@ -562,6 +656,14 @@ func (p *Parser) walkStmtList(stmts []ast.Stmt, pkgPath, currentFunc string, ord func (p *Parser) walkStmt(stmt ast.Stmt, pkgPath, currentFunc string, order *int, ctxStack []flowContext, imports map[string]string, edges *[]*graph.Edge) { switch s := stmt.(type) { + case *ast.AssignStmt: + // Track short variable declarations like repo := repository.NewInMem() + if s.Tok == token.DEFINE && len(s.Lhs) == 1 && len(s.Rhs) == 1 { + if ident, ok := s.Lhs[0].(*ast.Ident); ok { + p.trackAssignmentVar(s.Rhs[0], ident.Name, pkgPath, imports) + } + } + p.collectCallsFromNode(s, pkgPath, currentFunc, order, ctxStack, imports, edges) case *ast.BlockStmt: p.walkStmtList(s.List, pkgPath, currentFunc, order, ctxStack, imports, edges) case *ast.IfStmt: @@ -710,12 +812,38 @@ func (p *Parser) selectorChainString(expr ast.Expr) string { } } +// UnwindSelector recursively unwinds a selector expression chain, returning the +// base identifier and the ordered chain of field/method names. +// Example: for a.b.c(), returns (ident("a"), ["b", "c"]). +func UnwindSelector(expr *ast.SelectorExpr) (baseIdent *ast.Ident, chains []string) { + current := expr + for { + chains = append([]string{current.Sel.Name}, chains...) + if next, ok := current.X.(*ast.SelectorExpr); ok { + current = next + } else if ident, ok := current.X.(*ast.Ident); ok { + baseIdent = ident + break + } else { + break + } + } + return baseIdent, chains +} + // resolveCallTarget resolves a call expression to a graph node ID using // the local type registry for method calls on variables (Issue 1+2 fix). // For deeply nested selectors like s.repo.UpdateBalance, it walks the field chain // through the struct type registry to find the underlying method. func (p *Parser) resolveCallTarget(ce *ast.CallExpr, imports map[string]string, pkgPath string) string { - // Get the raw call target string (handles deep selectors) + // First, try AST-level resolution using UnwindSelector for SelectorExpr + if sel, ok := ce.Fun.(*ast.SelectorExpr); ok { + if targetID := p.resolveSelectorExpr(sel, imports, pkgPath); targetID != "" { + return targetID + } + } + + // Fallback: Get the raw call target string and try standard resolution target := p.getCallTarget(ce) if target == "" { return "" @@ -726,48 +854,92 @@ func (p *Parser) resolveCallTarget(ce *ast.CallExpr, imports map[string]string, return p.resolveID(target, imports, pkgPath) } - parts := strings.Split(target, ".") - methodName := parts[len(parts)-1] + return p.resolveID(target, imports, pkgPath) +} + +// resolveSelectorExpr resolves a SelectorExpr to a graph node ID using AST-level +// resolution with UnwindSelector for accurate base identifier extraction. +func (p *Parser) resolveSelectorExpr(sel *ast.SelectorExpr, imports map[string]string, pkgPath string) string { + baseIdent, chains := UnwindSelector(sel) + if baseIdent == nil || len(chains) == 0 { + return "" + } + + baseName := baseIdent.Name + methodName := chains[len(chains)-1] + + // Check if this is a package-level function call (pkg.Func) + // by checking if baseName resolves as an import with len(chains) >= 1 + if len(chains) == 1 { + // Two-part: baseName.methodName + // First check if it's a local variable method call + if p.localVarTypes != nil { + if typeKey, ok := p.localVarTypes[baseName]; ok { + typeParts := strings.SplitN(typeKey, ":", 2) + if len(typeParts) == 2 { + pkgPart := p.normalizePkgPart(typeParts[0], imports) + return fmt.Sprintf("method:%s:%s.%s", pkgPart, typeParts[1], methodName) + } + } + } + + // Check if it's a package function call + if path, ok := imports[baseName]; ok { + if p.moduleName != "" && strings.HasPrefix(path, p.moduleName) { + rel := strings.TrimPrefix(path, p.moduleName) + rel = strings.TrimPrefix(rel, "/") + return fmt.Sprintf("func:%s:%s", rel, methodName) + } + if isStdlibImport(path) { + return fmt.Sprintf("stdlib:%s:%s", path, methodName) + } + return fmt.Sprintf("func:%s:%s", path, methodName) + } + + // Local function + return fmt.Sprintf("func:%s:%s", pkgPath, baseName) + } - // Check if the first part is a local variable (receiver or local var) - // If so, attempt deep field chain resolution through struct registry + // Multi-part selector (baseName.field1.field2...methodName) + // Walk the struct field chain through the struct registry if p.localVarTypes != nil { - if typeKey, ok := p.localVarTypes[parts[0]]; ok { - // Walk the field chain (parts[1..n-1]) through struct types + if typeKey, ok := p.localVarTypes[baseName]; ok { typeParts := strings.SplitN(typeKey, ":", 2) if len(typeParts) == 2 { - // Expand import alias in package path part to canonical path - pkgPart := typeParts[0] - if canonicalPath, ok := imports[pkgPart]; ok { - if p.moduleName != "" && strings.HasPrefix(canonicalPath, p.moduleName) { - rel := strings.TrimPrefix(canonicalPath, p.moduleName) - rel = strings.TrimPrefix(rel, "/") - pkgPart = rel - } else { - pkgPart = canonicalPath - } - } + pkgPart := p.normalizePkgPart(typeParts[0], imports) currentType := fmt.Sprintf("%s:%s", pkgPart, typeParts[1]) - resolved := true - for i := 1; i < len(parts)-1; i++ { - fieldType := p.resolveFieldType(currentType, parts[i], imports) + + // Walk through intermediate fields (chains[0..n-2]) + for i := 0; i < len(chains)-1; i++ { + fieldType := p.resolveFieldType(currentType, chains[i], imports) if fieldType == "" { - resolved = false - break + return "" // Cannot resolve field } currentType = fieldType } - if resolved { - if uri := p.typeKeyToMethodURI(currentType, methodName, imports); uri != "" { - return uri - } + + // Now currentType is the key of the terminal type holding the method + if uri := p.typeKeyToMethodURI(currentType, methodName, imports); uri != "" { + return uri } } } } - // Fall back to standard resolution (package.func or unknown) - return p.resolveID(target, imports, pkgPath) + return "" +} + +// normalizePkgPart resolves an import alias to a canonical package path. +func (p *Parser) normalizePkgPart(pkgPart string, imports map[string]string) string { + if canonicalPath, ok := imports[pkgPart]; ok { + if p.moduleName != "" && strings.HasPrefix(canonicalPath, p.moduleName) { + rel := strings.TrimPrefix(canonicalPath, p.moduleName) + rel = strings.TrimPrefix(rel, "/") + return rel + } + return canonicalPath + } + return pkgPart } // typeKeyToMethodURI converts a type key and method name to a proper graph node URI. diff --git a/internal/parser/golang/parser_test.go b/internal/parser/golang/parser_test.go index 749d04e..f63ce02 100644 --- a/internal/parser/golang/parser_test.go +++ b/internal/parser/golang/parser_test.go @@ -90,7 +90,7 @@ func TestExtractCalls(t *testing.T) { from string to string }{ - {from: "method:" + pkgPath + ":Calculator.Add", to: "func:fmt:Println"}, + {from: "method:" + pkgPath + ":Calculator.Add", to: "stdlib:fmt:Println"}, {from: "func:" + pkgPath + ":Main", to: "func:" + pkgPath + ":Add"}, // calc.Add(5) should now resolve through local type inference instead of "unknown:calc.Add" {from: "func:" + pkgPath + ":Main", to: "method:" + pkgPath + ":Calculator.Add"}, @@ -246,7 +246,7 @@ func TestCrossPackageResolution(t *testing.T) { } target2 := p.resolveID("fmt.Println", imports, pkgPath) - expected2 := "func:fmt:Println" + expected2 := "stdlib:fmt:Println" if target2 != expected2 { t.Errorf("Expected %s, got %s", expected2, target2) } diff --git a/internal/parser/parser_test.go b/internal/parser/parser_test.go index 435caae..db915eb 100644 --- a/internal/parser/parser_test.go +++ b/internal/parser/parser_test.go @@ -157,7 +157,7 @@ func TestTypeRegistry_ResolveCallTarget(t *testing.T) { { target: "fmt.Println", pkgPath: "cmd/lea", - expected: "func:fmt:Println", + expected: "stdlib:fmt:Println", }, { target: "service.NewPaymentService", @@ -249,7 +249,7 @@ func TestCrossPackageResolution(t *testing.T) { expected string }{ {"contracts.SomeFunc", "func:internal/graph/contracts:SomeFunc"}, - {"fmt.Println", "func:fmt:Println"}, + {"fmt.Println", "stdlib:fmt:Println"}, {"LocalFunc", "func:cmd/lea:LocalFunc"}, } @@ -287,7 +287,7 @@ func TestExtractCalls_WithTypeRegistry(t *testing.T) { // Verify package-level func resolution funcID := reg.ResolveCallTarget("fmt.Println", imports, "cmd/lea") - expectedFunc := "func:fmt:Println" + expectedFunc := "stdlib:fmt:Println" if funcID != expectedFunc { t.Errorf("fmt.Println resolved to %q, want %q", funcID, expectedFunc) } @@ -328,17 +328,22 @@ func TestExtractCalls_PaymentService(t *testing.T) { foundUpdate := false for _, e := range edges { if e.FromID == expectedFrom && e.Type == graph.EdgeCalls { - if e.ToID == "func:fmt:Println" || e.ToID == "unknown:s.log.Info" { + if e.ToID == "method:gopump/pkg/logger:Logger.Info" || e.ToID == "stdlib:fmt:Sprintf" || e.ToID == "stdlib:fmt:Println" { foundInfo = true } - if e.ToID == "unknown:s.repo.UpdateBalance" { + if e.ToID == "method:gopump/internal/domain:WalletRepository.UpdateBalance" { foundUpdate = true } } } if !foundInfo { - t.Errorf("Expected a CALLS edge from %s to s.log.Info or fmt.Println", expectedFrom) + t.Errorf("Expected a CALLS edge from %s to s.log.Info or fmt.Println, got:\n", expectedFrom) + for _, e := range edges { + if e.FromID == expectedFrom { + t.Logf(" Edge: %s (%s) -> %s", e.FromID, e.Type, e.ToID) + } + } } if !foundUpdate { t.Errorf("Expected a CALLS edge from %s to s.repo.UpdateBalance", expectedFrom) @@ -567,7 +572,7 @@ func TestResolveCallTarget_Fallback(t *testing.T) { expected string }{ {"LocalFunc", "pkg", "func:pkg:LocalFunc"}, - {"fmt.Println", "pkg", "func:fmt:Println"}, + {"fmt.Println", "pkg", "stdlib:fmt:Println"}, {"unknown.Target", "pkg", "unknown:unknown.Target"}, } diff --git a/internal/parser/resolver.go b/internal/parser/resolver.go index d0d92b9..5561248 100644 --- a/internal/parser/resolver.go +++ b/internal/parser/resolver.go @@ -3,9 +3,42 @@ package parser import ( "fmt" + "go/ast" "strings" ) +// builtinFuncs is the set of Go built-in functions that should be labeled as stdlib:builtin:. +var builtinFuncs = map[string]bool{ + "make": true, "new": true, "panic": true, "append": true, + "len": true, "cap": true, "delete": true, "close": true, + "copy": true, "print": true, "println": true, "recover": true, + "complex": true, "real": true, "imag": true, +} + +// isStdlibImport returns true if importPath belongs to Go's standard library. +// Go stdlib packages never have a dot in the first path segment (before the first "/"), +// while third-party packages always start with a domain containing a dot. +func isStdlibImport(importPath string) bool { + if importPath == "" { + return false + } + firstSeg := importPath + if idx := strings.Index(importPath, "/"); idx >= 0 { + firstSeg = importPath[:idx] + } + return !strings.Contains(firstSeg, ".") +} + +// isBuiltinFunc returns true if name is a Go built-in function. +func isBuiltinFunc(name string) bool { + return builtinFuncs[name] +} + +// isInternalModulePath checks if the import path belongs to the current module. +func isInternalModulePath(path, moduleName string) bool { + return moduleName != "" && strings.HasPrefix(path, moduleName) +} + // StructFieldInfo holds the type information for a struct field. type StructFieldInfo struct { FieldName string @@ -96,11 +129,29 @@ func (tr *TypeRegistry) RegisterStruct(pkgPath, typeName string, fields []Struct } } -// ResolveMethodID resolves a simple selector expression (var.method) +// UnwindSelector recursively unwinds a selector expression chain, returning the +// base identifier and the ordered chain of field/method names. +// Example: for a.b.c(), returns (ident("a"), ["b", "c"]). +func UnwindSelector(expr *ast.SelectorExpr) (baseIdent *ast.Ident, chains []string) { + current := expr + for { + chains = append([]string{current.Sel.Name}, chains...) + if next, ok := current.X.(*ast.SelectorExpr); ok { + current = next + } else if ident, ok := current.X.(*ast.Ident); ok { + baseIdent = ident + break + } else { + break + } + } + return baseIdent, chains +} + +// ResolveMethodID resolves a selector expression (var.method or var.field.sub.method) // to a canonical method node ID. -// Only handles 2-part selectors (e.g., "svc.ProcessDeposit"). -// Multi-part selectors (e.g., "s.repo.UpdateBalance") require struct field -// chain walk and return "" to fall through to package-level resolution. +// Handles 2-part selectors (e.g., "svc.ProcessDeposit") and multi-part selectors +// (e.g., "s.repo.UpdateBalance") by walking the struct field chain. // Returns "" when resolution fails. func (tr *TypeRegistry) ResolveMethodID(target string, imports map[string]string, _ string) string { if tr == nil { @@ -111,29 +162,111 @@ func (tr *TypeRegistry) ResolveMethodID(target string, imports map[string]string } parts := strings.Split(target, ".") - // Only resolve 2-part selectors (var.method). Multi-part selectors - // like s.repo.UpdateBalance need struct field chain walk. - if len(parts) != 2 { - return "" - } - - methodName := parts[1] + methodName := parts[len(parts)-1] // Check if first part is a tracked local variable if tr.LocalVarTypes != nil { if typeKey, ok := tr.LocalVarTypes[parts[0]]; ok { - // typeKey is "fullPkgPath:ExactTypeName" - return tr.resolveFromTypeKey(typeKey, methodName, imports) + // For 2-part selectors (var.method), resolve directly + if len(parts) == 2 { + return tr.resolveFromTypeKey(typeKey, methodName, imports) + } + + // For multi-part selectors (var.field.sub.method), walk the struct field chain + // Resolve the base variable's type key ("fullPkgPath:ExactTypeName") to a struct key + typeKeyNormalized := tr.normalizeTypeKey(typeKey, imports) + currentTypeKey := typeKeyNormalized + + // Walk each intermediate field (parts[1]..parts[n-2]) through the struct registry + for i := 1; i < len(parts)-1; i++ { + fieldType := tr.ResolveFieldType(currentTypeKey, parts[i]) + if fieldType == "" { + return "" // Field not found in struct registry + } + // The field type might be a simple name ("WalletRepository") or + // a package-qualified name ("domain.WalletRepository"). + // Build the next struct key from the current struct's package path + field type. + fieldType = strings.TrimPrefix(fieldType, "*") + if strings.Contains(fieldType, ".") { + // Package-qualified: resolve through imports to get canonical key + if resolved := tr.resolvePackageQualifiedKey(fieldType, imports); resolved != "" { + currentTypeKey = resolved + } else { + return "" + } + } else { + // Same package: use the struct's own package path + ci := strings.Index(currentTypeKey, ":") + if ci < 0 { + return "" + } + currentTypeKey = fmt.Sprintf("%s:%s", currentTypeKey[:ci], fieldType) + } + } + + // Now currentTypeKey is the key of the terminal struct type holding the method + ci := strings.Index(currentTypeKey, ":") + if ci < 0 { + return "" + } + return fmt.Sprintf("method:%s:%s.%s", currentTypeKey[:ci], currentTypeKey[ci+1:], methodName) } } return "" } +// normalizeTypeKey resolves a type key into a canonical "fullPkgPath:TypeName" form, +// expanding import aliases in the package portion if needed. +func (tr *TypeRegistry) normalizeTypeKey(typeKey string, imports map[string]string) string { + idx := strings.Index(typeKey, ":") + if idx < 0 { + return typeKey + } + pkgPart := typeKey[:idx] + typeName := typeKey[idx+1:] + + // If the package part is an import alias, resolve it to canonical path + if canonicalPath, ok := imports[pkgPart]; ok { + if tr.ModuleName != "" && strings.HasPrefix(canonicalPath, tr.ModuleName) { + rel := strings.TrimPrefix(canonicalPath, tr.ModuleName) + rel = strings.TrimPrefix(rel, "/") + pkgPart = rel + } else { + pkgPart = canonicalPath + } + } + return fmt.Sprintf("%s:%s", pkgPart, typeName) +} + +// resolvePackageQualifiedKey resolves a package-qualified type name (e.g., "domain.WalletRepository") +// to a canonical "fullPkgPath:TypeName" key using the imports map. +func (tr *TypeRegistry) resolvePackageQualifiedKey(typeName string, imports map[string]string) string { + subParts := strings.SplitN(typeName, ".", 2) + if len(subParts) != 2 { + return "" + } + if pkgPath2, ok := imports[subParts[0]]; ok { + if tr.ModuleName != "" && strings.HasPrefix(pkgPath2, tr.ModuleName) { + rel := strings.TrimPrefix(pkgPath2, tr.ModuleName) + rel = strings.TrimPrefix(rel, "/") + return fmt.Sprintf("%s:%s", rel, subParts[1]) + } + return fmt.Sprintf("%s:%s", pkgPath2, subParts[1]) + } + return "" +} + // ResolveCallTarget resolves a full call target string to a graph node ID, // handling local variable method calls, package function calls, and local functions. +// Built-in functions (make, new, panic, etc.) are labeled stdlib:builtin:. +// Go standard library functions (fmt.Println, os.Open, etc.) are labeled stdlib::. func (tr *TypeRegistry) ResolveCallTarget(target string, imports map[string]string, pkgPath string) string { + // Handle built-in functions (no dot, no import resolution needed) if !strings.Contains(target, ".") { + if isBuiltinFunc(target) { + return fmt.Sprintf("stdlib:builtin:%s", target) + } return fmt.Sprintf("func:%s:%s", pkgPath, target) } @@ -147,22 +280,23 @@ func (tr *TypeRegistry) ResolveCallTarget(target string, imports map[string]stri prefix := parts[0] name := parts[1] - if tr != nil && tr.ModuleName != "" { + if tr != nil { if path, ok := imports[prefix]; ok { - relPath := path - if strings.HasPrefix(path, tr.ModuleName) { - relPath = strings.TrimPrefix(path, tr.ModuleName) + // Internal module package (e.g., internal/repository, pkg/logger) + if isInternalModulePath(path, tr.ModuleName) { + relPath := strings.TrimPrefix(path, tr.ModuleName) relPath = strings.TrimPrefix(relPath, "/") + return fmt.Sprintf("func:%s:%s", relPath, name) + } + // Go standard library package (fmt, os, context, etc.) + if isStdlibImport(path) { + return fmt.Sprintf("stdlib:%s:%s", path, name) } - return fmt.Sprintf("func:%s:%s", relPath, name) + // External third-party package + return fmt.Sprintf("func:%s:%s", path, name) } } - // Fallback: try import resolution - if path, ok := imports[prefix]; ok { - return fmt.Sprintf("func:%s:%s", path, name) - } - return fmt.Sprintf("unknown:%s", target) }