Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions docs/lsp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions internal/indexer/indexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
)
}
Expand Down Expand Up @@ -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),
)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/mcp/tools_enhancements.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
121 changes: 121 additions & 0 deletions internal/semantic/lsp/enrich_rebound_ledger_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
13 changes: 11 additions & 2 deletions internal/semantic/lsp/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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"
}

Expand All @@ -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()),
Expand Down
2 changes: 2 additions & 0 deletions internal/semantic/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
15 changes: 11 additions & 4 deletions internal/semantic/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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
Expand Down
Loading