diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 15cf7928..40007e4d 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -235,6 +235,12 @@ type Server struct { equivalence *search.EquivalenceTable contractRegistry *contracts.Registry semanticMgr *semantic.Manager + // refsConfirmed is the lazy-enrichment ledger: symbol IDs whose incoming + // references have been confirmed on demand (via ConfirmSymbolRefs) this + // daemon session, so a repeat usages query serves from the confirmed graph + // instead of re-spawning the language server. An entry is recorded after + // the first attempt (success or empty) to bound per-query LSP work. + refsConfirmed sync.Map // symbolID → struct{} feedback *feedbackManager notes *notesManager memories *memoryManager diff --git a/internal/mcp/tools_core.go b/internal/mcp/tools_core.go index 7327d4ff..5bdc8736 100644 --- a/internal/mcp/tools_core.go +++ b/internal/mcp/tools_core.go @@ -2278,6 +2278,10 @@ func (s *Server) handleGetCallers(ctx context.Context, req mcp.CallToolRequest) RepoAllow: resolved.RepoAllow, ExcludeTests: req.GetBool("exclude_tests", false), } + // Lazy enrichment: confirm this symbol's callers on demand before + // answering, so a graph indexed without the eager LSP sweep still returns + // compiler-grade callers. No-op when already confirmed or eager ran. + s.confirmSymbolRefsOnDemand(eng.GetSymbol(id)) s.hydrateProxyTargets(ctx, id) sg := eng.GetCallers(id, opts) sg = filterSubGraphByResolvedScope(sg, resolved) @@ -2505,6 +2509,11 @@ func (s *Server) handleFindUsages(ctx context.Context, req mcp.CallToolRequest) if node != nil && !resolvedScopeAllowsNode(resolved, node) { return symbolNotFoundGuidance(id), nil } + // Lazy enrichment: confirm this symbol's incoming references on demand + // before answering, so a graph indexed without the eager LSP sweep still + // converges to compiler-grade usages. No-op when already confirmed or eager + // ran. + s.confirmSymbolRefsOnDemand(node) opts := query.QueryOptions{ WorkspaceID: resolved.WorkspaceID, ProjectID: resolved.ProjectID, diff --git a/internal/mcp/tools_lsp.go b/internal/mcp/tools_lsp.go index a80edaca..dcd2cdda 100644 --- a/internal/mcp/tools_lsp.go +++ b/internal/mcp/tools_lsp.go @@ -49,6 +49,39 @@ func (s *Server) enrichNodeOnDemand(node *graph.Node) { _, _ = provider.EnrichNode(s.graph, s.workspaceRootFor(absPath), node) } +// confirmSymbolRefsOnDemand faults in a callable symbol's INCOMING references +// on a usages-shaped query (find_usages / get_callers) when the synchronous +// LSP sweep is off. It lazy-spawns the language server, confirms this one +// symbol's callers via call hierarchy, and lands the lsp_resolved edges into +// the graph so the query answer — and every repeat — reflects compiler-grade +// usages accuracy. Idempotent per session via the refsConfirmed ledger; a +// no-op for non-callable nodes, when no call-hierarchy server serves the +// language, or when already confirmed. +func (s *Server) confirmSymbolRefsOnDemand(node *graph.Node) { + if node == nil || s.semanticMgr == nil || s.graph == nil { + return + } + if node.Kind != graph.KindFunction && node.Kind != graph.KindMethod { + return + } + if _, done := s.refsConfirmed.Load(node.ID); done { + return + } + absPath, err := s.absolutePath(node.FilePath) + if err != nil { + return + } + provider, _, err := s.lspProviderForPath(absPath) + if err != nil || provider == nil { + return + } + _, _ = provider.ConfirmSymbolRefs(s.graph, s.workspaceRootFor(absPath), node) + // Record after the attempt (even on 0/err) so a query doesn't re-spawn the + // server every call; a file re-index clears staleness through the normal + // restub path. Durable-ledger + retry semantics are the fuller version. + s.refsConfirmed.Store(node.ID, struct{}{}) +} + // registerLSPTools wires the LSP-action MCP surface: // // get_diagnostics — most recent publishDiagnostics for a file diff --git a/internal/semantic/lsp/provider.go b/internal/semantic/lsp/provider.go index 7fb2d3b9..f82da1f2 100644 --- a/internal/semantic/lsp/provider.go +++ b/internal/semantic/lsp/provider.go @@ -2573,6 +2573,75 @@ func (p *Provider) EnsureFileOpen(repoRoot, relPath string) error { // Returns (true, nil) when a type was stamped, (false, nil) when the server had // no type at the node's position (nothing is written), and (false, err) on a // transport failure. The file is opened on the server first (idempotent). +// ConfirmSymbolRefs confirms the incoming references to ONE symbol on demand. +// It prepares a call hierarchy at the symbol's definition and, for every +// server-verified incoming call, mints or promotes an lsp_resolved edge — +// the per-symbol unit of the whole-repo confirm + call-hierarchy sweep. A +// find_usages / get_callers answer for symbol S needs S's callers confirmed, +// not the whole repo's, so this lets lazy enrichment restore compiler-grade +// usages accuracy at query time without paying a cold-path sweep. It reuses +// recordHierarchyCall, so every hard-won correctness rule (callable-kind +// matching, per-site promotion, declaration-identity) applies unchanged. +// +// Scoped to callable nodes (the call-hierarchy unit); returns (0, nil) for +// other kinds or a server without call-hierarchy. LSP round-trips run +// unlocked; the graph mutations are batched under the resolve mutex. +func (p *Provider) ConfirmSymbolRefs(g graph.Store, repoRoot string, n *graph.Node) (int, error) { + if n == nil || (n.Kind != graph.KindFunction && n.Kind != graph.KindMethod) { + return 0, nil + } + if !p.Supports("textDocument/prepareCallHierarchy") { + return 0, nil + } + line, ok := lspLine(n) + if !ok { + return 0, nil + } + rel := nodeRelPath(n) + if err := p.EnsureFileOpen(repoRoot, rel); err != nil { + return 0, err + } + absRoot, err := filepath.Abs(repoRoot) + if err != nil { + return 0, err + } + col := 0 + if src := p.getSource(repoRoot, rel); src != nil { + col = identifierColumn(src, n.StartLine, n.Name) + } + items, err := p.prepareCallHierarchy(repoRoot, rel, line, col) + if err != nil { + return 0, err + } + // LSP round-trips first, unlocked. + type hop struct { + other CallHierarchyItem + ranges []Range + } + var hops []hop + for _, item := range items { + ins, ierr := p.incomingCalls(item) + if ierr != nil { + continue + } + for _, ic := range ins { + hops = append(hops, hop{other: ic.From, ranges: ic.FromRanges}) + } + } + if len(hops) == 0 { + return 0, nil + } + // Graph mutations under the resolve lock, batched. + result := &semantic.EnrichResult{Provider: p.Name()} + rmu := g.ResolveMutex() + rmu.Lock() + for _, h := range hops { + p.recordHierarchyCall(g, n.RepoPrefix, absRoot, n, h.other, false, h.ranges, result) + } + rmu.Unlock() + return result.EdgesConfirmed + result.EdgesAdded, nil +} + func (p *Provider) EnrichNode(g graph.Store, repoRoot string, n *graph.Node) (bool, error) { if n == nil { return false, nil