diff --git a/internal/config/config.go b/internal/config/config.go index fb70f00d..dfb43c5d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 diff --git a/internal/mcp/tools_core.go b/internal/mcp/tools_core.go index 7b591862..7327d4ff 100644 --- a/internal/mcp/tools_core.go +++ b/internal/mcp/tools_core.go @@ -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) diff --git a/internal/mcp/tools_lsp.go b/internal/mcp/tools_lsp.go index 69b2f5c3..a80edaca 100644 --- a/internal/mcp/tools_lsp.go +++ b/internal/mcp/tools_lsp.go @@ -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 diff --git a/internal/semantic/config.go b/internal/semantic/config.go index 5b963cb3..5f55d283 100644 --- a/internal/semantic/config.go +++ b/internal/semantic/config.go @@ -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. diff --git a/internal/semantic/lsp/provider.go b/internal/semantic/lsp/provider.go index 01b75481..7fb2d3b9 100644 --- a/internal/semantic/lsp/provider.go +++ b/internal/semantic/lsp/provider.go @@ -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. diff --git a/internal/semantic/manager.go b/internal/semantic/manager.go index 7036ccbf..ae74290b 100644 --- a/internal/semantic/manager.go +++ b/internal/semantic/manager.go @@ -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) diff --git a/internal/semantic/manager_test.go b/internal/semantic/manager_test.go index 0feed99d..420bc217 100644 --- a/internal/semantic/manager_test.go +++ b/internal/semantic/manager_test.go @@ -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}, }, @@ -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}, @@ -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}, }, @@ -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()) @@ -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) @@ -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}, }, @@ -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}, }, @@ -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}, @@ -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}, @@ -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()) @@ -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} @@ -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{ @@ -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"}, @@ -508,7 +515,7 @@ func TestManager_EnrichAll_RouterDedupAcrossLanguages(t *testing.T) { // Router specs show up as "lsp-" 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"}, @@ -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}, @@ -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}, }, diff --git a/internal/serverstack/shared_server.go b/internal/serverstack/shared_server.go index fdde4963..ec591c34 100644 --- a/internal/serverstack/shared_server.go +++ b/internal/serverstack/shared_server.go @@ -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{ @@ -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 +}