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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:33:42 +08:00
commit a06f331eb8
3186 changed files with 689843 additions and 0 deletions
+91
View File
@@ -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()
}
}
+121
View File
@@ -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)
}
}
+63
View File
@@ -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)
}
+488
View File
@@ -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 ""
}
+228
View File
@@ -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)
}
+218
View File
@@ -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,
}
}
+165
View File
@@ -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)
}
+192
View File
@@ -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
}
+292
View File
@@ -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)
}
}
+66
View File
@@ -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]++
}
}
+94
View File
@@ -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"])
}
+81
View File
@@ -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
+44
View File
@@ -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)
}
}
+304
View File
@@ -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)")
}
+47
View File
@@ -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))
}
+166
View File
@@ -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))
})
}
}
+28
View File
@@ -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))
}
}
+677
View File
@@ -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)
}
}
+97
View File
@@ -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)
}
+52
View File
@@ -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)
}
+274
View File
@@ -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,
}
}
+117
View File
@@ -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")
}
+229
View File
@@ -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")
}