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
13 changes: 10 additions & 3 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 @@ -241,7 +245,7 @@ The per-file sweep (phase 5) is gated by a **sweep mode**:

| Mode | Behaviour |
| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `demand` | **Default.** Sweep a file only when its declarations still carry unresolved same-name call candidates (enrichment demand) or it declares a type / interface whose super/subtype hierarchy the sweep recovers. A file with neither signal is skipped, so a warm restart pays no sweep for it. Within a swept file, a node that already carries a `semantic_type` stamp from an earlier pass is not re-hovered. |
| `demand` | **Default.** Sweep a file only when its declarations still carry unresolved same-name call candidates (enrichment demand) or it carries a dispatch-relevant declaration: a callable taking part in dynamic dispatch, an interface, or a type involved in a super/subtype hierarchy (an `implements` / `extends` edge in either direction — a bare data type does not admit its file). In a language whose edge set carries no extractor-produced hierarchy edges at all (e.g. C, C++, Objective-C, Swift — the sweep's `typeHierarchy` hop is the only producer there), the type check falls back to admitting every type. A file with no signal is skipped, so a warm restart pays no sweep for it. Within a swept file, a node that already carries a `semantic_type` stamp from an earlier pass is not re-hovered. |
| `full` | Sweep every file of the language — maximal hover and hierarchy coverage, the right choice for a first cold index. |
| `off` | Skip the per-file sweep entirely. The confirm / rebind / references-add / interface passes still run, so edge tiers and recall are unaffected — only hover type strings and sweep-recovered hierarchy edges are dropped. |

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
21 changes: 18 additions & 3 deletions internal/semantic/enricher.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,28 @@ import (
// semantic source. Origin is set to LSP-grade (lsp_dispatch for interface
// implementations, lsp_resolved for everything else) since only compiler /
// type-system providers call ConfirmEdge.
//
// A non-LSP prior origin is preserved under meta confirmed_from_origin
// BEFORE the flip: the LSP dispatch gate's evidence probe tells extractor /
// resolver hierarchy edges from the sweep's own recoveries by origin, and a
// confirm that erased the origin in place would decay that evidence one
// pass at a time — the gate admits hierarchy types, the sweep confirms
// their edges, the probe loses them. Confirmation upgrades the tier; it
// must not erase which lane produced the edge. The marker's presence is the
// signal (an extractor stub's origin is legitimately empty) and it is
// written once — a re-confirmation sees an LSP-grade origin and leaves it.
func ConfirmEdge(e *graph.Edge, provider string) {
e.Confidence = 1.0
e.ConfidenceLabel = "EXTRACTED"
e.Origin = originForSemanticKind(e.Kind)
if e.Meta == nil {
e.Meta = make(map[string]any)
}
if e.Origin != graph.OriginLSPResolved && e.Origin != graph.OriginLSPDispatch {
if _, tagged := e.Meta["confirmed_from_origin"]; !tagged {
e.Meta["confirmed_from_origin"] = string(e.Origin)
}
}
e.Confidence = 1.0
e.ConfidenceLabel = "EXTRACTED"
e.Origin = originForSemanticKind(e.Kind)
e.Meta["semantic_source"] = provider
}

Expand Down
114 changes: 34 additions & 80 deletions internal/semantic/enricher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,88 +4,42 @@ import (
"testing"

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

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

func TestConfirmEdge(t *testing.T) {
e := &graph.Edge{
From: "a.go::Foo",
To: "b.go::Bar",
Kind: graph.EdgeCalls,
Confidence: 0.6,
ConfidenceLabel: "INFERRED",
}

ConfirmEdge(e, "test-provider")

assert.Equal(t, 1.0, e.Confidence)
assert.Equal(t, "EXTRACTED", e.ConfidenceLabel)
assert.Equal(t, "test-provider", e.Meta["semantic_source"])
}

func TestAddSemanticEdge(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "a.go::Foo", Kind: graph.KindFunction, Name: "Foo", FilePath: "a.go"})
g.AddNode(&graph.Node{ID: "b.go::Bar", Kind: graph.KindFunction, Name: "Bar", FilePath: "b.go"})

e := AddSemanticEdge(g, "a.go::Foo", "b.go::Bar", graph.EdgeCalls, "a.go", 10, "test")

assert.Equal(t, 1.0, e.Confidence)
assert.Equal(t, "EXTRACTED", e.ConfidenceLabel)
assert.Equal(t, "test", e.Meta["semantic_source"])

// Verify it's in the graph.
edges := g.GetOutEdges("a.go::Foo")
require.Len(t, edges, 1)
assert.Equal(t, "b.go::Bar", edges[0].To)
}

func TestRefuteEdge(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "a.go::Foo", Kind: graph.KindFunction, Name: "Foo", FilePath: "a.go"})
g.AddNode(&graph.Node{ID: "b.go::Bar", Kind: graph.KindFunction, Name: "Bar", FilePath: "b.go"})
g.AddEdge(&graph.Edge{From: "a.go::Foo", To: "b.go::Bar", Kind: graph.EdgeCalls, Confidence: 0.5})

e := &graph.Edge{From: "a.go::Foo", To: "b.go::Bar", Kind: graph.EdgeCalls}
removed := RefuteEdge(g, e)

assert.True(t, removed)
assert.Empty(t, g.GetOutEdges("a.go::Foo"))
}

func TestFindMatchingEdge(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "a.go::Foo", Kind: graph.KindFunction, Name: "Foo", FilePath: "a.go"})
g.AddNode(&graph.Node{ID: "b.go::Bar", Kind: graph.KindFunction, Name: "Bar", FilePath: "b.go"})
g.AddEdge(&graph.Edge{From: "a.go::Foo", To: "b.go::Bar", Kind: graph.EdgeCalls})

found := FindMatchingEdge(g, "a.go::Foo", "b.go::Bar", graph.EdgeCalls)
assert.NotNil(t, found)

notFound := FindMatchingEdge(g, "a.go::Foo", "b.go::Bar", graph.EdgeReferences)
assert.Nil(t, notFound)
}

func TestEnrichNodeMeta(t *testing.T) {
n := &graph.Node{ID: "a.go::Foo", Kind: graph.KindFunction, Name: "Foo"}

EnrichNodeMeta(n, "semantic_type", "func() error", "test")

assert.Equal(t, "func() error", n.Meta["semantic_type"])
assert.Equal(t, "test", n.Meta["semantic_source"])
}

func TestNodesByLanguage(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "a.go::Foo", Kind: graph.KindFunction, Name: "Foo", Language: "go"})
g.AddNode(&graph.Node{ID: "b.ts::Bar", Kind: graph.KindFunction, Name: "Bar", Language: "typescript"})
g.AddNode(&graph.Node{ID: "c.go::Baz", Kind: graph.KindFunction, Name: "Baz", Language: "go"})

goNodes := NodesByLanguage(g, "go")
assert.Len(t, goNodes, 2)

tsNodes := NodesByLanguage(g, "typescript")
assert.Len(t, tsNodes, 1)
// Confirmation upgrades the tier but must not erase where the edge came
// from: the LSP dispatch gate's evidence probe distinguishes extractor /
// resolver hierarchy edges from the sweep's own recoveries by origin, and a
// confirm that overwrote the origin in place would decay that evidence one
// pass at a time (self-reinforcing — the gate admits hierarchy types, the
// sweep confirms their edges, the probe loses them).
func TestConfirmEdgePreservesPriorOrigin(t *testing.T) {
t.Run("non-LSP origin is preserved in meta", func(t *testing.T) {
e := &graph.Edge{Kind: graph.EdgeExtends, Origin: graph.OriginASTResolved, Confidence: 0.7}
ConfirmEdge(e, "lsp-go")
assert.Equal(t, graph.OriginLSPResolved, e.Origin)
assert.Equal(t, 1.0, e.Confidence)
assert.Equal(t, string(graph.OriginASTResolved), e.Meta["confirmed_from_origin"])
})
t.Run("empty origin still leaves the marker", func(t *testing.T) {
// An extractor-minted stub edge carries no origin at all; the marker's
// PRESENCE is the provenance signal, not its value.
e := &graph.Edge{Kind: graph.EdgeExtends, Confidence: 0.7}
ConfirmEdge(e, "lsp-go")
_, ok := e.Meta["confirmed_from_origin"]
assert.True(t, ok)
})
t.Run("an edge already at LSP grade gains no marker", func(t *testing.T) {
e := &graph.Edge{Kind: graph.EdgeImplements, Origin: graph.OriginLSPDispatch, Confidence: 0.9}
ConfirmEdge(e, "lsp-go")
_, ok := e.Meta["confirmed_from_origin"]
assert.False(t, ok, "there is no non-LSP provenance to preserve")
})
t.Run("re-confirmation does not overwrite the original provenance", func(t *testing.T) {
e := &graph.Edge{Kind: graph.EdgeExtends, Origin: graph.OriginASTResolved, Confidence: 0.7}
ConfirmEdge(e, "lsp-go")
ConfirmEdge(e, "lsp-go") // now lsp_resolved — marker must keep ast_resolved
assert.Equal(t, string(graph.OriginASTResolved), e.Meta["confirmed_from_origin"])
})
}
Loading
Loading