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

2129 lines
96 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"
"sync"
)
// EdgeReindex is the per-edge payload for ReindexEdges. Edge points
// at the (already mutated) Edge value the caller wants the store to
// re-bind; OldTo is the To target the edge had BEFORE the mutation,
// so the store can drop the stale in-edge index entry for OldTo
// while writing the new one for Edge.To.
type EdgeReindex struct {
Edge *Edge
OldTo string
}
// EdgeProvenanceUpdate is the per-edge payload for
// SetEdgeProvenanceBatch. Edge points at the stored Edge whose
// origin should be promoted; NewOrigin is the target tier. The store
// only persists the change (and bumps EdgeIdentityRevisions) when
// NewOrigin differs from the currently stored Origin.
type EdgeProvenanceUpdate struct {
Edge *Edge
NewOrigin string
}
// Store is the persistence-and-query backend the rest of gortex sees
// behind the *Graph type. The only implementation today is the
// in-memory *Graph; future implementations will include an on-disk
// embedded-DB backend (local single-binary) and a remote network
// client. The interface is the seam that lets the rest of the
// codebase be backend-agnostic.
//
// The method set deliberately mirrors *Graph's current public API so
// the codebase compiles unchanged the day this interface lands. A few
// notes on shape:
//
// - Slice-shaped reads (AllNodes / AllEdges / FindNodesByName / …)
// materialise their result in memory — fine for the in-memory
// store, but disk / remote backends will want iterator-shaped
// variants added alongside as those implementations come online.
//
// - Memory-estimate methods (RepoMemoryEstimate /
// AllRepoMemoryEstimates) are inherently in-memory specific; disk
// and remote backends return whatever they can compute and callers
// treat the result as advisory.
//
// - ResolveMutex() returns a backend-owned mutex that resolver
// instances (cross-repo, temporal, external) share to serialise
// their edge-mutation passes against each other and against the
// indexer's incremental rewrites. Every backend needs equivalent
// coordination; the in-memory store uses its existing
// graph-wide resolveMu, disk backends keep a dedicated mutex
// alongside their own write serialisation. The returned pointer
// is owned by the store and must not be Unlocked when not held.
type Store interface {
// --- Writes -----------------------------------------------------
AddNode(n *Node)
AddBatch(nodes []*Node, edges []*Edge)
AddEdge(e *Edge)
SetEdgeProvenance(e *Edge, newOrigin string) bool
ReindexEdge(e *Edge, oldTo string)
// Batched siblings of the per-edge mutators. Same semantics, but
// disk backends amortise the per-call transaction overhead by
// committing in implementation-chosen chunks (the in-memory
// backend just loops). The resolver fans out per-edge mutations
// across thousands of edges in a single ResolveAll pass, so the
// per-call form was unusable on disk backends without these.
// Callers MUST first mutate the *Edge fields they want persisted
// (To / Kind / Origin / …) before handing the entry over — these
// methods read the post-mutation Edge state and update the
// backend's indexes accordingly.
ReindexEdges(batch []EdgeReindex)
SetEdgeProvenanceBatch(batch []EdgeProvenanceUpdate) (changed int)
RemoveEdge(from, to string, kind EdgeKind) bool
EvictFile(filePath string) (nodesRemoved, edgesRemoved int)
EvictRepo(repoPrefix string) (nodesRemoved, edgesRemoved int)
// --- Point lookups ---------------------------------------------
GetNode(id string) *Node
GetNodeByQualName(qualName string) *Node
// GetNodesByQualNames returns a map qualName→*Node (first match per
// qual_name) for the whole batch — the qual-name twin of
// FindNodesByNames. It pre-warms the resolver's import resolution:
// qual_name is unindexed on the disk backend, so the per-edge
// GetNodeByQualName in resolveImport is a full node scan per import
// edge; one batched IN-scan collapses that to a single query.
GetNodesByQualNames(qualNames []string) map[string]*Node
// --- Name + scope queries --------------------------------------
FindNodesByName(name string) []*Node
FindNodesByNameInRepo(name, repoPrefix string) []*Node
// FindNodesByNameContaining returns nodes whose Name (case-
// insensitive) contains the given substring. The implementation
// pushes the filter into the backend so only matching rows cross
// the cgo boundary — the old search-substring fallback's
// AllNodes()-then-filter pattern materialised the whole node set
// per query and breaks at Linux-kernel scale (10M+ symbols).
// limit caps the result set so a very common substring can't blow
// up memory; pass 0 for "no limit" (caller's responsibility to
// handle). The order is implementation-defined — callers that
// need deterministic output sort the result.
FindNodesByNameContaining(substr string, limit int) []*Node
GetFileNodes(filePath string) []*Node
GetRepoNodes(repoPrefix string) []*Node
// --- Edge adjacency --------------------------------------------
GetOutEdges(nodeID string) []*Edge
GetInEdges(nodeID string) []*Edge
// GetInEdgesByNodeIDs / GetOutEdgesByNodeIDs batch the per-node
// edge fan-out into a single backend round-trip. The rerank
// pipeline calls these once per Rerank() to materialise every
// candidate's incoming + outgoing edges in two cgo round-trips
// instead of 6N per-candidate calls. Missing IDs are absent from
// the returned map (callers can index without an ok-check via the
// nil-slice semantics of map[k][]*Edge — range over nil is a no-op).
GetInEdgesByNodeIDs(ids []string) map[string][]*Edge
GetOutEdgesByNodeIDs(ids []string) map[string][]*Edge
// GetRepoEdges returns every edge whose source node has the given
// RepoPrefix. Equivalent to GetRepoNodes(r) followed by
// GetOutEdges(n.ID) for every n, but executes as a single backend
// query — critical on the disk backend (SQLite)
// where the per-node loop is O(repo_nodes) round-trips. The
// in-memory backend forwards to that same nested walk; the disk
// backends push the join into one server-side query.
//
// Empty repoPrefix returns nothing — use AllEdges() for the
// global view. Nodes with an empty RepoPrefix are unreachable
// through this method by design (they don't belong to any repo).
GetRepoEdges(repoPrefix string) []*Edge
// --- Bulk reads ------------------------------------------------
AllNodes() []*Node
AllEdges() []*Edge
// --- Predicate-shaped reads (push filters into the store) ------
//
// These methods replace the pre-Store idiom of `for _, e := range
// AllEdges() { if cond { ... } }`. On the in-memory backend they
// iterate the existing internal byKind / byPrefix buckets — same
// algorithmic cost as the inline filter. On disk backends they
// fan out to dedicated indexes (idx_edge_kind / idx_node_kind /
// the to_id LIKE prefix scan, etc.) so the row count actually
// materialised is proportional to the predicate match, not the
// whole table.
//
// The resolver alone calls AllEdges/AllNodes 34× per pass and
// throws away >99% of each scan; using these predicate methods
// instead cut a 503-second disk-backed resolver pass on a 122k-node
// graph down to seconds.
//
// Iterators stop when the consumer's yield returns false.
// Implementations MUST honour early-stop so callers can break
// out of a search.
// EdgesByKind yields every edge whose Kind matches.
EdgesByKind(kind EdgeKind) iter.Seq[*Edge]
// NodesByKind yields every node whose Kind matches.
NodesByKind(kind NodeKind) iter.Seq[*Node]
// EdgesWithUnresolvedTarget yields every edge whose To has the
// "unresolved::" prefix. The resolver's main loop calls this
// once per pass; on disk backends it should range-scan a
// to-keyed index over the single contiguous "unresolved::" slice
// rather than materialise the whole edges table. Gate-owned fn-value
// placeholders (FnValuePlaceholderMarker) are excluded — the master
// resolver never binds them; the fn-value gate scans them itself by kind.
EdgesWithUnresolvedTarget() iter.Seq[*Edge]
// --- Batched point lookups -------------------------------------
//
// The resolver fires ~3-10 GetNode / FindNodesByName calls per
// unresolved edge across its workers. With 10-30k pending edges
// that's 100k-300k individual queries. On in-memory that's
// fine (map lookups, nanoseconds). On a disk backend each point
// lookup is ~ms — at 100k+ calls the per-pass cost is hundreds
// of seconds, dominating the resolver. The batched variants
// collapse those into one (or chunked) bulk query.
// GetNodesByIDs returns a map id→*Node for every input ID present
// in the store. IDs not in the store are simply absent from the
// returned map (no nil values). Callers may pass duplicates; the
// returned map dedupes naturally.
GetNodesByIDs(ids []string) map[string]*Node
// FindNodesByNames returns a map name→[]*Node where each slot
// holds every node whose Name field matches. Names that match no
// node are absent. Used by the resolver to pre-warm its name-only
// fallback lookup across the whole pending-edge slice in one
// batched call instead of one query per edge.
FindNodesByNames(names []string) map[string][]*Node
// --- Counts and stats ------------------------------------------
NodeCount() int
EdgeCount() int
Stats() GraphStats
RepoStats() map[string]GraphStats
RepoPrefixes() []string
// --- Provenance verification -----------------------------------
EdgeIdentityRevisions() int
VerifyEdgeIdentities() error
// --- Memory estimation (advisory; in-memory-specific) ----------
RepoMemoryEstimate(repoPrefix string) RepoMemoryEstimate
AllRepoMemoryEstimates() map[string]RepoMemoryEstimate
// --- Coordination ----------------------------------------------
// ResolveMutex returns a backend-owned mutex resolver instances
// share to serialise edge-mutation passes. See the package doc
// above for the full contract.
ResolveMutex() *sync.Mutex
}
// Compile-time assertion: *Graph satisfies the Store interface. If a
// *Graph method's signature ever drifts from the interface, the build
// fails fast here instead of at runtime when a different Store
// implementation gets swapped in.
var _ Store = (*Graph)(nil)
// BackendResolver is an optional interface backends MAY implement to
// drain the bulk-tractable subset of the resolver's work entirely
// inside the backend engine (a single server-side bulk UPDATE on the
// disk backend) instead of round-tripping every
// resolution decision back to Go.
//
// Sequencing matters: earlier rules are higher-precision than later
// ones. The orchestrator (ResolveAllBulk) runs them in the order
// listed below so that, e.g., an intra-file call binds to its same-
// file declaration before the unique-name pass would have bound it
// to a same-named symbol elsewhere in the repo.
//
// Each method returns the number of pending edges it drained.
// Unimplemented methods return (0, nil) and the orchestrator skips
// to the next. Errors surface as non-fatal — the orchestrator logs
// and continues with subsequent rules; the Go-side Resolver then
// picks up whatever the bulk pass didn't drain.
type BackendResolver interface {
// ResolveSameFile: unresolved::Name where target is in the
// caller's same source file. Strongest precision — a same-file
// declaration is almost never ambiguous.
ResolveSameFile() (resolved int, err error)
// ResolveSamePackage: unresolved::Name where target is in the
// caller's same directory (Go package). Repo_prefix must match
// to keep the rule within one source tree.
ResolveSamePackage() (resolved int, err error)
// ResolveImportAware: caller's file imports F, target is a
// symbol in F. Joins against the EdgeImports adjacency.
ResolveImportAware() (resolved int, err error)
// ResolveRelativeImports: unresolved::pyrel::<stem> / Dart
// relative-URI stubs rewritten to the matching KindFile node
// (e.g. <stem>.py or <stem>/__init__.py for Python).
// `lang` selects the dialect; empty string runs all supported
// dialects in turn.
ResolveRelativeImports(lang string) (resolved int, err error)
// ResolveCrossRepo: unresolved::Name where exactly one
// cross-repo Node carries that name. Lower precision than the
// same-repo rules; sets cross_repo = true on the resulting edge.
ResolveCrossRepo() (resolved int, err error)
// ResolveUniqueNames: unresolved::Name where exactly one Node
// in the entire graph carries that name. Lowest-precision
// "fallback" — runs after the same-file / same-package /
// import-aware passes have drained anything they could resolve
// more precisely.
ResolveUniqueNames() (resolved int, err error)
// ResolveExternalCallStubs: ensures every external::* edge
// target has a corresponding Node row (the existing
// SynthesizeExternalCalls pass on the Go side). Promotes
// origin to ast_resolved for edges that now point at a real
// stub.
ResolveExternalCallStubs() (resolved int, err error)
// ResolveAllBulk runs the bulk-tractable methods in
// precision-descending order and returns the cumulative count
// of edges resolved across all rules. The default backend
// implementation should chain the methods above; callers use
// ResolveAllBulk as the single Resolver-side hook.
ResolveAllBulk() (totalResolved int, err error)
}
// BulkLoader is an optional interface backends MAY implement to expose
// a high-throughput cold-load fast path that bypasses per-call query
// overhead. The cold-start indexer fires ~2000 small AddBatch calls
// during its parse phase; on backends where every AddBatch round-trips
// through a query parser that per-call cost
// dominates wall time. BulkLoader lets the indexer bracket the parse
// loop with BeginBulkLoad / FlushBulk: AddBatch calls inside the
// bracket buffer rows in memory, and FlushBulk commits them through
// the backend's native bulk primitive.
//
// Contract:
//
// - BeginBulkLoad may be called on a non-empty store. The cold-start
// parse phase calls it on an empty store, but later passes (notably
// the contracts pass, which appends a few hundred contract nodes /
// edges after resolve) re-enter the bracket against a populated
// backend. FlushBulk commits via the backend's native bulk
// primitive in MERGE-on-primary-key mode, so re-appending rows
// that share an ID with existing data does not duplicate them.
//
// - Between BeginBulkLoad and FlushBulk, AddBatch is the only mutator
// the caller may invoke. Reads (GetNode, AllEdges, EdgesByKind, …)
// return whatever the backend can see — typically nothing buffered.
// The resolver MUST NOT run until after FlushBulk.
//
// - FlushBulk commits everything buffered since BeginBulkLoad and
// returns the backend to normal per-call write mode. An error
// leaves the store in an implementation-defined state.
//
// - Calling BeginBulkLoad twice without an intervening FlushBulk,
// or calling FlushBulk without a prior BeginBulkLoad, is a
// programmer error; backends are free to panic.
//
// The in-memory *Graph deliberately does NOT implement BulkLoader —
// it's already optimal at the per-call path. bbolt and SQLite likewise
// skip it: their per-call overhead is already amortised by their own
// internal batching (chunked transactions, prepared statements). The
// interface is intentionally opt-in so the indexer can probe with a
// type assertion and fall through to today's per-batch path uniformly.
type BulkLoader interface {
BeginBulkLoad()
FlushBulk() error
}
// SymbolHit is a single full-text-search result: the matched node ID
// plus its relevance score from the backend's scorer (BM25 in
// the disk backend's FTS). Higher score = more relevant.
type SymbolHit struct {
NodeID string
Score float64
}
// SymbolFTSItem is the payload BulkUpsertSymbolFTS takes per node:
// the node's ID and its pre-tokenised text. Reused so the indexer
// can preallocate one slice and the backend can iterate without
// per-element wrapper allocs.
type SymbolFTSItem struct {
NodeID string
Tokens string
}
// SymbolSearcher is an optional interface backends MAY implement to
// expose engine-native full-text search over the graph's symbol
// names. When the backing store implements it, the daemon's
// search_symbols path routes through the backend FTS instead of
// building a parallel in-process Bleve/BM25 index — saving ~100MB
// of heap on a vscode-scale repo and putting the search latency in
// the same address space as the rest of the graph.
//
// Contract:
//
// - UpsertSymbolFTS is the per-call write path used by incremental
// reindex. The store decides how to persist the pre-tokenised
// text (a sidecar table, an FTS column, an in-engine index —
// backend choice). Tokens are produced by
// internal/search.Tokenize so camelCase / snake_case / path-
// separator semantics match the existing BM25 corpus contract.
//
// - BulkUpsertSymbolFTS is the cold-start fast path used by the
// indexer's shadow-swap drain. Implementations SHOULD use the
// backend's native bulk primitive
// so a 600k-node repo doesn't pay per-row query parse cost.
// Idempotent on NodeID like UpsertSymbolFTS — re-running with
// an overlapping set replaces in place.
//
// repoPrefix is the per-repo namespace; the store wipes only
// rows owned by that prefix before COPYing the new items, so
// multiple repos sharing one store don't clobber each other's
// FTS corpus. Empty prefix means "single-repo mode" — the
// store wipes everything (the legacy behaviour).
//
// - BuildSymbolIndex finalises the index after the bulk parse
// phase. For backends whose FTS index updates automatically on
// row writes, this is a one-shot cold-start call;
// for backends that need an explicit build pass, it's where
// the work happens. Idempotent — safe to call multiple times.
//
// - SearchSymbols runs a query and returns hits ordered by score
// descending. The query string is the user's raw input; the
// backend is expected to tokenise it the same way it tokenised
// the indexed text (typically by passing it through
// internal/search.Tokenize before invoking the FTS).
//
// - Close is implied by graph.Store.Close — no separate
// teardown method here.
type SymbolSearcher interface {
UpsertSymbolFTS(nodeID, tokens string) error
BulkUpsertSymbolFTS(repoPrefix string, items []SymbolFTSItem) error
BuildSymbolIndex() error
SearchSymbols(query string, limit int) ([]SymbolHit, error)
}
// ContentHit is one result from a ContentSearcher query: the content
// section node's ID plus locating metadata (file + ordinal), its BM25
// relevance score (higher = more relevant), and a short snippet excerpt
// from the matched body for display.
type ContentHit struct {
NodeID string
FilePath string
Ordinal int
Score float64
Snippet string
}
// ContentFTSItem is the payload AppendContent takes per content section:
// the node's ID, its locating metadata, and the full section body. The
// body is indexed in the content store and is deliberately NOT retained on
// the graph node (the node keeps only a short snippet), so the symbol
// index and the code-oriented passes stay free of bulk content text.
type ContentFTSItem struct {
NodeID string
FilePath string
Ordinal int
Body string
}
// ContentSearcher is an optional interface backends MAY implement to
// expose a full-text index over CONTENT (data_class="content") section
// text, kept physically separate from the symbol search (SymbolSearcher).
// Content text never enters the symbol FTS or the code-oriented analysis
// passes; it streams into this dedicated index per file during parsing, so
// a content-heavy repo (a few hundred large PDFs / text dumps that explode
// into hundreds of thousands of sections) cannot flood the symbol index or
// pin every section body in graph nodes.
//
// Contract:
//
// - WipeContent clears a repo's content rows before a full rebuild
// (empty prefix = whole table, single-repo / conformance behaviour).
//
// - WipeContentFile clears one file's content rows — the incremental
// reindex path when a single content file changes.
//
// - AppendContent inserts content rows for repoPrefix without wiping —
// the streamed per-file build path. Callers WipeContent (full) or
// WipeContentFile (incremental) first, then AppendContent each file's
// sections. Idempotent only in combination with a preceding wipe.
//
// - SearchContent runs a query scoped to repoPrefix (empty = all repos)
// and returns hits ordered by score descending, each with a snippet.
//
// - BuildContentIndex finalises the index after the bulk parse phase
// (segment merge / optimize). Idempotent — safe to call repeatedly.
//
// - ScanContent streams every stored content row (scoped to repoPrefix;
// empty = all repos) to fn with its FULL body — so a consumer that
// needs the whole section text (e.g. the content->code linker) reads it
// from here rather than the graph node, which keeps only a snippet.
// fn returns false to stop the scan early.
type ContentSearcher interface {
WipeContent(repoPrefix string) error
WipeContentFile(filePath string) error
AppendContent(repoPrefix string, items []ContentFTSItem) error
SearchContent(query, repoPrefix string, limit int) ([]ContentHit, error)
BuildContentIndex() error
ScanContent(repoPrefix string, fn func(nodeID, filePath, body string) bool) error
}
// SymbolBundle is the rerank-shaped result of one search call: the
// matched node, its BM25 score, AND the in/out edges the rerank
// pipeline reads from. Backends that can compose this in a single
// engine round-trip implement SymbolBundleSearcher; callers can fall
// through to SymbolSearcher + GetNodesByIDs + GetIn/OutEdgesByNodeIDs
// when the backend doesn't.
//
// The same node may appear in successive bundles when a multi-call
// retrieval path (primary + expansion) returns it more than once; the
// caller's dedup-by-ID step keeps the per-call shape simple and the
// engine can merge across calls into a single rerank candidate set
// without paying for the duplicate edge fetch — the second occurrence
// already carries the same edges.
type SymbolBundle struct {
Node *Node
Score float64
InEdges []*Edge
OutEdges []*Edge
}
// SymbolBundleSearcher is an optional capability backends MAY
// implement to fold the symbol-search hot path's three
// per-BM25-call cgo round-trips (FTS + GetNodesByIDs + the rerank
// prepare's batched in/out edge fetch) into one bundled
// engine-side call:
//
// - FTS yields (id, score)
// - One batched node materialise + one in-edge fan-in + one
// out-edge fan-out, all keyed on the same id list, return the
// bundle.
//
// Backends that do NOT implement this interface still serve the
// search path through SymbolSearcher; callers fall back to
// SymbolSearcher.SearchSymbols + GetNodesByIDs +
// GetIn/OutEdgesByNodeIDs and pay the per-call cgo cost the
// bundled form avoids. The contract is intentionally read-only —
// writes still go through UpsertSymbolFTS / BulkUpsertSymbolFTS on
// the SymbolSearcher.
type SymbolBundleSearcher interface {
SearchSymbolBundles(query string, limit int) ([]SymbolBundle, error)
}
// VectorItem is the payload BulkUpsertEmbeddings takes per node:
// the node's ID and its embedding vector. Length of Vec must
// match the dim the corresponding BuildVectorIndex call declared
// — backends with fixed-width vector columns reject inserts that
// don't match.
type VectorItem struct {
NodeID string
Vec []float32
}
// VectorHit is a single ANN search result: the matched node ID
// plus its distance to the query vector under the backend's
// metric (cosine by default). LOWER distance = more
// similar. Callers that need a similarity score in [0,1] should
// translate via `1 - distance` for cosine.
type VectorHit struct {
NodeID string
Distance float64
}
// VectorSearcher is an optional interface backends MAY implement to
// expose engine-native HNSW vector indexing over per-symbol
// embedding vectors. When the backing store implements it, the
// daemon's semantic-search path routes through the backend's
// native ANN index instead of holding a parallel in-process
// HNSW — saving roughly `dim × 4 × N` bytes of heap (≈ 1 GB for
// 384-dim × 663k symbols on a Vscode-scale repo).
//
// The bigger win is that vector neighbours and graph traversal can
// be combined in a single server-side round-trip: an ANN seed
// lookup feeding straight into an adjacency match (e.g. "callers
// of the nearest symbols, scoped to one repo and excluding tests").
//
// Today this is three round-trips on the in-process HNSW
// path (ANN → IDs → graph fetch → Go-side filter); with
// VectorSearcher it's one engine-side pipeline.
//
// Contract:
//
// - UpsertEmbedding is the per-call write path used by
// incremental reindex when one file's embeddings change.
//
// - BulkUpsertEmbeddings is the cold-start fast path used by
// the indexer's embedding pass. Implementations SHOULD use
// the backend's native bulk primitive so a 600k-node corpus
// doesn't pay per-row query parse cost. Idempotent on NodeID
// — re-running with an overlapping set replaces in place.
//
// - BuildVectorIndex finalises the HNSW index after the bulk
// populate. The dim parameter declares the embedding
// width; backends with fixed-width columns lazily create
// the storage schema on the first BuildVectorIndex call.
// Idempotent — safe to call multiple times with the same dim.
//
// - SimilarTo runs an ANN query: given a vector, return the k
// closest stored vectors ordered by ascending distance.
//
// - GetEmbeddings reads back the stored vectors for an explicit
// set of node IDs in one batch. Unlike SimilarTo it does not
// score or rank — it hands the raw vectors to the caller so a
// post-rerank refinement stage can recompute exact cosine
// against the query embedding. IDs with no stored vector are
// simply absent from the returned map (never an error); an
// empty input yields an empty map.
//
// - Close is implied by graph.Store.Close — no separate
// teardown method here.
type VectorSearcher interface {
UpsertEmbedding(nodeID string, vec []float32) error
BulkUpsertEmbeddings(items []VectorItem) error
BuildVectorIndex(dims int) error
SimilarTo(vec []float32, limit int) ([]VectorHit, error)
GetEmbeddings(ids []string) map[string][]float32
}
// PageRankOpts tunes the PageRank computation. Zero values request
// the backend default — only set fields you genuinely want to
// override so backends can pick their own parallel-tuned defaults
// without the caller second-guessing the constants.
//
// NodeKinds / EdgeKinds restrict the projected subgraph the
// algorithm runs over. Empty means "all kinds" — the algo sees the
// full graph. A non-empty filter is rewritten into a projected-
// graph predicate (e.g. n.kind = "function").
type PageRankOpts struct {
NodeKinds []NodeKind
EdgeKinds []EdgeKind
DampingFactor float64
MaxIterations int
Tolerance float64
Limit int // 0 = return every ranked node
}
// PageRankHit is one row of the PageRank output: the node ID plus
// its rank score. Hits come back sorted by rank descending.
type PageRankHit struct {
NodeID string
Rank float64
}
// PageRanker is an optional interface backends MAY implement to
// expose engine-native PageRank centrality. When the store
// implements it, the daemon's hotspot / authority-ranking path
// routes through the backend's parallel implementation instead of
// computing degree-centrality in-process.
//
// Engine-native PageRank is qualitatively different from the
// degree-based hotspot analyzer: random-walk authority weights
// rare-but-influential nodes the degree count would miss
// (a low-fan-in API that's called from every domain layer ranks
// higher than a high-fan-in test helper).
//
// Contract:
//
// - PageRank runs the algorithm against a projected subgraph and
// returns hits sorted by rank descending. The projection is
// declared and torn down per call — callers don't manage
// PROJECT_GRAPH lifecycle directly.
//
// - The score is normalized so the full corpus sums to 1.
// Relative ordering — not the absolute value — is what callers
// should consume.
//
// - Close is implied by graph.Store.Close.
type PageRanker interface {
PageRank(opts PageRankOpts) ([]PageRankHit, error)
}
// BundleFingerprintSink is an optional capability a backend MAY
// implement to accept an authoritative per-package content-fingerprint
// map for its SearchSymbolBundles cache. The daemon calls
// SetBundleFingerprints after every analysis pass with the fresh
// fingerprints derived from the live graph; the backend retires any
// cached bundle whose package fingerprint changed and serves the rest.
// Backends without a bundle cache simply do not implement this — the
// daemon's type assertion no-ops.
//
// fps is keyed by package key (the directory the package's files live
// in, repo-prefixed in multi-repo because the stored node file paths
// are). The fingerprints must be edge-aware — fold in the package's
// nodes AND the edges touching them — so a cross-file edge change
// invalidates the bundles whose in/out edges it altered.
type BundleFingerprintSink interface {
SetBundleFingerprints(fps map[string]uint64)
}
// CommunityOpts tunes Louvain community detection over a projected
// subgraph. Zero values request the backend default
// (maxPhases=20, maxIterations=20). NodeKinds / EdgeKinds
// restrict the projection; an empty filter runs over the full graph.
type CommunityOpts struct {
NodeKinds []NodeKind
EdgeKinds []EdgeKind
MaxPhases int
MaxIterations int
}
// CommunityHit is one row of the Louvain output: the node ID plus
// the integer community label the algorithm assigned. Two nodes
// with the same CommunityID are in the same community; the actual
// integer is opaque and promises no stability across runs.
type CommunityHit struct {
NodeID string
CommunityID int64
}
// CommunityDetector is an optional interface backends MAY
// implement to expose engine-native Louvain community detection.
// When the store implements it, the daemon's
// analysis.DetectCommunitiesLouvain
// path can delegate the partitioning step and keep the existing
// post-processing (label disambiguation, hub detection, cohesion,
// parent assignment).
//
// Contract:
//
// - Louvain runs the algorithm against a projected subgraph and
// returns one hit per node assigning it to a community. The
// projection is declared and torn down per call.
//
// - The engine-native implementation treats edges as undirected (the
// modularity score is computed on the undirected graph even
// though the projected Edge table is directed). Callers that
// care about directed modularity should consult the in-process
// fallback.
//
// - Close is implied by graph.Store.Close.
type CommunityDetector interface {
Louvain(opts CommunityOpts) ([]CommunityHit, error)
}
// ComponentOpts tunes connected-component computation over a
// projected subgraph. Zero values request the backend default
// (maxIterations=100). NodeKinds / EdgeKinds restrict
// the projection.
type ComponentOpts struct {
NodeKinds []NodeKind
EdgeKinds []EdgeKind
MaxIterations int
}
// ComponentHit is one row of a connected-component output: the
// node ID plus the integer component label the algorithm assigned.
// Two nodes with the same ComponentID are in the same component.
// The integer is opaque.
type ComponentHit struct {
NodeID string
ComponentID int64
}
// ComponentFinder is an optional interface backends MAY implement
// to expose engine-native weakly- and strongly-connected-component
// algorithms. Two methods because the algorithms answer different
// questions:
//
// - WeaklyConnectedComponents treats edges as undirected — every
// pair of nodes reachable from each other (ignoring direction)
// lands in one component. Useful for "is this symbol part of
// the connected core?" diagnostics.
//
// - StronglyConnectedComponents respects edge direction — only
// nodes mutually reachable end up in the same component. The
// SCC of a call graph is the cycle structure: every non-
// trivial SCC (size > 1) is a mutual-recursion ring.
//
// When the store implements ComponentFinder, the daemon's
// connectivity diagnostics and circular-dependency detection
// (`analyze kind=wcc` / `analyze kind=scc`) route through it;
// otherwise the in-process analysis.ComputeWCC / analysis.ComputeSCC
// fallbacks run.
type ComponentFinder interface {
WeaklyConnectedComponents(opts ComponentOpts) ([]ComponentHit, error)
StronglyConnectedComponents(opts ComponentOpts) ([]ComponentHit, error)
}
// KCoreOpts tunes k-core decomposition. NodeKinds / EdgeKinds
// restrict the projection. The algorithm itself takes no
// per-call parameters — it always computes the full
// decomposition (every node gets its k-degree).
type KCoreOpts struct {
NodeKinds []NodeKind
EdgeKinds []EdgeKind
}
// KCoreHit is one row of the k-core output: the node ID plus the
// largest k for which the node remains in the k-core after
// iteratively pruning nodes with degree < k. A node's KDegree is
// its position in the core hierarchy — high values mean the node
// sits inside a densely connected centre.
type KCoreHit struct {
NodeID string
KDegree int64
}
// KCorer is an optional interface backends MAY implement to
// expose engine-native k-core decomposition. When the store
// implements it, the daemon's `analyze kind=kcore` path delegates
// to the engine-native implementation; otherwise
// analysis.ComputeKCore runs in-process.
//
// k-core finds the densest subgraph: the k-core of a graph is
// the largest subgraph where every node has at least k
// neighbours. The k-degree of a node is the largest k for which
// it stays in the k-core — useful for "find the hub-of-hubs", or
// "what's the core infrastructure code that everything depends
// on".
type KCorer interface {
KCoreDecomposition(opts KCoreOpts) ([]KCoreHit, error)
}
// DeadCodeCandidator is an optional capability backends MAY implement
// to compute the dead-code candidate set server-side. The default Go
// path in analysis.FindDeadCode pulls every node + a batched in-edge
// map and filters in Go; on a disk backend that's
// ~1.3M edge rows per call. A backend that implements
// DeadCodeCandidator runs the equivalent WHERE-NOT-EXISTS filter
// inside the query engine and returns ~hundreds of true candidates,
// skipping the materialise-then-filter loop entirely.
//
// The opts mirror analysis.FindDeadCodeOptions to keep the surface
// in sync — only the fields the backend can act on (kinds + the
// per-kind in-edge allowlist) are honoured. File-path / build-tag
// / well-known-name exclusions stay in Go because they need
// string parsing the backend can't do efficiently.
type DeadCodeCandidator interface {
// DeadCodeCandidates returns nodes matching the allowed node
// kinds that have NO incoming edges of the corresponding
// allowed in-edge kinds. The map keys the in-edge allowlist by
// node kind — backends evaluate the right allowlist per row.
// Empty allowedInEdgeKinds for a kind means "any incoming edge
// counts as usage".
DeadCodeCandidates(allowedNodeKinds []NodeKind, allowedInEdgeKinds map[NodeKind][]EdgeKind) []*Node
}
// IfaceImplementsRow is the per-row payload returned by
// IfaceImplementsScanner — one tuple per EdgeImplements edge whose
// target is a KindInterface node carrying Meta["methods"]. TypeID
// is the implementing type (the edge's source); IfaceID is the
// interface (the edge's target); IfaceMeta is the interface
// node's decoded Meta map, from which the caller pulls the
// "methods" field. Rows where the interface had no Meta are
// elided server-side.
type IfaceImplementsRow struct {
TypeID string
IfaceID string
IfaceMeta map[string]any
}
// IfaceImplementsScanner returns the set of (typeID, interfaceID,
// interfaceMeta) tuples for every EdgeImplements edge where the
// target is a KindInterface node carrying Meta["methods"]. Used by
// analysis.FindDeadCode to compute "type implements interface, so
// these methods are alive even if never called directly". The
// server-side join is one query; the Go-side equivalent fetched
// every interface node then every implements edge separately.
//
// Optional capability — analysis.FindDeadCode falls back to the
// Go-side scan when the backend doesn't implement it.
type IfaceImplementsScanner interface {
IfaceImplementsRows() []IfaceImplementsRow
}
// NodeDegreeRow is one tuple returned by NodeDegreeAggregator. InCount
// counts EVERY incoming edge (any kind); OutCount counts EVERY outgoing
// edge; UsageInCount counts only the subset whose kind is in the
// "usage" set (Calls, References, Instantiates, Implements, Extends,
// Reads, Writes, Tests). The split exists because connectivity_health
// needs the totals (for isolated / leaf classification) AND the
// usage-edge presence (to fold ClassifyZeroEdge's logic in
// server-side); pulling them in one row saves a second cgo trip per
// node.
type NodeDegreeRow struct {
NodeID string
InCount int
OutCount int
UsageInCount int
}
// NodeDegreeAggregator is an optional capability backends MAY
// implement to return per-node in/out edge counts plus a usage-edge
// count, server-side. Used by analysis.GraphConnectivity to replace
// the per-node g.GetInEdges(id) + g.GetOutEdges(id) +
// graph.ClassifyZeroEdge(id) trio — three full edge materialisations
// per node on a disk backend.
// One round-trip returns all three counts and lets the analyzer
// classify isolated / leaf / source-only / sink-only / extraction-gap
// without ever materialising the underlying edge structs.
//
// The usageKinds slice MUST mirror graph.usageEdgeKinds (the set
// ClassifyZeroEdge consults). Empty usageKinds means UsageInCount is
// always 0; an empty input ids slice returns nil.
//
// Optional capability — GraphConnectivity falls back to the per-node
// GetInEdges/GetOutEdges path when the backend doesn't implement it.
type NodeDegreeAggregator interface {
NodeDegreeCounts(ids []string, usageKinds []EdgeKind) []NodeDegreeRow
}
// NodeFanRow is one tuple returned by NodeFanAggregator. FanIn counts
// incoming edges whose kind is in the fanInKinds set; FanOut counts
// outgoing edges whose kind is in the fanOutKinds set. The two kind
// sets are passed by the caller so the same capability serves both
// FindHotspots (fanIn = Calls+References, fanOut = Calls) and any
// future analyzer with a different kind split.
type NodeFanRow struct {
NodeID string
FanIn int
FanOut int
}
// NodeFanAggregator is an optional capability backends MAY implement
// to compute per-node fan-in / fan-out counts filtered by edge kind,
// server-side. Used by analysis.FindHotspots and
// handleAnalyzeHealthScore to replace the AllEdges() materialisation
// they both ran every call (~500k edges on the gortex
// workspace, the bulk of the wall-clock cost on a disk backend). The Go-side
// crossing computation still needs per-edge (from, to) for the
// Calls/References kinds — that runs through EdgesByKind, which
// streams without materialising the full edge set.
//
// Empty ids => nil; empty fanInKinds / fanOutKinds means that side
// is always 0. Output order is unspecified.
//
// Optional capability — both analyzers fall back to the AllEdges scan
// when the backend doesn't implement it.
type NodeFanAggregator interface {
NodeFanCounts(ids []string, fanInKinds []EdgeKind, fanOutKinds []EdgeKind) []NodeFanRow
}
// FileImporterRow is the per-row payload returned by FileImporters.
// FromFile is the importing file's path (the result the caller cares
// about); FromID / FromName / FromKind describe the node that owns
// the EdgeImports edge, in case the caller needs more than just the
// file list.
type FileImporterRow struct {
FromFile string
FromID string
FromName string
FromKind NodeKind
}
// FileImporters is an optional capability backends MAY implement to
// answer "which files import filePath?" with a single backend round-
// trip instead of a Go-side AllEdges() scan. The MCP check_references
// tool's importing-files block hammered AllEdges() per call: ~286k
// edges materialised on the gortex workspace, then a per-
// edge GetNode(e.To) + GetNode(e.From) — multiple thousand backend
// round-trips for a single check_references call. A backend that implements
// FileImporters runs the equivalent join inside the query engine and
// only surfaces the rows that match.
//
// Match semantics mirror the original handler: an EdgeImports edge
// counts when its To node's FilePath equals filePath OR when the To
// node's ID equals filePath (the file's own node id, used by the
// indexer for file-level import bindings). The same-file dedup the
// caller applies stays in Go — backends just stream the candidate
// rows.
//
// Optional capability — handleCheckReferences falls back to the
// AllEdges-driven loop when the backend doesn't implement it.
type FileImporters interface {
FileImporters(filePath string) []FileImporterRow
}
// InEdgeCounter is an optional capability backends MAY implement to
// compute incoming-edge fan-in counts per target node for a fixed
// set of edge kinds in one backend round-trip. The fallback iterates
// AllEdges() Go-side; on a disk backend that materialises every edge
// (~286k rows on the gortex workspace) just to bucket by To.
// The capability instead runs a single server-side GROUP BY filtered
// by edge kind and ships back only the per-target
// counts — a fraction of the rows and zero per-row Go object alloc.
//
// Used by handleGetUntestedSymbols to compute the calls+references
// fan-in ranking. The map keys are node IDs; values are the integer
// count of matching incoming edges. Targets with zero matching in-
// edges are absent from the map (callers index with `m[id]` and rely
// on the zero-value default).
//
// Optional capability — the handler falls back to AllEdges-driven
// bucketing when the backend doesn't implement it.
type InEdgeCounter interface {
InEdgeCountsByKind(kinds []EdgeKind) map[string]int
}
// NodesInFilesByKindFinder is an optional capability backends MAY
// implement to answer "which nodes of kinds K live in files F?"
// with a single backend round-trip. The fallback iterates AllNodes()
// Go-side; on a disk backend that materialises the full node table
// per call. The capability instead runs a single server-side query
// filtering by file path and kind, and ships only the matching rows.
//
// Used by handleFindDeclaration to build the per-file enclosing-
// symbol index off the small set of trigram-match file paths. The
// Go fallback's AllNodes pull was ~70k rows on the gortex workspace
// to land at ~hundreds of relevant rows.
//
// Empty files / empty kinds returns nil — never a whole-graph scan.
//
// Optional capability — the handler falls back to AllNodes when the
// backend doesn't implement it.
type NodesInFilesByKindFinder interface {
NodesInFilesByKind(files []string, kinds []NodeKind) []*Node
}
// FileMtimeWriter is an optional capability backends MAY implement to
// persist the per-file modification time the indexer uses for its
// incremental-reindex decisions. Lifting this state off the daemon's
// gob+gzip snapshot makes warm restarts read it through the same
// backend the graph already lives in (no second persistence surface
// to keep coherent).
//
// repoPrefix is the indexer's own prefix tag; mtimes is keyed on the
// repo-relative file path (the same key the in-memory Indexer's
// fileMtimes map uses). Empty input is a no-op; empty repoPrefix is
// allowed for single-repo daemons.
type FileMtimeWriter interface {
BulkSetFileMtimes(repoPrefix string, mtimes map[string]int64) error
}
// FileMtimeReader is the read side of FileMtimeWriter. Returns the
// recorded mtimes for one repo prefix as a fresh map (nil for "no
// data"). Used by warmup to seed ReconcileRepoCtx with the per-file
// mtimes it would otherwise have read from the gob snapshot.
type FileMtimeReader interface {
LoadFileMtimes(repoPrefix string) map[string]int64
}
// FileMtimeReplacer is an optional capability: persist the AUTHORITATIVE
// full mtime set for a repo prefix, dropping any previously-stored rows for
// files no longer present. The full-index persist path calls this so files
// deleted since the last index are pruned. A backend that only implements
// the upsert-only FileMtimeWriter leaves deleted-file rows behind, and
// warm-restart reconcile then detects them as phantom deletions on every
// restart — forcing a full re-track that never converges. Empty input is a
// no-op (it must never wipe a repo's mtimes from an empty snapshot).
type FileMtimeReplacer interface {
ReplaceFileMtimes(repoPrefix string, mtimes map[string]int64) error
}
// FileMtimeDeleter is an optional capability: drop the persisted mtime rows
// for a set of repo-relative file paths. The incremental-reindex / watcher
// path calls it when a file is deleted so the persisted set stays in step
// with the live graph (the per-file sibling of FileMtimeReplacer). Empty
// input is a no-op.
type FileMtimeDeleter interface {
DeleteFileMtimes(repoPrefix string, paths []string) error
}
// EnrichmentState is the per-(repo, provider) completion marker for
// semantic enrichment: the git revision the provider last enriched the repo
// at, when it finished, and the coverage it reached. Enrichment completion
// otherwise lives only in an in-memory map, so a restart forgets it and
// re-runs full LSP hover passes even though the persisted graph already
// carries the edges. Persisting this row lets the deferred-enrichment gate
// skip a provider whose IndexedSHA still matches HEAD on a clean tree.
type EnrichmentState struct {
RepoPrefix string
Provider string
IndexedSHA string
CompletedAt int64 // unix seconds
Coverage float64
}
// EnrichmentStateStore is an optional capability backends MAY implement to
// persist and read back the per-(repo, provider) enrichment completion
// marker. Backends without durable state (the in-memory graph) simply do
// not implement it — the manager type-asserts and, when the assertion
// fails, gates nothing (it always enriches), which is the safe default.
//
// GetEnrichmentState's bool is false when no row has been recorded yet (a
// never-enriched or pre-feature repo/provider), which the gate treats as
// "freshness unknown" and never blocks on.
type EnrichmentStateStore interface {
GetEnrichmentState(repoPrefix, provider string) (EnrichmentState, bool, error)
SetEnrichmentState(state EnrichmentState) error
}
// LightNodeReader is an optional store capability: a repo-scoped node scan
// that skips decoding each row's opaque meta blob, populating only struct
// columns and the already-promoted meta keys (signature/visibility/doc/
// external/return_type/.../semantic_type/semantic_source) into Meta. Safe
// for read-only structural use (file path, kind, position, the promoted
// stamp check) — a node fetched this way must NEVER be round-tripped back
// through AddNode/AddBatch, since any non-promoted meta content still
// living in the row's blob would be silently discarded on write. Backends
// with no separate blob (nothing to skip) need not implement it — callers
// fall back to the full scan.
type LightNodeReader interface {
GetRepoNodesLight(repoPrefix string) []*Node
}
// LightEdgeScanner is an optional store capability: a kind-scoped edge scan
// that skips decoding each row's opaque meta blob. AllEdgesLight returns the
// edges whose Kind is in kinds (an empty kinds list means every kind), Meta
// left nil, with only the struct columns plus the promoted edge fields
// (Origin/Tier/Confidence/ConfidenceLabel/CrossRepo/Line) populated.
//
// The warm-restart centrality/analysis passes (PageRank, HITS, adjacency CSR,
// process discovery, Leiden) each scan the whole call/reference edge set on
// every run. On a large multi-repo graph AllEdges() materialises millions of
// Meta maps those passes never read — the per-edge JSON decode + map
// allocation dominates the scan and inflates warm-restart heap. This capability
// serves the same rows without that cost.
//
// Contract drift, documented once: the ONE Meta key any of these passes still
// consults sits inside graph.ProvenanceWeight, and only for legacy rows whose
// Origin column is empty (it falls back to Meta["semantic_source"] to
// reconstruct the origin). Rows written since the origin-column promotion carry
// Origin directly, so ProvenanceWeight never touches Meta for them; on a
// meta-less legacy row it degrades to the default AST-inferred weight. That
// drift is accepted — the promotion is long-shipped and these passes produce
// approximate rankings, not exact values. Callers MUST NOT rely on any other
// Meta content from a light edge. The in-memory backend returns its live edges
// as-is (Meta present) rather than copy every edge just to strip Meta — the
// contract only promises Meta MAY be absent, so a correct caller reads only the
// promoted fields regardless of backend.
type LightEdgeScanner interface {
AllEdgesLight(kinds ...EdgeKind) []*Edge
}
// FnValuePlaceholderScanner is an optional store capability: a scan restricted
// to the fn-value gate's placeholder namespace (FnValuePlaceholderMarker, both
// the bare `unresolved::fnvalue::<name>` and the multi-repo
// `<repoPrefix>::unresolved::fnvalue::<name>` COPY-rewrite forms). The gate
// (resolver.ResolveFnValueCallbacks) is the sole consumer and needs only these
// placeholders, but its generic path scans the entire EdgeReferences kind —
// placeholders plus every real reference edge — and Go-filters by Meta["via"].
// On a large multi-repo graph that materialises millions of reference edges on
// every whole-graph synthesizer pass just to keep a handful of placeholders.
// Backends that can range-scan the namespace on a to-keyed index implement
// this; others are served by the gate's EdgesByKind(references) fallback. Meta
// IS populated — the gate reads Meta["via"] and the captured fn_value_name off
// each placeholder.
type FnValuePlaceholderScanner interface {
FnValuePlaceholderEdges() iter.Seq[*Edge]
}
// EdgePersister is an optional capability backends MAY implement to
// durably rewrite the mutable attribute columns (Confidence,
// ConfidenceLabel, Origin, Tier, Meta) of an edge already present in the
// graph, identified by its full logical key (From, To, Kind, FilePath,
// Line). The in-memory backend never needs it — GetOutEdges hands back
// the live *Edge pointer, so an in-place field mutation is already
// durable. A disk backend, by contrast, returns a detached row copy:
// mutating Confidence / Meta on that copy and calling SetEdgeProvenance
// (which only writes Origin + Tier) silently drops the rest. A pass that
// confirms an edge's full provenance bundle calls PersistEdgeAttributes
// so every backend keeps it. A no matching row is a no-op.
type EdgePersister interface {
PersistEdgeAttributes(e *Edge)
}
// EdgeMetaBatchPersister is the batched sibling of EdgePersister for passes
// that durably rewrite the attribute columns (Meta in particular) of many
// edges in one sweep — the resolver's terminal-skip stamping walks the whole
// unresolved population and flips a durable flag on the permanently external /
// stdlib / definition-less edges. A disk backend amortises the per-edge
// transaction overhead across the batch. The in-memory backend never
// implements it: a read hands back the live *Edge pointer, so an in-place Meta
// mutation is already durable and the caller's type assertion simply fails.
type EdgeMetaBatchPersister interface {
PersistEdgeAttributesBatch(edges []*Edge)
}
// NodeNameClassCount is one candidate name's classification tally from
// NodeNameClassCounter: how many same-named nodes are Real (non-stub,
// definition-kind) candidates vs Stub placeholders. A name with neither
// (Real == 0 && Stub == 0) has no matching node at all.
type NodeNameClassCount struct {
Real int
Stub int
}
// NodeNameClassCounter is an optional Store capability for classifying a
// batch of candidate identifier names in one round trip instead of one
// FindNodesByName call per name. The resolver's terminal classification
// (reconcileTerminalStamps / classifyTerminal in resolver/terminal.go) asks,
// for each still-unresolved edge's target identifier, whether ANY real
// definition or stub placeholder shares that name anywhere in the graph —
// language family turns out not to affect that decision, so a same-language
// and a cross-language real match both simply count as Real. definitionKinds
// is the set of NodeKind values that count as a "real" candidate (mirrors
// nodeIsDefinitionKind); a node whose kind isn't in that set and isn't a
// stub counts as neither. Backends without this capability fall back to the
// existing per-edge cachedFindNodesByName + IsStub loop.
type NodeNameClassCounter interface {
CountNodesByNameClass(names []string, definitionKinds []NodeKind) map[string]NodeNameClassCount
}
// CloneShingleWriter is an optional capability backends MAY implement
// to persist each function/method node's MinHash shingle set (a
// []uint64) keyed by node id. Lifting this state into the same backend
// the graph already lives in lets the maintained clone-detection
// count-min sketch (CMS) be rebuilt after a warm restart from the
// persisted snapshot — no re-parse, no second persistence surface to
// keep coherent. It is the shingle-set sibling of FileMtimeWriter.
//
// repoPrefix is the indexer's own prefix tag; rows is keyed on the
// node id whose shingle set the value carries. Empty input is a
// no-op; empty repoPrefix is allowed for single-repo daemons.
// DeleteCloneShingles drops the rows for a set of node ids (evicted
// or rebuilt symbols) so the persisted snapshot stays in step with
// the live graph; empty input is a no-op.
type CloneShingleWriter interface {
BulkSetCloneShingles(repoPrefix string, rows map[string][]uint64) error
DeleteCloneShingles(nodeIDs []string) error
}
// CloneShingleReader is the read side of CloneShingleWriter. Returns
// the recorded shingle sets for one repo prefix as a fresh map (nil
// for "no data"). Used by warmup to reseed the clone-detection CMS
// from the persisted snapshot instead of re-shingling every body.
type CloneShingleReader interface {
LoadCloneShingles(repoPrefix string) (map[string][]uint64, error)
}
// ConstantValueWriter is an optional capability backends MAY implement
// to persist a KindConstant node's literal value (string / numeric)
// keyed by node id, in a queryable sidecar rather than the gob-encoded
// Meta blob (which is unindexable and decoded on every node load). The
// resolver reads these to dereference a const-identifier dispatch name
// (e.g. `const ChargeCardActivity = "ChargeCard"`) to its value across
// files. It is the const-value sibling of CloneShingleWriter.
//
// rows is keyed on the const node id; the value is the literal text.
// Empty input is a no-op. DeleteConstantValuesByFiles drops the rows
// for a set of re-indexed / evicted files so the snapshot stays in step
// with the live graph.
type ConstantValueWriter interface {
BulkSetConstantValues(repoPrefix string, rows []ConstantValueRow) error
DeleteConstantValuesByFiles(repoPrefix string, files []string) error
}
// ConstantValueReader is the read side of ConstantValueWriter. Returns
// the recorded constant values for the supplied node ids as a fresh map
// (node id → value); ids with no recorded value are omitted. A nil /
// empty ids slice returns an empty map.
type ConstantValueReader interface {
ConstantValuesByNodeIDs(nodeIDs []string) (map[string]string, error)
}
// ConstantValueRow is one persisted constant value: the const node id,
// its owning file (for file-scoped eviction), and the literal value.
type ConstantValueRow struct {
NodeID string
FilePath string
Value string
}
// FileMetaRow is one per-file metadata record: the BLAKE3 content hash (the
// Merkle leaf), byte size, extracted node count, and a JSON array of
// parse-error locations ("" when clean). The Merkle tree stays the
// authoritative change detector; this row is queryable supplementary metadata
// that index_health surfaces per file.
type FileMetaRow struct {
FilePath string
ContentHash string
Size int
NodeCount int
Errors string
}
// FileMetaWriter persists per-file metadata rows. Implemented by the on-disk
// and in-memory backends; the indexer writes through it after each file's
// nodes are batched.
type FileMetaWriter interface {
SetFileMetas(repoPrefix string, rows []FileMetaRow) error
DeleteFileMetasByFiles(repoPrefix string, files []string) error
}
// FileMetaReader is the read side: every recorded file row for a repo prefix.
type FileMetaReader interface {
FileMetasForRepo(repoPrefix string) ([]FileMetaRow, error)
}
// RefFact is one durable resolved-reference fact: a reference edge from
// FromID resolved TO ToID with the provenance tier that resolved it. Persisted
// per source file so a reference's resolution is an auditable, diffable record
// — gortex persists the RESOLVED target + its 5-tier provenance (unlike
// codegraph, which persists only unresolved refs behind a flat provenance
// string). The candidate set the resolver chose AMONG (when ambiguous) rides
// in Candidates for later disambiguation / audit.
type RefFact struct {
RepoPrefix string
FromID string
ToID string
Kind string // edge kind (calls / references / …)
RefName string // the referenced symbol's bare name
Line int
Origin string // provenance tier (lsp_resolved … text_matched)
Tier string // coarse provenance label
Candidates []string
FilePath string // source file (denormalized for indexed per-file queries)
Lang string
}
// RefFactsWriter is an optional capability backends MAY implement to persist
// per-file resolved-reference facts in a dedicated sidecar table. Sibling of
// CloneShingleWriter / FileMtimeWriter. Empty input is a no-op.
type RefFactsWriter interface {
BulkSetRefFacts(repoPrefix string, facts []RefFact) error
DeleteRefFactsByFiles(repoPrefix string, files []string) error
}
// RefFactsReader is the read side of RefFactsWriter: the persisted facts for a
// set of source files (all files when files is empty), as the audit/diff seed.
type RefFactsReader interface {
LoadRefFactsByFiles(repoPrefix string, files []string) ([]RefFact, error)
// LoadRefFactsByTargets is the reverse lookup: the persisted facts that
// resolve TO any of the given node IDs, grouped by source file path. It
// answers "which files reference these symbols" durably — live in-edges
// are dropped when their target file is re-indexed, but the sidecar row
// keyed by to_id survives, so incremental re-resolution can find the
// referencing files after the eviction. Empty input yields an empty,
// non-nil map.
LoadRefFactsByTargets(repoPrefix string, targetIDs []string) (map[string][]RefFact, error)
}
// ChurnEnrichment is one node's git-churn enrichment, moved out of
// nodes.meta into a typed sidecar (change A). Maps 1:1 to the payload
// internal/churn.EnrichGraph used to stamp on Meta["churn"]/["churn_meta"].
// HeadSHA/Branch/ComputedAt are file-level only (empty for symbols).
type ChurnEnrichment struct {
NodeID string
RepoPrefix string
CommitCount int
AgeDays int
ChurnRate float64
LastAuthor string
LastCommitAt string // RFC3339
HeadSHA string
Branch string
ComputedAt string // RFC3339
}
// ChurnEnrichmentWriter is an optional capability backends MAY implement
// to persist git-churn enrichment in a typed sidecar instead of the
// node meta blob. When absent the enricher falls back to stamping
// Node.Meta (legacy path).
type ChurnEnrichmentWriter interface {
BulkSetChurn(repoPrefix string, rows []ChurnEnrichment) error
DeleteChurn(nodeIDs []string) error
}
// ChurnEnrichmentReader is the read side. ChurnRows returns every churn
// row for repoPrefix; an EMPTY repoPrefix returns ALL rows across repos
// (the cross-repo read get_churn_rate uses, then scope-filters per node).
type ChurnEnrichmentReader interface {
ChurnRows(repoPrefix string) []ChurnEnrichment
}
// CoverageEnrichment is one node's coverage enrichment (change A),
// moved out of nodes.meta into a typed sidecar.
type CoverageEnrichment struct {
NodeID string
RepoPrefix string
CoveragePct float64
NumStmt int
Hit int
}
// CoverageEnrichmentWriter persists coverage enrichment in a typed
// sidecar. Optional capability; absent → enricher falls back to Meta.
type CoverageEnrichmentWriter interface {
BulkSetCoverage(repoPrefix string, rows []CoverageEnrichment) error
DeleteCoverage(nodeIDs []string) error
}
// CoverageEnrichmentReader reads coverage rows; empty repoPrefix returns
// ALL rows across repos.
type CoverageEnrichmentReader interface {
CoverageRows(repoPrefix string) []CoverageEnrichment
}
// ReleaseEnrichment is one file node's "first appeared in <tag>"
// enrichment (change A), moved out of nodes.meta.
type ReleaseEnrichment struct {
NodeID string
RepoPrefix string
AddedIn string
}
// ReleaseEnrichmentWriter persists release enrichment in a typed sidecar.
type ReleaseEnrichmentWriter interface {
BulkSetReleases(repoPrefix string, rows []ReleaseEnrichment) error
DeleteReleases(nodeIDs []string) error
}
// ReleaseEnrichmentReader reads release rows; empty repoPrefix → all.
type ReleaseEnrichmentReader interface {
ReleaseRows(repoPrefix string) []ReleaseEnrichment
}
// BlameEnrichment is one node's latest-author enrichment (change A),
// moved out of nodes.meta. Timestamp is unix seconds.
type BlameEnrichment struct {
NodeID string
RepoPrefix string
Commit string
Email string
Timestamp int64
}
// BlameEnrichmentWriter persists blame enrichment in a typed sidecar.
type BlameEnrichmentWriter interface {
BulkSetBlame(repoPrefix string, rows []BlameEnrichment) error
DeleteBlame(nodeIDs []string) error
}
// BlameEnrichmentReader reads blame rows; empty repoPrefix → all.
type BlameEnrichmentReader interface {
BlameRows(repoPrefix string) []BlameEnrichment
}
// EdgesByKindsScanner is an optional capability backends MAY
// implement to stream every edge whose Kind is in the supplied set,
// in a single backend round-trip. The fallback iterates AllEdges()
// Go-side and filters in process — on a disk backend AllEdges
// materialises every edge (~286k rows on the gortex workspace) for the
// edge-driven analyzers (channel_ops, pubsub, k8s_resources,
// kustomize, error_surface, …) that only care about a handful of
// kinds. The capability runs a single server-side query filtering
// by edge kind and ships back only the matching rows.
//
// The single-kind variant EdgesByKind already exists, but the
// analyzers in question typically need 2-5 kinds in one pass; firing
// EdgesByKind once per kind would issue N independent backend queries
// when the planner can naturally batch them with an IN-list. Calling
// EdgesByKinds with one kind is equivalent to EdgesByKind for that
// kind — backends should still prefer the IN-list path so the call
// site never branches on len(kinds).
//
// Empty kinds yields nothing — never a whole-table scan. Iterators
// stop when the consumer's yield returns false; implementations MUST
// honour early-stop so callers can break out of a search.
//
// Optional capability — analyzers fall back to per-kind EdgesByKind
// iteration when the backend doesn't implement it.
type EdgesByKindsScanner interface {
EdgesByKinds(kinds []EdgeKind) iter.Seq[*Edge]
}
// NodesByKindsScanner is an optional capability backends MAY implement
// to fetch every node whose Kind is in the supplied set in a single
// backend round-trip. Replaces the AllNodes() + Go-side `if n.Kind !=
// allowed` filter used by the metadata-oriented analyze handlers
// (todos, stale_code, stale_flags, ownership, coverage_gaps,
// coverage_summary, cgo_users, wasm_users, orphan_tables,
// unreferenced_tables). Each of those scans the entire node table just
// to keep one or two kinds — on a disk backend that's ~70k rows on
// the gortex workspace per call. The capability runs a single
// server-side query filtering by node kind and ships only the
// matching rows.
//
// Why a separate kinds-IN scanner instead of looping the existing
// NodesByKind iterator per kind: on a disk backend NodesByKind is one
// query per call. Looping it for {function, method} doubles the round-trip
// count and rebuilds the row decoder for each pass. One IN-list query
// returns the union directly. The dedup is intentional — duplicated
// kinds in the input never reach the IN-list, matching the in-memory
// reference's behaviour.
//
// Optional capability — handlers fall back to AllNodes-driven scanning
// when the backend doesn't implement it. Empty kinds returns nil
// without touching the backend.
type NodesByKindsScanner interface {
NodesByKinds(kinds []NodeKind) []*Node
}
// EdgeAdjacencyForKinds is an optional capability backends MAY
// implement to stream (from, to) id pairs for every edge whose Kind
// is in the supplied edge-kind set AND whose endpoints both belong
// to the supplied node-kind set. The shape covers the betweenness /
// centrality adjacency build that today calls EdgesByKinds and
// filters Go-side: on a disk backend the per-edge row carries ~10 string
// columns, multiplied by ~286k edges on the gortex
// workspace, just for a build that uses only From/To. The
// capability returns a 2-column projection from a single server-side
// join — every endpoint kind is enforced by the planner, so neither
// the cross-kind edges nor the irrelevant columns ever leave the backend.
//
// Empty edgeKinds or empty nodeKinds yields nothing — never a
// whole-table scan. Iterators stop when the consumer's yield
// returns false; implementations MUST honour early-stop.
//
// Optional capability — analyzers fall back to EdgesByKinds when
// the backend doesn't implement it.
type EdgeAdjacencyForKinds interface {
EdgeAdjacencyForKinds(edgeKinds []EdgeKind, nodeKinds []NodeKind) iter.Seq[[2]string]
}
// ExternalCallCandidates is the optional pushdown for external-call
// synthesis. ExternalCallCandidateEdges returns only the call / reference
// edges whose target is an un-indexed external-package terminal
// (dep:: / stdlib:: / external::, including the per-repo-prefixed stdlib
// form) or an already-materialised external-call:: node — the exact set
// the synthesizer might act on. The disk backend selects these rows with
// a GLOB predicate (served by a partial index) instead of marshaling
// every call edge in the graph and filtering Go-side; the marshaling /
// allocation saving dominates on large graphs even when the planner
// full-scans.
//
// Optional capability — resolver.externalCallCandidateEdges falls back to
// the EdgesByKinds scan + prefix filter when the backend doesn't
// implement it (the in-memory store, where there is no row marshaling to
// save).
type ExternalCallCandidates interface {
ExternalCallCandidateEdges() []*Edge
}
// CommunityCrossingsByKind is an optional capability backends MAY
// implement to return per-source crossing counts for edges whose
// Kind is in the supplied set, given a node→community membership
// map. A "crossing" is an edge whose source community differs from
// its target community; the count is keyed by source id.
//
// Replaces the FindHotspots.countCrossings loop that today iterates
// EdgesByKind twice and tallies per-source Go-side: on the gortex
// workspace the two EdgesByKind passes materialised the full call /
// reference bucket (~286k rows × ~10 columns) just to
// derive a thousand-row aggregate. The capability ships only the
// (from, to) projection — the community comparison runs Go-side
// because the community map isn't a Node column today.
//
// Empty kinds or an empty community map returns nil. The map keys
// in the result MUST be source ids whose count is non-zero —
// implementations MUST drop zero-count rows so callers can probe
// existence without a >0 check.
//
// Optional capability — analyzers fall back to EdgesByKind iteration
// when the backend doesn't implement it.
type CommunityCrossingsByKind interface {
CommunityCrossingsByKind(kinds []EdgeKind, nodeToComm map[string]string) map[string]int
}
// NodeIDsByKinds is an optional capability backends MAY implement
// to return just the IDs of nodes whose Kind is in the supplied
// set. Replaces NodesByKinds in ranking paths (betweenness,
// hotspots) that only need to iterate ids — the full *Node carries
// ~10 string columns over cgo per row, and the candidate set is
// thousands of function/method rows, so the projection drops the
// per-call cgo allocation count by an order of magnitude.
//
// Empty kinds returns nil without touching the backend. Duplicated
// input kinds must NOT duplicate the output — backends MUST dedup
// the kind set in the IN-list.
//
// Optional capability — callers fall back to NodesByKinds when the
// backend doesn't implement it.
type NodeIDsByKinds interface {
NodeIDsByKinds(kinds []NodeKind) []string
}
// EdgeKindCounter is an optional capability backends MAY implement
// to return one row per distinct edge kind with its occurrence
// count, server-side. Used by handleGetSurprisingConnections to
// derive the "rare kinds" set (kinds whose share of all edges is at
// or below the rare_kind_pct threshold) without materialising every
// edge over cgo just to bucket by Kind. On the gortex workspace the
// AllEdges() bucket pass was ~286k edges over cgo per call; the
// aggregator returns ~30 rows.
//
// The map's key is the EdgeKind; the value is the integer occurrence
// count. Empty graph returns nil (or an empty map — callers MUST
// treat both as "no rare kinds detected").
//
// Optional capability — handleGetSurprisingConnections falls back
// to the AllEdges-driven kind bucketing when the backend doesn't
// implement it.
type EdgeKindCounter interface {
EdgeKindCounts() map[EdgeKind]int
}
// CrossRepoEdgeRow is one tuple returned by CrossRepoEdgeAggregator.
// Kind is the cross_repo_* edge kind verbatim. FromRepo / ToRepo
// are the source / target node's RepoPrefix; Count is the number of
// underlying edges that share the triple.
type CrossRepoEdgeRow struct {
Kind EdgeKind
FromRepo string
ToRepo string
Count int
}
// CrossRepoEdgeAggregator is an optional capability backends MAY
// implement to return pre-grouped cross-repo edge counts. Used by
// the get_architecture handler's cross_repo rollup, which previously
// scanned AllEdges() + per-edge GetNode(from)+GetNode(to) just to
// emit one row per (kind, from_repo, to_repo). On the gortex
// workspace that meant ~286k edge rows + ~thousands of GetNode
// round-trips for typically <100 cross-repo rows. The
// aggregator runs one server-side GROUP BY and ships only the surviving
// per-triple counts.
//
// Cross-repo edges are identified by graph.BaseKindForCrossRepo —
// the disk implementation MUST use the same kind list (so single-
// repo graphs return an empty slice, not a whole-graph scan).
//
// Optional capability — handleGetArchitecture falls back to the
// AllEdges-driven loop when the backend doesn't implement it.
type CrossRepoEdgeAggregator interface {
CrossRepoEdgeCounts() []CrossRepoEdgeRow
}
// FileImportCountRow is one tuple returned by FileImportAggregator.
// FilePath is the imported file path (the target node's FilePath, or
// the target node's ID when the indexer pointed the import edge at
// the file node directly). Count is the number of distinct EdgeImports
// edges whose To resolves to that path.
type FileImportCountRow struct {
FilePath string
Count int
}
// FileImportAggregator is an optional capability backends MAY
// implement to return per-target-file incoming-imports counts in
// one backend round-trip. Used by mostImportedFiles (shared between
// get_repo_outline and suggest_queries) which previously scanned
// AllEdges() + per-edge GetNode(to) just to bucket counts by path.
// On the gortex workspace that loop materialised ~286k edges + per-
// edge GetNode round-trips to produce a top-10 list. The
// aggregator GROUPs server-side and ships the per-file counts only.
//
// scope, when non-nil, bounds the counted edges to those whose target
// node ID lies in the slice (session-workspace clamp). An empty (but
// non-nil) scope returns nil — never a whole-graph scan. A nil scope
// means "no clamp" and counts every imports edge.
//
// Optional capability — mostImportedFiles falls back to the
// AllEdges-driven loop when the backend doesn't implement it.
type FileImportAggregator interface {
FileImportCounts(scope []string) []FileImportCountRow
}
// InDegreeForNodes is an optional capability backends MAY implement to
// return the per-target incoming-edge count for the given node id set
// in one backend round-trip. Unlike InEdgeCounter (which filters by
// edge kind across the WHOLE graph), this counter is scoped to a
// caller-supplied id set and counts EVERY incoming edge regardless of
// kind. handleGetSurprisingConnections needs both the hub heuristic
// and the per-edge anomaly walk, but the hub check only cares about
// nodes already inside the session-scoped working set; counting every
// edge across the table just to bucket by `To` materialises the entire
// edge column (~286k rows on a disk backend).
//
// Empty ids returns nil — never a whole-table scan. Targets with zero
// matching in-edges may be absent from the returned map (callers index
// with `m[id]` and treat zero as the default).
//
// Optional capability — handleGetSurprisingConnections falls back to
// the AllEdges-driven bucketing when the backend doesn't implement it.
type InDegreeForNodes interface {
InDegreeForNodes(ids []string) map[string]int
}
// ReachableForwardByKinds is an optional capability backends MAY
// implement to compute the set of node IDs reachable from the seed
// frontier via outgoing edges whose Kind is in the supplied set, in
// one backend round-trip. The Go fallback runs a layer-by-layer BFS
// firing GetOutEdges per node — on a disk backend that's N+1 round-trips
// where N is the transitive frontier size; on a 100k-symbol repo with
// a few thousand test functions the BFS easily issues tens of
// thousands of edge fetches.
//
// reachableFromTests in handleGetUntestedSymbols is the primary
// caller: seeds are every function/method in a test file, kinds are
// {calls, references}, and the result is the closed set of symbols
// covered transitively by the test surface. The capability runs one
// variable-length match expression and ships the closure back as a
// single id list.
//
// Empty seeds returns nil; an empty kinds set returns the seed set
// unchanged (no edges to traverse). The returned map keys are the
// reachable node IDs (including the seeds); the bool value is always
// true — the shape mirrors the in-memory implementation's covered set
// so the caller's index expression stays identical.
//
// Optional capability — reachableFromTests falls back to the
// per-layer GetOutEdges BFS when the backend doesn't implement it.
type ReachableForwardByKinds interface {
ReachableForwardByKinds(seeds []string, kinds []EdgeKind) map[string]bool
}
// ThrowerErrorRow is one tuple returned by ThrowerErrorSurfacer. ThrowerID
// is the symbol that originates the EdgeThrows edges; ErrorTargets is the
// distinct set of error-type node IDs the thrower reaches via EdgeThrows;
// ErrorMsgs is the distinct set of literal error-message strings the
// thrower emits (KindString nodes with meta.context = "error_msg", linked
// by EdgeEmits). Throws is the count of underlying EdgeThrows edges (one
// thrower may raise the same target multiple times from different sites).
// FilePath / Line are the row metadata the legacy handler propagated from
// the first edge / falling back to the thrower node — they ride here so
// the analyzer never has to issue a follow-up GetNode lookup.
type ThrowerErrorRow struct {
ThrowerID string
FilePath string
Line int
Throws int
ErrorTargets []string
ErrorMsgs []string
}
// ThrowerErrorSurfacer is an optional capability backends MAY implement
// to evaluate the analyze(error_surface) rollup entirely inside the
// storage layer. The Go fallback walks EdgeThrows once for the per-
// thrower aggregation, then issues GetOutEdges per surviving thrower
// to attach the literal error-message strings. On a disk backend that's
// two scans of the edge table plus an N+1 loop for the per-thrower
// emit walk; the capability runs two server-side GROUP BYs and ships the
// pre-shaped rows back.
//
// pathPrefix narrows the EdgeThrows rows by their stored FilePath
// prefix; an empty prefix means "every thrower". Returned rows are
// already deduplicated per (thrower, error_target) and per (thrower,
// error_msg) — callers feed them directly into the analyzer's sort /
// truncate path without further bucketing.
//
// Optional capability — handleAnalyzeErrorSurface falls back to the
// AllEdges-driven loop when the backend doesn't implement it.
type ThrowerErrorSurfacer interface {
ThrowerErrorSurface(pathPrefix string) []ThrowerErrorRow
}
// MemberMethodInfo is one row of the MemberMethodsByType projection.
// MethodID is the method node's id; Name is its name (the key the
// InferImplements method-set check compares against); FilePath /
// StartLine are the source coordinates InferOverrides stamps on the
// EdgeOverrides edge it emits; RepoPrefix lets consumers
// (ResolveGRPCStubCalls' pickGRPCHandler) tie-break on same-repo
// without a follow-up GetNode.
type MemberMethodInfo struct {
MethodID string
Name string
FilePath string
StartLine int
RepoPrefix string
}
// MemberMethodsByType is an optional capability backends MAY implement
// to return the typeID → []MemberMethodInfo projection of every
// EdgeMemberOf edge whose source is a KindMethod node, in one backend
// round-trip. Replaces the InferImplements / InferOverrides Pass 1
// pattern of EdgesByKind(EdgeMemberOf) followed by per-edge
// GetNode(e.From) to filter on Kind == KindMethod and read the
// method's columns. On a disk backend that loop is N+1 round-trips:
// each method GetNode pulls ~10 string columns + the Meta blob just to
// read four scalar fields. The capability runs a single server-side
// join and ships only the four method columns the resolver
// actually consumes.
//
// Empty graph returns nil; types with no method members are absent
// from the result. The returned slice's elements are unique per
// MethodID — duplicated (typeID, methodID) pairs (a method
// member-of'd twice) collapse to one row.
//
// Optional capability — InferImplements / InferOverrides fall back to
// the per-edge GetNode walk when the backend doesn't implement it.
type MemberMethodsByType interface {
MemberMethodsByType() map[string][]MemberMethodInfo
}
// StructuralParentEdgeRow is one tuple returned by StructuralParentEdges.
// FromID / ToID are the child / parent node IDs verbatim. FromKind /
// ToKind let the consumer apply the (Type | Interface) gate without a
// follow-up GetNode. Origin is the edge's resolution-tier label, which
// drives override-edge origin selection in InferOverrides.
type StructuralParentEdgeRow struct {
FromID string
ToID string
FromKind NodeKind
ToKind NodeKind
Origin string
}
// StructuralParentEdges is an optional capability backends MAY
// implement to return every EdgeExtends / EdgeImplements / EdgeComposes
// edge whose endpoints are both KindType / KindInterface, projected as
// (FromID, ToID, FromKind, ToKind, Origin) in one backend round-trip.
// Replaces the InferOverrides Pass 2 pattern of g.AllEdges() followed
// by per-edge GetNode(e.From) + GetNode(e.To) to apply the kind gate.
// On a disk backend the AllEdges scan materialises every edge (~286k
// on the gortex workspace) plus issues two per-edge node lookups; the
// capability runs one server-side join with kind filters on both sides
// and ships only the surviving rows back (typically a small fraction of
// the edge table).
//
// Empty graph returns nil. Rows from extends/implements/composes edges
// whose endpoints aren't both type/interface are filtered server-side
// — the consumer never has to gate them again.
//
// Optional capability — InferOverrides falls back to the AllEdges +
// per-edge GetNode walk when the backend doesn't implement it.
type StructuralParentEdges interface {
StructuralParentEdges() []StructuralParentEdgeRow
}
// CrossRepoCandidateRow is one tuple returned by CrossRepoCandidates.
// Edge is the underlying base-kind edge verbatim — the consumer
// rewrites Edge.CrossRepo on it and emits a parallel cross_repo_* edge.
// FromRepo / ToRepo are the (already-distinct) source and target
// RepoPrefix values projected from the endpoint nodes.
type CrossRepoCandidateRow struct {
Edge *Edge
FromRepo string
ToRepo string
}
// CrossRepoCandidates is an optional capability backends MAY implement
// to return every edge whose Kind has a parallel cross_repo_* kind AND
// whose endpoints carry two different non-empty RepoPrefix values, in
// one backend round-trip. Replaces the DetectCrossRepoEdges pattern of
// g.AllEdges() + per-edge GetNode(e.From) + GetNode(e.To) to extract
// the RepoPrefix pair. On a disk backend the AllEdges scan ships every
// edge in the graph plus issues two GetNode lookups per surviving
// row; the capability filters by edge kind + the repo-prefix mismatch
// server-side and ships only the surviving rows (typically a small
// fraction of the edge table on a multi-repo workspace).
//
// baseKinds is the set of edge kinds for which a CrossRepoKindFor
// mapping exists — the caller passes the list and the implementation
// MUST use exactly that set in the IN-list, so a single-repo graph
// (or a graph whose nodes carry no RepoPrefix) returns no rows.
//
// Optional capability — DetectCrossRepoEdges falls back to the
// AllEdges + per-edge GetNode loop when the backend doesn't implement
// it.
type CrossRepoCandidates interface {
CrossRepoCandidates(baseKinds []EdgeKind) []CrossRepoCandidateRow
}
// Direction selects which way a BFSCapable.BFS walk follows edges.
type Direction int
const (
// DirectionForward follows edges from source to target (from_id ->
// to_id): the callee / dependency / out-edge direction.
DirectionForward Direction = iota
// DirectionBackward follows edges from target to source (to_id ->
// from_id): the caller / dependent / in-edge direction.
DirectionBackward
)
// String renders the direction for logs / debugging.
func (d Direction) String() string {
if d == DirectionBackward {
return "backward"
}
return "forward"
}
// BFSHop is one node reached by a BFSCapable.BFS walk. NodeID is the
// reached node; Depth is its minimum hop-distance from the nearest seed
// (seeds are depth 0). ParentID + EdgeKind name the edge that first
// reached the node at that minimum depth — the discovery edge — so a
// caller can rebuild the spanning tree / call chain without a second
// pass. Seeds carry ParentID == "" and EdgeKind == "".
type BFSHop struct {
NodeID string
Depth int
ParentID string
EdgeKind EdgeKind
}
// BFSCapable is an optional capability a backend MAY implement to run a
// whole bounded breadth-first traversal in one round-trip instead of the
// engine's layer-by-layer GetOutEdges / GetInEdges walk (one store call
// per depth). The SQLite backend lowers it to a single recursive CTE; the
// in-memory graph keeps the reference Go walk. Both MUST agree on the
// reachable hop-set for the same arguments — the in-memory walk is the
// correctness oracle the disk implementation is shadow-tested against.
//
// Semantics:
// - seeds enter the result at depth 0 (ParentID / EdgeKind empty). Empty
// seeds returns nil; duplicate seeds collapse.
// - dir picks the edge direction — forward follows from_id -> to_id,
// backward follows to_id -> from_id.
// - kinds gates which edge kinds the walk follows. Empty kinds returns
// the seed hops only (no edge is followed).
// - only node-backed targets are followed and returned; an edge to a
// target with no node row (an unresolved / external stub) is not a hop.
// This mirrors the engine's reachable-set semantics, where such targets
// are dropped rather than expanded.
// - each node appears once, at its minimum depth; among the discovery
// edges at that depth the (ParentID, EdgeKind)-smallest is chosen, so
// the result is deterministic. A cycle terminates because depth is
// bounded by maxDepth, not because of any visited bookkeeping.
// - maxDepth bounds the hop distance: a node at depth d is expanded only
// while d < maxDepth, so the deepest hop is maxDepth. maxDepth <= 0
// returns the seed hops only.
// - the result is ordered by (Depth, NodeID); limit > 0 caps the number
// of hops after that ordering (limit <= 0 means no cap).
//
// Optional capability — the engine's GetCallers / GetCallChain /
// GetDependents / GetDependencies fall back to the in-memory layer walk
// when the backend does not implement it, or when the walk needs features
// the flat hop-set cannot express (workspace scope, test exclusion,
// dispatch expansion, or a bidirectional cluster walk).
type BFSCapable interface {
BFS(seeds []string, dir Direction, kinds []EdgeKind, maxDepth int, limit int) ([]BFSHop, error)
}
// ExtractCandidateRow is one tuple returned by ExtractCandidatesScanner.
// Caller / FanOut counts are distinct-by-endpoint (one caller counted
// once per (From, kind) pair, one callee counted once per (To, kind)
// pair) restricted to the call-like edge kinds the consumer cares
// about. LineCount is EndLine - StartLine + 1; rows whose StartLine or
// EndLine is zero are filtered server-side.
type ExtractCandidateRow struct {
NodeID string
Name string
FilePath string
StartLine int
EndLine int
LineCount int
CallerCount int
FanOut int
}
// ExtractCandidatesScanner is an optional capability backends MAY
// implement to compute the get_extraction_candidates ranking in two
// server-side round-trips (per-node caller-count and fan-out aggregation
// joined to the node table). Replaces the AllNodes() scan + per-node
// GetInEdges / GetOutEdges loop the handler used previously — on the
// gortex workspace that was ~30k node × 2 trips per call, where
// each trip materialised the full edge bucket just to count
// distinct endpoints. The capability instead runs the count
// (DISTINCT-by-endpoint) inside the engine and ships only the rows
// that satisfy the three threshold gates.
//
// Empty kinds yields nothing — the handler always passes a non-empty
// set (EdgeCalls + EdgeCrossRepoCalls). pathPrefix narrows the scan to
// nodes under that file-path prefix; empty matches every path. The
// returned rows mirror the result of the Go-side loop verbatim:
// thresholds applied, line_count = EndLine - StartLine + 1.
//
// Optional capability — handleGetExtractionCandidates falls back to
// the AllNodes scan when the backend doesn't implement it.
type ExtractCandidatesScanner interface {
ExtractCandidates(
kinds []EdgeKind,
minLines, minCallers, minFanOut int,
pathPrefix string,
) []ExtractCandidateRow
}
// FileSymbolNameRow is one tuple returned by FileSymbolNamesByPaths.
// FilePath echoes the input slot; Name is one symbol name observed in
// the file (function / method / type / interface kinds only, matching
// symbolNamesInFile's Go-side filter). One file may produce many rows.
type FileSymbolNameRow struct {
FilePath string
Name string
}
// FileSymbolNamesByPaths is an optional capability backends MAY
// implement to fetch the sorted distinct (file → function/method/type
// names) projection for a slice of file paths in one backend round-
// trip. Replaces the per-file GetFileNodes loop find_co_changing_symbols
// runs after a positive cochange match: 20 result rows × one
// per-file query each on a disk backend. The capability runs a single
// query filtering by file path and kind with an IN-list, and ships
// one row per (file, name).
//
// Empty paths returns nil — never a whole-table scan. Rows for paths
// with no qualifying symbols are absent from the result; callers
// always index by file path and treat missing keys as "no names".
//
// Optional capability — symbolNamesInFile and its callers fall back to
// the per-file GetFileNodes loop when the backend doesn't implement
// it.
type FileSymbolNamesByPaths interface {
FileSymbolNamesByPaths(paths []string, kinds []NodeKind) []FileSymbolNameRow
}
// ClassHierarchyRow is one tuple returned by ClassHierarchyTraverser.
// Path carries the node IDs visited from the seed (exclusive of the
// seed) out to the terminal node, in BFS order. EdgeKinds carries the
// per-hop edge kind so the caller can reconstruct the *Edge values.
// For a single hop Path has one element and EdgeKinds has one element;
// for a depth-N walk both slices have length N.
type ClassHierarchyRow struct {
Path []string
EdgeKinds []EdgeKind
}
// ClassHierarchyTraverser is an optional capability backends MAY
// implement to compute the inheritance subgraph rooted at a seed in
// one (or two — up + down) variable-length traversals, server-
// side. Replaces the BFS in query.ClassHierarchy: each frontier node
// fired GetNode + GetInEdges or GetOutEdges per visit on a disk
// backend, so a depth-5 walk over an interface with a wide implementer
// set burned hundreds of round-trips just to discover ~50 edges.
//
// kinds is the edge-kind set the walk consumes (EdgeExtends +
// EdgeImplements + EdgeComposes + EdgeOverrides). depth caps the hop
// budget. direction:
// - "up" — follow outgoing edges from each frontier node.
// - "down" — follow incoming edges into each frontier node.
//
// Empty kinds / depth <= 0 / unknown seed returns nil. The returned
// rows are deduplicated by (Path[-1], last EdgeKind) — the consumer
// reconstructs the visited node set and the edge list from them.
//
// Optional capability — query.ClassHierarchy falls back to the BFS
// when the backend doesn't implement it.
type ClassHierarchyTraverser interface {
ClassHierarchyTraverse(
seedID string,
direction string,
kinds []EdgeKind,
depth int,
) []ClassHierarchyRow
}
// FileEditingContext is an optional capability backends MAY
// implement to return the get_editing_context payload (defines +
// imports + 1-hop callers + 1-hop callees, all for one file) in a
// small fixed number of server-side round-trips. Replaces the handler's
// per-symbol GetCallers / GetCallChain loop — for a file with 30
// functions that fired 60 query-engine entry points on a disk backend.
//
// kinds is the set of node kinds the caller treats as call-targets
// (KindFunction + KindMethod). The capability returns FileNode (the
// file row), Defines (every non-file node anchored to the path,
// signature carried through Meta), Imports (the EdgeImports out-edges
// of the file node), CalledBy (one-hop callers of any defines node,
// filtered to symbols outside the file), and Calls (one-hop callees of
// any defines node, filtered to symbols outside the file). All five
// projections are scoped to the input file in one round-trip each.
//
// Optional capability — handleGetEditingContext falls back to the
// per-symbol loop when the backend doesn't implement it.
type FileEditingContextResult struct {
FileNode *Node
Defines []*Node
Imports []*Edge
CalledBy []*Node
Calls []*Node
}
type FileEditingContext interface {
FileEditingContext(filePath string, kinds []NodeKind) *FileEditingContextResult
}
// FileSubGraphReader is an optional capability backends MAY implement
// to return the full file neighbourhood — the file node, every node
// defined in or contained by it, and every adjacent edge — in a
// single backend round-trip.
//
// On the in-memory backend the per-id GetOutEdges / GetInEdges loop
// is already O(1) per node, so the query.Engine.GetFileSymbols
// fallback wraps it. On a disk backend the same loop is
// O(file_symbols) round-trips — ~547 symbols on a real file fanned
// out into ~5 000 round-trips just to dedup edges in Go. The
// capability lets the backend express the walk as a single server-side
// query over the node and edge indexes.
//
// Returned slices are deduplicated by the implementation. Missing
// file returns (nil, nil); empty file (file node only, no symbols)
// returns ([file], nil). Callers that need the symbols-only view
// strip KindFile + KindImport on top (see
// internal/mcp/tools_core.go::stripFileAndImportNodes).
//
// Optional capability — query.Engine.GetFileSymbols falls back to
// GetFileNodes + GetOut/InEdgesByNodeIDs when the backend doesn't
// implement it.
type FileSubGraphReader interface {
GetFileSubGraph(filePath string) (nodes []*Node, edges []*Edge)
}
// FrontierHop is one (edge, neighbour) pair from a FrontierExpander: an
// edge adjacent to a queried source node plus the node at its far end,
// with the neighbour's columns populated and Meta left nil (traversal
// callers don't read it). It lets a BFS record the edge and
// scope-check / materialise the neighbour without a GetNode per edge.
type FrontierHop struct {
Edge *Edge
Neighbor *Node
}
// FrontierExpander is an optional backend capability: given a set of
// source node IDs it returns, in a single round-trip, their adjacent
// edges of the requested kinds plus the neighbour nodes — the
// node-edge-node projection a BFS frontier needs. forward=true follows
// outgoing edges (neighbour = edge target); forward=false follows
// incoming (neighbour = edge source). kinds must be non-empty (the
// directed-traversal contract). limit derives a deterministic per-call
// row cap so a hub node's fan-out can no longer be dragged across the
// boundary in full.
//
// query.Engine.bfs uses it when the reader implements it (the disk
// store) and falls back to per-node GetOutEdges/GetInEdges + GetNode
// otherwise — the in-memory graph needs no batching (its reads are O(1)).
type FrontierExpander interface {
ExpandFrontier(ids []string, forward bool, kinds []EdgeKind, limit int) []FrontierHop
}
// FileSubGraphCountReader is the count-only sibling of
// FileSubGraphReader: returns the file's nodes plus the number of
// distinct edges adjacent to any of them, without materialising the
// edges themselves.
//
// The disk-backend headline cost for get_file_summary on a 500-symbol
// file was the ~4 000-row crossing to ship every adjacent edge back to
// Go. The gcx and compact output paths only emit a total_edges scalar
// in their meta headers — never per-edge rows — so handleGetFileSummary
// routes gcx through this method and skips the row materialisation
// entirely. The json output path keeps the full GetFileSubGraph call
// because it serialises every edge in the body, and the compact path
// keeps it because it summarises edges per confidence label.
//
// On the in-memory backend the per-node edge bucket lookups are
// already O(1), so its implementation just counts via the same path
// GetFileSubGraph walks; the win is on disk backends.
//
// Optional capability — query.Engine.GetFileSymbolsCounts falls back
// to len(GetFileSubGraph().edges) when the backend doesn't implement
// it.
type FileSubGraphCountReader interface {
GetFileSubGraphCounts(filePath string) (nodes []*Node, edgeCount int)
}
// NodeDegreeByKinds is an optional capability backends MAY implement
// to return per-node total in/out edge counts for every node whose
// kind is in the supplied set, server-side. Replaces the
// get_knowledge_gaps pattern of "give me all functions, then ask for
// their in/out degree" — on a disk backend that fed an IN-list of ~30k
// node IDs to the NodeDegreeCounts query, which has to compare every
// node against the list. The capability instead matches kinds at the
// source and groups by node — one query per direction with a kind
// predicate the planner can index.
//
// pathPrefix narrows the scan to nodes under that file-path prefix;
// empty matches every path. Empty kinds returns nil (never a whole-
// graph scan).
//
// The returned rows mirror NodeDegreeRow's shape but UsageInCount is
// always 0 — knowledge_gaps does not need the usage subset, only the
// total degree. Adding the usage filter back would re-tie the
// capability to ClassifyZeroEdge's notion of "alive" without buying
// any other call site.
//
// Optional capability — handleGetKnowledgeGaps falls back to the
// NodeDegreeCounts IN-list when the backend doesn't implement it.
type NodeDegreeByKinds interface {
NodeDegreeByKinds(kinds []NodeKind, pathPrefix string) []NodeDegreeRow
}