a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
331 lines
8.0 KiB
Go
331 lines
8.0 KiB
Go
package analysis
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// edgePair identifies a directed edge between two nodes.
|
|
type edgePair struct{ from, to string }
|
|
|
|
// Cycle represents a detected dependency cycle in the graph.
|
|
type Cycle struct {
|
|
Path []string `json:"path"` // ordered symbol IDs forming the cycle
|
|
Kind string `json:"kind"` // "import-cycle", "call-cycle", "cross-community-cycle"
|
|
Severity int `json:"severity"` // 3=import, 2=cross-community, 1=call
|
|
}
|
|
|
|
// DetectCycles finds all dependency cycles in the graph using Tarjan's SCC algorithm.
|
|
// If scope is non-empty, only nodes whose FilePath starts with scope are considered.
|
|
// Cycles are classified by edge type and community membership, then sorted by severity descending.
|
|
func DetectCycles(g graph.Store, communities *CommunityResult, scope string) []Cycle {
|
|
nodes := g.AllNodes()
|
|
|
|
// Build set of in-scope node IDs
|
|
inScope := make(map[string]bool, len(nodes))
|
|
for _, n := range nodes {
|
|
if n.Kind == graph.KindFile || n.Kind == graph.KindImport {
|
|
continue
|
|
}
|
|
if scope != "" && !strings.HasPrefix(n.FilePath, scope) {
|
|
continue
|
|
}
|
|
inScope[n.ID] = true
|
|
}
|
|
|
|
// Build adjacency list and track edge kinds between pairs.
|
|
//
|
|
// Edge collection streams only EdgeImports + EdgeCalls via
|
|
// EdgesByKind (two MATCH (...)-[e:Edge {kind: $kind}]->(...) on
|
|
// disk backends) instead of materialising every edge in the graph
|
|
// just to filter for two kinds -- ~500k edge rows over cgo dropped
|
|
// to the import-and-call subset (a few tens of thousands on the
|
|
// gortex workspace).
|
|
adj := make(map[string][]string)
|
|
edgeKinds := make(map[edgePair][]graph.EdgeKind)
|
|
|
|
collect := func(kind graph.EdgeKind) {
|
|
for e := range g.EdgesByKind(kind) {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
if !inScope[e.From] || !inScope[e.To] {
|
|
continue
|
|
}
|
|
pair := edgePair{e.From, e.To}
|
|
// Avoid duplicate adjacency entries
|
|
if _, exists := edgeKinds[pair]; !exists {
|
|
adj[e.From] = append(adj[e.From], e.To)
|
|
}
|
|
edgeKinds[pair] = append(edgeKinds[pair], kind)
|
|
}
|
|
}
|
|
collect(graph.EdgeImports)
|
|
collect(graph.EdgeCalls)
|
|
|
|
// Run Tarjan's SCC
|
|
sccs := tarjanSCC(inScope, adj)
|
|
|
|
// Convert SCCs to cycles
|
|
var cycles []Cycle
|
|
for _, scc := range sccs {
|
|
if len(scc) < 2 {
|
|
continue
|
|
}
|
|
|
|
// Order the cycle path by following edges within the SCC
|
|
path := orderCyclePath(scc, adj)
|
|
|
|
// Classify the cycle
|
|
kind, severity := classifyCycle(path, edgeKinds, communities)
|
|
|
|
cycles = append(cycles, Cycle{
|
|
Path: path,
|
|
Kind: kind,
|
|
Severity: severity,
|
|
})
|
|
}
|
|
|
|
// Sort by severity descending
|
|
sort.Slice(cycles, func(i, j int) bool {
|
|
return cycles[i].Severity > cycles[j].Severity
|
|
})
|
|
|
|
return cycles
|
|
}
|
|
|
|
// WouldCreateCycle checks if adding an edge from fromID to toID would create a cycle.
|
|
// It performs DFS from toID to see if fromID is reachable. If so, adding fromID→toID
|
|
// would close a cycle. Returns the cycle path from toID to fromID when found.
|
|
func WouldCreateCycle(g graph.Store, fromID, toID string) (bool, []string) {
|
|
edges := g.AllEdges()
|
|
|
|
// Build adjacency from calls and imports edges
|
|
adj := make(map[string][]string)
|
|
seen := make(map[string]map[string]bool)
|
|
for _, e := range edges {
|
|
if e.Kind != graph.EdgeImports && e.Kind != graph.EdgeCalls {
|
|
continue
|
|
}
|
|
if seen[e.From] == nil {
|
|
seen[e.From] = make(map[string]bool)
|
|
}
|
|
if !seen[e.From][e.To] {
|
|
seen[e.From][e.To] = true
|
|
adj[e.From] = append(adj[e.From], e.To)
|
|
}
|
|
}
|
|
|
|
// DFS from toID looking for fromID
|
|
visited := make(map[string]bool)
|
|
parent := make(map[string]string)
|
|
found := false
|
|
|
|
var dfs func(node string)
|
|
dfs = func(node string) {
|
|
if found {
|
|
return
|
|
}
|
|
if node == fromID {
|
|
found = true
|
|
return
|
|
}
|
|
visited[node] = true
|
|
for _, next := range adj[node] {
|
|
if found {
|
|
return
|
|
}
|
|
if !visited[next] {
|
|
parent[next] = node
|
|
dfs(next)
|
|
}
|
|
}
|
|
}
|
|
|
|
parent[toID] = ""
|
|
dfs(toID)
|
|
|
|
if !found {
|
|
return false, nil
|
|
}
|
|
|
|
// Reconstruct path from toID to fromID
|
|
var path []string
|
|
current := fromID
|
|
for current != toID {
|
|
path = append(path, current)
|
|
current = parent[current]
|
|
}
|
|
path = append(path, toID)
|
|
|
|
// Reverse to get toID → ... → fromID order
|
|
for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 {
|
|
path[i], path[j] = path[j], path[i]
|
|
}
|
|
|
|
return true, path
|
|
}
|
|
|
|
// tarjanSCC runs Tarjan's strongly connected components algorithm.
|
|
// Returns a list of SCCs, each being a slice of node IDs.
|
|
func tarjanSCC(nodeSet map[string]bool, adj map[string][]string) [][]string {
|
|
index := 0
|
|
stack := make([]string, 0)
|
|
onStack := make(map[string]bool)
|
|
indices := make(map[string]int)
|
|
lowlinks := make(map[string]int)
|
|
defined := make(map[string]bool)
|
|
var sccs [][]string
|
|
|
|
var strongConnect func(v string)
|
|
strongConnect = func(v string) {
|
|
indices[v] = index
|
|
lowlinks[v] = index
|
|
index++
|
|
defined[v] = true
|
|
stack = append(stack, v)
|
|
onStack[v] = true
|
|
|
|
for _, w := range adj[v] {
|
|
if !nodeSet[w] {
|
|
continue
|
|
}
|
|
if !defined[w] {
|
|
strongConnect(w)
|
|
if lowlinks[w] < lowlinks[v] {
|
|
lowlinks[v] = lowlinks[w]
|
|
}
|
|
} else if onStack[w] {
|
|
if indices[w] < lowlinks[v] {
|
|
lowlinks[v] = indices[w]
|
|
}
|
|
}
|
|
}
|
|
|
|
// If v is a root node, pop the SCC
|
|
if lowlinks[v] == indices[v] {
|
|
var scc []string
|
|
for {
|
|
w := stack[len(stack)-1]
|
|
stack = stack[:len(stack)-1]
|
|
onStack[w] = false
|
|
scc = append(scc, w)
|
|
if w == v {
|
|
break
|
|
}
|
|
}
|
|
sccs = append(sccs, scc)
|
|
}
|
|
}
|
|
|
|
for id := range nodeSet {
|
|
if !defined[id] {
|
|
strongConnect(id)
|
|
}
|
|
}
|
|
|
|
return sccs
|
|
}
|
|
|
|
// orderCyclePath finds a valid cycle within the SCC members by following edges.
|
|
// The returned path guarantees that for every consecutive pair (path[i], path[i+1])
|
|
// and the closing pair (path[last], path[0]), a directed edge exists in adj.
|
|
// Note: the path may not include all SCC members if no Hamiltonian cycle exists.
|
|
func orderCyclePath(scc []string, adj map[string][]string) []string {
|
|
sccSet := make(map[string]bool, len(scc))
|
|
for _, id := range scc {
|
|
sccSet[id] = true
|
|
}
|
|
|
|
// Find a cycle using DFS from the first node.
|
|
// In an SCC with size >= 2, there is always at least one cycle.
|
|
start := scc[0]
|
|
visited := make(map[string]bool)
|
|
parent := make(map[string]string)
|
|
|
|
var cyclePath []string
|
|
var dfs func(node string) bool
|
|
dfs = func(node string) bool {
|
|
visited[node] = true
|
|
for _, next := range adj[node] {
|
|
if !sccSet[next] {
|
|
continue
|
|
}
|
|
if next == start && node != start {
|
|
// Found a cycle back to start — reconstruct
|
|
cyclePath = []string{start}
|
|
cur := node
|
|
var stack []string
|
|
for cur != start {
|
|
stack = append(stack, cur)
|
|
cur = parent[cur]
|
|
}
|
|
// Reverse stack to get start -> ... -> node order
|
|
for i := len(stack) - 1; i >= 0; i-- {
|
|
cyclePath = append(cyclePath, stack[i])
|
|
}
|
|
return true
|
|
}
|
|
if !visited[next] {
|
|
parent[next] = node
|
|
if dfs(next) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
parent[start] = ""
|
|
dfs(start)
|
|
|
|
if len(cyclePath) >= 2 {
|
|
return cyclePath
|
|
}
|
|
|
|
// Fallback: return the SCC members in original order (should not happen for valid SCC >= 2)
|
|
return scc
|
|
}
|
|
|
|
// classifyCycle determines the kind and severity of a cycle based on edge types
|
|
// and community membership.
|
|
func classifyCycle(path []string, edgeKinds map[edgePair][]graph.EdgeKind, communities *CommunityResult) (string, int) {
|
|
// Check if any edge in the cycle is an import edge
|
|
hasImport := false
|
|
for i := 0; i < len(path); i++ {
|
|
from := path[i]
|
|
to := path[(i+1)%len(path)]
|
|
pair := edgePair{from, to}
|
|
for _, kind := range edgeKinds[pair] {
|
|
if kind == graph.EdgeImports {
|
|
hasImport = true
|
|
break
|
|
}
|
|
}
|
|
if hasImport {
|
|
break
|
|
}
|
|
}
|
|
|
|
if hasImport {
|
|
return "import-cycle", 3
|
|
}
|
|
|
|
// Check if cycle spans multiple communities
|
|
if communities != nil && communities.NodeToComm != nil {
|
|
communitySet := make(map[string]bool)
|
|
for _, id := range path {
|
|
if comm, ok := communities.NodeToComm[id]; ok {
|
|
communitySet[comm] = true
|
|
}
|
|
}
|
|
if len(communitySet) > 1 {
|
|
return "cross-community-cycle", 2
|
|
}
|
|
}
|
|
|
|
return "call-cycle", 1
|
|
}
|