refactoring
Monorepo-aware LSP routing at scale
Spawning scoped language server instances per project in a monorepo keeps IDE features fast by avoiding whole-repository indexing overhead.

Large monorepos break most IDE setups because the Language Server Protocol wasn't designed for hundred-thousand-file codebases with multiple languages, frameworks, and build targets in a single tree. A naïve LSP configuration runs one server per language across the entire repository, which means your TypeScript language server tries to index every .ts file in //frontend, //admin-ui, //mobile-web, and three experimental playgrounds you forgot existed. Autocomplete freezes. Go-to-definition times out. Developers add the repo root to their LSP ignore list and lose half the intelligence they relied on.
Monorepo-aware LSP routing solves this by treating the repository as a collection of projects—each with its own tsconfig, go.mod, pyproject.toml, or Cargo.toml—and spawning independent language server instances scoped to the files that project actually owns. When you open //services/billing/handler.ts, the router starts (or reuses) a TypeScript server that only sees //services/billing and its declared dependencies, not the entire monorepo. Refactoring within that boundary stays fast. Cross-project changes route through multiple servers with the router mediating definitions, references, and renames that span boundaries.
Why scope matters more than raw indexing speed
The instinct is to optimize indexing: faster watches, incremental updates, caching symbol tables. Those help, but the real win is not indexing irrelevant code at all. A payment service doesn't need live type information for the machine-learning training pipeline three directories over. Scoped LSP instances:
- Keep memory footprint per server under 500 MB instead of multi-gigabyte processes that page to disk.
- Avoid cascading re-checks when unrelated packages change. A CSS tweak in
//marketingwon't trigger TypeScript re-analysis in//api. - Produce correct diagnostics because each server sees the dependencies its project actually declared, not every transitive possibility in the tree.
The downside is complexity: you need a router that understands your monorepo's project structure, manages server lifetimes, and forwards LSP requests to the right backend. Most editor LSP clients assume one server per language globally; building a router means intercepting textDocument/definition, checking which project owns the file, and proxying to that project's server—or multiple servers if the definition crosses a boundary.
Mapping files to projects without a full build graph
The simplest router uses static configuration: a .lsp-projects.json that lists every project root and its file globs. When the editor opens //libs/auth/session.go, the router matches libs/auth/**/*.go to the project anchored at //libs/auth/go.mod and starts (or attaches to) a gopls instance with GOPACKAGESDRIVER or -modfile pointing there.
This works until projects overlap or share generated code. A more robust approach queries the build system:
# Bazel example: find the package owning a file
bazel query "//libs/auth:all" --output=package
Goatfied's agent loop does this during the constrain phase when planning multi-file edits. It asks the build graph which targets depend on a given source file, infers project boundaries from BUILD or package.json proximity, and routes LSP requests accordingly. For repositories using Bazel, Buck, or Pants, this is table stakes—file → target → project is a single query. For less formal monorepos (everything in pnpm-workspace.yaml but no strict dependency graph), the router falls back to heuristics: walk up the tree until you find a tsconfig.json with "references" or a go.mod.
Handling cross-project go-to-definition and rename
When you invoke "Go to definition" on an import from another project, the request lands at the language server for your current project. If that server has the dependency in its module graph (via tsconfig references, Go replace directives, or Bazel deps), it resolves the location. If not, the router must:
1. Identify the target project from the import path (e.g., @mycompany/auth maps to //libs/auth).
2. Forward a textDocument/definition request to that project's LSP server.
3. Return the remote result to the editor.
This gets tricky with renames. A rename request in //services/billing that touches a symbol exported from //libs/payments must:
- Compute the rename edits within
//services/billingusing its local server. - Forward the rename to
//libs/payments' server to update the export and any internal references. - Aggregate
WorkspaceEditresponses and deduplicate overlapping changes.
Goatfied's edit → validate → retry loop handles this by treating each language server as a constraint oracle. The planner generates a candidate multi-file diff, sends it to every affected project's LSP for validation (type-check, lint), collects diagnostics, and retries with narrower scope if errors appear. The router doesn't try to be smarter than the language servers; it orchestrates them and enforces that all edits pass their respective compile gates before merging.
Lifecycle management: when to start and stop servers
Spawning a language server per project sounds expensive, but most projects in a monorepo are dormant at any given time. The router should:
- Lazy-start: only launch a server when a file in that project is opened.
- Idle shutdown: kill servers that haven't received a request in 10 minutes (configurable).
- Shared servers for tiny projects: if
//tools/scriptscontains three Python files and no dependencies, route it to a shared "misc Python" server instead of spinning up a dedicated instance.
In practice, a developer working on the frontend touches 2–4 projects (the main UI package, a shared component library, maybe a mock API service). The router keeps 3–4 TypeScript servers alive and zero gopls instances if the developer hasn't opened Go code that session. Memory use scales with active work, not repository size.
Goatfied's self-hosted deployment runs the LSP router as a sidecar to the agent runtime, reusing the same project-boundary inference that gates CI checks. When the agent plans a refactor across //frontend and //shared-components, it knows from the build graph that those are separate projects, starts a language server for each (if not already running), validates the diff through both, and only proceeds if all diagnostics are clean. The managed cloud version does the same but amortizes server startup cost across multiple workspaces by pooling warm instances.
Practical patterns for the router layer
If you're building this in-house:
- Use LSP proxy libraries like
vscode-languageserver-protocolortower-lspinstead of raw JSON-RPC parsing. The protocol has 50+ message types; you don't want to maintain that by hand. - Normalize file URIs early. Editors send
file:///absolute/path, but your monorepo logic works on repo-relative paths like//libs/auth. Convert once at ingress. - Cache project → files mappings but invalidate on
package.json,tsconfig.json, orBUILDchanges. These files define project boundaries; if they change, the router must re-query the build system. - Expose a debug endpoint showing which servers are running, their memory use, and request counts. When a developer complains autocomplete is slow, you need to see whether the router sent the request to the right server or if that server is thrashing.
For repositories mixing multiple languages (Go services, TypeScript frontends, Python ML pipelines), run separate routers per language or a single multiplexing router with per-language backend pools. The latter is more complex but avoids editor-side configuration for each language.
How this fits into reproducible refactoring
Monorepo-aware LSP routing isn't just about performance—it's a prerequisite for safe large-scale changes. If your language servers don't agree on project boundaries, a rename in //libs/billing might miss references in //services/orders because the TypeScript server there never re-indexed after the change. The router enforces that every project's server sees a consistent view of shared code and validates edits against that view before they're committed.
Goatfied's agent loop pairs this with small, reversible diffs. When refactoring across projects, the planner proposes changes one project at a time (or in dependency order if the build graph allows parallel edits), runs LSP diagnostics + compile + test gates for each, and only advances if all pass. The router ensures those diagnostics come from the correct scoped servers, not a single overloaded global instance that's still indexing yesterday's branches.