LSP Enrichment
Specification of the optional Language Server Protocol enrichment pass that refines Aburi's Tree-sitter-derived IR — filling in SourceRange.startColumn / endColumn, resolving typed method dispatch (this.* / super.* / interface receivers), and inferring throws from declared return types — without changing IR shape, ordering, or any fingerprint value.
References:
overview.md§2 — the "Parser layer" decision row that adoptsTree-sitter core, always resident + optional LSP enrichmentand rejects LSP-only for CI-stability reasons; §4 — the extraction pipeline placement (tree-sitter parse (+ LSP enrich if available))call-resolution.md§2 — the Two-Tier Resolution Model whose LSP tier this pass supplies inputs for; §5 — the LSP-enriched resolution rules; §7.2 — the per-edge confidence table this pass populates thehigh/mediumrows of; §11.4 — the@aburi/coreplacement rationale mirrored hereeffect-propagation.md§3 — theCallEdge[]input that this pass indirectly enlarges; §5.3 — the confidence combination that consumes liftedCallEdge.confidenceslice-view.md§5.1 — the Edge set that clusters over the enrichedCallEdgegraphir-schema.md§1.1 — the absent-key vs explicit-nullconvention both fields below are classified under; §7 —Signature(extended here with an optionalinferredThrowsfield); §12 —SourceRange(existing optionalstartColumn/endColumnpopulated here); §15.2 — the non-breaking optional-field policy this pass relies onfingerprint.md§3.1 — theapifingerprint input this pass MUST NOT perturb; §4.1 — thelogicfingerprint input likewise; §5.1 — thesyntaxfingerprint input likewiselang-plugin.md§2.1 — Symbol existence and call sites are the plugin's authority, never LSP's; §2.2 — call resolution (and by extension LSP-tier resolution) is out of scope for the plugin; §4.7 —PluginContext.workspaceRootsupplies the LSProotUriconfig.md§5.4 —pluginOptionsscope hosts opaque per-plugin options (used only forinitializationOptionshere, not for timeouts); §11 — CLI override convention followed by--lsp/--no-lsp; §14.1 — the Config Schema Compatibility Policy that timeout-default revisions live under
1. Purpose
Aburi's Tree-sitter tier produces a complete, deterministic IR without any language server. This document specifies the optional LSP enrichment pass that refines four aspects of that IR:
Call.resolvedfor shapes the untyped tier leavesnull—this.*/super.*calls whose receiver class is resolvable through the type system, and interface-typed receivers with a single implementerSourceRange.startColumn/SourceRange.endColumn— populated fromtextDocument/documentSymbol; currently emitted asnullby the language pluginSignature.inferredThrows— a new optional array capturing throws declared on the return types of called functions (distinct fromSignature.throws, which stays reserved for explicitthrowstatements +@throwsJSDoc)CallEdge.confidence— monotone-upward promotion from the untyped tier value tohigh/mediumpercall-resolution.md§7.2
Non-goal: LSP is NEVER the source of truth for the existence of a Symbol, for the set of its call sites, or for its owning file. Those come from the language plugin (lang-plugin.md §2.1). This pass only refines already-emitted records.
The "optional" framing is required by overview.md §2: LSP-only was explicitly rejected for CI-stability and per-language variance reasons. Tree-sitter runs always; LSP runs when configured and available.
2. Pass Placement in the Pipeline
source files
↓ tree-sitter parse
↓ extractSymbols + walkBody + normalizeAst ← language plugin
SymbolCandidate[] with columns = null and untyped calls
↓ LSP enrichment (this document) ← @aburi/core, opt-in
SymbolCandidate[] with columns, receiverTypes, implementers, inferredThrows
↓ call resolution (fills Call.resolved) ← consumes LSP inputs (call-resolution.md §5)
↓ effect propagation
↓ fingerprint
L3 IRThe pass MUST run after every workspace file has been parsed (LSP didOpen needs the full workspace open before position queries return correct types) and before call resolution's LSP tier consumes the enriched receiver-type inputs.
The pass lives in @aburi/core, not in a language plugin. A single LSP session serves many files across the run; a LanguagePlugin's hook API operates one file at a time (every hook takes a single SourceFile, per lang-plugin.md §3), leaving no clean home for session state. This mirrors call-resolution.md §11.4.
3. Inputs and Preconditions
| Input | Origin | Notes |
|---|---|---|
SymbolCandidate[] | language plugin | one entry per Symbol; carries file, line ranges, target strings |
WorkspaceRoot | PluginContext.workspaceRoot (lang-plugin.md §4.7) | absolute path passed to the server as rootUri |
lspConfig | Config.lsp (§12) | server binary, timeouts, concurrency, opt-in flag |
languageServer | spawned per language | one persistent stdio process per enabled language |
Preconditions:
- LSP MUST be opt-in.
lsp.enableddefaults tofalse. Rationale:overview.md§2 rejects LSP-as-mandatory for CI-stability reasons; opt-in enrichment inherits the same concern for its own tier only. - The pass is a no-op for any language that has no configured
lsp.servers.<lang>entry (§12.1), regardless oflsp.enabled. Nothing onLanguageCapabilities(lang-plugin.md§6) governs LSP availability today; a language is "LSP-enrichable" purely by user configuration. - The server
commandMUST resolve onPATHor as an absolute path. If not, the pass triggers per-language fallback (§6.1). - Determinism MUST NOT depend on wall-clock server-response ordering; see §10.
4. LSP Communication Protocol Subset
4.1 Handshake
At scan start:
- Spawn one server process per language for which
lsp.enabledandlsp.servers.<lang>are both configured. - Send
initializewithrootUri = WorkspaceRoot,initializationOptionsfrom config (opaque, forwarded verbatim), and the client capability set{ textDocument: { hover, typeDefinition, implementation, documentSymbol } }. - Wait for the
initializeresponse withininitializeTimeoutMs, then sendinitialized.
At scan end: send shutdown, wait for the response, send exit, then close the pipe. If the process has not exited within 1 second, SIGKILL.
One server process per language covers the whole run. Per-file open/close cycles happen inside a single resident process; the process is never restarted mid-run.
4.2 Request-to-IR-field mapping
Every LSP request the pass issues, the IR field it enriches, and the confidence the enriched value carries:
| LSP request | Position input | Response consumed | IR field enriched | Confidence |
|---|---|---|---|---|
textDocument/documentSymbol | file URI | DocumentSymbol[].range (character offsets) | SourceRange.startColumn, SourceRange.endColumn | n/a (columns are not confidence-scored) |
textDocument/hover | call site of this.<m> / super.<m> / <receiver>.<m> | receiver type text | receiverType(callSite) fed to call-resolution.md §5.2 / §5.3 | high for direct class dispatch, medium for walked hierarchy (call-resolution.md §7.2) |
textDocument/typeDefinition | receiver position of an interface-typed call | destination symbol URI + range | resolves receiver to interface declaration for call-resolution.md §5.3 lookup | medium (interface dispatch) |
textDocument/implementation | interface declaration URI | array of implementer URIs | implementers(interfaceName) for call-resolution.md §5.3 | medium when there is exactly one implementer; unresolved (no promotion) on multi-implementer cases unless a framework plugin hook narrows it |
textDocument/hover on called symbol's declaration | declaration position of foo where a call site foo() was written | throws clauses in the declared signature | append to Signature.inferredThrows (§7.1) | n/a (Signature.inferredThrows is string[] with no per-entry confidence) |
Requests explicitly NOT used by this pass:
callHierarchy/incomingCalls/callHierarchy/outgoingCalls— Tree-sitter already produces the call graph viawalkBody(lang-plugin.md§4.4). Sourcing edges from LSP would duplicate the work and vary across servers. See §14.4.textDocument/references— reference resolution is the untyped tier's responsibility viaImportEdge(lang-plugin.md§4.4 /call-resolution.md§4.4). LSP is not asked to re-answer it.textDocument/definitionon otherwise-unresolved bare call targets —call-resolution.md§5 defines exactly two LSP-tier resolution rules (§5.2 forthis/super, §5.3 for interface-typed receivers). Neither covers a "last-resort go-to-definition" promotion of untyped bare targets. Adding that would require a new subsection incall-resolution.md§5 and is out of scope for this document; the untyped tier'snullis preserved.
4.3 Request batching
- After
didOpenfor a file, the pass issues onedocumentSymbolrequest per file, awaited as a single round-trip. - All hover / typeDefinition / implementation requests for that file's call sites are fanned out with
Promise.allunder a concurrency cap oflsp.servers.<lang>.concurrency(default8). - The pass MUST NOT batch across files. Each file goes
didOpen → drain requests → didCloseas a discrete unit. Rationale: per-file fallback (§6.1) needs a clear boundary, and per-file bounded memory keeps large monorepos tractable.
4.4 Timeouts
| Knob | Default | Rationale |
|---|---|---|
requestTimeoutMs | 500 | Warm-cache hover on typescript-language-server measures under 50 ms; a 10× margin accommodates the first few files while a request queue is warming without letting pathological single requests block the file budget. |
fileBudgetMs | 2000 | A p90 file with ~20 call sites × ~100 ms average round-trip on a cold cache ≈ 2 s. Beyond this the pass falls back per-file rather than waiting. |
initializeTimeoutMs | 10000 | The typescript-language-server handshake on a medium monorepo commonly takes 3–7 s; 10 s absorbs cold-disk starts. |
These numeric defaults are empirical starting points, measured against typescript-language-server on a medium (~500-file) monorepo at design time. Actual performance shifts with server version, language version, and workspace shape, so the defaults are exposed as named lsp.servers.<lang>.* core config fields (§12.1) that can be tuned per project. Revising the defaults themselves is non-breaking under config.md §14.1 (only removing or type-changing a field would be).
Notifications (didOpen, didClose, initialized, exit) are bounded too. They introduce no knob of their own: three of them reuse the configurable budgets above, and exit uses the fixed §4.1 shutdown grace period, which is not configurable and deliberately absent from the table. JSON-RPC treats a notification as fire-and-forget, but the write still awaits the transport, so a clogged stdio pipe stalls it exactly the way it stalls a request. An unbounded didOpen is the load-bearing case: it precedes the file's first request and therefore precedes every fileBudgetMs check, so the per-file budget below could never fire. The mapping:
didOpendraws onfileBudgetMs. A notification that spends the whole budget has left nothing for the enrichment it exists to enable, so the budget is already the correct ceiling. Exceeding it is a per-file fallback (§6.1), the same as any other way of spending the budget.didClosedraws onrequestTimeoutMs. It is a single small write with no enrichment riding on it, and the sooner a stalled one gives up the sooner the next file starts. Its outcome cannot change what the file produced, so it is logged and nothing more — it moves no counter and escalates nothing. A transport broken for good fails the next file'sdidOpeninstead, which is where §6.1 escalation starts.initializeddraws oninitializeTimeoutMs. The handshake is not complete until it is on the wire, so a write that never lands is aninitializefailure and takes the per-language fallback. It gets the full knob rather than what theinitializerequest left over — one operation, one budget, the same wayrequestTimeoutMsapplies to each request individually. A wholly unresponsive server can therefore cost twoinitializeTimeoutMsbefore its language is disabled.exitdraws on the §4.1 1 s shutdown grace period, as do theshutdownrequest that precedes it and the wait before SIGKILL that follows — three sequential steps, so a server that answers none of them delays the pass by at most three grace periods. A stalledexitis ignored; the SIGKILL is what actually guarantees the process goes away, and it too gives up after one further grace period rather than waiting on a process that may never be reapable.
A write addressed to a server already known to have exited fails immediately with server-disconnected instead of being attempted — for notifications as much as for requests. A write onto a dead pipe can resolve and look like success, which would leave the pass treating files as opened on a server that is not there and, because §6.1 escalation counts files, silently reporting them as enriched.
None of these are counted as requests in §7.2 — requestsIssued / requestsTimedOut / requestsFailed describe requests only. A didOpen that fails shows up as filesFellBack.
5. What Gets Enriched
Viewed from the IR side, the pass writes to exactly the following fields. Nothing else is touched.
| IR field | Untyped-tier value | LSP-tier value | Confidence transition |
|---|---|---|---|
SourceRange.startColumn / endColumn | null (hard-coded in the current TS extractor) | 1-based column from documentSymbol | no confidence field |
Call.resolved for this.* / super.* | null | Symbol id when class hierarchy is resolvable | null → high (direct) or medium (walked hierarchy) |
Call.resolved for interface-typed receivers | null | single implementer's Symbol id | null → medium |
CallEdge.confidence for calls already resolved by the untyped tier | untyped-tier value (call-resolution.md §7.2) | may be lifted per call-resolution.md §7.2 | monotone upward only |
Signature.inferredThrows (optional field, §7.1) | absent from the JSON | array of throws inferred from called signatures' declared throws | recorded on the Signature; MUST NOT be merged into Signature.throws |
Invariants across the table:
- The pass MUST NOT overwrite an already non-
nullCall.resolved. This mirrorscall-resolution.md§5.4. - The pass MUST NOT lower
CallEdge.confidence. - The pass MUST NOT emit fields not listed here.
- The pass MUST NOT request a hint for a
this.*/super.*target of more than two segments. It locates the callee by searching the source line for<receiver>.<method>, which forthis.emitter.emitisthis.emitand matches insidethis.emitter— so the server answers about the property while the pass still believes it asked about the method, and the hint it files is well-formed, correctly keyed, and names a callee the call site never reaches. Such a call keeps thenulland thedynamicdiagnostic (call-resolution.md§8.1 rule 2) it has with LSP off. Lifting this needs a locator that can address a whole receiver chain, not a check on the hint.
6. Fallback Semantics
The pass degrades gracefully at three progressively larger granularities.
6.1 Fallback tiers
Per-request fallback: a single LSP request errors or exceeds
requestTimeoutMs. The specific enrichment for that request is skipped; the affected IR field retains its untyped-tier value. Sibling requests for the same file are unaffected.Per-file fallback:
didOpenfails — including a write that exceeds its §4.4 bound or is addressed to a server already known to have exited — orfileBudgetMsis exceeded for the file, or three consecutive requests hit per-request fallback. The pass sendsdidClose, counts the file instats.lspEnrichment.filesFellBack(§7.2), keeps every untyped-tier value in that file, and moves to the next file.Per-language fallback:
initializefails, or five consecutive files hit per-file fallback for the same language, or anything thrown while enriching that language reaches the language boundary. The pass sendsshutdown/exitto that language's server, disables LSP for that language for the remainder of the run, and emits one CLI warning.The third condition is the catch-all, and it is a fallback rather than a fault because this pass is optional: everything it can lose is the typed-tier values for one language, and every one of those has an untyped-tier value already written. A throw is not a licence to lose the Document. What that language enriched before the throw is kept, per §6.2's
SourceRangerule.
6.2 IR degradation rules
Under any fallback:
SourceRange.startColumn/endColumnremainnull— the value the Tree-sitter tier wrote, not an absent key. They are Class A perir-schema.md§1.1, so a consumer cannot tell a fallback apart from a scan that never ran this pass by looking at key presence;stats.lspEnrichment(§7.2) is where that distinction lives.- Columns this pass had already written when the fallback fired are kept. "Remain
null" is about the columns the fallback prevented, not about the ones it arrived too late to prevent: a per-file fallback does not reach back into the files before it, and a per-language one does not reach back into the files that language already enriched. Rewriting them tonullwould be the fallback lowering a value the pass had earned — the same thing theCall.resolvedrule below forbids, and for the same reason. It also would not be reproducible: which files a language enriched before it failed is a property of the input, but re-nulling them makes the Document depend on where in the file list the failure landed. Call.resolvedremains at whatever the untyped tier produced (call-resolution.md§4). LSP fallback MUST NOT lower a resolved value back tonull.CallEdge.confidenceremains at the untyped tier's value.Signature.inferredThrowsis omitted entirely from the JSON when this pass could not compute it. It is never emitted as an empty array to signal "we tried and found none". Class B perir-schema.md§1.1 — the contrasting case to the columns above, on the same Symbol.- No error appears in the IR document itself; degradation is bookkept in
stats.lspEnrichment(§7.2).
6.3 Numbered rules (RFC 2119)
- Per-request fallback MUST NOT emit warnings. Rationale: warning per request would flood logs on large workspaces where transient timeouts are expected.
- Per-file fallback MUST record
stats.lspEnrichment.filesFellBack += 1. - Per-language fallback MUST append the language id to
stats.lspEnrichment.languagesDisabled[]and MUST emit exactly one CLI warning attributable to the fallback itself. Rationale: the count is over the lines that say a language was given up on, so that a reader can tell one disabled language from two. Rule 7's shutdown warning describes what happened afterwards and is not one of them. - Any fallback MUST NOT alter
Call.resolvedvalues set by the untyped tier. - Fallback state MUST NOT propagate across
aburi scaninvocations. Every scan starts with a clean per-language enablement. - The pass MUST NOT propagate an exception to its caller. Rationale: a scan that reached this point has a complete untyped-tier Document, and an optional pass is not a reason to lose it.
- A language whose server was started MUST be sent
shutdownexactly once on every exit from that language — a clean pass, a reported failure, and a thrown one alike. Rationale: the server is a child process, so an exit that skips it leaves the process running for the rest of the run and past it. - A
shutdownthat fails or does not answer within its bound MUST be warned about rather than propagated, and that warning is not counted by rule 3. Rationale: it runs where the pass is often already unwinding, so propagating it would replace the diagnostic the reader needs — but a server that may still be running is the one thing nothing else in the run would report. - The pass MUST NOT write to the Document after it has returned. Rationale: a request still in flight when the pass gives up on a file would otherwise land in Symbols the caller is already holding, which is §10.6 rather than untidiness.
7. Contract / Output Shape
7.1 Schema extensions (non-breaking per ir-schema.md §15.2)
Status: shipped. Both
Signature.inferredThrowsand theSourceRangecolumn population are present inschema/aburi.ir.v1.jsonandpackages/types/src/generated/ir.ts; this section documents the landed shape rather than proposing one.
SourceRange.startColumn / SourceRange.endColumn: no schema change. Both fields already exist in aburi.ir.v1.json as ["integer", "null"], Class A per ir-schema.md §1.1 — the Tree-sitter tier writes them as null and this pass overwrites them with 1-based integers. Neither tier ever removes the keys.
Signature.inferredThrows: string[]: an optional field, Class B per ir-schema.md §1.1 — absent unless this pass inferred at least one throw.
{
"inputs": [{ "name": "amount", "type": "Money" }],
"outputs": ["Invoice"],
"throws": ["CreditLimitExceeded"], // unchanged: explicit throws + @throws
"inferredThrows": ["NetworkError"], // NEW: appears only when LSP filled it
"async": true,
"generator": false,
"typeParameters": []
}Critical property: inferredThrows is deliberately excluded from the api fingerprint input (§8). It is a distinct field precisely so LSP-vs-no-LSP scans produce byte-identical api fingerprints.
7.2 Stats extension (non-fingerprinted)
{
"stats": {
"lspEnrichment": {
"enabled": true,
"filesEnriched": 412,
"filesFellBack": 3,
"requestsIssued": 5170,
"requestsTimedOut": 12,
"requestsFailed": 4,
"languagesDisabled": [],
"hintsProduced": 340,
"hintsConsumed": 331,
"hintsRejected": {
"unparseableHover": 18,
"ownerClassNotFound": 4,
"memberNotFound": 2,
"kindMismatch": 0,
"targetDropped": 9
}
}
}
}Why the hint counters exist. The seven counters above them describe requests, not answers. A request that comes back on time, with a body this pass cannot read a callee out of, is a healthy row in every one of them: it counts in requestsIssued, in neither failure counter, it resets the consecutive-failure counter in §6.1, and its file still lands in filesEnriched. So requestsIssued: 5170, requestsFailed: 0 describes a run that resolved 331 extra call sites and a run that resolved none, and nothing in the document tells the two apart. The hint counters are that distinction, and they are the only place it is recorded — §6.2 keeps errors out of the IR, and a hint the pass declined to write leaves no other trace anywhere.
The five buckets are one per place a hint can be lost, and they split across the two passes that can lose one:
| Counter | Written by | Meaning |
|---|---|---|
hintsProduced | enrichment (§5) | Hovers read all the way to a callee Symbol. Equal to the number of hints handed to the resolver except where two identical call sites share a key (§10.3): both are counted, and the first of them to be applied keeps the key. |
hintsRejected.unparseableHover | enrichment | The hover answered with no text payload the pass could read. A server correctly answering the specified null for a position it has nothing to say about lands here too, so a systematic mistake in which position the pass hovers surfaces in this bucket rather than in a failure counter. |
hintsRejected.ownerClassNotFound | enrichment | Text was read, but no owner class name appears in it, or the name it carries is not a class in the Symbol table. These are one bucket and not two — a hover-parser gap and a scan-coverage gap — because the outcome per call site is the same and the hover text that separates them is not in the IR to split them by. |
hintsRejected.memberNotFound | enrichment | The owner class is in the Symbol table and the member is not — usually a method inherited from a dependency the scan never read. |
hintsConsumed | resolver (call-resolution.md §5.2) | Call sites the LSP tier turned into an edge. |
hintsRejected.kindMismatch | resolver | A hint was found at the call site's key, but its receiver kind is not the one the call site writes. The key already carries the target (§10.1), so this is the check that holds for a receiverHints map a caller assembled by hand: the resolver declines rather than emit an edge the hover never justified. |
hintsRejected.targetDropped | resolver | The hint named a Symbol dropped by a Category B/C rule, whose body is empty and whose fingerprints are zeroed. |
Precedence. A hint can fail both resolver checks at once. The receiver kind is checked first and the buckets are exclusive, so such a hint counts as kindMismatch alone. The order is part of the contract for the same reason call-resolution.md §8.1's bucket precedence is: without it the same input can be bucketed two ways, and §10's byte-identical guarantee does not hold across implementations.
Two sums hold inside the pipeline, and the document does not let a reader check either. They are stated because they are what the counters mean, not as an integrity invariant — unlike stats.callResolution, whose sum is checked (invariant #15), because totalCalls can be recomputed from symbols[]. Neither right-hand side here can be:
- Producer:
hintsProduced + unparseableHover + ownerClassNotFound + memberNotFound= the hover requests that came back without a §6.1 failure. That count is not in the IR:requestsIssuedalso carries onedocumentSymbolper enriched file, so it cannot be reduced to hovers by subtracting the two failure counters. One case is carved out of the identity as well — a job whose caller Symbol has left the table between building the job and applying its answer is not counted anywhere, being an internal inconsistency rather than something the server did. - Consumer:
hintsConsumed + kindMismatch + targetDropped= the call sites that found a hint at their key. That count is recorded nowhere at all. Only calls the untyped tiers all missed reach the LSP tier (§5.4), so a hint the untyped tier made unnecessary is neither consumed nor rejected, and ahintsProducedwell above the consumer sum is the ordinary shape of a healthy scan rather than a fault. Both counters are call sites rather than distinct hints, which two identical call sites make visible: they share a key, so the one hint standing there is consumed twice.
Making the producer sum checkable would mean emitting the hover count as its own field and adding an integrity invariant over it. That is a reasonable follow-up; it is not done here, because it would leave the consumer half still unverifiable and the asymmetry would read as an oversight rather than a decision. LE25..LE28 hold both sums from the test side instead.
hintsConsumed: 0 with a non-zero hintsProduced and empty rejection buckets therefore says the untyped tier got there first; hintsConsumed: 0 with the buckets carrying the whole of hintsProduced says the typed tier ran and bought nothing.
All three counters are Class B per ir-schema.md §1.1 and are written whenever stats.lspEnrichment itself is, hintsRejected with all five buckets present and zeroed rather than omitted. Their absence means the document predates them.
Stats live outside the fingerprint hash inputs (fingerprint.md §3.1, §4.1, §5.1 — none of them list stats.*).
8. Interaction with Fingerprint / Diff
This pass writes to a strictly bounded set of IR fields (§5, §7.1). Whether enabling LSP changes any given fingerprint therefore reduces to: does the pass write to a field that enters that fingerprint's hash input, either directly, or indirectly through a downstream pass?
apifingerprint (fingerprint.md§3.1) inputssignature.inputs,signature.outputs,signature.throws,signature.typeParameters, and other explicit-authoring signals. LSP MUST NOT write to any of these.inferredThrowsis a distinct field precisely to preserve this. No downstream pass reads LSP output and mirrors it intoapiinput.syntaxfingerprint (fingerprint.md§5.1) inputs the language plugin's normalized AST string. LSP never rewrites this, and no downstream pass mirrors LSP output into it.logicfingerprint (fingerprint.md§4.1) inputs the rule sequence and the effect sequence. LSP output can change this indirectly. LSP liftsCallEdgecoverage; effect propagation readsCallEdge[]and, pereffect-propagation.md§8, propagated effects are appended toSymbol.effects[]and enter thelogicfingerprint's serialization. A caller in the transitive closure of a symbol that gained adb.writewill therefore have a differentlogicfingerprint underlsp.enabled: true(if the LSP tier resolved an additional edge into that closure) than underlsp.enabled: false. This is the deliberate behavior of the propagation pass —effect-propagation.md§8 rejects "exclude propagated effects fromlogicfingerprint input" explicitly, because that would blind the reviewer to the signal the pass exists to surface. See §14.8 for why we do not fight this.
Theorem (partial LSP fingerprint invariance). For any Symbol S, S.fingerprint.api and S.fingerprint.syntax are byte-identical under lsp.enabled: true and lsp.enabled: false.
Proof. Enumerate the fields this pass writes (§5 and §7.1): SourceRange.startColumn / endColumn, Call.resolved, CallEdge.confidence, Signature.inferredThrows. None appear in the api or syntax fingerprint input list (fingerprint.md §3.1 / §5.1). No downstream pass reads any of these fields and writes into api or syntax inputs. ∎
Non-theorem. S.fingerprint.logic is not guaranteed byte-identical across LSP-on and LSP-off scans of the same source tree. Cases where it differs:
- The untyped tier left
Call.resolved: nullfor a call whose actual callee has adb.write(or any classified effect) in its transitive downstream. LSP lifts the resolution, effect propagation runs, and the caller'sSymbol.effects[]gains a propagated entry. - Symmetric: LSP lifts a resolution that adds a propagated effect to some caller, then in a later scan LSP is disabled and the resolution reverts to
null— that caller'seffects[]loses the propagated entry.
Where logic is guaranteed byte-identical:
- Callees whose transitive out-closure contains no classified effects (their propagated
effects[]is empty in both modes). - Symbols outside the transitive caller closure of any LSP-newly-resolved edge (locality per
effect-propagation.md§10).
Corollary — diff stability requires a stable LSP configuration. Two aburi scan invocations compared by aburi diff MUST run with matching lsp.enabled and matching per-language server availability if the resulting changed / unchanged classifications are to reflect source changes only, not enablement changes. In practice this means: choose one setting for the project (on or off) and use it uniformly across every environment that produces IR intended for time-series comparison. See §13.
9. Diff Implications / Failure Modes
aburi diff is stable under LSP configuration only when both aburi scan runs used matching lsp.enabled and matching effective per-language server availability (§8 Corollary). The overview.md §2 "diff stability" mandate is honored in that regime.
What an LSP-enabled scan produces differently from an LSP-off scan of the same source tree:
- More
Call.resolvedvalues → richer Slice View clusters (slice-view.md§5.1) because moreCallEdgeentries survive the "unresolved calls contribute nothing" filter. - More propagated effects on the transitive callers of any Symbol whose new resolution reached into a classified-effect closure (
effect-propagation.md§8), and hence a differentSymbol.fingerprint.logicfor those callers.
Neither is a change to the IR schema; both are legitimate refinements of the IR's derived views and are the intended payoff of running LSP. What they mean operationally is that flipping lsp.enabled between two scans that are then compared will produce spurious logic changed entries on affected callers. Users who need CI-vs-local mixed configurations must accept that or run both scans with the same setting (§13).
Failure buckets (mirroring call-resolution.md §8.1 style):
| Bucket | Meaning |
|---|---|
lsp-disabled | lsp.enabled: false; no LSP attempt was made |
lsp-server-missing | command did not resolve; per-language fallback fired at initialize |
lsp-initialize-timeout | handshake exceeded initializeTimeoutMs |
lsp-file-budget-exceeded | fileBudgetMs consumed before enrichment finished |
lsp-request-timeout | a specific request exceeded requestTimeoutMs (per-request fallback fired) |
lsp-response-parse-error | server returned malformed JSON-RPC; treated as per-file fallback |
Buckets are counted in stats.lspEnrichment (§7.2) and do not appear in the IR itself.
10. Determinism Guarantees
The pass is a pure function of (SymbolCandidate[], lspConfig, serverResponses). Determinism is at least as strict as call-resolution.md §9.
Concrete rules:
- Every LSP response for a file is captured before any IR field is written — held as issued, against the identity of the job that issued it,
(Symbol id, call-site line, call target). This mirrorscall-resolution.md§5.5; there is no cache that outlives the file. A per-call-site output of the pass — a receiver hint (§5) — is keyed by(file, line, target), never by(file, line): one line holds as many call sites as it has calls, and a hint filed without the target is spent on whichever of them the resolver reaches. The pass declines to request a hint it could not key honestly: athis.*target of more than two segments, where the position searched for is not the position of the callee. - Ambiguity (multi-result
textDocument/implementation, multi-resulttextDocument/typeDefinition) is resolved by lexicographic tiebreak on destination Symbol id. In the multi-implementer case fortextDocument/implementation, the pass does not apply the tiebreak — it leavesCall.resolvedatnull(matchescall-resolution.md§5.3 semantics: pick a single implementer only when there is exactly one, unless a framework hook narrows the set). - Parallel LSP workers (from §4.3 concurrency) answer in nondeterministic wall-clock order but their held responses are consumed in a fixed order — Symbol id ascending, then call-site line ascending, then call target ascending — when writing to
SymbolCandidaterecords. Consumption starts only once every worker for the file has stopped: a write issued from inside a worker takes its order from the server's pace, which is what this rule denies it. Where two entries would write the same call site, the first in that order wins. This rule governs what is written and in which order; which jobs get far enough to be written at all is the per-file budget's question, and §6.1 owns it. - Cold-cache warm-up differences between runs are irrelevant because the pass does not use per-response timing as a signal.
- Silent retries with exponential backoff are prohibited. A request either succeeds within
requestTimeoutMsor triggers per-request fallback. Retry-on-load would make outcomes depend on machine load. - Fallback state (§6.1) is derived deterministically from the cache. Given identical
serverResponses, identical files will fall back and identical files will succeed.
11. Verifiable Properties (Test Criteria)
11.1 Communication protocol — LE1..LE3
- LE1:
initialize→initialized→ open a fixture file → receivedocumentSymbol→ columns populated on the IR for every Symbol in the fixture. - LE2: Server
commandbinary absent → per-language fallback fires at initialize, one CLI warning emitted, IR still produced from the untyped tier. - LE3: Server process is
SIGKILLed mid-scan → per-language fallback fires on the next request, subsequent files use the untyped tier only.
11.2 Enrichment correctness — LE4..LE6
- LE4:
this.foo()in classCwith methodfooin the same file →Call.resolved = C.foo's Symbol id,CallEdge.confidence = high. Matchescall-resolution.mdtest CR16. - LE5: interface-typed receiver with exactly one implementer →
Call.resolved= implementer's method Symbol id,CallEdge.confidence = medium. Matchescall-resolution.mdtest CR19. - LE6: file with no
this.*, no interface-typed receivers, and all calls already resolved by the untyped tier → LSP pass is a no-op on every IR value in that file (onlySourceRangecolumns change).
11.3 Fallback — LE7..LE8, LE19..LE23
- LE7: force per-request timeout for one specific call site's
hover→ that call site'sCall.resolvedstays at the untyped value; sibling call sites in the same file are unaffected;stats.lspEnrichment.requestsTimedOutincreases by 1. - LE8: file exceeds
fileBudgetMsafter half its call sites are enriched → the enriched half keeps LSP values, the unenriched half keeps untyped values,stats.lspEnrichment.filesFellBack += 1; the next file proceeds normally. - LE19 (notification writes are bounded, on both sides): a transport whose notification write never settles →
didOpenanddidCloseare still unresolved at one tick before their §4.4 bound and resolve with the timeout sentinel at it,initializereturns a failure wheninitializedcannot be written, andshutdowncompletes within one grace period whenexitcannot be written. A write that rejects — or that throws synchronously, asvscode-jsonrpcdoes on a closed connection — is reported, not raised. - LE20 (a stalled
didOpenis a per-file fallback): thedidOpenwrite for one file exceedsfileBudgetMs→ that file counts infilesFellBack, no request is issued against it,didCloseis still sent, and the next file is enriched normally. Holds both when the write fails and when it merely returns having spent the budget; a write that lands exactly on the budget is spending it, not exceeding it, and the file proceeds. - LE21 (a stalled
didCloseis not): thedidClosewrite for an otherwise healthy file fails → the file still counts infilesEnriched,filesFellBackdoes not increase, and the enrichment it earned is kept. - LE22 (a dead server is never written to): once the server process has exited,
didOpen/didClose/ any request reportserver-disconnectedwithout touching the transport. Five consecutive files failing that way disable the language, so a crash mid-scan is reported rather than absorbed as five silently "enriched" files. - LE23 (notifications do not move request counters): a
didOpenordidClosethat fails leavesrequestsIssued/requestsTimedOut/requestsFailedunchanged.
11.4 Partial fingerprint invariance (load-bearing) — LE9..LE12
- LE9: scan a fixture twice — once with
lsp.enabled: false, once withlsp.enabled: trueand a healthy server — and assert thatS.fingerprint.apiandS.fingerprint.syntaxare byte-identical for every SymbolS.S.fingerprint.logicis NOT asserted equal — see LE11 for its behavior. - LE10: identical to LE9 but arrange per-file fallback for half the files (e.g. inject request errors).
apiandsyntaxfingerprints MUST still be byte-identical. - LE11: same LSP-off vs LSP-on comparison as LE9, but the fixture contains at least one call where the untyped tier leaves
Call.resolved: nulland whose actual callee has adb.writein its transitive downstream. The transitive callers of that callee MUST have a differentlogicfingerprint between the two runs (matchingeffect-propagation.md§11.1 "propagation is monotone in resolved edges"). Symbols outside that transitive closure MUST have byte-identicallogicfingerprints. - LE12: LSP-off vs LSP-on comparison of
signature.throws— MUST be byte-identical for every Symbol (LSP-inferred throws land insignature.inferredThrows, never insignature.throws; guards §14.2).
11.5 Determinism — LE13..LE15, LE24
- LE13: reorder file processing (single-threaded vs concurrent workers) → byte-identical IR.
- LE14: language server returns implementers in reverse order between two runs → byte-identical
Call.resolvedvalues (tiebreak in §10.2). - LE15: identical scan run twice back-to-back on the same fixture → byte-identical IR including
stats.lspEnrichmentcounts. - LE24 (§10.1's key shape): two
this.*calls on one line (this.foo(this.baz())), with the server answering one of the two hovers slowly → each call resolves to its own callee, and inverting which hover is the slow one between runs changes nothing in the IR. A hint keyed without the target collapses the two into one and fails this. It does not exercise §10.3's consumption order: once the key carries the target, distinct call sites write distinct entries andinferredThrowsmerges through a sorted set, so applying responses from inside their workers produces the same IR. §10.3 is a structural guarantee here, not a behaviour any input distinguishes.
11.6 Behavioral guards — LE16..LE18
- LE16 (
CallEdge.confidencemonotone): for any Symbol whose LSP-offCallEdge.confidencefor a given edge isC_untyped, the LSP-on valueC_lspMUST satisfyC_lsp ≥ C_untypedon thehigh > medium > lowlattice. The pass MUST NEVER lower a confidence. - LE17 (
inferredThrowsomit-vs-empty): for a Symbol whose LSPhoveron called declarations returned no throws (either no calls declared throws, or LSP fell back), the emittedSignatureJSON MUST NOT contain aninferredThrowskey at all (per §6.2 / §7.1). Assert with a JSON-key existence check, not an array-length check. - LE18 (no silent retry): inject a request that fails with a transient error at time
tand succeeds at timet + Δ. The pass MUST NOT reissue that request within the same scan; the field stays at the untyped-tier value andstats.lspEnrichment.requestsTimedOut(or the appropriate bucket) increments by 1.
11.7 Hint observability — LE25..LE28
- LE25 (a hover that answers nothing is counted, not absorbed): a
hoverthat resolves with no readable text →hintsRejected.unparseableHoverincrements by 1,hintsProduceddoes not move, and the file still counts infilesEnrichedwithrequestsFailed/requestsTimedOutat 0 — the counter is the only thing separating this run from one that produced a hint. - LE26 (a hover naming what the Symbol table does not have is bucketed by which half is missing): hover text carrying no owner class name, or one naming a class no Symbol carries →
hintsRejected.ownerClassNotFoundincrements by 1; hover text naming a class the table has and a member it does not →hintsRejected.memberNotFoundincrements by 1. Neither moveshintsProduced. - LE27 (a hint the resolver declines is counted, not lost): a hand-built hint whose receiver kind is not the one its call site writes →
hintsRejected.kindMismatchincrements by 1; a hint naming a dropped Symbol →hintsRejected.targetDroppedincrements by 1. In both cases the call staysresolved: null, is bucketed into thecall-resolution.md§8.1 diagnostics like any other miss, andhintsConsumeddoes not move. - LE28 (an all-rejected scan says so): a scan in which every produced hint is refused reports
hintsConsumed: 0with the rejection buckets accounting for every hover, and reports it identically on a rerun (§10, LE15).
12. Config Surface
12.1 JSON
Status: shipped. The
lspobject is present inschema/aburi.config.v1.json, so this section documents the landed shape rather than proposing one.config.mddoes not yet carry it — until it does, this section is the config reference forlsp.
{
"lsp": {
"enabled": false,
"servers": {
"typescript": {
"command": "typescript-language-server",
"args": ["--stdio"],
"initializeTimeoutMs": 10000,
"requestTimeoutMs": 500,
"fileBudgetMs": 2000,
"concurrency": 8,
"initializationOptions": {}
}
}
}
}12.2 Field definitions
lsp.enabled(bool, defaultfalse): master switch.falseshort-circuits the pass to a no-op regardless ofservers.lsp.servers.<language-id>(object): one entry per language short-form id (typescript,python,go, …), keyed identically to the language-plugin id convention used inir-schema.md§3.lsp.servers.<lang>.command(string, required when the entry is present): absolute path or PATH-resolvable binary.lsp.servers.<lang>.args(string[], default[]).lsp.servers.<lang>.initializeTimeoutMs/requestTimeoutMs/fileBudgetMs/concurrency: the knobs specified in §4.3 / §4.4.lsp.servers.<lang>.initializationOptions(opaque object): forwarded verbatim to the server'sinitializerequest; contents are server-specific and outside Aburi's compatibility scope (config.md§5.4).
12.3 CLI override
--lsp / --no-lsp on aburi scan map to lsp.enabled = true / false, following config.md §11 boolean-flag convention. All other knobs are config-file only.
12.4 Autodetect
aburi init autodetects a candidate command per configured language plugin by probing PATH (for TypeScript: typescript-language-server). Regardless of what it detects, the emitted config sets lsp.enabled: false. Users MUST flip the master switch consciously.
13. CI Stance
Default off. Uniform across every environment that produces IR intended for time-series comparison.
Rationale is two-layered:
overview.md§2 rejects LSP-as-mandatory with "unstable in CI; large per-language variance". Making the pass opt-in preserves the tree-sitter tier as the CI-stable baseline. Off-by-default respects that.- §8 established that
logicfingerprint is not invariant underlsp.enabledtoggles. Consequently, mixinglsp.enabled: truein one environment withlsp.enabled: falsein another will produce spuriouschangedentries inaburi difffor callers in any LSP-newly-resolved effect closure. A project MUST pick one setting (on or off) and apply it uniformly to every scan whose IR feeds a time-series comparison, or else accept those spurious diffs as noise.
Requirements:
@aburi/github-actionMUST NOT setlsp.enabled: trueby default. A project that opts in for a workflow MUST also opt in for every developer-machine scan whose output is compared against CI's, or accept diff noise.aburi initMUST NOT enable LSP even when it successfully autodetects a server binary (§12.4).- Documentation MUST warn that mixed configurations across environments produce non-source-driven
logic changedentries.
The api and syntax fingerprints ARE byte-stable across LSP configurations (§8 Theorem). CI gates keyed only on --fail-on categories that read api or syntax deltas (e.g., "any api-breaking change") therefore fire identically regardless of LSP setting. Gates keyed on logic deltas do not.
14. Design Decisions
14.1 Why LSP enrichment lives in @aburi/core, not the language plugin
One LSP session serves many files across the whole run; a LanguagePlugin's hook API operates one file at a time (every hook takes a single SourceFile, per lang-plugin.md §3). Session state — the initialized server, the open-file set, the request cache — has no clean home inside a per-file hook. Placing the pass in @aburi/core mirrors call-resolution.md §11.4's rationale for the resolver.
14.2 Why inferredThrows is a distinct field from throws
Merging LSP-inferred throws into Signature.throws would make the api fingerprint depend on whether LSP was enabled (fingerprint.md §3.1 hashes signature.throws). That is the one fingerprint layer we CAN keep byte-stable under LSP toggles (§8 Theorem), and mixing inferred and explicit throws into a single hashed field would forfeit it. A separate, non-hashed field preserves the api invariance we have — the one gate consumers rely on to say "no API-breaking change here" without regard to LSP configuration — while still exposing inferred information to aburi explain and other read-side consumers.
14.3 Why default-off, globally
LSP introduces a runtime dependency on an external process, its version, and its cache state. Reproducibility MUST NOT depend on such state. Off-by-default in every environment (CI and local) forces users to opt in per-machine, which surfaces the trade-off clearly. Local developers who want richer aburi explain output opt in; CI stays deterministic.
14.4 Why no callHierarchy/*
Tree-sitter's walkBody already produces the outgoing call list per Symbol (lang-plugin.md §4.4). Asking the server for the same edges would duplicate work, invite discrepancies (Aburi would have to reconcile two sources for the same fact), and vary widely across servers (Pyright, typescript-language-server, and gopls all have different call-hierarchy behaviors). Aburi keeps a single source of truth for call sites.
14.5 Why per-file boundaries for fallback
A single pathological file (large generated .ts, refactor-in-progress with malformed types, unresolved d.ts include chains) should not poison the whole run. A per-request cap alone would let a single bad file drain a total-scan budget. A per-file cap alone would penalize the whole file for one slow request. Two independent circuit breakers (per-request + per-file, with per-language as the last line of defense) contain damage at each granularity.
14.6 Why 500 ms / 2000 ms defaults
Empirically derived from typescript-language-server on medium monorepos: warm hover < 50 ms, p90 file ~2 s (§4.4). The defaults trade some enrichment on cold starts (where request timeouts fire) for predictable file-level throughput. The knobs are named lsp.servers.<lang>.* core fields (§12.1), not pluginOptions — timeouts are not server-specific opaque configuration, they are Aburi-level circuit-breaker settings — but they are still user-tunable per project, and revising the defaults themselves is non-breaking under config.md §14.1.
14.7 Why lsp.servers is keyed by language short-form id
One LSP server per language, not per plugin manifest. A hypothetical @aburi/lang-python-experimental and @aburi/lang-python share the same Pyright process. Keying by the short-form language id (typescript, python, go) matches the same convention used for Symbol id language prefixes (ir-schema.md §3), keeping mental overhead down.
14.8 Why we do not fight logic fingerprint non-invariance
We could have introduced a "propagated-from-LSP-only" bit on each Symbol.effects[] entry and excluded such entries from the logic fingerprint. That would restore full LSP invariance at the cost of hiding real callee-effect changes from the reviewer whenever the resolution that surfaced them happened to require LSP. The pass exists to enrich the graph; the propagation pass exists to surface effect changes along the enriched graph; excluding LSP-derived enrichments from the fingerprint would defeat both. effect-propagation.md §8 makes the parallel argument for propagated effects generally. We accept the constraint (§13: use a uniform LSP setting for time-series comparison) as the honest price of the signal.