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
439 lines
15 KiB
Go
439 lines
15 KiB
Go
package analysis
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"testing"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// buildClusteredGraph builds a graph with several well-separated
|
|
// dense clusters, one per directory ("package"). Each cluster is a
|
|
// ring of `perCluster` functions so it forms a cohesive community;
|
|
// clusters are linked by a single weak cross-package edge so the
|
|
// graph is connected but the community boundaries are clear.
|
|
//
|
|
// grow names the package directories that each get one extra
|
|
// function wired into their ring — the knob the tests use to make
|
|
// a controlled set of packages "change" between runs. A package not
|
|
// in `grow` is rebuilt byte-identically.
|
|
func buildClusteredGraph(t *testing.T, clusters, perCluster int, grow ...string) *graph.Graph {
|
|
t.Helper()
|
|
g := graph.New()
|
|
|
|
growSet := make(map[string]bool, len(grow))
|
|
for _, p := range grow {
|
|
growSet[p] = true
|
|
}
|
|
|
|
pkgDir := func(c int) string { return fmt.Sprintf("pkg/mod%d", c) }
|
|
nodeID := func(c, i int) string { return fmt.Sprintf("%s/f%d.go::Fn%d_%d", pkgDir(c), i, c, i) }
|
|
|
|
addFn := func(c, i int) {
|
|
g.AddNode(&graph.Node{
|
|
ID: nodeID(c, i),
|
|
Kind: graph.KindFunction,
|
|
Name: fmt.Sprintf("Fn%d_%d", c, i),
|
|
FilePath: fmt.Sprintf("%s/f%d.go", pkgDir(c), i),
|
|
Language: "go",
|
|
})
|
|
}
|
|
call := func(from, to string) {
|
|
g.AddEdge(&graph.Edge{From: from, To: to, Kind: graph.EdgeCalls})
|
|
}
|
|
|
|
for c := 0; c < clusters; c++ {
|
|
n := perCluster
|
|
if growSet[pkgDir(c)] {
|
|
n++
|
|
}
|
|
for i := 0; i < n; i++ {
|
|
addFn(c, i)
|
|
}
|
|
// Dense ring inside the cluster: every node calls the next
|
|
// and the one after, so the induced subgraph is cohesive.
|
|
for i := 0; i < n; i++ {
|
|
call(nodeID(c, i), nodeID(c, (i+1)%n))
|
|
call(nodeID(c, i), nodeID(c, (i+2)%n))
|
|
}
|
|
}
|
|
// One weak inter-cluster edge per adjacent pair keeps the graph
|
|
// connected without merging the communities.
|
|
for c := 0; c+1 < clusters; c++ {
|
|
call(nodeID(c, 0), nodeID(c+1, 0))
|
|
}
|
|
|
|
return g
|
|
}
|
|
|
|
// communitySignature maps every node to the *set* of nodes it
|
|
// shares a community with — a representation that is invariant to
|
|
// how communities are numbered. Two partitions with the same
|
|
// signature group the same nodes together even if the "community-N"
|
|
// ids differ.
|
|
func communitySignature(cr *CommunityResult) map[string]string {
|
|
byComm := make(map[string][]string)
|
|
for nid, cid := range cr.NodeToComm {
|
|
byComm[cid] = append(byComm[cid], nid)
|
|
}
|
|
sig := make(map[string]string, len(cr.NodeToComm))
|
|
for _, members := range byComm {
|
|
sort.Strings(members)
|
|
joined := fmt.Sprint(members)
|
|
for _, m := range members {
|
|
sig[m] = joined
|
|
}
|
|
}
|
|
return sig
|
|
}
|
|
|
|
func TestDetectCommunitiesLeidenIncremental(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
run func(t *testing.T)
|
|
}{
|
|
{
|
|
// (a) The first call has no cache: it does a full
|
|
// recompute and returns a populated cache to carry
|
|
// forward.
|
|
name: "first run computes and caches",
|
|
run: func(t *testing.T) {
|
|
g := buildClusteredGraph(t, 5, 6)
|
|
result, cache, stats := DetectCommunitiesLeidenIncremental(g, nil)
|
|
if result == nil || len(result.Communities) == 0 {
|
|
t.Fatal("first run produced no communities")
|
|
}
|
|
if stats.Incremental {
|
|
t.Error("first run should be a full recompute, not incremental")
|
|
}
|
|
if stats.FullRecomputeReason == "" {
|
|
t.Error("first run should carry a full-recompute reason")
|
|
}
|
|
if cache == nil || cache.part == nil || len(cache.nodeComm) == 0 {
|
|
t.Fatal("first run did not populate the cache")
|
|
}
|
|
if len(cache.pkgFingerprint) == 0 {
|
|
t.Error("first run cached no package fingerprints")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// (b) A re-run on the identical graph reuses the cache
|
|
// and re-partitions nothing.
|
|
name: "no change reuses cache and repartitions nothing",
|
|
run: func(t *testing.T) {
|
|
g := buildClusteredGraph(t, 5, 6)
|
|
_, cache, _ := DetectCommunitiesLeidenIncremental(g, nil)
|
|
|
|
// Same graph (rebuilt identically) → fingerprints
|
|
// match → incremental with an empty changed set.
|
|
g2 := buildClusteredGraph(t, 5, 6)
|
|
result, _, stats := DetectCommunitiesLeidenIncremental(g2, cache)
|
|
if !stats.Incremental {
|
|
t.Fatalf("no-change re-run fell back to full recompute: %s", stats.FullRecomputeReason)
|
|
}
|
|
if stats.ChangedPackages != 0 {
|
|
t.Errorf("no-change re-run saw %d changed packages, want 0", stats.ChangedPackages)
|
|
}
|
|
if stats.RepartitionedNodes != 0 {
|
|
t.Errorf("no-change re-run re-partitioned %d nodes, want 0", stats.RepartitionedNodes)
|
|
}
|
|
if result == nil || len(result.Communities) == 0 {
|
|
t.Fatal("no-change re-run produced no communities")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// (c) After exactly one package changed, only that
|
|
// package is re-partitioned and the unchanged packages
|
|
// keep their grouping.
|
|
name: "one changed package repartitions only that package",
|
|
run: func(t *testing.T) {
|
|
g := buildClusteredGraph(t, 5, 6)
|
|
baseResult, cache, _ := DetectCommunitiesLeidenIncremental(g, nil)
|
|
baseSig := communitySignature(baseResult)
|
|
|
|
// mod2 grows by one function; every other package
|
|
// is byte-identical.
|
|
g2 := buildClusteredGraph(t, 5, 6, "pkg/mod2")
|
|
result, _, stats := DetectCommunitiesLeidenIncremental(g2, cache)
|
|
|
|
if !stats.Incremental {
|
|
t.Fatalf("single-package change fell back to full recompute: %s", stats.FullRecomputeReason)
|
|
}
|
|
if stats.ChangedPackages != 1 {
|
|
t.Errorf("expected exactly 1 changed package, got %d", stats.ChangedPackages)
|
|
}
|
|
if stats.RepartitionedNodes == 0 {
|
|
t.Error("a changed package should re-partition some nodes")
|
|
}
|
|
// The repartitioned set is the changed package plus
|
|
// its boundary — it must not be the whole graph.
|
|
totalNodes := len(result.NodeToComm)
|
|
if stats.RepartitionedNodes >= totalNodes {
|
|
t.Errorf("re-partitioned %d of %d nodes — incremental path touched the whole graph",
|
|
stats.RepartitionedNodes, totalNodes)
|
|
}
|
|
|
|
// Unchanged packages keep their community grouping:
|
|
// any two nodes grouped together before are still
|
|
// grouped together, and vice versa.
|
|
newSig := communitySignature(result)
|
|
for nid, want := range baseSig {
|
|
// Skip nodes living in the changed package.
|
|
if packageKey(nodePath(g2, nid)) == "pkg/mod2" {
|
|
continue
|
|
}
|
|
got, ok := newSig[nid]
|
|
if !ok {
|
|
t.Errorf("node %s lost its community after incremental run", nid)
|
|
continue
|
|
}
|
|
if !sameUnchangedGrouping(want, got, "pkg/mod2", g2) {
|
|
t.Errorf("unchanged node %s changed community grouping:\n before: %s\n after: %s",
|
|
nid, want, got)
|
|
}
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// (d) When most packages change the incremental path
|
|
// bails out to a full recompute.
|
|
name: "large change triggers full recompute fallback",
|
|
run: func(t *testing.T) {
|
|
g := buildClusteredGraph(t, 6, 6)
|
|
_, cache, _ := DetectCommunitiesLeidenIncremental(g, nil)
|
|
|
|
// Four of the six packages grow — the changed
|
|
// fraction (0.66) exceeds the fallback ratio, so the
|
|
// incremental path recomputes the whole graph.
|
|
g2 := buildClusteredGraph(t, 6, 6,
|
|
"pkg/mod0", "pkg/mod1", "pkg/mod2", "pkg/mod3")
|
|
result, _, stats := DetectCommunitiesLeidenIncremental(g2, cache)
|
|
if stats.Incremental {
|
|
t.Errorf("a large change (%d/%d packages) should fall back to a full recompute",
|
|
stats.ChangedPackages, stats.TotalPackages)
|
|
}
|
|
if stats.FullRecomputeReason == "" {
|
|
t.Error("full recompute should name a reason")
|
|
}
|
|
if result == nil || len(result.Communities) == 0 {
|
|
t.Fatal("full-recompute fallback produced no communities")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// (e) The incremental result agrees with a from-scratch
|
|
// full recompute on the regions that did not change.
|
|
name: "incremental consistent with full recompute on unchanged regions",
|
|
run: func(t *testing.T) {
|
|
g := buildClusteredGraph(t, 5, 6)
|
|
_, cache, _ := DetectCommunitiesLeidenIncremental(g, nil)
|
|
|
|
g2 := buildClusteredGraph(t, 5, 6, "pkg/mod3")
|
|
incrResult, _, stats := DetectCommunitiesLeidenIncremental(g2, cache)
|
|
if !stats.Incremental {
|
|
t.Fatalf("expected incremental path, got full recompute: %s", stats.FullRecomputeReason)
|
|
}
|
|
fullResult := DetectCommunitiesLeiden(g2)
|
|
|
|
incrSig := communitySignature(incrResult)
|
|
fullSig := communitySignature(fullResult)
|
|
|
|
// Within each unchanged package the two partitions
|
|
// must agree on which members cluster together. We
|
|
// compare grouping restricted to a single package
|
|
// (the cross-package wiring is identical, the dense
|
|
// rings dominate, so each package is one community
|
|
// in both).
|
|
for c := 0; c < 5; c++ {
|
|
pkg := fmt.Sprintf("pkg/mod%d", c)
|
|
if pkg == "pkg/mod3" {
|
|
continue // the changed package
|
|
}
|
|
if !agreeWithinPackage(incrSig, fullSig, pkg, g2) {
|
|
t.Errorf("unchanged package %s clusters differently under incremental vs full recompute", pkg)
|
|
}
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// (f) The incremental path is deterministic: the same
|
|
// cache and graph yield the same partition every time.
|
|
name: "deterministic across repeated runs",
|
|
run: func(t *testing.T) {
|
|
g := buildClusteredGraph(t, 5, 6)
|
|
_, cache, _ := DetectCommunitiesLeidenIncremental(g, nil)
|
|
|
|
// Re-run the same one-package change five times from
|
|
// the same cache; the partition must be identical.
|
|
var first map[string]string
|
|
for run := 0; run < 5; run++ {
|
|
gN := buildClusteredGraph(t, 5, 6, "pkg/mod1")
|
|
result, _, stats := DetectCommunitiesLeidenIncremental(gN, cache)
|
|
if !stats.Incremental {
|
|
t.Fatalf("run %d unexpectedly fell back to full recompute", run)
|
|
}
|
|
sig := communitySignature(result)
|
|
if first == nil {
|
|
first = sig
|
|
continue
|
|
}
|
|
if len(sig) != len(first) {
|
|
t.Fatalf("run %d produced a different node count: %d vs %d", run, len(sig), len(first))
|
|
}
|
|
for nid, want := range first {
|
|
if sig[nid] != want {
|
|
t.Errorf("run %d non-deterministic for node %s:\n first: %s\n now: %s",
|
|
run, nid, want, sig[nid])
|
|
}
|
|
}
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// A nil graph-with-no-edges case: an empty graph must
|
|
// not panic and must report a full recompute.
|
|
name: "empty graph yields empty result without panic",
|
|
run: func(t *testing.T) {
|
|
g := graph.New()
|
|
result, cache, stats := DetectCommunitiesLeidenIncremental(g, nil)
|
|
if result == nil {
|
|
t.Fatal("nil result on empty graph")
|
|
}
|
|
if len(result.Communities) != 0 {
|
|
t.Errorf("empty graph produced %d communities", len(result.Communities))
|
|
}
|
|
if stats.Incremental {
|
|
t.Error("empty graph cannot be incremental")
|
|
}
|
|
if cache == nil {
|
|
t.Fatal("empty graph returned a nil cache")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// A stale cache from a structurally different graph
|
|
// must never yield a wrong partition — it falls back.
|
|
name: "stale cache from a different graph falls back",
|
|
run: func(t *testing.T) {
|
|
gA := buildClusteredGraph(t, 4, 5)
|
|
_, cacheA, _ := DetectCommunitiesLeidenIncremental(gA, nil)
|
|
|
|
// A graph with the same package count but renamed
|
|
// packages — no fingerprint overlap.
|
|
gB := graph.New()
|
|
for c := 0; c < 4; c++ {
|
|
dir := fmt.Sprintf("svc/other%d", c)
|
|
ids := make([]string, 5)
|
|
for i := 0; i < 5; i++ {
|
|
id := fmt.Sprintf("%s/g%d.go::G%d_%d", dir, i, c, i)
|
|
ids[i] = id
|
|
gB.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id, FilePath: fmt.Sprintf("%s/g%d.go", dir, i), Language: "go"})
|
|
}
|
|
for i := 0; i < 5; i++ {
|
|
gB.AddEdge(&graph.Edge{From: ids[i], To: ids[(i+1)%5], Kind: graph.EdgeCalls})
|
|
gB.AddEdge(&graph.Edge{From: ids[i], To: ids[(i+2)%5], Kind: graph.EdgeCalls})
|
|
}
|
|
}
|
|
result, _, stats := DetectCommunitiesLeidenIncremental(gB, cacheA)
|
|
if stats.Incremental {
|
|
t.Error("a cache from a disjoint graph must not drive an incremental run")
|
|
}
|
|
if result == nil || len(result.Communities) == 0 {
|
|
t.Fatal("fallback on a disjoint graph produced no communities")
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, tc.run)
|
|
}
|
|
}
|
|
|
|
// nodePath returns a node's file path from the graph, "" if absent.
|
|
func nodePath(g *graph.Graph, id string) string {
|
|
if n := g.GetNode(id); n != nil {
|
|
return n.FilePath
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// sameUnchangedGrouping reports whether two community-membership
|
|
// strings describe the same grouping once members living in the
|
|
// changed package are dropped from both. The incremental path can
|
|
// legitimately pull a changed-package node into an unchanged node's
|
|
// community (that is the point), so the unchanged node's *membership
|
|
// set restricted to unchanged nodes* is what must stay stable.
|
|
func sameUnchangedGrouping(before, after, changedPkg string, g *graph.Graph) bool {
|
|
return restrictToUnchanged(before, changedPkg, g) == restrictToUnchanged(after, changedPkg, g)
|
|
}
|
|
|
|
// restrictToUnchanged parses a "[id id id]" membership string and
|
|
// re-renders it keeping only nodes outside the changed package.
|
|
func restrictToUnchanged(membership, changedPkg string, g *graph.Graph) string {
|
|
trimmed := membership
|
|
if len(trimmed) >= 2 && trimmed[0] == '[' && trimmed[len(trimmed)-1] == ']' {
|
|
trimmed = trimmed[1 : len(trimmed)-1]
|
|
}
|
|
var kept []string
|
|
start := 0
|
|
for i := 0; i <= len(trimmed); i++ {
|
|
if i == len(trimmed) || trimmed[i] == ' ' {
|
|
if i > start {
|
|
tok := trimmed[start:i]
|
|
if packageKey(nodePath(g, tok)) != changedPkg {
|
|
kept = append(kept, tok)
|
|
}
|
|
}
|
|
start = i + 1
|
|
}
|
|
}
|
|
sort.Strings(kept)
|
|
return fmt.Sprint(kept)
|
|
}
|
|
|
|
// agreeWithinPackage reports whether two signatures cluster the
|
|
// members of one package the same way. Each package's dense ring
|
|
// makes it a single community in any reasonable partition, so we
|
|
// just check that all of the package's nodes share one signature
|
|
// within each map and that the within-package grouping matches.
|
|
func agreeWithinPackage(sigA, sigB map[string]string, pkg string, g *graph.Graph) bool {
|
|
var ids []string
|
|
for nid := range sigA {
|
|
if packageKey(nodePath(g, nid)) == pkg {
|
|
ids = append(ids, nid)
|
|
}
|
|
}
|
|
if len(ids) < 2 {
|
|
return true
|
|
}
|
|
// Build, for each map, the partition of `ids` into groups that
|
|
// share a signature; compare the two partitions.
|
|
groupsOf := func(sig map[string]string) map[string]string {
|
|
// Map each id to a canonical group key = sorted list of
|
|
// package peers it shares a community with.
|
|
out := make(map[string]string, len(ids))
|
|
for _, a := range ids {
|
|
var peers []string
|
|
for _, b := range ids {
|
|
if sig[a] == sig[b] {
|
|
peers = append(peers, b)
|
|
}
|
|
}
|
|
sort.Strings(peers)
|
|
out[a] = fmt.Sprint(peers)
|
|
}
|
|
return out
|
|
}
|
|
ga, gb := groupsOf(sigA), groupsOf(sigB)
|
|
for _, id := range ids {
|
|
if ga[id] != gb[id] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|