@sackville-mcp/lsp 0.0.1-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1247 @@
1
+ import { MessageConnection } from "vscode-jsonrpc/node.js";
2
+
3
+ //#region src/encoding.d.ts
4
+ /**
5
+ * Position-encoding conversion — the single highest-correctness-risk corner of the LSP
6
+ * bridge (ADR 0011), and the reason this is the first slice. An LSP `Position.character`
7
+ * is a 0-based offset measured in **code units of the negotiated encoding**, NOT a column.
8
+ * JS strings are UTF-16, so a naive `col - 1` passes every ASCII test and then silently
9
+ * points at the WRONG symbol the moment a line contains a non-BMP character (emoji, CJK,
10
+ * combining marks) under a server that negotiated UTF-8. That is the worst failure for an
11
+ * agent tool — plausible, wrong, and silent — so the conversion is a pure, separately
12
+ * unit-tested function exercised against non-BMP fixtures here, before any server exists.
13
+ *
14
+ * Human columns are 1-based and count **Unicode code points** (what a person counts);
15
+ * LSP characters are 0-based code-unit offsets in the negotiated encoding.
16
+ */
17
+ type PositionEncoding = 'utf-8' | 'utf-16' | 'utf-32';
18
+ /**
19
+ * The encodings we advertise to the server, in preference order. UTF-16 is first
20
+ * deliberately: it is the JS-native, best-tested path (a server honouring our preference
21
+ * picks the one we exercise most). We still implement all three for servers that only
22
+ * speak UTF-8 (e.g. clangd).
23
+ */
24
+ declare const PREFERRED_ENCODINGS: readonly PositionEncoding[];
25
+ /**
26
+ * Resolve the encoding to use from the server's reported `positionEncoding`. Absent ⇒ the
27
+ * spec default, UTF-16. **Present but unsupported ⇒ throw** — never silently default, or
28
+ * we would do offset math in the wrong unit and return wrong locations.
29
+ */
30
+ declare function resolvePositionEncoding(reported: string | undefined): PositionEncoding;
31
+ /**
32
+ * Convert a 1-based human column (counting code points) on `lineText` to a 0-based LSP
33
+ * `character` offset in `encoding`. A column past the line end clamps to the line length.
34
+ */
35
+ declare function toLspCharacter(lineText: string, humanColumn: number, encoding: PositionEncoding): number;
36
+ /**
37
+ * Inverse of {@link toLspCharacter}: map a 0-based LSP `character` offset back to a 1-based
38
+ * human code-point column. An offset landing inside a multi-unit code point clamps to that
39
+ * code point's start; an offset past the line end clamps to one past the last column.
40
+ */
41
+ declare function fromLspCharacter(lineText: string, lspCharacter: number, encoding: PositionEncoding): number;
42
+ interface LspPositionParts {
43
+ line: number;
44
+ character: number;
45
+ }
46
+ /**
47
+ * Convert a human 1-based line:column over the full source `text` to a 0-based LSP
48
+ * `Position` in `encoding`. Lines are split on LF/CR/CRLF WITHOUT normalizing the document
49
+ * (the text we send the server is its source of truth); a leading BOM is stripped before
50
+ * line-1 column math.
51
+ */
52
+ declare function toLspPosition(text: string, humanLine: number, humanColumn: number, encoding: PositionEncoding): LspPositionParts;
53
+ interface HumanPosition {
54
+ line: number;
55
+ column: number;
56
+ }
57
+ /** Inverse of {@link toLspPosition}: map an LSP `Position` back to human 1-based line:column. */
58
+ declare function fromLspPosition(text: string, position: LspPositionParts, encoding: PositionEncoding): HumanPosition;
59
+ //#endregion
60
+ //#region src/normalize.d.ts
61
+ /**
62
+ * Pure LSP-result normalizers (ADR 0011, slice 1). LSP responses are polymorphic in ways
63
+ * that silently corrupt an agent-facing tool if mishandled — these reducers collapse each
64
+ * shape to one Sackville form, with no I/O. The traps the adversarial pass flagged:
65
+ *
66
+ * - **`Location` vs `LocationLink`.** definition/typeDefinition return
67
+ * `Location | Location[] | LocationLink[] | null`; `LocationLink` uses `targetUri` /
68
+ * `targetSelectionRange` — DIFFERENT field names than `Location`'s `uri`/`range`. Reading
69
+ * `.uri`/`.range` off a link yields `undefined` → silently empty navigation.
70
+ * - **`DocumentSymbol` vs `SymbolInformation`.** documentSymbol returns hierarchical
71
+ * `DocumentSymbol[]` (has `children`/`selectionRange`) OR flat `SymbolInformation[]` (has
72
+ * `location`) — two incompatible shapes under one method.
73
+ * - **Hover contents** can be `MarkupContent | MarkedString | MarkedString[]`.
74
+ *
75
+ * Ranges are kept in LSP 0-based form here; mapping them back to human 1-based line:column
76
+ * needs the file text + the negotiated encoding and lives in `encoding.ts`
77
+ * (`fromLspPosition`), applied at the surface.
78
+ */
79
+ interface LspPosition {
80
+ line: number;
81
+ character: number;
82
+ }
83
+ interface LspRange {
84
+ start: LspPosition;
85
+ end: LspPosition;
86
+ }
87
+ interface Location {
88
+ uri: string;
89
+ range: LspRange;
90
+ }
91
+ interface LocationLink {
92
+ targetUri: string;
93
+ targetRange: LspRange;
94
+ targetSelectionRange: LspRange;
95
+ originSelectionRange?: LspRange;
96
+ }
97
+ interface NormalizedLocation {
98
+ uri: string;
99
+ /** The precise symbol range (LocationLink.targetSelectionRange, or Location.range). */
100
+ range: LspRange;
101
+ /** The full enclosing range, when the server sent a LocationLink. */
102
+ fullRange?: LspRange;
103
+ }
104
+ /** Normalize any definition/references/typeDefinition result shape to a flat location list. */
105
+ declare function normalizeLocations(result: Location | Location[] | LocationLink[] | null | undefined): NormalizedLocation[];
106
+ type MarkedString = string | {
107
+ language: string;
108
+ value: string;
109
+ };
110
+ interface MarkupContent {
111
+ kind: 'plaintext' | 'markdown';
112
+ value: string;
113
+ }
114
+ interface Hover {
115
+ contents: MarkupContent | MarkedString | MarkedString[];
116
+ range?: LspRange;
117
+ }
118
+ interface NormalizedHover {
119
+ value: string;
120
+ range?: LspRange;
121
+ }
122
+ /** Normalize hover contents (MarkupContent | MarkedString | MarkedString[]) to one string. */
123
+ declare function normalizeHover(hover: Hover | null | undefined): NormalizedHover | null;
124
+ declare function symbolKindName(kind: number): string;
125
+ interface DocumentSymbol {
126
+ name: string;
127
+ kind: number;
128
+ detail?: string;
129
+ range: LspRange;
130
+ selectionRange: LspRange;
131
+ children?: DocumentSymbol[];
132
+ }
133
+ interface SymbolInformation {
134
+ name: string;
135
+ kind: number;
136
+ location: Location;
137
+ containerName?: string;
138
+ }
139
+ interface NormalizedSymbol {
140
+ name: string;
141
+ kind: number;
142
+ kindName: string;
143
+ detail?: string;
144
+ range: LspRange;
145
+ children?: NormalizedSymbol[];
146
+ /** Set only for flat SymbolInformation results. */
147
+ container?: string;
148
+ }
149
+ /** Normalize documentSymbol's dual shape (hierarchical DocumentSymbol[] or flat SymbolInformation[]). */
150
+ declare function normalizeDocumentSymbols(result: DocumentSymbol[] | SymbolInformation[] | null | undefined): NormalizedSymbol[];
151
+ /**
152
+ * A raw `workspace/symbol` result member. Two shapes under one method (LSP 3.17):
153
+ * - `SymbolInformation`: `location` is a full `Location` (`{uri, range}`).
154
+ * - `WorkspaceSymbol`: `location` may be a full `Location` OR a uri-only `{uri}` — the server
155
+ * defers the range to a `workspaceSymbol/resolve` round-trip. Reading `.location.range` off
156
+ * the uri-only form yields `undefined`; we surface the uri and omit the range (v1 does not
157
+ * resolve — see fixtures README; the real `typescript-language-server` 5.3.0 sends the full
158
+ * `Location` form).
159
+ */
160
+ interface WorkspaceSymbol {
161
+ name: string;
162
+ kind: number;
163
+ containerName?: string;
164
+ location: Location | {
165
+ uri: string;
166
+ };
167
+ }
168
+ interface NormalizedWorkspaceSymbol {
169
+ name: string;
170
+ kind: number;
171
+ kindName: string;
172
+ uri: string;
173
+ /** Absent when the server returned a uri-only `WorkspaceSymbol` (no range without resolve). */
174
+ range?: LspRange;
175
+ /** The declaring container (class/namespace), when the server reported one. */
176
+ container?: string;
177
+ }
178
+ /** Normalize a `workspace/symbol` result (flat `SymbolInformation[]`/`WorkspaceSymbol[]`). */
179
+ declare function normalizeWorkspaceSymbols(result: WorkspaceSymbol[] | null | undefined): NormalizedWorkspaceSymbol[];
180
+ declare function diagnosticSeverityName(severity: number): string;
181
+ /** A raw LSP `Diagnostic` (the member of a `publishDiagnostics` notification's `diagnostics`). */
182
+ interface Diagnostic {
183
+ range: LspRange;
184
+ message: string;
185
+ severity?: number;
186
+ code?: number | string;
187
+ source?: string;
188
+ tags?: number[];
189
+ relatedInformation?: {
190
+ location: Location;
191
+ message: string;
192
+ }[];
193
+ }
194
+ interface NormalizedRelatedInfo {
195
+ uri: string;
196
+ /** LSP 0-based range (mapped to human coords against its own file at the surface). */
197
+ range: LspRange;
198
+ message: string;
199
+ }
200
+ interface NormalizedDiagnostic {
201
+ /** LSP 0-based range (mapped to human coords in the queried file at the surface). */
202
+ range: LspRange;
203
+ message: string;
204
+ severity?: number;
205
+ /** `Error`/`Warning`/`Information`/`Hint`, when the server set a severity. */
206
+ severityName?: string;
207
+ code?: number | string;
208
+ source?: string;
209
+ /** `Unnecessary`/`Deprecated`, only when non-empty. */
210
+ tags?: string[];
211
+ related?: NormalizedRelatedInfo[];
212
+ }
213
+ /** Normalize a `publishDiagnostics` `diagnostics[]` (push model) — severity/tag names, related info. */
214
+ declare function normalizeDiagnostics(items: Diagnostic[] | null | undefined): NormalizedDiagnostic[];
215
+ /** A raw `CallHierarchyItem` (prepareCallHierarchy result + the node inside incoming/outgoing). */
216
+ interface CallHierarchyItem {
217
+ name: string;
218
+ kind: number;
219
+ detail?: string;
220
+ uri: string;
221
+ range: LspRange;
222
+ selectionRange: LspRange;
223
+ }
224
+ interface NormalizedCallItem {
225
+ name: string;
226
+ kind: number;
227
+ kindName: string;
228
+ detail?: string;
229
+ uri: string;
230
+ range: LspRange;
231
+ selectionRange: LspRange;
232
+ }
233
+ /** One edge of the call hierarchy: the other item + the ranges where the call occurs. */
234
+ interface NormalizedCall {
235
+ item: NormalizedCallItem;
236
+ fromRanges: LspRange[];
237
+ }
238
+ declare function normalizeCallHierarchyItem(item: CallHierarchyItem): NormalizedCallItem;
239
+ declare function normalizeCallHierarchyItems(result: CallHierarchyItem[] | null | undefined): NormalizedCallItem[];
240
+ /** `callHierarchy/incomingCalls` → `{from, fromRanges}[]`; the edge item is the CALLER. */
241
+ declare function normalizeIncomingCalls(result: {
242
+ from: CallHierarchyItem;
243
+ fromRanges: LspRange[];
244
+ }[] | null | undefined): NormalizedCall[];
245
+ /** `callHierarchy/outgoingCalls` → `{to, fromRanges}[]`; the edge item is the CALLEE. */
246
+ declare function normalizeOutgoingCalls(result: {
247
+ to: CallHierarchyItem;
248
+ fromRanges: LspRange[];
249
+ }[] | null | undefined): NormalizedCall[];
250
+ /**
251
+ * The tri-state query outcome (ADR 0011): an empty result is only authoritatively
252
+ * `no_result` when the server is READY; while it is still indexing an empty result is
253
+ * `not_ready` and must never be collapsed into "no definition" (an agent would act on the
254
+ * lie). Readiness is decided by the client (slice 2); this is the pure combinator.
255
+ */
256
+ type QueryStatus = 'ok' | 'not_ready' | 'no_result';
257
+ declare function decideStatus(isEmpty: boolean, ready: boolean): QueryStatus;
258
+ /** A raw LSP `TextEdit` (or `AnnotatedTextEdit`, which adds `annotationId`). */
259
+ interface RawTextEdit {
260
+ range: LspRange;
261
+ newText: string;
262
+ annotationId?: string;
263
+ }
264
+ /** A raw `TextDocumentEdit` documentChanges member: a versioned doc + its edits. */
265
+ interface RawTextDocumentEdit {
266
+ textDocument: {
267
+ uri: string;
268
+ version?: number | null;
269
+ };
270
+ edits: RawTextEdit[];
271
+ }
272
+ /** Resource-operation options (LSP `CreateFileOptions`/`RenameFileOptions`/`DeleteFileOptions`). */
273
+ interface ResourceOpOptions {
274
+ overwrite?: boolean;
275
+ ignoreIfExists?: boolean;
276
+ ignoreIfNotExists?: boolean;
277
+ recursive?: boolean;
278
+ }
279
+ /** A raw file-resource operation documentChanges member (CreateFile/RenameFile/DeleteFile). */
280
+ interface RawResourceOperation {
281
+ kind: 'create' | 'rename' | 'delete';
282
+ uri?: string;
283
+ oldUri?: string;
284
+ newUri?: string;
285
+ options?: ResourceOpOptions;
286
+ }
287
+ interface RawWorkspaceEdit {
288
+ changes?: Record<string, RawTextEdit[]>;
289
+ documentChanges?: (RawTextDocumentEdit | RawResourceOperation)[];
290
+ changeAnnotations?: Record<string, {
291
+ label?: string;
292
+ needsConfirmation?: boolean;
293
+ }>;
294
+ }
295
+ /** One edit on a file, range kept in LSP 0-based form (mapped to human coords at the surface). */
296
+ interface NormalizedFileEdit {
297
+ range: LspRange;
298
+ newText: string;
299
+ /** A `needsConfirmation` change annotation rode this edit — preview-only, excluded from apply. */
300
+ needsConfirmation?: boolean;
301
+ /** The change-annotation label, when one was attached. */
302
+ annotationLabel?: string;
303
+ }
304
+ interface NormalizedFileEdits {
305
+ uri: string;
306
+ edits: NormalizedFileEdit[];
307
+ }
308
+ /** A flagged resource operation — surfaced in the preview (paths relativized at the surface). */
309
+ interface NormalizedResourceOp {
310
+ kind: 'create' | 'rename' | 'delete';
311
+ uris: string[];
312
+ }
313
+ /**
314
+ * One operation in `documentChanges` ORDER (the apply engine executes these in sequence — a
315
+ * "Move to file" is `create`→`edit`(new)→`delete`(old), so order is load-bearing). `files`/
316
+ * `resourceOps` are the order-free buckets kept for preview/back-compat.
317
+ */
318
+ type NormalizedOp = {
319
+ type: 'edit';
320
+ uri: string;
321
+ edits: NormalizedFileEdit[];
322
+ } | {
323
+ type: 'create';
324
+ uri: string;
325
+ options?: ResourceOpOptions;
326
+ } | {
327
+ type: 'rename';
328
+ oldUri: string;
329
+ newUri: string;
330
+ options?: ResourceOpOptions;
331
+ } | {
332
+ type: 'delete';
333
+ uri: string;
334
+ options?: ResourceOpOptions;
335
+ };
336
+ interface NormalizedWorkspaceEdit {
337
+ files: NormalizedFileEdits[];
338
+ resourceOps: NormalizedResourceOp[];
339
+ /** The operations in `documentChanges` order (the apply engine's authority). */
340
+ operations: NormalizedOp[];
341
+ }
342
+ /**
343
+ * Normalize a `WorkspaceEdit` to a uniform `files → edits` list plus a separate, flagged list of
344
+ * resource operations. Handles both shapes (ADR 0011 addendum §2.5):
345
+ * - `documentChanges` takes PRECEDENCE over `changes` when both are present (never merged).
346
+ * - Per-file and per-edit order is preserved.
347
+ * - `CreateFile`/`RenameFile`/`DeleteFile` members are surfaced under `resourceOps`, never
348
+ * translated into a TextEdit (v1 refuses to apply them).
349
+ * - An `AnnotatedTextEdit` is normalized to `{range,newText}`; if its annotation is
350
+ * `needsConfirmation` the edit carries `needsConfirmation: true` + the label (a preview-only
351
+ * signal, excluded from apply) so the server's safety signal is never silently dropped.
352
+ *
353
+ * NOTE: real `typescript-language-server` 5.3.0 returns the legacy `changes` map for an ordinary
354
+ * rename even when the client advertises `documentChanges` (see `test/fixtures/README.md`); the
355
+ * `documentChanges` branch is exercised by a synthesized fixture.
356
+ */
357
+ declare function normalizeWorkspaceEdit(raw: RawWorkspaceEdit | null | undefined): NormalizedWorkspaceEdit;
358
+ /** `textDocument/prepareRename` raw result variants. */
359
+ type RawPrepareRename = LspRange | {
360
+ range: LspRange;
361
+ placeholder?: string;
362
+ } | {
363
+ defaultBehavior: boolean;
364
+ };
365
+ /** Normalized prepareRename outcome — a non-null value means the position IS renameable. */
366
+ interface PrepareRenameOutcome {
367
+ /** The renameable token range, when the server provided one. */
368
+ range?: LspRange;
369
+ /** A suggested placeholder (the existing identifier), when provided. */
370
+ placeholder?: string;
371
+ /** The server signalled default behavior (renameable; it derives the range itself). */
372
+ defaultBehavior?: boolean;
373
+ }
374
+ /**
375
+ * Normalize a `prepareRename` result. `null` ⇒ the position is NOT renameable (the engine maps
376
+ * this to a structured refusal, distinct from a tri-state `no_result`). A bare `Range`,
377
+ * `{range, placeholder}`, and `{defaultBehavior}` all mean renameable — the real
378
+ * `typescript-language-server` 5.3.0 returns a bare `Range` (see `test/fixtures/README.md`).
379
+ */
380
+ declare function normalizePrepareRename(raw: RawPrepareRename | null | undefined): PrepareRenameOutcome | null;
381
+ //#endregion
382
+ //#region src/client.d.ts
383
+ /** An operator-registry server entry: the binary + argv to spawn (structurally separate). */
384
+ interface ServerSpec {
385
+ command: string;
386
+ args: string[];
387
+ initializationOptions?: unknown;
388
+ }
389
+ /** An established connection to a language server, plus how to tear the process down. */
390
+ interface LspConnection {
391
+ connection: MessageConnection;
392
+ /** Hard teardown (SIGKILL + stream close) — last resort after graceful `shutdown`. */
393
+ dispose(): void;
394
+ }
395
+ /** Injected spawn seam — the only place a real process is created (tests inject a fake peer). */
396
+ type ServerSpawn = (spec: ServerSpec) => LspConnection;
397
+ /** Default live spawn: a child process over stdio, framed by the reference transport. */
398
+ declare const defaultServerSpawn: ServerSpawn;
399
+ interface LspClientOptions {
400
+ /** OPERATOR per-request wall-clock cap (ms) — the single authoritative deadline. */
401
+ timeoutMs: number;
402
+ /** Single-attempt mode: no retry/backoff (the gate's deterministic default). */
403
+ noRetry?: boolean;
404
+ /** Injected clock. Default `Date.now`. */
405
+ now?: () => number;
406
+ /** Injected cancellable delay. Default a `setTimeout` promise (the one seam that may). */
407
+ delay?: (ms: number) => Promise<void>;
408
+ /** Base backoff (ms) for the empty-result retry. Default 50. */
409
+ baseBackoffMs?: number;
410
+ /** Max backoff (ms). Default 1000. */
411
+ maxBackoffMs?: number;
412
+ }
413
+ /** Server provenance from `initialize` — `serverInfo`, optional per the protocol. */
414
+ interface ServerInfo {
415
+ name: string;
416
+ version?: string;
417
+ }
418
+ /** A call-hierarchy group: one prepared source item + the calls in the requested direction. */
419
+ interface CallHierarchyGroup {
420
+ source: NormalizedCallItem;
421
+ calls: NormalizedCall[];
422
+ }
423
+ type CallDirection = 'incoming' | 'outgoing';
424
+ /** A navigation result with its tri-state status and version/encoding provenance. */
425
+ interface NavResult<T> {
426
+ status: QueryStatus;
427
+ result: T;
428
+ serverInfo?: ServerInfo;
429
+ encoding: PositionEncoding;
430
+ }
431
+ interface InitializeSummary {
432
+ encoding: PositionEncoding;
433
+ serverInfo?: ServerInfo;
434
+ capabilities: ServerCapabilities;
435
+ }
436
+ /** The subset of `ServerCapabilities` this slice reads (any non-`false`/non-absent ⇒ enabled). */
437
+ type ServerCapabilities = Record<string, unknown>;
438
+ /** Thrown when a navigation is requested against a capability the server did not advertise. */
439
+ declare class LspUnsupportedError extends Error {
440
+ constructor(message: string);
441
+ }
442
+ declare class LspClient {
443
+ private readonly conn;
444
+ private readonly timeoutMs;
445
+ private readonly noRetry;
446
+ private readonly now;
447
+ private readonly delay;
448
+ private readonly baseBackoffMs;
449
+ private readonly maxBackoffMs;
450
+ private listening;
451
+ private _encoding;
452
+ private _serverInfo;
453
+ private _capabilities;
454
+ /** Active indexing work-done-progress tokens — non-empty ⇒ the server is still indexing. */
455
+ private readonly activeProgress;
456
+ /** Resolvers waiting for indexing to DRAIN (the active set to empty); fired on the final `end`. */
457
+ private readonly drainWaiters;
458
+ /**
459
+ * Open documents (open-once, no `didClose` by default). `version` is the per-uri monotonic
460
+ * document version: seeded at 1 by `didOpen`, pre-incremented by each `applyEdited` `didChange`
461
+ * — versions MUST strictly increase or the server ignores the change and keeps stale text.
462
+ */
463
+ private readonly open;
464
+ /**
465
+ * Pushed diagnostics per uri (the PUSH model — `textDocument/publishDiagnostics` is a server
466
+ * notification, not a request; tsserver advertises no `diagnosticProvider`, so pull diagnostics
467
+ * are unavailable). An entry's absence ⇒ the server hasn't published for that uri yet.
468
+ */
469
+ private readonly diagnostics;
470
+ /** Uris freshly `didOpen`ed whose first post-open publish hasn't arrived yet. */
471
+ private readonly awaitingDiagnostics;
472
+ /** Resolvers waiting for the NEXT publish on a uri (keyed); fired by the publish handler. */
473
+ private readonly diagnosticsWaiters;
474
+ constructor(connection: MessageConnection, options: LspClientOptions);
475
+ get encoding(): PositionEncoding;
476
+ get serverInfo(): ServerInfo | undefined;
477
+ get capabilities(): ServerCapabilities;
478
+ /** Indexing is active when at least one `$/progress` work-done token is open. */
479
+ get indexing(): boolean;
480
+ /** Any non-`false`, non-absent `*Provider` value counts as enabled (LSP convention). */
481
+ supports(provider: string): boolean;
482
+ /**
483
+ * `prepareRename` is advertised only by the OBJECT form `renameProvider: {prepareProvider:true}`
484
+ * — the boolean `supports()` helper cannot detect it, so the engine must check this before
485
+ * calling `prepareRename` (bare `renameProvider: true` supports rename but not prepare).
486
+ */
487
+ get supportsPrepareRename(): boolean;
488
+ /**
489
+ * Whether the server accepts `workspace/didChangeWorkspaceFolders` — the gate for the manager's
490
+ * grow-only warm-server reuse. Advertised by `ServerCapabilities.workspace.workspaceFolders`:
491
+ * `supported !== false` AND `changeNotifications` truthy (the spec allows `true` OR a string
492
+ * registration id). rust-analyzer advertises it; the captured tsserver 5.3.0 does NOT, so the
493
+ * manager falls back to spawning a fresh per-group server there.
494
+ */
495
+ get supportsWorkspaceFolderChange(): boolean;
496
+ /**
497
+ * Handshake. Registers the deadlock-safe inbound handlers + the `$/progress` listener
498
+ * BEFORE `listen()`, advertises the preferred encodings, then reads back the negotiated
499
+ * encoding + capabilities + provenance and sends `initialized`.
500
+ */
501
+ initialize(rootUri: string, opts?: {
502
+ initializationOptions?: unknown; /** Multi-root workspace folders. Defaults to the single `[{rootUri, 'root'}]` (ADR 0011 tail). */
503
+ workspaceFolders?: {
504
+ uri: string;
505
+ name: string;
506
+ }[];
507
+ }): Promise<InitializeSummary>;
508
+ /** Register the id-bearing inbound replies (deadlock-safe) + the `$/progress` tracker. */
509
+ private installInboundHandlers;
510
+ /** A promise that resolves the next time indexing drains to empty (an `end` clears the set). */
511
+ private indexingDrain;
512
+ /**
513
+ * Wait until the server is NOT indexing, bounded by `deadline`. Returns true if it settled,
514
+ * false if the deadline elapsed while still indexing. Event-driven (resolves on the `$/progress`
515
+ * `end`), with the injected `delay` as the deadline backstop — so the gate drives it
516
+ * deterministically and a real session blocks no longer than the operator timeout.
517
+ */
518
+ private awaitIndexingSettled;
519
+ /** Open a document full-text once (version 1); subsequent calls just bump the refcount. */
520
+ ensureOpen(uri: string, languageId: string, text: string): void;
521
+ /** Decrement a document's refcount (keeps the entry + version). Does NOT `didClose`. */
522
+ releaseDoc(uri: string): void;
523
+ /**
524
+ * After Sackville writes `newText` to `uri` on disk (write-mode, ADR 0011 addendum), resync the
525
+ * server's in-memory buffer with a **full-text `didChange`** so a later navigation sees
526
+ * post-rename positions (we never `didClose`, so the server still holds the pre-rename text).
527
+ * The version is **pre-incremented** — it must be strictly greater than the last `didOpen`/
528
+ * `didChange` or the server ignores the change. No-op for a uri the server never opened (it
529
+ * re-reads fresh on the next `didOpen`). Full-text (no incremental ranges) — correctness over
530
+ * bytes, and it avoids re-introducing offset math on the server-bound path.
531
+ */
532
+ applyEdited(uri: string, newText: string): void;
533
+ /**
534
+ * A file moved on disk (resource-op `RenameFile`). The open-once/no-`didClose` invariant keys the
535
+ * server buffer by `oldUri`, which now names a non-existent path — a later query would be silently
536
+ * wrong (the worst failure class). MIGRATE the open entry: `didClose(oldUri)` + `didOpen(newUri)`
537
+ * with the moved text, carrying the refcount and the languageId to the new key. NO-OP if `oldUri`
538
+ * was never opened (Sackville opens only the queried file, so the renamed file is usually closed).
539
+ * Run inside the held multi-URI lock so no concurrent query races the key migration.
540
+ */
541
+ didFileRename(oldUri: string, newUri: string, newText: string): void;
542
+ /**
543
+ * A file was deleted on disk (resource-op `DeleteFile`). `didClose` + evict the open entry so the
544
+ * server stops tracking a buffer for a path that no longer exists. NO-OP if it was never opened.
545
+ */
546
+ didFileDelete(uri: string): void;
547
+ /**
548
+ * Notify the server that the workspace-folder set changed (`workspace/didChangeWorkspaceFolders`)
549
+ * — the wire primitive behind the manager's grow-only warm-server reuse. A fire-and-forget
550
+ * notification, so it is ordered on the single connection BEFORE any subsequent request: a query
551
+ * sent right after sees the new folder scope. Capability gating is the caller's job (the manager
552
+ * only calls this when {@link supportsWorkspaceFolderChange}); the folders are operator-allowlisted
553
+ * roots, never agent-supplied paths.
554
+ */
555
+ changeWorkspaceFolders(added: {
556
+ uri: string;
557
+ name: string;
558
+ }[], removed: {
559
+ uri: string;
560
+ name: string;
561
+ }[]): void;
562
+ definition(uri: string, position: {
563
+ line: number;
564
+ character: number;
565
+ }): Promise<NavResult<NormalizedLocation[]>>;
566
+ typeDefinition(uri: string, position: {
567
+ line: number;
568
+ character: number;
569
+ }): Promise<NavResult<NormalizedLocation[]>>;
570
+ references(uri: string, position: {
571
+ line: number;
572
+ character: number;
573
+ }): Promise<NavResult<NormalizedLocation[]>>;
574
+ hover(uri: string, position: {
575
+ line: number;
576
+ character: number;
577
+ }): Promise<NavResult<NormalizedHover | null>>;
578
+ /** Document symbols — the file outline. Position-less (whole document); tri-state like the rest. */
579
+ documentSymbols(uri: string): Promise<NavResult<NormalizedSymbol[]>>;
580
+ /** A promise that resolves on the next `publishDiagnostics` for `uri`. */
581
+ private nextDiagnostics;
582
+ /**
583
+ * Diagnostics for an OPEN document (ADR 0011 staged tail; PUSH model). NOT capability-gated —
584
+ * `textDocument/publishDiagnostics` is a server notification every server may send, and tsserver
585
+ * advertises no `diagnosticProvider` (pull diagnostics are staged). The caller (manager.run) has
586
+ * already `didOpen`ed the file, which triggers the server's publish.
587
+ *
588
+ * Readiness (grounded in the captured timeline — `didOpen` → `$/progress` begin/end → publish
589
+ * ~60ms AFTER the project loads): wait out the project-load `$/progress`, then return the publish
590
+ * once the file's first post-open publish has arrived. An EMPTY publish is a legitimate `ok` (a
591
+ * clean file), never `no_result`. If the project never settles or no publish arrives within the
592
+ * deadline ⇒ `not_ready` (retry), the same honest-tri-state posture as the navigation reads.
593
+ */
594
+ documentDiagnostics(uri: string): Promise<NavResult<NormalizedDiagnostic[]>>;
595
+ /** PUSH model — accumulate the server's `publishDiagnostics` for an open file (see {@link documentDiagnostics}). */
596
+ private pushDiagnostics;
597
+ /**
598
+ * PULL model — `textDocument/diagnostic` (LSP 3.17), capability-gated on `diagnosticProvider`.
599
+ * A request/response (unlike push), so it is deterministic for a single-shot read. Echoes the
600
+ * provider's `identifier` when one was advertised (rust-analyzer requires it). Tri-state, with
601
+ * the diagnostics-specific twist that an EMPTY report is a legitimate `ok` (a clean file), NEVER
602
+ * `no_result` — so this does NOT reuse {@link withRetry} (whose empty ⇒ no_result is wrong here).
603
+ * Readiness: wait out the project-load `$/progress`; if the send (re)starts indexing, re-query the
604
+ * loaded project within the deadline; a soft "not ready" `ResponseError` backs off and retries
605
+ * inside the deadline. Still indexing at the deadline ⇒ `not_ready` (retry).
606
+ */
607
+ private pullDiagnostics;
608
+ /**
609
+ * `workspace/symbol` — project-wide symbol search by name (ADR 0011 staged tail). Position-less
610
+ * and file-less: the query is just a name fragment matched against the whole indexed workspace,
611
+ * so it needs no open document (the project is loaded at `initialize`). Tri-state like the rest —
612
+ * an empty result while the project is still indexing is `not_ready`, never collapsed into
613
+ * "no such symbol" (the cold-load trap the rest of the client already guards). Handles both the
614
+ * flat `SymbolInformation[]` (range present) and the uri-only `WorkspaceSymbol[]` shapes.
615
+ */
616
+ workspaceSymbols(query: string): Promise<NavResult<NormalizedWorkspaceSymbol[]>>;
617
+ /**
618
+ * `textDocument/prepareRename` — the cheap validate-first pre-flight (write-mode, ADR 0011
619
+ * addendum). Tri-state: `null` while indexing ⇒ `not_ready`; `null` while ready ⇒ `no_result`
620
+ * (the engine maps that to a structured "not renameable here" refusal); a non-null outcome ⇒
621
+ * `ok`. Only callable when {@link supportsPrepareRename} (the engine skips it otherwise).
622
+ */
623
+ prepareRename(uri: string, position: {
624
+ line: number;
625
+ character: number;
626
+ }): Promise<NavResult<PrepareRenameOutcome | null>>;
627
+ /**
628
+ * `textDocument/rename` — compute the cross-file `WorkspaceEdit` for renaming the symbol at
629
+ * `position` to `newName`. Capability-gated on `renameProvider`; normalized to the uniform
630
+ * `files`/`resourceOps` shape; tri-state (empty/`null` while indexing ⇒ `not_ready`). This
631
+ * computes only — applying the edit to disk is the gated engine's job (Slice F), never here.
632
+ */
633
+ rename(uri: string, position: {
634
+ line: number;
635
+ character: number;
636
+ }, newName: string): Promise<NavResult<NormalizedWorkspaceEdit>>;
637
+ /**
638
+ * Call hierarchy — a TWO-round-trip protocol. `prepareCallHierarchy` resolves the symbol at
639
+ * `position` to one or more `CallHierarchyItem`s (null vs empty is distinct; overloads yield
640
+ * MANY — we keep them all, never silently the first); then per item we fetch incoming or
641
+ * outgoing calls. Tri-state lives on the PREPARE step (empty-while-indexing ⇒ not_ready). A
642
+ * prepared item with no callers/callees is a legitimate `ok` with empty `calls`. The RAW item
643
+ * is passed back to the calls request (it may carry an opaque `data` field the server needs).
644
+ */
645
+ callHierarchy(uri: string, position: {
646
+ line: number;
647
+ character: number;
648
+ }, direction: CallDirection): Promise<NavResult<CallHierarchyGroup[]>>;
649
+ private navigateLocations;
650
+ /**
651
+ * The tri-state request loop: settle → send → decide, all inside one operator deadline.
652
+ *
653
+ * The load-bearing rule (ADR 0011 addendum — proven by a live `typescript-language-server`
654
+ * capture): **a result returned while the server is still indexing the project is NOT
655
+ * trustworthy** — tsserver answers an early request from a single-file *inferred* project
656
+ * (a non-empty BUT PARTIAL answer — e.g. a cross-file rename that sees only the opened file)
657
+ * and only *then* finishes loading the configured project. So:
658
+ *
659
+ * 1. Before sending, wait out any in-flight indexing (`awaitIndexingSettled`) so we hit the
660
+ * loaded project.
661
+ * 2. After sending, if indexing is active (the send itself triggered the configured-project
662
+ * load), the answer is from the unstable inferred project — loop to settle + re-query.
663
+ * 3. Once the server is settled: a non-empty result is `ok`; an empty result is `no_result`
664
+ * (retried with bounded backoff) — or `not_ready` only if we hit the deadline still indexing.
665
+ *
666
+ * This trades the old "return `not_ready` fast" for "wait for the correct answer within the
667
+ * deadline" — correctness over latency, bounded by the operator timeout.
668
+ */
669
+ private withRetry;
670
+ private wrap;
671
+ /** Graceful teardown: LSP `shutdown` request then the `exit` notification. */
672
+ shutdown(): Promise<void>;
673
+ }
674
+ //#endregion
675
+ //#region src/registry.d.ts
676
+ /**
677
+ * The operator-bound language→server registry (ADR 0011). Safety-critical: the agent supplies
678
+ * only a `language` string and NEVER a binary, argv, or path. The operator binds the registry
679
+ * out-of-band (`SACKVILLE_LSP_SERVERS`, JSON); a language absent from it is refused, never
680
+ * spawned.
681
+ *
682
+ * The registry is **JSON with `command` and `args[]` structurally separate** — deliberately
683
+ * NOT a `lang=cmd args;…` mini-DSL the engine would re-split, because real server commands
684
+ * routinely contain spaces, `=`, and wrapper prefixes (`rustup run stable rust-analyzer`).
685
+ * Splitting a string would corrupt those; an explicit array cannot.
686
+ */
687
+ /** One operator-registered language server: the binary + argv, kept structurally separate. */
688
+ interface ServerRegistryEntry {
689
+ command: string;
690
+ args: string[];
691
+ /** Passed verbatim as the LSP `initialize` `initializationOptions` (hardening flags, etc.). */
692
+ initializationOptions?: unknown;
693
+ /** The `languageId` sent in `didOpen`; defaults to the registry key when omitted. */
694
+ languageId?: string;
695
+ }
696
+ /** The full registry, keyed by the agent-facing `language` string. */
697
+ type ServerRegistry = Record<string, ServerRegistryEntry>;
698
+ /** Thrown on a malformed operator registry or a request for an unbound language. */
699
+ declare class LspRegistryError extends Error {
700
+ constructor(message: string);
701
+ }
702
+ /**
703
+ * Parse + validate the operator JSON registry. Fails loud on anything malformed — an
704
+ * operator misconfiguration must be a clear error, never a silently-empty or
705
+ * partially-parsed registry an agent then queries against.
706
+ */
707
+ declare function parseServerRegistry(json: string): ServerRegistry;
708
+ /** Resolve a language to its registered server, or refuse it (never spawns an unbound server). */
709
+ declare function resolveServer(registry: ServerRegistry, language: string): ServerRegistryEntry;
710
+ //#endregion
711
+ //#region src/manager.d.ts
712
+ /** Thrown when the manager refuses a request (root outside the allowlist, server cap reached). */
713
+ declare class LspManagerError extends Error {
714
+ constructor(message: string);
715
+ }
716
+ interface LanguageServerManagerOptions {
717
+ /** The operator-bound language→server registry (the agent picks only a language). */
718
+ registry: ServerRegistry;
719
+ /** OPERATOR allowlist of project roots a server may be initialized against. */
720
+ allowedRoots: string[];
721
+ /** Per-request wall-clock cap (ms) handed to each `LspClient`. */
722
+ timeoutMs: number;
723
+ /** Injected spawn seam. Default {@link defaultServerSpawn} (real `child_process.spawn`). */
724
+ serverSpawn?: ServerSpawn;
725
+ /** Idle time before a server is eligible for reaping, in ms. Default 15 min (warm servers). */
726
+ idleTtlMs?: number;
727
+ /** Max concurrent servers. Default 8. */
728
+ maxServers?: number;
729
+ /** Grace (ms) between the LSP `shutdown`/`exit` and the hard `dispose()`. Default 2000. */
730
+ shutdownGraceMs?: number;
731
+ /** Single-attempt client mode (the gate's deterministic default; production retries). */
732
+ noRetry?: boolean;
733
+ /** Injected clock. Default `Date.now`. */
734
+ now?: () => number;
735
+ /** Injected delay (the shutdown grace). Default a `setTimeout` promise. */
736
+ delay?: (ms: number) => Promise<void>;
737
+ }
738
+ interface QueryInput {
739
+ /** The agent-facing language id (resolved against the operator registry). */
740
+ language: string;
741
+ /** The primary project root — where the queried file lives; must be in `allowedRoots`. */
742
+ projectRoot: string;
743
+ /** The document URI being queried (`file://…`). */
744
+ uri: string;
745
+ /** The document's full text (sent verbatim in `didOpen`). */
746
+ text: string;
747
+ /**
748
+ * Additional allowlisted roots bound as workspace folders on the SAME server (multi-root, ADR
749
+ * 0011 tail). The server is keyed by the sorted root GROUP, so one server handles the whole
750
+ * group and cross-root navigation resolves. Each must be in `allowedRoots`. Default: none.
751
+ */
752
+ workspaceRoots?: string[];
753
+ }
754
+ /** Provenance for one live server — what `lsp_languages` surfaces (never the command/path). */
755
+ interface ServerDescription {
756
+ language: string;
757
+ projectRoot: string;
758
+ /** The full root group, set only for a multi-root server (length > 1). */
759
+ roots?: string[];
760
+ serverInfo?: {
761
+ name: string;
762
+ version?: string;
763
+ };
764
+ capabilities: Record<string, boolean>;
765
+ }
766
+ declare class LanguageServerManager {
767
+ private readonly registry;
768
+ private readonly allowedRoots;
769
+ private readonly timeoutMs;
770
+ private readonly serverSpawn;
771
+ private readonly idleTtlMs;
772
+ private readonly maxServers;
773
+ private readonly shutdownGraceMs;
774
+ private readonly noRetry;
775
+ private readonly now;
776
+ private readonly delay;
777
+ private readonly servers;
778
+ /** In-flight spawn+initialize, so concurrent first callers share one initialization. */
779
+ private readonly initing;
780
+ private reaper;
781
+ constructor(options: LanguageServerManagerOptions);
782
+ get serverCount(): number;
783
+ /**
784
+ * Run `fn` against the (shared, lazily-spawned) server for `(language, projectRoot)`, under
785
+ * the per-`(server, uri)` mutex. The document is opened once (refcounted in the client) and
786
+ * the server's idle clock is reset on entry and exit; `inFlight` is held for the duration so
787
+ * the reaper cannot tear the server down mid-request.
788
+ */
789
+ run<T>(input: QueryInput, fn: (client: LspClient) => Promise<T>): Promise<T>;
790
+ /**
791
+ * Like {@link run}, but holds the per-`(server, uri)` lock for MANY uris at once — the
792
+ * primitive a multi-file write needs (Slice F′). Locks are acquired in a deterministic SORTED
793
+ * order so two concurrent multi-file renames can never deadlock (no lock-ordering cycle), and
794
+ * are all held across the whole `fn` (the stage+commit+`didChange` window). Does NOT `didOpen`
795
+ * any document — the caller opened what it needs during the compute phase (open-once/refcount).
796
+ */
797
+ runWithUris<T>(input: {
798
+ language: string;
799
+ projectRoot: string;
800
+ uris: string[];
801
+ workspaceRoots?: string[];
802
+ }, fn: (client: LspClient) => Promise<T>): Promise<T>;
803
+ /**
804
+ * The resolved, sorted, de-duplicated root group for a query — the primary `projectRoot` plus any
805
+ * `workspaceRoots`. EVERY member is `assertRootAllowed`'d (refused before any spawn), so a
806
+ * multi-root group cannot smuggle in an un-allowlisted folder.
807
+ */
808
+ private resolveGroup;
809
+ /** Resolve (and lazily spawn+initialize) the shared server for `(language, root group)`. */
810
+ private acquire;
811
+ /**
812
+ * Find the UNIQUELY-largest warm server whose root group is a strict subset of `roots` (same
813
+ * language, and it must advertise workspace-folder change support), grow it to `roots` in place,
814
+ * and re-key it. Returns the grown entry, or `undefined` when there is no candidate or the largest
815
+ * subset is ambiguous (≥2 candidates tie for the most roots) — in which case we spawn fresh rather
816
+ * than guess which to mutate. Grow-only: a subset request of an existing LARGER server is NOT
817
+ * shrunk (it spawns its own server) — keeping keys, `describe()`, and confinement unambiguous.
818
+ */
819
+ private tryGrowExisting;
820
+ private spawnAndInit;
821
+ /** Provenance for every currently-live server (drives the always-on `lsp_languages` tool). */
822
+ describe(): ServerDescription[];
823
+ private assertRootAllowed;
824
+ /** Serialize `fn` against all other callers holding the same `(server, uri)` lock. */
825
+ private withUriLock;
826
+ /**
827
+ * Acquire the per-uri locks for EVERY uri (deduped, SORTED — deadlock-free ordering) and hold
828
+ * them all across `fn`, releasing in reverse on exit. Each lock chains on the same per-uri
829
+ * promise the single-uri `withUriLock` uses, so a multi-file write serializes correctly against
830
+ * any concurrent single-file query on one of its files.
831
+ */
832
+ private withUriLocks;
833
+ /**
834
+ * Reap every server idle for at least `idleTtlMs` that has NO in-flight request. Returns the
835
+ * reaped keys. Drives the deterministic reaper logic; `nowMs` defaults to the injected clock.
836
+ */
837
+ sweepIdle(nowMs?: number): Promise<string[]>;
838
+ private reap;
839
+ /** LSP `shutdown` → `exit` (graceful), then a clock-driven grace, then the hard `dispose()`. */
840
+ private gracefulStop;
841
+ /** Start the production idle reaper (a `setInterval` driving the injected-clock `sweepIdle`). */
842
+ startReaper(intervalMs: number): void;
843
+ stopReaper(): void;
844
+ /** Gracefully stop and dispose every server; the manager can be reused afterward. */
845
+ shutdown(): Promise<void>;
846
+ }
847
+ //#endregion
848
+ //#region src/confine.d.ts
849
+ /**
850
+ * The shared paired-gate + path-confinement guards for the LSP engines (ADR 0011). Factored
851
+ * out of `query.ts` so the read engine (`LspQueryEngine`) and the write engine
852
+ * (`LspRenameEngine`, Slice F) share one implementation — with a `resolveSymlinks` mode that
853
+ * the WRITE path always sets.
854
+ *
855
+ * The read path confines the single queried file lexically (lower stakes). The write path must
856
+ * confine EVERY file a `WorkspaceEdit` would touch, and lexically is not enough: `resolve()`
857
+ * does not canonicalize symlinks, so a symlink INSIDE the root pointing OUTSIDE it passes a
858
+ * prefix check and a write would clobber the out-of-root target. The write path therefore
859
+ * `realpath`-canonicalizes the root and each target's nearest existing ancestor and re-asserts
860
+ * containment, and refuses any non-`file://` scheme. Confinement is pure path/metadata work —
861
+ * it runs BEFORE any target file content is read.
862
+ */
863
+ /** Thrown when the paired operator gate denies a query/edit (allowRun off, out-of-bounds, bad scheme). */
864
+ declare class LspGateError extends Error {
865
+ constructor(message: string);
866
+ }
867
+ //#endregion
868
+ //#region src/query.d.ts
869
+ /** Reads a file's text, or `undefined` if it cannot be read. */
870
+ type FileReader = (absolutePath: string) => string | undefined;
871
+ interface LspQueryEngineOptions {
872
+ manager: LanguageServerManager;
873
+ /** OPERATOR opt-in to run navigation (which requires a live indexing daemon). Deny-by-default. */
874
+ allowRun: boolean;
875
+ /** OPERATOR allowlist of project roots. Load-bearing even with allowRun. */
876
+ allowedRoots: string[];
877
+ /** Injected file reader (default `readFileSync`). */
878
+ readFile?: FileReader;
879
+ }
880
+ type LspQueryKind = 'definition' | 'typeDefinition' | 'references' | 'hover' | 'documentSymbols' | 'workspaceSymbol' | 'diagnostics' | 'callHierarchy';
881
+ interface LspQueryInput {
882
+ /** The agent-facing language (resolved against the operator registry). */
883
+ language: string;
884
+ /** Project root — must be in `allowedRoots`; pinned to the server's `rootUri`. */
885
+ projectRoot: string;
886
+ /**
887
+ * Additional allowlisted roots bound as workspace folders on the SAME server (multi-root, ADR
888
+ * 0011 tail) — so cross-root navigation resolves through one server. Each must be in
889
+ * `allowedRoots`. The queried `file` still lives under the primary `projectRoot`.
890
+ */
891
+ workspaceRoots?: string[];
892
+ /**
893
+ * The file to query, relative to `projectRoot` (or absolute within it). Required for every kind
894
+ * EXCEPT `workspaceSymbol`, which searches the whole indexed project and needs no open file.
895
+ */
896
+ file?: string;
897
+ /** 1-based human line — required for the position-based kinds, ignored for `documentSymbols`. */
898
+ line?: number;
899
+ /** 1-based human column (code points) — required for the position-based kinds. */
900
+ column?: number;
901
+ kind: LspQueryKind;
902
+ /** The search string — required for (and only used by) `workspaceSymbol`. */
903
+ query?: string;
904
+ /** Call-hierarchy direction (callers vs callees); defaults to `incoming`. */
905
+ direction?: CallDirection;
906
+ /** Optional toolchain provenance to echo (the surface computes via `detectInstalledVersion`). */
907
+ toolchain?: {
908
+ name: string;
909
+ version: string | null;
910
+ };
911
+ }
912
+ interface HumanRange {
913
+ start: HumanPosition;
914
+ end: HumanPosition;
915
+ }
916
+ interface ResultLocation {
917
+ uri: string;
918
+ range: HumanRange;
919
+ /** The full enclosing range, when the server sent a LocationLink. */
920
+ fullRange?: HumanRange;
921
+ /** False ⇒ the target file was unreadable; the range is a best-effort `+1` of the LSP offsets. */
922
+ mapped: boolean;
923
+ }
924
+ /** A document symbol with its range mapped to human 1-based coords; children recurse. */
925
+ interface ResultSymbol {
926
+ name: string;
927
+ kind: number;
928
+ kindName: string;
929
+ detail?: string;
930
+ range: HumanRange;
931
+ /** Set only for flat `SymbolInformation` results. */
932
+ container?: string;
933
+ children?: ResultSymbol[];
934
+ }
935
+ /** A diagnostic with its range(s) mapped to human 1-based coords. */
936
+ interface ResultDiagnostic {
937
+ range: HumanRange;
938
+ message: string;
939
+ severity?: number;
940
+ severityName?: string;
941
+ code?: number | string;
942
+ source?: string;
943
+ tags?: string[];
944
+ related?: {
945
+ uri: string;
946
+ range: HumanRange;
947
+ message: string;
948
+ }[];
949
+ }
950
+ /** A workspace symbol with its range (if any) mapped to human 1-based coords. */
951
+ interface ResultWorkspaceSymbol {
952
+ name: string;
953
+ kind: number;
954
+ kindName: string;
955
+ uri: string;
956
+ /** The declaring container (class/namespace), when the server reported one. */
957
+ container?: string;
958
+ /** Absent when the server returned a uri-only `WorkspaceSymbol` (no range without resolve). */
959
+ range?: HumanRange;
960
+ /** True when a range was present AND its target file was readable (encoding-faithful map). */
961
+ mapped: boolean;
962
+ }
963
+ /** A call-hierarchy item with its ranges mapped to human 1-based coords. */
964
+ interface ResultCallItem {
965
+ name: string;
966
+ kind: number;
967
+ kindName: string;
968
+ detail?: string;
969
+ uri: string;
970
+ range: HumanRange;
971
+ selectionRange: HumanRange;
972
+ }
973
+ /** One call edge: the other item + the human-coord ranges where the call occurs. */
974
+ interface ResultCall {
975
+ item: ResultCallItem;
976
+ fromRanges: HumanRange[];
977
+ }
978
+ interface ResultCallGroup {
979
+ source: ResultCallItem;
980
+ direction: CallDirection;
981
+ calls: ResultCall[];
982
+ }
983
+ interface LspQueryResult {
984
+ status: QueryStatus;
985
+ kind: LspQueryKind;
986
+ locations?: ResultLocation[];
987
+ hover?: {
988
+ value: string;
989
+ range?: HumanRange;
990
+ };
991
+ symbols?: ResultSymbol[];
992
+ workspaceSymbols?: ResultWorkspaceSymbol[];
993
+ diagnostics?: ResultDiagnostic[];
994
+ callHierarchy?: ResultCallGroup[];
995
+ serverInfo?: ServerInfo;
996
+ toolchain?: {
997
+ name: string;
998
+ version: string | null;
999
+ };
1000
+ encoding: PositionEncoding;
1001
+ versionWarning?: string;
1002
+ }
1003
+ declare class LspQueryEngine {
1004
+ private readonly manager;
1005
+ private readonly allowRun;
1006
+ private readonly allowedRoots;
1007
+ private readonly readFile;
1008
+ constructor(options: LspQueryEngineOptions);
1009
+ query(input: LspQueryInput): Promise<LspQueryResult>;
1010
+ /**
1011
+ * Run the project-wide `workspace/symbol` search and shape its cross-file result.
1012
+ *
1013
+ * `workspace/symbol` takes no position, but a real server still needs a *project* to search.
1014
+ * Some servers — notably `typescript-language-server` — only build a project once a document is
1015
+ * open, and answer `workspace/symbol` with a "No Project" error otherwise (caught running the
1016
+ * greeter example live, the cold-load lesson again). So the agent may pass an OPTIONAL anchor
1017
+ * `file`: when present we open it (establishing the project) before searching; when absent we
1018
+ * search with no document open, which works for eager indexers (gopls, rust-analyzer) that load
1019
+ * the project at `initialize`.
1020
+ */
1021
+ private queryWorkspaceSymbol;
1022
+ /** The shared provenance/status envelope every result carries (status + encoding + provenance). */
1023
+ private baseResult;
1024
+ private shape;
1025
+ private mapLocation;
1026
+ /** Map a normalized document symbol (and its children) to human coords in the queried file. */
1027
+ private mapSymbol;
1028
+ /** Map a diagnostic's range (queried file) + any relatedInformation ranges (their own files). */
1029
+ private mapDiagnostic;
1030
+ /** Map a workspace symbol to human coords, reading its OWN target file (cross-file, read-only). */
1031
+ private mapWorkspaceSymbol;
1032
+ private mapCallItem;
1033
+ private mapCallGroup;
1034
+ private textForUri;
1035
+ /** Map an LSP 0-based range to a human 1-based range, encoding-faithfully when text is known. */
1036
+ private mapRange;
1037
+ }
1038
+ //#endregion
1039
+ //#region src/rename.d.ts
1040
+ /** Reads a file's text, or `undefined` if it cannot be read. */
1041
+ type FileReader$1 = (absolutePath: string) => string | undefined;
1042
+ /**
1043
+ * Lists candidate source files (absolute paths) under the allowlisted root group to scan for the
1044
+ * partial-rename completeness guard — same-language source only (`extension`, e.g. `.py`). Injected
1045
+ * so the gate never walks a real tree; `truncated` ⇒ a cap was hit and the scan is not exhaustive.
1046
+ */
1047
+ type ProjectFileLister = (roots: string[], opts: {
1048
+ extension: string;
1049
+ }) => {
1050
+ files: string[];
1051
+ truncated: boolean;
1052
+ };
1053
+ /** Default lister: a bounded, symlink-safe recursive walk collecting `extension` files, skipping
1054
+ * dependency/VCS/cache/build dirs and any dotdir. Stops (`truncated`) at {@link MAX_SCAN_FILES}.
1055
+ * The partial-rename guard is OFF until a lister is wired (like `redact`, the surfaces wire this). */
1056
+ declare const defaultListFiles: ProjectFileLister;
1057
+ /** How exhaustively the partial-rename guard could verify the edit covers every textual use. */
1058
+ type RenameCompleteness = 'complete' | 'suspect' | 'unknown';
1059
+ /**
1060
+ * One physical filesystem action in an apply commit. A `write` creates-or-overwrites a file's
1061
+ * content (the fold of a CreateFile + its edits, or an edit to a pre-existing file); `rename`/
1062
+ * `delete` are the resource-op file moves/removals (`RenameFile`/`DeleteFile`).
1063
+ */
1064
+ type PhysicalOp = {
1065
+ kind: 'write';
1066
+ absPath: string;
1067
+ newText: string;
1068
+ } | {
1069
+ kind: 'rename';
1070
+ fromAbs: string;
1071
+ toAbs: string;
1072
+ } | {
1073
+ kind: 'delete';
1074
+ absPath: string;
1075
+ };
1076
+ interface CommitResult {
1077
+ /** The ops that completed, in order. */
1078
+ completed: PhysicalOp[];
1079
+ /** True ⇒ an op faulted mid-execute; `completed` is the TERMINAL landed set (rename/delete are
1080
+ * irreversible — there is no rollback; reconcile via VCS). */
1081
+ partial: boolean;
1082
+ error?: string;
1083
+ }
1084
+ /**
1085
+ * The write seam (injected; tests substitute a fake so the gate never touches disk). The default
1086
+ * **stages every `write` to a sibling temp (+ fsync) first** (no target touched until all temps
1087
+ * exist — the strength the pure-text rename relies on), then executes every op IN ORDER: a `write`
1088
+ * commits via atomic rename of its temp, a `rename`/`delete` runs the fs primitive. Resource ops
1089
+ * are irreversible and cannot be staged, so a fault during the execute phase is terminal —
1090
+ * `partial: true` names exactly what landed.
1091
+ */
1092
+ interface RenameWriter {
1093
+ commit(ops: PhysicalOp[]): CommitResult;
1094
+ }
1095
+ declare const defaultRenameWriter: RenameWriter;
1096
+ interface LspRenameEngineOptions {
1097
+ manager: LanguageServerManager;
1098
+ /** OPERATOR opt-in to run navigation (which requires a live indexing daemon). Deny-by-default. */
1099
+ allowRun: boolean;
1100
+ /** OPERATOR allowlist of project roots. Load-bearing even with allowRun. */
1101
+ allowedRoots: string[];
1102
+ /** OPERATOR opt-in to WRITE the edit to disk. Distinct from allowRun; off ⇒ dry-run preview. */
1103
+ allowWrite: boolean;
1104
+ /**
1105
+ * OPERATOR opt-in to APPLY a rename whose completeness verdict is `suspect` — i.e. the old
1106
+ * identifier also appears in same-language files the server's edit does NOT touch (the hallmark
1107
+ * of an open-files-scoped server like pyright, whose cross-file rename can be silently partial).
1108
+ * Deny-by-default: a suspect rename is REFUSED for write unless this is set. Never an agent input.
1109
+ */
1110
+ allowPartialRename?: boolean;
1111
+ /**
1112
+ * OPERATOR opt-in to APPLY a DESTRUCTIVE resource op — `overwrite: true` on a CreateFile
1113
+ * (truncate-and-replace an existing file) or a RenameFile (clobber an existing REGULAR-FILE
1114
+ * target). Deny-by-default; refused for write unless set. Recursive/dir delete + symlink/dir
1115
+ * targets STAY refused even with this gate. Meaningless without `allowWrite` (the engine
1116
+ * re-checks both before any destructive op); never an agent input.
1117
+ */
1118
+ allowDestructiveResourceOps?: boolean;
1119
+ readFile?: FileReader$1;
1120
+ /** Lists same-language source files to scan for the partial-rename guard. UNSET ⇒ the guard is
1121
+ * inactive (no scan); the bin/CLI/MCP wire `defaultListFiles` to turn it on (cf. `redact`). */
1122
+ listFiles?: ProjectFileLister;
1123
+ writer?: RenameWriter;
1124
+ /** Secret redaction over every surfaced hunk (default identity; the bin wires @sackville-mcp/safety). */
1125
+ redact?: (text: string) => string;
1126
+ }
1127
+ interface LspRenameInput {
1128
+ language: string;
1129
+ projectRoot: string;
1130
+ /** The file to rename in, relative to projectRoot (or absolute within it). */
1131
+ file: string;
1132
+ /** 1-based human line of the symbol to rename. */
1133
+ line: number;
1134
+ /** 1-based human column (code points) of the symbol to rename. */
1135
+ column: number;
1136
+ /** The new identifier. Validated by isPlausibleRenameName before reaching the server. */
1137
+ newName: string;
1138
+ /**
1139
+ * Additional allowlisted roots bound as workspace folders on the SAME server (multi-root). A
1140
+ * cross-root rename may edit files in any of these; each must be in `allowedRoots`, and edited
1141
+ * files confine to the GROUP (`projectRoot` ∪ `workspaceRoots`), not just the primary root.
1142
+ */
1143
+ workspaceRoots?: string[];
1144
+ /** Optional toolchain provenance to echo. */
1145
+ toolchain?: {
1146
+ name: string;
1147
+ version: string | null;
1148
+ };
1149
+ }
1150
+ interface RenamePreviewEdit {
1151
+ range: HumanRange;
1152
+ oldText: string;
1153
+ newText: string;
1154
+ needsConfirmation?: boolean;
1155
+ annotationLabel?: string;
1156
+ }
1157
+ interface RenamePreviewFile {
1158
+ uri: string;
1159
+ /** Project-relative path (never absolute). `(out of project root)` for an out-of-root edit. */
1160
+ file: string;
1161
+ editCount: number;
1162
+ /** True ⇒ the edit targets a file outside the allowlisted root; its bytes are NOT surfaced. */
1163
+ outOfRoot?: boolean;
1164
+ /** The per-edit hunks (omitted for an out-of-root or unreadable file). */
1165
+ hunks?: RenamePreviewEdit[];
1166
+ }
1167
+ interface RenameDigest {
1168
+ file: string;
1169
+ before: string;
1170
+ after: string;
1171
+ }
1172
+ interface LspRenameResult {
1173
+ status: QueryStatus;
1174
+ kind: 'rename';
1175
+ applied: boolean;
1176
+ /** A structured reason the rename was not applied (not renameable, multi-file, resource ops, drift). */
1177
+ refused?: string;
1178
+ newName: string;
1179
+ fileCount: number;
1180
+ totalEditCount: number;
1181
+ edits: RenamePreviewFile[];
1182
+ resourceOps?: NormalizedResourceOp[];
1183
+ /** Per-file pre/post SHA-256 digests — the apply audit (only when applied). */
1184
+ digests?: RenameDigest[];
1185
+ /** Project-relative paths whose prior content a DESTRUCTIVE overwrite clobbered (gated; landed
1186
+ * only) — so a destructive clobber is always explicit in the envelope, not inferred from a digest. */
1187
+ overwritten?: string[];
1188
+ /** True ⇒ an irreversible resource op faulted mid-commit; `digests` names what landed (no
1189
+ * rollback — reconcile via VCS). */
1190
+ partial?: boolean;
1191
+ partialError?: string;
1192
+ /**
1193
+ * The partial-rename guard verdict (omitted when the rename was not `ok`). `complete` — every
1194
+ * same-language file mentioning the old name is in the edit; `suspect` — some are NOT (the edit
1195
+ * may be partial, e.g. an open-files-scoped server); `unknown` — the scan was truncated.
1196
+ */
1197
+ completeness?: RenameCompleteness;
1198
+ /** Project-relative same-language files that mention the old identifier but are absent from the
1199
+ * edit (capped). Populated when `completeness === 'suspect'`. */
1200
+ suspectedMissedFiles?: string[];
1201
+ serverInfo?: ServerInfo;
1202
+ toolchain?: {
1203
+ name: string;
1204
+ version: string | null;
1205
+ };
1206
+ encoding: PositionEncoding;
1207
+ versionWarning?: string;
1208
+ }
1209
+ declare class LspRenameEngine {
1210
+ private readonly manager;
1211
+ private readonly allowRun;
1212
+ private readonly allowedRoots;
1213
+ private readonly allowWrite;
1214
+ private readonly allowPartialRename;
1215
+ private readonly allowDestructiveResourceOps;
1216
+ private readonly readFile;
1217
+ /** When unset the partial-rename guard is inactive (the bin/CLI/MCP wire `defaultListFiles`). */
1218
+ private readonly listFiles?;
1219
+ private readonly writer;
1220
+ private readonly redact;
1221
+ constructor(options: LspRenameEngineOptions);
1222
+ rename(input: LspRenameInput): Promise<LspRenameResult>;
1223
+ /**
1224
+ * Decide + execute the apply, consuming the ordered `operations` (text edits interleaved with
1225
+ * CreateFile/RenameFile/DeleteFile). Confine EVERY touched URI (edit + create + rename old&new +
1226
+ * delete) to the root group all-or-nothing BEFORE any I/O; refuse the v1 cuts early (non-default
1227
+ * resource-op options; editing a file also renamed in the same batch). Then, under the multi-URI
1228
+ * lock over ALL touched URIs, replay the ops over a virtual content map (no writes) with the
1229
+ * staleness guards, build a physical plan, stage-then-commit it, and resync any open buffer
1230
+ * (`didChange` for an edited file, `didClose`+`didOpen` migration for a renamed/deleted one).
1231
+ */
1232
+ private applyEdit;
1233
+ /**
1234
+ * The partial-rename completeness guard. Extracts the old identifier at the queried position,
1235
+ * then scans the allowlisted root group for same-language files that mention it as a whole word
1236
+ * but are NOT covered by the server's edit (text edits + resource-op endpoints). Any such file is
1237
+ * a SUSPECT — the edit is likely partial (an open-files-scoped server). `unknown` ⇒ the scan was
1238
+ * truncated (cap hit). Server-agnostic: a whole-project-rename server covers every use ⇒ `complete`.
1239
+ */
1240
+ private assessCompleteness;
1241
+ private shape;
1242
+ private previewFile;
1243
+ private hunk;
1244
+ }
1245
+ //#endregion
1246
+ export { type CallDirection, type CallHierarchyGroup, type CallHierarchyItem, type CommitResult, type Diagnostic, type DocumentSymbol, type FileReader, type Hover, type HumanPosition, type HumanRange, type InitializeSummary, LanguageServerManager, type LanguageServerManagerOptions, type Location, type LocationLink, LspClient, type LspClientOptions, type LspConnection, LspGateError, LspManagerError, type LspPosition, type LspPositionParts, LspQueryEngine, type LspQueryEngineOptions, type LspQueryInput, type LspQueryKind, type LspQueryResult, type LspRange, LspRegistryError, LspRenameEngine, type LspRenameEngineOptions, type LspRenameInput, type LspRenameResult, LspUnsupportedError, type MarkedString, type MarkupContent, type NavResult, type NormalizedCall, type NormalizedCallItem, type NormalizedDiagnostic, type NormalizedFileEdit, type NormalizedFileEdits, type NormalizedHover, type NormalizedLocation, type NormalizedRelatedInfo, type NormalizedResourceOp, type NormalizedSymbol, type NormalizedWorkspaceEdit, type NormalizedWorkspaceSymbol, PREFERRED_ENCODINGS, type PhysicalOp, type PositionEncoding, type PrepareRenameOutcome, type ProjectFileLister, type QueryInput, type QueryStatus, type RawPrepareRename, type RawResourceOperation, type RawTextDocumentEdit, type RawTextEdit, type RawWorkspaceEdit, type RenameCompleteness, type RenameDigest, type RenamePreviewEdit, type RenamePreviewFile, type RenameWriter, type ResultCall, type ResultCallGroup, type ResultCallItem, type ResultDiagnostic, type ResultLocation, type ResultSymbol, type ResultWorkspaceSymbol, type ServerDescription, type ServerInfo, type ServerRegistry, type ServerRegistryEntry, type ServerSpawn, type ServerSpec, type SymbolInformation, type WorkspaceSymbol, decideStatus, defaultListFiles, defaultRenameWriter, defaultServerSpawn, diagnosticSeverityName, fromLspCharacter, fromLspPosition, normalizeCallHierarchyItem, normalizeCallHierarchyItems, normalizeDiagnostics, normalizeDocumentSymbols, normalizeHover, normalizeIncomingCalls, normalizeLocations, normalizeOutgoingCalls, normalizePrepareRename, normalizeWorkspaceEdit, normalizeWorkspaceSymbols, parseServerRegistry, resolvePositionEncoding, resolveServer, symbolKindName, toLspCharacter, toLspPosition };
1247
+ //# sourceMappingURL=index.d.mts.map