package main import ( "fmt" "go/ast" "go/parser" "go/token" "os" "path/filepath" "sort" "strings" ) // Analyzer parses Go source code and extracts structural information. type Analyzer struct { sourceDir string fset *token.FileSet } // NewAnalyzer creates a new Analyzer for the given source directory. func NewAnalyzer(sourceDir string) *Analyzer { return &Analyzer{ sourceDir: sourceDir, fset: token.NewFileSet(), } } // Analyze runs the full analysis pipeline. func (a *Analyzer) Analyze() (*AnalysisResult, error) { serverDir := filepath.Join(a.sourceDir, "server") // 1. Discover all Go files grouped by directory fileGroups, err := a.discoverFiles(serverDir) if err != nil { return nil, fmt.Errorf("discovering files: %w", err) } // 2. Parse each group into modules result := &AnalysisResult{} allImports := make(map[string]*ImportInfo) for dir, files := range fileGroups { module, imports, err := a.parseModule(dir, files) if err != nil { return nil, fmt.Errorf("parsing module %s: %w", dir, err) } result.Modules = append(result.Modules, *module) for _, imp := range imports { if existing, ok := allImports[imp.ImportPath]; ok { existing.UsedInFiles = append(existing.UsedInFiles, imp.UsedInFiles...) } else { allImports[imp.ImportPath] = &imp } } } // 3. Build module-level dependencies from import analysis result.Dependencies = a.buildDependencies(result.Modules) // 4. Collect imports for _, imp := range allImports { result.Imports = append(result.Imports, *imp) } sort.Slice(result.Imports, func(i, j int) bool { return result.Imports[i].ImportPath < result.Imports[j].ImportPath }) // Sort modules by name sort.Slice(result.Modules, func(i, j int) bool { return result.Modules[i].Name < result.Modules[j].Name }) return result, nil } // discoverFiles walks the source tree and groups .go files by directory. func (a *Analyzer) discoverFiles(root string) (map[string][]string, error) { groups := make(map[string][]string) err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { if info.Name() == "configs" || info.Name() == "testdata" { return filepath.SkipDir } return nil } if !strings.HasSuffix(info.Name(), ".go") { return nil } dir := filepath.Dir(path) groups[dir] = append(groups[dir], path) return nil }) return groups, err } // parseModule parses all Go files in a directory into a Module. func (a *Analyzer) parseModule(dir string, files []string) (*Module, []ImportInfo, error) { moduleName := a.moduleNameFromDir(dir) module := &Module{ Name: moduleName, GoPackage: moduleName, GoFile: dir, } var sourceFiles []string var testFiles []string for _, f := range files { if strings.HasSuffix(f, "_test.go") { testFiles = append(testFiles, f) } else { sourceFiles = append(sourceFiles, f) } } var allImports []ImportInfo totalLines := 0 for _, f := range sourceFiles { features, imports, lines, err := a.parseSourceFile(f) if err != nil { fmt.Fprintf(os.Stderr, "Warning: skipping %s: %v\n", f, err) continue } module.Features = append(module.Features, features...) allImports = append(allImports, imports...) totalLines += lines } for _, f := range testFiles { tests, _, lines, err := a.parseTestFile(f) if err != nil { fmt.Fprintf(os.Stderr, "Warning: skipping test %s: %v\n", f, err) continue } module.Tests = append(module.Tests, tests...) totalLines += lines } module.GoLineCount = totalLines return module, allImports, nil } // parseSourceFile extracts functions, methods, and imports from a Go source file. func (a *Analyzer) parseSourceFile(filePath string) ([]Feature, []ImportInfo, int, error) { src, err := os.ReadFile(filePath) if err != nil { return nil, nil, 0, err } file, err := parser.ParseFile(a.fset, filePath, src, parser.ParseComments) if err != nil { return nil, nil, 0, err } lines := strings.Count(string(src), "\n") + 1 relPath := a.relPath(filePath) var features []Feature var imports []ImportInfo for _, imp := range file.Imports { path := strings.Trim(imp.Path.Value, "\"") imports = append(imports, ImportInfo{ ImportPath: path, IsStdlib: isStdlib(path), UsedInFiles: []string{relPath}, }) } for _, decl := range file.Decls { fn, ok := decl.(*ast.FuncDecl) if !ok { continue } feature := Feature{ Name: fn.Name.Name, GoFile: relPath, GoMethod: fn.Name.Name, GoLineNumber: a.fset.Position(fn.Pos()).Line, } startLine := a.fset.Position(fn.Pos()).Line endLine := a.fset.Position(fn.End()).Line feature.GoLineCount = endLine - startLine + 1 if fn.Recv != nil && len(fn.Recv.List) > 0 { feature.GoClass = a.receiverTypeName(fn.Recv.List[0].Type) feature.Name = feature.GoClass + "." + fn.Name.Name } if fn.Doc != nil { feature.Description = strings.TrimSpace(fn.Doc.Text()) } features = append(features, feature) } return features, imports, lines, nil } // parseTestFile extracts test functions from a Go test file. func (a *Analyzer) parseTestFile(filePath string) ([]TestFunc, []ImportInfo, int, error) { src, err := os.ReadFile(filePath) if err != nil { return nil, nil, 0, err } file, err := parser.ParseFile(a.fset, filePath, src, parser.ParseComments) if err != nil { return nil, nil, 0, err } lines := strings.Count(string(src), "\n") + 1 relPath := a.relPath(filePath) var tests []TestFunc var imports []ImportInfo for _, imp := range file.Imports { path := strings.Trim(imp.Path.Value, "\"") imports = append(imports, ImportInfo{ ImportPath: path, IsStdlib: isStdlib(path), UsedInFiles: []string{relPath}, }) } for _, decl := range file.Decls { fn, ok := decl.(*ast.FuncDecl) if !ok { continue } name := fn.Name.Name if !strings.HasPrefix(name, "Test") && !strings.HasPrefix(name, "Benchmark") { continue } startLine := a.fset.Position(fn.Pos()).Line endLine := a.fset.Position(fn.End()).Line test := TestFunc{ Name: name, GoFile: relPath, GoMethod: name, GoLineNumber: startLine, GoLineCount: endLine - startLine + 1, } if fn.Doc != nil { test.Description = strings.TrimSpace(fn.Doc.Text()) } test.FeatureName = a.inferFeatureName(name) test.BestFeatureIdx = -1 if fn.Body != nil { test.Calls = a.extractCalls(fn.Body) } tests = append(tests, test) } return tests, imports, lines, nil } // buildDependencies creates module-level dependencies based on cross-package imports. func (a *Analyzer) buildDependencies(modules []Module) []Dependency { pkgToModule := make(map[string]string) for _, m := range modules { pkgToModule[m.GoPackage] = m.Name } var deps []Dependency for _, m := range modules { if m.Name != "server" && m.GoPackage != "server" { deps = append(deps, Dependency{ SourceModule: "server", TargetModule: m.Name, DependencyKind: "calls", }) } } return deps } // moduleNameFromDir converts a directory path to a module name. func (a *Analyzer) moduleNameFromDir(dir string) string { base := filepath.Base(dir) if base == "server" { return "server" } return base } // relPath returns a path relative to the analyzer's source directory. func (a *Analyzer) relPath(absPath string) string { rel, err := filepath.Rel(a.sourceDir, absPath) if err != nil { return absPath } return rel } // receiverTypeName extracts the type name from a method receiver. func (a *Analyzer) receiverTypeName(expr ast.Expr) string { switch t := expr.(type) { case *ast.StarExpr: return a.receiverTypeName(t.X) case *ast.Ident: return t.Name default: return "" } } // inferFeatureName attempts to derive a feature name from a test name. func (a *Analyzer) inferFeatureName(testName string) string { name := testName for _, prefix := range []string{"Test", "Benchmark"} { if strings.HasPrefix(name, prefix) { name = strings.TrimPrefix(name, prefix) break } } if name == "" { return "" } if idx := strings.Index(name, "_"); idx > 0 { name = name[:idx] + "." + name[idx+1:] } return name } // extractCalls walks an AST block statement and extracts all function/method calls. func (a *Analyzer) extractCalls(body *ast.BlockStmt) []CallInfo { seen := make(map[string]bool) var calls []CallInfo ast.Inspect(body, func(n ast.Node) bool { callExpr, ok := n.(*ast.CallExpr) if !ok { return true } var ci CallInfo switch fun := callExpr.Fun.(type) { case *ast.Ident: ci = CallInfo{FuncName: fun.Name} case *ast.SelectorExpr: ci = CallInfo{ RecvOrPkg: extractIdent(fun.X), MethodName: fun.Sel.Name, IsSelector: true, } default: return true } key := ci.callKey() if !seen[key] && !isFilteredCall(ci) { seen[key] = true calls = append(calls, ci) } return true }) return calls } // extractIdent extracts an identifier name from an expression (handles X in X.Y). func extractIdent(expr ast.Expr) string { switch e := expr.(type) { case *ast.Ident: return e.Name case *ast.SelectorExpr: return extractIdent(e.X) + "." + e.Sel.Name default: return "" } } // isFilteredCall returns true if a call should be excluded from feature matching. func isFilteredCall(c CallInfo) bool { if c.IsSelector { recv := c.RecvOrPkg // testing.T/B methods if recv == "t" || recv == "b" || recv == "tb" { return true } // stdlib packages if stdlibPkgs[recv] { return true } // NATS client libs if recv == "nats" || recv == "nuid" || recv == "nkeys" || recv == "jwt" { return true } return false } // Go builtins name := c.FuncName if builtinFuncs[name] { return true } // Test assertion helpers lower := strings.ToLower(name) if strings.HasPrefix(name, "require_") { return true } for _, prefix := range []string{"check", "verify", "assert", "expect"} { if strings.HasPrefix(lower, prefix) { return true } } return false } // featureRef identifies a feature within the analysis result. type featureRef struct { moduleIdx int featureIdx int goFile string goClass string } // resolveCallGraph matches test calls against known features across all modules. func resolveCallGraph(result *AnalysisResult) { // Build method index: go_method name → list of feature refs methodIndex := make(map[string][]featureRef) for mi, mod := range result.Modules { for fi, feat := range mod.Features { ref := featureRef{ moduleIdx: mi, featureIdx: fi, goFile: feat.GoFile, goClass: feat.GoClass, } methodIndex[feat.GoMethod] = append(methodIndex[feat.GoMethod], ref) } } // For each test, resolve calls to features for mi := range result.Modules { mod := &result.Modules[mi] for ti := range mod.Tests { test := &mod.Tests[ti] seen := make(map[int]bool) // feature indices already linked var linked []int testFileBase := sourceFileBase(test.GoFile) for _, call := range test.Calls { // Look up the method name name := call.MethodName if !call.IsSelector { name = call.FuncName } candidates := methodIndex[name] if len(candidates) == 0 { continue } // Ambiguity threshold: skip very common method names if len(candidates) > 10 { continue } // Filter to same module var sameModule []featureRef for _, ref := range candidates { if ref.moduleIdx == mi { sameModule = append(sameModule, ref) } } if len(sameModule) == 0 { continue } for _, ref := range sameModule { if !seen[ref.featureIdx] { seen[ref.featureIdx] = true linked = append(linked, ref.featureIdx) } } } test.LinkedFeatures = linked // Set BestFeatureIdx using priority: // (a) existing inferFeatureName match // (b) same-file-base match // (c) first remaining candidate if test.BestFeatureIdx < 0 && len(linked) > 0 { // Try same-file-base match first for _, fi := range linked { featFileBase := sourceFileBase(mod.Features[fi].GoFile) if featFileBase == testFileBase { test.BestFeatureIdx = fi break } } // Fall back to first candidate if test.BestFeatureIdx < 0 { test.BestFeatureIdx = linked[0] } } } } } // sourceFileBase strips _test.go suffix and path to get the base file name. func sourceFileBase(goFile string) string { base := filepath.Base(goFile) base = strings.TrimSuffix(base, "_test.go") base = strings.TrimSuffix(base, ".go") return base } var stdlibPkgs = map[string]bool{ "fmt": true, "time": true, "strings": true, "bytes": true, "errors": true, "os": true, "math": true, "sort": true, "reflect": true, "sync": true, "context": true, "io": true, "filepath": true, "strconv": true, "encoding": true, "json": true, "binary": true, "hex": true, "rand": true, "runtime": true, "atomic": true, "slices": true, "testing": true, "net": true, "bufio": true, "crypto": true, "log": true, "regexp": true, "unicode": true, "http": true, "url": true, } var builtinFuncs = map[string]bool{ "make": true, "append": true, "len": true, "cap": true, "close": true, "delete": true, "panic": true, "recover": true, "print": true, "println": true, "copy": true, "new": true, } // isStdlib checks if an import path is a Go standard library package. func isStdlib(importPath string) bool { firstSlash := strings.Index(importPath, "/") var first string if firstSlash < 0 { first = importPath } else { first = importPath[:firstSlash] } return !strings.Contains(first, ".") }