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
203 lines
6.1 KiB
Go
203 lines
6.1 KiB
Go
package cochange
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// gitRepo builds a throwaway git repository in a temp dir and returns
|
|
// its path. Skips the test when git is unavailable.
|
|
func gitRepo(t *testing.T) string {
|
|
t.Helper()
|
|
if _, err := exec.LookPath("git"); err != nil {
|
|
t.Skip("git not available")
|
|
}
|
|
dir := t.TempDir()
|
|
run(t, dir, "git", "init", "-q")
|
|
run(t, dir, "git", "config", "user.email", "test@example.com")
|
|
run(t, dir, "git", "config", "user.name", "Tester")
|
|
return dir
|
|
}
|
|
|
|
func run(t *testing.T, dir, name string, args ...string) {
|
|
t.Helper()
|
|
cmd := exec.Command(name, args...)
|
|
cmd.Dir = dir
|
|
cmd.Env = append(cmd.Environ(),
|
|
"GIT_AUTHOR_NAME=Tester", "GIT_AUTHOR_EMAIL=test@example.com",
|
|
"GIT_COMMITTER_NAME=Tester", "GIT_COMMITTER_EMAIL=test@example.com")
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("%s %s: %v\n%s", name, strings.Join(args, " "), err, out)
|
|
}
|
|
}
|
|
|
|
// commit writes files (path->content) and commits them in dir.
|
|
func commit(t *testing.T, dir, msg string, files map[string]string) {
|
|
t.Helper()
|
|
for rel, content := range files {
|
|
abs := filepath.Join(dir, rel)
|
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
run(t, dir, "git", "add", rel)
|
|
}
|
|
run(t, dir, "git", "commit", "-q", "-m", msg)
|
|
}
|
|
|
|
func TestMine_DetectsCoChangingFiles(t *testing.T) {
|
|
dir := gitRepo(t)
|
|
// a.go and b.go always move together; c.go moves alone.
|
|
commit(t, dir, "c1", map[string]string{"a.go": "1", "b.go": "1"})
|
|
commit(t, dir, "c2", map[string]string{"a.go": "2", "b.go": "2"})
|
|
commit(t, dir, "c3", map[string]string{"a.go": "3", "b.go": "3"})
|
|
commit(t, dir, "c4", map[string]string{"c.go": "1"})
|
|
|
|
res := Mine(context.Background(), dir, Options{})
|
|
if res.CommitsScanned != 4 {
|
|
t.Errorf("CommitsScanned = %d, want 4", res.CommitsScanned)
|
|
}
|
|
if len(res.Pairs) != 1 {
|
|
t.Fatalf("expected exactly one pair, got %d: %+v", len(res.Pairs), res.Pairs)
|
|
}
|
|
p := res.Pairs[0]
|
|
if p.FileA != "a.go" || p.FileB != "b.go" {
|
|
t.Errorf("pair = %s/%s, want a.go/b.go", p.FileA, p.FileB)
|
|
}
|
|
if p.Count != 3 {
|
|
t.Errorf("Count = %d, want 3", p.Count)
|
|
}
|
|
// a.go and b.go each changed in exactly 3 commits and always
|
|
// together -> cosine score is 1.0.
|
|
if p.Score < 0.999 {
|
|
t.Errorf("Score = %f, want ~1.0", p.Score)
|
|
}
|
|
}
|
|
|
|
func TestMine_MinCountFiltersWeakPairs(t *testing.T) {
|
|
dir := gitRepo(t)
|
|
// a.go + b.go share only one commit -> below the default MinCount.
|
|
commit(t, dir, "c1", map[string]string{"a.go": "1", "b.go": "1"})
|
|
commit(t, dir, "c2", map[string]string{"a.go": "2"})
|
|
|
|
res := Mine(context.Background(), dir, Options{})
|
|
if len(res.Pairs) != 0 {
|
|
t.Errorf("expected no pairs below MinCount, got %+v", res.Pairs)
|
|
}
|
|
|
|
// MinCount=1 surfaces the single-commit pair.
|
|
res = Mine(context.Background(), dir, Options{MinCount: 1})
|
|
if len(res.Pairs) != 1 {
|
|
t.Errorf("expected one pair with MinCount=1, got %d", len(res.Pairs))
|
|
}
|
|
}
|
|
|
|
func TestMine_SkipsSweepingCommits(t *testing.T) {
|
|
dir := gitRepo(t)
|
|
files := []string{"a.go", "b.go", "c.go", "d.go", "e.go"}
|
|
sweep := map[string]string{}
|
|
for _, f := range files {
|
|
sweep[f] = "v1"
|
|
}
|
|
commit(t, dir, "sweep", sweep)
|
|
for _, f := range files {
|
|
sweep[f] = "v2"
|
|
}
|
|
commit(t, dir, "sweep2", sweep)
|
|
|
|
// MaxFilesPerCommit=3 drops both five-file commits from pair
|
|
// counting, leaving no co-change evidence.
|
|
res := Mine(context.Background(), dir, Options{MaxFilesPerCommit: 3, MinCount: 1})
|
|
if len(res.Pairs) != 0 {
|
|
t.Errorf("sweeping commits should not produce pairs, got %+v", res.Pairs)
|
|
}
|
|
}
|
|
|
|
func TestMine_NotAGitRepo(t *testing.T) {
|
|
res := Mine(context.Background(), t.TempDir(), Options{})
|
|
if len(res.Pairs) != 0 || res.CommitsScanned != 0 {
|
|
t.Errorf("non-git dir should yield empty result, got %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestEnrichGraph_AddsSymmetricEdges(t *testing.T) {
|
|
dir := gitRepo(t)
|
|
commit(t, dir, "c1", map[string]string{"a.go": "1", "b.go": "1"})
|
|
commit(t, dir, "c2", map[string]string{"a.go": "2", "b.go": "2"})
|
|
|
|
g := graph.New()
|
|
g.AddNode(&graph.Node{ID: "a.go", Kind: graph.KindFile, Name: "a.go", FilePath: "a.go"})
|
|
g.AddNode(&graph.Node{ID: "b.go", Kind: graph.KindFile, Name: "b.go", FilePath: "b.go"})
|
|
|
|
added, err := EnrichGraph(g, dir, "")
|
|
if err != nil {
|
|
t.Fatalf("EnrichGraph: %v", err)
|
|
}
|
|
if added != 2 {
|
|
t.Fatalf("added = %d, want 2 (one pair, both directions)", added)
|
|
}
|
|
|
|
out := g.GetOutEdges("a.go")
|
|
var found *graph.Edge
|
|
for _, e := range out {
|
|
if e.Kind == graph.EdgeCoChange {
|
|
found = e
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Fatal("no EdgeCoChange out-edge from a.go")
|
|
}
|
|
if found.To != "b.go" {
|
|
t.Errorf("edge target = %s, want b.go", found.To)
|
|
}
|
|
if found.Meta["count"].(int) != 2 {
|
|
t.Errorf("Meta count = %v, want 2", found.Meta["count"])
|
|
}
|
|
if found.Confidence <= 0 {
|
|
t.Errorf("Confidence = %f, want > 0", found.Confidence)
|
|
}
|
|
}
|
|
|
|
func TestEnrichGraph_RepoPrefixScoping(t *testing.T) {
|
|
dir := gitRepo(t)
|
|
commit(t, dir, "c1", map[string]string{"a.go": "1", "b.go": "1"})
|
|
commit(t, dir, "c2", map[string]string{"a.go": "2", "b.go": "2"})
|
|
|
|
g := graph.New()
|
|
// File nodes carry a multi-repo prefix; the git-relative paths
|
|
// must still match via prefix stripping.
|
|
g.AddNode(&graph.Node{ID: "myrepo/a.go", Kind: graph.KindFile, Name: "a.go", FilePath: "myrepo/a.go", RepoPrefix: "myrepo"})
|
|
g.AddNode(&graph.Node{ID: "myrepo/b.go", Kind: graph.KindFile, Name: "b.go", FilePath: "myrepo/b.go", RepoPrefix: "myrepo"})
|
|
|
|
added, err := EnrichGraph(g, dir, "myrepo")
|
|
if err != nil {
|
|
t.Fatalf("EnrichGraph: %v", err)
|
|
}
|
|
if added != 2 {
|
|
t.Fatalf("added = %d, want 2", added)
|
|
}
|
|
if len(g.GetOutEdges("myrepo/a.go")) == 0 {
|
|
t.Error("expected a co-change edge on the prefixed file node")
|
|
}
|
|
}
|
|
|
|
func TestEnrichGraph_NotAGitRepo(t *testing.T) {
|
|
g := graph.New()
|
|
g.AddNode(&graph.Node{ID: "a.go", Kind: graph.KindFile, FilePath: "a.go"})
|
|
added, err := EnrichGraph(g, t.TempDir(), "")
|
|
if err != nil {
|
|
t.Fatalf("EnrichGraph: %v", err)
|
|
}
|
|
if added != 0 {
|
|
t.Errorf("added = %d, want 0 for non-git dir", added)
|
|
}
|
|
}
|