Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
131 changes: 131 additions & 0 deletions internal/semantic/lsp/enrich_dispatch_gate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package lsp

import (
"encoding/json"
"os"
"path/filepath"
"sync"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/zzet/gortex/internal/graph"
)

// #605: the per-file sweep gate. Under the demand default a file used to earn
// its sweep slot by declaring ANY type or interface — true for essentially
// every C# file, so the gate admitted the whole repo. The dispatch half must
// be as discriminating as the per-callable incoming-calls gate already is:
// a file sweeps when it carries unresolved demand, a dispatch-relevant
// callable, or a type actually involved in a super/subtype hierarchy. A
// plain data type with no bases, no subtypes, and no dispatch-relevant
// members buys nothing from hover or hierarchy interrogation.

// The type half of the file gate. Interfaces stay in unconditionally: an
// interface is the dispatch surface by definition, and the AST edges of its
// implementers may be exactly what failed to resolve — the case where it
// looks adjacency-less is the case where the sweep is most needed. A class
// earns its slot only through hierarchy involvement, and edge KINDS survive
// even when the AST could not resolve the target, so a class with an
// unresolvable base list still qualifies.
func TestEnrichTypeIsDispatchRelevantFromView(t *testing.T) {
iface := &graph.Node{ID: "s.cs::IShape", Kind: graph.KindInterface, Name: "IShape"}
impl := &graph.Node{ID: "s.cs::Circle", Kind: graph.KindType, Name: "Circle"}
unresolvedBase := &graph.Node{ID: "s.cs::Widget", Kind: graph.KindType, Name: "Widget"}
superType := &graph.Node{ID: "s.cs::Animal", Kind: graph.KindType, Name: "Animal"}
sub := &graph.Node{ID: "s.cs::Dog", Kind: graph.KindType, Name: "Dog"}
poco := &graph.Node{ID: "s.cs::Box", Kind: graph.KindType, Name: "Box"}
method := &graph.Node{ID: "s.cs::Box.Size", Kind: graph.KindMethod, Name: "Size"}

view := newLSPGraphView(
[]*graph.Node{iface, impl, unresolvedBase, superType, sub, poco, method},
[]*graph.Edge{
{From: impl.ID, To: iface.ID, Kind: graph.EdgeImplements},
{From: unresolvedBase.ID, To: graph.UnresolvedMarker + "VendorBase", Kind: graph.EdgeExtends},
{From: sub.ID, To: superType.ID, Kind: graph.EdgeExtends},
{From: method.ID, To: poco.ID, Kind: graph.EdgeMemberOf},
},
)

assert.True(t, enrichTypeIsDispatchRelevantFromView(view, iface), "an interface is always dispatch surface")
assert.True(t, enrichTypeIsDispatchRelevantFromView(view, impl), "a type implementing an interface")
assert.True(t, enrichTypeIsDispatchRelevantFromView(view, unresolvedBase), "an unresolvable base list still counts — the sweep is the only path that recovers it")
assert.True(t, enrichTypeIsDispatchRelevantFromView(view, superType), "a type something else extends")
assert.False(t, enrichTypeIsDispatchRelevantFromView(view, poco), "a bare data type is not")
assert.False(t, enrichTypeIsDispatchRelevantFromView(view, method), "callables have their own predicate")
assert.False(t, enrichTypeIsDispatchRelevantFromView(view, nil))
}

// TestLSP_Enrich_SweepGate drives one pass over three files under the demand
// default and asserts per-file sweep decisions by the hover requests the
// server saw:
// - poco.go: a bare struct + plain method, no demand — must be SKIPPED.
// - hier.go: a type implementing an interface — must be swept.
// - want.go: no types, but a declaration with an unresolved same-name
// candidate — the demand half must still admit it.
func TestLSP_Enrich_SweepGate(t *testing.T) {
t.Setenv(SweepEnv, "") // demand default

repoRoot := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(repoRoot, "poco.go"),
[]byte("package p\n\ntype Box struct{}\n\nfunc (b Box) Size() int { return 0 }\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(repoRoot, "hier.go"),
[]byte("package p\n\ntype Shape interface{ Area() float64 }\n\ntype Circle struct{}\n\nfunc (c Circle) Area() float64 { return 0 }\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(repoRoot, "want.go"),
[]byte("package p\n\nfunc Free() {}\n"), 0o644))

server := newFakeLSPServer()
var mu sync.Mutex
hoveredURIs := map[string]bool{}
server.handle("textDocument/hover", func(params json.RawMessage) (any, *jsonRPCError) {
var req struct {
TextDocument struct {
URI string `json:"uri"`
} `json:"textDocument"`
}
_ = json.Unmarshal(params, &req)
mu.Lock()
hoveredURIs[req.TextDocument.URI] = true
mu.Unlock()
return nil, nil
})

p, cleanup := providerWithFakeServer(t, server, []string{"go"})
defer cleanup()

g := graph.New()
// poco.go — a type and its method, hierarchy-uninvolved, no demand.
g.AddNode(&graph.Node{ID: "poco.go::Box", Kind: graph.KindType, Name: "Box",
FilePath: "poco.go", StartLine: 3, EndLine: 3, Language: "go"})
g.AddNode(&graph.Node{ID: "poco.go::Box.Size", Kind: graph.KindMethod, Name: "Size",
FilePath: "poco.go", StartLine: 5, EndLine: 5, Language: "go"})
g.AddEdge(&graph.Edge{From: "poco.go::Box.Size", To: "poco.go::Box", Kind: graph.EdgeMemberOf})
// hier.go — a type that implements an interface declared beside it.
g.AddNode(&graph.Node{ID: "hier.go::Shape", Kind: graph.KindInterface, Name: "Shape",
FilePath: "hier.go", StartLine: 3, EndLine: 3, Language: "go"})
g.AddNode(&graph.Node{ID: "hier.go::Circle", Kind: graph.KindType, Name: "Circle",
FilePath: "hier.go", StartLine: 5, EndLine: 5, Language: "go"})
g.AddNode(&graph.Node{ID: "hier.go::Circle.Area", Kind: graph.KindMethod, Name: "Area",
FilePath: "hier.go", StartLine: 7, EndLine: 7, Language: "go"})
g.AddEdge(&graph.Edge{From: "hier.go::Circle.Area", To: "hier.go::Circle", Kind: graph.EdgeMemberOf})
g.AddEdge(&graph.Edge{From: "hier.go::Circle", To: "hier.go::Shape", Kind: graph.EdgeImplements})
// want.go — no types at all; Free still has an unresolved same-name
// candidate, so the demand half of the gate must admit the file.
g.AddNode(&graph.Node{ID: "want.go::Free", Kind: graph.KindFunction, Name: "Free",
FilePath: "want.go", StartLine: 3, EndLine: 3, Language: "go"})
g.AddEdge(&graph.Edge{From: "hier.go::Circle.Area", To: graph.UnresolvedMarker + "*.Free",
Kind: graph.EdgeCalls, FilePath: "hier.go", Line: 7})

require.NoError(t, runEnrich(t, p, g, repoRoot, 3*time.Second))

mu.Lock()
defer mu.Unlock()
assert.False(t, hoveredURIs[pathToURI(filepath.Join(repoRoot, "poco.go"))],
"a hierarchy-uninvolved data type must not keep its file in the sweep")
assert.True(t, hoveredURIs[pathToURI(filepath.Join(repoRoot, "hier.go"))],
"a type involved in a hierarchy keeps its file in the sweep")
assert.True(t, hoveredURIs[pathToURI(filepath.Join(repoRoot, "want.go"))],
"unresolved demand keeps a type-less file in the sweep")
}
5 changes: 4 additions & 1 deletion internal/semantic/lsp/enrich_incoming_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,13 @@ func TestLSP_Enrich_IncomingSkippedForPlainStaticFunction(t *testing.T) {
defer cleanup()

g := graph.New()
// A type keeps the file in the demand-gated sweep (dispatch-relevant),
// A hierarchy-involved type keeps the file in the demand-gated sweep
// (its unresolvable base still counts — see typeIsDispatchRelevant),
// isolating the incoming decision from the file-level sweep gate.
g.AddNode(&graph.Node{ID: "svc.go::Marker", Kind: graph.KindType, Name: "Marker",
FilePath: "svc.go", StartLine: 3, EndLine: 3, Language: "go"})
g.AddEdge(&graph.Edge{From: "svc.go::Marker", To: graph.UnresolvedMarker + "VendorBase",
Kind: graph.EdgeExtends, FilePath: "svc.go", Line: 3})
g.AddNode(&graph.Node{ID: "svc.go::Plain", Kind: graph.KindFunction, Name: "Plain",
FilePath: "svc.go", StartLine: 5, EndLine: 5, Language: "go"})

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")
}
34 changes: 34 additions & 0 deletions internal/semantic/lsp/graph_batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,40 @@ func (v *lspGraphView) hasUnresolvedDemand(n *graph.Node) bool {
return len(v.inByID[graph.UnresolvedMarker+"*."+n.Name]) > 0
}

// typeIsDispatchRelevant reports whether a type declaration's super/subtype
// hierarchy is worth interrogating. An interface always is: it is the
// dispatch surface by definition, and its implementers' AST edges may be
// exactly what failed to resolve — the case where it looks adjacency-less is
// the case where the sweep is most needed. A class qualifies only through
// hierarchy involvement: an implements / extends edge in either direction.
// Edge KINDS survive even when the AST could not resolve the target, so a
// class with an unresolvable base list still qualifies — recovering those
// cross-file / dynamic hierarchy edges is the sweep's whole value for types.
// A bare data type with neither buys nothing from hover or hierarchy
// interrogation, and no longer keeps its file in the demand-gated sweep.
func (v *lspGraphView) typeIsDispatchRelevant(n *graph.Node) bool {
if n == nil {
return false
}
if n.Kind == graph.KindInterface {
return true
}
if n.Kind != graph.KindType {
return false
}
for _, e := range v.outByID[n.ID] {
if e.Kind == graph.EdgeImplements || e.Kind == graph.EdgeExtends {
return true
}
}
for _, e := range v.inByID[n.ID] {
if e.Kind == graph.EdgeImplements || e.Kind == graph.EdgeExtends {
return true
}
}
return false
}

func (v *lspGraphView) callableIsDispatchRelevant(n *graph.Node) bool {
if n == nil || (n.Kind != graph.KindFunction && n.Kind != graph.KindMethod) {
return false
Expand Down
Loading
Loading