Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

275 lines
8.5 KiB
Go

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,
}
}