synapsecns/sanguine

View on GitHub
contrib/git-changes-action/detector/module/depgraph.go

Summary

Maintainability
A
0 mins
Test Coverage
package moduledetector

import (
    "fmt"
    "github.com/ethereum/go-ethereum/common"
    "github.com/kendru/darwin/go/depgraph"
    "github.com/vishalkuo/bimap"
    "golang.org/x/mod/modfile"
    "os"
    "path"
    "path/filepath"
    "strings"
)

// getDependencyGraph returns a dependency graph of all the modules in the go.work file that refer to other modules in the go.work file
// returns a map of module (./my_module)->(./my_module_dependency1,./my_module_dependency2).
// nolint: cyclop
func getDependencyGraph(repoPath string) (moduleDeps 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, fmt.Errorf("go.work file not found in %s", repoPath)
    }

    //nolint: gosec
    workFile, err := os.ReadFile(goWorkPath)
    if err != nil {
        return nil, fmt.Errorf("failed to read go.work file: %w", err)
    }

    parsedWorkFile, err := modfile.ParseWork(goWorkPath, workFile, nil)
    if err != nil {
        return nil, fmt.Errorf("failed to parse go.work file: %w", err)
    }

    // map of module->dependencies + replaces
    var dependencies map[string]map[string]struct{}
    // bidirectional map of module->module
    // moduleRelativeName <-> modulePublicName
    var dependencyNames *bimap.BiMap[string, string]

    // iterate through each module in the go.work file
    // create a list of dependencies for each module
    // and module names
    dependencies, dependencyNames, err = makeDepMaps(repoPath, parsedWorkFile.Use)
    if err != nil {
        return nil, fmt.Errorf("failed to create dependency maps: %w", err)
    }

    depGraph := depgraph.New()
    // build the dependency graph
    for _, module := range parsedWorkFile.Use {
        for dep := range dependencies[module.Path] {
            // check if the full package name (e.g. github.com/myorg/myrepo/mymodule) is in the list of modules. If it is, add it as a dependency after renaming
            renamedDep, hasDep := dependencyNames.GetInverse(dep)
            if hasDep {
                err = depGraph.DependOn(module.Path, renamedDep)
                if err != nil {
                    return nil, fmt.Errorf("failed to add dependency %s -> %s: %w", module.Path, dep, err)
                }
            }

            if isRelativeDep(dep) {
                // if the dependency is relative, add it as a dependency
                err = depGraph.DependOn(module.Path, dep)
                if err != nil {
                    return nil, fmt.Errorf("failed to add dependency %s -> %s: %w", module.Path, dep, err)
                }
            }
        }
    }

    for _, module := range parsedWorkFile.Use {
        for dep := range depGraph.Dependencies(module.Path) {
            moduleDeps[module.Path] = append(moduleDeps[module.Path], dep)
        }
    }

    return moduleDeps, nil
}

// makeDepMaps builds a
// 1. module->dependency map
// 2. bidirectional map of module<->module (moduleRelativeName to modulePublicName).
func makeDepMaps(repoPath string, uses []*modfile.Use) (dependencies map[string]map[string]struct{}, dependencyNames *bimap.BiMap[string, string], err error) {
    // map of module->depndencies
    dependencies = make(map[string]map[string]struct{})

    // bidirectional map of module->module name
    // Maps relative to public names, used to filer out all external libraries/packages.
    dependencyNames = bimap.NewBiMap[string, string]()

    // iterate through each module in the go.work file
    // 1. Create a list of dependencies for each module
    // 2. Map public names to private names for each module
    //nolint: gosec
    for _, module := range uses {
        modContents, err := os.ReadFile(filepath.Join(repoPath, module.Path, "go.mod"))
        if err != nil {
            return dependencies, dependencyNames, 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, dependencyNames, fmt.Errorf("failed to parse module file %s: %w", module.Path, err)
        }

        dependencyNames.Insert(module.Path, parsedModFile.Module.Mod.Path)
        dependencies[module.Path] = make(map[string]struct{})

        // include all requires and replaces, as they are dependencies
        for _, require := range parsedModFile.Require {
            dependencies[module.Path][convertRelPath(repoPath, module.Path, require.Mod.Path)] = struct{}{}
        }
        for _, require := range parsedModFile.Replace {
            dependencies[module.Path][convertRelPath(repoPath, module.Path, require.New.Path)] = struct{}{}
        }
    }

    return dependencies, dependencyNames, nil
}

// isRelativeDep returns true if the dependency is relative to the module (starts with ./ or ../).
func isRelativeDep(path string) bool {
    return strings.HasPrefix(path, "./") || strings.HasPrefix(path, "../")
}

// convertRelPath converts a path relative to a module to a path relative to the repository root.
// it does nothing if the path does not start with ./ or ../.
func convertRelPath(repoPath string, modulePath, dependency string) string {
    if !isRelativeDep(dependency) {
        return dependency
    }

    // repo/./module => repo/module
    fullModulePath := filepath.Join(repoPath, modulePath)
    // repo/module/../dependency => repo/dependency
    fullDependencyPath := filepath.Join(fullModulePath, dependency)
    // repo/dependency => dependency
    trimmedPath := strings.TrimPrefix(fullDependencyPath, repoPath)
    if len(trimmedPath) == 0 {
        return "."
    }

    return fmt.Sprintf(".%s", trimmedPath)
}