chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/zzet/gortex/internal/config"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
"github.com/zzet/gortex/internal/indexer"
|
||||
"github.com/zzet/gortex/internal/parser"
|
||||
"github.com/zzet/gortex/internal/parser/languages"
|
||||
)
|
||||
|
||||
// buildTestEngine indexes the Gortex repo and returns an engine.
|
||||
func buildTestEngine(b *testing.B) *Engine {
|
||||
b.Helper()
|
||||
g := graph.New()
|
||||
reg := parser.NewRegistry()
|
||||
languages.RegisterAll(reg)
|
||||
idx := indexer.New(g, reg, config.IndexConfig{}, zap.NewNop())
|
||||
_, err := idx.Index("../..")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
return NewEngine(g)
|
||||
}
|
||||
|
||||
func BenchmarkSearchSymbols(b *testing.B) {
|
||||
eng := buildTestEngine(b)
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
eng.SearchSymbols("Server", 20)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGetDependencies(b *testing.B) {
|
||||
eng := buildTestEngine(b)
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
eng.GetDependencies("internal/mcp/server.go::NewServer", QueryOptions{Depth: 2, Limit: 50})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGetDependents(b *testing.B) {
|
||||
eng := buildTestEngine(b)
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
eng.GetDependents("internal/graph/graph.go::Graph", QueryOptions{Depth: 3, Limit: 50})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGetCallers(b *testing.B) {
|
||||
eng := buildTestEngine(b)
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
eng.GetCallers("internal/graph/graph.go::Graph.AddNode", QueryOptions{Depth: 2, Limit: 50})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGetCallChain(b *testing.B) {
|
||||
eng := buildTestEngine(b)
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
eng.GetCallChain("cmd/gortex/serve.go::runServe", QueryOptions{Depth: 4, Limit: 50})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFindUsages(b *testing.B) {
|
||||
eng := buildTestEngine(b)
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
eng.FindUsages("internal/graph/graph.go::Graph")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGetCluster(b *testing.B) {
|
||||
eng := buildTestEngine(b)
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
eng.GetCluster("internal/mcp/server.go::NewServer", QueryOptions{Depth: 2, Limit: 50})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkStats(b *testing.B) {
|
||||
eng := buildTestEngine(b)
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
eng.Stats()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package query_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
"github.com/zzet/gortex/internal/graph/store_sqlite"
|
||||
"github.com/zzet/gortex/internal/query"
|
||||
)
|
||||
|
||||
// bfsFixture seeds a store with a deep call chain plus a cycle, a
|
||||
// reference edge, and an unresolved dynamic-dispatch target, so the
|
||||
// traversal exercises depth, cycle termination, and boundary recording.
|
||||
func bfsFixture(s graph.Store) {
|
||||
for _, id := range []string{"main", "a", "b", "c", "d", "x", "h"} {
|
||||
s.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id, FilePath: "p.go", Language: "go", StartLine: 1, EndLine: 5})
|
||||
}
|
||||
add := func(from, to string, kind graph.EdgeKind, line int) {
|
||||
s.AddEdge(&graph.Edge{From: from, To: to, Kind: kind, FilePath: "p.go", Line: line, Confidence: 1, Origin: graph.OriginASTResolved})
|
||||
}
|
||||
add("main", "a", graph.EdgeCalls, 1)
|
||||
add("a", "b", graph.EdgeCalls, 2)
|
||||
add("b", "c", graph.EdgeCalls, 3)
|
||||
add("c", "d", graph.EdgeCalls, 4)
|
||||
add("a", "x", graph.EdgeCalls, 5)
|
||||
add("b", "a", graph.EdgeCalls, 6) // cycle
|
||||
add("h", "d", graph.EdgeReferences, 7)
|
||||
add("c", "unresolved::dyn", graph.EdgeCalls, 8) // dropped dynamic dispatch
|
||||
}
|
||||
|
||||
func nodeIDSet(sg *query.SubGraph) []string {
|
||||
ids := make([]string, 0, len(sg.Nodes))
|
||||
for _, n := range sg.Nodes {
|
||||
ids = append(ids, n.ID)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
func edgeKeySet(sg *query.SubGraph) []string {
|
||||
keys := make([]string, 0, len(sg.Edges))
|
||||
for _, e := range sg.Edges {
|
||||
keys = append(keys, e.From+"->"+e.To+":"+string(e.Kind))
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func eqStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// TestBFSBackendParity asserts the SQLite recursive-CTE BFS path and the
|
||||
// in-memory Go BFS path drive get_call_chain / get_callers to identical
|
||||
// reachable node sets, discovery-edge endpoint sets, and dispatch-boundary
|
||||
// flags — the cross-backend identical-results guarantee.
|
||||
func TestBFSBackendParity(t *testing.T) {
|
||||
mem := graph.New()
|
||||
bfsFixture(mem)
|
||||
|
||||
disk, err := store_sqlite.Open(filepath.Join(t.TempDir(), "store.sqlite"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
defer disk.Close()
|
||||
bfsFixture(disk)
|
||||
|
||||
memEng := query.NewEngine(mem)
|
||||
diskEng := query.NewEngine(disk)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
run func(e *query.Engine) *query.SubGraph
|
||||
}{
|
||||
{"call_chain/deep", func(e *query.Engine) *query.SubGraph {
|
||||
return e.GetCallChain("main", query.QueryOptions{Depth: 6, Limit: 50, Detail: "brief"})
|
||||
}},
|
||||
{"call_chain/shallow", func(e *query.Engine) *query.SubGraph {
|
||||
return e.GetCallChain("main", query.QueryOptions{Depth: 2, Limit: 50, Detail: "brief"})
|
||||
}},
|
||||
{"callers/deep", func(e *query.Engine) *query.SubGraph {
|
||||
return e.GetCallers("d", query.QueryOptions{Depth: 6, Limit: 50, Detail: "brief"})
|
||||
}},
|
||||
{"call_chain/limit", func(e *query.Engine) *query.SubGraph {
|
||||
return e.GetCallChain("main", query.QueryOptions{Depth: 6, Limit: 3, Detail: "brief"})
|
||||
}},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
memSG := c.run(memEng)
|
||||
diskSG := c.run(diskEng)
|
||||
|
||||
if mn, dn := nodeIDSet(memSG), nodeIDSet(diskSG); !eqStrings(mn, dn) {
|
||||
t.Fatalf("node sets differ:\n memory: %v\n sqlite: %v", mn, dn)
|
||||
}
|
||||
if me, de := edgeKeySet(memSG), edgeKeySet(diskSG); !eqStrings(me, de) {
|
||||
t.Fatalf("edge sets differ:\n memory: %v\n sqlite: %v", me, de)
|
||||
}
|
||||
if memSG.LowerBound != diskSG.LowerBound {
|
||||
t.Fatalf("LowerBound differs: memory=%v sqlite=%v", memSG.LowerBound, diskSG.LowerBound)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The deep call chain drops c -> unresolved::dyn, so the reachable set is
|
||||
// a floor on both backends.
|
||||
if sg := memEng.GetCallChain("main", query.QueryOptions{Depth: 6, Limit: 50, Detail: "brief"}); !sg.LowerBound {
|
||||
t.Fatalf("expected LowerBound=true for the dynamic-dispatch chain, got %+v", sg.Boundaries)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// An edge carrying Meta["call_sites"] expands into one usage row per site.
|
||||
func TestFindUsages_ExpandsCallSites(t *testing.T) {
|
||||
g := graph.New()
|
||||
target := "h.php::HandlerInterface.handle"
|
||||
caller := "app.php::run"
|
||||
g.AddNode(&graph.Node{ID: target, Kind: graph.KindMethod, Name: "handle", FilePath: "h.php", StartLine: 3, Language: "php"})
|
||||
g.AddNode(&graph.Node{ID: caller, Kind: graph.KindFunction, Name: "run", FilePath: "app.php", StartLine: 2, EndLine: 10, Language: "php"})
|
||||
|
||||
e := &graph.Edge{From: caller, To: target, Kind: graph.EdgeCalls, FilePath: "app.php", Line: 5, Origin: graph.OriginLSPResolved}
|
||||
graph.AppendCallSite(e, "app.php", 7)
|
||||
graph.AppendCallSite(e, "app.php", 9)
|
||||
g.AddEdge(e)
|
||||
|
||||
sg := NewEngine(g).FindUsages(target)
|
||||
|
||||
var lines []int
|
||||
for _, ue := range sg.Edges {
|
||||
if ue.From == caller && ue.To == target && ue.Kind == graph.EdgeCalls {
|
||||
lines = append(lines, ue.Line)
|
||||
}
|
||||
}
|
||||
assert.ElementsMatch(t, []int{5, 7, 9}, lines, "one usage row per call site")
|
||||
}
|
||||
|
||||
// A call_sites entry that coincides with a real per-line edge is not
|
||||
// double-counted; the real edge wins.
|
||||
func TestFindUsages_CallSitesDedupAgainstRealEdge(t *testing.T) {
|
||||
g := graph.New()
|
||||
target := "h.php::X.foo"
|
||||
caller := "app.php::run"
|
||||
g.AddNode(&graph.Node{ID: target, Kind: graph.KindMethod, Name: "foo", FilePath: "h.php", StartLine: 3, Language: "php"})
|
||||
g.AddNode(&graph.Node{ID: caller, Kind: graph.KindFunction, Name: "run", FilePath: "app.php", StartLine: 2, EndLine: 10, Language: "php"})
|
||||
|
||||
g.AddEdge(&graph.Edge{From: caller, To: target, Kind: graph.EdgeCalls, FilePath: "app.php", Line: 7, Origin: graph.OriginASTResolved})
|
||||
e5 := &graph.Edge{From: caller, To: target, Kind: graph.EdgeCalls, FilePath: "app.php", Line: 5, Origin: graph.OriginLSPResolved}
|
||||
graph.AppendCallSite(e5, "app.php", 7) // duplicates the real edge's site
|
||||
g.AddEdge(e5)
|
||||
|
||||
sg := NewEngine(g).FindUsages(target)
|
||||
|
||||
countAt7 := 0
|
||||
var lines []int
|
||||
for _, ue := range sg.Edges {
|
||||
if ue.To == target && ue.Kind == graph.EdgeCalls {
|
||||
lines = append(lines, ue.Line)
|
||||
if ue.Line == 7 {
|
||||
countAt7++
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, countAt7, "site 7 must not be double-counted")
|
||||
assert.ElementsMatch(t, []int{5, 7}, lines)
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
package query
|
||||
|
||||
import "github.com/zzet/gortex/internal/graph"
|
||||
|
||||
// HierarchyDirection picks which side of the class-hierarchy graph
|
||||
// ClassHierarchy traverses from the seed.
|
||||
type HierarchyDirection string
|
||||
|
||||
const (
|
||||
HierarchyUp HierarchyDirection = "up"
|
||||
HierarchyDown HierarchyDirection = "down"
|
||||
HierarchyBoth HierarchyDirection = "both"
|
||||
)
|
||||
|
||||
// typeHierarchyEdgeKinds is the set traversed when the visited node is
|
||||
// a type / interface. EdgeExtends covers single + multiple inheritance,
|
||||
// EdgeImplements bridges concrete type ↔ interface, EdgeComposes covers
|
||||
// Go struct embedding / Rust trait bounds / Python multiple inheritance
|
||||
// mixins.
|
||||
var typeHierarchyEdgeKinds = map[graph.EdgeKind]bool{
|
||||
graph.EdgeExtends: true,
|
||||
graph.EdgeImplements: true,
|
||||
graph.EdgeComposes: true,
|
||||
}
|
||||
|
||||
// methodHierarchyEdgeKinds is the set traversed when the visited node
|
||||
// is a method. EdgeOverrides is method-level: child method → parent /
|
||||
// interface method.
|
||||
var methodHierarchyEdgeKinds = map[graph.EdgeKind]bool{
|
||||
graph.EdgeOverrides: true,
|
||||
}
|
||||
|
||||
// ClassHierarchy returns the inheritance subgraph rooted at seedID.
|
||||
//
|
||||
// Walks the graph through EdgeExtends + EdgeImplements + EdgeComposes
|
||||
// for type nodes and EdgeOverrides for method nodes. Direction picks
|
||||
// the side(s) of the hierarchy:
|
||||
//
|
||||
// - HierarchyUp — outgoing edges (parents / interfaces a child
|
||||
// extends or implements; parent methods this method overrides).
|
||||
// - HierarchyDown — incoming edges (subclasses / implementers; methods
|
||||
// that override this one).
|
||||
// - HierarchyBoth — union of the two.
|
||||
//
|
||||
// When includeMethods is true and a type / interface node is reached,
|
||||
// its methods (in-edges of EdgeMemberOf whose From side is a function
|
||||
// or method node) are pulled into the result and override links from
|
||||
// each method are walked in the same direction(s).
|
||||
//
|
||||
// Workspace / project scope is enforced via opts.ScopeAllows on every
|
||||
// neighbour. opts.MinTier is applied as a post-pass over the collected
|
||||
// edges (consistent with the rest of the engine surface).
|
||||
//
|
||||
// Picks ClassHierarchyTraverser when the backend implements it: that
|
||||
// path runs the BFS as one variable-length traversal per direction
|
||||
// inside the engine, replacing the per-node GetNode + GetIn/OutEdges
|
||||
// loop the fallback runs. On a disk backend a deep walk over a wide
|
||||
// implementer set previously fired hundreds of round-trips per
|
||||
// call — the pushdown drops to one or two queries.
|
||||
func (e *Engine) ClassHierarchy(seedID string, direction HierarchyDirection, depth int, includeMethods bool, opts QueryOptions) *SubGraph {
|
||||
if direction == "" {
|
||||
direction = HierarchyBoth
|
||||
}
|
||||
if depth <= 0 {
|
||||
depth = 5
|
||||
}
|
||||
if depth > 64 {
|
||||
depth = 64
|
||||
}
|
||||
|
||||
seed := e.g.GetNode(seedID)
|
||||
if seed == nil {
|
||||
return &SubGraph{}
|
||||
}
|
||||
|
||||
if _, ok := e.g.(graph.ClassHierarchyTraverser); ok {
|
||||
return e.classHierarchyPushdown(seed, direction, depth, includeMethods, opts)
|
||||
}
|
||||
return e.classHierarchyWalk(seed, direction, depth, includeMethods, opts)
|
||||
}
|
||||
|
||||
// classHierarchyPushdown runs the BFS through the
|
||||
// ClassHierarchyTraverser capability. Each direction issues one or
|
||||
// two backend round-trips (the type-edge kinds, optionally chasing
|
||||
// methods through EdgeMemberOf) instead of the per-frontier per-hop
|
||||
// loop the fallback runs.
|
||||
func (e *Engine) classHierarchyPushdown(
|
||||
seed *graph.Node,
|
||||
direction HierarchyDirection,
|
||||
depth int,
|
||||
includeMethods bool,
|
||||
opts QueryOptions,
|
||||
) *SubGraph {
|
||||
tr := e.g.(graph.ClassHierarchyTraverser)
|
||||
walkUp := direction == HierarchyUp || direction == HierarchyBoth
|
||||
walkDown := direction == HierarchyDown || direction == HierarchyBoth
|
||||
|
||||
typeKinds := []graph.EdgeKind{graph.EdgeExtends, graph.EdgeImplements, graph.EdgeComposes}
|
||||
methodKinds := []graph.EdgeKind{graph.EdgeOverrides}
|
||||
|
||||
// Per-direction walks: type-hierarchy kinds rooted at seed if seed
|
||||
// is a type/interface; method-hierarchy kinds rooted at seed if
|
||||
// seed is a method/function. Methods reached via includeMethods
|
||||
// are added as separate roots in a follow-up pass.
|
||||
var rows []graph.ClassHierarchyRow
|
||||
seedIsType := seed.Kind == graph.KindType || seed.Kind == graph.KindInterface
|
||||
seedIsMethod := seed.Kind == graph.KindMethod || seed.Kind == graph.KindFunction
|
||||
if seedIsType {
|
||||
if walkUp {
|
||||
rows = append(rows, tr.ClassHierarchyTraverse(seed.ID, "up", typeKinds, depth)...)
|
||||
}
|
||||
if walkDown {
|
||||
rows = append(rows, tr.ClassHierarchyTraverse(seed.ID, "down", typeKinds, depth)...)
|
||||
}
|
||||
} else if seedIsMethod {
|
||||
if walkUp {
|
||||
rows = append(rows, tr.ClassHierarchyTraverse(seed.ID, "up", methodKinds, depth)...)
|
||||
}
|
||||
if walkDown {
|
||||
rows = append(rows, tr.ClassHierarchyTraverse(seed.ID, "down", methodKinds, depth)...)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect the node IDs visited so we can resolve them in one
|
||||
// batched fetch, instead of one GetNode per row.
|
||||
visited := map[string]bool{seed.ID: true}
|
||||
for _, r := range rows {
|
||||
for _, id := range r.Path {
|
||||
visited[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
// includeMethods folds in EdgeMemberOf hops from every visited
|
||||
// type node. The override walk on each method then runs as a
|
||||
// further pushdown call.
|
||||
memberLinks := []struct {
|
||||
from, to string
|
||||
kind graph.EdgeKind
|
||||
}{}
|
||||
if includeMethods {
|
||||
typeIDs := make([]string, 0, len(visited))
|
||||
for id := range visited {
|
||||
n := e.g.GetNode(id)
|
||||
if n == nil {
|
||||
continue
|
||||
}
|
||||
if n.Kind == graph.KindType || n.Kind == graph.KindInterface {
|
||||
typeIDs = append(typeIDs, id)
|
||||
}
|
||||
}
|
||||
if len(typeIDs) > 0 {
|
||||
memberIns := e.g.GetInEdgesByNodeIDs(typeIDs)
|
||||
methodRoots := []string{}
|
||||
for _, id := range typeIDs {
|
||||
for _, ed := range memberIns[id] {
|
||||
if ed == nil || ed.Kind != graph.EdgeMemberOf {
|
||||
continue
|
||||
}
|
||||
member := e.g.GetNode(ed.From)
|
||||
if member == nil {
|
||||
continue
|
||||
}
|
||||
if member.Kind != graph.KindMethod && member.Kind != graph.KindFunction {
|
||||
continue
|
||||
}
|
||||
memberLinks = append(memberLinks, struct {
|
||||
from, to string
|
||||
kind graph.EdgeKind
|
||||
}{from: member.ID, to: id, kind: graph.EdgeMemberOf})
|
||||
if !visited[member.ID] {
|
||||
visited[member.ID] = true
|
||||
methodRoots = append(methodRoots, member.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, mid := range methodRoots {
|
||||
if walkUp {
|
||||
subRows := tr.ClassHierarchyTraverse(mid, "up", methodKinds, depth)
|
||||
for _, sr := range subRows {
|
||||
for _, id := range sr.Path {
|
||||
visited[id] = true
|
||||
}
|
||||
}
|
||||
rows = append(rows, methodPathsWithRoot(mid, subRows)...)
|
||||
}
|
||||
if walkDown {
|
||||
subRows := tr.ClassHierarchyTraverse(mid, "down", methodKinds, depth)
|
||||
for _, sr := range subRows {
|
||||
for _, id := range sr.Path {
|
||||
visited[id] = true
|
||||
}
|
||||
}
|
||||
rows = append(rows, methodPathsWithRoot(mid, subRows)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve every visited node + collect the edge pointers in one
|
||||
// place. The capability doesn't carry edge pointers (on-disk
|
||||
// backend edges aren't first-class objects), so we re-resolve them via
|
||||
// GetOutEdgesByNodeIDs / GetInEdgesByNodeIDs once per direction.
|
||||
allIDs := make([]string, 0, len(visited))
|
||||
for id := range visited {
|
||||
allIDs = append(allIDs, id)
|
||||
}
|
||||
nodeMap := e.g.GetNodesByIDs(allIDs)
|
||||
if nodeMap[seed.ID] == nil {
|
||||
nodeMap[seed.ID] = seed
|
||||
}
|
||||
|
||||
resultNodes := make([]*graph.Node, 0, len(allIDs))
|
||||
for _, id := range allIDs {
|
||||
n := nodeMap[id]
|
||||
if n == nil {
|
||||
continue
|
||||
}
|
||||
if opts.hasScopeFilter() && id != seed.ID && !opts.ScopeAllows(n) {
|
||||
continue
|
||||
}
|
||||
resultNodes = append(resultNodes, n)
|
||||
}
|
||||
|
||||
// Reconstruct edges: each row's Path[i] → Path[i+1] (for i>=0)
|
||||
// carries an edge of EdgeKinds[i]. The seed's first hop is from
|
||||
// seed → Path[0]. The direction the walk came from determines
|
||||
// whether the edge points seed→neighbour or neighbour→seed.
|
||||
resultEdges := make([]*graph.Edge, 0)
|
||||
seenEdge := make(map[string]bool)
|
||||
addEdge := func(from, to string, kind graph.EdgeKind) {
|
||||
// Find the actual *Edge so the downstream FilterByMinTier
|
||||
// still has the origin / tier columns to read.
|
||||
var found *graph.Edge
|
||||
for _, ed := range e.g.GetOutEdges(from) {
|
||||
if ed == nil {
|
||||
continue
|
||||
}
|
||||
if ed.To == to && ed.Kind == kind {
|
||||
found = ed
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
// Direction-flipped lookup — happens when "down" walks
|
||||
// hand back paths whose hops are in-edges of the seed.
|
||||
for _, ed := range e.g.GetInEdges(from) {
|
||||
if ed == nil {
|
||||
continue
|
||||
}
|
||||
if ed.From == to && ed.Kind == kind {
|
||||
found = ed
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
return
|
||||
}
|
||||
k := found.From + "→" + found.To + "::" + string(found.Kind) + ":" + edgeMetaTag(found)
|
||||
if seenEdge[k] {
|
||||
return
|
||||
}
|
||||
seenEdge[k] = true
|
||||
resultEdges = append(resultEdges, found)
|
||||
}
|
||||
for _, r := range rows {
|
||||
prev := seed.ID
|
||||
for i, nb := range r.Path {
|
||||
if i >= len(r.EdgeKinds) {
|
||||
break
|
||||
}
|
||||
addEdge(prev, nb, r.EdgeKinds[i])
|
||||
prev = nb
|
||||
}
|
||||
}
|
||||
for _, link := range memberLinks {
|
||||
addEdge(link.from, link.to, link.kind)
|
||||
}
|
||||
|
||||
// Workspace-scope post-filter for edges (any edge whose endpoints
|
||||
// were dropped from resultNodes is also dropped).
|
||||
if opts.hasScopeFilter() {
|
||||
nodeSet := make(map[string]bool, len(resultNodes))
|
||||
for _, n := range resultNodes {
|
||||
nodeSet[n.ID] = true
|
||||
}
|
||||
filtered := resultEdges[:0]
|
||||
for _, ed := range resultEdges {
|
||||
if !nodeSet[ed.From] || !nodeSet[ed.To] {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, ed)
|
||||
}
|
||||
resultEdges = filtered
|
||||
}
|
||||
|
||||
sg := &SubGraph{
|
||||
Nodes: resultNodes,
|
||||
Edges: resultEdges,
|
||||
TotalNodes: len(resultNodes),
|
||||
TotalEdges: len(resultEdges),
|
||||
}
|
||||
if opts.MinTier != "" {
|
||||
sg.FilterByMinTier(opts.MinTier)
|
||||
}
|
||||
return sg
|
||||
}
|
||||
|
||||
// methodPathsWithRoot rebases the traversal rows so the seed prefix
|
||||
// in their paths reflects the method root they came from rather than
|
||||
// the outer ClassHierarchy seed. Returned rows are otherwise
|
||||
// unchanged.
|
||||
func methodPathsWithRoot(root string, rows []graph.ClassHierarchyRow) []graph.ClassHierarchyRow {
|
||||
out := make([]graph.ClassHierarchyRow, len(rows))
|
||||
for i, r := range rows {
|
||||
newPath := append([]string{root}, r.Path...)
|
||||
newKinds := append([]graph.EdgeKind{}, r.EdgeKinds...)
|
||||
// The seed→Path[0] hop is encoded by EdgeMemberOf in the outer
|
||||
// addEdge pass, so we keep the EdgeKinds slice aligned with
|
||||
// the slice the caller iterates ([0]=Path[0]→Path[1]).
|
||||
out[i] = graph.ClassHierarchyRow{Path: newPath[1:], EdgeKinds: newKinds}
|
||||
_ = newPath
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// classHierarchyWalk is the in-memory BFS path. Kept verbatim so the
|
||||
// in-memory backend has the same shape it had before the pushdown
|
||||
// landed.
|
||||
func (e *Engine) classHierarchyWalk(
|
||||
seed *graph.Node,
|
||||
direction HierarchyDirection,
|
||||
depth int,
|
||||
includeMethods bool,
|
||||
opts QueryOptions,
|
||||
) *SubGraph {
|
||||
walkUp := direction == HierarchyUp || direction == HierarchyBoth
|
||||
walkDown := direction == HierarchyDown || direction == HierarchyBoth
|
||||
|
||||
visitedNodes := make(map[string]bool)
|
||||
visitedEdges := make(map[string]bool)
|
||||
var resultNodes []*graph.Node
|
||||
var resultEdges []*graph.Edge
|
||||
|
||||
addNode := func(n *graph.Node) {
|
||||
if n == nil || visitedNodes[n.ID] {
|
||||
return
|
||||
}
|
||||
visitedNodes[n.ID] = true
|
||||
resultNodes = append(resultNodes, n)
|
||||
}
|
||||
|
||||
edgeKey := func(ed *graph.Edge) string {
|
||||
return ed.From + "→" + ed.To + "::" + string(ed.Kind) + ":" + edgeMetaTag(ed)
|
||||
}
|
||||
addEdge := func(ed *graph.Edge) {
|
||||
if ed == nil {
|
||||
return
|
||||
}
|
||||
k := edgeKey(ed)
|
||||
if visitedEdges[k] {
|
||||
return
|
||||
}
|
||||
visitedEdges[k] = true
|
||||
resultEdges = append(resultEdges, ed)
|
||||
}
|
||||
|
||||
addNode(seed)
|
||||
|
||||
type queued struct {
|
||||
id string
|
||||
depth int
|
||||
}
|
||||
queue := []queued{{id: seed.ID, depth: 0}}
|
||||
|
||||
for len(queue) > 0 {
|
||||
cur := queue[0]
|
||||
queue = queue[1:]
|
||||
if cur.depth >= depth {
|
||||
continue
|
||||
}
|
||||
|
||||
curNode := e.g.GetNode(cur.id)
|
||||
if curNode == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
isType := curNode.Kind == graph.KindType || curNode.Kind == graph.KindInterface
|
||||
isMethod := curNode.Kind == graph.KindMethod || curNode.Kind == graph.KindFunction
|
||||
|
||||
if includeMethods && isType {
|
||||
for _, mEdge := range e.g.GetInEdges(cur.id) {
|
||||
if mEdge.Kind != graph.EdgeMemberOf {
|
||||
continue
|
||||
}
|
||||
member := e.g.GetNode(mEdge.From)
|
||||
if member == nil {
|
||||
continue
|
||||
}
|
||||
if member.Kind != graph.KindMethod && member.Kind != graph.KindFunction {
|
||||
continue
|
||||
}
|
||||
if opts.hasScopeFilter() && !opts.ScopeAllows(member) {
|
||||
continue
|
||||
}
|
||||
addNode(member)
|
||||
addEdge(mEdge)
|
||||
queue = append(queue, queued{id: member.ID, depth: cur.depth})
|
||||
}
|
||||
}
|
||||
|
||||
var kindSet map[graph.EdgeKind]bool
|
||||
switch {
|
||||
case isType:
|
||||
kindSet = typeHierarchyEdgeKinds
|
||||
case isMethod:
|
||||
kindSet = methodHierarchyEdgeKinds
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
if walkUp {
|
||||
for _, ed := range e.g.GetOutEdges(cur.id) {
|
||||
if !kindSet[ed.Kind] {
|
||||
continue
|
||||
}
|
||||
neighbor := e.g.GetNode(ed.To)
|
||||
if neighbor == nil {
|
||||
continue
|
||||
}
|
||||
if opts.hasScopeFilter() && !opts.ScopeAllows(neighbor) {
|
||||
continue
|
||||
}
|
||||
addEdge(ed)
|
||||
if !visitedNodes[neighbor.ID] {
|
||||
addNode(neighbor)
|
||||
queue = append(queue, queued{id: neighbor.ID, depth: cur.depth + 1})
|
||||
}
|
||||
}
|
||||
}
|
||||
if walkDown {
|
||||
for _, ed := range e.g.GetInEdges(cur.id) {
|
||||
if !kindSet[ed.Kind] {
|
||||
continue
|
||||
}
|
||||
neighbor := e.g.GetNode(ed.From)
|
||||
if neighbor == nil {
|
||||
continue
|
||||
}
|
||||
if opts.hasScopeFilter() && !opts.ScopeAllows(neighbor) {
|
||||
continue
|
||||
}
|
||||
addEdge(ed)
|
||||
if !visitedNodes[neighbor.ID] {
|
||||
addNode(neighbor)
|
||||
queue = append(queue, queued{id: neighbor.ID, depth: cur.depth + 1})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sg := &SubGraph{
|
||||
Nodes: resultNodes,
|
||||
Edges: resultEdges,
|
||||
TotalNodes: len(resultNodes),
|
||||
TotalEdges: len(resultEdges),
|
||||
}
|
||||
if opts.MinTier != "" {
|
||||
sg.FilterByMinTier(opts.MinTier)
|
||||
}
|
||||
return sg
|
||||
}
|
||||
|
||||
// edgeMetaTag is a small disambiguator for edges that share From / To /
|
||||
// Kind but carry distinct metadata (e.g. multiple EdgeOverrides between
|
||||
// the same method pair via different language sources). Falls back to
|
||||
// the edge file:line when no semantic_source is set.
|
||||
func edgeMetaTag(ed *graph.Edge) string {
|
||||
if ed.Meta != nil {
|
||||
if src, ok := ed.Meta["semantic_source"].(string); ok && src != "" {
|
||||
return src
|
||||
}
|
||||
}
|
||||
if ed.FilePath != "" {
|
||||
return ed.FilePath
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// buildHierarchyGraph models a small OO subtree:
|
||||
//
|
||||
// interface Animal
|
||||
// ↑ implements (in-edges of Animal)
|
||||
// type Dog ← composes Tail
|
||||
// ↑ extends (in-edges of Dog)
|
||||
// type Puppy ← extends
|
||||
// type ServiceDog ← extends
|
||||
//
|
||||
// method Animal.Speak — interface method
|
||||
// method Dog.Speak — overrides Animal.Speak
|
||||
// method Puppy.Speak — overrides Dog.Speak
|
||||
// method ServiceDog.Speak — overrides Dog.Speak
|
||||
//
|
||||
// Plus an unrelated method Tail.Wag attached to the composed Tail type
|
||||
// so include_methods doesn't accidentally pull it in via the composition
|
||||
// edge alone.
|
||||
func buildHierarchyGraph() *graph.Graph {
|
||||
g := graph.New()
|
||||
|
||||
nodes := []*graph.Node{
|
||||
{ID: "animal.go::Animal", Kind: graph.KindInterface, Name: "Animal", FilePath: "animal.go", Language: "go"},
|
||||
{ID: "animal.go::Animal.Speak", Kind: graph.KindMethod, Name: "Animal.Speak", FilePath: "animal.go", Language: "go"},
|
||||
|
||||
{ID: "dog.go::Dog", Kind: graph.KindType, Name: "Dog", FilePath: "dog.go", Language: "go"},
|
||||
{ID: "dog.go::Dog.Speak", Kind: graph.KindMethod, Name: "Dog.Speak", FilePath: "dog.go", Language: "go"},
|
||||
|
||||
{ID: "tail.go::Tail", Kind: graph.KindType, Name: "Tail", FilePath: "tail.go", Language: "go"},
|
||||
{ID: "tail.go::Tail.Wag", Kind: graph.KindMethod, Name: "Tail.Wag", FilePath: "tail.go", Language: "go"},
|
||||
|
||||
{ID: "puppy.go::Puppy", Kind: graph.KindType, Name: "Puppy", FilePath: "puppy.go", Language: "go"},
|
||||
{ID: "puppy.go::Puppy.Speak", Kind: graph.KindMethod, Name: "Puppy.Speak", FilePath: "puppy.go", Language: "go"},
|
||||
|
||||
{ID: "service.go::ServiceDog", Kind: graph.KindType, Name: "ServiceDog", FilePath: "service.go", Language: "go"},
|
||||
{ID: "service.go::ServiceDog.Speak", Kind: graph.KindMethod, Name: "ServiceDog.Speak", FilePath: "service.go", Language: "go"},
|
||||
}
|
||||
for _, n := range nodes {
|
||||
g.AddNode(n)
|
||||
}
|
||||
|
||||
edges := []*graph.Edge{
|
||||
// Type hierarchy.
|
||||
{From: "dog.go::Dog", To: "animal.go::Animal", Kind: graph.EdgeImplements, FilePath: "dog.go"},
|
||||
{From: "dog.go::Dog", To: "tail.go::Tail", Kind: graph.EdgeComposes, FilePath: "dog.go"},
|
||||
{From: "puppy.go::Puppy", To: "dog.go::Dog", Kind: graph.EdgeExtends, FilePath: "puppy.go"},
|
||||
{From: "service.go::ServiceDog", To: "dog.go::Dog", Kind: graph.EdgeExtends, FilePath: "service.go"},
|
||||
|
||||
// Method membership.
|
||||
{From: "animal.go::Animal.Speak", To: "animal.go::Animal", Kind: graph.EdgeMemberOf, FilePath: "animal.go"},
|
||||
{From: "dog.go::Dog.Speak", To: "dog.go::Dog", Kind: graph.EdgeMemberOf, FilePath: "dog.go"},
|
||||
{From: "tail.go::Tail.Wag", To: "tail.go::Tail", Kind: graph.EdgeMemberOf, FilePath: "tail.go"},
|
||||
{From: "puppy.go::Puppy.Speak", To: "puppy.go::Puppy", Kind: graph.EdgeMemberOf, FilePath: "puppy.go"},
|
||||
{From: "service.go::ServiceDog.Speak", To: "service.go::ServiceDog", Kind: graph.EdgeMemberOf, FilePath: "service.go"},
|
||||
|
||||
// Method overrides.
|
||||
{From: "dog.go::Dog.Speak", To: "animal.go::Animal.Speak", Kind: graph.EdgeOverrides, FilePath: "dog.go"},
|
||||
{From: "puppy.go::Puppy.Speak", To: "dog.go::Dog.Speak", Kind: graph.EdgeOverrides, FilePath: "puppy.go"},
|
||||
{From: "service.go::ServiceDog.Speak", To: "dog.go::Dog.Speak", Kind: graph.EdgeOverrides, FilePath: "service.go"},
|
||||
}
|
||||
for _, e := range edges {
|
||||
g.AddEdge(e)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func sortedNodeIDs(sg *SubGraph) []string {
|
||||
ids := make([]string, 0, len(sg.Nodes))
|
||||
for _, n := range sg.Nodes {
|
||||
ids = append(ids, n.ID)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestClassHierarchy_TypeUp(t *testing.T) {
|
||||
e := NewEngine(buildHierarchyGraph())
|
||||
|
||||
// From a leaf type, walking "up" should reach its parent (Dog),
|
||||
// the interface Dog implements (Animal), and the type Dog composes (Tail).
|
||||
sg := e.ClassHierarchy("puppy.go::Puppy", HierarchyUp, 5, false, QueryOptions{})
|
||||
|
||||
ids := sortedNodeIDs(sg)
|
||||
assert.Contains(t, ids, "puppy.go::Puppy")
|
||||
assert.Contains(t, ids, "dog.go::Dog")
|
||||
assert.Contains(t, ids, "animal.go::Animal")
|
||||
assert.Contains(t, ids, "tail.go::Tail")
|
||||
// Sibling ServiceDog should not appear in an "up"-only walk from Puppy.
|
||||
assert.NotContains(t, ids, "service.go::ServiceDog")
|
||||
}
|
||||
|
||||
func TestClassHierarchy_TypeDown(t *testing.T) {
|
||||
e := NewEngine(buildHierarchyGraph())
|
||||
|
||||
// From the root interface Animal, "down" should reach implementers
|
||||
// and their subclasses transitively.
|
||||
sg := e.ClassHierarchy("animal.go::Animal", HierarchyDown, 5, false, QueryOptions{})
|
||||
|
||||
ids := sortedNodeIDs(sg)
|
||||
assert.Contains(t, ids, "animal.go::Animal")
|
||||
assert.Contains(t, ids, "dog.go::Dog")
|
||||
assert.Contains(t, ids, "puppy.go::Puppy")
|
||||
assert.Contains(t, ids, "service.go::ServiceDog")
|
||||
// Tail is a composition-up from Dog, not a child — must not appear.
|
||||
assert.NotContains(t, ids, "tail.go::Tail")
|
||||
}
|
||||
|
||||
func TestClassHierarchy_TypeBoth(t *testing.T) {
|
||||
e := NewEngine(buildHierarchyGraph())
|
||||
|
||||
sg := e.ClassHierarchy("dog.go::Dog", HierarchyBoth, 5, false, QueryOptions{})
|
||||
ids := sortedNodeIDs(sg)
|
||||
|
||||
// Both directions from Dog: parents (Animal, Tail) and children (Puppy, ServiceDog).
|
||||
assert.Contains(t, ids, "animal.go::Animal")
|
||||
assert.Contains(t, ids, "tail.go::Tail")
|
||||
assert.Contains(t, ids, "puppy.go::Puppy")
|
||||
assert.Contains(t, ids, "service.go::ServiceDog")
|
||||
}
|
||||
|
||||
func TestClassHierarchy_DepthLimit(t *testing.T) {
|
||||
e := NewEngine(buildHierarchyGraph())
|
||||
|
||||
// Depth=1 from Animal should reach direct implementers but not
|
||||
// their grandchildren.
|
||||
sg := e.ClassHierarchy("animal.go::Animal", HierarchyDown, 1, false, QueryOptions{})
|
||||
ids := sortedNodeIDs(sg)
|
||||
assert.Contains(t, ids, "dog.go::Dog")
|
||||
assert.NotContains(t, ids, "puppy.go::Puppy")
|
||||
assert.NotContains(t, ids, "service.go::ServiceDog")
|
||||
}
|
||||
|
||||
func TestClassHierarchy_MethodSeed(t *testing.T) {
|
||||
e := NewEngine(buildHierarchyGraph())
|
||||
|
||||
// Dog.Speak — up should reach Animal.Speak, down should reach
|
||||
// Puppy.Speak and ServiceDog.Speak.
|
||||
sg := e.ClassHierarchy("dog.go::Dog.Speak", HierarchyBoth, 5, false, QueryOptions{})
|
||||
ids := sortedNodeIDs(sg)
|
||||
assert.Contains(t, ids, "animal.go::Animal.Speak")
|
||||
assert.Contains(t, ids, "puppy.go::Puppy.Speak")
|
||||
assert.Contains(t, ids, "service.go::ServiceDog.Speak")
|
||||
}
|
||||
|
||||
func TestClassHierarchy_IncludeMethods(t *testing.T) {
|
||||
e := NewEngine(buildHierarchyGraph())
|
||||
|
||||
// From Dog with include_methods, we should pull in Dog.Speak (member),
|
||||
// then walk EdgeOverrides up (to Animal.Speak) and down (to Puppy.Speak,
|
||||
// ServiceDog.Speak). Tail.Wag should also appear because Tail is in
|
||||
// the type subgraph and we surface its members. No override chain
|
||||
// rooted at Tail.Wag exists, so it stays as a leaf.
|
||||
sg := e.ClassHierarchy("dog.go::Dog", HierarchyBoth, 5, true, QueryOptions{})
|
||||
ids := sortedNodeIDs(sg)
|
||||
|
||||
assert.Contains(t, ids, "dog.go::Dog.Speak")
|
||||
assert.Contains(t, ids, "animal.go::Animal.Speak")
|
||||
assert.Contains(t, ids, "puppy.go::Puppy.Speak")
|
||||
assert.Contains(t, ids, "service.go::ServiceDog.Speak")
|
||||
assert.Contains(t, ids, "tail.go::Tail.Wag")
|
||||
}
|
||||
|
||||
func TestClassHierarchy_NoMethods(t *testing.T) {
|
||||
e := NewEngine(buildHierarchyGraph())
|
||||
|
||||
// Without include_methods, no method nodes should be pulled in just
|
||||
// from a type seed (only types in the hierarchy plus Tail via composition).
|
||||
sg := e.ClassHierarchy("dog.go::Dog", HierarchyBoth, 5, false, QueryOptions{})
|
||||
for _, n := range sg.Nodes {
|
||||
assert.NotEqual(t, graph.KindMethod, n.Kind, "unexpected method node %q in non-include_methods walk", n.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassHierarchy_UnknownSeed(t *testing.T) {
|
||||
e := NewEngine(buildHierarchyGraph())
|
||||
sg := e.ClassHierarchy("does/not/exist", HierarchyBoth, 5, false, QueryOptions{})
|
||||
assert.Empty(t, sg.Nodes)
|
||||
assert.Empty(t, sg.Edges)
|
||||
}
|
||||
|
||||
func TestClassHierarchy_WorkspaceScope(t *testing.T) {
|
||||
g := graph.New()
|
||||
// Two workspaces. ws=alpha contains Base, ws=beta contains Sub.
|
||||
g.AddNode(&graph.Node{ID: "alpha::base", Kind: graph.KindType, Name: "Base", FilePath: "a/base.go", WorkspaceID: "alpha"})
|
||||
g.AddNode(&graph.Node{ID: "beta::sub", Kind: graph.KindType, Name: "Sub", FilePath: "b/sub.go", WorkspaceID: "beta"})
|
||||
g.AddEdge(&graph.Edge{From: "beta::sub", To: "alpha::base", Kind: graph.EdgeExtends})
|
||||
|
||||
e := NewEngine(g)
|
||||
|
||||
// Walking down from Base with no scope sees Sub.
|
||||
sg := e.ClassHierarchy("alpha::base", HierarchyDown, 5, false, QueryOptions{})
|
||||
require.Equal(t, 2, len(sg.Nodes))
|
||||
|
||||
// Confined to the alpha workspace, the cross-workspace child is dropped.
|
||||
sg = e.ClassHierarchy("alpha::base", HierarchyDown, 5, false, QueryOptions{WorkspaceID: "alpha"})
|
||||
ids := sortedNodeIDs(sg)
|
||||
assert.Equal(t, []string{"alpha::base"}, ids)
|
||||
}
|
||||
|
||||
func TestClassHierarchy_MinTier(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "p", Kind: graph.KindInterface, Name: "P"})
|
||||
g.AddNode(&graph.Node{ID: "c1", Kind: graph.KindType, Name: "C1"})
|
||||
g.AddNode(&graph.Node{ID: "c2", Kind: graph.KindType, Name: "C2"})
|
||||
// One LSP-resolved implements edge, one inferred.
|
||||
g.AddEdge(&graph.Edge{From: "c1", To: "p", Kind: graph.EdgeImplements, Origin: graph.OriginLSPResolved})
|
||||
g.AddEdge(&graph.Edge{From: "c2", To: "p", Kind: graph.EdgeImplements, Origin: graph.OriginASTInferred})
|
||||
|
||||
e := NewEngine(g)
|
||||
// Without min_tier, both implementers come back.
|
||||
sg := e.ClassHierarchy("p", HierarchyDown, 5, false, QueryOptions{})
|
||||
assert.Equal(t, 2, len(sg.Edges))
|
||||
|
||||
// With min_tier=lsp_resolved, only the high-confidence edge survives.
|
||||
sg = e.ClassHierarchy("p", HierarchyDown, 5, false, QueryOptions{MinTier: graph.OriginLSPResolved})
|
||||
require.Equal(t, 1, len(sg.Edges))
|
||||
assert.Equal(t, "c1", sg.Edges[0].From)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// ClosureOptions controls a multi-seed dependency-closure traversal
|
||||
// (Engine.ImportClosure). It generalises WalkBudgeted from a single
|
||||
// start node to a set of seeds, and ranks every reached node by its
|
||||
// graph distance to the nearest seed rather than stopping on an
|
||||
// encoded-size estimate.
|
||||
type ClosureOptions struct {
|
||||
// EdgeKinds is the set of edge kinds the closure follows. An empty
|
||||
// slice falls back to dependencyEdgeKinds (imports / calls /
|
||||
// references / depends_on plus the infrastructure edges), the same
|
||||
// allowlist GetDependencies walks.
|
||||
EdgeKinds []graph.EdgeKind
|
||||
// MaxDepth caps how far the closure expands from the seed set. A
|
||||
// non-positive value falls back to a built-in default.
|
||||
MaxDepth int
|
||||
// MaxNodes caps the size of the returned closure (the seeds always
|
||||
// count toward it). A non-positive value falls back to a built-in
|
||||
// default so a pathological seed set can never expand without
|
||||
// bound. Nodes are admitted in breadth-first / nearest-first order,
|
||||
// so the cap keeps the closest nodes.
|
||||
MaxNodes int
|
||||
// WorkspaceID / ProjectID scope the traversal exactly as the
|
||||
// matching WalkOptions fields do — neighbours outside the scope are
|
||||
// dropped along with the edge that reached them.
|
||||
WorkspaceID string
|
||||
ProjectID string
|
||||
}
|
||||
|
||||
// ClosureNode is one node in a dependency closure, tagged with its
|
||||
// graph distance to the nearest seed (0 for a seed itself).
|
||||
type ClosureNode struct {
|
||||
Node *graph.Node
|
||||
Distance int
|
||||
}
|
||||
|
||||
// ClosureResult is the outcome of a multi-seed closure traversal.
|
||||
type ClosureResult struct {
|
||||
// Nodes are the closure members sorted by (distance, ID) so the
|
||||
// result — and any pack built from it — is deterministic regardless
|
||||
// of the backend's edge enumeration order.
|
||||
Nodes []ClosureNode
|
||||
// Edges are the traversed edges that connect closure members. A
|
||||
// cross-edge between two already-visited members is still recorded.
|
||||
Edges []*graph.Edge
|
||||
// SeedIDs is the de-duplicated, in-scope seed set the traversal
|
||||
// actually started from (a seed outside scope or absent from the
|
||||
// graph is dropped before expansion).
|
||||
SeedIDs []string
|
||||
// Truncated reports whether the node cap stopped the expansion
|
||||
// before the closure was fully explored.
|
||||
Truncated bool
|
||||
// StoppedAtDepth is the deepest distance the traversal reached.
|
||||
StoppedAtDepth int
|
||||
}
|
||||
|
||||
const (
|
||||
closureDefaultMaxDepth = 6
|
||||
closureDefaultMaxNodes = 400
|
||||
)
|
||||
|
||||
// closureScopeAllows reuses the WalkOptions scope rule so a closure
|
||||
// enforces the same workspace/project boundary without duplicating the
|
||||
// fallback logic.
|
||||
func (o ClosureOptions) closureScopeAllows(n *graph.Node) bool {
|
||||
return WalkOptions{WorkspaceID: o.WorkspaceID, ProjectID: o.ProjectID}.scopeAllows(n)
|
||||
}
|
||||
|
||||
// ImportClosure walks the transitive dependency closure of a set of
|
||||
// seeds. Starting from every seed at distance 0, it expands breadth
|
||||
// first over the chosen edge kinds (default: the dependency allowlist —
|
||||
// imports / calls / references / depends_on / infra), recording each
|
||||
// reached node's distance to the NEAREST seed. Unresolved / external
|
||||
// neighbours are skipped and the workspace/project scope is enforced
|
||||
// exactly as WalkBudgeted does.
|
||||
//
|
||||
// Unlike WalkBudgeted (single seed, token-bounded) this is multi-seed
|
||||
// and node-bounded: it is the substrate for a closure-context packer
|
||||
// that ranks the closure by graph distance and hands it to the
|
||||
// token-budgeted manifest. The returned nodes are sorted by
|
||||
// (distance, ID) so the result is order-independent across backends.
|
||||
func (e *Engine) ImportClosure(seedIDs []string, opts ClosureOptions) *ClosureResult {
|
||||
maxDepth := opts.MaxDepth
|
||||
if maxDepth <= 0 {
|
||||
maxDepth = closureDefaultMaxDepth
|
||||
}
|
||||
maxNodes := opts.MaxNodes
|
||||
if maxNodes <= 0 {
|
||||
maxNodes = closureDefaultMaxNodes
|
||||
}
|
||||
|
||||
kinds := opts.EdgeKinds
|
||||
if len(kinds) == 0 {
|
||||
kinds = dependencyEdgeKinds
|
||||
}
|
||||
kindSet := make(map[graph.EdgeKind]bool, len(kinds))
|
||||
for _, k := range kinds {
|
||||
kindSet[k] = true
|
||||
}
|
||||
|
||||
distance := make(map[string]int)
|
||||
nodeByID := make(map[string]*graph.Node)
|
||||
var edges []*graph.Edge
|
||||
seenEdge := make(map[string]bool)
|
||||
|
||||
type item struct {
|
||||
id string
|
||||
depth int
|
||||
}
|
||||
var queue []item
|
||||
var seeds []string
|
||||
|
||||
// Seed the frontier. A seed that is out of scope, unresolved, or
|
||||
// absent from the graph never enters the closure — the caller's
|
||||
// distance ranking and budget should not be perturbed by a seed
|
||||
// that cannot contribute real context.
|
||||
seedSeen := make(map[string]bool)
|
||||
for _, id := range seedIDs {
|
||||
if id == "" || seedSeen[id] {
|
||||
continue
|
||||
}
|
||||
if graph.IsUnresolvedTarget(id) || strings.HasPrefix(id, "external::") {
|
||||
continue
|
||||
}
|
||||
n := e.g.GetNode(id)
|
||||
if n == nil || !opts.closureScopeAllows(n) {
|
||||
continue
|
||||
}
|
||||
seedSeen[id] = true
|
||||
seeds = append(seeds, id)
|
||||
distance[id] = 0
|
||||
nodeByID[id] = n
|
||||
queue = append(queue, item{id: id, depth: 0})
|
||||
}
|
||||
|
||||
truncated := false
|
||||
stoppedAtDepth := 0
|
||||
|
||||
for len(queue) > 0 {
|
||||
cur := queue[0]
|
||||
queue = queue[1:]
|
||||
if cur.depth > stoppedAtDepth {
|
||||
stoppedAtDepth = cur.depth
|
||||
}
|
||||
if cur.depth >= maxDepth {
|
||||
continue
|
||||
}
|
||||
|
||||
// Closure is a dependency-direction walk: follow outgoing edges
|
||||
// (what the seed depends on). This mirrors GetDependencies, the
|
||||
// established "what does X need" traversal.
|
||||
for _, edge := range e.g.GetOutEdges(cur.id) {
|
||||
if !kindSet[edge.Kind] {
|
||||
continue
|
||||
}
|
||||
neighborID := edge.To
|
||||
if graph.IsUnresolvedTarget(neighborID) ||
|
||||
strings.HasPrefix(neighborID, "external::") {
|
||||
continue
|
||||
}
|
||||
n := e.g.GetNode(neighborID)
|
||||
if n == nil || !opts.closureScopeAllows(n) {
|
||||
continue
|
||||
}
|
||||
|
||||
// The edge is part of the result regardless of whether the
|
||||
// neighbour is new — a cross-edge between two visited closure
|
||||
// members is still a real dependency. Dedup by identity so a
|
||||
// parallel edge pair is not double-counted.
|
||||
ekey := string(edge.Kind) + "\x00" + edge.From + "\x00" + edge.To
|
||||
if !seenEdge[ekey] {
|
||||
seenEdge[ekey] = true
|
||||
edges = append(edges, edge)
|
||||
}
|
||||
|
||||
if _, seen := distance[neighborID]; seen {
|
||||
continue
|
||||
}
|
||||
// Node cap: stop admitting new nodes once the closure is
|
||||
// full. Already-queued nodes still drain so their connecting
|
||||
// edges are recorded, but no deeper frontier is added.
|
||||
if len(nodeByID) >= maxNodes {
|
||||
truncated = true
|
||||
continue
|
||||
}
|
||||
distance[neighborID] = cur.depth + 1
|
||||
nodeByID[neighborID] = n
|
||||
queue = append(queue, item{id: neighborID, depth: cur.depth + 1})
|
||||
}
|
||||
}
|
||||
|
||||
nodes := make([]ClosureNode, 0, len(nodeByID))
|
||||
for id, n := range nodeByID {
|
||||
nodes = append(nodes, ClosureNode{Node: n, Distance: distance[id]})
|
||||
}
|
||||
sort.Slice(nodes, func(i, j int) bool {
|
||||
if nodes[i].Distance != nodes[j].Distance {
|
||||
return nodes[i].Distance < nodes[j].Distance
|
||||
}
|
||||
return nodes[i].Node.ID < nodes[j].Node.ID
|
||||
})
|
||||
|
||||
sort.Strings(seeds)
|
||||
return &ClosureResult{
|
||||
Nodes: nodes,
|
||||
Edges: edges,
|
||||
SeedIDs: seeds,
|
||||
Truncated: truncated,
|
||||
StoppedAtDepth: stoppedAtDepth,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// distanceByID flattens a closure result into an id→distance map for
|
||||
// terse assertions.
|
||||
func distanceByID(res *ClosureResult) map[string]int {
|
||||
out := make(map[string]int, len(res.Nodes))
|
||||
for _, m := range res.Nodes {
|
||||
out[m.Node.ID] = m.Distance
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestImportClosure_ExpandsOverDependencyEdges(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
// Default edge kinds (dependency allowlist) from main reach the
|
||||
// whole call chain plus the referenced type.
|
||||
res := e.ImportClosure([]string{"main.go::main"}, ClosureOptions{})
|
||||
dist := distanceByID(res)
|
||||
|
||||
require.Contains(t, dist, "main.go::main")
|
||||
assert.Equal(t, 0, dist["main.go::main"])
|
||||
assert.Contains(t, dist, "pkg/server.go::Start")
|
||||
assert.Contains(t, dist, "pkg/db.go::Connect")
|
||||
assert.Contains(t, dist, "pkg/db.go::Ping")
|
||||
// references edge is in the dependency allowlist.
|
||||
assert.Contains(t, dist, "pkg/db.go::DBImpl")
|
||||
}
|
||||
|
||||
func TestImportClosure_ExpandsOverImports(t *testing.T) {
|
||||
g := buildTestGraph()
|
||||
e := NewEngine(g)
|
||||
|
||||
// The pkg/server.go file imports pkg/db.go. Seeding the file node
|
||||
// follows the imports edge into the dependency closure.
|
||||
res := e.ImportClosure([]string{"pkg/server.go"}, ClosureOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeImports},
|
||||
})
|
||||
dist := distanceByID(res)
|
||||
|
||||
assert.Equal(t, 0, dist["pkg/server.go"])
|
||||
if assert.Contains(t, dist, "pkg/db.go") {
|
||||
assert.Equal(t, 1, dist["pkg/db.go"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportClosure_DistanceRanking(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
res := e.ImportClosure([]string{"main.go::main"}, ClosureOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeCalls},
|
||||
})
|
||||
dist := distanceByID(res)
|
||||
|
||||
// main -> Start -> Connect -> Ping is a depth-3 call chain.
|
||||
assert.Equal(t, 0, dist["main.go::main"])
|
||||
assert.Equal(t, 1, dist["pkg/server.go::Start"])
|
||||
assert.Equal(t, 2, dist["pkg/db.go::Connect"])
|
||||
assert.Equal(t, 3, dist["pkg/db.go::Ping"])
|
||||
|
||||
// The result must be sorted by (distance, id) regardless of edge
|
||||
// enumeration order.
|
||||
for i := 1; i < len(res.Nodes); i++ {
|
||||
prev, cur := res.Nodes[i-1], res.Nodes[i]
|
||||
if prev.Distance == cur.Distance {
|
||||
assert.LessOrEqual(t, prev.Node.ID, cur.Node.ID)
|
||||
} else {
|
||||
assert.Less(t, prev.Distance, cur.Distance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportClosure_NodeBudgetCap(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
// Cap at two nodes: the seed plus the single nearest neighbour.
|
||||
res := e.ImportClosure([]string{"main.go::main"}, ClosureOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeCalls},
|
||||
MaxNodes: 2,
|
||||
})
|
||||
assert.LessOrEqual(t, len(res.Nodes), 2)
|
||||
assert.True(t, res.Truncated, "expected truncation under a 2-node cap on a 4-node chain")
|
||||
// The seed is always kept; the deepest node must have been dropped.
|
||||
dist := distanceByID(res)
|
||||
assert.Contains(t, dist, "main.go::main")
|
||||
assert.NotContains(t, dist, "pkg/db.go::Ping")
|
||||
}
|
||||
|
||||
func TestImportClosure_DepthCap(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
res := e.ImportClosure([]string{"main.go::main"}, ClosureOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeCalls},
|
||||
MaxDepth: 1,
|
||||
})
|
||||
dist := distanceByID(res)
|
||||
// Depth 1 reaches Start but not the deeper Connect / Ping.
|
||||
assert.Contains(t, dist, "pkg/server.go::Start")
|
||||
assert.NotContains(t, dist, "pkg/db.go::Connect")
|
||||
assert.NotContains(t, dist, "pkg/db.go::Ping")
|
||||
}
|
||||
|
||||
func TestImportClosure_MultiSeedMergeTakesNearest(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
// Seed both main (distance to Ping = 3) and Connect (distance to
|
||||
// Ping = 1). The merged closure must record the NEAREST distance.
|
||||
res := e.ImportClosure([]string{"main.go::main", "pkg/db.go::Connect"}, ClosureOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeCalls},
|
||||
})
|
||||
dist := distanceByID(res)
|
||||
|
||||
assert.Equal(t, 0, dist["main.go::main"])
|
||||
assert.Equal(t, 0, dist["pkg/db.go::Connect"])
|
||||
// Ping is 1 hop from Connect, 3 from main -> nearest wins.
|
||||
assert.Equal(t, 1, dist["pkg/db.go::Ping"])
|
||||
// Both seeds are recorded, de-duplicated and sorted.
|
||||
assert.Equal(t, []string{"main.go::main", "pkg/db.go::Connect"}, res.SeedIDs)
|
||||
}
|
||||
|
||||
func TestImportClosure_SkipsUnresolvedAndOutOfScope(t *testing.T) {
|
||||
g := buildTestGraph()
|
||||
for _, n := range g.AllNodes() {
|
||||
n.WorkspaceID = "main"
|
||||
}
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "other/x.go::Foreign", Kind: graph.KindFunction, Name: "Foreign",
|
||||
FilePath: "other/x.go", Language: "go", WorkspaceID: "other",
|
||||
})
|
||||
g.AddEdge(&graph.Edge{
|
||||
From: "pkg/db.go::Ping", To: "other/x.go::Foreign",
|
||||
Kind: graph.EdgeCalls, FilePath: "pkg/db.go", Line: 20,
|
||||
})
|
||||
// An unresolved target must never enter the closure.
|
||||
g.AddEdge(&graph.Edge{
|
||||
From: "main.go::main", To: "unresolved::Mystery",
|
||||
Kind: graph.EdgeCalls, FilePath: "main.go", Line: 9,
|
||||
})
|
||||
e := NewEngine(g)
|
||||
|
||||
res := e.ImportClosure([]string{"main.go::main"}, ClosureOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeCalls},
|
||||
WorkspaceID: "main",
|
||||
})
|
||||
dist := distanceByID(res)
|
||||
assert.NotContains(t, dist, "other/x.go::Foreign", "out-of-scope neighbour must be dropped")
|
||||
assert.NotContains(t, dist, "unresolved::Mystery", "unresolved target must be dropped")
|
||||
}
|
||||
|
||||
func TestImportClosure_NoSeedsResolved(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
res := e.ImportClosure([]string{"does/not::Exist", "unresolved::Nope"}, ClosureOptions{})
|
||||
assert.Empty(t, res.Nodes)
|
||||
assert.Empty(t, res.SeedIDs)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"github.com/zzet/gortex/internal/embedding"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
"github.com/zzet/gortex/internal/search"
|
||||
"github.com/zzet/gortex/internal/search/rerank"
|
||||
)
|
||||
|
||||
// defaultCosineTopN bounds how many of the top ranked candidates the
|
||||
// post-rerank cosine refinement re-scores. The stage embeds the query
|
||||
// once and fetches this many stored vectors in one batch — keeping the
|
||||
// bound small (a few dozen) means the refinement is a cheap O(topN)
|
||||
// pass over an already-ranked head, never a re-rank of the whole pool.
|
||||
const defaultCosineTopN = 32
|
||||
|
||||
// embedderProvider is the optional capability a search backend exposes
|
||||
// when it carries a query embedder (the HybridBackend). Declared here
|
||||
// so the query package can recover the embedder from whatever backend
|
||||
// the engine currently holds without depending on the concrete type.
|
||||
type embedderProvider interface {
|
||||
Embedder() embedding.Provider
|
||||
}
|
||||
|
||||
// backendEmbedder extracts the query embedder from a search backend,
|
||||
// unwrapping one level of Swappable. Returns nil when no embedder is
|
||||
// reachable — the caller treats that as "vector channel inactive" and
|
||||
// skips the refinement entirely.
|
||||
func backendEmbedder(b search.Backend) embedding.Provider {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
if ep, ok := b.(embedderProvider); ok {
|
||||
if e := ep.Embedder(); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
if sw, ok := b.(*search.Swappable); ok {
|
||||
if ep, ok := sw.Inner().(embedderProvider); ok {
|
||||
return ep.Embedder()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refineByCosine re-orders the top of an already-ranked candidate slice
|
||||
// by exact cosine similarity between the query embedding and each
|
||||
// candidate's stored embedding — recovering the precise semantic
|
||||
// distance the rank-based SemanticSignal throws away.
|
||||
//
|
||||
// It is deliberately best-effort and regression-safe: it is a no-op
|
||||
// (returning cands untouched) whenever the vector channel is inactive,
|
||||
// the store can't read embeddings back, the embedder is absent, or the
|
||||
// query fails to embed. Only candidates whose stored vector matches the
|
||||
// query embedding's dimension participate; a candidate with no stored
|
||||
// vector keeps its rerank position and is never demoted below one that
|
||||
// was scored.
|
||||
//
|
||||
// Only the top `topN` candidates are touched. The tail below topN keeps
|
||||
// its rerank order, so the refinement can sharpen the head without
|
||||
// disturbing the long fallback tail. The relative order of refined
|
||||
// candidates among themselves is decided purely by cosine; ties fall
|
||||
// back to the incoming rerank order for determinism.
|
||||
func refineByCosine(
|
||||
query string,
|
||||
cands []*rerank.Candidate,
|
||||
embedder embedding.Provider,
|
||||
vectors graph.VectorSearcher,
|
||||
topN int,
|
||||
) []*rerank.Candidate {
|
||||
if embedder == nil || vectors == nil || query == "" || len(cands) < 2 {
|
||||
return cands
|
||||
}
|
||||
if topN <= 0 {
|
||||
topN = defaultCosineTopN
|
||||
}
|
||||
head := topN
|
||||
if head > len(cands) {
|
||||
head = len(cands)
|
||||
}
|
||||
|
||||
// Collect the candidate IDs in the head window and pull their
|
||||
// stored vectors in one batch. An empty result means none of the
|
||||
// head candidates were embedded — nothing to refine.
|
||||
ids := make([]string, 0, head)
|
||||
for _, c := range cands[:head] {
|
||||
if c != nil && c.Node != nil && c.Node.ID != "" {
|
||||
ids = append(ids, c.Node.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return cands
|
||||
}
|
||||
stored := vectors.GetEmbeddings(ids)
|
||||
if len(stored) == 0 {
|
||||
return cands
|
||||
}
|
||||
|
||||
// Embed the query exactly once. A failure here is not an error to
|
||||
// the caller — search must still return the rerank order.
|
||||
qVec, err := embedder.Embed(context.Background(), query)
|
||||
if err != nil || len(qVec) == 0 {
|
||||
return cands
|
||||
}
|
||||
qNorm := vecNorm(qVec)
|
||||
if qNorm == 0 {
|
||||
return cands
|
||||
}
|
||||
|
||||
// Score every head candidate that has a dimension-matched stored
|
||||
// vector. scored[i] is the cosine similarity (higher = closer) for
|
||||
// cands[i]; candidates without a usable vector are left unscored
|
||||
// and keep their incoming order relative to one another.
|
||||
type scoredCand struct {
|
||||
cand *rerank.Candidate
|
||||
cosine float64
|
||||
scored bool
|
||||
order int // incoming rerank position, the stable tiebreak
|
||||
}
|
||||
window := make([]scoredCand, head)
|
||||
anyScored := false
|
||||
for i, c := range cands[:head] {
|
||||
sc := scoredCand{cand: c, order: i}
|
||||
if c != nil && c.Node != nil {
|
||||
if vec, ok := stored[c.Node.ID]; ok && len(vec) == len(qVec) {
|
||||
if cNorm := vecNorm(vec); cNorm > 0 {
|
||||
sc.cosine = cosineSimilarity(qVec, vec, qNorm, cNorm)
|
||||
sc.scored = true
|
||||
anyScored = true
|
||||
}
|
||||
}
|
||||
}
|
||||
window[i] = sc
|
||||
}
|
||||
if !anyScored {
|
||||
return cands
|
||||
}
|
||||
|
||||
// Stable sort: scored candidates ahead of unscored ones, scored
|
||||
// ranked by descending cosine, and every tie (including the whole
|
||||
// unscored block) broken by the incoming rerank order so the result
|
||||
// is deterministic and an unscored candidate never leapfrogs a
|
||||
// scored one.
|
||||
sort.SliceStable(window, func(a, b int) bool {
|
||||
wa, wb := window[a], window[b]
|
||||
if wa.scored != wb.scored {
|
||||
return wa.scored // scored sorts before unscored
|
||||
}
|
||||
if wa.scored && wb.scored && wa.cosine != wb.cosine {
|
||||
return wa.cosine > wb.cosine
|
||||
}
|
||||
return wa.order < wb.order
|
||||
})
|
||||
|
||||
out := make([]*rerank.Candidate, 0, len(cands))
|
||||
for _, w := range window {
|
||||
out = append(out, w.cand)
|
||||
}
|
||||
out = append(out, cands[head:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// vecNorm returns the Euclidean (L2) norm of v as a float64.
|
||||
func vecNorm(v []float32) float64 {
|
||||
var sum float64
|
||||
for _, f := range v {
|
||||
d := float64(f)
|
||||
sum += d * d
|
||||
}
|
||||
return math.Sqrt(sum)
|
||||
}
|
||||
|
||||
// cosineSimilarity returns cosine_similarity(a, b) in [-1, 1] given
|
||||
// precomputed norms; higher means more similar. a and b are assumed
|
||||
// equal length with non-zero norms.
|
||||
func cosineSimilarity(a, b []float32, aNorm, bNorm float64) float64 {
|
||||
var dot float64
|
||||
for i := range a {
|
||||
dot += float64(a[i]) * float64(b[i])
|
||||
}
|
||||
sim := dot / (aNorm * bNorm)
|
||||
if sim > 1 {
|
||||
sim = 1
|
||||
} else if sim < -1 {
|
||||
sim = -1
|
||||
}
|
||||
return sim
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
"github.com/zzet/gortex/internal/search"
|
||||
"github.com/zzet/gortex/internal/search/rerank"
|
||||
)
|
||||
|
||||
// fakeEmbedder is a minimal embedding.Provider for the refinement
|
||||
// tests. Embed returns the configured query vector; the batch / dim /
|
||||
// close methods are present only to satisfy the interface.
|
||||
type fakeEmbedder struct {
|
||||
queryVec []float32
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeEmbedder) Embed(_ context.Context, _ string) ([]float32, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.queryVec, nil
|
||||
}
|
||||
|
||||
func (f *fakeEmbedder) EmbedBatch(_ context.Context, texts []string) ([][]float32, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
out := make([][]float32, len(texts))
|
||||
for i := range out {
|
||||
out[i] = f.queryVec
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeEmbedder) Dimensions() int { return len(f.queryVec) }
|
||||
func (f *fakeEmbedder) Close() error { return nil }
|
||||
|
||||
// fakeVectorSearcher returns stored vectors from a fixed map and tracks
|
||||
// the IDs GetEmbeddings was asked for, so a test can assert the stage
|
||||
// only fetched the bounded head window.
|
||||
type fakeVectorSearcher struct {
|
||||
vecs map[string][]float32
|
||||
askedFor []string
|
||||
getCalled bool
|
||||
returnNone bool // simulate "store has no vectors at all"
|
||||
}
|
||||
|
||||
func (f *fakeVectorSearcher) UpsertEmbedding(string, []float32) error { return nil }
|
||||
func (f *fakeVectorSearcher) BulkUpsertEmbeddings([]graph.VectorItem) error { return nil }
|
||||
func (f *fakeVectorSearcher) BuildVectorIndex(int) error { return nil }
|
||||
func (f *fakeVectorSearcher) SimilarTo([]float32, int) ([]graph.VectorHit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeVectorSearcher) GetEmbeddings(ids []string) map[string][]float32 {
|
||||
f.getCalled = true
|
||||
f.askedFor = append(f.askedFor, ids...)
|
||||
if f.returnNone {
|
||||
return map[string][]float32{}
|
||||
}
|
||||
out := make(map[string][]float32, len(ids))
|
||||
for _, id := range ids {
|
||||
if v, ok := f.vecs[id]; ok {
|
||||
out[id] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cand builds a candidate at a given incoming rerank position.
|
||||
func cand(id string, textRank int) *rerank.Candidate {
|
||||
return &rerank.Candidate{
|
||||
Node: &graph.Node{ID: id, Name: id},
|
||||
TextRank: textRank,
|
||||
VectorRank: -1,
|
||||
}
|
||||
}
|
||||
|
||||
func ids(cands []*rerank.Candidate) []string {
|
||||
out := make([]string, len(cands))
|
||||
for i, c := range cands {
|
||||
out[i] = c.Node.ID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestRefineByCosine_ReordersByCosine asserts the stage reorders the
|
||||
// head by exact cosine: the candidate whose stored vector points most
|
||||
// nearly the same direction as the query embedding rises to the top,
|
||||
// even though it started last in the rerank order.
|
||||
func TestRefineByCosine_ReordersByCosine(t *testing.T) {
|
||||
query := []float32{1, 0, 0}
|
||||
// "c" is the closest to the query direction, then "b", then "a";
|
||||
// the incoming rerank order is the reverse (a, b, c), so a correct
|
||||
// cosine refinement must invert it.
|
||||
vs := &fakeVectorSearcher{vecs: map[string][]float32{
|
||||
"a": {0, 1, 0}, // orthogonal — cosine 0
|
||||
"b": {1, 1, 0}, // 45° — cosine ~0.707
|
||||
"c": {10, 0.1, 0}, // almost parallel — cosine ~1
|
||||
}}
|
||||
emb := &fakeEmbedder{queryVec: query}
|
||||
|
||||
in := []*rerank.Candidate{cand("a", 0), cand("b", 1), cand("c", 2)}
|
||||
out := refineByCosine("q", in, emb, vs, 10)
|
||||
|
||||
got := ids(out)
|
||||
want := []string{"c", "b", "a"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("cosine refinement order = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefineByCosine_NoopWhenVectorsAbsent asserts the stage is a strict
|
||||
// no-op (order preserved) when the store has no vectors for the
|
||||
// candidates — the regression-safety contract.
|
||||
func TestRefineByCosine_NoopWhenVectorsAbsent(t *testing.T) {
|
||||
emb := &fakeEmbedder{queryVec: []float32{1, 0, 0}}
|
||||
|
||||
t.Run("store returns empty", func(t *testing.T) {
|
||||
vs := &fakeVectorSearcher{returnNone: true}
|
||||
in := []*rerank.Candidate{cand("a", 0), cand("b", 1), cand("c", 2)}
|
||||
out := refineByCosine("q", in, emb, vs, 10)
|
||||
if got := ids(out); got[0] != "a" || got[1] != "b" || got[2] != "c" {
|
||||
t.Fatalf("expected order preserved, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no matching ids", func(t *testing.T) {
|
||||
vs := &fakeVectorSearcher{vecs: map[string][]float32{"zzz": {1, 0, 0}}}
|
||||
in := []*rerank.Candidate{cand("a", 0), cand("b", 1)}
|
||||
out := refineByCosine("q", in, emb, vs, 10)
|
||||
if got := ids(out); got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("expected order preserved, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil vector searcher", func(t *testing.T) {
|
||||
in := []*rerank.Candidate{cand("a", 0), cand("b", 1)}
|
||||
out := refineByCosine("q", in, emb, nil, 10)
|
||||
if got := ids(out); got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("expected order preserved, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil embedder", func(t *testing.T) {
|
||||
vs := &fakeVectorSearcher{vecs: map[string][]float32{"a": {1, 0, 0}}}
|
||||
in := []*rerank.Candidate{cand("a", 0), cand("b", 1)}
|
||||
out := refineByCosine("q", in, nil, vs, 10)
|
||||
if got := ids(out); got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("expected order preserved, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestRefineByCosine_NoopWhenQueryEmbedFails asserts a query embed
|
||||
// failure leaves the order untouched rather than erroring out.
|
||||
func TestRefineByCosine_NoopWhenQueryEmbedFails(t *testing.T) {
|
||||
vs := &fakeVectorSearcher{vecs: map[string][]float32{
|
||||
"a": {1, 0, 0}, "b": {0, 1, 0},
|
||||
}}
|
||||
emb := &fakeEmbedder{err: errors.New("embed boom")}
|
||||
in := []*rerank.Candidate{cand("a", 0), cand("b", 1)}
|
||||
out := refineByCosine("q", in, emb, vs, 10)
|
||||
if got := ids(out); got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("expected order preserved on embed failure, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefineByCosine_UnscoredCandidatesKeepTailOrder asserts that a
|
||||
// candidate with no stored vector is never promoted above a scored one
|
||||
// and that unscored candidates keep their relative incoming order.
|
||||
func TestRefineByCosine_UnscoredCandidatesKeepTailOrder(t *testing.T) {
|
||||
query := []float32{1, 0, 0}
|
||||
// Only "b" and "d" have stored vectors; "a" and "c" do not. "d" is
|
||||
// closer to the query than "b". The scored pair must sort to the
|
||||
// front by cosine (d, b); the unscored pair must follow in their
|
||||
// incoming order (a, c).
|
||||
vs := &fakeVectorSearcher{vecs: map[string][]float32{
|
||||
"b": {1, 1, 0}, // ~0.707
|
||||
"d": {1, 0.05, 0}, // ~1.0
|
||||
}}
|
||||
emb := &fakeEmbedder{queryVec: query}
|
||||
|
||||
in := []*rerank.Candidate{cand("a", 0), cand("b", 1), cand("c", 2), cand("d", 3)}
|
||||
out := refineByCosine("q", in, emb, vs, 10)
|
||||
|
||||
got := ids(out)
|
||||
want := []string{"d", "b", "a", "c"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("order = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefineByCosine_OnlyTouchesTopN asserts the stage bounds its work
|
||||
// to the top-N head: candidates beyond the bound keep their position
|
||||
// and their vectors are never fetched.
|
||||
func TestRefineByCosine_OnlyTouchesTopN(t *testing.T) {
|
||||
query := []float32{1, 0, 0}
|
||||
vs := &fakeVectorSearcher{vecs: map[string][]float32{
|
||||
"a": {0, 1, 0}, // orthogonal
|
||||
"b": {10, 0.1, 0}, // near-parallel
|
||||
"c": {5, 0, 0}, // parallel but outside the window
|
||||
}}
|
||||
emb := &fakeEmbedder{queryVec: query}
|
||||
|
||||
in := []*rerank.Candidate{cand("a", 0), cand("b", 1), cand("c", 2)}
|
||||
// topN = 2 → only "a" and "b" participate; "c" stays put.
|
||||
out := refineByCosine("q", in, emb, vs, 2)
|
||||
|
||||
got := ids(out)
|
||||
want := []string{"b", "a", "c"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("order = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
// The store must only have been asked for the head window IDs.
|
||||
for _, asked := range vs.askedFor {
|
||||
if asked == "c" {
|
||||
t.Fatalf("GetEmbeddings was asked for an out-of-window id %q", asked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefineByCosine_NoopBelowTwoCandidates asserts the stage does not
|
||||
// run (and never even embeds the query) for a trivial candidate set.
|
||||
func TestRefineByCosine_NoopBelowTwoCandidates(t *testing.T) {
|
||||
vs := &fakeVectorSearcher{vecs: map[string][]float32{"a": {1, 0, 0}}}
|
||||
emb := &fakeEmbedder{queryVec: []float32{1, 0, 0}}
|
||||
in := []*rerank.Candidate{cand("a", 0)}
|
||||
out := refineByCosine("q", in, emb, vs, 10)
|
||||
if len(out) != 1 || out[0].Node.ID != "a" {
|
||||
t.Fatalf("single-candidate set must be returned unchanged")
|
||||
}
|
||||
if vs.getCalled {
|
||||
t.Fatalf("GetEmbeddings must not be called for a sub-2 candidate set")
|
||||
}
|
||||
}
|
||||
|
||||
// emptyTextBackend is a no-op search.Backend used only to construct a
|
||||
// HybridBackend in the embedder-unwrap test.
|
||||
type emptyTextBackend struct{}
|
||||
|
||||
func (emptyTextBackend) Add(string, ...string) {}
|
||||
func (emptyTextBackend) Remove(string) {}
|
||||
func (emptyTextBackend) Search(string, int) []search.SearchResult { return nil }
|
||||
func (emptyTextBackend) Count() int { return 0 }
|
||||
func (emptyTextBackend) Close() {}
|
||||
|
||||
// TestBackendEmbedder_UnwrapsSwappable asserts the embedder resolver
|
||||
// finds the query embedder through the production backend chain
|
||||
// (Swappable wrapping a HybridBackend) — the wiring the handler relies
|
||||
// on — and returns nil for a plain text backend that carries none.
|
||||
func TestBackendEmbedder_UnwrapsSwappable(t *testing.T) {
|
||||
emb := &fakeEmbedder{queryVec: []float32{1, 0, 0}}
|
||||
hybrid := search.NewHybrid(emptyTextBackend{}, search.NewVector(3), emb)
|
||||
sw := search.NewSwappable(hybrid)
|
||||
|
||||
if got := backendEmbedder(sw); got != emb {
|
||||
t.Fatalf("backendEmbedder must unwrap Swappable->Hybrid to the embedder, got %v", got)
|
||||
}
|
||||
if got := backendEmbedder(hybrid); got != emb {
|
||||
t.Fatalf("backendEmbedder must read the embedder off a bare Hybrid, got %v", got)
|
||||
}
|
||||
if got := backendEmbedder(search.NewSwappable(emptyTextBackend{})); got != nil {
|
||||
t.Fatalf("backendEmbedder must return nil for a text-only backend, got %v", got)
|
||||
}
|
||||
if got := backendEmbedder(nil); got != nil {
|
||||
t.Fatalf("backendEmbedder(nil) must be nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngineRefineByCosine_NoopWhenStoreLacksVectors asserts the engine
|
||||
// method no-ops cleanly when the underlying graph reader does not
|
||||
// implement graph.VectorSearcher (the in-memory store) — proving the
|
||||
// production wiring can never panic on a non-vector backend.
|
||||
func TestEngineRefineByCosine_NoopWhenStoreLacksVectors(t *testing.T) {
|
||||
e := NewEngine(graph.New()) // *graph.Graph does NOT implement VectorSearcher
|
||||
in := []*rerank.Candidate{cand("a", 0), cand("b", 1)}
|
||||
out := e.RefineByCosine("q", in, 0)
|
||||
if got := ids(out); got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("engine refine must be a no-op without a vector store, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/zzet/gortex/internal/search/rerank"
|
||||
)
|
||||
|
||||
// crossRepoRerank reassigns per-channel candidate ranks repository by
|
||||
// repository so a large repo's corpus size cannot bury a small repo's
|
||||
// best hits in a multi-repo workspace.
|
||||
//
|
||||
// The backend indexes every tracked repo into one merged corpus, so a
|
||||
// raw BM25 / vector rank reflects how a hit fares against ALL repos at
|
||||
// once — a 30k-symbol repo's matches crowd out a 500-symbol repo's
|
||||
// matches even when the latter are a better answer. Reassigning the
|
||||
// TextRank / VectorRank counters per repo means each repo's #1 hit is
|
||||
// rank 0, each repo's #2 hit is rank 1, and so on. The downstream
|
||||
// rerank's bm25 and semantic signals use the reciprocal-rank (RRF)
|
||||
// kernel, so per-repo ranks turn those signals into a genuine
|
||||
// cross-repo RRF fusion — the structural and session signals then
|
||||
// discriminate between the repos' top hits.
|
||||
//
|
||||
// No-op when the candidate set comes from a single repository (or is
|
||||
// empty): the merged ranks are already a fair within-repo order.
|
||||
func crossRepoRerank(cands []*rerank.Candidate) {
|
||||
repos := make(map[string]struct{}, 4)
|
||||
for _, c := range cands {
|
||||
if c != nil && c.Node != nil {
|
||||
repos[c.Node.RepoPrefix] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(repos) < 2 {
|
||||
return
|
||||
}
|
||||
reassignChannelPerRepo(cands, func(c *rerank.Candidate) *int { return &c.TextRank })
|
||||
reassignChannelPerRepo(cands, func(c *rerank.Candidate) *int { return &c.VectorRank })
|
||||
}
|
||||
|
||||
// reassignChannelPerRepo renumbers one channel's ranks (selected by
|
||||
// rankOf) so the counter resets at each repository. Candidates absent
|
||||
// from the channel (rank < 0) are left untouched. Ties on the prior
|
||||
// global rank — the exact-name fallback tier assigns several
|
||||
// candidates the same rank — break on node ID so the renumbering is
|
||||
// deterministic.
|
||||
func reassignChannelPerRepo(cands []*rerank.Candidate, rankOf func(*rerank.Candidate) *int) {
|
||||
ranked := make([]*rerank.Candidate, 0, len(cands))
|
||||
for _, c := range cands {
|
||||
if c != nil && c.Node != nil && *rankOf(c) >= 0 {
|
||||
ranked = append(ranked, c)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(ranked, func(i, j int) bool {
|
||||
ri, rj := *rankOf(ranked[i]), *rankOf(ranked[j])
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return ranked[i].Node.ID < ranked[j].Node.ID
|
||||
})
|
||||
counter := make(map[string]int, 4)
|
||||
for _, c := range ranked {
|
||||
repo := c.Node.RepoPrefix
|
||||
*rankOf(c) = counter[repo]
|
||||
counter[repo]++
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
"github.com/zzet/gortex/internal/search/rerank"
|
||||
)
|
||||
|
||||
func xrepoCand(id, repo string, textRank, vecRank int) *rerank.Candidate {
|
||||
return &rerank.Candidate{
|
||||
Node: &graph.Node{ID: id, RepoPrefix: repo},
|
||||
TextRank: textRank,
|
||||
VectorRank: vecRank,
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossRepoRerank_PerRepoCounters(t *testing.T) {
|
||||
// Global text ranks 0..4 interleaved across two repositories.
|
||||
cands := []*rerank.Candidate{
|
||||
xrepoCand("alpha::a", "alpha", 0, -1),
|
||||
xrepoCand("beta::a", "beta", 1, -1),
|
||||
xrepoCand("alpha::b", "alpha", 2, -1),
|
||||
xrepoCand("beta::b", "beta", 3, -1),
|
||||
xrepoCand("alpha::c", "alpha", 4, -1),
|
||||
}
|
||||
crossRepoRerank(cands)
|
||||
want := map[string]int{
|
||||
"alpha::a": 0, "alpha::b": 1, "alpha::c": 2,
|
||||
"beta::a": 0, "beta::b": 1,
|
||||
}
|
||||
for _, c := range cands {
|
||||
require.Equalf(t, want[c.Node.ID], c.TextRank, "TextRank of %s", c.Node.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossRepoRerank_SingleRepoNoOp(t *testing.T) {
|
||||
cands := []*rerank.Candidate{
|
||||
xrepoCand("r::a", "solo", 0, -1),
|
||||
xrepoCand("r::b", "solo", 1, -1),
|
||||
xrepoCand("r::c", "solo", 2, -1),
|
||||
}
|
||||
crossRepoRerank(cands)
|
||||
for i, c := range cands {
|
||||
require.Equal(t, i, c.TextRank, "single-repo ranks must be left untouched")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossRepoRerank_VectorChannelToo(t *testing.T) {
|
||||
cands := []*rerank.Candidate{
|
||||
xrepoCand("alpha::a", "alpha", -1, 0),
|
||||
xrepoCand("beta::a", "beta", -1, 1),
|
||||
xrepoCand("alpha::b", "alpha", -1, 2),
|
||||
}
|
||||
crossRepoRerank(cands)
|
||||
got := map[string]int{}
|
||||
for _, c := range cands {
|
||||
got[c.Node.ID] = c.VectorRank
|
||||
}
|
||||
require.Equal(t, 0, got["alpha::a"])
|
||||
require.Equal(t, 1, got["alpha::b"])
|
||||
require.Equal(t, 0, got["beta::a"])
|
||||
}
|
||||
|
||||
func TestCrossRepoRerank_TieBreakDeterministic(t *testing.T) {
|
||||
// The exact-name fallback tier hands several candidates the same
|
||||
// global rank; per-repo renumbering must be ID-stable.
|
||||
build := func() []*rerank.Candidate {
|
||||
return []*rerank.Candidate{
|
||||
xrepoCand("alpha::z", "alpha", 5, -1),
|
||||
xrepoCand("alpha::a", "alpha", 5, -1),
|
||||
xrepoCand("beta::m", "beta", 5, -1),
|
||||
}
|
||||
}
|
||||
first := build()
|
||||
crossRepoRerank(first)
|
||||
for run := 0; run < 5; run++ {
|
||||
c := build()
|
||||
crossRepoRerank(c)
|
||||
for i := range c {
|
||||
require.Equal(t, first[i].TextRank, c[i].TextRank, "run %d differs", run)
|
||||
}
|
||||
}
|
||||
got := map[string]int{}
|
||||
for _, c := range first {
|
||||
got[c.Node.ID] = c.TextRank
|
||||
}
|
||||
// alpha::a sorts before alpha::z by ID → takes per-repo rank 0.
|
||||
require.Equal(t, 0, got["alpha::a"])
|
||||
require.Equal(t, 1, got["alpha::z"])
|
||||
require.Equal(t, 0, got["beta::m"])
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// TestCallChainDispatchExpansionThroughOverrides proves the polymorphic
|
||||
// dispatch expansion: a forward call chain that reaches an interface method
|
||||
// auto-reaches the concrete implementations (and continues through them) ONLY
|
||||
// when IncludeDispatch is set; the dedicated EdgeOverrides edges are recorded
|
||||
// as-is (never synthesized into fake calls — so hierarchy queries stay
|
||||
// precise); and DispatchMinTier gates which overrides qualify by provenance.
|
||||
func TestCallChainDispatchExpansionThroughOverrides(t *testing.T) {
|
||||
g := graph.New()
|
||||
for _, id := range []string{"caller", "Iface.Do", "ImplA.Do", "ImplB.Do", "LegacyImpl.Do", "helperA"} {
|
||||
g.AddNode(&graph.Node{ID: id, Kind: graph.KindMethod, Name: id})
|
||||
}
|
||||
g.AddEdge(&graph.Edge{From: "caller", To: "Iface.Do", Kind: graph.EdgeCalls})
|
||||
// Two high-tier overrides + one low-tier (text-matched) override.
|
||||
g.AddEdge(&graph.Edge{From: "ImplA.Do", To: "Iface.Do", Kind: graph.EdgeOverrides, Origin: graph.OriginASTResolved})
|
||||
g.AddEdge(&graph.Edge{From: "ImplB.Do", To: "Iface.Do", Kind: graph.EdgeOverrides, Origin: graph.OriginASTResolved})
|
||||
g.AddEdge(&graph.Edge{From: "LegacyImpl.Do", To: "Iface.Do", Kind: graph.EdgeOverrides, Origin: graph.OriginTextMatched})
|
||||
// ImplA continues the chain.
|
||||
g.AddEdge(&graph.Edge{From: "ImplA.Do", To: "helperA", Kind: graph.EdgeCalls})
|
||||
|
||||
e := NewEngine(g)
|
||||
nodeSet := func(sg *SubGraph) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, n := range sg.Nodes {
|
||||
out[n.ID] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Baseline: no dispatch expansion — the chain dead-ends at the interface.
|
||||
base := nodeSet(e.GetCallChain("caller", QueryOptions{Depth: 5, Limit: 50}))
|
||||
if base["ImplA.Do"] || base["helperA"] {
|
||||
t.Error("without IncludeDispatch the chain must NOT reach concrete impls")
|
||||
}
|
||||
|
||||
// Dispatch-aware: reaches the overriders and continues through them.
|
||||
got := nodeSet(e.GetCallChain("caller", QueryOptions{Depth: 5, Limit: 50, IncludeDispatch: true}))
|
||||
for _, want := range []string{"Iface.Do", "ImplA.Do", "ImplB.Do", "LegacyImpl.Do", "helperA"} {
|
||||
if !got[want] {
|
||||
t.Errorf("dispatch expansion did not reach %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// The override edges are recorded AS EdgeOverrides, not synthesized calls.
|
||||
sg := e.GetCallChain("caller", QueryOptions{Depth: 5, Limit: 50, IncludeDispatch: true})
|
||||
var overrideEdges, fakeCalls int
|
||||
for _, ed := range sg.Edges {
|
||||
if ed.From == "ImplA.Do" && ed.To == "Iface.Do" {
|
||||
if ed.Kind == graph.EdgeOverrides {
|
||||
overrideEdges++
|
||||
}
|
||||
if ed.Kind == graph.EdgeCalls {
|
||||
fakeCalls++
|
||||
}
|
||||
}
|
||||
}
|
||||
if overrideEdges == 0 {
|
||||
t.Error("the override edge was not preserved in the result subgraph")
|
||||
}
|
||||
if fakeCalls != 0 {
|
||||
t.Error("a fake `calls` edge was synthesized for an override (precision lost)")
|
||||
}
|
||||
|
||||
// min_tier gate: requiring AST-resolved overrides drops the text-matched one.
|
||||
gated := nodeSet(e.GetCallChain("caller", QueryOptions{
|
||||
Depth: 5, Limit: 50, IncludeDispatch: true, DispatchMinTier: graph.OriginASTResolved,
|
||||
}))
|
||||
if !gated["ImplA.Do"] || !gated["ImplB.Do"] {
|
||||
t.Error("AST-resolved overrides should still expand under DispatchMinTier")
|
||||
}
|
||||
if gated["LegacyImpl.Do"] {
|
||||
t.Error("DispatchMinTier=OriginASTResolved should have gated out the text-matched override")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// TestGetCallChain_RecordsDispatchBoundary: a forward call-chain walk that
|
||||
// drops an unresolved (dynamic-dispatch) out-edge must flag the reachable set
|
||||
// as a floor and name the boundary.
|
||||
func TestGetCallChain_RecordsDispatchBoundary(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "A", Kind: graph.KindFunction, Name: "A"})
|
||||
g.AddNode(&graph.Node{ID: "B", Kind: graph.KindFunction, Name: "B"})
|
||||
g.AddEdge(&graph.Edge{From: "A", To: "B", Kind: graph.EdgeCalls, Origin: graph.OriginASTResolved})
|
||||
// B dispatches to an unresolved target the resolver could not bind.
|
||||
g.AddEdge(&graph.Edge{From: "B", To: "unresolved::handler", Kind: graph.EdgeCalls, Origin: graph.OriginASTInferred})
|
||||
|
||||
sg := NewEngine(g).GetCallChain("A", QueryOptions{Depth: 3, Limit: 50})
|
||||
if !sg.LowerBound {
|
||||
t.Fatalf("expected LowerBound=true, got boundaries=%+v", sg.Boundaries)
|
||||
}
|
||||
if len(sg.Boundaries) != 1 {
|
||||
t.Fatalf("expected 1 boundary, got %d: %+v", len(sg.Boundaries), sg.Boundaries)
|
||||
}
|
||||
b := sg.Boundaries[0]
|
||||
if b.SeedID != "B" || b.Target != "handler" || b.Reason != graph.BoundaryDynamicDispatch || b.Direction != "callees" {
|
||||
t.Errorf("unexpected boundary: %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetCallChain_NoBoundary: a fully-resolved chain reports no boundary and
|
||||
// no lower-bound flag (so existing responses are unchanged).
|
||||
func TestGetCallChain_NoBoundary(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "A", Kind: graph.KindFunction, Name: "A"})
|
||||
g.AddNode(&graph.Node{ID: "B", Kind: graph.KindFunction, Name: "B"})
|
||||
g.AddEdge(&graph.Edge{From: "A", To: "B", Kind: graph.EdgeCalls, Origin: graph.OriginASTResolved})
|
||||
sg := NewEngine(g).GetCallChain("A", QueryOptions{Depth: 3, Limit: 50})
|
||||
if sg.LowerBound || len(sg.Boundaries) != 0 {
|
||||
t.Errorf("expected no boundary, got LowerBound=%v boundaries=%+v", sg.LowerBound, sg.Boundaries)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// buildTestGraph creates a small realistic graph:
|
||||
//
|
||||
// main.go::main -> calls -> pkg/server.go::Start
|
||||
// pkg/server.go::Start -> calls -> pkg/db.go::Connect
|
||||
// pkg/db.go::Connect -> calls -> pkg/db.go::Ping
|
||||
// pkg/server.go -> imports -> pkg/db.go
|
||||
// pkg/db.go::DBImpl -> implements -> pkg/db.go::DB (interface)
|
||||
func buildTestGraph() *graph.Graph {
|
||||
g := graph.New()
|
||||
|
||||
nodes := []*graph.Node{
|
||||
{ID: "main.go", Kind: graph.KindFile, Name: "main.go", FilePath: "main.go", Language: "go"},
|
||||
{ID: "main.go::main", Kind: graph.KindFunction, Name: "main", FilePath: "main.go", Language: "go",
|
||||
StartLine: 5, EndLine: 10, Meta: map[string]any{"signature": "func main()"}},
|
||||
{ID: "pkg/server.go", Kind: graph.KindFile, Name: "server.go", FilePath: "pkg/server.go", Language: "go"},
|
||||
{ID: "pkg/server.go::Start", Kind: graph.KindFunction, Name: "Start", FilePath: "pkg/server.go", Language: "go",
|
||||
StartLine: 10, EndLine: 20},
|
||||
{ID: "pkg/db.go", Kind: graph.KindFile, Name: "db.go", FilePath: "pkg/db.go", Language: "go"},
|
||||
{ID: "pkg/db.go::Connect", Kind: graph.KindFunction, Name: "Connect", FilePath: "pkg/db.go", Language: "go",
|
||||
StartLine: 5, EndLine: 15},
|
||||
{ID: "pkg/db.go::Ping", Kind: graph.KindFunction, Name: "Ping", FilePath: "pkg/db.go", Language: "go",
|
||||
StartLine: 17, EndLine: 22},
|
||||
{ID: "pkg/db.go::DB", Kind: graph.KindInterface, Name: "DB", FilePath: "pkg/db.go", Language: "go",
|
||||
StartLine: 1, EndLine: 4},
|
||||
{ID: "pkg/db.go::DBImpl", Kind: graph.KindType, Name: "DBImpl", FilePath: "pkg/db.go", Language: "go",
|
||||
StartLine: 24, EndLine: 30},
|
||||
}
|
||||
for _, n := range nodes {
|
||||
g.AddNode(n)
|
||||
}
|
||||
|
||||
edges := []*graph.Edge{
|
||||
{From: "main.go::main", To: "pkg/server.go::Start", Kind: graph.EdgeCalls, FilePath: "main.go", Line: 7},
|
||||
{From: "pkg/server.go::Start", To: "pkg/db.go::Connect", Kind: graph.EdgeCalls, FilePath: "pkg/server.go", Line: 12},
|
||||
{From: "pkg/db.go::Connect", To: "pkg/db.go::Ping", Kind: graph.EdgeCalls, FilePath: "pkg/db.go", Line: 10},
|
||||
{From: "pkg/server.go", To: "pkg/db.go", Kind: graph.EdgeImports, FilePath: "pkg/server.go", Line: 3},
|
||||
{From: "pkg/db.go::DBImpl", To: "pkg/db.go::DB", Kind: graph.EdgeImplements, FilePath: "pkg/db.go", Line: 24},
|
||||
{From: "main.go::main", To: "pkg/db.go::DBImpl", Kind: graph.EdgeReferences, FilePath: "main.go", Line: 8},
|
||||
}
|
||||
for _, e := range edges {
|
||||
g.AddEdge(e)
|
||||
}
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
func TestGetSymbol(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
n := e.GetSymbol("pkg/db.go::Connect")
|
||||
require.NotNil(t, n)
|
||||
assert.Equal(t, "Connect", n.Name)
|
||||
|
||||
assert.Nil(t, e.GetSymbol("nonexistent"))
|
||||
}
|
||||
|
||||
func TestFindSymbols(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
results := e.FindSymbols("Connect")
|
||||
require.Len(t, results, 1)
|
||||
|
||||
results = e.FindSymbols("Connect", graph.KindFunction)
|
||||
require.Len(t, results, 1)
|
||||
|
||||
results = e.FindSymbols("Connect", graph.KindType)
|
||||
assert.Empty(t, results)
|
||||
}
|
||||
|
||||
func TestGetFileSymbols(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
sg := e.GetFileSymbols("pkg/db.go")
|
||||
assert.GreaterOrEqual(t, len(sg.Nodes), 4) // file + Connect + Ping + DB + DBImpl
|
||||
}
|
||||
|
||||
func TestGetDependencies(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
// main calls Start (depth 1).
|
||||
sg := e.GetDependencies("main.go::main", QueryOptions{Depth: 1, Limit: 50, Detail: "full"})
|
||||
assert.GreaterOrEqual(t, len(sg.Nodes), 2) // main + Start
|
||||
|
||||
// Depth 2 should also reach Connect.
|
||||
sg = e.GetDependencies("main.go::main", QueryOptions{Depth: 2, Limit: 50, Detail: "full"})
|
||||
ids := nodeIDs(sg.Nodes)
|
||||
assert.Contains(t, ids, "pkg/server.go::Start")
|
||||
assert.Contains(t, ids, "pkg/db.go::Connect")
|
||||
}
|
||||
|
||||
func TestGetDependents(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
// Who depends on Connect?
|
||||
sg := e.GetDependents("pkg/db.go::Connect", QueryOptions{Depth: 2, Limit: 50})
|
||||
ids := nodeIDs(sg.Nodes)
|
||||
assert.Contains(t, ids, "pkg/server.go::Start")
|
||||
}
|
||||
|
||||
func TestGetCallChain(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
sg := e.GetCallChain("main.go::main", QueryOptions{Depth: 3, Limit: 50})
|
||||
ids := nodeIDs(sg.Nodes)
|
||||
assert.Contains(t, ids, "pkg/server.go::Start")
|
||||
assert.Contains(t, ids, "pkg/db.go::Connect")
|
||||
assert.Contains(t, ids, "pkg/db.go::Ping")
|
||||
}
|
||||
|
||||
func TestGetCallers(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
sg := e.GetCallers("pkg/db.go::Ping", QueryOptions{Depth: 3, Limit: 50})
|
||||
ids := nodeIDs(sg.Nodes)
|
||||
assert.Contains(t, ids, "pkg/db.go::Connect")
|
||||
}
|
||||
|
||||
// Regression: methods wired in by reference (e.g. HTTP handler registration
|
||||
// like `mux.HandleFunc("/p", h.foo)`) carry an EdgeReferences edge from the
|
||||
// registration site to the method — they're never reached by a direct
|
||||
// EdgeCalls. Before the fix GetCallers ignored EdgeReferences and these
|
||||
// methods looked dead in the graph.
|
||||
func TestGetCallers_IncludesMethodValueReferences(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "routes.go::Register", Kind: graph.KindFunction, Name: "Register", FilePath: "routes.go", Language: "go"})
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "handler.go::Handler.HandleHealth", Kind: graph.KindMethod, Name: "HandleHealth",
|
||||
FilePath: "handler.go", Language: "go", Meta: map[string]any{"receiver": "Handler"},
|
||||
})
|
||||
// `mux.HandleFunc("/health", h.HandleHealth)` after the resolver:
|
||||
g.AddEdge(&graph.Edge{
|
||||
From: "routes.go::Register", To: "handler.go::Handler.HandleHealth",
|
||||
Kind: graph.EdgeReferences, FilePath: "routes.go", Line: 12,
|
||||
})
|
||||
|
||||
e := NewEngine(g)
|
||||
sg := e.GetCallers("handler.go::Handler.HandleHealth", QueryOptions{Depth: 1, Limit: 50})
|
||||
ids := nodeIDs(sg.Nodes)
|
||||
assert.Contains(t, ids, "routes.go::Register",
|
||||
"method-value registration site should surface as a caller via EdgeReferences")
|
||||
}
|
||||
|
||||
func TestFindImplementations(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
impls := e.FindImplementations("pkg/db.go::DB")
|
||||
require.Len(t, impls, 1)
|
||||
assert.Equal(t, "DBImpl", impls[0].Name)
|
||||
}
|
||||
|
||||
func TestFindUsages(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
sg := e.FindUsages("pkg/db.go::DBImpl")
|
||||
ids := nodeIDs(sg.Nodes)
|
||||
assert.Contains(t, ids, "main.go::main") // references DBImpl
|
||||
}
|
||||
|
||||
func TestGetCluster(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
sg := e.GetCluster("pkg/server.go::Start", QueryOptions{Depth: 1, Limit: 50})
|
||||
assert.GreaterOrEqual(t, len(sg.Nodes), 3) // Start + main + Connect
|
||||
}
|
||||
|
||||
func TestTruncation(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
sg := e.GetCallChain("main.go::main", QueryOptions{Depth: 10, Limit: 2})
|
||||
assert.True(t, sg.Truncated)
|
||||
assert.LessOrEqual(t, len(sg.Nodes), 2)
|
||||
}
|
||||
|
||||
func TestCycleHandling(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "a", Kind: graph.KindFunction, Name: "a", FilePath: "a.go", Language: "go"})
|
||||
g.AddNode(&graph.Node{ID: "b", Kind: graph.KindFunction, Name: "b", FilePath: "b.go", Language: "go"})
|
||||
g.AddEdge(&graph.Edge{From: "a", To: "b", Kind: graph.EdgeCalls, FilePath: "a.go", Line: 1})
|
||||
g.AddEdge(&graph.Edge{From: "b", To: "a", Kind: graph.EdgeCalls, FilePath: "b.go", Line: 1})
|
||||
|
||||
e := NewEngine(g)
|
||||
sg := e.GetCallChain("a", QueryOptions{Depth: 10, Limit: 50})
|
||||
// Should terminate without infinite loop.
|
||||
assert.LessOrEqual(t, len(sg.Nodes), 2)
|
||||
}
|
||||
|
||||
func TestStats(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
s := e.Stats()
|
||||
assert.Equal(t, 9, s.TotalNodes)
|
||||
assert.Equal(t, 6, s.TotalEdges)
|
||||
assert.Equal(t, 9, s.ByLanguage["go"])
|
||||
}
|
||||
|
||||
func TestBriefModeStripsMeta(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
sg := e.GetDependencies("main.go::main", QueryOptions{Depth: 1, Limit: 50, Detail: "brief"})
|
||||
for _, n := range sg.Nodes {
|
||||
assert.Nil(t, n.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
// buildMultiRepoTestGraph creates a graph with nodes from two repos for testing repo-scoped queries.
|
||||
func buildMultiRepoTestGraph() *graph.Graph {
|
||||
g := graph.New()
|
||||
|
||||
nodes := []*graph.Node{
|
||||
{ID: "repoA/main.go::main", Kind: graph.KindFunction, Name: "main", FilePath: "repoA/main.go", Language: "go", RepoPrefix: "repoA", StartLine: 1, EndLine: 10},
|
||||
{ID: "repoA/pkg/util.go::Helper", Kind: graph.KindFunction, Name: "Helper", FilePath: "repoA/pkg/util.go", Language: "go", RepoPrefix: "repoA", StartLine: 1, EndLine: 5},
|
||||
{ID: "repoB/lib.go::Helper", Kind: graph.KindFunction, Name: "Helper", FilePath: "repoB/lib.go", Language: "go", RepoPrefix: "repoB", StartLine: 1, EndLine: 8},
|
||||
{ID: "repoB/lib.go::Process", Kind: graph.KindFunction, Name: "Process", FilePath: "repoB/lib.go", Language: "go", RepoPrefix: "repoB", StartLine: 10, EndLine: 20},
|
||||
}
|
||||
for _, n := range nodes {
|
||||
g.AddNode(n)
|
||||
}
|
||||
|
||||
edges := []*graph.Edge{
|
||||
{From: "repoA/main.go::main", To: "repoA/pkg/util.go::Helper", Kind: graph.EdgeCalls, FilePath: "repoA/main.go", Line: 5},
|
||||
{From: "repoA/main.go::main", To: "repoB/lib.go::Process", Kind: graph.EdgeCalls, FilePath: "repoA/main.go", Line: 7, CrossRepo: true},
|
||||
{From: "repoB/lib.go::Process", To: "repoB/lib.go::Helper", Kind: graph.EdgeCalls, FilePath: "repoB/lib.go", Line: 12},
|
||||
}
|
||||
for _, e := range edges {
|
||||
g.AddEdge(e)
|
||||
}
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
func TestSearchSymbolsInRepo(t *testing.T) {
|
||||
g := buildMultiRepoTestGraph()
|
||||
e := NewEngine(g)
|
||||
|
||||
// Search for "Helper" scoped to repoA — should only return repoA's Helper.
|
||||
results := e.SearchSymbolsInRepo("Helper", "repoA", 10)
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, "repoA/pkg/util.go::Helper", results[0].ID)
|
||||
|
||||
// Search for "Helper" scoped to repoB — should only return repoB's Helper.
|
||||
results = e.SearchSymbolsInRepo("Helper", "repoB", 10)
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, "repoB/lib.go::Helper", results[0].ID)
|
||||
|
||||
// Search for "Process" scoped to repoA — should return nothing.
|
||||
results = e.SearchSymbolsInRepo("Process", "repoA", 10)
|
||||
assert.Empty(t, results)
|
||||
|
||||
// Search for a non-existent repo — should return nothing.
|
||||
results = e.SearchSymbolsInRepo("Helper", "repoC", 10)
|
||||
assert.Empty(t, results)
|
||||
}
|
||||
|
||||
func TestSearchSymbolsInRepo_Limit(t *testing.T) {
|
||||
g := buildMultiRepoTestGraph()
|
||||
e := NewEngine(g)
|
||||
|
||||
// With limit=1, should return at most 1 result.
|
||||
results := e.SearchSymbolsInRepo("Helper", "repoA", 1)
|
||||
assert.Len(t, results, 1)
|
||||
}
|
||||
|
||||
func TestGetFileSymbolsInRepo(t *testing.T) {
|
||||
g := buildMultiRepoTestGraph()
|
||||
e := NewEngine(g)
|
||||
|
||||
// Get symbols for repoB/lib.go scoped to repoB.
|
||||
sg := e.GetFileSymbolsInRepo("repoB/lib.go", "repoB")
|
||||
assert.Len(t, sg.Nodes, 2) // Helper and Process
|
||||
ids := nodeIDs(sg.Nodes)
|
||||
assert.Contains(t, ids, "repoB/lib.go::Helper")
|
||||
assert.Contains(t, ids, "repoB/lib.go::Process")
|
||||
|
||||
// Get symbols for repoB/lib.go scoped to repoA — should return nothing.
|
||||
sg = e.GetFileSymbolsInRepo("repoB/lib.go", "repoA")
|
||||
assert.Empty(t, sg.Nodes)
|
||||
|
||||
// Get symbols for a non-existent file — should return empty.
|
||||
sg = e.GetFileSymbolsInRepo("nonexistent.go", "repoA")
|
||||
assert.Empty(t, sg.Nodes)
|
||||
}
|
||||
|
||||
func TestGetFileSymbolsInRepo_Edges(t *testing.T) {
|
||||
g := buildMultiRepoTestGraph()
|
||||
e := NewEngine(g)
|
||||
|
||||
// Edges should be included when at least one endpoint is in the filtered set.
|
||||
sg := e.GetFileSymbolsInRepo("repoB/lib.go", "repoB")
|
||||
assert.Greater(t, len(sg.Edges), 0)
|
||||
}
|
||||
|
||||
func nodeIDs(nodes []*graph.Node) []string {
|
||||
ids := make([]string, len(nodes))
|
||||
for i, n := range nodes {
|
||||
ids[i] = n.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// A framework-contract `provides` edge landing on a @Bean factory method is
|
||||
// plumbing, not a usage — find_usages on the method must exclude it while
|
||||
// keeping genuine call usages. The same `provides` kind landing on a DI token
|
||||
// (a non-code symbol) IS the meaningful relationship and must survive.
|
||||
func TestFindUsages_ExcludesFrameworkContractOnCode(t *testing.T) {
|
||||
g := graph.New()
|
||||
cfg := "Config.java::AppConfig"
|
||||
bean := "Config.java::AppConfig.localeResolver"
|
||||
caller := "Other.java::Other.use"
|
||||
token := "Tokens.java::API_TOKEN"
|
||||
provider := "Providers.java::Providers.register"
|
||||
|
||||
g.AddNode(&graph.Node{ID: "Config.java", Kind: graph.KindFile, Name: "Config.java", Language: "java"})
|
||||
g.AddNode(&graph.Node{ID: cfg, Kind: graph.KindType, Name: "AppConfig", FilePath: "Config.java", Language: "java"})
|
||||
g.AddNode(&graph.Node{ID: bean, Kind: graph.KindMethod, Name: "localeResolver", FilePath: "Config.java", Language: "java"})
|
||||
g.AddNode(&graph.Node{ID: caller, Kind: graph.KindMethod, Name: "use", FilePath: "Other.java", Language: "java"})
|
||||
g.AddNode(&graph.Node{ID: token, Kind: graph.KindConstant, Name: "API_TOKEN", FilePath: "Tokens.java", Language: "java"})
|
||||
g.AddNode(&graph.Node{ID: provider, Kind: graph.KindMethod, Name: "register", FilePath: "Providers.java", Language: "java"})
|
||||
|
||||
// @Bean plumbing: AppConfig provides the localeResolver factory method.
|
||||
g.AddEdge(&graph.Edge{From: cfg, To: bean, Kind: graph.EdgeProvides, FilePath: "Config.java", Line: 9})
|
||||
// A genuine call usage of the bean method.
|
||||
g.AddEdge(&graph.Edge{From: caller, To: bean, Kind: graph.EdgeCalls, FilePath: "Other.java", Line: 12})
|
||||
// DI-token provider: register() provides the API_TOKEN.
|
||||
g.AddEdge(&graph.Edge{From: provider, To: token, Kind: graph.EdgeProvides, FilePath: "Providers.java", Line: 4})
|
||||
|
||||
e := NewEngine(g)
|
||||
|
||||
beanUsages := e.FindUsages(bean)
|
||||
var sawProvides, sawCall bool
|
||||
for _, ed := range beanUsages.Edges {
|
||||
switch ed.Kind {
|
||||
case graph.EdgeProvides:
|
||||
sawProvides = true
|
||||
case graph.EdgeCalls:
|
||||
sawCall = true
|
||||
}
|
||||
}
|
||||
assert.False(t, sawProvides, "find_usages on a @Bean method must not surface the provides contract edge")
|
||||
assert.True(t, sawCall, "find_usages on a @Bean method must still surface genuine call usages")
|
||||
|
||||
// The empty-usage @Bean case: a bean method whose only incoming edge is
|
||||
// the provides contract must report zero usages.
|
||||
g.AddNode(&graph.Node{ID: "Config.java::AppConfig.cacheCustomizer", Kind: graph.KindMethod, Name: "cacheCustomizer", FilePath: "Config.java", Language: "java"})
|
||||
g.AddEdge(&graph.Edge{From: cfg, To: "Config.java::AppConfig.cacheCustomizer", Kind: graph.EdgeProvides, FilePath: "Config.java", Line: 15})
|
||||
e2 := NewEngine(g)
|
||||
emptyBean := e2.FindUsages("Config.java::AppConfig.cacheCustomizer")
|
||||
assert.Empty(t, emptyBean.Edges, "a @Bean method whose only edge is the provides contract has no usages")
|
||||
|
||||
// A DI token keeps its providers.
|
||||
tokenUsages := e2.FindUsages(token)
|
||||
var tokenSawProvides bool
|
||||
for _, ed := range tokenUsages.Edges {
|
||||
if ed.Kind == graph.EdgeProvides {
|
||||
tokenSawProvides = true
|
||||
}
|
||||
}
|
||||
assert.True(t, tokenSawProvides, "find_usages on a DI token must still surface its providers")
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
"github.com/zzet/gortex/internal/parser/languages"
|
||||
)
|
||||
|
||||
// Repeated calls to the same method from the same caller on separate lines are
|
||||
// distinct usages and must each surface in find_usages — call-edge identity is
|
||||
// keyed on the source line, so consecutive `vet.getSpecialties()` sites do not
|
||||
// collapse into a single reported usage.
|
||||
func TestFindUsages_RepeatedCallSitesSurvive(t *testing.T) {
|
||||
src := []byte(`public class ClinicServiceTests {
|
||||
private Vet vet;
|
||||
void shouldFindVets() {
|
||||
vet.getSpecialties();
|
||||
vet.getSpecialties();
|
||||
}
|
||||
}
|
||||
`)
|
||||
e := languages.NewJavaExtractor()
|
||||
result, err := e.Extract("ClinicServiceTests.java", src)
|
||||
require.NoError(t, err)
|
||||
|
||||
g := graph.New()
|
||||
// The callee lives in another file; wire it and the extracted nodes/edges.
|
||||
g.AddNode(&graph.Node{ID: "Vet.java::Vet.getSpecialties", Kind: graph.KindMethod, Name: "getSpecialties", FilePath: "Vet.java", Language: "java"})
|
||||
for _, n := range result.Nodes {
|
||||
g.AddNode(n)
|
||||
}
|
||||
// Bind each extracted getSpecialties call edge to the callee (the resolver
|
||||
// does this in the daemon; we bind directly to isolate find_usages).
|
||||
for _, ed := range result.Edges {
|
||||
if ed.Kind == graph.EdgeCalls && ed.To == "unresolved::*.getSpecialties" {
|
||||
ed.To = "Vet.java::Vet.getSpecialties"
|
||||
}
|
||||
g.AddEdge(ed)
|
||||
}
|
||||
|
||||
sg := NewEngine(g).FindUsages("Vet.java::Vet.getSpecialties")
|
||||
lines := map[int]bool{}
|
||||
for _, ed := range sg.Edges {
|
||||
if ed.Kind == graph.EdgeCalls {
|
||||
lines[ed.Line] = true
|
||||
}
|
||||
}
|
||||
assert.True(t, lines[4], "the getSpecialties() call on line 4 must be a usage")
|
||||
assert.True(t, lines[5], "the second getSpecialties() call on line 5 must also be a usage (repeated sites do not collapse)")
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// Import / re-export statements are usages: pyright and tsserver both
|
||||
// count `from x import name` / `export {name} from …` lines in their
|
||||
// reference sets, and a symbol consumed only through a façade module
|
||||
// otherwise reports zero usages ("likely unused") despite live consumers.
|
||||
func TestFindUsages_IncludesImportAndReExportEdges(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "src/vanilla.ts::createStore", Kind: graph.KindFunction, Name: "createStore", FilePath: "src/vanilla.ts"})
|
||||
g.AddNode(&graph.Node{ID: "src/index.ts", Kind: graph.KindFile, Name: "index.ts", FilePath: "src/index.ts"})
|
||||
g.AddNode(&graph.Node{ID: "tests/basic.test.tsx", Kind: graph.KindFile, Name: "basic.test.tsx", FilePath: "tests/basic.test.tsx"})
|
||||
|
||||
g.AddEdge(&graph.Edge{
|
||||
From: "tests/basic.test.tsx", To: "src/vanilla.ts::createStore",
|
||||
Kind: graph.EdgeImports, FilePath: "tests/basic.test.tsx", Line: 3,
|
||||
Origin: graph.OriginASTResolved,
|
||||
})
|
||||
g.AddEdge(&graph.Edge{
|
||||
From: "src/index.ts", To: "src/vanilla.ts::createStore",
|
||||
Kind: graph.EdgeReExports, FilePath: "src/index.ts", Line: 1,
|
||||
Origin: graph.OriginASTResolved,
|
||||
})
|
||||
|
||||
e := NewEngine(g)
|
||||
sg := e.FindUsagesScoped("src/vanilla.ts::createStore", QueryOptions{})
|
||||
|
||||
kinds := map[graph.EdgeKind]int{}
|
||||
for _, edge := range sg.Edges {
|
||||
kinds[edge.Kind]++
|
||||
}
|
||||
assert.Equal(t, 1, kinds[graph.EdgeImports], "import statement must count as a usage")
|
||||
assert.Equal(t, 1, kinds[graph.EdgeReExports], "re-export statement must count as a usage")
|
||||
}
|
||||
|
||||
func TestRefContextOf_ImportEdges(t *testing.T) {
|
||||
imp := &graph.Edge{Kind: graph.EdgeImports}
|
||||
assert.Equal(t, graph.RefContextImport, graph.RefContextOf(imp, graph.KindFile))
|
||||
rex := &graph.Edge{Kind: graph.EdgeReExports}
|
||||
assert.Equal(t, graph.RefContextImport, graph.RefContextOf(rex, graph.KindFile))
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// TestQueryOptions_ScopeAllows is the per-node enforcement matrix for
|
||||
// the workspace boundary. It covers the singleton fallback (a node
|
||||
// with no declared workspace is keyed on its repo prefix) which keeps
|
||||
// the session boundary consistent with how the indexer stamps nodes.
|
||||
func TestQueryOptions_ScopeAllows(t *testing.T) {
|
||||
node := func(ws, proj, prefix string) *graph.Node {
|
||||
return &graph.Node{WorkspaceID: ws, ProjectID: proj, RepoPrefix: prefix}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
opts QueryOptions
|
||||
node *graph.Node
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty scope allows everything",
|
||||
opts: QueryOptions{},
|
||||
node: node("vio", "", "rate_checkers_detector"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
// ScopeAllows treats a nil node as "allow" — every caller
|
||||
// nil-checks before calling (the engine traversal does, and
|
||||
// the MCP layer's nodeInSessionScope rejects nil itself).
|
||||
name: "nil node passes (callers nil-check upstream)",
|
||||
opts: QueryOptions{WorkspaceID: "gortex"},
|
||||
node: nil,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "same workspace passes",
|
||||
opts: QueryOptions{WorkspaceID: "gortex"},
|
||||
node: node("gortex", "", "gortex"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "different workspace is rejected",
|
||||
opts: QueryOptions{WorkspaceID: "gortex"},
|
||||
node: node("vio", "", "rate_checkers_detector"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "node with empty workspace falls back to repo prefix — match",
|
||||
opts: QueryOptions{WorkspaceID: "rate_checkers_detector"},
|
||||
node: node("", "", "rate_checkers_detector"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "node with empty workspace falls back to repo prefix — mismatch",
|
||||
opts: QueryOptions{WorkspaceID: "gortex"},
|
||||
node: node("", "", "rate_checkers_detector"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "project narrows within the workspace — match",
|
||||
opts: QueryOptions{WorkspaceID: "gortex", ProjectID: "web"},
|
||||
node: node("gortex", "web", "web"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "project narrows within the workspace — mismatch",
|
||||
opts: QueryOptions{WorkspaceID: "gortex", ProjectID: "web"},
|
||||
node: node("gortex", "core", "core"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty project on opts allows any project in the workspace",
|
||||
opts: QueryOptions{WorkspaceID: "gortex"},
|
||||
node: node("gortex", "web", "web"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "project match cannot rescue a workspace mismatch",
|
||||
opts: QueryOptions{WorkspaceID: "gortex", ProjectID: "web"},
|
||||
node: node("vio", "web", "web"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "repo allow nil does not narrow",
|
||||
opts: QueryOptions{WorkspaceID: "gortex"},
|
||||
node: node("gortex", "core", "core"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "repo allow match passes",
|
||||
opts: QueryOptions{RepoAllow: map[string]bool{"core": true}},
|
||||
node: node("", "", "core"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "repo allow mismatch is rejected",
|
||||
opts: QueryOptions{RepoAllow: map[string]bool{"web": true}},
|
||||
node: node("", "", "core"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "workspace project and repo allow compose",
|
||||
opts: QueryOptions{
|
||||
WorkspaceID: "gortex",
|
||||
ProjectID: "backend",
|
||||
RepoAllow: map[string]bool{"payments": true},
|
||||
},
|
||||
node: node("gortex", "backend", "payments"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "repo allow cannot rescue a project mismatch",
|
||||
opts: QueryOptions{
|
||||
WorkspaceID: "gortex",
|
||||
ProjectID: "backend",
|
||||
RepoAllow: map[string]bool{"payments": true},
|
||||
},
|
||||
node: node("gortex", "frontend", "payments"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// Single-repo (unprefixed) mode: nodes carry no RepoPrefix
|
||||
// while the registry — and therefore every RepoAllow set —
|
||||
// keys the repo by name. Repo narrowing must not reject the
|
||||
// lone repo's own nodes (the fresh-install search_symbols
|
||||
// zero-rows regression).
|
||||
name: "repo allow admits an unprefixed single-repo node",
|
||||
opts: QueryOptions{RepoAllow: map[string]bool{"gin": true}},
|
||||
node: node("gin", "gin", ""),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "unprefixed node passes repo allow under a matching workspace",
|
||||
opts: QueryOptions{
|
||||
WorkspaceID: "gin",
|
||||
RepoAllow: map[string]bool{"gin": true},
|
||||
},
|
||||
node: node("gin", "gin", ""),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
// The carve-out must not weaken the workspace boundary: an
|
||||
// unprefixed node outside the session workspace stays
|
||||
// rejected even when RepoAllow would vacuously admit it.
|
||||
name: "workspace mismatch still rejects an unprefixed node",
|
||||
opts: QueryOptions{
|
||||
WorkspaceID: "other",
|
||||
RepoAllow: map[string]bool{"other": true},
|
||||
},
|
||||
node: node("gin", "gin", ""),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, tt.opts.ScopeAllows(tt.node))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func TestFilterSpeculative(t *testing.T) {
|
||||
mk := func() *SubGraph {
|
||||
return &SubGraph{Edges: []*graph.Edge{
|
||||
{From: "a", To: "b", Kind: graph.EdgeCalls},
|
||||
{From: "a", To: "c", Kind: graph.EdgeCalls, Meta: map[string]any{graph.MetaSpeculative: true}},
|
||||
}}
|
||||
}
|
||||
// Default-exclude: speculative dropped.
|
||||
sg := mk()
|
||||
sg.FilterSpeculative(false)
|
||||
if len(sg.Edges) != 1 || sg.Edges[0].To != "b" {
|
||||
t.Fatalf("FilterSpeculative(false) must drop speculative edges, got %d", len(sg.Edges))
|
||||
}
|
||||
// Opt-in: speculative kept.
|
||||
sg2 := mk()
|
||||
sg2.FilterSpeculative(true)
|
||||
if len(sg2.Edges) != 2 {
|
||||
t.Fatalf("FilterSpeculative(true) must keep speculative edges, got %d", len(sg2.Edges))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
"github.com/zzet/gortex/internal/search/rerank"
|
||||
)
|
||||
|
||||
// SubGraph is a JSON-serializable result from a graph query.
|
||||
type SubGraph struct {
|
||||
Nodes []*graph.Node `json:"nodes"`
|
||||
Edges []*graph.Edge `json:"edges"`
|
||||
TotalNodes int `json:"total_nodes"`
|
||||
TotalEdges int `json:"total_edges"`
|
||||
Truncated bool `json:"truncated"`
|
||||
// TextMatchedSuppressed counts name-only (text_matched) edges dropped
|
||||
// by the adaptive default: once the result carries resolver-verified
|
||||
// evidence for the symbol, the name-only fan-out is redundant noise.
|
||||
// Zero — and omitted — when nothing was suppressed. Re-run with
|
||||
// min_tier:"text_matched" to include the hidden rows.
|
||||
TextMatchedSuppressed int `json:"text_matched_suppressed,omitempty"`
|
||||
// SuppressionCaveat is attached by the adaptive text_matched default
|
||||
// (find_usages / get_callers) when TextMatchedSuppressed > 0 AND the
|
||||
// target's file was re-parsed on the live watch path without re-running
|
||||
// semantic enrichment — so the resolver-verified edges that triggered
|
||||
// suppression may be below the tier the enrichment pass would mint, and
|
||||
// the hidden name-only usages could be the real ones. Empty (omitted)
|
||||
// otherwise. Independent of Caveat, which only fires for a zero-edge
|
||||
// result — and since suppression only runs when a stronger edge exists,
|
||||
// the two never coexist.
|
||||
SuppressionCaveat string `json:"suppression_caveat,omitempty"`
|
||||
// RelatedTools is a one-line, additive completeness cue naming a
|
||||
// deferred tool whose trigger the response content just matched (e.g. a
|
||||
// find_usages on a dispatch-heavy interface → find_implementations). It
|
||||
// is emitted at most once per tool per session so a discovery hint
|
||||
// surfaces without repeating. Empty (omitted) when nothing matched or the
|
||||
// cue was already shown this session.
|
||||
RelatedTools string `json:"related_tools,omitempty"`
|
||||
// Caveat is attached only when an edge-returning query (find_usages,
|
||||
// get_callers) comes back with no edges, classifying whether the
|
||||
// empty result reflects genuinely unused code or an extraction gap.
|
||||
// Nil — and omitted from the response — for any non-empty result.
|
||||
Caveat *graph.ZeroEdgeCaveat `json:"caveat,omitempty"`
|
||||
// TierFiltered is attached when a min_tier filter dropped edges while
|
||||
// lower-tier edges still existed — so a min_tier that empties the result
|
||||
// is legible as "filtered", not "no usages". Set by FilterByMinTier;
|
||||
// omitted when no min_tier was applied or nothing was below the tier.
|
||||
TierFiltered *graph.TierFilteredCaveat `json:"tier_filtered,omitempty"`
|
||||
// CallerNotes carries concurrency-safety annotations keyed by node
|
||||
// ID. Populated only by get_callers (which classifies each caller);
|
||||
// other traversal tools share this struct and leave it nil, so it
|
||||
// is omitted from their responses. A node appears here only when at
|
||||
// least one concurrency flag is set, so an absent entry means
|
||||
// "neither sync_guarded nor cross_concurrent".
|
||||
CallerNotes map[string]*graph.ConcurrencyAnnotation `json:"caller_notes,omitempty"`
|
||||
// BudgetHit is set by token-budgeted traversals (WalkBudgeted) when
|
||||
// the walk stopped because the estimated encoded size of the result
|
||||
// reached the caller's token budget. False — and omitted — for a
|
||||
// traversal that completed within budget or never imposed one.
|
||||
BudgetHit bool `json:"budget_hit,omitempty"`
|
||||
// StoppedAtDepth records the BFS depth the budgeted traversal had
|
||||
// reached when it stopped — either the deepest depth fully expanded,
|
||||
// or the depth at which the budget / depth cap halted expansion.
|
||||
// Zero — and omitted — for traversals that don't track depth.
|
||||
StoppedAtDepth int `json:"stopped_at_depth,omitempty"`
|
||||
// LastSynced is the time the stalest federation proxy node in this
|
||||
// result was last pulled from its owning remote. Set only when the
|
||||
// traversal crossed into a remote-owned proxy node; omitted (and nil)
|
||||
// for a purely-local result, so a caller can see how fresh the
|
||||
// remote-derived part of the answer is.
|
||||
LastSynced *time.Time `json:"last_synced,omitempty"`
|
||||
// LowerBound is set by call-graph traversals (get_call_chain) when the
|
||||
// walk dropped one or more dynamic-dispatch / unresolved out-edges: the
|
||||
// reachable set is then a floor, not exhaustive. Omitted when false.
|
||||
LowerBound bool `json:"lower_bound,omitempty"`
|
||||
// DynamicBoundaries enriches a dispatch-bounded result with the body-level
|
||||
// {site, form, key, candidate-shortlist} of each runtime-dispatch site in
|
||||
// the seed symbol — so an agent reads "static path ends HERE, form X, key
|
||||
// Y, candidates A/B" instead of spiralling through the source. Computed on
|
||||
// demand (find_usages / smart_context / explore); never persisted.
|
||||
DynamicBoundaries []graph.DynamicBoundary `json:"dynamic_boundaries,omitempty"`
|
||||
// Boundaries names the unresolved/dispatch sites that made the result a
|
||||
// floor. Populated only by call-graph traversals; omitted when empty.
|
||||
Boundaries []graph.EpistemicBoundary `json:"boundaries,omitempty"`
|
||||
// UsageSummary is a compact completeness rollup attached only by
|
||||
// find_usages: the total reference count, the number of distinct
|
||||
// files those references span, and the test-file share. Nil — and
|
||||
// omitted — for every other traversal that shares this struct, and
|
||||
// for an empty result (the Caveat already explains that case). Lets
|
||||
// an agent see at a glance whether the usage list already covers
|
||||
// tests instead of re-grepping *_test.go files to find out.
|
||||
UsageSummary *UsageSummary `json:"usage_summary,omitempty"`
|
||||
}
|
||||
|
||||
// UsageSummary is the compact completeness rollup on a find_usages
|
||||
// result: NRefs total references, spread across NFiles distinct files,
|
||||
// of which NTestRefs originate in test files. It is derived from the
|
||||
// same edges and per-node test classification as the per-usage rows, so
|
||||
// the rollup never disagrees with the references it summarizes.
|
||||
type UsageSummary struct {
|
||||
NRefs int `json:"n_refs" toon:"n_refs"`
|
||||
NFiles int `json:"n_files" toon:"n_files"`
|
||||
NTestRefs int `json:"n_test_refs" toon:"n_test_refs"`
|
||||
}
|
||||
|
||||
// QueryOptions controls traversal depth, result limits, and detail level.
|
||||
type QueryOptions struct {
|
||||
Depth int `json:"depth"`
|
||||
Limit int `json:"limit"`
|
||||
Detail string `json:"detail"` // "brief" or "full"
|
||||
MinTier string `json:"min_tier,omitempty"` // see graph.Origin* constants; "" = no filter
|
||||
// WorkspaceID, when set, restricts traversal to nodes whose
|
||||
// effective workspace (Node.WorkspaceID || Node.RepoPrefix
|
||||
// fallback) equals this slug. Empty disables the filter —
|
||||
// preserves the legacy global-graph behaviour for callers that
|
||||
// don't care about the workspace boundary.
|
||||
WorkspaceID string `json:"workspace_id,omitempty"`
|
||||
// ProjectID applies the same scoping for the soft sub-boundary.
|
||||
// Honoured only when WorkspaceID is also set; on its own it would
|
||||
// be ambiguous (two workspaces could declare a project with the
|
||||
// same name).
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
// RepoAllow, when non-empty, restricts traversal to nodes whose
|
||||
// RepoPrefix is present in the allow-set. Nil or empty preserves
|
||||
// the legacy no-repo-filter behaviour. This is intentionally a
|
||||
// soft breadth control inside any workspace boundary, not a
|
||||
// replacement for caller-side workspace isolation.
|
||||
RepoAllow map[string]bool `json:"repo_allow,omitempty"`
|
||||
// ExcludeTests, when true, drops edges originating from a function
|
||||
// flagged as a test (Node.Meta["is_test"] = true) — set by the
|
||||
// indexer's test-edge pass. Lets find_usages / get_callers answer
|
||||
// "who depends on X *in production*" without test-noise dilution.
|
||||
ExcludeTests bool `json:"exclude_tests,omitempty"`
|
||||
|
||||
// IncludeDispatch makes a forward call-graph walk (get_call_chain /
|
||||
// trace / callees) polymorphic-dispatch aware: when the chain reaches an
|
||||
// interface / abstract method, it also expands through that method's
|
||||
// EdgeOverrides in-edges to the concrete implementations that override
|
||||
// it. The dedicated override edges are recorded AS-IS — never synthesized
|
||||
// into fake `calls` edges — so find_implementations / get_class_hierarchy
|
||||
// stay precise while a trace auto-reaches the impls. Off by default.
|
||||
IncludeDispatch bool `json:"include_dispatch,omitempty"`
|
||||
// DispatchMinTier gates which override edges qualify for dispatch
|
||||
// expansion by minimum provenance tier (see graph.Origin* constants);
|
||||
// "" admits every override edge. Lets a caller demand, e.g., only
|
||||
// LSP-confirmed overrides — a min_tier control codegraph cannot offer.
|
||||
DispatchMinTier string `json:"dispatch_min_tier,omitempty"`
|
||||
// DispatchFanout caps how many overriders a single method expands to
|
||||
// (0 → defaultDispatchFanout), bounding the blow-up on a hub interface
|
||||
// implemented by hundreds of types.
|
||||
DispatchFanout int `json:"dispatch_fanout,omitempty"`
|
||||
|
||||
// SearchTimings, when non-nil, is populated by the search hot path
|
||||
// (SearchSymbolsScoped → gatherBackendCandidates) with per-phase
|
||||
// wall-clock breakdowns. Used by the MCP search_symbols handler's
|
||||
// debug log line; nil disables instrumentation. Single-call: the
|
||||
// caller MUST hand a fresh struct per query (the engine does not
|
||||
// reset). Never serialised — `json:"-"` keeps the option struct
|
||||
// JSON shape stable.
|
||||
SearchTimings *SearchTimings `json:"-"`
|
||||
|
||||
// RerankContext is the optional rerank context the engine uses when
|
||||
// gathering bundle candidates: each bundle's in/out edges are
|
||||
// seeded into the context's edge caches so the handler-side
|
||||
// rerank.Pipeline.Rerank can skip its own batched edge fetch on
|
||||
// the merged candidate set. Pass nil — the engine's gather path
|
||||
// still works, the bundle's edges are just discarded after the
|
||||
// per-call rerank. Never serialised.
|
||||
RerankContext *rerank.Context `json:"-"`
|
||||
|
||||
// SkipInnerRerank, when true, makes SearchSymbolsRanked skip its
|
||||
// own per-call rerank.Pipeline.Rerank pass. Callers that fan a
|
||||
// search across N expansion terms and merge the results themselves
|
||||
// (the MCP search_symbols handler) re-run the rerank once on the
|
||||
// merged candidate set with the full session-aware context — the
|
||||
// inner per-call rerank is wasted work whose output is mostly
|
||||
// discarded by the merge. Flipping this on collapses N+1
|
||||
// engine-side rerank invocations to zero. The merge-side rerank
|
||||
// is the source of truth either way.
|
||||
SkipInnerRerank bool `json:"-"`
|
||||
|
||||
// SkipVectorChannel, when true, makes gatherBackendCandidates skip
|
||||
// the vector channel entirely — no embedder call, no ANN search.
|
||||
// Set by the MCP search_symbols handler on identifier-shape queries
|
||||
// (QueryClassSymbol / QueryClassPath / QueryClassSignature) where
|
||||
// the rerank's classWeightTable already proves the semantic
|
||||
// channel contributes near-zero useful signal (multipliers 0.65 /
|
||||
// 0.45 / 0.80 vs the baseline 1.00 for concept). Saves the embed
|
||||
// + vector search round-trip on the common-case identifier lookup.
|
||||
// The bundle path's vector-only branch and the legacy
|
||||
// SearchChannels path both honour this flag.
|
||||
SkipVectorChannel bool `json:"-"`
|
||||
|
||||
// SkipExactNameSplice, when true, makes gatherBackendCandidates
|
||||
// skip the FindNodesByName(query) splice-in. Set by callers that
|
||||
// know the query string cannot match any exact node name — the
|
||||
// fetchAndMergeBM25 fan-out's combined-OR call is the canonical
|
||||
// case: a concatenated bag of expansion terms ("NewServer
|
||||
// StartServer Server.Init …") can't be the literal Name of any
|
||||
// node, so the FindNodesByName query round-trip is wasted work.
|
||||
// The primary query still runs the splice.
|
||||
SkipExactNameSplice bool `json:"-"`
|
||||
|
||||
// CosineRerank, when true, runs the post-rerank exact-cosine
|
||||
// refinement stage after SearchSymbolsRanked's per-call rerank:
|
||||
// the top candidates are re-ordered by exact cosine similarity
|
||||
// between the query embedding and each candidate's stored
|
||||
// embedding, recovering the precise semantic distance the
|
||||
// rank-based SemanticSignal discards. The stage is a strict no-op
|
||||
// when the vector channel is inactive (no embedder, no stored
|
||||
// vectors, query fails to embed), so it can never regress a
|
||||
// text-only search. Honoured only when the engine has not been
|
||||
// asked to skip its inner rerank — the production handler runs
|
||||
// the refinement itself against its merged candidate set.
|
||||
CosineRerank bool `json:"-"`
|
||||
|
||||
// CosineTopN bounds how many of the top ranked candidates the
|
||||
// cosine refinement re-scores. Zero uses the package default.
|
||||
CosineTopN int `json:"-"`
|
||||
}
|
||||
|
||||
// SearchTimings carries per-phase wall-clock measurements collected
|
||||
// by the BM25 retrieval pipeline. Zero-valued fields mean the phase
|
||||
// didn't run on this call (e.g. FallbackMS is 0 when the BM25 result
|
||||
// already saturated the limit).
|
||||
type SearchTimings struct {
|
||||
BM25PrimaryMS int64 // time spent in the primary BM25 backend call
|
||||
BM25ExpansionMS int64 // time spent across all expansion-term BM25 calls
|
||||
GetNodesMS int64 // time spent materialising BM25/vector IDs via GetNodesByIDs
|
||||
FindNameMS int64 // time spent on the FindNodesByName splice-in
|
||||
FallbackMS int64 // time spent in the substring/name-contains fallback
|
||||
// Sub-buckets of the BM25*MS totals — proves which phase inside
|
||||
// the wrapper is actually slow. Accumulated across every
|
||||
// primary + expansion BM25 invocation.
|
||||
TextBackendMS int64 // strictly inside Backend.Search / text channel
|
||||
EmbedMS int64 // inside embedder.Embed (vector path only)
|
||||
VectorSearchMS int64 // inside vector.Search ANN call (vector path only)
|
||||
EngineRerankMS int64 // inside rerank.Pipeline.Rerank in SearchSymbolsRanked
|
||||
// BundleMS accumulates the wall-clock spent inside
|
||||
// SymbolBundleSearcherBackend.SearchSymbolBundles (one query per
|
||||
// BM25 fan-out that returns Node + in/out edges in one bundle).
|
||||
// When the backend supports bundles, the bundle path replaces the
|
||||
// (TextBackend + GetNodes) sub-buckets; the bm25_backend_ms
|
||||
// derivation in the handler subtracts BundleMS so the existing
|
||||
// fields stay meaningful.
|
||||
BundleMS int64
|
||||
// CacheHitRate is the fraction of post-merge candidates whose
|
||||
// in/out edges were already in the rerank Context cache when the
|
||||
// handler-side prepare() ran. 1.0 means every candidate was
|
||||
// pre-seeded from a bundle; 0.0 means the rerank had to fetch
|
||||
// every candidate's edges itself. Populated by the handler when
|
||||
// the bundle path is active so the search_symbols debug log can
|
||||
// surface how often the seeding actually catches.
|
||||
CacheHitRate float64
|
||||
}
|
||||
|
||||
// ScopeAllows reports whether a node passes the workspace/project
|
||||
// scope expressed in opts. Empty WorkspaceID means "no scope" — every
|
||||
// node passes. Same effective-fallback rule as the matcher: missing
|
||||
// WorkspaceID on the node falls back to its RepoPrefix.
|
||||
//
|
||||
// Exported so the MCP layer can enforce the session's workspace
|
||||
// boundary on by-id and whole-graph handlers that don't route through
|
||||
// the engine's scoped traversal.
|
||||
func (o QueryOptions) ScopeAllows(n *graph.Node) bool {
|
||||
if n == nil {
|
||||
return true
|
||||
}
|
||||
if o.WorkspaceID != "" {
|
||||
ws := n.WorkspaceID
|
||||
if ws == "" {
|
||||
ws = n.RepoPrefix
|
||||
}
|
||||
if ws != o.WorkspaceID {
|
||||
return false
|
||||
}
|
||||
if o.ProjectID != "" {
|
||||
proj := n.ProjectID
|
||||
if proj == "" {
|
||||
proj = n.RepoPrefix
|
||||
}
|
||||
if proj != o.ProjectID {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
// A node with an empty RepoPrefix was minted in single-repo
|
||||
// (unprefixed) mode — the RepoAllow keys are registry prefixes,
|
||||
// which unprefixed nodes never carry, so a repo narrow can only
|
||||
// ever be satisfied vacuously. Admit the node: the workspace /
|
||||
// project checks above still bound it for scoped sessions. Same
|
||||
// carve-out the MCP layer's filterNodes / field-query / API-impact
|
||||
// filters already apply.
|
||||
if len(o.RepoAllow) > 0 && n.RepoPrefix != "" && !o.RepoAllow[n.RepoPrefix] {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (o QueryOptions) hasScopeFilter() bool {
|
||||
return o.WorkspaceID != "" || len(o.RepoAllow) > 0
|
||||
}
|
||||
|
||||
// FilterByMinTier drops edges whose Origin rank is below minTier.
|
||||
//
|
||||
// Nodes are left untouched — a hop that gets filtered can leave an
|
||||
// unreachable node in Nodes. That's acceptable for the current surface
|
||||
// area (agents filter by tier mainly for one-hop questions like "who
|
||||
// calls this?"), and pruning orphans would silently change the node set
|
||||
// when a caller might still want to see them. Callers that care can
|
||||
// post-prune themselves.
|
||||
//
|
||||
// Edges without Origin set fall back to graph.DefaultOriginFor (derived
|
||||
// from kind + confidence + semantic_source meta) so filters work on
|
||||
// edges produced before this field existed or by providers not yet
|
||||
// updated.
|
||||
func (sg *SubGraph) FilterByMinTier(minTier string) {
|
||||
if minTier == "" || sg == nil {
|
||||
return
|
||||
}
|
||||
kept := make([]*graph.Edge, 0, len(sg.Edges))
|
||||
dropped := 0
|
||||
maxDroppedRank := -1
|
||||
maxDroppedOrigin := ""
|
||||
for _, e := range sg.Edges {
|
||||
origin := effectiveOrigin(e)
|
||||
if graph.MeetsMinTier(origin, minTier) {
|
||||
kept = append(kept, e)
|
||||
continue
|
||||
}
|
||||
dropped++
|
||||
if r := graph.OriginRank(origin); r > maxDroppedRank {
|
||||
maxDroppedRank = r
|
||||
maxDroppedOrigin = origin
|
||||
}
|
||||
}
|
||||
sg.Edges = kept
|
||||
// Record a caveat when the filter dropped edges that still exist below the
|
||||
// tier — so an empty (or thinned) result reads as "tier-filtered", not as
|
||||
// "no usages". Only meaningful when the filter actually emptied the visible
|
||||
// set; a min_tier that leaves edges keeps its own rows as the signal.
|
||||
if dropped > 0 && len(kept) == 0 {
|
||||
sg.TierFiltered = &graph.TierFilteredCaveat{
|
||||
Class: graph.TierFilteredClass,
|
||||
EdgesBelowMinTier: dropped,
|
||||
MaxAvailableTier: maxDroppedOrigin,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// effectiveOrigin returns the edge's origin tier, backfilled for edges
|
||||
// produced before Origin existed (or by providers not yet stamping it).
|
||||
func effectiveOrigin(e *graph.Edge) string {
|
||||
if e.Origin != "" {
|
||||
return e.Origin
|
||||
}
|
||||
src, _ := e.Meta["semantic_source"].(string)
|
||||
return graph.DefaultOriginFor(e.Kind, e.Confidence, src)
|
||||
}
|
||||
|
||||
// SuppressRedundantTextMatches drops text_matched edges when the result
|
||||
// also carries resolver-verified evidence (ast_inferred or better): once a
|
||||
// symbol has real resolved references, the name-only fan-out — every
|
||||
// same-named token in the repo — buries them. When text matches are the
|
||||
// ONLY evidence they are all kept, so recall through dynamic code paths
|
||||
// never regresses. Orphaned nodes are pruned; the drop count lands in
|
||||
// TextMatchedSuppressed. Callers apply this only when the user did not
|
||||
// pass an explicit min_tier.
|
||||
func (sg *SubGraph) SuppressRedundantTextMatches() {
|
||||
if sg == nil || len(sg.Edges) == 0 {
|
||||
return
|
||||
}
|
||||
textRank := graph.OriginRank(graph.OriginTextMatched)
|
||||
stronger := false
|
||||
for _, e := range sg.Edges {
|
||||
if graph.OriginRank(effectiveOrigin(e)) > textRank {
|
||||
stronger = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !stronger {
|
||||
return
|
||||
}
|
||||
kept := make([]*graph.Edge, 0, len(sg.Edges))
|
||||
dropped := 0
|
||||
for _, e := range sg.Edges {
|
||||
// Drop exactly the text_matched tier. Untagged edges (rank 0)
|
||||
// stay: they predate origin stamping and carry unknown — not
|
||||
// low — confidence.
|
||||
if e.Origin == graph.OriginTextMatched {
|
||||
dropped++
|
||||
continue
|
||||
}
|
||||
kept = append(kept, e)
|
||||
}
|
||||
if dropped == 0 {
|
||||
return
|
||||
}
|
||||
sg.Edges = kept
|
||||
sg.TextMatchedSuppressed = dropped
|
||||
referenced := make(map[string]struct{}, len(kept)*2)
|
||||
for _, e := range kept {
|
||||
referenced[e.From] = struct{}{}
|
||||
referenced[e.To] = struct{}{}
|
||||
}
|
||||
nodes := make([]*graph.Node, 0, len(sg.Nodes))
|
||||
for _, n := range sg.Nodes {
|
||||
if _, ok := referenced[n.ID]; ok {
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
}
|
||||
sg.Nodes = nodes
|
||||
}
|
||||
|
||||
// FilterSpeculative drops best-guess speculative edges (Meta[speculative]=true)
|
||||
// unless include is true. Called with include=false by default on every
|
||||
// edge-returning query, so speculative dynamic-dispatch edges never pollute a
|
||||
// default result — they are opt-in only.
|
||||
func (sg *SubGraph) FilterSpeculative(include bool) {
|
||||
if include || sg == nil {
|
||||
return
|
||||
}
|
||||
kept := sg.Edges[:0]
|
||||
for _, e := range sg.Edges {
|
||||
if !e.IsSpeculative() {
|
||||
kept = append(kept, e)
|
||||
}
|
||||
}
|
||||
sg.Edges = kept
|
||||
}
|
||||
|
||||
// ToDot returns a Graphviz DOT representation of the subgraph.
|
||||
func (sg *SubGraph) ToDot() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("digraph gortex {\n")
|
||||
b.WriteString(" rankdir=LR;\n")
|
||||
b.WriteString(" node [fontname=\"monospace\" fontsize=10];\n")
|
||||
b.WriteString(" edge [fontname=\"monospace\" fontsize=8];\n\n")
|
||||
|
||||
kindColors := map[graph.NodeKind]string{
|
||||
graph.KindFile: "#607D8B",
|
||||
graph.KindPackage: "#bb9af7",
|
||||
graph.KindFunction: "#7aa2f7",
|
||||
graph.KindMethod: "#7dcfff",
|
||||
graph.KindType: "#9ece6a",
|
||||
graph.KindInterface: "#73daca",
|
||||
graph.KindVariable: "#ff9e64",
|
||||
graph.KindImport: "#795548",
|
||||
}
|
||||
|
||||
kindShapes := map[graph.NodeKind]string{
|
||||
graph.KindFile: "folder",
|
||||
graph.KindFunction: "ellipse",
|
||||
graph.KindMethod: "ellipse",
|
||||
graph.KindType: "box",
|
||||
graph.KindInterface: "box",
|
||||
graph.KindVariable: "triangle",
|
||||
graph.KindImport: "note",
|
||||
graph.KindPackage: "diamond",
|
||||
}
|
||||
|
||||
for _, n := range sg.Nodes {
|
||||
color := kindColors[n.Kind]
|
||||
if color == "" {
|
||||
color = "#565f89"
|
||||
}
|
||||
shape := kindShapes[n.Kind]
|
||||
if shape == "" {
|
||||
shape = "ellipse"
|
||||
}
|
||||
label := fmt.Sprintf("%s\\n%s", n.Name, n.Kind)
|
||||
fmt.Fprintf(&b, " %q [label=%q shape=%s style=filled fillcolor=%q fontcolor=white];\n",
|
||||
n.ID, label, shape, color)
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
|
||||
edgeColors := map[graph.EdgeKind]string{
|
||||
graph.EdgeCalls: "#7aa2f7",
|
||||
graph.EdgeImports: "#565f89",
|
||||
graph.EdgeDefines: "#414868",
|
||||
graph.EdgeImplements: "#9ece6a",
|
||||
graph.EdgeExtends: "#bb9af7",
|
||||
graph.EdgeOverrides: "#f7768e",
|
||||
graph.EdgeReferences: "#3b4261",
|
||||
graph.EdgeMemberOf: "#3b4261",
|
||||
graph.EdgeInstantiates: "#e0af68",
|
||||
}
|
||||
|
||||
for _, e := range sg.Edges {
|
||||
color := edgeColors[e.Kind]
|
||||
if color == "" {
|
||||
color = "#3b4261"
|
||||
}
|
||||
fmt.Fprintf(&b, " %q -> %q [label=%q color=%q];\n",
|
||||
e.From, e.To, e.Kind, color)
|
||||
}
|
||||
|
||||
b.WriteString("}\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ToMermaid returns a Mermaid flowchart representation of the subgraph.
|
||||
// Renders in GitHub, Notion, and most markdown viewers.
|
||||
func (sg *SubGraph) ToMermaid() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("graph LR\n")
|
||||
|
||||
// Mermaid node shapes by kind.
|
||||
// [text] = rectangle, ([text]) = rounded, ((text)) = circle,
|
||||
// {text} = diamond, >text] = flag, [(text)] = stadium
|
||||
for _, n := range sg.Nodes {
|
||||
safeID := mermaidID(n.ID)
|
||||
label := fmt.Sprintf("%s\n%s", n.Name, n.Kind)
|
||||
|
||||
switch n.Kind {
|
||||
case graph.KindFile:
|
||||
fmt.Fprintf(&b, " %s[\"%s\"]\n", safeID, mermaidEscape(label))
|
||||
case graph.KindFunction, graph.KindMethod:
|
||||
fmt.Fprintf(&b, " %s([\"%s\"])\n", safeID, mermaidEscape(label))
|
||||
case graph.KindType, graph.KindInterface:
|
||||
fmt.Fprintf(&b, " %s[\"%s\"]\n", safeID, mermaidEscape(label))
|
||||
case graph.KindVariable:
|
||||
fmt.Fprintf(&b, " %s>\"%s\"]\n", safeID, mermaidEscape(label))
|
||||
case graph.KindPackage:
|
||||
fmt.Fprintf(&b, " %s{\"%s\"}\n", safeID, mermaidEscape(label))
|
||||
default:
|
||||
fmt.Fprintf(&b, " %s[\"%s\"]\n", safeID, mermaidEscape(label))
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
|
||||
// Mermaid edge styles by kind.
|
||||
edgeStyles := map[graph.EdgeKind]string{
|
||||
graph.EdgeCalls: "-->",
|
||||
graph.EdgeImports: "-.->",
|
||||
graph.EdgeDefines: "-->",
|
||||
graph.EdgeImplements: "-. implements .->",
|
||||
graph.EdgeExtends: "-. extends .->",
|
||||
graph.EdgeOverrides: "-. overrides .->",
|
||||
graph.EdgeReferences: "-->",
|
||||
graph.EdgeMemberOf: "-->",
|
||||
graph.EdgeInstantiates: "-. new .->",
|
||||
}
|
||||
|
||||
for _, e := range sg.Edges {
|
||||
style := edgeStyles[e.Kind]
|
||||
if style == "" {
|
||||
style = "-->"
|
||||
}
|
||||
fromID := mermaidID(e.From)
|
||||
toID := mermaidID(e.To)
|
||||
|
||||
// For simple arrow styles, add the edge kind as label.
|
||||
if style == "-->" || style == "-.->" {
|
||||
fmt.Fprintf(&b, " %s %s|%s| %s\n", fromID, style, e.Kind, toID)
|
||||
} else {
|
||||
fmt.Fprintf(&b, " %s %s %s\n", fromID, style, toID)
|
||||
}
|
||||
}
|
||||
|
||||
// Style classes for node coloring.
|
||||
b.WriteString("\n")
|
||||
kindCSS := map[graph.NodeKind]string{
|
||||
graph.KindFile: "fill:#607D8B,color:#fff",
|
||||
graph.KindPackage: "fill:#bb9af7,color:#fff",
|
||||
graph.KindFunction: "fill:#7aa2f7,color:#fff",
|
||||
graph.KindMethod: "fill:#7dcfff,color:#fff",
|
||||
graph.KindType: "fill:#9ece6a,color:#fff",
|
||||
graph.KindInterface: "fill:#73daca,color:#fff",
|
||||
graph.KindVariable: "fill:#ff9e64,color:#fff",
|
||||
graph.KindImport: "fill:#795548,color:#fff",
|
||||
}
|
||||
|
||||
// Group nodes by kind for class assignment.
|
||||
byKind := make(map[graph.NodeKind][]string)
|
||||
for _, n := range sg.Nodes {
|
||||
byKind[n.Kind] = append(byKind[n.Kind], mermaidID(n.ID))
|
||||
}
|
||||
for kind, ids := range byKind {
|
||||
css := kindCSS[kind]
|
||||
if css == "" {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, " classDef %s %s\n", kind, css)
|
||||
fmt.Fprintf(&b, " class %s %s\n", strings.Join(ids, ","), kind)
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// mermaidID converts a node ID to a Mermaid-safe identifier.
|
||||
// Mermaid IDs can't contain ::, /, or dots.
|
||||
func mermaidID(id string) string {
|
||||
r := strings.NewReplacer(
|
||||
"::", "_",
|
||||
"/", "_",
|
||||
".", "_",
|
||||
"-", "_",
|
||||
" ", "_",
|
||||
"<", "_",
|
||||
">", "_",
|
||||
"(", "_",
|
||||
")", "_",
|
||||
)
|
||||
return r.Replace(id)
|
||||
}
|
||||
|
||||
// mermaidEscape escapes characters that break Mermaid labels.
|
||||
func mermaidEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "\"", "#quot;")
|
||||
return s
|
||||
}
|
||||
|
||||
// DefaultOptions returns options with sensible defaults.
|
||||
func DefaultOptions() QueryOptions {
|
||||
return QueryOptions{
|
||||
Depth: 3,
|
||||
Limit: 50,
|
||||
Detail: "brief",
|
||||
}
|
||||
}
|
||||
|
||||
// WalkOptions controls a token-budgeted free-form graph traversal
|
||||
// (Engine.WalkBudgeted). It is deliberately a separate struct from
|
||||
// QueryOptions: a budgeted walk stops on an encoded-size estimate
|
||||
// rather than a node count, and lets the caller pick an arbitrary set
|
||||
// of edge kinds and a traversal direction — neither of which the
|
||||
// fixed-purpose QueryOptions traversals expose.
|
||||
type WalkOptions struct {
|
||||
// EdgeKinds is the set of edge kinds the walk follows. An empty
|
||||
// slice means "every edge kind" and, combined with Direction
|
||||
// "both", reproduces an undirected neighbourhood walk.
|
||||
EdgeKinds []graph.EdgeKind
|
||||
// Direction is "out" (follow outgoing edges — the default when
|
||||
// empty), "in" (follow incoming edges), or "both" (undirected).
|
||||
Direction string
|
||||
// TokenBudget is the approximate token ceiling for the encoded
|
||||
// result. The walk stops appending nodes once the running estimate
|
||||
// would exceed it. A non-positive value disables the budget.
|
||||
TokenBudget int
|
||||
// MaxDepth is a hard safety cap on BFS depth, applied even when the
|
||||
// token budget would allow deeper expansion. A non-positive value
|
||||
// falls back to a built-in default.
|
||||
MaxDepth int
|
||||
// WorkspaceID / ProjectID / RepoAllow scope the traversal exactly as the
|
||||
// matching QueryOptions fields do — neighbours outside the scope
|
||||
// are dropped along with the edge that reached them.
|
||||
WorkspaceID string
|
||||
ProjectID string
|
||||
RepoAllow map[string]bool
|
||||
// CommunityID, when non-empty, constrains the walk to a single
|
||||
// detected community: a neighbour is admitted only when it has no
|
||||
// community membership (a structural node Leiden never partitioned
|
||||
// — file / import / param) OR its membership equals CommunityID.
|
||||
// A neighbour with a *different* membership is dropped along with
|
||||
// the edge that reached it. NodeToComm must be supplied for the
|
||||
// filter to engage; an empty CommunityID disables it entirely.
|
||||
CommunityID string
|
||||
// NodeToComm maps node ID to its community ID, as produced by the
|
||||
// community-detection pass. Only nodes Leiden partitioned over the
|
||||
// call / reference graph appear here; an absent entry means "no
|
||||
// defined membership" and the CommunityID filter lets such a node
|
||||
// pass (it never had a community to be excluded from).
|
||||
NodeToComm map[string]string
|
||||
}
|
||||
|
||||
// scopeAllows reports whether n passes this walk's workspace/project
|
||||
// scope. Mirrors QueryOptions.ScopeAllows so budgeted walks enforce the
|
||||
// same boundary without duplicating the fallback rules.
|
||||
func (o WalkOptions) scopeAllows(n *graph.Node) bool {
|
||||
return QueryOptions{WorkspaceID: o.WorkspaceID, ProjectID: o.ProjectID, RepoAllow: o.RepoAllow}.ScopeAllows(n)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// Empty-origin call edges are resolver-bound usages (the common case for
|
||||
// languages the resolver binds by name), not name-only text-match fan-out.
|
||||
// They must survive suppression even when a stronger (LSP-confirmed) edge
|
||||
// exists for the same target — the rust-analyzer partial-enrichment case
|
||||
// that otherwise collapsed find_usages from hundreds of sites to a handful.
|
||||
func TestSuppressRedundantTextMatches_KeepsEmptyOriginWhenResolvedExists(t *testing.T) {
|
||||
sg := suppressSubGraph(
|
||||
&graph.Edge{From: "a::caller", To: "b::foo", Kind: graph.EdgeCalls, Origin: graph.OriginLSPResolved},
|
||||
&graph.Edge{From: "c::c1", To: "b::foo", Kind: graph.EdgeCalls}, // empty origin — resolver-bound
|
||||
&graph.Edge{From: "d::c2", To: "b::foo", Kind: graph.EdgeCalls}, // empty origin — resolver-bound
|
||||
&graph.Edge{From: "e::noise", To: "b::foo", Kind: graph.EdgeCalls, Origin: graph.OriginTextMatched},
|
||||
)
|
||||
sg.SuppressRedundantTextMatches()
|
||||
|
||||
// Only the explicitly text_matched edge is dropped.
|
||||
require.Equal(t, 3, len(sg.Edges))
|
||||
require.Equal(t, 1, sg.TextMatchedSuppressed)
|
||||
for _, e := range sg.Edges {
|
||||
require.NotEqual(t, graph.OriginTextMatched, e.Origin)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// suppressSubGraph builds a SubGraph whose node set is exactly the union of
|
||||
// the edges' endpoints — the shape find_usages / get_callers hand to
|
||||
// SuppressRedundantTextMatches.
|
||||
func suppressSubGraph(edges ...*graph.Edge) *SubGraph {
|
||||
sg := &SubGraph{Edges: edges}
|
||||
seen := map[string]bool{}
|
||||
for _, e := range edges {
|
||||
for _, id := range []string{e.From, e.To} {
|
||||
if !seen[id] {
|
||||
seen[id] = true
|
||||
sg.Nodes = append(sg.Nodes, &graph.Node{ID: id})
|
||||
}
|
||||
}
|
||||
}
|
||||
return sg
|
||||
}
|
||||
|
||||
func suppressNodeIDs(sg *SubGraph) []string {
|
||||
ids := make([]string, 0, len(sg.Nodes))
|
||||
for _, n := range sg.Nodes {
|
||||
ids = append(ids, n.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestSuppressRedundantTextMatches_DropsTextWhenResolvedEvidenceExists(t *testing.T) {
|
||||
sg := suppressSubGraph(
|
||||
&graph.Edge{From: "a.go::caller", To: "b.go::T.Get", Kind: graph.EdgeCalls, Origin: graph.OriginLSPResolved},
|
||||
&graph.Edge{From: "e.go::heuristic", To: "b.go::T.Get", Kind: graph.EdgeCalls, Origin: graph.OriginASTInferred},
|
||||
&graph.Edge{From: "c.go::other", To: "b.go::T.Get", Kind: graph.EdgeCalls, Origin: graph.OriginTextMatched},
|
||||
&graph.Edge{From: "d.go::noise", To: "b.go::T.Get", Kind: graph.EdgeCalls, Origin: graph.OriginTextMatched},
|
||||
)
|
||||
sg.SuppressRedundantTextMatches()
|
||||
|
||||
assert.Len(t, sg.Edges, 2)
|
||||
for _, e := range sg.Edges {
|
||||
assert.NotEqual(t, graph.OriginTextMatched, e.Origin)
|
||||
}
|
||||
assert.Equal(t, 2, sg.TextMatchedSuppressed)
|
||||
// Orphaned text-match callers pruned; surviving endpoints stay.
|
||||
assert.ElementsMatch(t,
|
||||
[]string{"a.go::caller", "e.go::heuristic", "b.go::T.Get"},
|
||||
suppressNodeIDs(sg))
|
||||
}
|
||||
|
||||
func TestSuppressRedundantTextMatches_KeepsTextWhenOnlyEvidence(t *testing.T) {
|
||||
sg := suppressSubGraph(
|
||||
&graph.Edge{From: "c.go::other", To: "b.go::T.Get", Kind: graph.EdgeCalls, Origin: graph.OriginTextMatched},
|
||||
&graph.Edge{From: "d.go::more", To: "b.go::T.Get", Kind: graph.EdgeCalls, Origin: graph.OriginTextMatched},
|
||||
)
|
||||
sg.SuppressRedundantTextMatches()
|
||||
|
||||
assert.Len(t, sg.Edges, 2)
|
||||
assert.Zero(t, sg.TextMatchedSuppressed)
|
||||
assert.Len(t, sg.Nodes, 3)
|
||||
}
|
||||
|
||||
func TestSuppressRedundantTextMatches_KeepsUntaggedEdges(t *testing.T) {
|
||||
// An edge with no Origin stamp is backfilled by effectiveOrigin; it is
|
||||
// suppressed only if the backfill lands exactly on text_matched. Pin
|
||||
// the test to whatever the backfill says so it can't drift.
|
||||
untagged := &graph.Edge{From: "u.go::legacy", To: "b.go::T.Get", Kind: graph.EdgeCalls, Confidence: 1.0}
|
||||
require.NotEqual(t, graph.OriginTextMatched, effectiveOrigin(untagged),
|
||||
"precondition: a confidence-1.0 call edge must not backfill to text_matched")
|
||||
|
||||
sg := suppressSubGraph(
|
||||
&graph.Edge{From: "a.go::caller", To: "b.go::T.Get", Kind: graph.EdgeCalls, Origin: graph.OriginLSPResolved},
|
||||
untagged,
|
||||
&graph.Edge{From: "c.go::noise", To: "b.go::T.Get", Kind: graph.EdgeCalls, Origin: graph.OriginTextMatched},
|
||||
)
|
||||
sg.SuppressRedundantTextMatches()
|
||||
|
||||
assert.Len(t, sg.Edges, 2)
|
||||
assert.Equal(t, 1, sg.TextMatchedSuppressed)
|
||||
assert.ElementsMatch(t,
|
||||
[]string{"a.go::caller", "u.go::legacy", "b.go::T.Get"},
|
||||
suppressNodeIDs(sg))
|
||||
}
|
||||
|
||||
func TestSuppressRedundantTextMatches_NilAndEmptySafe(t *testing.T) {
|
||||
var nilSG *SubGraph
|
||||
assert.NotPanics(t, func() { nilSG.SuppressRedundantTextMatches() })
|
||||
|
||||
empty := &SubGraph{}
|
||||
empty.SuppressRedundantTextMatches()
|
||||
assert.Zero(t, empty.TextMatchedSuppressed)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// A min_tier that filters every edge while lower-tier edges exist records a
|
||||
// tier_filtered caveat instead of leaving a bare empty result that reads as
|
||||
// "no usages".
|
||||
func TestFilterByMinTier_TierFilteredCaveat(t *testing.T) {
|
||||
sg := &SubGraph{Edges: []*graph.Edge{
|
||||
{From: "a", To: "t", Kind: graph.EdgeCalls, Origin: graph.OriginTextMatched},
|
||||
{From: "b", To: "t", Kind: graph.EdgeCalls, Origin: graph.OriginASTInferred},
|
||||
}}
|
||||
sg.FilterByMinTier("lsp_resolved")
|
||||
|
||||
assert.Empty(t, sg.Edges)
|
||||
require.NotNil(t, sg.TierFiltered)
|
||||
assert.Equal(t, graph.TierFilteredClass, sg.TierFiltered.Class)
|
||||
assert.Equal(t, 2, sg.TierFiltered.EdgesBelowMinTier)
|
||||
// ast_inferred (rank 3) outranks text_matched (rank 2), so it is the best
|
||||
// tier actually available below lsp_resolved.
|
||||
assert.Equal(t, graph.OriginASTInferred, sg.TierFiltered.MaxAvailableTier)
|
||||
}
|
||||
|
||||
// When some edges survive the filter, no caveat is attached — the surviving
|
||||
// rows are their own signal.
|
||||
func TestFilterByMinTier_NoCaveatWhenEdgesSurvive(t *testing.T) {
|
||||
sg := &SubGraph{Edges: []*graph.Edge{
|
||||
{From: "a", To: "t", Kind: graph.EdgeCalls, Origin: graph.OriginLSPResolved},
|
||||
{From: "b", To: "t", Kind: graph.EdgeCalls, Origin: graph.OriginTextMatched},
|
||||
}}
|
||||
sg.FilterByMinTier("lsp_resolved")
|
||||
|
||||
assert.Len(t, sg.Edges, 1)
|
||||
assert.Nil(t, sg.TierFiltered, "caveat only when the filter empties the visible set")
|
||||
}
|
||||
|
||||
// No min_tier is a no-op — no caveat, all edges kept.
|
||||
func TestFilterByMinTier_EmptyTierIsNoop(t *testing.T) {
|
||||
sg := &SubGraph{Edges: []*graph.Edge{
|
||||
{From: "a", To: "t", Kind: graph.EdgeCalls, Origin: graph.OriginTextMatched},
|
||||
}}
|
||||
sg.FilterByMinTier("")
|
||||
assert.Len(t, sg.Edges, 1)
|
||||
assert.Nil(t, sg.TierFiltered)
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// knownEdgeKinds is the set of edge kinds parseEdgeKinds accepts. It is
|
||||
// the queryable surface of internal/graph/edge.go — the kinds an agent
|
||||
// is likely to traverse on. Synthetic / internal kinds the graph emits
|
||||
// but no traversal tool should expose are intentionally omitted.
|
||||
var knownEdgeKinds = map[string]graph.EdgeKind{
|
||||
"imports": graph.EdgeImports,
|
||||
"defines": graph.EdgeDefines,
|
||||
"calls": graph.EdgeCalls,
|
||||
"instantiates": graph.EdgeInstantiates,
|
||||
"implements": graph.EdgeImplements,
|
||||
"extends": graph.EdgeExtends,
|
||||
"references": graph.EdgeReferences,
|
||||
"member_of": graph.EdgeMemberOf,
|
||||
"provides": graph.EdgeProvides,
|
||||
"consumes": graph.EdgeConsumes,
|
||||
"matches": graph.EdgeMatches,
|
||||
"annotated": graph.EdgeAnnotated,
|
||||
"tests": graph.EdgeTests,
|
||||
"reads": graph.EdgeReads,
|
||||
"writes": graph.EdgeWrites,
|
||||
"throws": graph.EdgeThrows,
|
||||
"returns": graph.EdgeReturns,
|
||||
"typed_as": graph.EdgeTypedAs,
|
||||
"captures": graph.EdgeCaptures,
|
||||
"spawns": graph.EdgeSpawns,
|
||||
"sends": graph.EdgeSends,
|
||||
"recvs": graph.EdgeRecvs,
|
||||
"queries": graph.EdgeQueries,
|
||||
"reads_config": graph.EdgeReadsConfig,
|
||||
"reads_env": graph.EdgeReadsEnv,
|
||||
"executes_process": graph.EdgeExecutesProcess,
|
||||
"accesses_field": graph.EdgeAccessesField,
|
||||
"emits": graph.EdgeEmits,
|
||||
"overrides": graph.EdgeOverrides,
|
||||
"depends_on": graph.EdgeDependsOn,
|
||||
"composes": graph.EdgeComposes,
|
||||
"produces_topic": graph.EdgeProducesTopic,
|
||||
"consumes_topic": graph.EdgeConsumesTopic,
|
||||
}
|
||||
|
||||
// KnownEdgeKinds returns the sorted list of edge-kind names that
|
||||
// parseEdgeKinds accepts. Used to build tool-description text so the
|
||||
// documented surface can never drift from the parser.
|
||||
func KnownEdgeKinds() []string {
|
||||
out := make([]string, 0, len(knownEdgeKinds))
|
||||
for k := range knownEdgeKinds {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ParseEdgeKindsCSV parses a comma-separated list of edge-kind names
|
||||
// into graph.EdgeKind values. Whitespace around each token is trimmed
|
||||
// and empty tokens are skipped, so "calls, references" and
|
||||
// "calls,,references" both parse. An empty (or all-empty) input returns
|
||||
// a nil slice with no error — callers treat nil as "default" or "every
|
||||
// kind" per their own semantics. An unrecognised token is a hard error
|
||||
// naming the offender. Shared by the walk_graph, graph_query, and nav
|
||||
// MCP tools so their accepted edge-kind surface can never diverge.
|
||||
func ParseEdgeKindsCSV(csv string) ([]graph.EdgeKind, error) {
|
||||
if strings.TrimSpace(csv) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var out []graph.EdgeKind
|
||||
seen := make(map[graph.EdgeKind]bool)
|
||||
for _, tok := range strings.Split(csv, ",") {
|
||||
tok = strings.TrimSpace(tok)
|
||||
if tok == "" {
|
||||
continue
|
||||
}
|
||||
kind, ok := knownEdgeKinds[strings.ToLower(tok)]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown edge kind %q (valid: %s)",
|
||||
tok, strings.Join(KnownEdgeKinds(), ", "))
|
||||
}
|
||||
if seen[kind] {
|
||||
continue
|
||||
}
|
||||
seen[kind] = true
|
||||
out = append(out, kind)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// walkTokenEstimate is the per-node contribution to the running encoded-
|
||||
// size estimate used by WalkBudgeted. The encoder emits one row per node
|
||||
// (id, kind, name, path, line, …) and roughly one row per edge; this
|
||||
// constant approximates the token cost of a node row plus its incident
|
||||
// edge row at the GCX wire format's density. It is deliberately
|
||||
// conservative — over-estimating stops the walk a little early, which is
|
||||
// the safe direction for a budget.
|
||||
const walkTokenEstimate = 28
|
||||
|
||||
// walkBudgetTokens converts a running byte estimate into tokens at the
|
||||
// ~3.5 bytes/token heuristic used elsewhere in the codebase.
|
||||
func walkBudgetTokens(bytesEstimate int) int {
|
||||
return bytesEstimate * 10 / 35
|
||||
}
|
||||
|
||||
// WalkBudgeted performs a token-budgeted breadth-first traversal from
|
||||
// startID. It generalises bfs: the caller picks the edge kinds and the
|
||||
// direction, and the walk stops appending nodes once the estimated
|
||||
// encoded size of the result would exceed opts.TokenBudget (rather than
|
||||
// on a fixed node count). opts.MaxDepth is a hard safety cap applied
|
||||
// regardless of the budget.
|
||||
//
|
||||
// The returned SubGraph carries BudgetHit (true when the token budget
|
||||
// stopped the walk) and StoppedAtDepth (the deepest BFS depth reached).
|
||||
// When opts.EdgeKinds is empty the walk follows every known edge kind;
|
||||
// combined with Direction "both" that is an undirected neighbourhood
|
||||
// expansion. Unresolved / external neighbours are skipped, and the
|
||||
// workspace/project scope in opts is enforced exactly as bfs does.
|
||||
func (e *Engine) WalkBudgeted(startID string, opts WalkOptions) *SubGraph {
|
||||
maxDepth := opts.MaxDepth
|
||||
if maxDepth <= 0 {
|
||||
maxDepth = 8
|
||||
}
|
||||
|
||||
direction := strings.ToLower(strings.TrimSpace(opts.Direction))
|
||||
if direction == "" {
|
||||
direction = "out"
|
||||
}
|
||||
both := direction == "both"
|
||||
forward := direction != "in"
|
||||
|
||||
// An empty kind set means "follow every known kind". Building the
|
||||
// set explicitly (rather than a bidir==nil sentinel like bfs) keeps
|
||||
// the direction and kind axes independent.
|
||||
allKinds := len(opts.EdgeKinds) == 0
|
||||
kindSet := make(map[graph.EdgeKind]bool, len(opts.EdgeKinds))
|
||||
for _, k := range opts.EdgeKinds {
|
||||
kindSet[k] = true
|
||||
}
|
||||
|
||||
visited := make(map[string]bool)
|
||||
var allNodes []*graph.Node
|
||||
var allEdges []*graph.Edge
|
||||
budgetHit := false
|
||||
stoppedAtDepth := 0
|
||||
|
||||
type item struct {
|
||||
id string
|
||||
depth int
|
||||
}
|
||||
|
||||
visited[startID] = true
|
||||
// byteEstimate tracks the running encoded size. The seed enters
|
||||
// only after the scope gate, so it is counted up front when kept.
|
||||
byteEstimate := 0
|
||||
if n := e.g.GetNode(startID); n != nil {
|
||||
if !opts.scopeAllows(n) {
|
||||
return &SubGraph{}
|
||||
}
|
||||
allNodes = append(allNodes, n)
|
||||
byteEstimate += walkTokenEstimate
|
||||
}
|
||||
queue := []item{{id: startID, depth: 0}}
|
||||
|
||||
for len(queue) > 0 {
|
||||
cur := queue[0]
|
||||
queue = queue[1:]
|
||||
if cur.depth > stoppedAtDepth {
|
||||
stoppedAtDepth = cur.depth
|
||||
}
|
||||
if cur.depth >= maxDepth {
|
||||
continue
|
||||
}
|
||||
|
||||
var edges []*graph.Edge
|
||||
if both {
|
||||
edges = append(e.g.GetOutEdges(cur.id), e.g.GetInEdges(cur.id)...)
|
||||
} else if forward {
|
||||
edges = e.g.GetOutEdges(cur.id)
|
||||
} else {
|
||||
edges = e.g.GetInEdges(cur.id)
|
||||
}
|
||||
|
||||
for _, edge := range edges {
|
||||
if !allKinds && !kindSet[edge.Kind] {
|
||||
continue
|
||||
}
|
||||
|
||||
var neighborID string
|
||||
if both {
|
||||
if edge.From == cur.id {
|
||||
neighborID = edge.To
|
||||
} else {
|
||||
neighborID = edge.From
|
||||
}
|
||||
} else if forward {
|
||||
if edge.From != cur.id {
|
||||
continue
|
||||
}
|
||||
neighborID = edge.To
|
||||
} else {
|
||||
if edge.To != cur.id {
|
||||
continue
|
||||
}
|
||||
neighborID = edge.From
|
||||
}
|
||||
|
||||
if graph.IsUnresolvedTarget(neighborID) ||
|
||||
strings.HasPrefix(neighborID, "external::") {
|
||||
continue
|
||||
}
|
||||
|
||||
n := e.g.GetNode(neighborID)
|
||||
if n == nil {
|
||||
continue
|
||||
}
|
||||
if !opts.scopeAllows(n) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Community gate: when the caller pins a CommunityID, a
|
||||
// neighbour with a *different* defined membership is dropped
|
||||
// along with the edge that reached it. A neighbour with no
|
||||
// membership (a structural node Leiden never partitioned)
|
||||
// passes — it was never in any community to be excluded
|
||||
// from. The filter is a no-op when CommunityID is empty or
|
||||
// NodeToComm was not supplied.
|
||||
if opts.CommunityID != "" && opts.NodeToComm != nil {
|
||||
if comm, ok := opts.NodeToComm[neighborID]; ok && comm != opts.CommunityID {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// The edge is part of the result regardless of whether its
|
||||
// target node is new — a cross-edge between two visited
|
||||
// nodes is still a real relationship.
|
||||
allEdges = append(allEdges, edge)
|
||||
|
||||
if visited[neighborID] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Token budget: stop appending nodes once the running
|
||||
// estimate would exceed the budget. Already-queued nodes
|
||||
// still drain so their edges are recorded, but no deeper
|
||||
// frontier is added.
|
||||
if opts.TokenBudget > 0 &&
|
||||
walkBudgetTokens(byteEstimate+walkTokenEstimate) > opts.TokenBudget {
|
||||
budgetHit = true
|
||||
continue
|
||||
}
|
||||
|
||||
visited[neighborID] = true
|
||||
allNodes = append(allNodes, n)
|
||||
byteEstimate += walkTokenEstimate
|
||||
queue = append(queue, item{id: neighborID, depth: cur.depth + 1})
|
||||
}
|
||||
}
|
||||
|
||||
return &SubGraph{
|
||||
Nodes: allNodes,
|
||||
Edges: allEdges,
|
||||
TotalNodes: len(allNodes),
|
||||
TotalEdges: len(allEdges),
|
||||
Truncated: budgetHit,
|
||||
BudgetHit: budgetHit,
|
||||
StoppedAtDepth: stoppedAtDepth,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
// The test graph's call chain is main -> Start -> Connect -> Ping.
|
||||
// These tests pin a community over a subset of those nodes and assert
|
||||
// the walk's community gate keeps in-community neighbours, drops
|
||||
// cross-community ones, and lets membership-less nodes through.
|
||||
|
||||
func TestWalkBudgeted_CommunityFilter_DropsCrossCommunity(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
// main + Start are in community "a"; Connect is in community "b".
|
||||
nodeToComm := map[string]string{
|
||||
"main.go::main": "a",
|
||||
"pkg/server.go::Start": "a",
|
||||
"pkg/db.go::Connect": "b",
|
||||
"pkg/db.go::Ping": "b",
|
||||
}
|
||||
|
||||
sg := e.WalkBudgeted("main.go::main", WalkOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeCalls},
|
||||
Direction: "out",
|
||||
CommunityID: "a",
|
||||
NodeToComm: nodeToComm,
|
||||
})
|
||||
ids := nodeIDs(sg.Nodes)
|
||||
assert.Contains(t, ids, "main.go::main")
|
||||
assert.Contains(t, ids, "pkg/server.go::Start")
|
||||
// Connect is in community "b" — dropped, and Ping behind it never
|
||||
// gets reached.
|
||||
assert.NotContains(t, ids, "pkg/db.go::Connect")
|
||||
assert.NotContains(t, ids, "pkg/db.go::Ping")
|
||||
}
|
||||
|
||||
func TestWalkBudgeted_CommunityFilter_KeepsInCommunity(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
// Whole chain in community "a".
|
||||
nodeToComm := map[string]string{
|
||||
"main.go::main": "a",
|
||||
"pkg/server.go::Start": "a",
|
||||
"pkg/db.go::Connect": "a",
|
||||
"pkg/db.go::Ping": "a",
|
||||
}
|
||||
|
||||
sg := e.WalkBudgeted("main.go::main", WalkOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeCalls},
|
||||
Direction: "out",
|
||||
CommunityID: "a",
|
||||
NodeToComm: nodeToComm,
|
||||
})
|
||||
ids := nodeIDs(sg.Nodes)
|
||||
assert.Contains(t, ids, "pkg/server.go::Start")
|
||||
assert.Contains(t, ids, "pkg/db.go::Connect")
|
||||
assert.Contains(t, ids, "pkg/db.go::Ping")
|
||||
}
|
||||
|
||||
func TestWalkBudgeted_CommunityFilter_StructuralNodePassthrough(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
// Only main + Start carry a membership. Connect / Ping have NO
|
||||
// entry — they are treated as membership-less structural nodes and
|
||||
// must pass the gate (an absent entry is not exclusion).
|
||||
nodeToComm := map[string]string{
|
||||
"main.go::main": "a",
|
||||
"pkg/server.go::Start": "a",
|
||||
}
|
||||
|
||||
sg := e.WalkBudgeted("main.go::main", WalkOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeCalls},
|
||||
Direction: "out",
|
||||
CommunityID: "a",
|
||||
NodeToComm: nodeToComm,
|
||||
})
|
||||
ids := nodeIDs(sg.Nodes)
|
||||
assert.Contains(t, ids, "pkg/server.go::Start")
|
||||
// Connect + Ping have no membership -> not excluded.
|
||||
assert.Contains(t, ids, "pkg/db.go::Connect")
|
||||
assert.Contains(t, ids, "pkg/db.go::Ping")
|
||||
}
|
||||
|
||||
func TestWalkBudgeted_CommunityFilter_NilMapNoOp(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
// CommunityID set but NodeToComm nil — the filter must no-op (the
|
||||
// production wiring passes nil when analysis hasn't run).
|
||||
sg := e.WalkBudgeted("main.go::main", WalkOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeCalls},
|
||||
Direction: "out",
|
||||
CommunityID: "a",
|
||||
NodeToComm: nil,
|
||||
})
|
||||
ids := nodeIDs(sg.Nodes)
|
||||
assert.Contains(t, ids, "pkg/server.go::Start")
|
||||
assert.Contains(t, ids, "pkg/db.go::Connect")
|
||||
assert.Contains(t, ids, "pkg/db.go::Ping")
|
||||
}
|
||||
|
||||
func TestWalkBudgeted_CommunityFilter_EmptyIDNoOp(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
// Empty CommunityID disables the gate even when NodeToComm is set.
|
||||
sg := e.WalkBudgeted("main.go::main", WalkOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeCalls},
|
||||
Direction: "out",
|
||||
CommunityID: "",
|
||||
NodeToComm: map[string]string{"pkg/db.go::Connect": "b"},
|
||||
})
|
||||
assert.Contains(t, nodeIDs(sg.Nodes), "pkg/db.go::Connect")
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func TestParseEdgeKindsCSV(t *testing.T) {
|
||||
t.Run("single", func(t *testing.T) {
|
||||
got, err := ParseEdgeKindsCSV("calls")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []graph.EdgeKind{graph.EdgeCalls}, got)
|
||||
})
|
||||
|
||||
t.Run("multiple with whitespace", func(t *testing.T) {
|
||||
got, err := ParseEdgeKindsCSV(" calls , references ")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences}, got)
|
||||
})
|
||||
|
||||
t.Run("empty tokens skipped", func(t *testing.T) {
|
||||
got, err := ParseEdgeKindsCSV("calls,,implements")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []graph.EdgeKind{graph.EdgeCalls, graph.EdgeImplements}, got)
|
||||
})
|
||||
|
||||
t.Run("dedup", func(t *testing.T) {
|
||||
got, err := ParseEdgeKindsCSV("calls,calls,references")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences}, got)
|
||||
})
|
||||
|
||||
t.Run("empty input is nil no error", func(t *testing.T) {
|
||||
got, err := ParseEdgeKindsCSV(" ")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, got)
|
||||
})
|
||||
|
||||
t.Run("case insensitive", func(t *testing.T) {
|
||||
got, err := ParseEdgeKindsCSV("CALLS")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []graph.EdgeKind{graph.EdgeCalls}, got)
|
||||
})
|
||||
|
||||
t.Run("unknown kind errors", func(t *testing.T) {
|
||||
_, err := ParseEdgeKindsCSV("calls,bogus")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "bogus")
|
||||
})
|
||||
}
|
||||
|
||||
func TestWalkBudgeted_Direction(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
t.Run("out follows callees", func(t *testing.T) {
|
||||
sg := e.WalkBudgeted("main.go::main", WalkOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeCalls},
|
||||
Direction: "out",
|
||||
})
|
||||
ids := nodeIDs(sg.Nodes)
|
||||
assert.Contains(t, ids, "main.go::main")
|
||||
assert.Contains(t, ids, "pkg/server.go::Start")
|
||||
assert.Contains(t, ids, "pkg/db.go::Connect")
|
||||
assert.Contains(t, ids, "pkg/db.go::Ping")
|
||||
})
|
||||
|
||||
t.Run("in follows callers", func(t *testing.T) {
|
||||
sg := e.WalkBudgeted("pkg/db.go::Ping", WalkOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeCalls},
|
||||
Direction: "in",
|
||||
})
|
||||
ids := nodeIDs(sg.Nodes)
|
||||
assert.Contains(t, ids, "pkg/db.go::Connect")
|
||||
assert.Contains(t, ids, "pkg/server.go::Start")
|
||||
assert.Contains(t, ids, "main.go::main")
|
||||
// Forward-only callees of Ping must not appear.
|
||||
assert.NotContains(t, ids, "pkg/db.go::DBImpl")
|
||||
})
|
||||
|
||||
t.Run("both is undirected", func(t *testing.T) {
|
||||
sg := e.WalkBudgeted("pkg/server.go::Start", WalkOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeCalls},
|
||||
Direction: "both",
|
||||
})
|
||||
ids := nodeIDs(sg.Nodes)
|
||||
// Reaches both the caller (main) and the callee chain.
|
||||
assert.Contains(t, ids, "main.go::main")
|
||||
assert.Contains(t, ids, "pkg/db.go::Connect")
|
||||
assert.Contains(t, ids, "pkg/db.go::Ping")
|
||||
})
|
||||
|
||||
t.Run("empty direction defaults to out", func(t *testing.T) {
|
||||
sg := e.WalkBudgeted("main.go::main", WalkOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeCalls},
|
||||
})
|
||||
ids := nodeIDs(sg.Nodes)
|
||||
assert.Contains(t, ids, "pkg/server.go::Start")
|
||||
})
|
||||
}
|
||||
|
||||
func TestWalkBudgeted_EdgeKindFiltering(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
t.Run("calls only ignores references", func(t *testing.T) {
|
||||
sg := e.WalkBudgeted("main.go::main", WalkOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeCalls},
|
||||
Direction: "out",
|
||||
})
|
||||
ids := nodeIDs(sg.Nodes)
|
||||
// main references DBImpl, but references is not in the kind set.
|
||||
assert.NotContains(t, ids, "pkg/db.go::DBImpl")
|
||||
})
|
||||
|
||||
t.Run("references reaches DBImpl", func(t *testing.T) {
|
||||
sg := e.WalkBudgeted("main.go::main", WalkOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeReferences},
|
||||
Direction: "out",
|
||||
})
|
||||
ids := nodeIDs(sg.Nodes)
|
||||
assert.Contains(t, ids, "pkg/db.go::DBImpl")
|
||||
})
|
||||
|
||||
t.Run("multiple kinds reach both", func(t *testing.T) {
|
||||
sg := e.WalkBudgeted("main.go::main", WalkOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences},
|
||||
Direction: "out",
|
||||
})
|
||||
ids := nodeIDs(sg.Nodes)
|
||||
assert.Contains(t, ids, "pkg/server.go::Start")
|
||||
assert.Contains(t, ids, "pkg/db.go::DBImpl")
|
||||
})
|
||||
|
||||
t.Run("empty kinds follows every edge", func(t *testing.T) {
|
||||
sg := e.WalkBudgeted("main.go::main", WalkOptions{
|
||||
Direction: "out",
|
||||
})
|
||||
ids := nodeIDs(sg.Nodes)
|
||||
assert.Contains(t, ids, "pkg/server.go::Start")
|
||||
assert.Contains(t, ids, "pkg/db.go::DBImpl")
|
||||
})
|
||||
}
|
||||
|
||||
func TestWalkBudgeted_DepthCap(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
// Depth 1 from main reaches Start but not Connect (depth 2).
|
||||
sg := e.WalkBudgeted("main.go::main", WalkOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeCalls},
|
||||
Direction: "out",
|
||||
MaxDepth: 1,
|
||||
})
|
||||
ids := nodeIDs(sg.Nodes)
|
||||
assert.Contains(t, ids, "pkg/server.go::Start")
|
||||
assert.NotContains(t, ids, "pkg/db.go::Connect")
|
||||
assert.Equal(t, 1, sg.StoppedAtDepth)
|
||||
assert.False(t, sg.BudgetHit)
|
||||
}
|
||||
|
||||
func TestWalkBudgeted_TokenBudget(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
|
||||
t.Run("tight budget stops early", func(t *testing.T) {
|
||||
// A budget that admits only the seed plus a node or two.
|
||||
sg := e.WalkBudgeted("main.go::main", WalkOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeCalls},
|
||||
Direction: "out",
|
||||
TokenBudget: 10,
|
||||
})
|
||||
assert.True(t, sg.BudgetHit)
|
||||
assert.True(t, sg.Truncated)
|
||||
// The whole 4-node call chain must not have been walked.
|
||||
assert.Less(t, len(sg.Nodes), 4)
|
||||
})
|
||||
|
||||
t.Run("generous budget completes", func(t *testing.T) {
|
||||
sg := e.WalkBudgeted("main.go::main", WalkOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeCalls},
|
||||
Direction: "out",
|
||||
TokenBudget: 100000,
|
||||
})
|
||||
assert.False(t, sg.BudgetHit)
|
||||
ids := nodeIDs(sg.Nodes)
|
||||
assert.Contains(t, ids, "pkg/db.go::Ping")
|
||||
})
|
||||
}
|
||||
|
||||
func TestWalkBudgeted_MissingSeed(t *testing.T) {
|
||||
e := NewEngine(buildTestGraph())
|
||||
sg := e.WalkBudgeted("does/not::Exist", WalkOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeCalls},
|
||||
})
|
||||
assert.Empty(t, sg.Nodes)
|
||||
assert.Empty(t, sg.Edges)
|
||||
}
|
||||
|
||||
func TestWalkBudgeted_WorkspaceScope(t *testing.T) {
|
||||
g := buildTestGraph()
|
||||
// Tag every node in the test graph with workspace "main", then add
|
||||
// a foreign node in workspace "other" reachable by a call edge.
|
||||
for _, n := range g.AllNodes() {
|
||||
n.WorkspaceID = "main"
|
||||
}
|
||||
g.AddNode(&graph.Node{
|
||||
ID: "other/x.go::Foreign", Kind: graph.KindFunction, Name: "Foreign",
|
||||
FilePath: "other/x.go", Language: "go", WorkspaceID: "other",
|
||||
})
|
||||
g.AddEdge(&graph.Edge{
|
||||
From: "pkg/db.go::Ping", To: "other/x.go::Foreign",
|
||||
Kind: graph.EdgeCalls, FilePath: "pkg/db.go", Line: 20,
|
||||
})
|
||||
e := NewEngine(g)
|
||||
|
||||
scoped := e.WalkBudgeted("main.go::main", WalkOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeCalls},
|
||||
Direction: "out",
|
||||
WorkspaceID: "main",
|
||||
})
|
||||
assert.NotContains(t, nodeIDs(scoped.Nodes), "other/x.go::Foreign")
|
||||
|
||||
unscoped := e.WalkBudgeted("main.go::main", WalkOptions{
|
||||
EdgeKinds: []graph.EdgeKind{graph.EdgeCalls},
|
||||
Direction: "out",
|
||||
})
|
||||
assert.Contains(t, nodeIDs(unscoped.Nodes), "other/x.go::Foreign")
|
||||
}
|
||||
Reference in New Issue
Block a user