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:: / Dart // relative-URI stubs rewritten to the matching KindFile node // (e.g. .py or /__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::` and the multi-repo // `::unresolved::fnvalue::` 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 " // 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 }