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