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
833 lines
25 KiB
Go
833 lines
25 KiB
Go
package graph
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// OverlayLayer is one MCP session's parsed editor-buffer state. It
|
|
// holds the nodes and edges that the overlay introduces (or hides via
|
|
// tombstones) on top of an immutable base graph. The layer is built
|
|
// once per (session, content-hash) tuple by the MCP overlay middleware
|
|
// (`internal/mcp/overlay_view.go::buildOverlayLayer`) and is consulted
|
|
// read-only by `OverlaidView`.
|
|
//
|
|
// **Identity is preserved.** Gortex node IDs are derived from
|
|
// `file::symbol` paths, so a symbol that exists in both the on-disk
|
|
// and overlay versions of a file ends up with the same ID — the
|
|
// view substitutes the overlay's version transparently. New overlay
|
|
// symbols (a function the user just typed) get IDs that don't exist
|
|
// in base; deleted symbols (removed from the buffer) simply aren't in
|
|
// the layer's per-file node list.
|
|
//
|
|
// The layer is immutable after construction. The middleware never
|
|
// mutates it once the View is in flight; the base graph is never
|
|
// mutated by overlay flow at all. This is what makes the design
|
|
// safe for concurrent multi-session deployments — no shared mutable
|
|
// state between sessions or between an overlay-active session and a
|
|
// non-overlay session.
|
|
type OverlayLayer struct {
|
|
// Files covered by the overlay. The key is the file's graph path
|
|
// (repo-prefixed in multi-repo mode). Presence in this map means
|
|
// "the View should hide base's view of this path" — either to
|
|
// replace it with overlay content (entries[path] != nil) or to
|
|
// tombstone it (entries[path].Deleted).
|
|
entries map[string]*overlayFileEntry
|
|
|
|
// nodeByID lets GetNode hit a single map lookup. Holds every
|
|
// non-tombstoned overlay node across every overlay file.
|
|
nodeByID map[string]*Node
|
|
|
|
// outEdges maps each overlay-introduced source node ID to its
|
|
// resolved outgoing edges. Filled by the local resolver pass at
|
|
// layer construction.
|
|
outEdges map[string][]*Edge
|
|
|
|
// inEdges is the reverse index of outEdges keyed by target ID,
|
|
// so OverlaidView.GetInEdges can merge overlay-originating
|
|
// edges with base in-edges in O(1).
|
|
inEdges map[string][]*Edge
|
|
|
|
// nodesByName/Qual index overlay nodes for FindNodesByName /
|
|
// GetNodeByQualName fast paths.
|
|
nodesByName map[string][]*Node
|
|
nodesByQual map[string]*Node
|
|
|
|
// nameRemoved is the set of (name → IDs from base that are no
|
|
// longer present under the View). FindNodesByName uses this to
|
|
// filter base hits whose enclosing file is overlaid but whose
|
|
// id disappeared from the overlay's node list.
|
|
nameRemoved map[string]map[string]bool
|
|
}
|
|
|
|
// overlayFileEntry carries one file's overlay state inside the
|
|
// layer. Deleted=true is the tombstone variant — no nodes, no edges.
|
|
type overlayFileEntry struct {
|
|
Path string
|
|
Deleted bool
|
|
Nodes []*Node
|
|
}
|
|
|
|
// NewOverlayLayer constructs an empty layer. Callers build it up via
|
|
// AddFile / AddNode / AddEdge during the per-request layer-build
|
|
// pass, then freeze it by handing it to NewOverlaidView. After that
|
|
// point the layer is treated as immutable; the View never writes
|
|
// back.
|
|
func NewOverlayLayer() *OverlayLayer {
|
|
return &OverlayLayer{
|
|
entries: make(map[string]*overlayFileEntry),
|
|
nodeByID: make(map[string]*Node),
|
|
outEdges: make(map[string][]*Edge),
|
|
inEdges: make(map[string][]*Edge),
|
|
nodesByName: make(map[string][]*Node),
|
|
nodesByQual: make(map[string]*Node),
|
|
nameRemoved: make(map[string]map[string]bool),
|
|
}
|
|
}
|
|
|
|
// MarkFile registers an overlay file. Call once per overlay path
|
|
// before AddNode / AddEdge for that file. `deleted` true means the
|
|
// path is a tombstone — the View hides base's view of the path
|
|
// entirely, returning no nodes from GetFileNodes and treating the
|
|
// path's node IDs as non-existent for GetNode.
|
|
func (l *OverlayLayer) MarkFile(graphPath string, deleted bool) {
|
|
l.entries[graphPath] = &overlayFileEntry{Path: graphPath, Deleted: deleted}
|
|
}
|
|
|
|
// AddNode attaches one parsed overlay node to the layer. Must be
|
|
// called after MarkFile for the node's file. Idempotent on (graphPath,
|
|
// node ID) — second add silently replaces.
|
|
func (l *OverlayLayer) AddNode(graphPath string, n *Node) {
|
|
if n == nil {
|
|
return
|
|
}
|
|
entry, ok := l.entries[graphPath]
|
|
if !ok {
|
|
entry = &overlayFileEntry{Path: graphPath}
|
|
l.entries[graphPath] = entry
|
|
}
|
|
if entry.Deleted {
|
|
// Tombstone: silently drop. Caller bug — but cheap to absorb.
|
|
return
|
|
}
|
|
entry.Nodes = append(entry.Nodes, n)
|
|
l.nodeByID[n.ID] = n
|
|
if n.Name != "" {
|
|
l.nodesByName[n.Name] = append(l.nodesByName[n.Name], n)
|
|
}
|
|
if n.QualName != "" {
|
|
l.nodesByQual[n.QualName] = n
|
|
}
|
|
}
|
|
|
|
// AddEdge attaches one resolved overlay edge. The local-resolver
|
|
// pass at layer construction is expected to have rewritten any
|
|
// `unresolved::*` placeholders to point at concrete (overlay or
|
|
// base) node IDs before calling this; edges still carrying the
|
|
// placeholder are kept verbatim so OverlaidView.GetOutEdges still
|
|
// surfaces them — query tools can decide how to handle them, just
|
|
// like base's resolver-skipped edges.
|
|
func (l *OverlayLayer) AddEdge(e *Edge) {
|
|
if e == nil {
|
|
return
|
|
}
|
|
l.outEdges[e.From] = append(l.outEdges[e.From], e)
|
|
l.inEdges[e.To] = append(l.inEdges[e.To], e)
|
|
}
|
|
|
|
// MarkRemoved tells the layer that a base node ID is hidden by the
|
|
// overlay even though the overlay didn't re-emit it (a symbol the
|
|
// user deleted from the buffer). FindNodesByName uses this to filter
|
|
// stale base hits.
|
|
func (l *OverlayLayer) MarkRemoved(baseName, baseID string) {
|
|
if baseName == "" || baseID == "" {
|
|
return
|
|
}
|
|
set, ok := l.nameRemoved[baseName]
|
|
if !ok {
|
|
set = make(map[string]bool)
|
|
l.nameRemoved[baseName] = set
|
|
}
|
|
set[baseID] = true
|
|
}
|
|
|
|
// HasFile reports whether the overlay covers a particular graph path
|
|
// (either with replacement content or as a tombstone). The View uses
|
|
// this to decide whether to consult overlay or base for the path's
|
|
// reads.
|
|
func (l *OverlayLayer) HasFile(graphPath string) bool {
|
|
if l == nil {
|
|
return false
|
|
}
|
|
_, ok := l.entries[graphPath]
|
|
return ok
|
|
}
|
|
|
|
// IsTombstone reports whether the overlay marks the path as deleted.
|
|
func (l *OverlayLayer) IsTombstone(graphPath string) bool {
|
|
if l == nil {
|
|
return false
|
|
}
|
|
e := l.entries[graphPath]
|
|
return e != nil && e.Deleted
|
|
}
|
|
|
|
// FilePaths returns the sorted list of overlay-covered paths. Used
|
|
// by analyzers / the diff tool to enumerate the overlay's footprint.
|
|
func (l *OverlayLayer) FilePaths() []string {
|
|
if l == nil {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(l.entries))
|
|
for p := range l.entries {
|
|
out = append(out, p)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// HasNode reports whether the overlay layer carries a node with this
|
|
// ID. Used by the local-resolver pass in the mcp layer to drop base
|
|
// hits whose file is overlaid but whose specific ID wasn't kept by
|
|
// the overlay (i.e. the user deleted that symbol from the buffer).
|
|
func (l *OverlayLayer) HasNode(id string) bool {
|
|
if l == nil {
|
|
return false
|
|
}
|
|
_, ok := l.nodeByID[id]
|
|
return ok
|
|
}
|
|
|
|
// NodesByName returns the overlay-introduced nodes with the given
|
|
// short name. Empty slice when none. Used by the local-resolver
|
|
// pass.
|
|
func (l *OverlayLayer) NodesByName(name string) []*Node {
|
|
if l == nil {
|
|
return nil
|
|
}
|
|
src := l.nodesByName[name]
|
|
out := make([]*Node, len(src))
|
|
copy(out, src)
|
|
return out
|
|
}
|
|
|
|
// OutEdgesByFromAll returns a snapshot of the layer's outgoing-edge
|
|
// map keyed by source ID. The resolver pass iterates this to rewrite
|
|
// `unresolved::*` placeholders. The returned map shares its slices
|
|
// with the layer (resolver mutates Edge.To in place); the map keys
|
|
// are stable for the snapshot.
|
|
func (l *OverlayLayer) OutEdgesByFromAll() map[string][]*Edge {
|
|
if l == nil {
|
|
return nil
|
|
}
|
|
out := make(map[string][]*Edge, len(l.outEdges))
|
|
for k, v := range l.outEdges {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
// RebuildInEdges rebuilds the reverse-index map after the local
|
|
// resolver pass mutates Edge.To in place. Cheap: O(#overlay edges).
|
|
func (l *OverlayLayer) RebuildInEdges() {
|
|
if l == nil {
|
|
return
|
|
}
|
|
l.inEdges = make(map[string][]*Edge, len(l.outEdges))
|
|
for _, edges := range l.outEdges {
|
|
for _, e := range edges {
|
|
l.inEdges[e.To] = append(l.inEdges[e.To], e)
|
|
}
|
|
}
|
|
}
|
|
|
|
// nodesForFile returns the overlay nodes for a path (empty for
|
|
// tombstones). Internal — used by OverlaidView.
|
|
func (l *OverlayLayer) nodesForFile(graphPath string) []*Node {
|
|
if l == nil {
|
|
return nil
|
|
}
|
|
e := l.entries[graphPath]
|
|
if e == nil || e.Deleted {
|
|
return nil
|
|
}
|
|
out := make([]*Node, len(e.Nodes))
|
|
copy(out, e.Nodes)
|
|
return out
|
|
}
|
|
|
|
// OverlaidView composes an immutable base Reader with a per-session
|
|
// overlay layer. Every read path consults the layer first for paths
|
|
// the overlay covers; falls through to base otherwise. The base is
|
|
// never mutated; the layer is built once per request and discarded
|
|
// with the request. This means concurrent sessions — overlay-active
|
|
// or not — each see their own consistent view, and the file watcher's
|
|
// reindex passes (which mutate base) don't corrupt overlay queries.
|
|
type OverlaidView struct {
|
|
base Reader
|
|
layer *OverlayLayer
|
|
|
|
// statsOnce caches the (potentially expensive) Stats walk so
|
|
// repeated calls within one request don't pay the AllNodes /
|
|
// AllEdges cost twice.
|
|
statsOnce sync.Once
|
|
stats GraphStats
|
|
}
|
|
|
|
// NewOverlaidView builds a view. If layer is nil the view is a pure
|
|
// pass-through and consumers pay no overlay overhead.
|
|
func NewOverlaidView(base Reader, layer *OverlayLayer) *OverlaidView {
|
|
return &OverlaidView{base: base, layer: layer}
|
|
}
|
|
|
|
// Base exposes the underlying base reader. The diff tool reads
|
|
// against (view.Base()) and against (view) directly to compute the
|
|
// delta induced by the overlay.
|
|
func (v *OverlaidView) Base() Reader { return v.base }
|
|
|
|
// Layer exposes the per-session overlay layer (nil when none).
|
|
// Diagnostic / debug tools use it to introspect what the overlay
|
|
// covers.
|
|
func (v *OverlaidView) Layer() *OverlayLayer { return v.layer }
|
|
|
|
// IDFile returns the file path encoded in a Gortex node ID, or "" if
|
|
// the id isn't file-anchored. Gortex IDs follow the pattern
|
|
// `<filepath>::<symbol>[.member][#param:name]` so the file prefix is
|
|
// the substring before the first `::`. Module / package / virtual
|
|
// nodes use other prefixes that won't match an overlay path.
|
|
func IDFile(id string) string {
|
|
if id == "" {
|
|
return ""
|
|
}
|
|
if i := strings.Index(id, "::"); i > 0 {
|
|
return id[:i]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// nodeBelongsToOverlay reports whether an ID's file is covered by
|
|
// the layer.
|
|
func (v *OverlaidView) nodeBelongsToOverlay(id string) bool {
|
|
if v.layer == nil {
|
|
return false
|
|
}
|
|
return v.layer.HasFile(IDFile(id))
|
|
}
|
|
|
|
// GetNode returns the overlay's version of a node when the ID
|
|
// belongs to an overlaid file, the base node otherwise. Returns nil
|
|
// when the symbol exists in base but was removed in the overlay
|
|
// (the per-file overlay node list didn't include it).
|
|
func (v *OverlaidView) GetNode(id string) *Node {
|
|
if v.layer != nil {
|
|
if v.nodeBelongsToOverlay(id) {
|
|
return v.layer.nodeByID[id] // may be nil — overlay deleted it
|
|
}
|
|
}
|
|
if v.base == nil {
|
|
return nil
|
|
}
|
|
return v.base.GetNode(id)
|
|
}
|
|
|
|
// GetNodesByIDs returns the overlay-aware *Node for each input ID.
|
|
// Overlay-owned IDs short-circuit to the per-session layer (and may
|
|
// resolve to nil when the overlay deleted the node); the remainder
|
|
// fans out as a single batched lookup against the base store. Missing
|
|
// IDs are simply absent from the returned map.
|
|
func (v *OverlaidView) GetNodesByIDs(ids []string) map[string]*Node {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]*Node, len(ids))
|
|
baseIDs := ids[:0:0] // fresh backing array — never aliases caller's slice
|
|
for _, id := range ids {
|
|
if id == "" {
|
|
continue
|
|
}
|
|
if _, dup := out[id]; dup {
|
|
continue
|
|
}
|
|
if v.layer != nil && v.nodeBelongsToOverlay(id) {
|
|
if n := v.layer.nodeByID[id]; n != nil {
|
|
out[id] = n
|
|
}
|
|
// Overlay tombstone — ID is hidden, do not fall back to base.
|
|
continue
|
|
}
|
|
// Track for the single base round-trip; reserve a slot in `out`
|
|
// only after the batched lookup returns.
|
|
baseIDs = append(baseIDs, id)
|
|
}
|
|
if len(baseIDs) > 0 && v.base != nil {
|
|
for id, n := range v.base.GetNodesByIDs(baseIDs) {
|
|
if n != nil {
|
|
out[id] = n
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GetNodeByQualName: overlay first, then base. Base hits are filtered
|
|
// to drop entries whose file is overlaid (the overlay's view wins).
|
|
func (v *OverlaidView) GetNodeByQualName(qualName string) *Node {
|
|
if v.layer != nil {
|
|
if n := v.layer.nodesByQual[qualName]; n != nil {
|
|
return n
|
|
}
|
|
}
|
|
if v.base == nil {
|
|
return nil
|
|
}
|
|
n := v.base.GetNodeByQualName(qualName)
|
|
if n != nil && v.layer != nil && v.layer.HasFile(IDFile(n.ID)) {
|
|
// Base hit landed in an overlaid file but the overlay didn't
|
|
// re-emit a node with this qualified name → it's gone.
|
|
return nil
|
|
}
|
|
return n
|
|
}
|
|
|
|
// GetNodesByQualNames resolves each name through GetNodeByQualName so the
|
|
// overlay's layer-first / shadowed-file filtering applies — an inherited
|
|
// base batch would bypass the overlay. Per-name is fine: an interactive
|
|
// overlay's working set is small (the batch form exists for the
|
|
// cold-warmup scale on the base store, not here). Returns only hits.
|
|
func (v *OverlaidView) GetNodesByQualNames(qualNames []string) map[string]*Node {
|
|
out := make(map[string]*Node, len(qualNames))
|
|
for _, q := range qualNames {
|
|
if q == "" {
|
|
continue
|
|
}
|
|
if _, done := out[q]; done {
|
|
continue
|
|
}
|
|
if n := v.GetNodeByQualName(q); n != nil {
|
|
out[q] = n
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// FindNodesByName merges base hits (filtered to drop nodes in
|
|
// overlaid files unless the overlay re-emitted them) with overlay
|
|
// hits. Order is overlay-first, then base — callers that picked
|
|
// "first match" semantics get the overlay version automatically.
|
|
func (v *OverlaidView) FindNodesByName(name string) []*Node {
|
|
var out []*Node
|
|
if v.layer != nil {
|
|
out = append(out, v.layer.nodesByName[name]...)
|
|
}
|
|
if v.base == nil {
|
|
return out
|
|
}
|
|
for _, n := range v.base.FindNodesByName(name) {
|
|
if v.layer != nil {
|
|
if v.layer.HasFile(IDFile(n.ID)) {
|
|
// Overlaid file: base's node for this name is
|
|
// always hidden. If the overlay re-emitted the same
|
|
// ID it's already in `out` from the layer's
|
|
// nodesByName prepend above; if the overlay deleted
|
|
// the symbol it must not surface at all. Either way
|
|
// we skip — no need to discriminate.
|
|
continue
|
|
}
|
|
if v.layer.nameRemoved[name] != nil && v.layer.nameRemoved[name][n.ID] {
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, n)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// FindNodesByNameContaining merges overlay-touched name hits with the
|
|
// base result, then re-applies the per-overlay-file masking the same
|
|
// way FindNodesByName does. Order is overlay-first, then base; the
|
|
// limit caps the merged total. Empty substr or both layers nil
|
|
// returns nil.
|
|
func (v *OverlaidView) FindNodesByNameContaining(substr string, limit int) []*Node {
|
|
if substr == "" {
|
|
return nil
|
|
}
|
|
needle := strings.ToLower(substr)
|
|
var out []*Node
|
|
// Overlay-side: walk the layer's nodesByName index — the same
|
|
// bucket FindNodesByName reads from — and accept any name whose
|
|
// lowercase form contains the needle.
|
|
if v.layer != nil {
|
|
for name, bucket := range v.layer.nodesByName {
|
|
if strings.Contains(strings.ToLower(name), needle) {
|
|
out = append(out, bucket...)
|
|
if limit > 0 && len(out) >= limit {
|
|
return out[:limit]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if v.base == nil {
|
|
return out
|
|
}
|
|
// Base-side: fetch with an inflated limit so overlay-mask drops
|
|
// don't leave a short page. Then re-apply the same overlaid-file
|
|
// + name-removed mask FindNodesByName uses.
|
|
fetch := limit
|
|
if fetch > 0 {
|
|
fetch *= 2
|
|
}
|
|
for _, n := range v.base.FindNodesByNameContaining(substr, fetch) {
|
|
if v.layer != nil {
|
|
if v.layer.HasFile(IDFile(n.ID)) {
|
|
continue
|
|
}
|
|
if v.layer.nameRemoved[n.Name] != nil && v.layer.nameRemoved[n.Name][n.ID] {
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, n)
|
|
if limit > 0 && len(out) >= limit {
|
|
return out[:limit]
|
|
}
|
|
}
|
|
if limit > 0 && len(out) > limit {
|
|
out = out[:limit]
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GetFileNodes: if the path is overlaid, return overlay's nodes
|
|
// (empty for tombstones). Otherwise pass through to base.
|
|
func (v *OverlaidView) GetFileNodes(filePath string) []*Node {
|
|
if v.layer != nil && v.layer.HasFile(filePath) {
|
|
return v.layer.nodesForFile(filePath)
|
|
}
|
|
if v.base == nil {
|
|
return nil
|
|
}
|
|
return v.base.GetFileNodes(filePath)
|
|
}
|
|
|
|
// GetRepoNodes filters base's per-repo node list by dropping nodes
|
|
// whose file is overlaid (unless the overlay re-emitted them) and
|
|
// appending the overlay's nodes for any overlaid file inside the
|
|
// requested repo prefix.
|
|
func (v *OverlaidView) GetRepoNodes(repoPrefix string) []*Node {
|
|
if v.base == nil {
|
|
return nil
|
|
}
|
|
baseNodes := v.base.GetRepoNodes(repoPrefix)
|
|
if v.layer == nil {
|
|
return baseNodes
|
|
}
|
|
out := make([]*Node, 0, len(baseNodes))
|
|
for _, n := range baseNodes {
|
|
if v.layer.HasFile(IDFile(n.ID)) {
|
|
// File is overlaid. Surface only if the overlay
|
|
// re-emitted this exact ID; otherwise it's hidden.
|
|
if v.layer.nodeByID[n.ID] == nil {
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, n)
|
|
}
|
|
for _, path := range v.layer.FilePaths() {
|
|
if !strings.HasPrefix(path, repoPrefix+"/") && path != repoPrefix {
|
|
continue
|
|
}
|
|
out = append(out, v.layer.nodesForFile(path)...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GetOutEdges: when the source node's file is overlaid, use the
|
|
// overlay's resolved out-edges. Otherwise return base's edges but
|
|
// drop any whose target points into an overlaid file at a node ID
|
|
// the overlay no longer carries (target deleted in buffer).
|
|
func (v *OverlaidView) GetOutEdges(nodeID string) []*Edge {
|
|
if v.layer != nil && v.nodeBelongsToOverlay(nodeID) {
|
|
src := v.layer.outEdges[nodeID]
|
|
out := make([]*Edge, len(src))
|
|
copy(out, src)
|
|
return out
|
|
}
|
|
if v.base == nil {
|
|
return nil
|
|
}
|
|
edges := v.base.GetOutEdges(nodeID)
|
|
if v.layer == nil {
|
|
return edges
|
|
}
|
|
out := edges[:0:0]
|
|
for _, e := range edges {
|
|
if v.layer.HasFile(IDFile(e.To)) {
|
|
if v.layer.nodeByID[e.To] == nil {
|
|
continue // target deleted in overlay
|
|
}
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GetInEdges merges base's incoming edges (filtered to drop those
|
|
// originating in overlaid files, since those are replaced by overlay
|
|
// versions) with the overlay's in-edges for the same target.
|
|
func (v *OverlaidView) GetInEdges(nodeID string) []*Edge {
|
|
if v.layer == nil {
|
|
if v.base == nil {
|
|
return nil
|
|
}
|
|
return v.base.GetInEdges(nodeID)
|
|
}
|
|
var out []*Edge
|
|
if v.base != nil {
|
|
for _, e := range v.base.GetInEdges(nodeID) {
|
|
if v.layer.HasFile(IDFile(e.From)) {
|
|
// Source is overlaid — the overlay's version of this
|
|
// edge wins (or the overlay simply deleted the call).
|
|
continue
|
|
}
|
|
if v.layer.HasFile(IDFile(e.To)) && v.layer.nodeByID[e.To] == nil {
|
|
// Target was deleted by the overlay.
|
|
continue
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
out = append(out, v.layer.inEdges[nodeID]...)
|
|
return out
|
|
}
|
|
|
|
// GetOutEdgesByNodeIDs returns the overlay-aware outgoing-edge map for
|
|
// every input id. Overlay-owned ids short-circuit to the per-session
|
|
// layer; the remainder fans out as a single batched lookup against
|
|
// the base store. Output mirrors GetOutEdges's per-id semantics
|
|
// (target-side overlay deletions filtered out), but in one cgo
|
|
// round-trip per direction instead of N.
|
|
func (v *OverlaidView) GetOutEdgesByNodeIDs(ids []string) map[string][]*Edge {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string][]*Edge, len(ids))
|
|
baseIDs := ids[:0:0]
|
|
seen := make(map[string]struct{}, len(ids))
|
|
for _, id := range ids {
|
|
if id == "" {
|
|
continue
|
|
}
|
|
if _, dup := seen[id]; dup {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
if v.layer != nil && v.nodeBelongsToOverlay(id) {
|
|
src := v.layer.outEdges[id]
|
|
cp := make([]*Edge, len(src))
|
|
copy(cp, src)
|
|
out[id] = cp
|
|
continue
|
|
}
|
|
baseIDs = append(baseIDs, id)
|
|
}
|
|
if len(baseIDs) > 0 && v.base != nil {
|
|
base := v.base.GetOutEdgesByNodeIDs(baseIDs)
|
|
for id, edges := range base {
|
|
if v.layer == nil {
|
|
out[id] = edges
|
|
continue
|
|
}
|
|
filtered := edges[:0:0]
|
|
for _, e := range edges {
|
|
if v.layer.HasFile(IDFile(e.To)) {
|
|
if v.layer.nodeByID[e.To] == nil {
|
|
continue // target deleted in overlay
|
|
}
|
|
}
|
|
filtered = append(filtered, e)
|
|
}
|
|
out[id] = filtered
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GetInEdgesByNodeIDs is the inbound sibling of GetOutEdgesByNodeIDs.
|
|
// Merges base in-edges (filtered to drop edges sourced in overlaid
|
|
// files) with overlay-introduced in-edges for each input id, all in a
|
|
// single batched base round-trip.
|
|
func (v *OverlaidView) GetInEdgesByNodeIDs(ids []string) map[string][]*Edge {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string][]*Edge, len(ids))
|
|
seen := make(map[string]struct{}, len(ids))
|
|
uniq := ids[:0:0]
|
|
for _, id := range ids {
|
|
if id == "" {
|
|
continue
|
|
}
|
|
if _, dup := seen[id]; dup {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
uniq = append(uniq, id)
|
|
}
|
|
if len(uniq) == 0 {
|
|
return out
|
|
}
|
|
if v.base != nil {
|
|
base := v.base.GetInEdgesByNodeIDs(uniq)
|
|
for _, id := range uniq {
|
|
edges := base[id]
|
|
if v.layer == nil {
|
|
out[id] = edges
|
|
continue
|
|
}
|
|
filtered := edges[:0:0]
|
|
for _, e := range edges {
|
|
if v.layer.HasFile(IDFile(e.From)) {
|
|
continue // source is overlaid — overlay's version wins
|
|
}
|
|
if v.layer.HasFile(IDFile(e.To)) && v.layer.nodeByID[e.To] == nil {
|
|
continue // target was deleted by overlay
|
|
}
|
|
filtered = append(filtered, e)
|
|
}
|
|
out[id] = filtered
|
|
}
|
|
}
|
|
if v.layer != nil {
|
|
for _, id := range uniq {
|
|
if extras := v.layer.inEdges[id]; len(extras) > 0 {
|
|
out[id] = append(out[id], extras...)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// AllNodes returns base's nodes minus nodes in overlaid files, plus
|
|
// every node the overlay introduced. Bulk-read consumers (analyzers,
|
|
// search reindex, snapshot export) get an overlay-consistent view
|
|
// without paying any extra copy beyond the base snapshot's.
|
|
func (v *OverlaidView) AllNodes() []*Node {
|
|
if v.base == nil {
|
|
return nil
|
|
}
|
|
baseNodes := v.base.AllNodes()
|
|
if v.layer == nil {
|
|
return baseNodes
|
|
}
|
|
out := make([]*Node, 0, len(baseNodes))
|
|
for _, n := range baseNodes {
|
|
if v.layer.HasFile(IDFile(n.ID)) {
|
|
if v.layer.nodeByID[n.ID] == nil {
|
|
continue
|
|
}
|
|
// Else: overlay's version was kept under the same ID; the
|
|
// layer's slice will include it below, so skip base's copy
|
|
// to avoid duplicates.
|
|
continue
|
|
}
|
|
out = append(out, n)
|
|
}
|
|
for _, n := range v.layer.nodeByID {
|
|
out = append(out, n)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// AllEdges returns base's edges minus those involving overlaid
|
|
// files, plus every overlay-introduced edge.
|
|
func (v *OverlaidView) AllEdges() []*Edge {
|
|
if v.base == nil {
|
|
return nil
|
|
}
|
|
baseEdges := v.base.AllEdges()
|
|
if v.layer == nil {
|
|
return baseEdges
|
|
}
|
|
out := make([]*Edge, 0, len(baseEdges))
|
|
for _, e := range baseEdges {
|
|
if v.layer.HasFile(IDFile(e.From)) || v.layer.HasFile(IDFile(e.To)) {
|
|
continue
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
for _, edges := range v.layer.outEdges {
|
|
out = append(out, edges...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// NodeCount / EdgeCount — derived from base counters adjusted by the
|
|
// overlay delta. Cheap enough to recompute per call.
|
|
func (v *OverlaidView) NodeCount() int {
|
|
if v.base == nil {
|
|
return 0
|
|
}
|
|
if v.layer == nil {
|
|
return v.base.NodeCount()
|
|
}
|
|
delta := 0
|
|
for path, entry := range v.layer.entries {
|
|
baseCount := len(v.base.GetFileNodes(path))
|
|
if entry.Deleted {
|
|
delta -= baseCount
|
|
continue
|
|
}
|
|
delta += len(entry.Nodes) - baseCount
|
|
}
|
|
return v.base.NodeCount() + delta
|
|
}
|
|
|
|
func (v *OverlaidView) EdgeCount() int {
|
|
if v.base == nil {
|
|
return 0
|
|
}
|
|
if v.layer == nil {
|
|
return v.base.EdgeCount()
|
|
}
|
|
return len(v.AllEdges())
|
|
}
|
|
|
|
// EdgeIdentityRevisions delegates to the base graph: provenance churn
|
|
// is a property of the persistent graph, and an overlay layer is a
|
|
// non-mutating per-session shadow that never upgrades edge provenance.
|
|
func (v *OverlaidView) EdgeIdentityRevisions() int {
|
|
if v.base == nil {
|
|
return 0
|
|
}
|
|
return v.base.EdgeIdentityRevisions()
|
|
}
|
|
|
|
// Stats is best-effort under overlay: we report base's stats (the
|
|
// analyzer-shaped GraphStats requires per-kind / per-language
|
|
// breakdowns that the overlay layer doesn't expose cheaply). Caching
|
|
// keeps repeated Stats() calls inside one request to a single base
|
|
// lookup.
|
|
func (v *OverlaidView) Stats() GraphStats {
|
|
if v.base == nil {
|
|
return GraphStats{}
|
|
}
|
|
v.statsOnce.Do(func() {
|
|
v.stats = v.base.Stats()
|
|
})
|
|
return v.stats
|
|
}
|
|
|
|
// RepoStats — same conservatism as Stats; overlay deltas are
|
|
// excluded. The handful of tools that read RepoStats are bookkeeping
|
|
// rather than load-bearing, and the overlay-affected nodes are still
|
|
// reachable through the per-node read paths.
|
|
func (v *OverlaidView) RepoStats() map[string]GraphStats {
|
|
if v.base == nil {
|
|
return nil
|
|
}
|
|
return v.base.RepoStats()
|
|
}
|
|
|
|
// Compile-time assertion that *OverlaidView satisfies Reader.
|
|
var _ Reader = (*OverlaidView)(nil)
|