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

4010 lines
120 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package graph
import (
"iter"
"math/bits"
"os"
"runtime"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
)
// GraphStats holds summary counts of the graph contents.
type GraphStats struct {
TotalNodes int `json:"total_nodes"`
TotalEdges int `json:"total_edges"`
ByKind map[string]int `json:"by_kind"`
ByLanguage map[string]int `json:"by_language"`
}
// The graph's write fan-out is sharded: each shard owns a disjoint
// slice of node IDs (by FNV hash) and its own RWMutex, so parallel
// indexers writing distinct nodes don't contend for a single lock. The
// shard count is chosen once per Graph at construction (see
// defaultShardCount) rather than fixed at compile time, so many-core
// machines get more fan-out while small machines keep the historical
// count. It is always a power of two: shardIdx masks an ID's hash with
// count-1, which equals the modulo only for powers of two.
//
// Trade-off: more shards cut write contention but make cross-shard
// operations (AddEdge across shards, plus exhaustive reads that walk
// every shard) take more locks. To avoid deadlock we always acquire
// shards in ascending index order.
const (
// shardCountEnv pins the shard fan-out for a new graph, overriding
// the CPU-derived default. Useful for benchmarks and tests. The
// value is coerced to a power of two within [minShards, maxShards].
shardCountEnv = "GORTEX_GRAPH_SHARDS"
// minShards / maxShards bound every shard count — derived or
// explicitly overridden — to a sane, power-of-two range. minShards
// must stay >= 1 so the shardMask (count-1) is a valid mask.
minShards = 1
maxShards = 512
// derivedShardFloor / derivedShardCeiling bound the CPU-derived
// count. The floor preserves the historical fan-out of 16 on typical
// (<= 16 logical CPU) machines, so sharded results stay
// byte-identical there; the ceiling caps per-graph shard overhead on
// many-core boxes.
derivedShardFloor = 16
derivedShardCeiling = 256
)
// edgeKey is the logical identity of an edge — two edges with the same
// key are considered the same edge and dedup to one entry in the
// adjacency lists. Line is part of the key because a caller can hold
// the same target reference at two different call sites and both are
// real call-graph edges (see `foo(); foo();` in the same function).
//
// Both From and To are in the key even though the adjacency lists are
// keyed by one endpoint each — the other endpoint varies within an
// inEdges[to] slice (many From → same To) and within an outEdges[from]
// slice the inverse varies. Without both, property-test graphs with
// several callers to the same callee and no FilePath/Line distinction
// would dedup down to one entry and lose cross-caller traversal.
type edgeKey struct {
From string
To string
Kind EdgeKind
FilePath string
Line int
}
func keyOf(e *Edge) edgeKey {
return edgeKey{From: e.From, To: e.To, Kind: e.Kind, FilePath: e.FilePath, Line: e.Line}
}
// edgeHash is the stored form of an edge's logical identity. A full
// edgeKey carries four strings plus an int — 72 bytes as a map key or
// sidecar element. Collapsing it to a 128-bit hash shrinks the
// outEdgeIdx / inEdgeIdx maps and the outEdgeKeys / inEdgeKeys sidecars
// by ~4.5×, the single largest line item in cold-warmup resident
// memory. 128 bits keeps a collision between any two distinct edges in
// a graph of realistic size out of reach (~1e-25 at 10M edges), so the
// indexes stay unique-key maps — the dedup and swap-with-last logic is
// byte-for-byte the same as the edgeKey version, only the key type
// changes.
type edgeHash struct{ lo, hi uint64 }
const fnvOffset64 = 1469598103934665603
const fnvPrime64 = 1099511628211
// hashEdgeKey computes the 128-bit identity hash of an edge key. Each
// 64-bit half is an independent FNV-1a pass: distinct seeds plus a
// reversed field order decorrelate the halves so the pair behaves as a
// 128-bit value rather than two views of the same 64-bit hash.
func hashEdgeKey(k edgeKey) edgeHash {
return edgeHash{
lo: fnv1aEdge(fnvOffset64, k, false),
hi: fnv1aEdge(0x9e3779b97f4a7c15, k, true),
}
}
// fnv1aEdge folds every edgeKey field into one FNV-1a 64-bit hash. A
// 0xff separator after each string keeps "ab"+"c" distinct from
// "a"+"bc"; reversed==true walks the string fields back-to-front.
func fnv1aEdge(seed uint64, k edgeKey, reversed bool) uint64 {
h := seed
mix := func(s string) {
for i := 0; i < len(s); i++ {
h ^= uint64(s[i])
h *= fnvPrime64
}
h ^= 0xff
h *= fnvPrime64
}
if reversed {
mix(k.FilePath)
mix(string(k.Kind))
mix(k.To)
mix(k.From)
} else {
mix(k.From)
mix(k.To)
mix(string(k.Kind))
mix(k.FilePath)
}
h ^= uint64(k.Line)
h *= fnvPrime64
return h
}
// hashEdgeIdentity computes the provenance-bearing identity hash of an
// edge: the logical key folded together with Origin. It reuses the
// edgeKey FNV machinery — each 64-bit half is hashEdgeKey's
// corresponding half with the Origin string mixed in as a sixth field
// (same 0xff-separated FNV-1a fold, same reversed-order decorrelation).
//
// This is deliberately distinct from hashEdgeKey: the dedup index
// (outEdgeIdx / inEdgeIdx) stays keyed on the Origin-free logical key
// so an Origin upgrade replaces the slot in place rather than creating
// a parallel edge. The identity hash is the separate value Edge.
// IdentityHash exposes so a provenance change is observable as a
// distinct identity.
func hashEdgeIdentity(k edgeKey, origin string) edgeHash {
return edgeHash{
lo: mixOriginInto(fnv1aEdge(fnvOffset64, k, false), origin),
hi: mixOriginInto(fnv1aEdge(0x9e3779b97f4a7c15, k, true), origin),
}
}
// mixOriginInto folds the Origin string into an already-computed
// fnv1aEdge hash with the same 0xff-separated FNV-1a step the key
// fields use, so Origin is a first-class sixth field of the identity
// rather than a bolt-on.
func mixOriginInto(h uint64, origin string) uint64 {
for i := 0; i < len(origin); i++ {
h ^= uint64(origin[i])
h *= fnvPrime64
}
h ^= 0xff
h *= fnvPrime64
return h
}
// shard is a fragment of the graph's data. Each shard holds the node
// metadata for the subset of IDs that hash to it, plus the outgoing
// edges whose source ID is in this shard and the incoming edges whose
// target ID is in this shard. Secondary indexes (byName, byFile, etc.)
// inside a shard only contain nodes owned by that shard; aggregate
// queries iterate every shard and concatenate.
//
// Each slice-valued secondary index has a paired "Idx" sidecar that
// maps an entry's identity to its position in the slice. Two invariants
// the sidecars enforce:
//
// 1. Idempotent writes. AddNode/AddEdge consult the sidecar; duplicate
// inserts replace the pointer in place instead of appending.
// Without this, restart paths that load a snapshot and then re-run
// IndexCtx (which doesn't evict first) silently double every edge
// and every secondary-index entry. Fixes bug B1 at the write layer,
// regardless of which call site forgets to evict first.
// 2. O(1) removal via swap-with-last. Removing a node from a
// byRepo slice of 50k entries with filterEdge-style scanning was
// O(N); now it's O(1) — flip the last element into the removed
// slot and shrink the slice by one. Iteration order changes after a
// removal, but callers that require stable ordering (snapshot
// encoding) sort before emitting anyway.
type shard struct {
mu sync.RWMutex
nodes map[string]*Node
outEdges map[string][]*Edge
inEdges map[string][]*Edge
byFile map[string][]*Node
byName map[string][]*Node
byQual map[string]*Node
byRepo map[string][]*Node // repoPrefix → nodes owned by this shard
// Sidecar position indexes — see comment on shard. Reads are
// unchanged (callers still iterate the slices); only writes
// consult these maps.
byFileIdx map[string]map[string]int // filePath → id → position
byNameIdx map[string]map[string]int // name → id → position
byRepoIdx map[string]map[string]int // repoPrefix → id → position
outEdgeIdx map[string]map[edgeHash]int // fromID → hash → position
inEdgeIdx map[string]map[edgeHash]int // toID → hash → position
// Reverse-index slices that remember each entry's insertion-time
// edgeHash, parallel to outEdges / inEdges. Required because keyOf
// is computed from live Edge fields (To, From, ...) — and the
// resolver mutates Edge.To when retargeting an unresolved edge.
// During swap-with-last in removeEdgeFromBucket, computing
// keyOf(swapped) on a swapped element whose To was just mutated
// produced a different key than the one it was originally indexed
// under, leaving a stale sidecar position pointing past the slice
// (panic: "index out of range [42] with length 41" in
// addEdgeToBucket on the next insert that collided with the stale
// key). Storing the original hash here makes the swap update
// independent of live Edge state.
outEdgeKeys map[string][]edgeHash // fromID → position → hash
inEdgeKeys map[string][]edgeHash // toID → position → hash
// Running per-repo memory totals maintained under the shard's
// existing write lock. Reading them out of RepoMemoryEstimate is
// O(shard count) instead of the O(repo nodes + total edges) walk
// the function used to do — and AllRepoMemoryEstimates collapses
// R repo-by-repo queries into one O(R · S) sum. Edges are charged
// to the source node's RepoPrefix; the shard owning the source
// edge bucket owns the counter, so accounting matches what the
// old AllEdges walk attributed.
repoNodeBytes map[string]uint64
repoNodeCount map[string]int
repoEdgeBytes map[string]uint64
repoEdgeCount map[string]int
}
func newShard() *shard {
return &shard{
nodes: make(map[string]*Node),
outEdges: make(map[string][]*Edge),
inEdges: make(map[string][]*Edge),
byFile: make(map[string][]*Node),
byName: make(map[string][]*Node),
byQual: make(map[string]*Node),
byRepo: make(map[string][]*Node),
byFileIdx: make(map[string]map[string]int),
byNameIdx: make(map[string]map[string]int),
byRepoIdx: make(map[string]map[string]int),
outEdgeIdx: make(map[string]map[edgeHash]int),
inEdgeIdx: make(map[string]map[edgeHash]int),
outEdgeKeys: make(map[string][]edgeHash),
inEdgeKeys: make(map[string][]edgeHash),
repoNodeBytes: make(map[string]uint64),
repoNodeCount: make(map[string]int),
repoEdgeBytes: make(map[string]uint64),
repoEdgeCount: make(map[string]int),
}
}
// repoNodeAdd registers a node under its RepoPrefix bucket. Caller
// must hold s.mu.Lock. No-op for nodes without a prefix (single-repo
// mode and synthetic nodes that intentionally skip byRepo accounting).
func (s *shard) repoNodeAdd(n *Node) {
if n == nil || n.RepoPrefix == "" {
return
}
s.repoNodeBytes[n.RepoPrefix] += nodeBytes(n)
s.repoNodeCount[n.RepoPrefix]++
}
// repoNodeRemove undoes repoNodeAdd. Clamps to zero on underflow so a
// stale counter cannot wrap a uint64.
func (s *shard) repoNodeRemove(n *Node) {
if n == nil || n.RepoPrefix == "" {
return
}
b := nodeBytes(n)
if cur := s.repoNodeBytes[n.RepoPrefix]; cur >= b {
s.repoNodeBytes[n.RepoPrefix] = cur - b
} else {
s.repoNodeBytes[n.RepoPrefix] = 0
}
if s.repoNodeCount[n.RepoPrefix] > 0 {
s.repoNodeCount[n.RepoPrefix]--
}
if s.repoNodeBytes[n.RepoPrefix] == 0 && s.repoNodeCount[n.RepoPrefix] == 0 {
delete(s.repoNodeBytes, n.RepoPrefix)
delete(s.repoNodeCount, n.RepoPrefix)
}
}
// repoEdgeAdd attributes an edge to its source repo. repoPrefix is the
// source node's RepoPrefix as resolved at the time the edge is
// inserted; storing it here means a later swap of Edge.To (ReindexEdge)
// doesn't have to walk the source-node lookup again.
func (s *shard) repoEdgeAdd(repoPrefix string, e *Edge) {
if repoPrefix == "" || e == nil {
return
}
s.repoEdgeBytes[repoPrefix] += edgeBytes(e)
s.repoEdgeCount[repoPrefix]++
}
// repoEdgeRemove undoes repoEdgeAdd. Same clamp-to-zero discipline as
// repoNodeRemove.
func (s *shard) repoEdgeRemove(repoPrefix string, e *Edge) {
if repoPrefix == "" || e == nil {
return
}
b := edgeBytes(e)
if cur := s.repoEdgeBytes[repoPrefix]; cur >= b {
s.repoEdgeBytes[repoPrefix] = cur - b
} else {
s.repoEdgeBytes[repoPrefix] = 0
}
if s.repoEdgeCount[repoPrefix] > 0 {
s.repoEdgeCount[repoPrefix]--
}
if s.repoEdgeBytes[repoPrefix] == 0 && s.repoEdgeCount[repoPrefix] == 0 {
delete(s.repoEdgeBytes, repoPrefix)
delete(s.repoEdgeCount, repoPrefix)
}
}
// addNodeToBucket appends n to bucket[key] unless an entry with that id
// is already present, in which case the existing slot is overwritten
// with the new pointer. Returns the position of the entry.
func addNodeToBucket(bucket map[string][]*Node, idx map[string]map[string]int, key, id string, n *Node) {
if inner, ok := idx[key]; ok {
if pos, exists := inner[id]; exists {
bucket[key][pos] = n
return
}
}
pos := len(bucket[key])
bucket[key] = append(bucket[key], n)
inner, ok := idx[key]
if !ok {
inner = make(map[string]int)
idx[key] = inner
}
inner[id] = pos
}
// removeNodeFromBucket swap-removes the entry with id from bucket[key],
// updating the sidecar position of the swapped-in element. No-op when
// the entry is absent. Cleans up the bucket + sidecar when the last
// entry for key leaves.
func removeNodeFromBucket(bucket map[string][]*Node, idx map[string]map[string]int, key, id string) {
inner, ok := idx[key]
if !ok {
return
}
pos, exists := inner[id]
if !exists {
return
}
slice := bucket[key]
last := len(slice) - 1
if pos != last {
swapped := slice[last]
slice[pos] = swapped
inner[swapped.ID] = pos
}
slice = slice[:last]
delete(inner, id)
if len(inner) == 0 {
delete(idx, key)
delete(bucket, key)
} else {
bucket[key] = slice
}
}
// addEdgeToBucket appends e to bucket[key] unless an entry with the
// same logical identity (edgeKey) is already there, in which case the
// existing slot is overwritten. Reports whether this was a new insert
// and, on an in-place replace, whether the replacement carried a
// different Origin than the edge it displaced.
//
// keys is the parallel slice that remembers each slot's insertion-time
// edgeKey so removeEdgeFromBucket can update sidecars without
// recomputing keyOf on a possibly-mutated swapped Edge.
//
// originChanged surfaces the resolver path where an edge is re-added
// (via AddEdge) with upgraded provenance rather than mutated in place:
// the logical key still matches, so the slot is replaced, but the
// edge's identity has changed. AddEdge funnels this into the
// graph-level identity-revision counter so that re-add path is as
// observable as an explicit SetEdgeProvenance call.
func addEdgeToBucket(bucket map[string][]*Edge, keys map[string][]edgeHash, idx map[string]map[edgeHash]int, key string, e *Edge) (inserted, originChanged bool) {
h := hashEdgeKey(keyOf(e))
if inner, ok := idx[key]; ok {
if pos, exists := inner[h]; exists {
old := bucket[key][pos]
originChanged = old != nil && old != e && old.Origin != e.Origin
bucket[key][pos] = e
// keys[key][pos] already equals h — same logical identity.
return false, originChanged
}
}
pos := len(bucket[key])
bucket[key] = append(bucket[key], e)
keys[key] = append(keys[key], h)
inner, ok := idx[key]
if !ok {
inner = make(map[edgeHash]int)
idx[key] = inner
}
inner[h] = pos
return true, false
}
// removeEdgeFromBucket removes the entry with key k from bucket[key]
// using swap-with-last, maintaining the sidecar. No-op when absent.
func removeEdgeFromBucket(bucket map[string][]*Edge, keys map[string][]edgeHash, idx map[string]map[edgeHash]int, key string, k edgeHash) bool {
inner, ok := idx[key]
if !ok {
return false
}
pos, exists := inner[k]
if !exists {
return false
}
slice := bucket[key]
keySlice := keys[key]
last := len(slice) - 1
if pos != last {
slice[pos] = slice[last]
// Use the swapped slot's STORED insertion-time hash, not
// hashEdgeKey(keyOf(swapped)). The Edge's To may have been
// mutated by the resolver between insertion and now, in which
// case keyOf would yield a different hash than the sidecar
// entry that actually points at this slot — leaking a stale
// "last" position that later panics in addEdgeToBucket.
swappedKey := keySlice[last]
keySlice[pos] = swappedKey
inner[swappedKey] = pos
}
slice = slice[:last]
keySlice = keySlice[:last]
delete(inner, k)
if len(inner) == 0 {
delete(idx, key)
delete(bucket, key)
delete(keys, key)
} else {
bucket[key] = slice
keys[key] = keySlice
}
return true
}
// Graph is a thread-safe in-memory knowledge graph. Internally sharded
// by node-ID hash so writers touching disjoint IDs run in parallel.
//
// resolveMu is a graph-wide lock shared by every resolver.Resolver
// constructed against this Graph. Per-shard locks protect individual
// node/edge writes, but resolution phases (ResolveAll, ResolveFile)
// iterate every shard and mutate edge targets in place — two
// resolvers racing on the same edge produced the data race that
// MultiIndexer.indexMultiRepo triggered when its per-repo goroutines
// each created their own Resolver. Routing every resolver through
// this single mutex serialises those phases without blocking
// ordinary shard-scoped reads and writes.
type Graph struct {
// shards holds the lock-sharded node/edge maps. Its length is
// shardCount (a power of two); shardMask is shardCount-1, precomputed
// so the hot-path shardIdx masks with a single AND instead of a
// modulo.
shards []*shard
shardCount int
shardMask uint32
resolveMu sync.Mutex
// edgeIdentityRevisions counts how many times an in-graph edge's
// provenance-bearing identity changed — i.e. its Origin was
// upgraded or reverted while its logical (From,To,Kind,FilePath,
// Line) key stayed fixed. Each such change is conceptually a
// retirement of the old identity and creation of a new one. Both
// sanctioned mutation paths feed this counter: SetEdgeProvenance
// (an explicit in-place change) and addEdgeToBucket's in-place
// replace branch (a re-add of the same logical edge carrying an
// upgraded Origin). The count is the tamper-evidence surface:
// provenance cannot churn without it moving.
edgeIdentityRevisions atomic.Int64
// edgeMutGen bumps whenever the AllEdges output would change —
// new edge inserted, existing edge removed, or an edge's
// canonical key changed via ReindexEdge. Origin-only updates
// (counted by edgeIdentityRevisions) do not bump this because the
// slice content is unaffected. AllEdges uses the counter as a
// cache-validity tag so repeated post-resolve analysis walks
// share one materialised slice instead of each rebuilding the
// 4 M-edge snapshot.
edgeMutGen atomic.Uint64
allEdgesCacheMu sync.Mutex
allEdgesCache []*Edge
allEdgesCacheGen uint64
// cloneShingles is the in-memory implementation of the
// CloneShingle* capability: per-symbol MinHash shingle sets keyed by
// node id, alongside the repo prefix that owns each row so per-repo
// reseeds isolate correctly. Guarded by cloneShinglesMu. Slices are
// deep-copied on set and on read so callers can't mutate the stored
// state. The on-disk backend persists the same shape; the in-memory
// store keeps it live so the conformance suite exercises both.
cloneShinglesMu sync.Mutex
cloneShingles map[string]cloneShingleEntry
// churnEnrich is the in-memory churn-enrichment sidecar (change A).
churnEnrichMu sync.Mutex
churnEnrich map[string]ChurnEnrichment
// coverageEnrich is the in-memory coverage-enrichment sidecar.
coverageEnrichMu sync.Mutex
coverageEnrich map[string]CoverageEnrichment
// releaseEnrich is the in-memory release-enrichment sidecar.
releaseEnrichMu sync.Mutex
releaseEnrich map[string]ReleaseEnrichment
// blameEnrich is the in-memory blame-enrichment sidecar.
blameEnrichMu sync.Mutex
blameEnrich map[string]BlameEnrichment
// constValues is the in-memory implementation of the ConstantValue*
// capability: a KindConstant node's literal value keyed by node id,
// alongside its owning file (for file-scoped eviction) and repo
// prefix. Guarded by constValuesMu.
constValuesMu sync.Mutex
constValues map[string]constValueEntry
// fileMetas holds per-file metadata rows keyed by repoPrefix -> filePath
// (the files sidecar feeding index_health). Guarded by fileMetasMu.
fileMetasMu sync.Mutex
fileMetas map[string]map[string]FileMetaRow
}
// cloneShingleEntry is one in-memory clone_shingles row: the owning
// repo prefix plus the (already deep-copied) shingle set.
type cloneShingleEntry struct {
repoPrefix string
shingles []uint64
}
// constValueEntry is one in-memory constant_values row: the owning repo
// prefix and file (for file-scoped eviction) plus the literal value.
type constValueEntry struct {
repoPrefix string
filePath string
value string
}
// Compile-time assertions that the in-memory *Graph satisfies the
// optional per-symbol clone-shingle persistence capabilities, so the
// conformance suite exercises the same code path against both backends.
var (
_ CloneShingleWriter = (*Graph)(nil)
_ CloneShingleReader = (*Graph)(nil)
_ ChurnEnrichmentWriter = (*Graph)(nil)
_ ChurnEnrichmentReader = (*Graph)(nil)
_ CoverageEnrichmentWriter = (*Graph)(nil)
_ CoverageEnrichmentReader = (*Graph)(nil)
_ ReleaseEnrichmentWriter = (*Graph)(nil)
_ ReleaseEnrichmentReader = (*Graph)(nil)
_ BlameEnrichmentWriter = (*Graph)(nil)
_ BlameEnrichmentReader = (*Graph)(nil)
_ ReleaseEnrichmentWriter = (*Graph)(nil)
_ ReleaseEnrichmentReader = (*Graph)(nil)
_ ConstantValueWriter = (*Graph)(nil)
_ ConstantValueReader = (*Graph)(nil)
)
// New creates an empty graph with a shard fan-out derived from the host
// (see defaultShardCount).
func New() *Graph {
return newWithShardCount(defaultShardCount())
}
// newWithShardCount creates an empty graph with an explicit shard
// fan-out. The count is coerced to a power of two within
// [minShards, maxShards] so the shardMask (count-1) modulo trick stays
// exact regardless of what the caller asks for. Used by New and by
// benchmarks/tests that pin a specific fan-out.
func newWithShardCount(count int) *Graph {
count = coerceShardCount(count)
g := &Graph{
shards: make([]*shard, count),
shardCount: count,
shardMask: uint32(count - 1),
}
for i := range g.shards {
g.shards[i] = newShard()
}
return g
}
// defaultShardCount picks the shard fan-out for a new graph. An explicit
// GORTEX_GRAPH_SHARDS override wins (coerced to a power of two within
// [minShards, maxShards]); otherwise the count derives from
// runtime.NumCPU(), rounded up to the next power of two and clamped to
// [derivedShardFloor, derivedShardCeiling].
func defaultShardCount() int {
if v := strings.TrimSpace(os.Getenv(shardCountEnv)); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return coerceShardCount(n)
}
}
n := nextPow2(runtime.NumCPU())
if n < derivedShardFloor {
n = derivedShardFloor
}
if n > derivedShardCeiling {
n = derivedShardCeiling
}
return n
}
// coerceShardCount rounds n up to the nearest power of two and clamps it
// to [minShards, maxShards]. Both bounds are powers of two, so the
// result is always a valid power-of-two shard count — the invariant the
// shardMask modulo trick depends on.
func coerceShardCount(n int) int {
n = nextPow2(n)
if n < minShards {
n = minShards
}
if n > maxShards {
n = maxShards
}
return n
}
// nextPow2 returns the smallest power of two >= n, and at least 1.
func nextPow2(n int) int {
if n <= 1 {
return 1
}
return 1 << bits.Len(uint(n-1))
}
// ResolveMutex returns the graph-wide mutex resolvers use to
// serialise resolution phases against this graph. Exposed for the
// resolver package; every Resolver built from the same Graph shares
// the same lock.
func (g *Graph) ResolveMutex() *sync.Mutex {
return &g.resolveMu
}
// ReindexEdges is the batched sibling of ReindexEdge. The in-memory
// store has no per-call commit overhead so the implementation is a
// straight loop; the value of the batch API lives in the disk
// backends, where it collapses N transaction commits into one.
func (g *Graph) ReindexEdges(batch []EdgeReindex) {
for _, r := range batch {
if r.Edge == nil {
continue
}
g.ReindexEdge(r.Edge, r.OldTo)
}
}
// BulkSetCloneShingles is the in-memory implementation of
// CloneShingleWriter. It records every (nodeID -> shingles) entry for
// one repo prefix, replacing any prior value in place. Slices are
// deep-copied on the way in so a later mutation of the caller's slice
// can't corrupt the stored state. Empty input is a no-op.
func (g *Graph) BulkSetCloneShingles(repoPrefix string, rows map[string][]uint64) error {
if len(rows) == 0 {
return nil
}
g.cloneShinglesMu.Lock()
defer g.cloneShinglesMu.Unlock()
if g.cloneShingles == nil {
g.cloneShingles = make(map[string]cloneShingleEntry, len(rows))
}
for id, sh := range rows {
cp := make([]uint64, len(sh))
copy(cp, sh)
g.cloneShingles[id] = cloneShingleEntry{repoPrefix: repoPrefix, shingles: cp}
}
return nil
}
// DeleteCloneShingles is the in-memory implementation of the
// CloneShingleWriter delete side. It drops the rows for the supplied
// node ids. Empty input is a no-op; missing ids are ignored.
func (g *Graph) DeleteCloneShingles(nodeIDs []string) error {
if len(nodeIDs) == 0 {
return nil
}
g.cloneShinglesMu.Lock()
defer g.cloneShinglesMu.Unlock()
for _, id := range nodeIDs {
if id == "" {
continue
}
delete(g.cloneShingles, id)
}
return nil
}
// LoadCloneShingles is the in-memory implementation of
// CloneShingleReader. It returns a fresh map of the shingle sets owned
// by one repo prefix, deep-copying each slice so callers can't mutate
// the stored state. Always returns a non-nil (possibly empty) map and
// never an error.
func (g *Graph) LoadCloneShingles(repoPrefix string) (map[string][]uint64, error) {
g.cloneShinglesMu.Lock()
defer g.cloneShinglesMu.Unlock()
out := make(map[string][]uint64)
for id, entry := range g.cloneShingles {
if entry.repoPrefix != repoPrefix {
continue
}
cp := make([]uint64, len(entry.shingles))
copy(cp, entry.shingles)
out[id] = cp
}
return out, nil
}
// BulkSetConstantValues is the in-memory ConstantValueWriter. It records
// every (nodeID -> value) row for one repo prefix, replacing any prior
// value in place. Empty input is a no-op.
func (g *Graph) BulkSetConstantValues(repoPrefix string, rows []ConstantValueRow) error {
if len(rows) == 0 {
return nil
}
g.constValuesMu.Lock()
defer g.constValuesMu.Unlock()
if g.constValues == nil {
g.constValues = make(map[string]constValueEntry, len(rows))
}
for _, r := range rows {
if r.NodeID == "" {
continue
}
g.constValues[r.NodeID] = constValueEntry{repoPrefix: repoPrefix, filePath: r.FilePath, value: r.Value}
}
return nil
}
// DeleteConstantValuesByFiles is the in-memory ConstantValueWriter delete
// side: it drops every row whose file is in the supplied set for the given
// repo prefix, so a reindex of those files replaces their values cleanly.
func (g *Graph) DeleteConstantValuesByFiles(repoPrefix string, files []string) error {
if len(files) == 0 {
return nil
}
fileSet := make(map[string]struct{}, len(files))
for _, f := range files {
fileSet[f] = struct{}{}
}
g.constValuesMu.Lock()
defer g.constValuesMu.Unlock()
for id, entry := range g.constValues {
if entry.repoPrefix != repoPrefix {
continue
}
if _, ok := fileSet[entry.filePath]; ok {
delete(g.constValues, id)
}
}
return nil
}
// ConstantValuesByNodeIDs is the in-memory ConstantValueReader. It returns
// the recorded values for the supplied node ids (omitting ids with no
// recorded value). Always returns a non-nil map and never an error.
func (g *Graph) ConstantValuesByNodeIDs(nodeIDs []string) (map[string]string, error) {
out := make(map[string]string, len(nodeIDs))
if len(nodeIDs) == 0 {
return out, nil
}
g.constValuesMu.Lock()
defer g.constValuesMu.Unlock()
for _, id := range nodeIDs {
if entry, ok := g.constValues[id]; ok {
out[id] = entry.value
}
}
return out, nil
}
// SetFileMetas is the in-memory FileMetaWriter. It records each per-file row
// for one repo prefix, replacing any prior row in place. Empty input is a
// no-op.
func (g *Graph) SetFileMetas(repoPrefix string, rows []FileMetaRow) error {
if len(rows) == 0 {
return nil
}
g.fileMetasMu.Lock()
defer g.fileMetasMu.Unlock()
if g.fileMetas == nil {
g.fileMetas = make(map[string]map[string]FileMetaRow)
}
byFile := g.fileMetas[repoPrefix]
if byFile == nil {
byFile = make(map[string]FileMetaRow, len(rows))
g.fileMetas[repoPrefix] = byFile
}
for _, r := range rows {
if r.FilePath == "" {
continue
}
byFile[r.FilePath] = r
}
return nil
}
// DeleteFileMetasByFiles drops the rows for the supplied files in one repo
// prefix so a reindex replaces them cleanly.
func (g *Graph) DeleteFileMetasByFiles(repoPrefix string, files []string) error {
if len(files) == 0 {
return nil
}
g.fileMetasMu.Lock()
defer g.fileMetasMu.Unlock()
byFile := g.fileMetas[repoPrefix]
if byFile == nil {
return nil
}
for _, f := range files {
delete(byFile, f)
}
return nil
}
// FileMetasForRepo is the in-memory FileMetaReader: every recorded file row
// for the repo prefix. Always non-nil.
func (g *Graph) FileMetasForRepo(repoPrefix string) ([]FileMetaRow, error) {
g.fileMetasMu.Lock()
defer g.fileMetasMu.Unlock()
byFile := g.fileMetas[repoPrefix]
out := make([]FileMetaRow, 0, len(byFile))
for _, r := range byFile {
out = append(out, r)
}
return out, nil
}
// BulkSetChurn is the in-memory ChurnEnrichmentWriter. ChurnEnrichment
// is a flat value type, so a map store needs no deep copy.
func (g *Graph) BulkSetChurn(repoPrefix string, rows []ChurnEnrichment) error {
if len(rows) == 0 {
return nil
}
g.churnEnrichMu.Lock()
defer g.churnEnrichMu.Unlock()
if g.churnEnrich == nil {
g.churnEnrich = make(map[string]ChurnEnrichment, len(rows))
}
for _, r := range rows {
r.RepoPrefix = repoPrefix
g.churnEnrich[r.NodeID] = r
}
return nil
}
// DeleteChurn is the in-memory ChurnEnrichmentWriter delete side.
func (g *Graph) DeleteChurn(nodeIDs []string) error {
if len(nodeIDs) == 0 {
return nil
}
g.churnEnrichMu.Lock()
defer g.churnEnrichMu.Unlock()
for _, id := range nodeIDs {
if id != "" {
delete(g.churnEnrich, id)
}
}
return nil
}
// ChurnRows is the in-memory ChurnEnrichmentReader. An empty repoPrefix
// returns all rows across repos.
func (g *Graph) ChurnRows(repoPrefix string) []ChurnEnrichment {
g.churnEnrichMu.Lock()
defer g.churnEnrichMu.Unlock()
out := make([]ChurnEnrichment, 0, len(g.churnEnrich))
for _, r := range g.churnEnrich {
if repoPrefix != "" && r.RepoPrefix != repoPrefix {
continue
}
out = append(out, r)
}
return out
}
// BulkSetCoverage is the in-memory CoverageEnrichmentWriter.
func (g *Graph) BulkSetCoverage(repoPrefix string, rows []CoverageEnrichment) error {
if len(rows) == 0 {
return nil
}
g.coverageEnrichMu.Lock()
defer g.coverageEnrichMu.Unlock()
if g.coverageEnrich == nil {
g.coverageEnrich = make(map[string]CoverageEnrichment, len(rows))
}
for _, r := range rows {
r.RepoPrefix = repoPrefix
g.coverageEnrich[r.NodeID] = r
}
return nil
}
// DeleteCoverage is the in-memory CoverageEnrichmentWriter delete side.
func (g *Graph) DeleteCoverage(nodeIDs []string) error {
if len(nodeIDs) == 0 {
return nil
}
g.coverageEnrichMu.Lock()
defer g.coverageEnrichMu.Unlock()
for _, id := range nodeIDs {
if id != "" {
delete(g.coverageEnrich, id)
}
}
return nil
}
// ChurnRows-style reader for coverage; empty repoPrefix returns all.
func (g *Graph) CoverageRows(repoPrefix string) []CoverageEnrichment {
g.coverageEnrichMu.Lock()
defer g.coverageEnrichMu.Unlock()
out := make([]CoverageEnrichment, 0, len(g.coverageEnrich))
for _, r := range g.coverageEnrich {
if repoPrefix != "" && r.RepoPrefix != repoPrefix {
continue
}
out = append(out, r)
}
return out
}
// BulkSetReleases is the in-memory ReleaseEnrichmentWriter.
func (g *Graph) BulkSetReleases(repoPrefix string, rows []ReleaseEnrichment) error {
if len(rows) == 0 {
return nil
}
g.releaseEnrichMu.Lock()
defer g.releaseEnrichMu.Unlock()
if g.releaseEnrich == nil {
g.releaseEnrich = make(map[string]ReleaseEnrichment, len(rows))
}
for _, r := range rows {
r.RepoPrefix = repoPrefix
g.releaseEnrich[r.NodeID] = r
}
return nil
}
// DeleteReleases is the in-memory ReleaseEnrichmentWriter delete side.
func (g *Graph) DeleteReleases(nodeIDs []string) error {
if len(nodeIDs) == 0 {
return nil
}
g.releaseEnrichMu.Lock()
defer g.releaseEnrichMu.Unlock()
for _, id := range nodeIDs {
if id != "" {
delete(g.releaseEnrich, id)
}
}
return nil
}
// ReleaseRows reads release rows; empty repoPrefix returns all.
func (g *Graph) ReleaseRows(repoPrefix string) []ReleaseEnrichment {
g.releaseEnrichMu.Lock()
defer g.releaseEnrichMu.Unlock()
out := make([]ReleaseEnrichment, 0, len(g.releaseEnrich))
for _, r := range g.releaseEnrich {
if repoPrefix != "" && r.RepoPrefix != repoPrefix {
continue
}
out = append(out, r)
}
return out
}
// BulkSetBlame is the in-memory BlameEnrichmentWriter.
func (g *Graph) BulkSetBlame(repoPrefix string, rows []BlameEnrichment) error {
if len(rows) == 0 {
return nil
}
g.blameEnrichMu.Lock()
defer g.blameEnrichMu.Unlock()
if g.blameEnrich == nil {
g.blameEnrich = make(map[string]BlameEnrichment, len(rows))
}
for _, r := range rows {
r.RepoPrefix = repoPrefix
g.blameEnrich[r.NodeID] = r
}
return nil
}
// DeleteBlame is the in-memory BlameEnrichmentWriter delete side.
func (g *Graph) DeleteBlame(nodeIDs []string) error {
if len(nodeIDs) == 0 {
return nil
}
g.blameEnrichMu.Lock()
defer g.blameEnrichMu.Unlock()
for _, id := range nodeIDs {
if id != "" {
delete(g.blameEnrich, id)
}
}
return nil
}
// BlameRows reads blame rows; empty repoPrefix returns all.
func (g *Graph) BlameRows(repoPrefix string) []BlameEnrichment {
g.blameEnrichMu.Lock()
defer g.blameEnrichMu.Unlock()
out := make([]BlameEnrichment, 0, len(g.blameEnrich))
for _, r := range g.blameEnrich {
if repoPrefix != "" && r.RepoPrefix != repoPrefix {
continue
}
out = append(out, r)
}
return out
}
// EdgesByKind yields every edge whose Kind matches. In-memory
// implementation iterates the materialised AllEdges() slice and
// filters; the algorithmic cost is identical to a hand-written
// "for _, e := range g.AllEdges() { if e.Kind == kind }" loop, which
// is what most call sites used before the predicate API existed.
// Disk backends override this with an index-backed scan.
func (g *Graph) EdgesByKind(kind EdgeKind) iter.Seq[*Edge] {
return func(yield func(*Edge) bool) {
for _, e := range g.AllEdges() {
if e == nil || e.Kind != kind {
continue
}
if !yield(e) {
return
}
}
}
}
// EdgesByKinds is the in-memory reference implementation of
// EdgesByKindsScanner. Single pass over AllEdges with a small
// pre-built kind set — same algorithmic cost as the legacy `for _, e
// := range g.AllEdges() { if e.Kind == X || e.Kind == Y }` loop the
// edge-driven analyzers used before this capability existed. Disk
// backends override with a single `WHERE kind IN $kinds` query so the
// edge-driven analyzers stop firing one EdgesByKind per kind (or
// worse, scanning AllEdges and filtering Go-side).
//
// Empty kinds yields nothing — matches the disk contract.
func (g *Graph) EdgesByKinds(kinds []EdgeKind) iter.Seq[*Edge] {
if len(kinds) == 0 {
return func(yield func(*Edge) bool) {}
}
set := make(map[EdgeKind]struct{}, len(kinds))
for _, k := range kinds {
if k == "" {
continue
}
set[k] = struct{}{}
}
if len(set) == 0 {
return func(yield func(*Edge) bool) {}
}
return func(yield func(*Edge) bool) {
for _, e := range g.AllEdges() {
if e == nil {
continue
}
if _, ok := set[e.Kind]; !ok {
continue
}
if !yield(e) {
return
}
}
}
}
// NodesByKind yields every node whose Kind matches. Same semantics
// and same in-memory cost story as EdgesByKind.
func (g *Graph) NodesByKind(kind NodeKind) iter.Seq[*Node] {
return func(yield func(*Node) bool) {
for _, n := range g.AllNodes() {
if n == nil || n.Kind != kind {
continue
}
if !yield(n) {
return
}
}
}
}
// GetNodesByIDs returns a map id→*Node for every input ID that
// exists in the store. The in-memory implementation loops the
// existing GetNode — algorithmic cost identical to a hand-written
// loop in the caller, no concurrency win here. The value of the
// batched API lives in the disk backends, where it collapses N
// per-id SQL/bolt queries into one.
func (g *Graph) GetNodesByIDs(ids []string) map[string]*Node {
if len(ids) == 0 {
return nil
}
out := make(map[string]*Node, len(ids))
for _, id := range ids {
if id == "" {
continue
}
if _, ok := out[id]; ok {
continue
}
if n := g.GetNode(id); n != nil {
out[id] = n
}
}
return out
}
// FindNodesByNames is the batched sibling of FindNodesByName.
func (g *Graph) FindNodesByNames(names []string) map[string][]*Node {
if len(names) == 0 {
return nil
}
out := make(map[string][]*Node, len(names))
for _, name := range names {
if name == "" {
continue
}
if _, ok := out[name]; ok {
continue
}
matches := g.FindNodesByName(name)
if len(matches) > 0 {
out[name] = matches
}
}
return out
}
// EdgesWithUnresolvedTarget yields every edge whose To has the
// "unresolved::" prefix — the resolver's main pending-edge filter.
// In-memory iterates all edges and prefix-checks; disk backends back
// it with a range scan on a to-keyed index. Gate-owned fn-value
// placeholders (`unresolved::fnvalue::<name>`) are excluded: the master
// resolver can never bind them, so surfacing them here only bloats the
// pending set it reconciles every pass — the fn-value gate scans them
// itself via EdgesByKind(references).
func (g *Graph) EdgesWithUnresolvedTarget() iter.Seq[*Edge] {
return func(yield func(*Edge) bool) {
for _, e := range g.AllEdges() {
if e == nil {
continue
}
// IsUnresolvedTarget matches both the bare `unresolved::<name>`
// form and the multi-repo `<repoPrefix>::unresolved::<name>`
// form that the disk backend's bulk-load rewrite produces. A bare
// HasPrefix check silently skipped every prefixed stub, so the
// Go resolver never got a second pass at multi-repo edges.
// IsFnValuePlaceholder drops the gate-owned fnvalue sub-namespace.
if !IsUnresolvedTarget(e.To) || IsFnValuePlaceholder(e.To) {
continue
}
if !yield(e) {
return
}
}
}
}
// FnValuePlaceholderEdges implements graph.FnValuePlaceholderScanner: it yields
// every edge whose To is a fn-value gate placeholder (both the bare and the
// multi-repo COPY-rewrite forms) — the exact inverse of the exclusion
// EdgesWithUnresolvedTarget applies. Full edges with Meta intact, since the gate
// reads Meta["via"] and the captured fn_value_name off each one.
func (g *Graph) FnValuePlaceholderEdges() iter.Seq[*Edge] {
return func(yield func(*Edge) bool) {
for _, e := range g.AllEdges() {
if e == nil || !IsFnValuePlaceholder(e.To) {
continue
}
if !yield(e) {
return
}
}
}
}
// AllEdgesLight implements graph.LightEdgeScanner for the in-memory backend.
// There is no separate meta blob to skip here — the edges are live structs — so
// it returns the matching edges as-is, Meta present. Honouring the "Meta
// absent" half of the contract would mean copying every edge to strip Meta,
// doubling heap for zero benefit; the contract only promises Meta MAY be absent
// and correct callers read only the promoted fields either way. An empty kinds
// list returns every edge.
func (g *Graph) AllEdgesLight(kinds ...EdgeKind) []*Edge {
all := g.AllEdges()
if len(kinds) == 0 {
return all
}
want := make(map[EdgeKind]struct{}, len(kinds))
for _, k := range kinds {
if k != "" {
want[k] = struct{}{}
}
}
if len(want) == 0 {
return nil
}
out := make([]*Edge, 0, len(all))
for _, e := range all {
if e != nil {
if _, ok := want[e.Kind]; ok {
out = append(out, e)
}
}
}
return out
}
// EdgesForKindsLight returns the edges of the given kinds (an empty kinds list
// means every kind) for a whole-graph scan, preferring the meta-less
// LightEdgeScanner capability when the store implements it (skips the per-edge
// Meta decode on disk backends) and otherwise falling back to AllEdges() with a
// Go-side kind filter. See LightEdgeScanner for the Meta-presence contract:
// callers must read only the promoted edge fields, never arbitrary Meta.
func EdgesForKindsLight(g Store, kinds ...EdgeKind) []*Edge {
if g == nil {
return nil
}
if sc, ok := g.(LightEdgeScanner); ok {
return sc.AllEdgesLight(kinds...)
}
all := g.AllEdges()
if len(kinds) == 0 {
return all
}
want := make(map[EdgeKind]struct{}, len(kinds))
for _, k := range kinds {
if k != "" {
want[k] = struct{}{}
}
}
if len(want) == 0 {
return nil
}
out := make([]*Edge, 0, len(all))
for _, e := range all {
if e != nil {
if _, ok := want[e.Kind]; ok {
out = append(out, e)
}
}
}
return out
}
// DeadCodeCandidates is the in-memory reference implementation of
// DeadCodeCandidator. Iterates the requested node kinds and filters
// out anything whose incoming-edge bucket contains an allowlist match
// — same algorithm the analysis.FindDeadCode loop runs, just exposed
// as a single capability the disk backend can short-circuit with
// one query per kind. Pure map / slice walks here; the win lives
// in the disk backend where the equivalent path materialises the full
// in-edge map.
func (g *Graph) DeadCodeCandidates(allowedNodeKinds []NodeKind, allowedInEdgeKinds map[NodeKind][]EdgeKind) []*Node {
if len(allowedNodeKinds) == 0 {
return nil
}
// Build a per-kind set so the inner loop can match against a map
// instead of re-scanning the allowlist slice for every edge.
allowedSet := make(map[NodeKind]map[EdgeKind]struct{}, len(allowedNodeKinds))
for _, k := range allowedNodeKinds {
set := make(map[EdgeKind]struct{}, len(allowedInEdgeKinds[k]))
for _, ek := range allowedInEdgeKinds[k] {
set[ek] = struct{}{}
}
allowedSet[k] = set
}
var out []*Node
for _, k := range allowedNodeKinds {
allowed, hasAllow := allowedSet[k]
anyKindCounts := !hasAllow || len(allowed) == 0
for n := range g.NodesByKind(k) {
if n == nil {
continue
}
incoming := g.GetInEdges(n.ID)
dead := true
for _, e := range incoming {
if e == nil {
continue
}
if anyKindCounts {
dead = false
break
}
if _, ok := allowed[e.Kind]; ok {
dead = false
break
}
}
if dead {
out = append(out, n)
}
}
}
return out
}
// IfaceImplementsRows is the in-memory reference implementation of
// IfaceImplementsScanner. Joins KindInterface nodes carrying
// Meta["methods"] with their EdgeImplements predecessors and returns
// one row per (typeID, ifaceID, ifaceMeta) tuple.
func (g *Graph) IfaceImplementsRows() []IfaceImplementsRow {
// Index interfaces with methods by ID so the edge walk is O(edges)
// rather than O(edges × interfaces).
ifaceMeta := make(map[string]map[string]any)
for n := range g.NodesByKind(KindInterface) {
if n == nil || n.Meta == nil {
continue
}
if _, ok := n.Meta["methods"]; !ok {
continue
}
ifaceMeta[n.ID] = n.Meta
}
if len(ifaceMeta) == 0 {
return nil
}
var out []IfaceImplementsRow
for e := range g.EdgesByKind(EdgeImplements) {
if e == nil {
continue
}
meta, ok := ifaceMeta[e.To]
if !ok {
continue
}
out = append(out, IfaceImplementsRow{
TypeID: e.From,
IfaceID: e.To,
IfaceMeta: meta,
})
}
return out
}
// NodeDegreeCounts is the in-memory reference implementation of
// NodeDegreeAggregator. Walks the per-node in/out edge buckets the
// in-memory backend already maintains — same cost as the per-node
// loop GraphConnectivity ran before this capability landed, just
// folded into one method call so the analyzer can pick the disk
// backend's bulk implementation transparently. Missing ids are
// elided from the result (matching the disk contract).
func (g *Graph) NodeDegreeCounts(ids []string, usageKinds []EdgeKind) []NodeDegreeRow {
if len(ids) == 0 {
return nil
}
usage := make(map[EdgeKind]struct{}, len(usageKinds))
for _, k := range usageKinds {
usage[k] = struct{}{}
}
seen := make(map[string]struct{}, len(ids))
out := make([]NodeDegreeRow, 0, len(ids))
for _, id := range ids {
if id == "" {
continue
}
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
// Skip unknown ids — the disk backend's WHERE n.id IN $ids
// clause naturally drops them; mirror that here so both
// backends return the same row count.
if g.GetNode(id) == nil {
continue
}
in := g.GetInEdges(id)
row := NodeDegreeRow{
NodeID: id,
InCount: len(in),
OutCount: len(g.GetOutEdges(id)),
}
if len(usage) > 0 {
for _, e := range in {
if e == nil {
continue
}
if _, ok := usage[e.Kind]; ok {
row.UsageInCount++
}
}
}
out = append(out, row)
}
return out
}
// FileImporters is the in-memory reference implementation of the
// FileImporters capability. Iterates EdgeImports via the byKind
// bucket — same cost as the legacy AllEdges()+filter loop in
// handleCheckReferences, but exposes the predicate as a single call
// the disk backend can short-circuit with one query.
//
// Matches edges whose To node satisfies filePath == n.FilePath OR
// filePath == n.ID. The dual match keeps parity with the indexer's
// two import shapes: file-targeted imports point at the file node
// (n.ID == filePath), while symbol-targeted imports land on a symbol
// whose FilePath equals filePath.
func (g *Graph) FileImporters(filePath string) []FileImporterRow {
if filePath == "" {
return nil
}
var out []FileImporterRow
for e := range g.EdgesByKind(EdgeImports) {
if e == nil {
continue
}
to := g.GetNode(e.To)
if to == nil {
continue
}
if to.FilePath != filePath && to.ID != filePath {
continue
}
from := g.GetNode(e.From)
if from == nil {
continue
}
out = append(out, FileImporterRow{
FromFile: from.FilePath,
FromID: from.ID,
FromName: from.Name,
FromKind: from.Kind,
})
}
return out
}
// NodeFanCounts is the in-memory reference implementation of
// NodeFanAggregator. Two passes over the per-node in/out edge buckets
// the in-memory backend already maintains, filtered by the caller's
// kind sets. The disk backend overrides with one query per direction
// to drop the AllEdges() materialisation FindHotspots / health_score
// were running every call.
func (g *Graph) NodeFanCounts(ids []string, fanInKinds []EdgeKind, fanOutKinds []EdgeKind) []NodeFanRow {
if len(ids) == 0 {
return nil
}
inSet := make(map[EdgeKind]struct{}, len(fanInKinds))
for _, k := range fanInKinds {
inSet[k] = struct{}{}
}
outSet := make(map[EdgeKind]struct{}, len(fanOutKinds))
for _, k := range fanOutKinds {
outSet[k] = struct{}{}
}
seen := make(map[string]struct{}, len(ids))
out := make([]NodeFanRow, 0, len(ids))
for _, id := range ids {
if id == "" {
continue
}
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
if g.GetNode(id) == nil {
continue
}
row := NodeFanRow{NodeID: id}
if len(inSet) > 0 {
for _, e := range g.GetInEdges(id) {
if e == nil {
continue
}
if _, ok := inSet[e.Kind]; ok {
row.FanIn++
}
}
}
if len(outSet) > 0 {
for _, e := range g.GetOutEdges(id) {
if e == nil {
continue
}
if _, ok := outSet[e.Kind]; ok {
row.FanOut++
}
}
}
out = append(out, row)
}
return out
}
// InEdgeCountsByKind is the in-memory reference implementation of
// the InEdgeCounter capability. Walks each requested EdgeKind via
// the byKind bucket and increments a per-To counter. Same algorithm
// the AllEdges-bucketing fallback in handleGetUntestedSymbols runs;
// the win lives in the disk backend where AllEdges() materialises every
// edge just to bucket by target.
//
// Dedupes the kind set up front so a sloppy caller passing the same
// kind twice doesn't double-count — matches the disk backend's
// IN-list dedup.
func (g *Graph) InEdgeCountsByKind(kinds []EdgeKind) map[string]int {
if len(kinds) == 0 {
return nil
}
seen := make(map[EdgeKind]struct{}, len(kinds))
out := make(map[string]int)
for _, k := range kinds {
if _, ok := seen[k]; ok {
continue
}
seen[k] = struct{}{}
for e := range g.EdgesByKind(k) {
if e == nil {
continue
}
out[e.To]++
}
}
return out
}
// NodesInFilesByKind is the in-memory reference implementation of
// the NodesInFilesByKindFinder capability. Filters NodesByKind for
// each requested kind down to the file set. Same algorithm as the
// Go-side loop in find_declaration's buildDeclFileIndex; the win
// lives in disk backends where AllNodes() over cgo dwarfs the few
// hundred surviving rows.
func (g *Graph) NodesInFilesByKind(files []string, kinds []NodeKind) []*Node {
if len(files) == 0 || len(kinds) == 0 {
return nil
}
wanted := make(map[string]struct{}, len(files))
for _, f := range files {
if f == "" {
continue
}
wanted[f] = struct{}{}
}
if len(wanted) == 0 {
return nil
}
// Dedup the kinds so a sloppy caller doesn't double-scan.
seenKind := make(map[NodeKind]struct{}, len(kinds))
var out []*Node
for _, k := range kinds {
if _, ok := seenKind[k]; ok {
continue
}
seenKind[k] = struct{}{}
for n := range g.NodesByKind(k) {
if n == nil {
continue
}
if _, ok := wanted[n.FilePath]; !ok {
continue
}
out = append(out, n)
}
}
return out
}
// NodesByKinds is the in-memory reference implementation of the
// NodesByKindsScanner capability. Loops the existing NodesByKind
// iterator per requested kind — algorithmic cost identical to the
// hand-written `for _, n := range AllNodes() if n.Kind == K` pattern
// the metadata analyzers used before. The win lives in the disk
// backend, where one IN-list query replaces the AllNodes() pull.
//
// Dedupes the kind set up front so a sloppy caller passing the same
// kind twice doesn't double-yield — matches the disk backend's
// IN-list dedup. Empty kinds returns nil without touching the store.
func (g *Graph) NodesByKinds(kinds []NodeKind) []*Node {
if len(kinds) == 0 {
return nil
}
seen := make(map[NodeKind]struct{}, len(kinds))
var out []*Node
for _, k := range kinds {
if _, ok := seen[k]; ok {
continue
}
seen[k] = struct{}{}
for n := range g.NodesByKind(k) {
if n == nil {
continue
}
out = append(out, n)
}
}
return out
}
// EdgeAdjacencyForKinds is the in-memory reference implementation of
// the EdgeAdjacencyForKinds capability. One AllEdges scan that yields
// (from, to) pairs whose Kind is in the supplied edge-kind set AND
// whose endpoints both have a Kind in the node-kind set — identical
// shape to the join the disk backend folds into a single
// query.
//
// Empty edgeKinds or empty nodeKinds yields nothing — matches the
// disk contract.
func (g *Graph) EdgeAdjacencyForKinds(edgeKinds []EdgeKind, nodeKinds []NodeKind) iter.Seq[[2]string] {
if len(edgeKinds) == 0 || len(nodeKinds) == 0 {
return func(yield func([2]string) bool) {}
}
eset := make(map[EdgeKind]struct{}, len(edgeKinds))
for _, k := range edgeKinds {
if k == "" {
continue
}
eset[k] = struct{}{}
}
nset := make(map[NodeKind]struct{}, len(nodeKinds))
for _, k := range nodeKinds {
if k == "" {
continue
}
nset[k] = struct{}{}
}
if len(eset) == 0 || len(nset) == 0 {
return func(yield func([2]string) bool) {}
}
return func(yield func([2]string) bool) {
for _, e := range g.AllEdges() {
if e == nil {
continue
}
if _, ok := eset[e.Kind]; !ok {
continue
}
from := g.GetNode(e.From)
to := g.GetNode(e.To)
if from == nil || to == nil {
continue
}
if _, ok := nset[from.Kind]; !ok {
continue
}
if _, ok := nset[to.Kind]; !ok {
continue
}
if !yield([2]string{e.From, e.To}) {
return
}
}
}
}
// CommunityCrossingsByKind is the in-memory reference implementation
// of the CommunityCrossingsByKind capability. AllEdges scan with the
// kind-set filter, then a Go-side community comparison per edge —
// the exact loop FindHotspots.countCrossings ran before this
// capability existed.
//
// Empty kinds or empty nodeToComm returns nil. Zero-count sources
// never surface (matches the disk contract — callers probe by
// existence).
func (g *Graph) CommunityCrossingsByKind(kinds []EdgeKind, nodeToComm map[string]string) map[string]int {
if len(kinds) == 0 || len(nodeToComm) == 0 {
return nil
}
set := make(map[EdgeKind]struct{}, len(kinds))
for _, k := range kinds {
if k == "" {
continue
}
set[k] = struct{}{}
}
if len(set) == 0 {
return nil
}
out := make(map[string]int)
for _, e := range g.AllEdges() {
if e == nil {
continue
}
if _, ok := set[e.Kind]; !ok {
continue
}
from := nodeToComm[e.From]
to := nodeToComm[e.To]
if from == "" || to == "" || from == to {
continue
}
out[e.From]++
}
if len(out) == 0 {
return nil
}
return out
}
// NodeIDsByKinds is the in-memory reference implementation of the
// NodeIDsByKinds capability. Single AllNodes pass with a kind-set
// filter, deduped on input — same algorithm as NodesByKinds but
// returns only the ID column. The disk-backend win is the projection
// drop, not the algorithmic shape.
func (g *Graph) NodeIDsByKinds(kinds []NodeKind) []string {
if len(kinds) == 0 {
return nil
}
seen := make(map[NodeKind]struct{}, len(kinds))
for _, k := range kinds {
if k == "" {
continue
}
seen[k] = struct{}{}
}
if len(seen) == 0 {
return nil
}
var out []string
for _, n := range g.AllNodes() {
if n == nil {
continue
}
if _, ok := seen[n.Kind]; !ok {
continue
}
out = append(out, n.ID)
}
return out
}
// EdgeKindCounts is the in-memory reference implementation of the
// EdgeKindCounter capability. One AllEdges scan with a per-kind
// tally — the exact loop the get_surprising_connections Go fallback
// already runs today, just exposed as a single method call so the
// the disk backend can short-circuit with a server-side GROUP BY.
//
// Empty graph returns nil so callers can short-circuit a downstream
// "kindCounts != nil" gate.
func (g *Graph) EdgeKindCounts() map[EdgeKind]int {
out := map[EdgeKind]int{}
for _, e := range g.AllEdges() {
if e == nil {
continue
}
out[e.Kind]++
}
if len(out) == 0 {
return nil
}
return out
}
// CrossRepoEdgeCounts is the in-memory reference implementation of
// CrossRepoEdgeAggregator. Iterates the four cross_repo_* byKind
// buckets and groups by (kind, fromRepoPrefix, toRepoPrefix). Same
// algorithm as the architecture handler's AllEdges loop but exposes
// it as a single capability so the disk backend can fold the join into
// one query.
//
// Returns nil when the graph carries no cross-repo edges (single-
// repo mode) so the caller's empty-list rendering kicks in without
// allocating.
func (g *Graph) CrossRepoEdgeCounts() []CrossRepoEdgeRow {
type key struct {
kind EdgeKind
fromRepo string
toRepo string
}
counts := map[key]int{}
for _, k := range []EdgeKind{
EdgeCrossRepoCalls,
EdgeCrossRepoImplements,
EdgeCrossRepoExtends,
} {
for e := range g.EdgesByKind(k) {
if e == nil {
continue
}
from := g.GetNode(e.From)
to := g.GetNode(e.To)
if from == nil || to == nil {
continue
}
counts[key{kind: e.Kind, fromRepo: from.RepoPrefix, toRepo: to.RepoPrefix}]++
}
}
if len(counts) == 0 {
return nil
}
out := make([]CrossRepoEdgeRow, 0, len(counts))
for k, c := range counts {
out = append(out, CrossRepoEdgeRow{
Kind: k.kind, FromRepo: k.fromRepo, ToRepo: k.toRepo, Count: c,
})
}
return out
}
// FileImportCounts is the in-memory reference implementation of
// FileImportAggregator. Iterates the EdgeImports byKind bucket and
// groups by the target file path — coalescing to To-node FilePath
// or, when the indexer pointed the import edge at the file node
// directly, the target ID. Same algorithm as the AllEdges loop in
// mostImportedFiles; the win lives in disk backends where AllEdges
// + per-edge GetNode round-trips over cgo dwarf the few hundred
// surviving rows.
//
// scope, when non-nil, bounds the result to edges whose target ID
// lies in the slice (session-workspace clamp). A nil scope counts
// every imports edge. An empty (non-nil) scope returns nil — never
// a whole-graph scan.
func (g *Graph) FileImportCounts(scope []string) []FileImportCountRow {
if scope != nil && len(scope) == 0 {
return nil
}
var allowed map[string]struct{}
if scope != nil {
allowed = make(map[string]struct{}, len(scope))
for _, id := range scope {
if id == "" {
continue
}
allowed[id] = struct{}{}
}
if len(allowed) == 0 {
return nil
}
}
counts := map[string]int{}
for e := range g.EdgesByKind(EdgeImports) {
if e == nil {
continue
}
target := g.GetNode(e.To)
if target == nil {
continue
}
if allowed != nil {
if _, ok := allowed[target.ID]; !ok {
continue
}
}
path := target.FilePath
if path == "" {
path = target.ID
}
if path == "" {
continue
}
counts[path]++
}
if len(counts) == 0 {
return nil
}
out := make([]FileImportCountRow, 0, len(counts))
for p, c := range counts {
out = append(out, FileImportCountRow{FilePath: p, Count: c})
}
return out
}
// SetEdgeProvenanceBatch is the batched sibling of SetEdgeProvenance.
// Same story as ReindexEdges: per-call in memory, one transaction in
// the disk backends. Returns the number of edges whose Origin
// actually changed (matches the sum of per-edge SetEdgeProvenance
// boolean returns).
func (g *Graph) SetEdgeProvenanceBatch(batch []EdgeProvenanceUpdate) int {
changed := 0
for _, u := range batch {
if u.Edge == nil {
continue
}
if g.SetEdgeProvenance(u.Edge, u.NewOrigin) {
changed++
}
}
return changed
}
// shardIdx picks the shard index for an ID using FNV-1a. Inlined to
// avoid the per-call hash-object allocation that the stdlib's
// fnv.New32a() incurs — shardIdx is on the hottest path in the graph
// (every AddNode / AddEdge / GetNode call), and the heap profile shows
// 690 MB/30 s of fnv state allocations during cold-start indexing.
// shardMask is shardCount-1 and shardCount is a power of two, so
// h & shardMask is an exact, branch-free modulo.
func (g *Graph) shardIdx(id string) int {
var h uint32 = 2166136261
for i := 0; i < len(id); i++ {
h ^= uint32(id[i])
h *= 16777619
}
return int(h & g.shardMask)
}
// shardFor returns the shard that owns the given ID.
func (g *Graph) shardFor(id string) *shard {
return g.shards[g.shardIdx(id)]
}
// lockTwoWrite locks two shards for write in ascending index order to
// prevent deadlock. If both IDs land in the same shard, the mutex is
// locked exactly once. Returns a closure the caller defers to unlock.
func (g *Graph) lockTwoWrite(idA, idB string) func() {
a := g.shardIdx(idA)
b := g.shardIdx(idB)
if a == b {
s := g.shards[a]
s.mu.Lock()
return s.mu.Unlock
}
lo, hi := a, b
if lo > hi {
lo, hi = hi, lo
}
sLo := g.shards[lo]
sHi := g.shards[hi]
sLo.mu.Lock()
sHi.mu.Lock()
return func() {
sHi.mu.Unlock()
sLo.mu.Unlock()
}
}
// lockThreeWrite locks up to three shards for write in ascending index
// order, deduplicating any that collide. Used by ReindexEdge, which
// mutates the From shard's outEdgeIdx plus both in-edge buckets when a
// resolver step retargets an edge; missing any of the three races with
// concurrent AddEdge on that shard (bug: "concurrent map read and map
// write" in addEdgeToBucket).
func (g *Graph) lockThreeWrite(idA, idB, idC string) func() {
a, b, c := g.shardIdx(idA), g.shardIdx(idB), g.shardIdx(idC)
// Sort (a, b, c) ascending without allocating a slice.
if a > b {
a, b = b, a
}
if b > c {
b, c = c, b
}
if a > b {
a, b = b, a
}
// Dedupe: lock each distinct index once.
idxs := [3]int{a, -1, -1}
n := 1
if b != a {
idxs[n] = b
n++
}
if c != b && c != a {
idxs[n] = c
n++
}
for i := 0; i < n; i++ {
g.shards[idxs[i]].mu.Lock()
}
return func() {
for i := n - 1; i >= 0; i-- {
g.shards[idxs[i]].mu.Unlock()
}
}
}
// lockAllWrite / lockAllRead take every shard's lock in order. Used by
// operations that have to touch the whole graph (AllNodes, Stats,
// EvictRepo). Callers must match with unlockAllWrite / unlockAllRead.
func (g *Graph) lockAllWrite() {
for _, s := range g.shards {
s.mu.Lock()
}
}
func (g *Graph) unlockAllWrite() {
for i := len(g.shards) - 1; i >= 0; i-- {
g.shards[i].mu.Unlock()
}
}
func (g *Graph) lockAllRead() {
for _, s := range g.shards {
s.mu.RLock()
}
}
func (g *Graph) unlockAllRead() {
for i := len(g.shards) - 1; i >= 0; i-- {
g.shards[i].mu.RUnlock()
}
}
// AddNode inserts or updates a node in the graph and all secondary
// indexes. Idempotent — a second call with the same ID replaces the
// existing Node pointer in place instead of appending duplicates to the
// byFile / byName / byRepo slices. If the new Node's FilePath, Name, or
// RepoPrefix differs from the stored one, the secondary-index entries
// are migrated from the old bucket to the new one atomically under the
// shard lock.
func (g *Graph) AddNode(n *Node) {
s := g.shardFor(n.ID)
s.mu.Lock()
g.addNodeLocked(s, n)
s.mu.Unlock()
}
// addNodeLocked is AddNode's body, expecting the caller to already hold
// the shard's write lock. Used by AddBatch to amortise lock acquisition
// across many node inserts targeting the same shard.
func (g *Graph) addNodeLocked(s *shard, n *Node) {
prev, hadPrev := s.nodes[n.ID]
// Subtract the previous size/count before overwriting; the new
// node's contribution is re-added after the RepoPrefix-preservation
// logic below has settled on the final prefix.
if hadPrev {
s.repoNodeRemove(prev)
}
s.nodes[n.ID] = n
if hadPrev {
if prev.FilePath != n.FilePath {
removeNodeFromBucket(s.byFile, s.byFileIdx, prev.FilePath, n.ID)
}
if prev.Name != n.Name {
removeNodeFromBucket(s.byName, s.byNameIdx, prev.Name, n.ID)
}
if prev.QualName != n.QualName && prev.QualName != "" {
// byQual is a 1:1 index, not a slice — only delete when
// the stored entry still points at this node ID (a
// different node may have since taken the slot).
if cur, ok := s.byQual[prev.QualName]; ok && cur.ID == n.ID {
delete(s.byQual, prev.QualName)
}
}
if prev.RepoPrefix != n.RepoPrefix && prev.RepoPrefix != "" {
// Preserve a previously-set RepoPrefix rather than letting
// an empty-prefix re-add silently strip the node out of its
// byRepo bucket. The downgrade-to-empty case has no
// legitimate caller (contract nodes use distinct IDs that
// never collide with symbol IDs; the parse and IncrementalReindex
// paths route through applyRepoPrefix which always stamps
// the active idx.repoPrefix on the new node) and previously
// caused per-repo `byRepo[prefix]` to drain mid-warmup,
// breaking RepoStats / RepoMemoryEstimate / GetRepoNodes.
// Restore the old prefix on the new node so the bucket
// stays populated. The legitimate RepoPrefix-change case
// (snapshot prefix → new prefix because config moved) still
// works because n.RepoPrefix is non-empty there.
if n.RepoPrefix == "" {
n.RepoPrefix = prev.RepoPrefix
} else {
removeNodeFromBucket(s.byRepo, s.byRepoIdx, prev.RepoPrefix, n.ID)
}
}
}
addNodeToBucket(s.byFile, s.byFileIdx, n.FilePath, n.ID, n)
addNodeToBucket(s.byName, s.byNameIdx, n.Name, n.ID, n)
if n.QualName != "" {
s.byQual[n.QualName] = n
}
if n.RepoPrefix != "" {
addNodeToBucket(s.byRepo, s.byRepoIdx, n.RepoPrefix, n.ID, n)
}
s.repoNodeAdd(n)
}
// AddBatch inserts a set of nodes and edges in shard-grouped passes,
// acquiring each involved shard's write lock at most once across the
// whole batch. Replaces the O(N + 2E) per-item lock acquisitions of
// AddNode / AddEdge with O(distinct_shards) — typically ~16 instead of
// ~450 per per-file worker batch. The contention profile measured 69
// of 102 goroutines blocked on lockTwoWrite during cold-start parsing;
// batching is the throughput fix.
//
// Observable semantics differ slightly from a sequence of AddNode /
// AddEdge calls: for cross-shard edges, the From shard's outEdges
// receives the edge before the To shard's inEdges does. Readers that
// query one side only see the change atomically per shard; readers
// that join both sides may briefly see an outgoing edge whose
// reciprocal in-edge hasn't landed yet. The parser worker path that
// drives this is followed by ResolveAll / global derivation passes
// that take the resolver mutex graph-wide, so no concurrent reader is
// expected to depend on cross-side atomicity during warmup.
func (g *Graph) AddBatch(nodes []*Node, edges []*Edge) {
if len(nodes) == 0 && len(edges) == 0 {
return
}
nodesByShard := make([][]*Node, g.shardCount)
outEdgesByShard := make([][]*Edge, g.shardCount)
inEdgesByShard := make([][]*Edge, g.shardCount)
for _, n := range nodes {
if n == nil || n.ID == "" {
continue
}
i := g.shardIdx(n.ID)
nodesByShard[i] = append(nodesByShard[i], n)
}
for _, e := range edges {
if e == nil {
continue
}
outEdgesByShard[g.shardIdx(e.From)] = append(outEdgesByShard[g.shardIdx(e.From)], e)
inEdgesByShard[g.shardIdx(e.To)] = append(inEdgesByShard[g.shardIdx(e.To)], e)
}
for i := range g.shards {
if len(nodesByShard[i]) == 0 && len(outEdgesByShard[i]) == 0 && len(inEdgesByShard[i]) == 0 {
continue
}
s := g.shards[i]
s.mu.Lock()
for _, n := range nodesByShard[i] {
g.addNodeLocked(s, n)
}
// Out-side writes own the "was this a new insert?" signal that
// drives the per-repo edge counter and the "did Origin change?"
// signal that drives the identity-revision counter — the in-side
// write is bookkeeping only and charges neither (mirrors
// AddEdge's behaviour).
for _, e := range outEdgesByShard[i] {
inserted, originChanged := addEdgeToBucket(s.outEdges, s.outEdgeKeys, s.outEdgeIdx, e.From, e)
if originChanged {
g.edgeIdentityRevisions.Add(1)
}
if inserted {
g.edgeMutGen.Add(1)
var srcRepo string
if src, ok := s.nodes[e.From]; ok && src != nil {
srcRepo = src.RepoPrefix
}
s.repoEdgeAdd(srcRepo, e)
}
}
for _, e := range inEdgesByShard[i] {
addEdgeToBucket(s.inEdges, s.inEdgeKeys, s.inEdgeIdx, e.To, e)
}
s.mu.Unlock()
}
}
// AddEdge inserts or updates a directed edge in the graph. Locks both
// the From and To shards (same shard locked once if they collide) so
// outEdges and inEdges stay consistent. Idempotent: a second call with
// the same (From, To, Kind, FilePath, Line) replaces the stored *Edge
// pointer in place — newer metadata (Confidence, Origin, etc.) wins,
// adjacency-list length is unchanged. Drops the double-edge problem
// that used to surface after daemon restarts (bug B1).
func (g *Graph) AddEdge(e *Edge) {
unlock := g.lockTwoWrite(e.From, e.To)
defer unlock()
sFrom := g.shardFor(e.From)
sTo := g.shardFor(e.To)
// Only charge the source-repo counter on a brand-new insert.
// Idempotent re-adds (same edgeKey) replace the slot in place and
// would otherwise double-count. addEdgeToBucket reports inserted ==
// true only when it actually appended. The out-side write owns both
// signals: the new-insert flag (per-repo counter) and the origin-
// changed flag (identity-revision counter). The in-side write is
// bookkeeping only — charging either counter there would double it.
inserted, originChanged := addEdgeToBucket(sFrom.outEdges, sFrom.outEdgeKeys, sFrom.outEdgeIdx, e.From, e)
addEdgeToBucket(sTo.inEdges, sTo.inEdgeKeys, sTo.inEdgeIdx, e.To, e)
if originChanged {
// A re-add with the same logical key but an upgraded Origin:
// the old identity is retired, a new one created.
g.edgeIdentityRevisions.Add(1)
}
if inserted {
g.edgeMutGen.Add(1)
var srcRepo string
if src, ok := sFrom.nodes[e.From]; ok && src != nil {
srcRepo = src.RepoPrefix
}
sFrom.repoEdgeAdd(srcRepo, e)
}
}
// SetEdgeProvenance changes the Origin of an edge already in the graph
// and is the only sanctioned way to do so. Conceptually it is a
// delete-then-insert of the edge's identity: because Origin is part of
// IdentityHash, the old provenance-bearing identity is retired and a
// new one created — even though the logical (From,To,Kind,FilePath,
// Line) key, and therefore the adjacency-list slot, is unchanged.
//
// It computes the edge's old and new IdentityHash. When they are equal
// (newOrigin matches the current Origin) nothing changes and it returns
// false. When they differ it applies e.Origin = newOrigin, re-derives
// the Origin-derived Tier label when one was set (Confidence and
// ConfidenceLabel are score-derived, not Origin-derived, so they are
// left intact), increments the graph-level identity-revision counter,
// and returns true.
//
// Mutating Edge.Origin directly on an in-graph edge bypasses the
// counter and is a provenance-tampering bug — route every such change
// here so the churn stays observable via EdgeIdentityRevisions.
func (g *Graph) SetEdgeProvenance(e *Edge, newOrigin string) bool {
if e == nil {
return false
}
unlock := g.lockTwoWrite(e.From, e.To)
defer unlock()
oldIdentity := e.IdentityHash()
newIdentity := hashEdgeIdentity(keyOf(e), newOrigin)
if oldIdentity == newIdentity {
return false
}
e.Origin = newOrigin
// Tier is a pure projection of Origin (graph.ResolvedBy). Re-derive
// it only when it was already populated — an empty Tier is the
// in-memory default and re-deriving would silently start stamping
// it. The same *Edge pointer lives in both the out- and in-edge
// buckets, so this write is visible from every adjacency view.
if e.Tier != "" {
e.Tier = ResolvedBy(newOrigin)
}
g.edgeIdentityRevisions.Add(1)
return true
}
// EdgeIdentityRevisions returns how many times an in-graph edge's
// provenance-bearing identity has changed over this graph's lifetime —
// the running total fed by SetEdgeProvenance and by AddEdge's in-place
// re-add path. It is monotonic and never decremented; a reindex that
// retires and recreates edges does not roll it back. Surfaced through
// graph_stats as the tamper-evidence signal for provenance churn.
func (g *Graph) EdgeIdentityRevisions() int {
return int(g.edgeIdentityRevisions.Load())
}
// VerifyEdgeIdentities walks every edge and confirms its provenance-
// bearing identity is internally consistent: the edge stored in a
// source node's outEdges bucket and the edge stored in the target
// node's inEdges bucket are the same *Edge pointer and therefore agree
// on IdentityHash. addEdgeToBucket stores one shared pointer in both
// buckets, so a consistent graph always passes; a divergence means
// some code mutated Origin on a copied edge (e.g. a resolver clone)
// and wrote it into only one adjacency view, leaving the two sides
// disagreeing about provenance. Returns nil when every edge is
// consistent, or an error naming the first divergent edge.
//
// This is the assertion a test uses to prove provenance cannot be
// silently changed outside SetEdgeProvenance.
func (g *Graph) VerifyEdgeIdentities() error {
g.lockAllRead()
defer g.unlockAllRead()
for _, s := range g.shards {
for _, edges := range s.outEdges {
for _, e := range edges {
if e == nil {
continue
}
want := e.IdentityHash()
sTo := g.shardFor(e.To)
found := false
for _, in := range sTo.inEdges[e.To] {
if in == e {
if in.IdentityHash() != want {
return &edgeIdentityError{edge: e, reason: "inEdges pointer disagrees on identity hash"}
}
found = true
break
}
}
if !found {
return &edgeIdentityError{edge: e, reason: "outEdges edge missing from target inEdges bucket"}
}
}
}
}
return nil
}
// edgeIdentityError reports the first edge VerifyEdgeIdentities found
// to be inconsistent across the out- and in-edge adjacency views.
type edgeIdentityError struct {
edge *Edge
reason string
}
func (e *edgeIdentityError) Error() string {
return "edge identity inconsistent (" + e.reason + "): " +
e.edge.From + " -" + string(e.edge.Kind) + "-> " + e.edge.To
}
// ReindexEdge updates the inEdges index after an edge's To field has
// been mutated (e.g., by the resolver changing "unresolved::X" to a
// real target). oldTo is the previous value of e.To before mutation.
//
// Three sidecar entries change: the outEdgeIdx key for From (since the
// edgeKey depends on To), the inEdgeIdx entry on the old target bucket
// (removed), and the inEdgeIdx entry on the new target bucket (added).
func (g *Graph) ReindexEdge(e *Edge, oldTo string) {
if oldTo == e.To {
return
}
// Must lock the From shard too — we mutate sFrom.outEdgeIdx below,
// and without its lock a concurrent AddEdge on From panics the
// runtime with "concurrent map read and map write".
unlock := g.lockThreeWrite(e.From, oldTo, e.To)
defer unlock()
// Old identity uses oldTo; the current edge struct already has the
// new To set, so we reconstruct the key before mutation.
oldKey := hashEdgeKey(edgeKey{From: e.From, To: oldTo, Kind: e.Kind, FilePath: e.FilePath, Line: e.Line})
newKey := hashEdgeKey(keyOf(e))
sFrom := g.shardFor(e.From)
// outEdges slot position doesn't move — only the key under which
// the sidecar records it changes. Avoid a churn of slice growth by
// swapping the sidecar entry in place.
//
// The parallel outEdgeKeys slice MUST be updated alongside
// outEdgeIdx. removeEdgeFromBucket reads outEdgeKeys[pos] to
// learn the swapped slot's insertion-time key during swap-with-
// last; leaving outEdgeKeys stale here would re-insert the old
// key into outEdgeIdx pointing at a swapped position, and the
// next swap on that key would compute a pos past the (now
// shorter) slice — the exact index-out-of-range panic that
// surfaces during evictEdgesLocked when warmup retargets a lot
// of edges via ReindexEdge.
if fromIdx, ok := sFrom.outEdgeIdx[e.From]; ok {
if pos, exists := fromIdx[oldKey]; exists {
delete(fromIdx, oldKey)
fromIdx[newKey] = pos
if keys, ok := sFrom.outEdgeKeys[e.From]; ok && pos < len(keys) {
keys[pos] = newKey
}
}
}
// Move from the old target's inEdges bucket to the new one.
sOld := g.shardFor(oldTo)
removeEdgeFromBucket(sOld.inEdges, sOld.inEdgeKeys, sOld.inEdgeIdx, oldTo, oldKey)
sNew := g.shardFor(e.To)
addEdgeToBucket(sNew.inEdges, sNew.inEdgeKeys, sNew.inEdgeIdx, e.To, e)
// No edgeMutGen bump here: outEdges retains the same *Edge slot,
// and the cached AllEdges slice holds pointers — readers see the
// already-mutated e.To via that pointer. The cache stays valid.
}
// GetNode returns a node by ID, or nil if not found.
func (g *Graph) GetNode(id string) *Node {
s := g.shardFor(id)
s.mu.RLock()
defer s.mu.RUnlock()
return s.nodes[id]
}
// GetNodeByQualName returns a node by fully-qualified name, or nil.
// The qual name index is partitioned across shards (each shard owns the
// qual names of the nodes it stores), so we ask every shard.
func (g *Graph) GetNodeByQualName(qualName string) *Node {
for _, s := range g.shards {
s.mu.RLock()
if n, ok := s.byQual[qualName]; ok {
s.mu.RUnlock()
return n
}
s.mu.RUnlock()
}
return nil
}
// GetNodesByQualNames is the batch form of GetNodeByQualName — returns
// only the qual_names that have a node (an absent key means "no node").
// The in-memory byQual index makes each lookup O(1); the method exists
// for Store-interface parity with the disk backend, where it collapses
// N per-edge qual_name scans into a single IN-scan.
func (g *Graph) GetNodesByQualNames(qualNames []string) map[string]*Node {
out := make(map[string]*Node, len(qualNames))
for _, q := range qualNames {
if q == "" {
continue
}
if _, done := out[q]; done {
continue
}
for _, s := range g.shards {
s.mu.RLock()
n, ok := s.byQual[q]
s.mu.RUnlock()
if ok {
out[q] = n
break
}
}
}
return out
}
// FindNodesByName returns all nodes matching the short name.
//
// Implementation walks every shard's byName bucket. The two-pass shape
// (sum then allocate) trades one extra read-lock round trip per shard
// for a single right-sized allocation — the prior single-pass append
// re-grew `out` on every hot shard (1 + log2(N) reallocations), which
// the cold-index heap profile attributed 5.22 GB / 14% of total alloc
// to. Names with a long candidate list (`Visit`, `init`, `create`)
// see the biggest win.
func (g *Graph) FindNodesByName(name string) []*Node {
total := 0
for _, s := range g.shards {
s.mu.RLock()
total += len(s.byName[name])
s.mu.RUnlock()
}
if total == 0 {
return nil
}
out := make([]*Node, 0, total)
for _, s := range g.shards {
s.mu.RLock()
if src := s.byName[name]; len(src) > 0 {
out = append(out, src...)
}
s.mu.RUnlock()
}
return out
}
// FindNodesByNameInRepo returns nodes matching the short name that are
// either in the given repoPrefix or carry an empty RepoPrefix (synthetic
// / stdlib nodes — kept same-repo by convention). Equivalent to
// filterSameRepo(repoPrefix, FindNodesByName(name)) but skips the
// intermediate cross-repo candidate slice.
//
// In single-repo graphs (repoPrefix == ""), behaves identically to
// FindNodesByName.
func (g *Graph) FindNodesByNameInRepo(name, repoPrefix string) []*Node {
if repoPrefix == "" {
return g.FindNodesByName(name)
}
// First pass: count matches that pass the repo filter. Counting in
// a separate pass keeps `out` right-sized even when ~95% of the
// byName bucket lives in unrelated repos.
total := 0
for _, s := range g.shards {
s.mu.RLock()
for _, n := range s.byName[name] {
if n.RepoPrefix == "" || n.RepoPrefix == repoPrefix {
total++
}
}
s.mu.RUnlock()
}
if total == 0 {
return nil
}
out := make([]*Node, 0, total)
for _, s := range g.shards {
s.mu.RLock()
for _, n := range s.byName[name] {
if n.RepoPrefix == "" || n.RepoPrefix == repoPrefix {
out = append(out, n)
}
}
s.mu.RUnlock()
}
return out
}
// FindNodesByNameContaining returns nodes whose Name (case-insensitive)
// contains substr. The in-memory backend has no name-substring index,
// so this is a single pass over the byName buckets (which already group
// nodes by exact name — the same allocation we'd pay for one FindNodesByName
// call per distinct name). limit caps the slice; 0 means "no limit".
//
// Stable order is the caller's responsibility — bucket iteration is
// deterministic per shard but cross-shard order isn't fixed.
func (g *Graph) FindNodesByNameContaining(substr string, limit int) []*Node {
if substr == "" {
return nil
}
needle := strings.ToLower(substr)
var out []*Node
for _, s := range g.shards {
s.mu.RLock()
for name, bucket := range s.byName {
if !strings.Contains(strings.ToLower(name), needle) {
continue
}
out = append(out, bucket...)
if limit > 0 && len(out) >= limit {
s.mu.RUnlock()
return out[:limit]
}
}
s.mu.RUnlock()
}
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out
}
// GetFileNodes returns all nodes defined in the given file.
func (g *Graph) GetFileNodes(filePath string) []*Node {
var out []*Node
for _, s := range g.shards {
s.mu.RLock()
if src := s.byFile[filePath]; len(src) > 0 {
out = append(out, src...)
}
s.mu.RUnlock()
}
return out
}
// GetOutEdges returns outgoing edges for a node.
func (g *Graph) GetOutEdges(nodeID string) []*Edge {
s := g.shardFor(nodeID)
s.mu.RLock()
defer s.mu.RUnlock()
src := s.outEdges[nodeID]
out := make([]*Edge, len(src))
copy(out, src)
return out
}
// GetInEdges returns incoming edges for a node.
func (g *Graph) GetInEdges(nodeID string) []*Edge {
s := g.shardFor(nodeID)
s.mu.RLock()
defer s.mu.RUnlock()
src := s.inEdges[nodeID]
out := make([]*Edge, len(src))
copy(out, src)
return out
}
// GetOutEdgesByNodeIDs returns a map id→outgoing edges for every input
// id. The in-memory backend loops the existing GetOutEdges — cost
// matches a hand-written loop in the caller. The value of the batched
// API lives in the disk backend, where it collapses N point lookups into
// one bulk query. Empty input returns nil; duplicate ids are
// deduped naturally. Missing ids are absent from the returned map.
func (g *Graph) GetOutEdgesByNodeIDs(ids []string) map[string][]*Edge {
if len(ids) == 0 {
return nil
}
out := make(map[string][]*Edge, len(ids))
for _, id := range ids {
if id == "" {
continue
}
if _, ok := out[id]; ok {
continue
}
out[id] = g.GetOutEdges(id)
}
return out
}
// GetInEdgesByNodeIDs is the inbound sibling of GetOutEdgesByNodeIDs.
// See that doc-comment for the contract.
func (g *Graph) GetInEdgesByNodeIDs(ids []string) map[string][]*Edge {
if len(ids) == 0 {
return nil
}
out := make(map[string][]*Edge, len(ids))
for _, id := range ids {
if id == "" {
continue
}
if _, ok := out[id]; ok {
continue
}
out[id] = g.GetInEdges(id)
}
return out
}
// EvictFile removes all nodes and edges belonging to the given file
// path. Nodes for one file can span many shards (different IDs hash
// differently), so we lock all shards for this multi-shard operation.
func (g *Graph) EvictFile(filePath string) (nodesRemoved, edgesRemoved int) {
g.lockAllWrite()
defer g.unlockAllWrite()
// Gather nodes across shards.
var nodes []*Node
for _, s := range g.shards {
nodes = append(nodes, s.byFile[filePath]...)
}
if len(nodes) == 0 {
return 0, 0
}
// id → source-repo captured BEFORE we delete the node from
// s.nodes; evictEdgesLocked needs the repo to debit per-repo
// edge counters and the live node would already be gone.
evictedIDs := make(map[string]string, len(nodes))
for _, n := range nodes {
evictedIDs[n.ID] = n.RepoPrefix
}
for _, n := range nodes {
s := g.shardFor(n.ID)
s.repoNodeRemove(n)
delete(s.nodes, n.ID)
if n.QualName != "" {
if cur, ok := s.byQual[n.QualName]; ok && cur.ID == n.ID {
delete(s.byQual, n.QualName)
}
}
removeNodeFromBucket(s.byName, s.byNameIdx, n.Name, n.ID)
removeNodeFromBucket(s.byFile, s.byFileIdx, filePath, n.ID)
if n.RepoPrefix != "" {
removeNodeFromBucket(s.byRepo, s.byRepoIdx, n.RepoPrefix, n.ID)
}
}
nodesRemoved = len(nodes)
edgesRemoved = g.evictEdgesLocked(evictedIDs)
return nodesRemoved, edgesRemoved
}
// evictEdgesLocked is the shared edge-removal core used by EvictFile
// and EvictRepo. Callers must hold every shard's write lock.
//
// For each evicted node we remove its outEdges and inEdges entries. To
// clean the reverse index on non-evicted endpoints we do a swap-with-
// last removal via sidecar, which is O(1) per edge instead of the
// O(slice-size) filterEdge scan the older implementation used.
func (g *Graph) evictEdgesLocked(evictedIDs map[string]string) int {
removed := 0
defer func() {
if removed > 0 {
g.edgeMutGen.Add(1)
}
}()
// Phase 1: remove outgoing edges from every evicted node. Use the
// parallel outEdgeKeys slice to look up each entry's insertion-time
// edgeKey rather than recomputing keyOf — the latter races with
// resolver-driven Edge.To mutations elsewhere in the graph and
// can yield a key that doesn't match the inEdges sidecar.
for id, srcRepo := range evictedIDs {
s := g.shardFor(id)
edges := s.outEdges[id]
keys := s.outEdgeKeys[id]
removed += len(edges)
for i, e := range edges {
s.repoEdgeRemove(srcRepo, e)
if _, evicted := evictedIDs[e.To]; !evicted {
sTo := g.shardFor(e.To)
removeEdgeFromBucket(sTo.inEdges, sTo.inEdgeKeys, sTo.inEdgeIdx, e.To, keys[i])
}
}
delete(s.outEdges, id)
delete(s.outEdgeKeys, id)
delete(s.outEdgeIdx, id)
}
// Phase 2: remove incoming edges to every evicted node (from
// non-evicted sources — same-direction edges were already handled
// in phase 1 and counted). Each surviving source's repo is read
// from its live node — sFrom.nodes[e.From] is still present in
// this phase because that source wasn't evicted.
for id := range evictedIDs {
s := g.shardFor(id)
edges := s.inEdges[id]
keys := s.inEdgeKeys[id]
for i, e := range edges {
if _, evicted := evictedIDs[e.From]; !evicted {
removed++
sFrom := g.shardFor(e.From)
var srcRepo string
if src, ok := sFrom.nodes[e.From]; ok && src != nil {
srcRepo = src.RepoPrefix
}
removeEdgeFromBucket(sFrom.outEdges, sFrom.outEdgeKeys, sFrom.outEdgeIdx, e.From, keys[i])
sFrom.repoEdgeRemove(srcRepo, e)
}
}
delete(s.inEdges, id)
delete(s.inEdgeKeys, id)
delete(s.inEdgeIdx, id)
}
return removed
}
// RemoveEdge removes a specific edge by from, to, and kind. Returns
// true if the edge was found and removed. When multiple edges match
// (same from/to/kind but different file/line — rare but possible),
// removes the first one encountered.
func (g *Graph) RemoveEdge(from, to string, kind EdgeKind) bool {
unlock := g.lockTwoWrite(from, to)
defer unlock()
sFrom := g.shardFor(from)
outList := sFrom.outEdges[from]
outKeys := sFrom.outEdgeKeys[from]
targetIdx := -1
for i, e := range outList {
if e.To == to && e.Kind == kind {
targetIdx = i
break
}
}
if targetIdx < 0 {
return false
}
// Snapshot the edge plus its source-repo before mutating the
// buckets — once removeEdgeFromBucket swaps the tail in, outList[i]
// is no longer the edge we're removing.
removed := outList[targetIdx]
var srcRepo string
if src, ok := sFrom.nodes[from]; ok && src != nil {
srcRepo = src.RepoPrefix
}
// Use the stored insertion-time key rather than keyOf(target) so
// removal is robust against in-flight Edge.To mutations.
k := outKeys[targetIdx]
removeEdgeFromBucket(sFrom.outEdges, sFrom.outEdgeKeys, sFrom.outEdgeIdx, from, k)
sTo := g.shardFor(to)
removeEdgeFromBucket(sTo.inEdges, sTo.inEdgeKeys, sTo.inEdgeIdx, to, k)
sFrom.repoEdgeRemove(srcRepo, removed)
g.edgeMutGen.Add(1)
return true
}
// NodeCount returns the total number of nodes.
func (g *Graph) NodeCount() int {
total := 0
for _, s := range g.shards {
s.mu.RLock()
total += len(s.nodes)
s.mu.RUnlock()
}
return total
}
// EdgeCount returns the total number of edges.
func (g *Graph) EdgeCount() int {
total := 0
for _, s := range g.shards {
s.mu.RLock()
for _, edges := range s.outEdges {
total += len(edges)
}
s.mu.RUnlock()
}
return total
}
// AllNodes returns a snapshot of all nodes. Locks every shard for read
// to produce a coherent view — callers use this for snapshots,
// contracts extraction, etc. where a consistent crop matters.
func (g *Graph) AllNodes() []*Node {
g.lockAllRead()
defer g.unlockAllRead()
total := 0
for _, s := range g.shards {
total += len(s.nodes)
}
out := make([]*Node, 0, total)
for _, s := range g.shards {
for _, n := range s.nodes {
out = append(out, n)
}
}
return out
}
// AllEdges returns a snapshot of all outgoing edges across every shard.
//
// Cached by the edgeMutGen counter: once built, subsequent calls
// return the same slice pointer as long as no edge has been
// inserted, removed, or had its slot pointer replaced. Mutations
// bump edgeMutGen, the next AllEdges sees the mismatch and rebuilds.
//
// On a 4 M-edge graph (k8s) one snapshot is ~32 MB of pointer slice;
// the post-resolve analysis fan-out (cycles, communities, deadcode,
// hierarchy, pagerank, hits, betweenness, several MCP analyzers) used
// to call this dozens of times per cold-index, all allocating fresh
// — 2.72 GB / 8 % of total in the heap profile. Caching collapses
// that to a single allocation per generation.
//
// Callers MUST treat the returned slice as read-only. Mutating its
// pointers is a data race against any concurrent reader holding the
// same cached reference. The underlying *Edge structs are themselves
// shared with the graph and may be mutated by ReindexEdge /
// SetEdgeProvenance — that's intentional, and readers see those
// mutations through the pointer.
func (g *Graph) AllEdges() []*Edge {
curGen := g.edgeMutGen.Load()
g.allEdgesCacheMu.Lock()
defer g.allEdgesCacheMu.Unlock()
if g.allEdgesCache != nil && g.allEdgesCacheGen == curGen {
return g.allEdgesCache
}
g.lockAllRead()
// Pre-size from the per-shard outEdges entry counts. EdgeCount
// would re-lock; just sum inline while holding the locks.
total := 0
for _, s := range g.shards {
for _, edges := range s.outEdges {
total += len(edges)
}
}
out := make([]*Edge, 0, total)
for _, s := range g.shards {
for _, edges := range s.outEdges {
out = append(out, edges...)
}
}
g.unlockAllRead()
g.allEdgesCache = out
g.allEdgesCacheGen = curGen
return out
}
// DrainNodes yields every node and FREES the graph's internal node
// storage shard-by-shard as it goes. After Drain finishes the graph
// holds zero nodes. Intended for the one-shot persist path where the
// shadow is about to be discarded: AllNodes would pin the full 11 GB
// graph for the entire persist phase; Drain releases each shard's
// node map (and the per-name / per-file / per-repo indexes) as soon
// as that shard's iteration completes, so GC can reclaim ~700 MB at
// a time on a Linux-scale graph instead of waiting for the indexer's
// defer to return.
//
// The graph remains structurally consistent during Drain — edges and
// other indexes are untouched, only the node maps are emptied. If
// you also need DrainEdges, call them in either order; both are
// destructive and idempotent (a second call yields nothing).
func (g *Graph) DrainNodes() iter.Seq[*Node] {
return func(yield func(*Node) bool) {
for _, s := range g.shards {
s.mu.Lock()
nodes := s.nodes
// Replace with an empty map so the shard's read methods
// keep working (return zero) instead of nil-panicking.
s.nodes = map[string]*Node{}
s.byFile = map[string][]*Node{}
s.byName = map[string][]*Node{}
s.byQual = map[string]*Node{}
s.byRepo = map[string][]*Node{}
s.byFileIdx = map[string]map[string]int{}
s.byNameIdx = map[string]map[string]int{}
s.byRepoIdx = map[string]map[string]int{}
s.mu.Unlock()
for _, n := range nodes {
if !yield(n) {
return
}
}
// nodes goes out of scope here — the shard's old map plus
// every *Node it referenced is now GC-eligible (assuming
// the caller has dropped any remaining reference).
}
}
}
// DrainEdges yields every edge and FREES the graph's internal edge
// storage shard-by-shard. Same semantics as DrainNodes — meant for
// the persist hand-off, not for general queries.
func (g *Graph) DrainEdges() iter.Seq[*Edge] {
// Invalidate the AllEdges cache so any subsequent caller doesn't
// see drained-shard zombies. The cache holds direct *Edge slice
// references that DrainEdges is about to start freeing.
g.allEdgesCacheMu.Lock()
g.allEdgesCache = nil
g.allEdgesCacheGen = 0
g.allEdgesCacheMu.Unlock()
return func(yield func(*Edge) bool) {
for _, s := range g.shards {
s.mu.Lock()
outEdges := s.outEdges
s.outEdges = map[string][]*Edge{}
s.inEdges = map[string][]*Edge{}
s.outEdgeIdx = map[string]map[edgeHash]int{}
s.inEdgeIdx = map[string]map[edgeHash]int{}
s.outEdgeKeys = map[string][]edgeHash{}
s.inEdgeKeys = map[string][]edgeHash{}
s.mu.Unlock()
for _, edges := range outEdges {
for _, e := range edges {
if !yield(e) {
return
}
}
}
}
}
}
// Stats returns summary counts by kind and language.
func (g *Graph) Stats() GraphStats {
g.lockAllRead()
defer g.unlockAllRead()
byKind := make(map[string]int)
byLang := make(map[string]int)
totalNodes := 0
for _, s := range g.shards {
for _, n := range s.nodes {
// Cross-daemon proxy-edge nodes stand in for symbols a
// remote daemon owns; they are never counted in local
// stats. Inert until edge-minting is enabled.
if IsProxyNode(n) {
continue
}
totalNodes++
byKind[string(n.Kind)]++
if n.Language != "" {
byLang[n.Language]++
}
}
}
edgeCount := 0
for _, s := range g.shards {
for _, edges := range s.outEdges {
edgeCount += len(edges)
}
}
return GraphStats{
TotalNodes: totalNodes,
TotalEdges: edgeCount,
ByKind: byKind,
ByLanguage: byLang,
}
}
// GetRepoNodes returns all nodes belonging to the given repository
// prefix. Each shard holds a byRepo slice for nodes it owns; we
// aggregate across shards.
func (g *Graph) GetRepoNodes(repoPrefix string) []*Node {
var out []*Node
for _, s := range g.shards {
s.mu.RLock()
if src := s.byRepo[repoPrefix]; len(src) > 0 {
out = append(out, src...)
}
s.mu.RUnlock()
}
return out
}
// GetRepoEdges returns every edge whose source node has the given
// RepoPrefix — the in-memory reference implementation of the
// Store-interface method. Walks each shard's byRepo bucket and
// concatenates that node's outEdges in place (no per-node
// GetOutEdges call, so no per-call slice copy). Equivalent in
// observable behaviour to the GetRepoNodes(r) × GetOutEdges loop
// callers used before this method existed; meant to give disk
// backends a single-query hook without changing in-memory cost.
// Empty repoPrefix returns nil (callers use AllEdges() instead).
func (g *Graph) GetRepoEdges(repoPrefix string) []*Edge {
if repoPrefix == "" {
return nil
}
var out []*Edge
for _, s := range g.shards {
s.mu.RLock()
for _, n := range s.byRepo[repoPrefix] {
if src := s.outEdges[n.ID]; len(src) > 0 {
out = append(out, src...)
}
}
s.mu.RUnlock()
}
return out
}
// EvictRepo removes all nodes with matching RepoPrefix and all edges
// referencing those nodes. Returns counts of removed nodes and edges.
func (g *Graph) EvictRepo(repoPrefix string) (nodesRemoved, edgesRemoved int) {
g.lockAllWrite()
defer g.unlockAllWrite()
var nodes []*Node
for _, s := range g.shards {
nodes = append(nodes, s.byRepo[repoPrefix]...)
}
if len(nodes) == 0 {
return 0, 0
}
evictedIDs := make(map[string]string, len(nodes))
for _, n := range nodes {
evictedIDs[n.ID] = repoPrefix
}
for _, n := range nodes {
s := g.shardFor(n.ID)
s.repoNodeRemove(n)
delete(s.nodes, n.ID)
if n.QualName != "" {
if cur, ok := s.byQual[n.QualName]; ok && cur.ID == n.ID {
delete(s.byQual, n.QualName)
}
}
removeNodeFromBucket(s.byName, s.byNameIdx, n.Name, n.ID)
removeNodeFromBucket(s.byFile, s.byFileIdx, n.FilePath, n.ID)
removeNodeFromBucket(s.byRepo, s.byRepoIdx, repoPrefix, n.ID)
}
nodesRemoved = len(nodes)
edgesRemoved = g.evictEdgesLocked(evictedIDs)
return nodesRemoved, edgesRemoved
}
// RepoStats returns per-repository node and edge counts.
func (g *Graph) RepoStats() map[string]GraphStats {
g.lockAllRead()
defer g.unlockAllRead()
// Aggregate byRepo across shards first.
repoNodes := make(map[string][]*Node)
for _, s := range g.shards {
for prefix, nodes := range s.byRepo {
repoNodes[prefix] = append(repoNodes[prefix], nodes...)
}
}
stats := make(map[string]GraphStats, len(repoNodes))
repoByKind := make(map[string]map[string]int)
repoByLang := make(map[string]map[string]int)
repoNodeCount := make(map[string]int)
for prefix, nodes := range repoNodes {
repoNodeCount[prefix] = len(nodes)
byKind := make(map[string]int)
byLang := make(map[string]int)
for _, n := range nodes {
byKind[string(n.Kind)]++
if n.Language != "" {
byLang[n.Language]++
}
}
repoByKind[prefix] = byKind
repoByLang[prefix] = byLang
}
// Count edges per repo by the From node's repo. Need to look up the
// From node in whichever shard owns it.
repoEdgeCount := make(map[string]int)
for _, s := range g.shards {
for _, edges := range s.outEdges {
for _, e := range edges {
fromShard := g.shardFor(e.From)
if fromNode, ok := fromShard.nodes[e.From]; ok && fromNode.RepoPrefix != "" {
repoEdgeCount[fromNode.RepoPrefix]++
}
}
}
}
for prefix := range repoNodes {
stats[prefix] = GraphStats{
TotalNodes: repoNodeCount[prefix],
TotalEdges: repoEdgeCount[prefix],
ByKind: repoByKind[prefix],
ByLanguage: repoByLang[prefix],
}
}
return stats
}
// RepoMemoryEstimate is an approximate breakdown of how many bytes a
// single repository's graph contribution occupies. It covers the
// sharded node/edge maps only — search and vector indexes are
// orthogonal and computed elsewhere.
type RepoMemoryEstimate struct {
NodeBytes uint64 `json:"node_bytes"`
EdgeBytes uint64 `json:"edge_bytes"`
NodeCount int `json:"node_count"`
EdgeCount int `json:"edge_count"`
}
// Total returns the sum of NodeBytes and EdgeBytes.
func (e RepoMemoryEstimate) Total() uint64 { return e.NodeBytes + e.EdgeBytes }
// per-node fixed overhead: the struct header plus the amortised cost
// of the pointers held by byRepo/byFile/byName/byQual secondary
// indexes inside each shard (4 maps × ~24 bytes for map bucket + slice
// element ≈ 100 bytes). Tuned against runtime.ReadMemStats deltas on a
// 50k-node repo; within ~10% of actual.
const nodeStructOverhead = 240
// per-edge fixed overhead: two string pointers, kind, filepath, line,
// plus slice-header and adjacency-map amortisation for outEdges AND
// inEdges (every edge is stored once as a struct but is referenced from
// both the source's out-adjacency list and the target's in-adjacency
// list, so the amortised overhead is ~2× slice-element + map-bucket).
const edgeStructOverhead = 144
// RepoMemoryEstimate sums the running per-shard counters maintained
// by AddNode / AddEdge / RemoveEdge / EvictFile / EvictRepo. O(shard
// count) instead of the O(repo nodes + total edges) walk this used to
// do — relevant because daemon-status queries call this once per
// tracked repo, and on a 488-repo / 1.9M-edge graph the old
// implementation was the single biggest source of writer contention
// during warmup.
func (g *Graph) RepoMemoryEstimate(repoPrefix string) RepoMemoryEstimate {
g.lockAllRead()
defer g.unlockAllRead()
var est RepoMemoryEstimate
for _, s := range g.shards {
est.NodeBytes += s.repoNodeBytes[repoPrefix]
est.NodeCount += s.repoNodeCount[repoPrefix]
est.EdgeBytes += s.repoEdgeBytes[repoPrefix]
est.EdgeCount += s.repoEdgeCount[repoPrefix]
}
return est
}
// AllRepoMemoryEstimates returns the per-repo estimate for every
// repo with a tracked counter — one pass across shards, one read lock
// acquisition. Callers driving a per-repo loop (daemon status) should
// prefer this over the single-repo variant.
func (g *Graph) AllRepoMemoryEstimates() map[string]RepoMemoryEstimate {
g.lockAllRead()
defer g.unlockAllRead()
out := make(map[string]RepoMemoryEstimate)
for _, s := range g.shards {
for prefix, bytes := range s.repoNodeBytes {
est := out[prefix]
est.NodeBytes += bytes
est.NodeCount += s.repoNodeCount[prefix]
out[prefix] = est
}
for prefix, bytes := range s.repoEdgeBytes {
est := out[prefix]
est.EdgeBytes += bytes
est.EdgeCount += s.repoEdgeCount[prefix]
out[prefix] = est
}
}
return out
}
// nodeBytes estimates the memory footprint of a single graph.Node.
func nodeBytes(n *Node) uint64 {
if n == nil {
return 0
}
b := uint64(nodeStructOverhead)
b += uint64(len(n.ID) + len(n.Name) + len(n.QualName) + len(n.FilePath) + len(n.Language) + len(n.RepoPrefix))
b += metaBytes(n.Meta)
return b
}
// edgeBytes estimates the memory footprint of a single graph.Edge.
func edgeBytes(e *Edge) uint64 {
if e == nil {
return 0
}
b := uint64(edgeStructOverhead)
b += uint64(len(e.From) + len(e.To) + len(e.Kind) + len(e.FilePath))
return b
}
// metaBytes approximates the size of a Node.Meta map. Only handles the
// kinds of values we actually produce (string, bool, numeric, nested
// map, []string) — more exotic types fall back to a conservative
// constant rather than reflecting recursively.
func metaBytes(m map[string]any) uint64 {
if m == nil {
return 0
}
// map header + bucket amortisation for small maps.
b := uint64(48 + 8*len(m))
for k, v := range m {
b += uint64(len(k)) + 16 // key entry overhead
switch val := v.(type) {
case string:
b += uint64(len(val)) + 16
case bool:
b += 1 + 16
case int, int32, int64, uint, uint32, uint64, float32, float64:
b += 8 + 16
case []string:
b += 24 // slice header
for _, s := range val {
b += uint64(len(s)) + 16
}
case map[string]any:
b += metaBytes(val)
default:
b += 32 // unknown — leave a sensible estimate
}
}
return b
}
// RepoPrefixes returns a list of unique repository prefixes in the
// graph.
func (g *Graph) RepoPrefixes() []string {
seen := make(map[string]struct{})
for _, s := range g.shards {
s.mu.RLock()
for prefix := range s.byRepo {
seen[prefix] = struct{}{}
}
s.mu.RUnlock()
}
prefixes := make([]string, 0, len(seen))
for prefix := range seen {
prefixes = append(prefixes, prefix)
}
return prefixes
}
// InDegreeForNodes is the in-memory reference implementation of the
// InDegreeForNodes capability. Walks the per-target in-edge buckets
// directly — the same arithmetic the disk backend pushes into a single
// server-side COUNT.
func (g *Graph) InDegreeForNodes(ids []string) map[string]int {
if len(ids) == 0 {
return nil
}
out := make(map[string]int, len(ids))
for _, id := range ids {
if id == "" {
continue
}
c := len(g.GetInEdges(id))
if c == 0 {
continue
}
out[id] = c
}
return out
}
// ReachableForwardByKinds is the in-memory reference implementation
// of the ReachableForwardByKinds capability. Layer-by-layer BFS from
// the seed frontier, following only edges whose Kind is in the
// supplied set. Pure map / slice walks here — the win is the disk
// backend folds the BFS into one variable-length match.
func (g *Graph) ReachableForwardByKinds(seeds []string, kinds []EdgeKind) map[string]bool {
if len(seeds) == 0 {
return nil
}
covered := make(map[string]bool, len(seeds))
frontier := make([]string, 0, len(seeds))
for _, id := range seeds {
if id == "" || covered[id] {
continue
}
covered[id] = true
frontier = append(frontier, id)
}
if len(kinds) == 0 {
return covered
}
allowed := make(map[EdgeKind]struct{}, len(kinds))
for _, k := range kinds {
allowed[k] = struct{}{}
}
for len(frontier) > 0 {
next := frontier[:0:0]
for _, id := range frontier {
for _, e := range g.GetOutEdges(id) {
if e == nil {
continue
}
if _, ok := allowed[e.Kind]; !ok {
continue
}
if !covered[e.To] {
covered[e.To] = true
next = append(next, e.To)
}
}
}
frontier = next
}
return covered
}
// ThrowerErrorSurface is the in-memory reference implementation of
// the ThrowerErrorSurfacer capability. Walks EdgeThrows once for the
// per-thrower target dedup, then walks each thrower's out-edges for
// the EdgeEmits → KindString(context=error_msg) attachment. The disk
// backend collapses both passes into two server-side GROUP BYs.
func (g *Graph) ThrowerErrorSurface(pathPrefix string) []ThrowerErrorRow {
byThrower := map[string]*ThrowerErrorRow{}
addUnique := func(set []string, v string) []string {
if slices.Contains(set, v) {
return set
}
return append(set, v)
}
for e := range g.EdgesByKind(EdgeThrows) {
if e == nil {
continue
}
if pathPrefix != "" && !strings.HasPrefix(e.FilePath, pathPrefix) {
continue
}
row, ok := byThrower[e.From]
if !ok {
file := e.FilePath
line := e.Line
n := g.GetNode(e.From)
if n != nil {
if file == "" {
file = n.FilePath
}
if line == 0 {
line = n.StartLine
}
}
row = &ThrowerErrorRow{ThrowerID: e.From, FilePath: file, Line: line}
byThrower[e.From] = row
}
row.Throws++
row.ErrorTargets = addUnique(row.ErrorTargets, e.To)
}
for thrower, row := range byThrower {
for _, e := range g.GetOutEdges(thrower) {
if e == nil || e.Kind != EdgeEmits {
continue
}
n := g.GetNode(e.To)
if n == nil || n.Kind != KindString {
continue
}
ctxLabel, _ := n.Meta["context"].(string)
if ctxLabel != "error_msg" {
continue
}
row.ErrorMsgs = addUnique(row.ErrorMsgs, n.Name)
}
}
out := make([]ThrowerErrorRow, 0, len(byThrower))
for _, r := range byThrower {
out = append(out, *r)
}
return out
}
// MemberMethodsByType is the in-memory reference implementation of the
// MemberMethodsByType capability. One EdgesByKind(EdgeMemberOf) walk
// joined with the in-memory node table to filter Kind == KindMethod
// and project the four columns the resolver consumes — the exact
// loop the resolver runs today, just exposed as a single method call
// so the disk backend can fold the join into one query.
//
// Empty graph returns nil. Per-type method lists are deduplicated by
// MethodID so a method that appears twice in the EdgeMemberOf bucket
// (defensive against double-insertion) yields a single row.
func (g *Graph) MemberMethodsByType() map[string][]MemberMethodInfo {
out := map[string][]MemberMethodInfo{}
seen := map[string]map[string]struct{}{}
for e := range g.EdgesByKind(EdgeMemberOf) {
if e == nil {
continue
}
m := g.GetNode(e.From)
if m == nil || m.Kind != KindMethod {
continue
}
typeID := e.To
dedup := seen[typeID]
if dedup == nil {
dedup = make(map[string]struct{})
seen[typeID] = dedup
}
if _, ok := dedup[m.ID]; ok {
continue
}
dedup[m.ID] = struct{}{}
out[typeID] = append(out[typeID], MemberMethodInfo{
MethodID: m.ID,
Name: m.Name,
FilePath: m.FilePath,
StartLine: m.StartLine,
RepoPrefix: m.RepoPrefix,
})
}
if len(out) == 0 {
return nil
}
return out
}
// StructuralParentEdges is the in-memory reference implementation of
// the StructuralParentEdges capability. Single AllEdges scan with the
// (Extends | Implements | Composes) kind gate and the
// (Type | Interface) endpoint-kind gate applied per edge.
//
// Empty graph or no matching edges returns nil.
func (g *Graph) StructuralParentEdges() []StructuralParentEdgeRow {
var out []StructuralParentEdgeRow
for _, e := range g.AllEdges() {
if e == nil {
continue
}
switch e.Kind {
case EdgeExtends, EdgeImplements, EdgeComposes:
default:
continue
}
from := g.GetNode(e.From)
to := g.GetNode(e.To)
if from == nil || to == nil {
continue
}
if from.Kind != KindType && from.Kind != KindInterface {
continue
}
if to.Kind != KindType && to.Kind != KindInterface {
continue
}
out = append(out, StructuralParentEdgeRow{
FromID: from.ID,
ToID: to.ID,
FromKind: from.Kind,
ToKind: to.Kind,
Origin: e.Origin,
})
}
return out
}
// CrossRepoCandidates is the in-memory reference implementation of the
// CrossRepoCandidates capability. Single AllEdges scan with the
// edge-kind gate + the (non-empty, distinct) repo-prefix gate. Returns
// one row per surviving edge carrying the underlying Edge pointer plus
// the two RepoPrefix values projected from the endpoints.
//
// Empty baseKinds returns nil — matches the disk-backend contract.
// Single-repo graphs (or graphs whose nodes carry no RepoPrefix)
// return no rows because the prefix gate filters them out.
func (g *Graph) CrossRepoCandidates(baseKinds []EdgeKind) []CrossRepoCandidateRow {
if len(baseKinds) == 0 {
return nil
}
kset := make(map[EdgeKind]struct{}, len(baseKinds))
for _, k := range baseKinds {
if k == "" {
continue
}
kset[k] = struct{}{}
}
if len(kset) == 0 {
return nil
}
var out []CrossRepoCandidateRow
for _, e := range g.AllEdges() {
if e == nil {
continue
}
if _, ok := kset[e.Kind]; !ok {
continue
}
from := g.GetNode(e.From)
to := g.GetNode(e.To)
if from == nil || to == nil {
continue
}
if from.RepoPrefix == "" || to.RepoPrefix == "" {
continue
}
if from.RepoPrefix == to.RepoPrefix {
continue
}
out = append(out, CrossRepoCandidateRow{
Edge: e,
FromRepo: from.RepoPrefix,
ToRepo: to.RepoPrefix,
})
}
return out
}
// bfsHopLess orders two discovery candidates for the SAME node at the
// SAME depth: smaller ParentID first, then smaller EdgeKind. This is the
// tie-break the SQLite implementation applies via
// ROW_NUMBER() OVER (PARTITION BY node_id ORDER BY depth, parent_id,
// edge_kind), so both backends settle on the identical discovery edge.
func bfsHopLess(a, b BFSHop) bool {
if a.ParentID != b.ParentID {
return a.ParentID < b.ParentID
}
return a.EdgeKind < b.EdgeKind
}
// BFS is the in-memory reference implementation of the BFSCapable
// capability — the oracle the SQLite recursive-CTE walk is shadow-tested
// against. Layer-by-layer BFS over GetOutEdges / GetInEdges; see the
// BFSCapable doc for the exact reachability / determinism / bound
// semantics. Always returns a nil error (the error in the signature is
// for the disk implementation's query failures).
func (g *Graph) BFS(seeds []string, dir Direction, kinds []EdgeKind, maxDepth, limit int) ([]BFSHop, error) {
if len(seeds) == 0 {
return nil, nil
}
kset := make(map[EdgeKind]struct{}, len(kinds))
for _, k := range kinds {
if k != "" {
kset[k] = struct{}{}
}
}
forward := dir != DirectionBackward
// chosen[node] = the winning hop for that node (min depth, then the
// (parent, kind)-smallest discovery edge). Once a node is in chosen it
// is settled at its minimum depth and never revisited — that, plus the
// maxDepth bound, is what terminates a cyclic graph.
chosen := make(map[string]BFSHop, len(seeds))
frontier := make([]string, 0, len(seeds))
for _, s := range seeds {
if s == "" {
continue
}
if _, ok := chosen[s]; ok {
continue
}
chosen[s] = BFSHop{NodeID: s, Depth: 0}
frontier = append(frontier, s)
}
if len(kset) > 0 && maxDepth > 0 {
for depth := 0; depth < maxDepth && len(frontier) > 0; depth++ {
// Collect every depth+1 candidate across the WHOLE frontier
// before settling, so a node reached by several same-layer
// parents converges on the deterministic (parent, kind)-smallest
// — matching the CTE's ROW_NUMBER pick.
cand := make(map[string]BFSHop)
for _, cur := range frontier {
var edges []*Edge
if forward {
edges = g.GetOutEdges(cur)
} else {
edges = g.GetInEdges(cur)
}
for _, e := range edges {
if e == nil {
continue
}
if _, ok := kset[e.Kind]; !ok {
continue
}
nb := e.To
if !forward {
nb = e.From
}
if nb == "" {
continue
}
if _, ok := chosen[nb]; ok {
continue // already settled at a shallower-or-equal depth
}
// Node-backed targets only: an edge to an unresolved /
// external stub (no node row) is not a reachable hop.
if g.GetNode(nb) == nil {
continue
}
h := BFSHop{NodeID: nb, Depth: depth + 1, ParentID: cur, EdgeKind: e.Kind}
if existing, ok := cand[nb]; !ok || bfsHopLess(h, existing) {
cand[nb] = h
}
}
}
next := make([]string, 0, len(cand))
for nb, h := range cand {
chosen[nb] = h
next = append(next, nb)
}
frontier = next
}
}
out := make([]BFSHop, 0, len(chosen))
for _, h := range chosen {
out = append(out, h)
}
slices.SortFunc(out, func(a, b BFSHop) int {
if a.Depth != b.Depth {
return a.Depth - b.Depth
}
if a.NodeID < b.NodeID {
return -1
}
if a.NodeID > b.NodeID {
return 1
}
return 0
})
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out, nil
}
// ExtractCandidates is the in-memory reference implementation of
// ExtractCandidatesScanner. Walks NodesByKind for function + method,
// applies the threshold gates locally, and counts distinct in-edge
// From / out-edge To values restricted to the requested edge kinds.
func (g *Graph) ExtractCandidates(
kinds []EdgeKind,
minLines, minCallers, minFanOut int,
pathPrefix string,
) []ExtractCandidateRow {
if len(kinds) == 0 {
return nil
}
kset := make(map[EdgeKind]struct{}, len(kinds))
for _, k := range kinds {
if k == "" {
continue
}
kset[k] = struct{}{}
}
if len(kset) == 0 {
return nil
}
var out []ExtractCandidateRow
for _, n := range g.NodesByKinds([]NodeKind{KindFunction, KindMethod}) {
if n == nil {
continue
}
if pathPrefix != "" && !strings.HasPrefix(n.FilePath, pathPrefix) {
continue
}
if n.StartLine == 0 || n.EndLine == 0 {
continue
}
lineCount := n.EndLine - n.StartLine + 1
if lineCount < minLines {
continue
}
callerSet := make(map[string]struct{})
for _, e := range g.GetInEdges(n.ID) {
if e == nil {
continue
}
if _, ok := kset[e.Kind]; !ok {
continue
}
callerSet[e.From] = struct{}{}
}
if len(callerSet) < minCallers {
continue
}
calleeSet := make(map[string]struct{})
for _, e := range g.GetOutEdges(n.ID) {
if e == nil {
continue
}
if _, ok := kset[e.Kind]; !ok {
continue
}
calleeSet[e.To] = struct{}{}
}
if len(calleeSet) < minFanOut {
continue
}
out = append(out, ExtractCandidateRow{
NodeID: n.ID,
Name: n.Name,
FilePath: n.FilePath,
StartLine: n.StartLine,
EndLine: n.EndLine,
LineCount: lineCount,
CallerCount: len(callerSet),
FanOut: len(calleeSet),
})
}
return out
}
// FileSymbolNamesByPaths is the in-memory reference implementation of
// the FileSymbolNamesByPaths capability. Walks GetFileNodes for every
// input path, keeps the requested kinds, and emits one row per
// (path, name) pair. Duplicates within a file collapse to a single
// row (a method declared once per file emits once regardless of how
// many times the indexer touched it).
func (g *Graph) FileSymbolNamesByPaths(paths []string, kinds []NodeKind) []FileSymbolNameRow {
if len(paths) == 0 {
return nil
}
kset := make(map[NodeKind]struct{}, len(kinds))
for _, k := range kinds {
if k == "" {
continue
}
kset[k] = struct{}{}
}
seen := make(map[string]struct{})
dedupKey := func(p, name string) string { return p + "\x00" + name }
var out []FileSymbolNameRow
for _, p := range paths {
if p == "" {
continue
}
for _, n := range g.GetFileNodes(p) {
if n == nil || n.Name == "" {
continue
}
if len(kset) > 0 {
if _, ok := kset[n.Kind]; !ok {
continue
}
}
k := dedupKey(p, n.Name)
if _, ok := seen[k]; ok {
continue
}
seen[k] = struct{}{}
out = append(out, FileSymbolNameRow{FilePath: p, Name: n.Name})
}
}
return out
}
// ClassHierarchyTraverse is the in-memory reference implementation of
// ClassHierarchyTraverser. Performs the same BFS as
// query.ClassHierarchy, but stops at the kind/depth gates and returns
// the full Path + EdgeKinds for each terminal node reached so the
// disk backend's variable-length match can be a drop-in
// replacement. Direction "up" follows out-edges; "down" follows
// in-edges.
func (g *Graph) ClassHierarchyTraverse(
seedID string,
direction string,
kinds []EdgeKind,
depth int,
) []ClassHierarchyRow {
if seedID == "" || depth <= 0 || len(kinds) == 0 {
return nil
}
kset := make(map[EdgeKind]struct{}, len(kinds))
for _, k := range kinds {
if k == "" {
continue
}
kset[k] = struct{}{}
}
if len(kset) == 0 {
return nil
}
if g.GetNode(seedID) == nil {
return nil
}
walkUp := direction == "up"
walkDown := direction == "down"
if !walkUp && !walkDown {
return nil
}
type queued struct {
id string
path []string
edgeKinds []EdgeKind
hops int
}
visited := map[string]struct{}{seedID: {}}
queue := []queued{{id: seedID, path: nil, edgeKinds: nil, hops: 0}}
var out []ClassHierarchyRow
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
if cur.hops >= depth {
continue
}
var edges []*Edge
if walkUp {
edges = g.GetOutEdges(cur.id)
} else {
edges = g.GetInEdges(cur.id)
}
for _, e := range edges {
if e == nil {
continue
}
if _, ok := kset[e.Kind]; !ok {
continue
}
var nb string
if walkUp {
nb = e.To
} else {
nb = e.From
}
if nb == "" {
continue
}
if _, ok := visited[nb]; ok {
continue
}
visited[nb] = struct{}{}
newPath := append([]string(nil), cur.path...)
newPath = append(newPath, nb)
newKinds := append([]EdgeKind(nil), cur.edgeKinds...)
newKinds = append(newKinds, e.Kind)
out = append(out, ClassHierarchyRow{
Path: newPath,
EdgeKinds: newKinds,
})
queue = append(queue, queued{id: nb, path: newPath, edgeKinds: newKinds, hops: cur.hops + 1})
}
}
return out
}
// FileEditingContext is the in-memory reference implementation of the
// FileEditingContext capability. Performs the equivalent of
// GetFileSymbols + per-function GetCallers/GetCallChain but bounded
// to the call/method node set, so the disk backend's batched query
// returns the same projection. The kinds parameter is the set of
// kinds treated as call targets (function + method).
func (g *Graph) FileEditingContext(filePath string, kinds []NodeKind) *FileEditingContextResult {
if filePath == "" {
return nil
}
nodes := g.GetFileNodes(filePath)
if len(nodes) == 0 {
return nil
}
kset := make(map[NodeKind]struct{}, len(kinds))
for _, k := range kinds {
if k == "" {
continue
}
kset[k] = struct{}{}
}
res := &FileEditingContextResult{}
var fileNodeID string
var defNodeIDs []string
for _, n := range nodes {
if n == nil {
continue
}
if n.Kind == KindFile {
res.FileNode = n
fileNodeID = n.ID
continue
}
res.Defines = append(res.Defines, n)
if _, ok := kset[n.Kind]; ok {
defNodeIDs = append(defNodeIDs, n.ID)
}
}
if fileNodeID != "" {
for _, e := range g.GetOutEdges(fileNodeID) {
if e == nil {
continue
}
if e.Kind == EdgeImports {
res.Imports = append(res.Imports, e)
}
}
}
if len(defNodeIDs) == 0 {
return res
}
inEdges := g.GetInEdgesByNodeIDs(defNodeIDs)
outEdges := g.GetOutEdgesByNodeIDs(defNodeIDs)
callerIDSet := make(map[string]struct{})
calleeIDSet := make(map[string]struct{})
for _, id := range defNodeIDs {
for _, e := range inEdges[id] {
if e == nil || e.Kind != EdgeCalls {
continue
}
if e.From == "" {
continue
}
callerIDSet[e.From] = struct{}{}
}
for _, e := range outEdges[id] {
if e == nil || e.Kind != EdgeCalls {
continue
}
if e.To == "" {
continue
}
calleeIDSet[e.To] = struct{}{}
}
}
callerIDs := make([]string, 0, len(callerIDSet))
for id := range callerIDSet {
callerIDs = append(callerIDs, id)
}
calleeIDs := make([]string, 0, len(calleeIDSet))
for id := range calleeIDSet {
calleeIDs = append(calleeIDs, id)
}
callerNodes := g.GetNodesByIDs(callerIDs)
calleeNodes := g.GetNodesByIDs(calleeIDs)
for _, id := range callerIDs {
n := callerNodes[id]
if n == nil || n.FilePath == filePath {
continue
}
res.CalledBy = append(res.CalledBy, n)
}
for _, id := range calleeIDs {
n := calleeNodes[id]
if n == nil || n.FilePath == filePath {
continue
}
res.Calls = append(res.Calls, n)
}
return res
}
// GetFileSubGraph is the in-memory reference implementation of the
// FileSubGraphReader capability. Iterates the existing per-file
// byFile bucket and the per-node outEdges / inEdges shards — the
// same lookups Engine.GetFileSymbols' fallback path already runs,
// just collapsed behind one method so the disk backend can push the
// whole walk into a single query.
func (g *Graph) GetFileSubGraph(filePath string) ([]*Node, []*Edge) {
if filePath == "" {
return nil, nil
}
nodes := g.GetFileNodes(filePath)
if len(nodes) == 0 {
return nil, nil
}
ids := make([]string, 0, len(nodes))
for _, n := range nodes {
if n != nil && n.ID != "" {
ids = append(ids, n.ID)
}
}
outByID := g.GetOutEdgesByNodeIDs(ids)
inByID := g.GetInEdgesByNodeIDs(ids)
type edgeKey struct {
from string
to string
kind EdgeKind
}
seen := make(map[edgeKey]struct{}, 2*len(ids))
edges := make([]*Edge, 0, 2*len(ids))
add := func(e *Edge) {
if e == nil {
return
}
k := edgeKey{from: e.From, to: e.To, kind: e.Kind}
if _, ok := seen[k]; ok {
return
}
seen[k] = struct{}{}
edges = append(edges, e)
}
for _, id := range ids {
for _, e := range outByID[id] {
add(e)
}
for _, e := range inByID[id] {
add(e)
}
}
return nodes, edges
}
// GetFileSubGraphCounts is the in-memory reference implementation of
// FileSubGraphCountReader. The per-node bucket reads are already
// O(1) so it just walks GetFileSubGraph and reports len(edges); the
// row-materialisation win belongs to disk backends.
func (g *Graph) GetFileSubGraphCounts(filePath string) ([]*Node, int) {
nodes, edges := g.GetFileSubGraph(filePath)
return nodes, len(edges)
}
// NodeDegreeByKinds is the in-memory reference implementation of the
// NodeDegreeByKinds capability. Walks NodesByKinds and reads each
// node's in/out edge buckets — the disk backend overrides with one
// kind-filtered aggregation per direction so the IN-list of node IDs
// the legacy NodeDegreeCounts path needed is avoided altogether.
func (g *Graph) NodeDegreeByKinds(kinds []NodeKind, pathPrefix string) []NodeDegreeRow {
if len(kinds) == 0 {
return nil
}
pool := g.NodesByKinds(kinds)
out := make([]NodeDegreeRow, 0, len(pool))
for _, n := range pool {
if n == nil {
continue
}
if pathPrefix != "" && !strings.HasPrefix(n.FilePath, pathPrefix) {
continue
}
out = append(out, NodeDegreeRow{
NodeID: n.ID,
InCount: len(g.GetInEdges(n.ID)),
OutCount: len(g.GetOutEdges(n.ID)),
})
}
return out
}