From 4a9ed41d223458ca9d30ebe42d89f71b1ed98d7c Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:04:42 +0200 Subject: [PATCH 1/7] lsp: count definition rebinds separately from confirmations The definition fallback's rebind arm rewrites an edge's target and tags rebound_from, then counted the correction into edges_confirmed. Split it into EnrichResult/EnrichmentStatus EdgesRebound and surface it beside the existing counters (enrichment logs, index_health sums, zero-yield guard). --- internal/indexer/indexer.go | 3 + internal/mcp/tools_enhancements.go | 4 +- .../lsp/enrich_rebound_ledger_test.go | 121 ++++++++++++++++++ internal/semantic/lsp/provider.go | 7 +- internal/semantic/manager.go | 2 + internal/semantic/provider.go | 7 + 6 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 internal/semantic/lsp/enrich_rebound_ledger_test.go diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index 5954c2004..312e7c378 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 4c31bf09d..11eb83c24 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 000000000..bfa28911f --- /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 845d4333a..0c97fe8c6 100644 --- a/internal/semantic/lsp/provider.go +++ b/internal/semantic/lsp/provider.go @@ -1229,7 +1229,7 @@ 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++ } } releaseSite() @@ -1260,6 +1260,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 +1783,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 +1807,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 77e48fde6..03bdd1f72 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 dc6952667..f6a582a20 100644 --- a/internal/semantic/provider.go +++ b/internal/semantic/provider.go @@ -132,6 +132,12 @@ type EnrichResult struct { 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 From 7a481edbc3727de2a1911001aac41842f576b2d9 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:05:52 +0200 Subject: [PATCH 2/7] semantic: restore gofmt alignment in EnrichResult --- internal/semantic/provider.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/semantic/provider.go b/internal/semantic/provider.go index f6a582a20..cb6928750 100644 --- a/internal/semantic/provider.go +++ b/internal/semantic/provider.go @@ -128,10 +128,10 @@ 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 — From 92a09c9fd202f12d179d015098a94d88f6ab5f07 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:15:23 +0200 Subject: [PATCH 3/7] lsp: count definition-fallback verdicts as productivity yield On a degraded pass the serial fallback loop can be the only source of progress - every other usefulYield site is unreachable - so the productivity checkpoint read a pass that settles thousands of edges there as zero-yield and cancelled it. --- internal/semantic/lsp/provider.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/semantic/lsp/provider.go b/internal/semantic/lsp/provider.go index 0c97fe8c6..ff40bce0e 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 @@ -1230,6 +1235,7 @@ func (p *Provider) EnrichRepoContext(ctx context.Context, g graph.Store, repoPre fallbackMutations.stageReindex(view, t.edge, oldTo) rmu.Unlock() result.EdgesRebound++ + usefulYield.Add(1) } } releaseSite() From ac70174e5ba37753f7a75d0583588e1bf556bbb1 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:15:23 +0200 Subject: [PATCH 4/7] docs: document edges_rebound in the LSP enrichment phases --- docs/lsp.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/lsp.md b/docs/lsp.md index 7e75eb2e0..693936ce7 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 From 20310c876a3bb21330a51add2d991ef8d9681d97 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:53:18 +0200 Subject: [PATCH 5/7] lsp: gate the file sweep on real dispatch relevance The dispatch half of the demand-gated file sweep admitted any file that declared a type or interface - in C# that is every file. Gate it on the same evidence the per-callable incoming gate uses: dispatch-relevant callables, interfaces, and types with implements/extends adjacency in either direction (edge kinds survive unresolved targets, so an unresolvable base list still qualifies). A bare data type no longer admits its file; the demand half is untouched. --- .../semantic/lsp/enrich_dispatch_gate_test.go | 131 ++++++++++++++++++ internal/semantic/lsp/enrich_incoming_test.go | 5 +- internal/semantic/lsp/graph_batch.go | 34 +++++ internal/semantic/lsp/provider.go | 32 ++--- internal/semantic/lsp/sweep.go | 27 ++-- internal/semantic/lsp/sweep_test.go | 13 +- 6 files changed, 204 insertions(+), 38 deletions(-) create mode 100644 internal/semantic/lsp/enrich_dispatch_gate_test.go diff --git a/internal/semantic/lsp/enrich_dispatch_gate_test.go b/internal/semantic/lsp/enrich_dispatch_gate_test.go new file mode 100644 index 000000000..cf675ed9b --- /dev/null +++ b/internal/semantic/lsp/enrich_dispatch_gate_test.go @@ -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") +} diff --git a/internal/semantic/lsp/enrich_incoming_test.go b/internal/semantic/lsp/enrich_incoming_test.go index f955cff31..40fb8a24c 100644 --- a/internal/semantic/lsp/enrich_incoming_test.go +++ b/internal/semantic/lsp/enrich_incoming_test.go @@ -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"}) diff --git a/internal/semantic/lsp/graph_batch.go b/internal/semantic/lsp/graph_batch.go index 35f20d5d7..3d44edd38 100644 --- a/internal/semantic/lsp/graph_batch.go +++ b/internal/semantic/lsp/graph_batch.go @@ -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 diff --git a/internal/semantic/lsp/provider.go b/internal/semantic/lsp/provider.go index ff40bce0e..db1180e3b 100644 --- a/internal/semantic/lsp/provider.go +++ b/internal/semantic/lsp/provider.go @@ -330,21 +330,6 @@ func enrichNodeHasUnresolvedDemandFromView(view *lspGraphView, n *graph.Node) bo return view.hasUnresolvedDemand(n) } -// enrichNodeIsDispatchRelevant reports whether a declaration's super/subtype -// hierarchy the per-file sweep must interrogate: a type or interface whose -// extends / supertype / subtype edges the AST extractor commonly misses (they -// are cross-file or resolved dynamically). Such declarations never contribute -// unresolved-call demand — enrichNodeHasUnresolvedDemand only counts callables — -// so a file whose only enrichable work is a type hierarchy would score zero -// demand and be skipped under the demand default. Marking it dispatch-relevant -// keeps that file in the sweep so its hierarchy edges are still recovered. -func enrichNodeIsDispatchRelevant(n *graph.Node) bool { - if n == nil { - return false - } - return n.Kind == graph.KindType || n.Kind == graph.KindInterface -} - // enrichCallableIsDispatchRelevant reports whether a function or method takes // part in dynamic dispatch, so its incoming callers name concrete targets the // outgoing side of the sweep cannot reach. Every intra-repo static call is @@ -383,6 +368,17 @@ func enrichCallableIsDispatchRelevantFromView(view *lspGraphView, n *graph.Node) return view.callableIsDispatchRelevant(n) } +// enrichTypeIsDispatchRelevantFromView is the type half of the per-file sweep +// gate: an interface, or a class involved in a super/subtype hierarchy — see +// lspGraphView.typeIsDispatchRelevant. Such declarations never contribute +// unresolved-call demand (demand only counts callables), so a file whose only +// enrichable work is a type hierarchy would score zero demand and be skipped +// under the demand default; this signal keeps exactly those files in while a +// bare data type no longer admits its whole file. +func enrichTypeIsDispatchRelevantFromView(view *lspGraphView, n *graph.Node) bool { + return view.typeIsDispatchRelevant(n) +} + // nodeHasSemanticType reports whether a node already carries a non-empty // semantic_type stamp from an earlier enrichment. The per-file sweep skips // re-hovering such a node — the hover would only re-derive the identical type @@ -1382,9 +1378,11 @@ func (p *Provider) EnrichRepoContext(ctx context.Context, g graph.Store, repoPre // goroutine starts staging new hierarchy edges into that projection. nodeDemand := make(map[string]bool, len(langNodes)) nodeDispatch := make(map[string]bool, len(langNodes)) + nodeTypeDispatch := make(map[string]bool, len(langNodes)) for _, n := range langNodes { nodeDemand[n.ID] = enrichNodeHasUnresolvedDemandFromView(view, n) nodeDispatch[n.ID] = enrichCallableIsDispatchRelevantFromView(view, n) + nodeTypeDispatch[n.ID] = enrichTypeIsDispatchRelevantFromView(view, n) } // Group enrichment targets by file so each file's open/close lifecycle @@ -1394,7 +1392,7 @@ func (p *Provider) EnrichRepoContext(ctx context.Context, g graph.Store, repoPre rel string nodes []*graph.Node demand int // declarations still carrying unresolved same-name candidates - dispatch bool // carries a type / interface whose hierarchy the sweep interrogates + dispatch bool // carries a dispatch-relevant callable, or a type involved in a hierarchy } var fileList []*fileTargets fileIndex := map[string]*fileTargets{} @@ -1413,7 +1411,7 @@ func (p *Provider) EnrichRepoContext(ctx context.Context, g graph.Store, repoPre if nodeDemand[n.ID] { ft.demand++ } - if enrichNodeIsDispatchRelevant(n) { + if nodeDispatch[n.ID] || nodeTypeDispatch[n.ID] { ft.dispatch = true } } diff --git a/internal/semantic/lsp/sweep.go b/internal/semantic/lsp/sweep.go index cdafd5931..391da73be 100644 --- a/internal/semantic/lsp/sweep.go +++ b/internal/semantic/lsp/sweep.go @@ -23,13 +23,16 @@ const SweepEnv = "GORTEX_LSP_SWEEP" // // - sweepModeDemand (DEFAULT): sweep a file when its declarations still // carry unresolved same-name call candidates (enrichment demand) OR it -// declares a type / interface whose super/subtype hierarchy the sweep -// interrogates (dispatch-relevant). The dispatch disjunct is load-bearing: -// a type / interface never contributes call demand, yet the sweep is the -// only path that recovers its cross-file / dynamic extends / supertype -// edges, so gating on demand alone would silently drop them. A file with -// neither signal is skipped, so a warm restart pays no sweep for it while -// the already-enriched declarations that are swept skip their redundant +// carries a dispatch-relevant declaration — a callable taking part in +// dynamic dispatch, or an interface / hierarchy-involved type (see +// lspGraphView.typeIsDispatchRelevant). The dispatch disjunct is +// load-bearing: a type never contributes call demand, yet the sweep is +// the only path that recovers its cross-file / dynamic extends / +// supertype edges, so gating on demand alone would silently drop them. +// A bare data type with no hierarchy involvement carries none of those +// edges, so it no longer admits its file. A file with neither signal is +// skipped, so a warm restart pays no sweep for it while the +// already-enriched declarations that are swept skip their redundant // hover. // - sweepModeFull: sweep every file of the language — the pre-knob // behaviour, kept for a cold index that wants maximal hover coverage. @@ -94,11 +97,11 @@ func (p *Provider) effectiveSweepMode() string { // run for a file under mode, given its unresolved-demand count and whether it // carries a dispatch-relevant declaration. Under the demand default a file is // swept when at least one of its declarations still has unresolved same-name -// call candidates (demand > 0) OR it declares a type / interface whose -// super/subtype hierarchy the sweep interrogates (dispatch) — the latter never -// surfaces as demand, so without this disjunct a type-only file would drop the -// extends / supertype edges only this sweep recovers. "full" always sweeps, -// "off" never does. +// call candidates (demand > 0) OR it carries a dispatch-relevant declaration +// (dispatch): a callable taking part in dynamic dispatch, or an interface / +// hierarchy-involved type. The latter never surfaces as demand, so without +// this disjunct a hierarchy-carrying file would drop the extends / supertype +// edges only this sweep recovers. "full" always sweeps, "off" never does. func sweepFile(mode string, demand int, dispatch bool) bool { switch mode { case sweepModeOff: diff --git a/internal/semantic/lsp/sweep_test.go b/internal/semantic/lsp/sweep_test.go index ee47c6ca0..31d36bf25 100644 --- a/internal/semantic/lsp/sweep_test.go +++ b/internal/semantic/lsp/sweep_test.go @@ -93,14 +93,6 @@ func TestSweepFile(t *testing.T) { assert.False(t, sweepFile("bogus", 0, false)) } -func TestEnrichNodeIsDispatchRelevant(t *testing.T) { - assert.True(t, enrichNodeIsDispatchRelevant(&graph.Node{Kind: graph.KindType})) - assert.True(t, enrichNodeIsDispatchRelevant(&graph.Node{Kind: graph.KindInterface})) - assert.False(t, enrichNodeIsDispatchRelevant(&graph.Node{Kind: graph.KindFunction})) - assert.False(t, enrichNodeIsDispatchRelevant(&graph.Node{Kind: graph.KindMethod})) - assert.False(t, enrichNodeIsDispatchRelevant(nil)) -} - func TestNodeHasSemanticType(t *testing.T) { assert.False(t, nodeHasSemanticType(nil)) assert.False(t, nodeHasSemanticType(&graph.Node{})) @@ -275,10 +267,15 @@ func TestLSP_Enrich_SweepDispatchRelevantRecoversHierarchyByDefault(t *testing.T g := graph.New() // Only type declarations — no functions, so demand == 0 for this file. + // The extends clause is syntax, so the extractor emitted its edge even + // though it could not resolve the target — the unresolvable-base shape + // that keeps the file dispatch-relevant (see typeIsDispatchRelevant). g.AddNode(&graph.Node{ID: "h.ts::Animal", Kind: graph.KindType, Name: "Animal", FilePath: "h.ts", StartLine: 1, EndLine: 1, Language: "typescript"}) g.AddNode(&graph.Node{ID: "h.ts::Dog", Kind: graph.KindType, Name: "Dog", FilePath: "h.ts", StartLine: 2, EndLine: 2, Language: "typescript"}) + g.AddEdge(&graph.Edge{From: "h.ts::Dog", To: graph.UnresolvedMarker + "Animal", + Kind: graph.EdgeExtends, FilePath: "h.ts", Line: 2}) done := make(chan error, 1) go func() { From 80b32a05e42d2be3852a7ba85b39ae9510848c30 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:58:14 +0200 Subject: [PATCH 6/7] lsp: fall back to the permissive type gate without hierarchy evidence The strict type gate is circular in languages whose extractor emits no base-list edges and that have no supplemental type lane (c, cpp, objc, swift): there the sweep's typeHierarchy hop is the only producer of extends / implements edges, so requiring such an edge for admission demands as input exactly what the sweep exists to produce, and a class-only file's hierarchy is never recovered. Probe the language's edge set once per pass: any extends / implements edge a non-LSP lane minted (extractor, tstypes, resolver inference) keeps the strict gate; zero such edges falls the type check back to admitting every type. Sweep-recovered edges (lsp_resolved / lsp_dispatch) are excluded from the evidence - counting the sweep's own output would flip a hierarchy-blind language onto the strict gate one run later and silently drop every class added after that. One pass over the projected repo edges, attributed per language via the source node - no per-language table. --- docs/lsp.md | 2 +- .../semantic/lsp/enrich_dispatch_gate_test.go | 170 +++++++++++++++++- internal/semantic/lsp/graph_batch.go | 12 +- internal/semantic/lsp/provider.go | 41 ++++- 4 files changed, 212 insertions(+), 13 deletions(-) diff --git a/docs/lsp.md b/docs/lsp.md index 693936ce7..86ecff683 100644 --- a/docs/lsp.md +++ b/docs/lsp.md @@ -245,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. | diff --git a/internal/semantic/lsp/enrich_dispatch_gate_test.go b/internal/semantic/lsp/enrich_dispatch_gate_test.go index cf675ed9b..c1f0ab5c0 100644 --- a/internal/semantic/lsp/enrich_dispatch_gate_test.go +++ b/internal/semantic/lsp/enrich_dispatch_gate_test.go @@ -49,13 +49,56 @@ func TestEnrichTypeIsDispatchRelevantFromView(t *testing.T) { }, ) - 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)) + assert.True(t, enrichTypeIsDispatchRelevantFromView(view, iface, true), "an interface is always dispatch surface") + assert.True(t, enrichTypeIsDispatchRelevantFromView(view, impl, true), "a type implementing an interface") + assert.True(t, enrichTypeIsDispatchRelevantFromView(view, unresolvedBase, true), "an unresolvable base list still counts — the sweep is the only path that recovers it") + assert.True(t, enrichTypeIsDispatchRelevantFromView(view, superType, true), "a type something else extends") + assert.False(t, enrichTypeIsDispatchRelevantFromView(view, poco, true), "a bare data type is not") + assert.False(t, enrichTypeIsDispatchRelevantFromView(view, method, true), "callables have their own predicate") + assert.False(t, enrichTypeIsDispatchRelevantFromView(view, nil, true)) + + // Without hierarchy evidence the strict check is circular (the sweep is + // the only producer of the qualifying edge) — every type falls back to + // permissive admission while non-types stay out. + assert.True(t, enrichTypeIsDispatchRelevantFromView(view, poco, false), "no evidence → a bare data type is admitted permissively") + assert.True(t, enrichTypeIsDispatchRelevantFromView(view, iface, false)) + assert.False(t, enrichTypeIsDispatchRelevantFromView(view, method, false), "the fallback widens types only, never callables") + assert.False(t, enrichTypeIsDispatchRelevantFromView(view, nil, false)) +} + +// The self-tuning switch for the type gate: evidence is an extends / +// implements edge some non-LSP lane minted for THIS language. The sweep's own +// recoveries (lsp_resolved / lsp_dispatch) and other languages' edges do not +// count — either would flip a hierarchy-blind language onto the circular +// strict gate. +func TestEnrichLanguageHasHierarchyEvidence(t *testing.T) { + goImpl := &graph.Node{ID: "a.go::Circle", Kind: graph.KindType, Name: "Circle", Language: "go"} + goIface := &graph.Node{ID: "a.go::Shape", Kind: graph.KindInterface, Name: "Shape", Language: "go"} + csImpl := &graph.Node{ID: "b.cs::Disc", Kind: graph.KindType, Name: "Disc", Language: "csharp"} + csIface := &graph.Node{ID: "b.cs::IShape", Kind: graph.KindInterface, Name: "IShape", Language: "csharp"} + nodes := []*graph.Node{goImpl, goIface, csImpl, csIface} + matchGo := func(lang string) bool { return lang == "go" } + + astEdge := &graph.Edge{From: goImpl.ID, To: goIface.ID, Kind: graph.EdgeImplements, Origin: graph.OriginASTResolved} + bareEdge := &graph.Edge{From: goImpl.ID, To: graph.UnresolvedMarker + "Base", Kind: graph.EdgeExtends} + lspEdge := &graph.Edge{From: goImpl.ID, To: goIface.ID, Kind: graph.EdgeImplements, Origin: graph.OriginLSPResolved} + dispatchEdge := &graph.Edge{From: goImpl.ID, To: goIface.ID, Kind: graph.EdgeImplements, Origin: graph.OriginLSPDispatch} + csEdge := &graph.Edge{From: csImpl.ID, To: csIface.ID, Kind: graph.EdgeImplements, Origin: graph.OriginASTResolved} + callEdge := &graph.Edge{From: goImpl.ID, To: goIface.ID, Kind: graph.EdgeCalls, Origin: graph.OriginASTResolved} + + probe := func(edges ...*graph.Edge) bool { + return enrichLanguageHasHierarchyEvidence(newLSPGraphView(nodes, edges), edges, matchGo) + } + + assert.True(t, probe(astEdge), "an extractor-produced implements edge is evidence") + assert.True(t, probe(bareEdge), "an unresolved-target base-list edge (empty origin) is evidence — the extractor minted it") + assert.False(t, probe(), "no edges, no evidence") + assert.False(t, probe(lspEdge), "the sweep's own lsp_resolved recovery is not evidence") + assert.False(t, probe(dispatchEdge), "lsp_dispatch is not evidence either") + assert.False(t, probe(csEdge), "another language's edge is not evidence for this one") + assert.False(t, probe(callEdge), "non-hierarchy kinds never count") + assert.True(t, probe(lspEdge, csEdge, astEdge), "one qualifying edge among noise is enough") + assert.False(t, probe(nil), "nil edges are skipped") } // TestLSP_Enrich_SweepGate drives one pass over three files under the demand @@ -129,3 +172,116 @@ func TestLSP_Enrich_SweepGate(t *testing.T) { assert.True(t, hoveredURIs[pathToURI(filepath.Join(repoRoot, "want.go"))], "unresolved demand keeps a type-less file in the sweep") } + +// The strict gate is circular in languages whose extractor emits no base-list +// edges and that have no supplemental type lane (c, cpp, objc, swift): there +// the sweep's typeHierarchy hop is the ONLY producer of extends / implements +// edges, so requiring such an edge for admission demands as input exactly +// what the sweep exists to produce, and the hierarchy is never recovered. +// When the language's edge set carries zero extractor-produced hierarchy +// edges the gate must fall back to the permissive kind check. +func TestLSP_Enrich_SweepGate_PermissiveFallbackWithoutHierarchyEvidence(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)) + + server := newInstrumentedServer() + mu, hovered := hoverURIRecorder(server) + + p, cleanup := providerWithInstrumentedServer(t, server, []string{"go"}, 4) + defer cleanup() + + // A bare class-only graph: no extends / implements edge exists anywhere in + // the language — the extractor for this language cannot produce one. + g := graph.New() + 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}) + + require.NoError(t, runEnrich(t, p, g, repoRoot, 3*time.Second)) + + mu.Lock() + defer mu.Unlock() + assert.True(t, hovered["poco.go"], + "with zero hierarchy edges in the language the type gate must fall back to permissive — the sweep is the only producer of those edges") +} + +// The evidence probe is per-language: another language's hierarchy edges say +// nothing about what THIS language's extractor can produce. A repo mixing C# +// (extractor emits implements) with a hierarchy-blind language must not let +// the C# edges flip the blind language onto the strict gate. +func TestLSP_Enrich_SweepGate_OtherLanguageEvidenceDoesNotCount(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)) + + server := newInstrumentedServer() + mu, hovered := hoverURIRecorder(server) + + p, cleanup := providerWithInstrumentedServer(t, server, []string{"go"}, 4) + defer cleanup() + + g := graph.New() + g.AddNode(&graph.Node{ID: "poco.go::Box", Kind: graph.KindType, Name: "Box", + FilePath: "poco.go", StartLine: 3, EndLine: 3, Language: "go"}) + // Hierarchy evidence exists in the repo — but in a DIFFERENT language. + g.AddNode(&graph.Node{ID: "s.cs::IShape", Kind: graph.KindInterface, Name: "IShape", + FilePath: "s.cs", StartLine: 1, EndLine: 1, Language: "csharp"}) + g.AddNode(&graph.Node{ID: "s.cs::Circle", Kind: graph.KindType, Name: "Circle", + FilePath: "s.cs", StartLine: 3, EndLine: 3, Language: "csharp"}) + g.AddEdge(&graph.Edge{From: "s.cs::Circle", To: "s.cs::IShape", Kind: graph.EdgeImplements, + FilePath: "s.cs", Line: 3}) + + require.NoError(t, runEnrich(t, p, g, repoRoot, 3*time.Second)) + + mu.Lock() + defer mu.Unlock() + assert.True(t, hovered["poco.go"], + "hierarchy evidence from another language must not put this language on the strict gate") +} + +// Edges the sweep itself recovered (lsp_resolved / lsp_dispatch) are not +// evidence the extractor can produce them: counting the sweep's own output +// would flip a hierarchy-blind language onto the strict gate on the second +// run, and every class added after that would carry no edge, be dropped, and +// never have its hierarchy recovered. +func TestLSP_Enrich_SweepGate_LSPRecoveredEdgesAreNotEvidence(t *testing.T) { + t.Setenv(SweepEnv, "") // demand default + + repoRoot := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(repoRoot, "hier.go"), + []byte("package p\n\ntype Base struct{}\n\ntype Derived struct{ Base }\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(repoRoot, "fresh.go"), + []byte("package p\n\ntype Fresh struct{}\n"), 0o644)) + + server := newInstrumentedServer() + mu, hovered := hoverURIRecorder(server) + + p, cleanup := providerWithInstrumentedServer(t, server, []string{"go"}, 4) + defer cleanup() + + // The only hierarchy edge in the language is one a PRIOR sweep recovered. + g := graph.New() + g.AddNode(&graph.Node{ID: "hier.go::Base", Kind: graph.KindType, Name: "Base", + FilePath: "hier.go", StartLine: 3, EndLine: 3, Language: "go"}) + g.AddNode(&graph.Node{ID: "hier.go::Derived", Kind: graph.KindType, Name: "Derived", + FilePath: "hier.go", StartLine: 5, EndLine: 5, Language: "go"}) + g.AddEdge(&graph.Edge{From: "hier.go::Derived", To: "hier.go::Base", Kind: graph.EdgeExtends, + FilePath: "hier.go", Line: 5, + Confidence: 1.0, ConfidenceLabel: "CONFIRMED", Origin: graph.OriginLSPResolved}) + g.AddNode(&graph.Node{ID: "fresh.go::Fresh", Kind: graph.KindType, Name: "Fresh", + FilePath: "fresh.go", StartLine: 3, EndLine: 3, Language: "go"}) + + require.NoError(t, runEnrich(t, p, g, repoRoot, 3*time.Second)) + + mu.Lock() + defer mu.Unlock() + assert.True(t, hovered["fresh.go"], + "sweep-recovered edges are not extractor evidence — a new bare class must still be admitted") +} diff --git a/internal/semantic/lsp/graph_batch.go b/internal/semantic/lsp/graph_batch.go index 3d44edd38..9a8cbdebb 100644 --- a/internal/semantic/lsp/graph_batch.go +++ b/internal/semantic/lsp/graph_batch.go @@ -254,7 +254,14 @@ func (v *lspGraphView) hasUnresolvedDemand(n *graph.Node) bool { // 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 { +// +// That strict check presumes SOME lane other than the sweep can mint the +// qualifying edge. hierarchyEvidence says whether one has (see +// enrichLanguageHasHierarchyEvidence); when it has not, every class is +// treated as hierarchy-involved — the pre-gate permissive behaviour — because +// in such a language the sweep is the only producer of the very edge the +// strict check would require. +func (v *lspGraphView) typeIsDispatchRelevant(n *graph.Node, hierarchyEvidence bool) bool { if n == nil { return false } @@ -264,6 +271,9 @@ func (v *lspGraphView) typeIsDispatchRelevant(n *graph.Node) bool { if n.Kind != graph.KindType { return false } + if !hierarchyEvidence { + return true + } for _, e := range v.outByID[n.ID] { if e.Kind == graph.EdgeImplements || e.Kind == graph.EdgeExtends { return true diff --git a/internal/semantic/lsp/provider.go b/internal/semantic/lsp/provider.go index db1180e3b..a1a8e0db9 100644 --- a/internal/semantic/lsp/provider.go +++ b/internal/semantic/lsp/provider.go @@ -374,9 +374,41 @@ func enrichCallableIsDispatchRelevantFromView(view *lspGraphView, n *graph.Node) // unresolved-call demand (demand only counts callables), so a file whose only // enrichable work is a type hierarchy would score zero demand and be skipped // under the demand default; this signal keeps exactly those files in while a -// bare data type no longer admits its whole file. -func enrichTypeIsDispatchRelevantFromView(view *lspGraphView, n *graph.Node) bool { - return view.typeIsDispatchRelevant(n) +// bare data type no longer admits its whole file. hierarchyEvidence is the +// per-language self-tuning switch computed by +// enrichLanguageHasHierarchyEvidence: without it the strict check would be +// circular and the gate stays permissive. +func enrichTypeIsDispatchRelevantFromView(view *lspGraphView, n *graph.Node, hierarchyEvidence bool) bool { + return view.typeIsDispatchRelevant(n, hierarchyEvidence) +} + +// enrichLanguageHasHierarchyEvidence reports whether the language's edge set +// carries at least one extends / implements edge some non-LSP lane produced — +// the AST extractor, the tstypes supplemental lane, or resolver inference. +// The strict type gate is meaningful only where such a lane exists: in +// languages with none (c, cpp, objc, swift — no base-list extraction, no +// tstypes lane) the sweep's typeHierarchy hop is the ONLY producer of +// hierarchy edges, so requiring an existing edge for admission would demand +// as input exactly what the sweep exists to produce, and a class-only file's +// hierarchy would never be recovered. Edges the sweep itself recovered +// (lsp_resolved / lsp_dispatch) are excluded: counting the sweep's own output +// would flip such a language onto the strict gate one run later and silently +// drop every class added after that. One pass over the repo's projected +// edges, attributed to a language via the source node — no per-language +// table. +func enrichLanguageHasHierarchyEvidence(view *lspGraphView, repoEdges []*graph.Edge, languageMatches func(string) bool) bool { + for _, e := range repoEdges { + if e == nil || (e.Kind != graph.EdgeImplements && e.Kind != graph.EdgeExtends) { + continue + } + if e.Origin == graph.OriginLSPResolved || e.Origin == graph.OriginLSPDispatch { + continue + } + if from := view.nodesByID[e.From]; from != nil && languageMatches(from.Language) { + return true + } + } + return false } // nodeHasSemanticType reports whether a node already carries a non-empty @@ -1376,13 +1408,14 @@ func (p *Provider) EnrichRepoContext(ctx context.Context, g graph.Store, repoPre // Demand/dispatch decisions are immutable during the concurrent sweep. // Compute them once from the repo adjacency projection before any file // goroutine starts staging new hierarchy edges into that projection. + langHierarchyEvidence := enrichLanguageHasHierarchyEvidence(view, repoEdges, p.languageMatches) nodeDemand := make(map[string]bool, len(langNodes)) nodeDispatch := make(map[string]bool, len(langNodes)) nodeTypeDispatch := make(map[string]bool, len(langNodes)) for _, n := range langNodes { nodeDemand[n.ID] = enrichNodeHasUnresolvedDemandFromView(view, n) nodeDispatch[n.ID] = enrichCallableIsDispatchRelevantFromView(view, n) - nodeTypeDispatch[n.ID] = enrichTypeIsDispatchRelevantFromView(view, n) + nodeTypeDispatch[n.ID] = enrichTypeIsDispatchRelevantFromView(view, n, langHierarchyEvidence) } // Group enrichment targets by file so each file's open/close lifecycle From 83645eef861e8f09dd04ad722ccc32047dea81d4 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:12:31 +0200 Subject: [PATCH 7/7] lsp: confirmation preserves edge provenance so gate evidence cannot decay --- internal/semantic/enricher.go | 21 +++- internal/semantic/enricher_test.go | 114 ++++++------------ .../semantic/lsp/enrich_dispatch_gate_test.go | 92 ++++++++++++++ internal/semantic/lsp/provider.go | 23 ++-- 4 files changed, 160 insertions(+), 90 deletions(-) diff --git a/internal/semantic/enricher.go b/internal/semantic/enricher.go index 74cb8735e..76ff1b24d 100644 --- a/internal/semantic/enricher.go +++ b/internal/semantic/enricher.go @@ -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 } diff --git a/internal/semantic/enricher_test.go b/internal/semantic/enricher_test.go index da6807775..c066ee1ae 100644 --- a/internal/semantic/enricher_test.go +++ b/internal/semantic/enricher_test.go @@ -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"]) + }) } diff --git a/internal/semantic/lsp/enrich_dispatch_gate_test.go b/internal/semantic/lsp/enrich_dispatch_gate_test.go index c1f0ab5c0..e833899d2 100644 --- a/internal/semantic/lsp/enrich_dispatch_gate_test.go +++ b/internal/semantic/lsp/enrich_dispatch_gate_test.go @@ -99,6 +99,18 @@ func TestEnrichLanguageHasHierarchyEvidence(t *testing.T) { assert.False(t, probe(callEdge), "non-hierarchy kinds never count") assert.True(t, probe(lspEdge, csEdge, astEdge), "one qualifying edge among noise is enough") assert.False(t, probe(nil), "nil edges are skipped") + + // A confirmed extractor edge keeps its provenance: ConfirmEdge flips the + // origin to LSP grade but records the prior origin, so confirmation does + // not decay the evidence the gate depends on. + confirmedEdge := &graph.Edge{From: goImpl.ID, To: goIface.ID, Kind: graph.EdgeImplements, + Origin: graph.OriginLSPResolved, + Meta: map[string]any{"confirmed_from_origin": string(graph.OriginASTResolved)}} + assert.True(t, probe(confirmedEdge), "a confirmed extractor edge remains evidence via its preserved provenance") + confirmedStub := &graph.Edge{From: goImpl.ID, To: goIface.ID, Kind: graph.EdgeExtends, + Origin: graph.OriginLSPResolved, + Meta: map[string]any{"confirmed_from_origin": ""}} + assert.True(t, probe(confirmedStub), "marker presence is the signal — an empty prior origin still counts") } // TestLSP_Enrich_SweepGate drives one pass over three files under the demand @@ -285,3 +297,83 @@ func TestLSP_Enrich_SweepGate_LSPRecoveredEdgesAreNotEvidence(t *testing.T) { assert.True(t, hovered["fresh.go"], "sweep-recovered edges are not extractor evidence — a new bare class must still be admitted") } + +// The decay scenario: an extractor-minted extends edge is confirmed by the +// sweep's typeHierarchy hop, which flips its origin to lsp_resolved — exactly +// what the evidence probe excludes. Confirmation must preserve the prior +// provenance so the NEXT pass still sees extractor evidence; without that the +// gate is self-defeating (admit hierarchy types → sweep confirms their edges → +// evidence gone → permissive fallback), oscillating on every enrich/reindex +// cycle. +func TestLSP_Enrich_SweepGate_ConfirmationDoesNotDecayEvidence(t *testing.T) { + t.Setenv(SweepEnv, "") // demand default + + repoRoot := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(repoRoot, "hier.go"), + []byte("package p\n\ntype Base struct{}\n\ntype Derived struct{ Base }\n"), 0o644)) + + server := newInstrumentedServer() + server.handle("textDocument/hover", func(json.RawMessage) (any, *jsonRPCError) { return nil, nil }) + derivedItem := TypeHierarchyItem{ + Name: "Derived", URI: pathToURI(filepath.Join(repoRoot, "hier.go")), + SelectionRange: Range{Start: Position{Line: 4, Character: 5}, End: Position{Line: 4, Character: 12}}, + } + baseItem := TypeHierarchyItem{ + Name: "Base", URI: pathToURI(filepath.Join(repoRoot, "hier.go")), + SelectionRange: Range{Start: Position{Line: 2, Character: 5}, End: Position{Line: 2, Character: 9}}, + } + server.handle("textDocument/prepareTypeHierarchy", func(params json.RawMessage) (any, *jsonRPCError) { + var req struct { + Position Position `json:"position"` + } + _ = json.Unmarshal(params, &req) + if req.Position.Line == 4 { + return []TypeHierarchyItem{derivedItem}, nil + } + return []TypeHierarchyItem{baseItem}, nil + }) + server.handle("typeHierarchy/supertypes", func(params json.RawMessage) (any, *jsonRPCError) { + var req struct { + Item TypeHierarchyItem `json:"item"` + } + _ = json.Unmarshal(params, &req) + if req.Item.Name == "Derived" { + // The compiler agrees with the extractor: Derived extends Base. + return []TypeHierarchyItem{baseItem}, nil + } + return []TypeHierarchyItem{}, nil + }) + server.handle("typeHierarchy/subtypes", func(params json.RawMessage) (any, *jsonRPCError) { + return []TypeHierarchyItem{}, nil + }) + + p, cleanup := providerWithInstrumentedServer(t, server, []string{"go"}, 4) + defer cleanup() + p.caps = ServerCapabilities{TypeHierarchyProvider: true} + + derived := &graph.Node{ID: "hier.go::Derived", Kind: graph.KindType, Name: "Derived", + FilePath: "hier.go", StartLine: 5, EndLine: 5, Language: "go"} + base := &graph.Node{ID: "hier.go::Base", Kind: graph.KindType, Name: "Base", + FilePath: "hier.go", StartLine: 3, EndLine: 3, Language: "go"} + edge := &graph.Edge{From: derived.ID, To: base.ID, Kind: graph.EdgeExtends, + FilePath: "hier.go", Line: 5, + Confidence: 0.7, ConfidenceLabel: "INFERRED", Origin: graph.OriginASTResolved} + g := graph.New() + g.AddNode(derived) + g.AddNode(base) + g.AddEdge(edge) + + require.NoError(t, runEnrich(t, p, g, repoRoot, 3*time.Second)) + + // The confirm happened — origin flipped to the excluded grade… + require.Equal(t, graph.OriginLSPResolved, edge.Origin, + "fixture sanity: the sweep must confirm the extractor edge in place") + require.Equal(t, 1.0, edge.Confidence) + + // …and the probe, over the post-pass edge set, still sees evidence. + edges := []*graph.Edge{edge} + view := newLSPGraphView([]*graph.Node{derived, base}, edges) + assert.True(t, + enrichLanguageHasHierarchyEvidence(view, edges, func(lang string) bool { return lang == "go" }), + "confirmation must not decay the evidence the gate depends on") +} diff --git a/internal/semantic/lsp/provider.go b/internal/semantic/lsp/provider.go index a1a8e0db9..76acfedb4 100644 --- a/internal/semantic/lsp/provider.go +++ b/internal/semantic/lsp/provider.go @@ -390,19 +390,28 @@ func enrichTypeIsDispatchRelevantFromView(view *lspGraphView, n *graph.Node, hie // tstypes lane) the sweep's typeHierarchy hop is the ONLY producer of // hierarchy edges, so requiring an existing edge for admission would demand // as input exactly what the sweep exists to produce, and a class-only file's -// hierarchy would never be recovered. Edges the sweep itself recovered -// (lsp_resolved / lsp_dispatch) are excluded: counting the sweep's own output -// would flip such a language onto the strict gate one run later and silently -// drop every class added after that. One pass over the repo's projected -// edges, attributed to a language via the source node — no per-language -// table. +// hierarchy would never be recovered. Edges the sweep itself MINTED +// (lsp_resolved / lsp_dispatch, no provenance marker) are excluded: counting +// the sweep's own output would flip such a language onto the strict gate one +// run later and silently drop every class added after that. An LSP-origin +// edge carrying meta confirmed_from_origin still counts: ConfirmEdge flips +// the origin in place when the sweep agrees with a non-LSP lane's edge, and +// without the preserved provenance every confirm would decay exactly the +// evidence this probe depends on. One pass over the repo's projected edges, +// attributed to a language via the source node — no per-language table. func enrichLanguageHasHierarchyEvidence(view *lspGraphView, repoEdges []*graph.Edge, languageMatches func(string) bool) bool { for _, e := range repoEdges { if e == nil || (e.Kind != graph.EdgeImplements && e.Kind != graph.EdgeExtends) { continue } if e.Origin == graph.OriginLSPResolved || e.Origin == graph.OriginLSPDispatch { - continue + confirmed := false + if e.Meta != nil { + _, confirmed = e.Meta["confirmed_from_origin"] + } + if !confirmed { + continue + } } if from := view.nodesByID[e.From]; from != nil && languageMatches(from.Language) { return true