diff --git a/docs/lsp.md b/docs/lsp.md index 7e75eb2e..693936ce 100644 --- a/docs/lsp.md +++ b/docs/lsp.md @@ -206,6 +206,10 @@ ambiguous. A pass runs up to five phases: 3. **Definition-rebind fallback** — for edges the confirm pass could not settle from references alone, asks for the call site's definition (`textDocument/definition`) and rebinds the edge to the concrete target. + An answer that agrees with the heuristic target counts as + `edges_confirmed`; an answer naming a different same-name declaration + rewrites the edge (tagged `rebound_from`) and counts as `edges_rebound` — + a correction of the heuristic graph, not a confirmation of it. 4. **References-add pass** — only for servers that expose references but not a call hierarchy; recovers the caller edges a declaration's references imply. 5. **Per-file sweep** — the whole-repo hover / hierarchy phase. Per function or @@ -297,8 +301,11 @@ cross-file signal to show for the cost. Rather than drive that churn, the pass **degrades to reference confirmation**: it runs the confirm and rebind passes (which work inside the fallback translation unit on fallback flags) and skips the interface pass, the references-add pass, the entire per-file sweep, and all -header files. Edge tiers and confirmed / refuted edges are unaffected; hover -type strings and call / type-hierarchy edges are absent for that pass. +header files. Edge tiers are unaffected, and the pass's yield lands under +`edges_confirmed` / `edges_rebound` as usual — a degraded pass that settles +most of its edges through the definition fallback reports mostly rebinds, not +zero yield. Hover type strings and call / type-hierarchy edges are absent for +that pass. A degraded pass warns once with the remediation and marks its result `degraded`. `index_health` surfaces a recommendation naming the repository and diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index 5954c200..312e7c37 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -1084,6 +1084,7 @@ func (idx *Indexer) runDeferredEnrich() { zap.Int("confirmed", result.EdgesConfirmed), zap.Int("added", result.EdgesAdded), zap.Int("refuted", result.EdgesRefuted), + zap.Int("rebound", result.EdgesRebound), zap.Float64("coverage", result.CoveragePercent), ) if result.Partial { @@ -1131,6 +1132,7 @@ func (idx *Indexer) runDeferredEnrich() { zap.Int("confirmed", r.EdgesConfirmed), zap.Int("added", r.EdgesAdded), zap.Int("refuted", r.EdgesRefuted), + zap.Int("rebound", r.EdgesRebound), zap.Float64("coverage", r.CoveragePercent), ) } @@ -3800,6 +3802,7 @@ func (idx *Indexer) indexCtxRaw(ctx context.Context, root string) (result *Index zap.Int("confirmed", r.EdgesConfirmed), zap.Int("added", r.EdgesAdded), zap.Int("refuted", r.EdgesRefuted), + zap.Int("rebound", r.EdgesRebound), zap.Float64("coverage", r.CoveragePercent), ) } diff --git a/internal/mcp/tools_enhancements.go b/internal/mcp/tools_enhancements.go index 4c31bf09..11eb83c2 100644 --- a/internal/mcp/tools_enhancements.go +++ b/internal/mcp/tools_enhancements.go @@ -3295,7 +3295,7 @@ func (s *Server) buildIndexHealthPayloadCtx(ctx context.Context) (map[string]any continue } label := st.Provider + " in " + st.Repo - landed := st.EdgesConfirmed + st.EdgesAdded + st.NodesEnriched + st.SymbolsCovered + landed := st.EdgesConfirmed + st.EdgesRebound + st.EdgesAdded + st.NodesEnriched + st.SymbolsCovered // A provider that degrades for a language the graph does not contain is // correct and expected — the Go pass on a Rust tree is the case the // module gate exists to skip cheaply. Only a language actually present @@ -3430,7 +3430,7 @@ func (s *Server) buildIndexHealthPayloadCtx(ctx context.Context) (map[string]any if st.Language == "" { continue } - lspEdgesByLang[st.Language] += st.EdgesAdded + st.EdgesConfirmed + lspEdgesByLang[st.Language] += st.EdgesAdded + st.EdgesConfirmed + st.EdgesRebound } if len(lspEdgesByLang) > 0 { result["lsp_resolved_edges_by_language"] = lspEdgesByLang diff --git a/internal/semantic/lsp/enrich_rebound_ledger_test.go b/internal/semantic/lsp/enrich_rebound_ledger_test.go new file mode 100644 index 00000000..bfa28911 --- /dev/null +++ b/internal/semantic/lsp/enrich_rebound_ledger_test.go @@ -0,0 +1,121 @@ +package lsp + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" +) + +// Rebound ledger (#605): the definition fallback can answer an unconfirmed +// edge two ways — the server agrees with the heuristic target, or it lands +// on a DIFFERENT same-name declaration and the edge is rewritten (tagged +// rebound_from). The second outcome is a correction of the heuristic +// graph, not a confirmation of it, and the result must count it +// separately: a pass that "confirmed" 5,000 edges by rewriting half of +// them describes accuracy the graph never had. + +// reboundFixture writes two same-name C declarations in separate served +// files plus a caller, and seeds one ambiguous call edge bound to the +// same-file declaration. references never confirm it, so the definition +// fallback adjudicates; the scripted definition answer decides which +// outcome the test observes. NeedsCompileDB with no database keeps the +// pass degraded — reference-confirm and definition-fallback only, no +// sweep noise. +func reboundFixture(t *testing.T) (string, graph.Store) { + t.Helper() + repoRoot := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(repoRoot, "a.c"), + []byte("int target(void) { return 0; }\nint caller(void) { return target(); }\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(repoRoot, "b.c"), + []byte("int target(void) { return 1; }\n"), 0o644)) + + g := graph.New() + g.AddNode(&graph.Node{ID: "a.c::target", Kind: graph.KindFunction, Name: "target", + FilePath: "a.c", StartLine: 1, EndLine: 1, Language: "c"}) + g.AddNode(&graph.Node{ID: "a.c::caller", Kind: graph.KindFunction, Name: "caller", + FilePath: "a.c", StartLine: 2, EndLine: 2, Language: "c"}) + g.AddNode(&graph.Node{ID: "b.c::target", Kind: graph.KindFunction, Name: "target", + FilePath: "b.c", StartLine: 1, EndLine: 1, Language: "c"}) + g.AddEdge(&graph.Edge{From: "a.c::caller", To: "a.c::target", Kind: graph.EdgeCalls, + FilePath: "a.c", Line: 2, Confidence: 0.7, ConfidenceLabel: "INFERRED", Origin: graph.OriginTextMatched}) + return repoRoot, g +} + +// reboundProvider wires the fixture's provider: references never confirm, +// definition answers with the given location. +func reboundProvider(t *testing.T, defLoc Location) (*Provider, func()) { + t.Helper() + server := newInstrumentedServer() + server.handle("textDocument/references", func(params json.RawMessage) (any, *jsonRPCError) { + return []Location{}, nil + }) + server.handle("textDocument/definition", func(params json.RawMessage) (any, *jsonRPCError) { + return []Location{defLoc}, nil + }) + p, cleanup := providerWithInstrumentedServer(t, server, []string{"c", "cpp"}, 2) + p.spec = clangdLikeSpec() + return p, cleanup +} + +func callEdgeFrom(t *testing.T, g graph.Store, from string) *graph.Edge { + t.Helper() + for _, e := range g.GetOutEdges(from) { + if e.Kind == graph.EdgeCalls { + return e + } + } + t.Fatalf("no call edge from %s", from) + return nil +} + +func TestLSP_Enrich_DefinitionRebindCountedAsRebound(t *testing.T) { + repoRoot, g := reboundFixture(t) + p, cleanup := reboundProvider(t, Location{ + URI: pathToURI(filepath.Join(repoRoot, "b.c")), + Range: Range{Start: Position{Line: 0, Character: 4}}, + }) + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + result, err := p.EnrichRepoContext(ctx, g, "", repoRoot, nil) + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, 1, result.EdgesRebound, "a rewritten target is a correction, counted as rebound") + assert.Zero(t, result.EdgesConfirmed, "a rebind must not inflate the confirmed count") + + e := callEdgeFrom(t, g, "a.c::caller") + assert.Equal(t, "b.c::target", e.To, "the edge follows the server's answer") + assert.Equal(t, "a.c::target", e.Meta["rebound_from"], "the ledger tag names the heuristic target") +} + +func TestLSP_Enrich_DefinitionAgreementCountedAsConfirmed(t *testing.T) { + repoRoot, g := reboundFixture(t) + p, cleanup := reboundProvider(t, Location{ + URI: pathToURI(filepath.Join(repoRoot, "a.c")), + Range: Range{Start: Position{Line: 0, Character: 4}}, + }) + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + result, err := p.EnrichRepoContext(ctx, g, "", repoRoot, nil) + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, 1, result.EdgesConfirmed, "server agreement is a genuine confirmation") + assert.Zero(t, result.EdgesRebound, "nothing was rewritten") + + e := callEdgeFrom(t, g, "a.c::caller") + assert.Equal(t, "a.c::target", e.To, "the heuristic target stands") + assert.NotContains(t, e.Meta, "rebound_from") +} diff --git a/internal/semantic/lsp/provider.go b/internal/semantic/lsp/provider.go index 845d4333..ff40bce0 100644 --- a/internal/semantic/lsp/provider.go +++ b/internal/semantic/lsp/provider.go @@ -1219,6 +1219,11 @@ func (p *Provider) EnrichRepoContext(ctx context.Context, g graph.Store, repoPre fallbackMutations.stagePersist(t.edge) rmu.Unlock() result.EdgesConfirmed++ + // Both fallback arms are yield the productivity checkpoint must + // see: on a degraded pass this loop can be the ONLY source of + // progress, and without these the checkpoint reads a pass that + // settles thousands of edges here as zero-yield and cancels it. + usefulYield.Add(1) case rebindTargetAcceptable(cand.Kind) && !edgeExistsAt(view, t.edge.From, cand.ID, t.edge.Kind, t.edge.Line): rmu.Lock() // Mutate the full edge state before staging the set-oriented @@ -1229,7 +1234,8 @@ func (p *Provider) EnrichRepoContext(ctx context.Context, g graph.Store, repoPre t.edge.Meta["rebound_from"] = oldTo fallbackMutations.stageReindex(view, t.edge, oldTo) rmu.Unlock() - result.EdgesConfirmed++ + result.EdgesRebound++ + usefulYield.Add(1) } } releaseSite() @@ -1260,6 +1266,7 @@ func (p *Provider) EnrichRepoContext(ctx context.Context, g graph.Store, repoPre zap.String("repo_prefix", repoPrefix), zap.Bool("degraded", true), zap.Int("edges_confirmed", result.EdgesConfirmed), + zap.Int("edges_rebound", result.EdgesRebound), zap.Int("did_opens", didOpens), zap.Int("reopened_files", reopenedFiles), zap.Int("doc_evictions", docEvictions), @@ -1782,7 +1789,8 @@ func (p *Provider) EnrichRepoContext(ctx context.Context, g graph.Store, repoPre ) if targetedBreaker.isTripped() && hoverBreaker.isTripped() && - result.EdgesConfirmed == 0 && result.EdgesAdded == 0 && result.NodesEnriched == 0 { + result.EdgesConfirmed == 0 && result.EdgesRebound == 0 && + result.EdgesAdded == 0 && result.NodesEnriched == 0 { result.DegradedReason = "language server answered no request for this workspace; pass abandoned by zero-yield breaker" } @@ -1805,6 +1813,7 @@ func (p *Provider) EnrichRepoContext(ctx context.Context, g graph.Store, repoPre p.logger.Warn("LSP enrich: pass cancelled at deadline; completed work already landed", zap.String("repo_prefix", repoPrefix), zap.Int("edges_confirmed", result.EdgesConfirmed), + zap.Int("edges_rebound", result.EdgesRebound), zap.Int("edges_added", result.EdgesAdded), zap.Int("nodes_enriched", result.NodesEnriched), zap.Error(ctx.Err()), diff --git a/internal/semantic/manager.go b/internal/semantic/manager.go index 77e48fde..03bdd1f7 100644 --- a/internal/semantic/manager.go +++ b/internal/semantic/manager.go @@ -727,6 +727,7 @@ func (m *Manager) setEnrichStatus(repo, provider, lang, state string, deadline t if result != nil { st.DurationMs = result.DurationMs st.EdgesConfirmed = result.EdgesConfirmed + st.EdgesRebound = result.EdgesRebound st.EdgesAdded = result.EdgesAdded st.NodesEnriched = result.NodesEnriched st.SymbolsTotal = result.SymbolsTotal @@ -1207,6 +1208,7 @@ func (m *Manager) runEnrichOne(g graph.Store, repoName, repoRoot, lang string, p zap.Int("confirmed", result.EdgesConfirmed), zap.Int("added", result.EdgesAdded), zap.Int("refuted", result.EdgesRefuted), + zap.Int("rebound", result.EdgesRebound), zap.Int("nodes_enriched", result.NodesEnriched), zap.Float64("coverage", result.CoveragePercent), zap.Int64("duration_ms", result.DurationMs), diff --git a/internal/semantic/provider.go b/internal/semantic/provider.go index dc695266..cb692875 100644 --- a/internal/semantic/provider.go +++ b/internal/semantic/provider.go @@ -128,10 +128,16 @@ var ErrWorkspaceNotReady = errors.New("semantic: workspace did not become ready // EnrichResult contains statistics from an enrichment pass. type EnrichResult struct { - Provider string `json:"provider"` - Language string `json:"language"` - EdgesConfirmed int `json:"edges_confirmed"` - EdgesRefuted int `json:"edges_refuted"` + Provider string `json:"provider"` + Language string `json:"language"` + EdgesConfirmed int `json:"edges_confirmed"` + EdgesRefuted int `json:"edges_refuted"` + // EdgesRebound counts unconfirmed edges whose target the definition + // answer REWROTE to a different same-name declaration (tagged + // rebound_from on the edge). A rebind corrects the heuristic graph — + // kept out of EdgesConfirmed so that count only ever means "the + // heuristic target was right". + EdgesRebound int `json:"edges_rebound"` EdgesAdded int `json:"edges_added"` NodesEnriched int `json:"nodes_enriched"` SymbolsCovered int `json:"symbols_covered"` @@ -218,6 +224,7 @@ type EnrichmentStatus struct { DeadlineSeconds float64 `json:"deadline_seconds,omitempty"` DurationMs int64 `json:"duration_ms,omitempty"` EdgesConfirmed int `json:"edges_confirmed"` + EdgesRebound int `json:"edges_rebound"` EdgesAdded int `json:"edges_added"` NodesEnriched int `json:"nodes_enriched"` // Add-phase coverage — the targets eligible for the hover/references