chore: import upstream snapshot with attribution
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:33:42 +08:00
commit a06f331eb8
3186 changed files with 689843 additions and 0 deletions
+431
View File
@@ -0,0 +1,431 @@
# LSP Routing
Gortex uses the Language Server Protocol (LSP) for two things:
1. **Compiler-grade resolution during enrichment.** When the resolver
leaves an edge as `ast_inferred` or `text_matched`, an LSP server
(`textDocument/definition` + `textDocument/implementation`) upgrades
it to `lsp_resolved` / `lsp_dispatch`. This raises precision on
`find_usages`, `get_callers`, `find_implementations`, and the
contract pipeline's binding resolver.
2. **On-demand actions** via the four MCP tools that wrap the LSP
action surface: `get_diagnostics`, `get_code_actions`,
`apply_code_action`, `fix_all_in_file`.
Both paths route through the same per-daemon `lsp.Router` — one
subprocess per language server, lazy-spawned on first request, idle
reaper at ten minutes, LRU eviction at six concurrent.
## Server registry
Sixteen servers ship in the registry today
(`internal/semantic/lsp/registry.go`):
| Spec name | Command | Languages | Default priority |
| ---------------------------- | -------------------------------- | --------------------------- | ---------------- |
| `gopls` | `gopls` | go | 3 |
| `typescript-language-server` | `typescript-language-server` | typescript, javascript | 5 |
| `pyright` | `pyright-langserver` | python | 5 |
| `rust-analyzer` | `rust-analyzer` | rust | 5 |
| `clangd` | `clangd --background-index` | c, c++, objc, objc++ | 5 |
| `jdtls` | `jdtls` | java | 6 |
| `kotlin-language-server` | `kotlin-language-server` | kotlin | 6 |
| `omnisharp` | `omnisharp -lsp` | csharp | 5 |
| `ruby-lsp` | `ruby-lsp` | ruby | 5 |
| `phpactor` | `phpactor language-server` | php | 5 |
| `lua-language-server` | `lua-language-server` | lua | 5 |
| `sourcekit-lsp` | `sourcekit-lsp` | swift | 5 |
| `haskell-language-server` | `haskell-language-server-wrapper`| haskell | 5 |
| `elixir-ls` | `elixir-ls` | elixir | 5 |
| `ocamllsp` | `ocamllsp` | ocaml | 5 |
| `zls` | `zls` | zig | 5 |
Several specs declare `AlternativeCommands` — Gortex picks the first
binary on `PATH`:
- `pyright` → falls back to `jedi-language-server` or `pylsp`.
- `ruby-lsp` → falls back to `solargraph stdio`.
- `phpactor` → falls back to `intelephense --stdio`.
Lower priority numbers win when more than one provider serves the same
language. `gopls` is `3` so it beats SCIP-based providers (`5`) for Go;
`jdtls` is `6` so it's lower-priority than the SCIP-java path that
ships separately.
`clangd` is the one server that needs a compilation database for full
enrichment. Point it at a `compile_commands.json` (or a `compile_flags.txt`
/ `.clangd`) at the repo root; without one, gortex degrades that repository's
enrichment to reference confirmation — see
[Enrichment cost model](#enrichment-cost-model).
## Enabling a server
Add it to `.gortex.yaml`:
```yaml
semantic:
enabled: true
mode: typecheck # or "callgraph"
providers:
- name: gopls
enabled: true
- name: rust-analyzer
enabled: true
- name: pyright
enabled: true
```
Names match the **Spec name** column above. The router pre-registers
every enabled spec at boot but does not spawn anything yet —
subprocesses start the first time a tool calls into them.
## Installing the underlying servers
Gortex does not ship the LSP binaries. Install the ones you want to
use; the router falls back gracefully when a binary is missing
(`SpecAvailable(name)` returns false → tool returns a structured
`no_lsp_for` error instead of hanging).
```bash
# Go
go install golang.org/x/tools/gopls@latest
# Rust
rustup component add rust-analyzer
# Python (pick one)
npm install -g pyright # recommended
pip install jedi-language-server # alt
pip install python-lsp-server # alt (pylsp)
# TypeScript / JavaScript
npm install -g typescript typescript-language-server
# C / C++ / Objective-C
brew install llvm # ships clangd
# or apt install clangd
# Java
brew install jdtls
# Kotlin
brew install kotlin-language-server
# C#
dotnet tool install --global Microsoft.OmniSharp
# Ruby (pick one)
gem install ruby-lsp # recommended
gem install solargraph # alt
# PHP (pick one)
composer global require phpactor/phpactor
npm install -g intelephense # alt
# Lua
brew install lua-language-server
# Swift
# Bundled with Xcode toolchain on macOS; no separate install.
# Haskell
ghcup install hls
# Elixir
brew install elixir-ls
# OCaml
opam install ocaml-lsp-server
# Zig
brew install zls
```
Verify with `gortex daemon status` — the LSP-router section lists
`alive`, `last_used`, and the resolved command for each running
server. Newly enabled specs show up only after the first request that
needs them.
## Lifecycle
The router applies these defaults in `cmd/gortex/server.go` and
`cmd/gortex/mcp.go`:
| Knob | Default | What it does |
| ----------------- | ------------ | ----------------------------------------------------------------------------------------------------- |
| `WithIdleTimeout` | 10 minutes | Subprocess closes if no `For()` / `ForSpec()` call lands in this window. |
| `WithReaperInterval` | 1 minute | Background tick invokes `Reap()` to enforce the idle timeout. |
| `WithMaxAlive` | 6 servers | LRU eviction kicks in when a seventh distinct server would spawn — the least-recently-used one closes. |
These defaults suit a polyglot workspace where most languages are
touched only intermittently. Override them by editing the
`lsp.NewRouter(...).With...` chain in your build if you need a longer
warm pool or a tighter memory bound.
## Enrichment cost model
The resolution path (use 1 at the top of this page) runs as a batch
**enrichment pass** — one pass per (repository × language server) — that walks
the repo's nodes for that language and upgrades edges the AST resolver left
ambiguous. A pass runs up to five phases:
1. **Interface pass** — for each interface / abstract declaration, asks the
server for its implementations (`textDocument/implementation`) and links the
dispatch edges.
2. **Confirm pass** — for every ambiguous edge (one whose confidence the
resolver could not raise to certainty), asks for the referent's references
(`textDocument/references`) and confirms or refutes the edge. Grouped by
referent file so each file opens once.
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.
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
method it prepares a call hierarchy and reads outgoing (and, where it helps,
incoming) calls; per type or interface it reads the super/subtype hierarchy;
per symbol it hovers for a type string.
Each phase opens the files it touches on the language server (`didOpen`) and
closes them when done (`didClose`). The first four phases carry most of the
precision gain; the sweep carries most of the cost — on a warm restart, where
every node already reloads with its resolved edges and type stamp, the sweep is
almost pure churn.
### Bounded document session
All five phases share one **document session** per pass. It opens each file on a
server at most once while the file is in use, keeps recently-closed files warm
in an LRU tail so a later phase reuses the open document instead of reopening
it, and closes everything at pass end. The simultaneously-open ceiling is
`2 × max_parallel` documents per server (floor 4): the pinned working set stays
within `max_parallel` (bounded by the pass's file semaphore) and the extra
headroom is the warm tail. `didOpen` / `didClose` stay paired per (file,
server) — a file opened on a server that later dies still gets its close attempt
on that server — while the server's open-document set stays bounded.
This matters most for `clangd` without a compilation database: every `didOpen`
triggers a full fallback-preamble + AST rebuild, so reopening the same file
across phases multiplies that cost.
### Sweep modes
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. |
| `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. |
Resolution precedence, highest first:
1. The `GORTEX_LSP_SWEEP` environment variable (`demand` / `full` / `off`, with
`none` accepted as an alias for `off`; case- and whitespace-insensitive). Set
it where you launch `gortex daemon` / `gortex server` to dial one run without
editing config.
2. The `.gortex.yaml` key `semantic.lsp_sweep` (same values).
3. A per-server default. Most servers have none and fall through to the global
default; **rust-analyzer defaults to `full`**. Rust method calls bind
overwhelmingly to standard-library receiver types the graph never indexes, so
static confirmation leaves rust-analyzer's net-new call-hierarchy edges on the
table — its recall lives in the full sweep. Either operator source above
overrides it, so `semantic.lsp_sweep: demand` (or the env) puts rust-analyzer
back on the demand gate.
4. The `demand` default.
An unrecognised value at any level is ignored and the next source applies.
```yaml
semantic:
enabled: true
lsp_sweep: demand # demand (default) | full | off
```
### Incoming-calls policy
In the sweep's call-hierarchy step, outgoing calls are always fetched — the
sweep visits every caller, so a declaration's outgoing hops alone reconstruct
every intra-repo static call edge. Incoming calls are fetched only where the
outgoing side is blind: a dispatch-relevant declaration (whose concrete callers
land on the incoming side of an interface method, not its outgoing side) or one
that still carries unresolved demand — or under `full` mode. Files are swept
demand-first, so a declaration whose incoming calls are skipped at a deadline
cut still loses no reachable edge. The count of declined round trips rides on
the pass-complete log as `incoming_calls_skipped`.
### Compile-database preflight (clangd)
Before enriching a repository with a server that needs a compilation database
(`clangd`), gortex checks the workspace root for one of:
- `compile_commands.json` — canonical CMake / Bear output
- `build*/compile_commands.json` — out-of-tree build directories
- `compile_flags.txt` — clangd's flat-flags fallback
- `.clangd` — a hand-written clangd config
If none is present, clangd rebuilds a full fallback AST on every `didOpen`, and
opening a header directly makes it a standalone translation unit with no
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.
A degraded pass warns once with the remediation and marks its result
`degraded`. `index_health` surfaces a recommendation naming the repository and
provider:
> generate compile_commands.json (cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON,
> bear -- make, or meson) at the repo root, then reindex
Degradation is deliberate, not a failure — `semantic_enrichment_ok` stays true.
### Telemetry
The pass-complete log line (`LSP enrich: hover phase complete`) carries the cost
accounting for the pass:
| Field | Meaning |
| ----- | ------- |
| `sweep_mode` | The effective sweep mode for the pass. |
| `did_opens` | Total `didOpen` calls across every phase. |
| `reopened_files` | Files opened more than once (a warm-tail miss). |
| `doc_evictions` | LRU evictions — a `didClose` forced to make room. |
| `peak_open_docs` | Peak simultaneously-open documents on any one server. |
| `req_references`, `req_implementations`, `req_definitions`, `req_hovers` | Per-method request counts. |
| `req_prepare_call_hierarchy`, `req_outgoing_calls`, `req_incoming_calls` | Call-hierarchy request counts. |
| `incoming_calls_skipped` | Incoming-calls round trips the policy above declined. |
| `req_prepare_type_hierarchy`, `req_supertypes`, `req_subtypes` | Type-hierarchy request counts. |
| `skipped_already_stamped` | Nodes whose hover was skipped because they already carried a `semantic_type`. |
A degraded pass logs `LSP enrich: degraded pass complete (reference
confirmation only)` instead, with `degraded=true` and the document-open counters
but no hover / hierarchy counts.
## Diagnostics
Two surfaces:
### Pull (poll-based)
`get_diagnostics` returns the most recent `publishDiagnostics` payload
the LSP server produced for a file. Use it for one-shot reads, batch
checks, or contexts where the agent doesn't maintain a long-lived
session.
### Push (opt-in)
`subscribe_diagnostics` opts the calling MCP session into
`notifications/diagnostics` push events. After subscribing, every LSP
`publishDiagnostics` for any router-managed server is forwarded to the
session as an MCP notification with this shape:
```json
{
"method": "notifications/diagnostics",
"params": {
"uri": "file:///abs/path/to/main.go",
"path": "/abs/path/to/main.go",
"server": "gopls",
"diagnostics": [
{ "range": {"start": {"line": 41, "character": 4}, "end": {...}},
"severity": 1, "message": "missing return", "source": "gopls" }
]
}
}
```
Push semantics:
- **Opt-in per session.** Sessions that never call `subscribe_diagnostics`
receive nothing — no broadcast spam.
- **Delta-only.** Identical re-publishes (which some servers emit on
every save even when nothing changed) are suppressed at the
broadcaster — your subscribers only see real changes.
- **All-router-managed servers.** One subscription covers every spec
the user enabled in config. The `server` field on each notification
identifies which LSP produced the payload.
- **Non-blocking.** Notifications use `SendNotificationToAllClients`
which drops to an error hook when a session's notification channel
is full — slow consumers don't block the LSP message-pump.
Call `unsubscribe_diagnostics` to opt back out (idempotent).
Pair with `get_code_actions` + `apply_code_action` + `fix_all_in_file`
for the full edit-time diagnostic loop without polling.
## Language-specific behaviour
A few servers need per-language handling beyond the generic router. These
knobs are **environment variables** read by the daemon process — set them
where you launch `gortex daemon` / `gortex server`. There is no `.gortex.yaml`
key for them today.
### C# — NuGet audit advisories (`omnisharp` / `csharp-ls`)
The Roslyn `MSBuildWorkspace` these servers build escalates a NuGet audit
*warning* (the `NU19xx` family — e.g. a transitively vulnerable package) to a
**fatal** project-load failure and drops every project that references the
flagged package. Those projects then have no compilation, so the server emits
false `CS####` "unresolved type / namespace" diagnostics — even though
`dotnet build` / `dotnet test` keep `NU19xx` a non-fatal warning and succeed.
Gortex applies two complementary, C#-scoped fixes, **both on by default**:
| Env var | Default | Effect |
| --- | --- | --- |
| `GORTEX_LSP_CSHARP_RESTORE` | on | Before spawning the C# server, run `dotnet restore -p:NuGetAudit=false` in the workspace so the MSBuild workspace loads every project (root-cause fix). Best-effort: a failure logs and falls through to a normal spawn; skipped on passive IDE attach and when `dotnet` is not on `PATH`. |
| `GORTEX_LSP_CSHARP_DIAG_FILTER` | on | Strip diagnostics whose code is the `NU####` NuGet family from `publishDiagnostics` before storing / fanning out (symptom fix). Deliberately narrow — real `CS####` compiler diagnostics always pass through. |
Set either to a falsey value (`0` / `off` / `false` / `none`) to disable it —
e.g. `GORTEX_LSP_CSHARP_RESTORE=0` for offline / air-gapped indexing or to
keep indexing off the network. Restore is on by default because gortex only
indexes repositories you explicitly add (it never auto-discovers), and
spawning the C# server already evaluates the project's MSBuild graph — so the
restore adds no execution surface beyond the workspace load it precedes. A
successful restore logs `lsp: csharp pre-restore complete (NuGetAudit
suppressed)`; a failure logs `lsp: csharp pre-restore failed; spawning server
anyway` with the restore output tail.
### Java — build-backed resolution (`jdtls`)
By default `jdtls` runs in a **no-build** mode (JRE-only classpath, Maven /
Gradle import and autobuild disabled) so indexing an untrusted Java repo never
runs its build. Resolution is more limited in this mode (jdtls falls back to
an "invisible project"). Set `GORTEX_LSP_JDTLS_TRUST_BUILD=1` (or `true`) to
allow full Maven / Gradle import + autobuild for higher-fidelity resolution —
**opt-in**, because it executes the repository's build tooling. Enable it only
for repositories you trust.
## Troubleshooting
- **`no_lsp_for` error:** the file extension didn't match any
registered spec. Either the spec isn't enabled in `.gortex.yaml`, or
the binary isn't on `PATH`. Run the spec's `--version` directly to
confirm install.
- **`router spawn <name>: ...` error:** the binary was on `PATH` at
boot but the subprocess failed to initialise (commonly a missing
dependency such as `node` for `pyright`, or a workspace-config
mismatch). The error surfaces the LSP server's stderr.
- **Server keeps restarting:** the idle reaper closed it, then the
next request re-spawned. Increase `WithIdleTimeout` if this hurts
warm-cache benchmarks.
- **High memory under polyglot load:** lower `WithMaxAlive` from 6 to
3-4. The LRU evicts the least-recent server transparently.
## Implementation notes
- The router lives at `internal/semantic/lsp/router.go`. It satisfies
the `semantic.LSPRouter` interface so `semantic.Manager` can drive
batch enrichment without taking a hard import dependency on the lsp
package (which would create a cycle — lsp already imports semantic
for the Provider interface).
- `tools_lsp.go::lspProviderForPath` queries the router first; if no
router is wired (legacy boot paths, tests), it falls back to a scan
through `Manager.AllProviders()` so user-defined daemons (specs not
in the registry) still work.
- One `*lsp.Provider` per spec, regardless of how many MCP sessions
hit it. Concurrency is bounded by `ServerSpec.MaxParallel` (6-10
inflight requests per server depending on the spec).