contrib/git-changes-action/detector/package/depgraph.go
package packagedetector
import (
"fmt"
"github.com/ethereum/go-ethereum/common"
"github.com/kendru/darwin/go/depgraph"
"github.com/vishalkuo/bimap"
"go/parser"
"go/token"
"golang.org/x/mod/modfile"
"os"
"path"
"path/filepath"
"strings"
)
// nolint: cyclop
func getPackageDependencyGraph(repoPath string) (moduleDeps map[string][]string, packagesPerModule map[string][]string, err error) {
moduleDeps = make(map[string][]string)
// parse the go.work file
goWorkPath := path.Join(repoPath, "go.work")
if !common.FileExist(goWorkPath) {
return nil, nil, fmt.Errorf("go.work file not found in %s", repoPath)
}
//nolint: gosec
workFile, err := os.ReadFile(goWorkPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to read go.work file: %w", err)
}
parsedWorkFile, err := modfile.ParseWork(goWorkPath, workFile, nil)
if err != nil {
return nil, nil, fmt.Errorf("failed to parse go.work file: %w", err)
}
// map of package->dependencies
var dependencies map[string]map[string]struct{}
// iterate through each module in the go.work file
// create a list of dependencies for each package
// and generate a list of packages per module
// nolint: gocognit
dependencies, packagesPerModule, err = makePackageDepMaps(repoPath, parsedWorkFile.Use)
if err != nil {
return nil, nil, fmt.Errorf("failed to create dependency maps: %w", err)
}
depGraph := depgraph.New()
for _, module := range parsedWorkFile.Use {
for _, relativePackageName := range packagesPerModule[module.Path] {
for relativePackageDependencyName := range dependencies[relativePackageName] {
err = depGraph.DependOn(relativePackageName, relativePackageDependencyName)
// Circular dependencies are fine as long as both packages are in the same module
if err != nil && !(strings.Contains(relativePackageDependencyName, module.Path) && strings.Contains(relativePackageName, module.Path)) {
return nil, nil, fmt.Errorf("failed to add dependency %s -> %s: %w", relativePackageName, relativePackageDependencyName, err)
}
}
}
}
for _, module := range parsedWorkFile.Use {
for _, relativePackageName := range packagesPerModule[module.Path] {
for dep := range depGraph.Dependencies(relativePackageName) {
moduleDeps[relativePackageName] = append(moduleDeps[relativePackageName], dep)
}
}
}
return moduleDeps, packagesPerModule, nil
}
func extractGoFileNames(pwd string, currentPackage string, goFiles map[string][]string) (err error) {
searchNext := make(map[string]string)
_, packageDir := path.Split(currentPackage)
searchNext[pwd] = packageDir
for len(searchNext) > 0 {
discovered := make(map[string]string)
for path, dirName := range searchNext {
// see https://stackoverflow.com/a/68559720
dirName := dirName
path := path
err := filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() && !(path == filePath) {
discovered[filePath] = info.Name()
return filepath.SkipDir
} else if strings.HasSuffix(info.Name(), ".go") {
goFiles["/"+dirName] = append(goFiles["/"+dirName], filePath)
}
return nil
})
if err != nil {
return fmt.Errorf("failed to walk path: %w", err)
}
}
searchNext = discovered
}
return nil
}
// nolint: gocognit, cyclop
func makePackageDepMaps(repoPath string, uses []*modfile.Use) (dependencies map[string]map[string]struct{}, packagesPerModule map[string][]string, err error) {
// map of packages -> dependencies
dependencies = make(map[string]map[string]struct{})
// bidirectional map of package->package name
// Maps relative to public names, used to filer out all external libraries/packages.
dependencyNames := bimap.NewBiMap[string, string]()
// map of module->packages
packagesPerModule = make(map[string][]string)
// map module->package->goFiles
// Maps each module to all packages and each package to its go files.
extractedGoFileNames := make(map[string]map[string][]string)
pwd, err := os.Getwd()
if err != nil {
return dependencies, packagesPerModule, fmt.Errorf("failed to read current directory: %w", err)
}
// iterate through each module in the go.work file
// 1. Extract all go files filepaths for each package.
// 2. Create a map where key is module, value is an array with all packages in the module
// 3. Map public name to relative name for each package (used to filter external library/package imports)
for _, module := range uses {
// nolint: gosec
modContents, err := os.ReadFile(filepath.Join(repoPath, module.Path, "go.mod"))
if err != nil {
return dependencies, packagesPerModule, fmt.Errorf("failed to read module file %s: %w", module.Path, err)
}
parsedModFile, err := modfile.Parse(module.Path, modContents, nil)
if err != nil {
return dependencies, packagesPerModule, fmt.Errorf("failed to parse module file %s: %w", module.Path, err)
}
extractedGoFileNames[module.Path] = make(map[string][]string)
err = extractGoFileNames(pwd+module.Path[1:], module.Path[1:], extractedGoFileNames[module.Path])
if err != nil {
return dependencies, packagesPerModule, fmt.Errorf("failed to extract go files for module %s: %w", module.Path, err)
}
for packageName := range extractedGoFileNames[module.Path] {
var relativePackageName string
if strings.HasSuffix(module.Path, packageName) {
relativePackageName = module.Path
} else {
relativePackageName = module.Path + packageName
}
var publicPackageName string
if strings.HasSuffix(parsedModFile.Module.Mod.Path, packageName) {
publicPackageName = parsedModFile.Module.Mod.Path
} else {
publicPackageName = parsedModFile.Module.Mod.Path + packageName
}
packagesPerModule[module.Path] = append(packagesPerModule[module.Path], relativePackageName)
dependencyNames.Insert(relativePackageName, publicPackageName)
}
}
// iterate through each module in the go.work file
// For every package in the module
// using the filepaths extracted on the previous loop, parse files and extract imports.
// Ignore any external library/package imports
for _, module := range uses {
for packageInModule, files := range extractedGoFileNames[module.Path] {
var relativePackageName string
if strings.HasSuffix(module.Path, packageInModule) {
relativePackageName = module.Path
} else {
relativePackageName = module.Path + packageInModule
}
dependencies[relativePackageName] = make(map[string]struct{})
for _, file := range files {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly)
if err != nil {
return dependencies, packagesPerModule, fmt.Errorf("failed to parse go file %s in package %s: %w", file, relativePackageName, err)
}
for _, s := range f.Imports {
// s.Path.Value contains double quotation marks that must be removed before indexing dependencyNames
renamedDep, hasDep := dependencyNames.GetInverse(s.Path.Value[1 : len(s.Path.Value)-1])
if hasDep && (relativePackageName != renamedDep) {
dependencies[relativePackageName][renamedDep] = struct{}{}
}
}
}
}
}
return dependencies, packagesPerModule, nil
}