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
5 changes: 5 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,11 @@ type SemanticConfig struct {
// passes still run.
// The GORTEX_LSP_SWEEP env override wins over this setting.
LSPSweep string `mapstructure:"lsp_sweep" yaml:"lsp_sweep,omitempty"`
// EagerLSP runs the subprocess LSP servers during synchronous enrichment.
// Default false: LSP is the slowest part of a cold index and the in-process
// tiers (go-types, tree-sitter floor) cover the fast baseline, so LSP is
// lazy-spawned on demand instead. GORTEX_LSP_EAGER=1 restores eager LSP.
EagerLSP bool `mapstructure:"eager_lsp" yaml:"eager_lsp,omitempty"`
// SkipEmbed lists (language, kind) combinations that should be
// indexed for graph queries but *not* embedded into the vector
// search. Design tokens (CSS custom properties), terraform
Expand Down
6 changes: 6 additions & 0 deletions internal/mcp/tools_core.go
Original file line number Diff line number Diff line change
Expand Up @@ -1329,6 +1329,12 @@ func (s *Server) handleGetSymbol(ctx context.Context, req mcp.CallToolRequest) (

s.sessionFor(ctx).recordSymbol(id)

// On-demand LSP: when the synchronous LSP sweep is off (the default), fault
// in this symbol's precise type now — one hover on the lazy-spawned server,
// cached in the graph. No-op when it already has a type or no server serves
// the language.
s.enrichNodeOnDemand(node)

detail := req.GetString("detail", "brief")
if detail == "brief" {
return s.respondScopedJSONOrTOON(ctx, req, s.withAbsPath(node).Brief(), resolved)
Expand Down
35 changes: 35 additions & 0 deletions internal/mcp/tools_lsp.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,45 @@ import (

"github.com/mark3labs/mcp-go/mcp"

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

// nodeHasSemanticType reports whether a node already carries a semantic_type
// stamp — from eager enrichment or a prior on-demand fault-in.
func nodeHasSemanticType(n *graph.Node) bool {
if n == nil || n.Meta == nil {
return false
}
s, _ := n.Meta["semantic_type"].(string)
return s != ""
}

// enrichNodeOnDemand faults in an LSP-grade semantic_type for a single symbol
// when the synchronous LSP sweep is off (the default). It lazy-spawns the
// language server for the node's file via the router, hovers the symbol, and
// stamps the type into the graph so a second read is free — the per-symbol
// counterpart to the whole-repo enrichment that no longer runs at cold-index
// time. Best-effort and idempotent: a no-op when the node already has a type,
// no LSP server serves its language, or the semantic manager is off. Mutates
// the passed node in place (and persists) so the caller's response reflects the
// fresh type.
func (s *Server) enrichNodeOnDemand(node *graph.Node) {
if node == nil || s.semanticMgr == nil || s.graph == nil || nodeHasSemanticType(node) {
return
}
absPath, err := s.absolutePath(node.FilePath)
if err != nil {
return
}
provider, _, err := s.lspProviderForPath(absPath)
if err != nil || provider == nil {
return
}
_, _ = provider.EnrichNode(s.graph, s.workspaceRootFor(absPath), node)
}

// registerLSPTools wires the LSP-action MCP surface:
//
// get_diagnostics — most recent publishDiagnostics for a file
Expand Down
10 changes: 10 additions & 0 deletions internal/semantic/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ type Config struct {
// each spawned LSP provider via the router's WithEnrichSweepMode. The
// GORTEX_LSP_SWEEP env override wins over it at enrichment time.
LSPSweep string `mapstructure:"lsp_sweep" yaml:"lsp_sweep,omitempty"`
// EagerLSP runs the subprocess LSP servers during the synchronous
// enrichment pass. Default false: LSP is the slowest part of a cold index
// (a full gopls/tsserver/rust-analyzer/pyright sweep can run for minutes to
// hours) and its net-new value over the in-process tiers is narrow — a Go
// module is served by go-types, and every language has the tree-sitter
// floor. With this off, cold/warm start pays only the fast in-process
// providers; the LSP router stays available so a query can still lazy-spawn
// a server on demand. Set true (or GORTEX_LSP_EAGER=1) to restore the
// pre-change eager behaviour.
EagerLSP bool `mapstructure:"eager_lsp" yaml:"eager_lsp,omitempty"`
}

// ProviderConfig holds configuration for a single semantic provider.
Expand Down
43 changes: 43 additions & 0 deletions internal/semantic/lsp/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -2562,6 +2562,49 @@ func (p *Provider) EnsureFileOpen(repoRoot, relPath string) error {
return p.openDocument(abs, relPath)
}

// EnrichNode lazily enriches a single symbol node with an LSP-grade
// semantic_type (and return_type for callables), on demand. It is the
// per-symbol counterpart to the whole-repo Enrich pass: when the synchronous
// LSP sweep is off (the default), a query tool that needs a precise type for
// one symbol calls this to fault in exactly that symbol's hover — one LSP
// round-trip, not a repo sweep — and the stamp persists in the graph so a
// second query is free.
//
// Returns (true, nil) when a type was stamped, (false, nil) when the server had
// no type at the node's position (nothing is written), and (false, err) on a
// transport failure. The file is opened on the server first (idempotent).
func (p *Provider) EnrichNode(g graph.Store, repoRoot string, n *graph.Node) (bool, error) {
if n == nil {
return false, nil
}
line, ok := lspLine(n)
if !ok {
return false, nil
}
rel := nodeRelPath(n)
if err := p.EnsureFileOpen(repoRoot, rel); err != nil {
return false, err
}
col := 0
if src := p.getSource(repoRoot, rel); src != nil {
col = identifierColumn(src, n.StartLine, n.Name)
}
hr, err := p.hoverWith(p.client, repoRoot, rel, line, col)
if err != nil {
return false, err
}
if hr == nil {
return false, nil
}
typeInfo := extractTypeFromHover(hr.Contents.Value)
if typeInfo == "" {
return false, nil
}
semantic.EnrichNodeMeta(n, "semantic_type", typeInfo, p.Name())
g.AddBatch([]*graph.Node{n}, nil)
return true, nil
}

// getSource returns cached file content from the most recent
// openDocument call. Returns nil when not cached — callers fall
// back to col=0 then.
Expand Down
9 changes: 8 additions & 1 deletion internal/semantic/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,14 @@ func (m *Manager) EnrichAll(g graph.Store, roots map[string]string, opts EnrichO
// priority number; ties break by spec-name lexicographic order.
// Eager providers from selectProviders already won their language;
// router specs may only fill gaps.
if m.lspRouter != nil {
//
// Skipped unless EagerLSP is set: the subprocess LSP sweep is the slowest
// part of a cold index and its net-new value over the in-process tiers
// (go-types for Go, the tree-sitter floor for every language) is narrow.
// Leaving it out of the synchronous pass is what keeps cold/warm start
// fast; the router is still wired, so a query can lazy-spawn a server on
// demand.
if m.config.EagerLSP && m.lspRouter != nil {
// Pre-pass: pure metadata, no spawn.
bestSpec := make(map[string]string) // language → winning spec name
bestPrio := make(map[string]int)
Expand Down
41 changes: 25 additions & 16 deletions internal/semantic/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ func (m *mockProvider) EnrichFile(g graph.Store, repoRoot, filePath string) (*En
func TestManager_EnrichAll(t *testing.T) {
logger := zap.NewNop()
cfg := Config{
Enabled: true,
Enabled: true,
EagerLSP: true, // router-backed LSP dispatch is opt-in
Providers: []ProviderConfig{
{Name: "test-go", Languages: []string{"go"}, Priority: 1, Enabled: true},
},
Expand All @@ -71,7 +72,8 @@ func TestManager_EnrichAll(t *testing.T) {
func TestManager_PrioritySelection(t *testing.T) {
logger := zap.NewNop()
cfg := Config{
Enabled: true,
Enabled: true,
EagerLSP: true, // router-backed LSP dispatch is opt-in
Providers: []ProviderConfig{
{Name: "high-priority", Languages: []string{"go"}, Priority: 1, Enabled: true},
{Name: "low-priority", Languages: []string{"go"}, Priority: 2, Enabled: true},
Expand Down Expand Up @@ -114,7 +116,8 @@ func TestManager_PrioritySelection(t *testing.T) {
func TestManager_UnavailableProvider(t *testing.T) {
logger := zap.NewNop()
cfg := Config{
Enabled: true,
Enabled: true,
EagerLSP: true, // router-backed LSP dispatch is opt-in
Providers: []ProviderConfig{
{Name: "unavailable", Languages: []string{"go"}, Priority: 1, Enabled: true},
},
Expand Down Expand Up @@ -272,7 +275,7 @@ func (e assertionError) Error() string { return string(e) }
// TestManager_LSPRouter_RoundTrip — SetLSPRouter / LSPRouter accessor
// pair returns the same instance.
func TestManager_LSPRouter_RoundTrip(t *testing.T) {
mgr := NewManager(Config{Enabled: true}, zap.NewNop())
mgr := NewManager(Config{Enabled: true, EagerLSP: true}, zap.NewNop())
r := &fakeRouter{}
mgr.SetLSPRouter(r)
assert.Same(t, r, mgr.LSPRouter())
Expand All @@ -283,7 +286,9 @@ func TestManager_LSPRouter_RoundTrip(t *testing.T) {
// against the returned provider exactly once per repo root.
func TestManager_EnrichAll_RoutesThroughLSPRouter(t *testing.T) {
logger := zap.NewNop()
cfg := Config{Enabled: true}
// EagerLSP: this test exercises the synchronous router-backed LSP dispatch,
// which is opt-in (LSP is lazy by default).
cfg := Config{Enabled: true, EagerLSP: true}

mgr := NewManager(cfg, logger)

Expand Down Expand Up @@ -314,7 +319,8 @@ func TestManager_EnrichAll_RoutesThroughLSPRouter(t *testing.T) {
func TestManager_EnrichAll_SkipsCoveredLanguages(t *testing.T) {
logger := zap.NewNop()
cfg := Config{
Enabled: true,
Enabled: true,
EagerLSP: true, // router-backed LSP dispatch is opt-in
Providers: []ProviderConfig{
{Name: "eager-go", Languages: []string{"go"}, Priority: 1, Enabled: true},
},
Expand Down Expand Up @@ -347,7 +353,8 @@ func TestManager_EnrichAll_SkipsCoveredLanguages(t *testing.T) {
func TestManager_EnrichAll_GatesAbsentLanguages(t *testing.T) {
logger := zap.NewNop()
cfg := Config{
Enabled: true,
Enabled: true,
EagerLSP: true, // router-backed LSP dispatch is opt-in
Providers: []ProviderConfig{
{Name: "eager-go", Languages: []string{"go"}, Priority: 1, Enabled: true},
},
Expand Down Expand Up @@ -387,7 +394,7 @@ func TestManager_EnrichAll_GatesAbsentLanguages(t *testing.T) {
// least one available spec returns true, and the check does NOT
// trigger ProviderForSpec (which would lazy-spawn a real LSP).
func TestManager_HasProviders_RouterOnly(t *testing.T) {
mgr := NewManager(Config{Enabled: true}, zap.NewNop())
mgr := NewManager(Config{Enabled: true, EagerLSP: true}, zap.NewNop())
r := &fakeRouter{
specs: []string{"rust-analyzer"},
available: map[string]bool{"rust-analyzer": true},
Expand All @@ -405,7 +412,7 @@ func TestManager_HasProviders_RouterOnly(t *testing.T) {
// TestManager_HasProviders_NoneAvailable — router enabled but no spec
// available returns false (and again does not spawn).
func TestManager_HasProviders_NoneAvailable(t *testing.T) {
mgr := NewManager(Config{Enabled: true}, zap.NewNop())
mgr := NewManager(Config{Enabled: true, EagerLSP: true}, zap.NewNop())
r := &fakeRouter{
specs: []string{"rust-analyzer"},
available: map[string]bool{"rust-analyzer": false},
Expand All @@ -417,7 +424,7 @@ func TestManager_HasProviders_NoneAvailable(t *testing.T) {
// TestManager_Close_ShutsDownRouter — Manager.Close cascades into
// LSPRouter.Close exactly once.
func TestManager_Close_ShutsDownRouter(t *testing.T) {
mgr := NewManager(Config{Enabled: true}, zap.NewNop())
mgr := NewManager(Config{Enabled: true, EagerLSP: true}, zap.NewNop())
r := &fakeRouter{}
mgr.SetLSPRouter(r)
require.NoError(t, mgr.Close())
Expand All @@ -430,7 +437,7 @@ func TestManager_Close_ShutsDownRouter(t *testing.T) {
// it).
func TestManager_EnrichAll_ArbitratesRouterDupes(t *testing.T) {
logger := zap.NewNop()
mgr := NewManager(Config{Enabled: true}, logger)
mgr := NewManager(Config{Enabled: true, EagerLSP: true}, logger)

pyrightProvider := &mockProvider{name: "lsp-pyright", languages: []string{"python"}, available: true}
jediProvider := &mockProvider{name: "lsp-jedi", languages: []string{"python"}, available: true}
Expand Down Expand Up @@ -462,7 +469,7 @@ func TestManager_EnrichAll_ArbitratesRouterDupes(t *testing.T) {
// TestManager_EnrichAll_RouterTieBreakerByName — equal priorities tie-
// break alphabetically by spec name (deterministic).
func TestManager_EnrichAll_RouterTieBreakerByName(t *testing.T) {
mgr := NewManager(Config{Enabled: true}, zap.NewNop())
mgr := NewManager(Config{Enabled: true, EagerLSP: true}, zap.NewNop())
pa := &mockProvider{name: "lsp-aaa", languages: []string{"go"}, available: true}
pb := &mockProvider{name: "lsp-bbb", languages: []string{"go"}, available: true}
r := &fakeRouter{
Expand All @@ -483,7 +490,7 @@ func TestManager_EnrichAll_RouterTieBreakerByName(t *testing.T) {
// TestManager_EnrichAll_RouterDedupAcrossLanguages — one spec serving
// two languages runs Enrich exactly once, not once per language.
func TestManager_EnrichAll_RouterDedupAcrossLanguages(t *testing.T) {
mgr := NewManager(Config{Enabled: true}, zap.NewNop())
mgr := NewManager(Config{Enabled: true, EagerLSP: true}, zap.NewNop())
tsProvider := &mockProvider{
name: "lsp-typescript-language-server",
languages: []string{"typescript", "javascript"},
Expand All @@ -508,7 +515,7 @@ func TestManager_EnrichAll_RouterDedupAcrossLanguages(t *testing.T) {
// Router specs show up as "lsp-<spec>" with status driven by
// SpecAvailable, no spawn triggered.
func TestManager_Stats_IncludesRouterSpecs(t *testing.T) {
mgr := NewManager(Config{Enabled: true}, zap.NewNop())
mgr := NewManager(Config{Enabled: true, EagerLSP: true}, zap.NewNop())
mgr.RegisterProvider(&mockProvider{
name: "scip-go",
languages: []string{"go"},
Expand Down Expand Up @@ -546,7 +553,8 @@ func TestManager_Stats_IncludesRouterSpecs(t *testing.T) {
// over pyright.
func TestManager_ConfigPriority_Override(t *testing.T) {
cfg := Config{
Enabled: true,
Enabled: true,
EagerLSP: true, // router-backed LSP dispatch is opt-in
Providers: []ProviderConfig{
{Name: "jedi-language-server", Priority: 1, Enabled: true},
{Name: "pyright", Priority: 5, Enabled: true},
Expand All @@ -573,7 +581,8 @@ func TestManager_ConfigPriority_Override(t *testing.T) {
func TestManager_Stats(t *testing.T) {
logger := zap.NewNop()
cfg := Config{
Enabled: true,
Enabled: true,
EagerLSP: true, // router-backed LSP dispatch is opt-in
Providers: []ProviderConfig{
{Name: "test-go", Languages: []string{"go"}, Priority: 1, Enabled: true},
},
Expand Down
15 changes: 15 additions & 0 deletions internal/serverstack/shared_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ func NewSharedServer(cfg SharedServerConfig) (*SharedServer, error) {
RefuteUnconfirmed: conf.Semantic.RefuteUnconfirmed,
ExcludeGlobs: conf.Semantic.ExcludeGlobs,
LSPSweep: conf.Semantic.LSPSweep,
EagerLSP: eagerLSPEnabled(conf.Semantic),
}
for _, pc := range conf.Semantic.Providers {
out := semantic.ProviderConfig{
Expand Down Expand Up @@ -693,3 +694,17 @@ func goTypesIncludeTests() bool {
return os.Getenv("GORTEX_GO_TYPES_TESTS") == "1" ||
strings.EqualFold(os.Getenv("GORTEX_GO_TYPES_TESTS"), "true")
}

// eagerLSPEnabled reports whether the subprocess LSP servers run during the
// synchronous enrichment pass. Default OFF — LSP is the slowest part of a cold
// index and its net-new value over the in-process tiers (go-types for Go, the
// tree-sitter floor for every language) is narrow, so it is kept off the
// cold/warm-start path and the router lazy-spawns a server on demand instead.
// GORTEX_LSP_EAGER=1 (or semantic.eager_lsp in config) restores the eager
// behaviour.
func eagerLSPEnabled(sem config.SemanticConfig) bool {
if v := os.Getenv("GORTEX_LSP_EAGER"); v != "" {
return v == "1" || strings.EqualFold(v, "true")
}
return sem.EagerLSP
}
Loading