diff --git a/tools/go-analyzer/analyzer.go b/tools/go-analyzer/analyzer.go new file mode 100644 index 0000000..bf26165 --- /dev/null +++ b/tools/go-analyzer/analyzer.go @@ -0,0 +1,344 @@ +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) + 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 +} + +// 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, ".") +}