@remnic/coding-graph 9.3.759
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.
- package/README.md +130 -0
- package/dist/chunk-5I2DBHOQ.js +1042 -0
- package/dist/chunk-5I2DBHOQ.js.map +1 -0
- package/dist/chunk-CPYJACC5.js +1838 -0
- package/dist/chunk-CPYJACC5.js.map +1 -0
- package/dist/chunk-ZVCMIM4T.js +216 -0
- package/dist/chunk-ZVCMIM4T.js.map +1 -0
- package/dist/cypher/query-parser.d.ts +253 -0
- package/dist/cypher/query-parser.js +17 -0
- package/dist/cypher/query-parser.js.map +1 -0
- package/dist/graph-schema.d.ts +84 -0
- package/dist/graph-schema.js +17 -0
- package/dist/graph-schema.js.map +1 -0
- package/dist/graph-store.d.ts +938 -0
- package/dist/graph-store.js +16 -0
- package/dist/graph-store.js.map +1 -0
- package/dist/index.d.ts +1953 -0
- package/dist/index.js +3509 -0
- package/dist/index.js.map +1 -0
- package/grammars/tree-sitter-bash.wasm +0 -0
- package/grammars/tree-sitter-c.wasm +0 -0
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/grammars/tree-sitter-cpp.wasm +0 -0
- package/grammars/tree-sitter-go.wasm +0 -0
- package/grammars/tree-sitter-java.wasm +0 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-kotlin.wasm +0 -0
- package/grammars/tree-sitter-php.wasm +0 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-ruby.wasm +0 -0
- package/grammars/tree-sitter-rust.wasm +0 -0
- package/grammars/tree-sitter-swift.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- package/package.json +79 -0
- package/src/co-change.test.ts +175 -0
- package/src/co-change.ts +167 -0
- package/src/cypher/query-parser.test.ts +1107 -0
- package/src/cypher/query-parser.ts +1692 -0
- package/src/detect-changes.test.ts +533 -0
- package/src/detect-changes.ts +367 -0
- package/src/engine/emit.ts +556 -0
- package/src/engine/engine.test.ts +1417 -0
- package/src/engine/engine.ts +182 -0
- package/src/engine/extractors.ts +486 -0
- package/src/engine/fixtures.ts +364 -0
- package/src/engine/language-sniff.ts +56 -0
- package/src/engine/parser-backend.ts +206 -0
- package/src/engine/utf16-offsets.ts +68 -0
- package/src/git-invoker.test.ts +116 -0
- package/src/git-invoker.ts +426 -0
- package/src/graph-schema.test.ts +541 -0
- package/src/graph-schema.ts +383 -0
- package/src/graph-store-pr2.test.ts +1879 -0
- package/src/graph-store.test.ts +1420 -0
- package/src/graph-store.ts +3489 -0
- package/src/index-status.test.ts +303 -0
- package/src/index-status.ts +135 -0
- package/src/index.ts +384 -0
- package/src/lsp/byte-position.ts +173 -0
- package/src/lsp/characterization.test.ts +174 -0
- package/src/lsp/client.test.ts +275 -0
- package/src/lsp/client.ts +484 -0
- package/src/lsp/config.ts +219 -0
- package/src/lsp/degradation.ts +86 -0
- package/src/lsp/fixtures/fake-server.mjs +198 -0
- package/src/lsp/framing.test.ts +180 -0
- package/src/lsp/framing.ts +177 -0
- package/src/lsp/resolution.test.ts +497 -0
- package/src/lsp/resolution.ts +483 -0
- package/src/lsp/status.ts +140 -0
- package/src/lsp/types.ts +167 -0
- package/src/reindex.test.ts +1038 -0
- package/src/reindex.ts +908 -0
- package/src/row-types.ts +45 -0
- package/src/semantic/canonical-text.test.ts +150 -0
- package/src/semantic/canonical-text.ts +219 -0
- package/src/semantic/config.ts +235 -0
- package/src/semantic/index.ts +78 -0
- package/src/semantic/minhash.test.ts +197 -0
- package/src/semantic/minhash.ts +261 -0
- package/src/semantic/semantic-query.ts +173 -0
- package/src/semantic/semantic.test.ts +1315 -0
- package/src/semantic/similarity.ts +268 -0
- package/src/semantic/types.ts +145 -0
- package/src/semantic/vectors.ts +235 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal JSON-RPC-over-stdio LSP client.
|
|
3
|
+
*
|
|
4
|
+
* Implements exactly the protocol subset the resolution pass needs
|
|
5
|
+
* (LSP 3.17): initialize → initialized → didOpen → definition →
|
|
6
|
+
* shutdown → exit. No npm LSP framework — the protocol subset is small
|
|
7
|
+
* and a dependency here would bloat the optional package (issue #1555).
|
|
8
|
+
*
|
|
9
|
+
* Failure discipline (rule 13): every operation degrades to a tagged
|
|
10
|
+
* `LspDegradation` — the client NEVER throws to a caller. Server crashes
|
|
11
|
+
* mid-run, protocol errors, and timeouts all surface as distinct codes.
|
|
12
|
+
*
|
|
13
|
+
* Lifecycle: the client owns the child process. `dispose()` sends
|
|
14
|
+
* shutdown + exit, then hard-kills (SIGKILL) any lingering process —
|
|
15
|
+
* no zombie children survive (tested).
|
|
16
|
+
*/
|
|
17
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import process from "node:process";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
encodeLspFrame,
|
|
23
|
+
LspFrameDecoder,
|
|
24
|
+
} from "./framing.js";
|
|
25
|
+
import {
|
|
26
|
+
type JsonRpcRequest,
|
|
27
|
+
type LspInitializeParams,
|
|
28
|
+
type LspInitializeResult,
|
|
29
|
+
type LspLocation,
|
|
30
|
+
type LspTextDocumentItem,
|
|
31
|
+
type LspTextDocumentPositionParams,
|
|
32
|
+
} from "./types.js";
|
|
33
|
+
import {
|
|
34
|
+
lspDegradation,
|
|
35
|
+
type LspDegradation,
|
|
36
|
+
type LspDegradationCode,
|
|
37
|
+
type LspResult,
|
|
38
|
+
} from "./degradation.js";
|
|
39
|
+
import type { LspServerLaunchSpec } from "./config.js";
|
|
40
|
+
|
|
41
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
42
|
+
// Pending-request entry — stores the resolver pair + timeout handle.
|
|
43
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
interface PendingRequest {
|
|
46
|
+
readonly resolve: (value: unknown) => void;
|
|
47
|
+
readonly reject: (degradation: LspDegradation) => void;
|
|
48
|
+
readonly timer: ReturnType<typeof setTimeout>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
52
|
+
// Connection options
|
|
53
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
export interface LspClientOptions {
|
|
56
|
+
readonly launchSpec: LspServerLaunchSpec;
|
|
57
|
+
readonly rootUri: string | null;
|
|
58
|
+
readonly timeoutMs: number;
|
|
59
|
+
/**
|
|
60
|
+
* Optional spawn override — test seam. When provided, the client calls
|
|
61
|
+
* this instead of `child_process.spawn`. Must return a ChildProcess-
|
|
62
|
+
* compatible object with stdin/stdout streams.
|
|
63
|
+
*/
|
|
64
|
+
readonly spawnFn?: typeof spawn;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
68
|
+
// LspClient — the connection. One instance per server process.
|
|
69
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export class LspClient {
|
|
72
|
+
private readonly child: ChildProcess;
|
|
73
|
+
private readonly decoder = new LspFrameDecoder();
|
|
74
|
+
private readonly rootUri: string | null;
|
|
75
|
+
private readonly timeoutMs: number;
|
|
76
|
+
private nextId = 1;
|
|
77
|
+
private readonly pending = new Map<number, PendingRequest>();
|
|
78
|
+
private disposed = false;
|
|
79
|
+
private crashed = false;
|
|
80
|
+
private crashCode: LspDegradationCode | null = null;
|
|
81
|
+
private serverCapabilities: LspInitializeResult["capabilities"] | null = null;
|
|
82
|
+
|
|
83
|
+
private constructor(child: ChildProcess, rootUri: string | null, timeoutMs: number) {
|
|
84
|
+
this.child = child;
|
|
85
|
+
this.rootUri = rootUri;
|
|
86
|
+
this.timeoutMs = timeoutMs;
|
|
87
|
+
|
|
88
|
+
// Wire stdout → decoder → message dispatcher.
|
|
89
|
+
this.child.stdout?.setEncoding("utf8");
|
|
90
|
+
this.child.stdout?.on("data", (chunk: Buffer | string) => this.onStdoutData(chunk));
|
|
91
|
+
|
|
92
|
+
// Drain stderr so the OS pipe buffer doesn't fill and block the server.
|
|
93
|
+
this.child.stderr?.on("data", () => {});
|
|
94
|
+
|
|
95
|
+
// Unexpected exit → mark crashed, reject all pending.
|
|
96
|
+
this.child.on("exit", (code, signal) => this.onChildExit(code, signal));
|
|
97
|
+
this.child.on("error", (err) => this.onChildError(err));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Spawn the server and perform the initialize handshake. Returns a
|
|
102
|
+
* tagged result — `{ ok: true, client }` on success, or a degradation
|
|
103
|
+
* on failure (server_missing, handshake_timeout, handshake_error).
|
|
104
|
+
*/
|
|
105
|
+
static async connect(options: LspClientOptions): Promise<
|
|
106
|
+
| { ok: true; client: LspClient }
|
|
107
|
+
| { ok: false; degradation: LspDegradation }
|
|
108
|
+
> {
|
|
109
|
+
let child: ChildProcess;
|
|
110
|
+
const spawnFn = options.spawnFn ?? spawn;
|
|
111
|
+
try {
|
|
112
|
+
child = spawnFn(options.launchSpec.command, [...options.launchSpec.args], {
|
|
113
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
114
|
+
shell: false,
|
|
115
|
+
// The server inherits our cwd so relative rootUri paths resolve.
|
|
116
|
+
cwd: process.cwd(),
|
|
117
|
+
});
|
|
118
|
+
} catch {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
degradation: lspDegradation(
|
|
122
|
+
"server_missing",
|
|
123
|
+
`failed to spawn ${options.launchSpec.command}`,
|
|
124
|
+
),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// If spawn emitted 'error' synchronously (ENOENT), treat as missing.
|
|
129
|
+
// We detect this by checking if the child has already exited.
|
|
130
|
+
if (child.exitCode !== null && child.exitCode !== undefined) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
degradation: lspDegradation("server_missing"),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const client = new LspClient(child, options.rootUri, options.timeoutMs);
|
|
138
|
+
|
|
139
|
+
// If the spawn errored asynchronously (ENOENT fires as 'error' event),
|
|
140
|
+
// the client.crashed flag is set by onChildError. Check after wiring.
|
|
141
|
+
if (client.crashed) {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
degradation: lspDegradation(client.crashCode ?? "server_crashed"),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── initialize handshake ──
|
|
149
|
+
const initParams: LspInitializeParams = {
|
|
150
|
+
processId: process.pid,
|
|
151
|
+
rootUri: options.rootUri,
|
|
152
|
+
capabilities: {},
|
|
153
|
+
};
|
|
154
|
+
const initResult = await client.request("initialize", initParams);
|
|
155
|
+
if (!initResult.ok) {
|
|
156
|
+
// Remap request_timeout → handshake_timeout (the timeout happened
|
|
157
|
+
// during the initialize handshake). protocol_error, server_crashed,
|
|
158
|
+
// and server_missing pass through with their original codes — they
|
|
159
|
+
// describe the specific failure precisely enough.
|
|
160
|
+
await client.dispose();
|
|
161
|
+
const code =
|
|
162
|
+
initResult.degradation.code === "request_timeout"
|
|
163
|
+
? "handshake_timeout"
|
|
164
|
+
: initResult.degradation.code;
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
degradation: lspDegradation(code, initResult.degradation.detail),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (initResult.value === null || typeof initResult.value !== "object") {
|
|
172
|
+
await client.dispose();
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
degradation: lspDegradation("protocol_error", "initialize response missing or invalid"),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const initResponse = initResult.value as LspInitializeResult;
|
|
179
|
+
client.serverCapabilities = initResponse.capabilities;
|
|
180
|
+
|
|
181
|
+
// initialized notification (no response expected).
|
|
182
|
+
client.notify("initialized", {});
|
|
183
|
+
|
|
184
|
+
return { ok: true, client };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Send `textDocument/didOpen` — notifies the server about an open
|
|
189
|
+
* document with its full content. No response expected.
|
|
190
|
+
*/
|
|
191
|
+
didOpen(item: LspTextDocumentItem): void {
|
|
192
|
+
this.notify("textDocument/didOpen", { textDocument: item });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Send `textDocument/definition` for a position in a document. Returns
|
|
197
|
+
* the definition locations (may be empty, a single location, or an
|
|
198
|
+
* array). Degrades on timeout/error/crash — never throws.
|
|
199
|
+
*/
|
|
200
|
+
async definition(
|
|
201
|
+
params: LspTextDocumentPositionParams,
|
|
202
|
+
): Promise<LspResult<{ locations: LspLocation[] }>> {
|
|
203
|
+
if (this.disposed || this.crashed) {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
degradation: lspDegradation("server_crashed"),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const result = await this.request("textDocument/definition", params);
|
|
210
|
+
if (!result.ok) {
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
// LSP allows Location | Location[] | null for definition.
|
|
214
|
+
const value = result.value;
|
|
215
|
+
let locations: LspLocation[];
|
|
216
|
+
if (value === null || value === undefined) {
|
|
217
|
+
locations = [];
|
|
218
|
+
} else if (Array.isArray(value)) {
|
|
219
|
+
locations = value as LspLocation[];
|
|
220
|
+
} else {
|
|
221
|
+
locations = [value as LspLocation];
|
|
222
|
+
}
|
|
223
|
+
return { ok: true, locations };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Send `shutdown`, then `exit`, then SIGKILL if the process lingers.
|
|
228
|
+
* Idempotent — safe to call multiple times. After dispose, no child
|
|
229
|
+
* process remains (tested — zombie cleanup).
|
|
230
|
+
*/
|
|
231
|
+
async dispose(): Promise<void> {
|
|
232
|
+
if (this.disposed) return;
|
|
233
|
+
this.disposed = true;
|
|
234
|
+
|
|
235
|
+
// Reject all pending requests immediately.
|
|
236
|
+
for (const [id, entry] of this.pending) {
|
|
237
|
+
clearTimeout(entry.timer);
|
|
238
|
+
entry.reject(lspDegradation("server_crashed"));
|
|
239
|
+
this.pending.delete(id);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Try graceful shutdown: send shutdown request, then exit notification.
|
|
243
|
+
if (!this.crashed && this.child.stdin && !this.child.stdin.destroyed) {
|
|
244
|
+
try {
|
|
245
|
+
const promise = new Promise<void>((resolve) => {
|
|
246
|
+
// Send shutdown — don't wait for a response longer than the timeout.
|
|
247
|
+
this.child.stdin?.write(encodeLspFrame({ jsonrpc: "2.0", id: 0, method: "shutdown" }));
|
|
248
|
+
this.child.stdin?.write(encodeLspFrame({ jsonrpc: "2.0", method: "exit" }));
|
|
249
|
+
// Give the server a brief window to exit cleanly.
|
|
250
|
+
const timer = setTimeout(resolve, 500);
|
|
251
|
+
this.child.on("exit", () => {
|
|
252
|
+
clearTimeout(timer);
|
|
253
|
+
resolve();
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
await promise;
|
|
257
|
+
} catch {
|
|
258
|
+
// Best-effort — if writing fails, hard-kill below.
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Hard kill — ensures no zombie survives even if graceful exit failed.
|
|
263
|
+
this.hardKill();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Returns the pid of the child process (for zombie-cleanup tests). */
|
|
267
|
+
get pid(): number | undefined {
|
|
268
|
+
return this.child.pid;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** True if the server reported definitionProvider capability. */
|
|
272
|
+
get supportsDefinition(): boolean {
|
|
273
|
+
const caps = this.serverCapabilities;
|
|
274
|
+
return caps !== null && Boolean(caps.definitionProvider);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
278
|
+
// Internal — JSON-RPC request/notification machinery
|
|
279
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Send a request and await its response. Returns the `result` field
|
|
283
|
+
* on success, or a degradation on timeout/error/crash/protocol-error.
|
|
284
|
+
*/
|
|
285
|
+
private request(method: string, params: unknown): Promise<
|
|
286
|
+
| { ok: true; value: unknown }
|
|
287
|
+
| { ok: false; degradation: LspDegradation }
|
|
288
|
+
> {
|
|
289
|
+
if (this.disposed || this.crashed) {
|
|
290
|
+
return Promise.resolve({
|
|
291
|
+
ok: false,
|
|
292
|
+
degradation: lspDegradation("server_crashed"),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
const id = this.nextId++;
|
|
296
|
+
const message: JsonRpcRequest = { jsonrpc: "2.0", id, method, params };
|
|
297
|
+
const promise = new Promise<unknown>((resolve, reject) => {
|
|
298
|
+
const timer = setTimeout(() => {
|
|
299
|
+
const entry = this.pending.get(id);
|
|
300
|
+
if (entry) {
|
|
301
|
+
this.pending.delete(id);
|
|
302
|
+
entry.reject(lspDegradation("request_timeout", `method=${method}`));
|
|
303
|
+
}
|
|
304
|
+
}, this.timeoutMs);
|
|
305
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
306
|
+
try {
|
|
307
|
+
const frame = encodeLspFrame(message);
|
|
308
|
+
if (!this.child.stdin || this.child.stdin.destroyed) {
|
|
309
|
+
clearTimeout(timer);
|
|
310
|
+
this.pending.delete(id);
|
|
311
|
+
reject(lspDegradation("server_crashed"));
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
this.child.stdin.write(frame);
|
|
315
|
+
} catch {
|
|
316
|
+
clearTimeout(timer);
|
|
317
|
+
this.pending.delete(id);
|
|
318
|
+
reject(lspDegradation("server_crashed"));
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
return promise.then(
|
|
323
|
+
(value: unknown) => ({ ok: true as const, value }),
|
|
324
|
+
(degradation: LspDegradation) => ({ ok: false as const, degradation }),
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Send a notification (no response expected). Best-effort — if the
|
|
330
|
+
* write fails, the next request will surface the crash.
|
|
331
|
+
*/
|
|
332
|
+
private notify(method: string, params: unknown): void {
|
|
333
|
+
if (this.disposed || this.crashed) return;
|
|
334
|
+
try {
|
|
335
|
+
const message: JsonRpcRequest = { jsonrpc: "2.0", method, params };
|
|
336
|
+
this.child.stdin?.write(encodeLspFrame(message));
|
|
337
|
+
} catch {
|
|
338
|
+
// Best-effort — notifications have no response path.
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Dispatch a decoded JSON-RPC message. Correlates responses to pending
|
|
344
|
+
* requests by id; ignores server-initiated notifications (we don't
|
|
345
|
+
* need them for the resolution pass).
|
|
346
|
+
*/
|
|
347
|
+
/**
|
|
348
|
+
* Dispatch a decoded JSON-RPC message. Correlates responses to pending
|
|
349
|
+
* requests by id; ignores server-initiated notifications.
|
|
350
|
+
*/
|
|
351
|
+
private dispatchMessage(msg: unknown): void {
|
|
352
|
+
if (typeof msg !== "object" || msg === null) return;
|
|
353
|
+
const rpc = msg as Record<string, unknown>;
|
|
354
|
+
if (rpc.id === undefined || (rpc.result === undefined && rpc.error === undefined)) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const id = typeof rpc.id === "number" ? rpc.id : Number(rpc.id);
|
|
358
|
+
const entry = this.pending.get(id);
|
|
359
|
+
if (!entry) return;
|
|
360
|
+
clearTimeout(entry.timer);
|
|
361
|
+
this.pending.delete(id);
|
|
362
|
+
if (rpc.error !== undefined) {
|
|
363
|
+
// Don't echo raw server error text — it may contain absolute paths.
|
|
364
|
+
entry.reject(lspDegradation("request_error", "server returned an error response"));
|
|
365
|
+
} else {
|
|
366
|
+
entry.resolve(rpc.result);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* stdout data handler — feed the decoder, dispatch complete messages,
|
|
372
|
+
* detect protocol errors.
|
|
373
|
+
*/
|
|
374
|
+
private onStdoutData(chunk: Buffer | string): void {
|
|
375
|
+
const result = this.decoder.feed(chunk);
|
|
376
|
+
if (!result.ok) {
|
|
377
|
+
this.handleProtocolError(result.error.detail);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
for (const msg of result.messages) {
|
|
381
|
+
this.dispatchMessage(msg);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Handle an unexpected child exit. All pending requests are rejected
|
|
387
|
+
* with server_crashed.
|
|
388
|
+
*/
|
|
389
|
+
private onChildExit(code: number | null, signal: NodeJS.Signals | null): void {
|
|
390
|
+
// Normal exit during dispose — don't mark as crashed.
|
|
391
|
+
if (this.disposed) return;
|
|
392
|
+
this.crashed = true;
|
|
393
|
+
this.crashCode = "server_crashed";
|
|
394
|
+
const detail =
|
|
395
|
+
code !== null ? `server exited with code ${code}` : `server killed by ${signal}`;
|
|
396
|
+
for (const [id, entry] of this.pending) {
|
|
397
|
+
clearTimeout(entry.timer);
|
|
398
|
+
entry.reject(lspDegradation("server_crashed", detail));
|
|
399
|
+
this.pending.delete(id);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Handle a spawn error (ENOENT etc). Marks the server as missing.
|
|
405
|
+
*/
|
|
406
|
+
private onChildError(err: Error): void {
|
|
407
|
+
// ENOENT = binary not found; other spawn errors = server present but failing.
|
|
408
|
+
this.crashed = true;
|
|
409
|
+
const isENOENT = err.message.includes("ENOENT");
|
|
410
|
+
this.crashCode = isENOENT ? "server_missing" : "server_crashed";
|
|
411
|
+
const detail = isENOENT ? undefined : "spawn error";
|
|
412
|
+
for (const [id, entry] of this.pending) {
|
|
413
|
+
clearTimeout(entry.timer);
|
|
414
|
+
entry.reject(lspDegradation(this.crashCode, detail));
|
|
415
|
+
this.pending.delete(id);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Protocol error — the stream produced a malformed frame. Reject all
|
|
421
|
+
* pending and mark disposed so no further requests can be sent.
|
|
422
|
+
*/
|
|
423
|
+
private handleProtocolError(detail: string): void {
|
|
424
|
+
this.crashed = true;
|
|
425
|
+
for (const [id, entry] of this.pending) {
|
|
426
|
+
clearTimeout(entry.timer);
|
|
427
|
+
entry.reject(lspDegradation("protocol_error", detail));
|
|
428
|
+
this.pending.delete(id);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Hard-kill the child process: SIGKILL. Called by dispose() as a
|
|
434
|
+
* final cleanup guarantee. Also called if the graceful shutdown path
|
|
435
|
+
* fails. Uses `kill` which is a no-op if the process already exited.
|
|
436
|
+
*/
|
|
437
|
+
private hardKill(): void {
|
|
438
|
+
try {
|
|
439
|
+
if (this.child.pid !== undefined && !this.child.killed) {
|
|
440
|
+
this.child.kill("SIGKILL");
|
|
441
|
+
}
|
|
442
|
+
} catch {
|
|
443
|
+
// Best-effort — the process may have already exited.
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
449
|
+
// URI helpers — convert between file paths and LSP URIs.
|
|
450
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Convert a repo-relative or absolute file path to a `file://` URI.
|
|
454
|
+
* Handles Windows drive letters (C:\ → file:///C:/).
|
|
455
|
+
*/
|
|
456
|
+
export function pathToUri(filePath: string): string {
|
|
457
|
+
const abs = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
|
|
458
|
+
const normalized = abs.replace(/\\/g, "/");
|
|
459
|
+
// Windows drive paths need a leading slash: C:/foo → /C:/foo → file:///C:/foo
|
|
460
|
+
const withSlash = /^[A-Za-z]:/.test(normalized) ? `/${normalized}` : normalized;
|
|
461
|
+
return `file://${withSlash}`;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Convert a `file://` URI back to an absolute file path.
|
|
466
|
+
*/
|
|
467
|
+
export function uriToPath(uri: string): string {
|
|
468
|
+
if (uri.startsWith("file://")) {
|
|
469
|
+
let rest = uri.slice("file://".length);
|
|
470
|
+
// Decode percent-encoded characters (e.g. %20 → space) per RFC 8089.
|
|
471
|
+
try {
|
|
472
|
+
rest = decodeURIComponent(rest);
|
|
473
|
+
} catch {
|
|
474
|
+
// Malformed escape sequence — keep raw path (best-effort).
|
|
475
|
+
}
|
|
476
|
+
// Windows: file:///C:/foo → C:/foo
|
|
477
|
+
if (/^\/[A-Za-z]:/.test(rest)) {
|
|
478
|
+
return rest.slice(1).replace(/\//g, path.sep);
|
|
479
|
+
}
|
|
480
|
+
// Unix: file:///foo → /foo
|
|
481
|
+
return rest.replace(/\//g, path.sep);
|
|
482
|
+
}
|
|
483
|
+
return uri;
|
|
484
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSP configuration types and defaults (issue #1555 — rule 30/48).
|
|
3
|
+
*
|
|
4
|
+
* `codingGraph.lsp.enabled` defaults `false`. Enabling LSP with zero
|
|
5
|
+
* servers installed must produce a working index identical to Phase A
|
|
6
|
+
* plus visible degradations — the characterization test proves this.
|
|
7
|
+
*
|
|
8
|
+
* Env overrides follow gotcha 9: `REMNIC_CODING_GRAPH_LSP_ENABLED` with
|
|
9
|
+
* `ENGRAM_` fallback.
|
|
10
|
+
*/
|
|
11
|
+
import process from "node:process";
|
|
12
|
+
|
|
13
|
+
import type { CodingGraphLanguage } from "@remnic/core";
|
|
14
|
+
|
|
15
|
+
import type { LspDegradation } from "./degradation.js";
|
|
16
|
+
|
|
17
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
18
|
+
// Server launch spec — argv array end-to-end (rule 10).
|
|
19
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How to launch a language server: command + argv. Always an array of
|
|
23
|
+
* strings — never a shell string — so injection via config is impossible
|
|
24
|
+
* (rule 10: argv arrays end-to-end).
|
|
25
|
+
*/
|
|
26
|
+
export interface LspServerLaunchSpec {
|
|
27
|
+
readonly command: string;
|
|
28
|
+
readonly args: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Per-language server overrides. Keys are language identifiers (matching
|
|
33
|
+
* {@link CodingGraphLanguage}); values are launch specs that REPLACE the
|
|
34
|
+
* default. An unknown language key is an error (rule 51 — list supported
|
|
35
|
+
* languages).
|
|
36
|
+
*/
|
|
37
|
+
export type LspServerOverrides = Partial<Record<CodingGraphLanguage, LspServerLaunchSpec>>;
|
|
38
|
+
|
|
39
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
40
|
+
// Configuration shape
|
|
41
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export interface LspConfig {
|
|
44
|
+
/** Master switch — default false (rule 30/48). */
|
|
45
|
+
readonly enabled: boolean;
|
|
46
|
+
/** Per-language server overrides (default: empty — use registry defaults). */
|
|
47
|
+
readonly servers: LspServerOverrides;
|
|
48
|
+
/** Handshake + per-request timeout in ms (default 3000). */
|
|
49
|
+
readonly timeoutMs: number;
|
|
50
|
+
/** Max definition requests per index run (default 500). */
|
|
51
|
+
readonly maxRequestsPerRun: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const DEFAULT_LSP_TIMEOUT_MS = 3_000;
|
|
55
|
+
export const DEFAULT_LSP_MAX_REQUESTS_PER_RUN = 500;
|
|
56
|
+
|
|
57
|
+
export const DEFAULT_LSP_CONFIG: LspConfig = {
|
|
58
|
+
enabled: false,
|
|
59
|
+
servers: {},
|
|
60
|
+
timeoutMs: DEFAULT_LSP_TIMEOUT_MS,
|
|
61
|
+
maxRequestsPerRun: DEFAULT_LSP_MAX_REQUESTS_PER_RUN,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Parse and validate user-supplied LSP config. Unknown keys in `servers`
|
|
66
|
+
* are rejected with a degradation listing supported languages (rule 51).
|
|
67
|
+
* Non-executable absolute paths are rejected (rule 24 analog).
|
|
68
|
+
*
|
|
69
|
+
* Returns `{ ok: true, config }` or `{ ok: false, degradation }` — never
|
|
70
|
+
* throws (rule 13).
|
|
71
|
+
*/
|
|
72
|
+
export type LspConfigParseResult =
|
|
73
|
+
| { readonly ok: true; readonly config: LspConfig }
|
|
74
|
+
| { readonly ok: false; readonly degradation: LspDegradation };
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Parse the `enabled` flag strictly. Boolean values pass through; strings
|
|
78
|
+
* like `"false"`, `"0"`, `"no"`, and `""` are false (so env/CLI overrides
|
|
79
|
+
* work as operators expect). Any other truthy value enables.
|
|
80
|
+
*/
|
|
81
|
+
function parseEnabledFlag(v: unknown): boolean {
|
|
82
|
+
if (typeof v === "boolean") return v;
|
|
83
|
+
if (typeof v === "string") {
|
|
84
|
+
const lower = v.trim().toLowerCase();
|
|
85
|
+
return lower !== "false" && lower !== "0" && lower !== "no" && lower !== "";
|
|
86
|
+
}
|
|
87
|
+
return Boolean(v);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Parse and validate user-supplied LSP config. Unknown keys in `servers`
|
|
92
|
+
* are rejected with a degradation listing supported languages (rule 51).
|
|
93
|
+
* Non-executable absolute paths are rejected (rule 24 analog).
|
|
94
|
+
*
|
|
95
|
+
* Returns `{ ok: true, config }` or `{ ok: false, degradation }` — never
|
|
96
|
+
* throws (rule 13).
|
|
97
|
+
*/
|
|
98
|
+
export function parseLspConfig(
|
|
99
|
+
raw: unknown,
|
|
100
|
+
knownLanguages: readonly CodingGraphLanguage[],
|
|
101
|
+
): LspConfigParseResult {
|
|
102
|
+
if (raw === null || raw === undefined) {
|
|
103
|
+
return { ok: true, config: DEFAULT_LSP_CONFIG };
|
|
104
|
+
}
|
|
105
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
degradation: {
|
|
109
|
+
backend: "lsp",
|
|
110
|
+
code: "protocol_error",
|
|
111
|
+
detail: "codingGraph.lsp must be an object",
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const obj = raw as Record<string, unknown>;
|
|
116
|
+
const knownSet = new Set(knownLanguages);
|
|
117
|
+
|
|
118
|
+
const enabled = obj.enabled === undefined ? false : parseEnabledFlag(obj.enabled);
|
|
119
|
+
const timeoutMs = obj.timeoutMs === undefined ? DEFAULT_LSP_TIMEOUT_MS : Number(obj.timeoutMs);
|
|
120
|
+
const maxRequestsPerRun =
|
|
121
|
+
obj.maxRequestsPerRun === undefined ? DEFAULT_LSP_MAX_REQUESTS_PER_RUN : Number(obj.maxRequestsPerRun);
|
|
122
|
+
|
|
123
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
degradation: {
|
|
127
|
+
backend: "lsp",
|
|
128
|
+
code: "protocol_error",
|
|
129
|
+
detail: `lsp.timeoutMs must be a non-negative number, got ${JSON.stringify(obj.timeoutMs)}`,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (!Number.isFinite(maxRequestsPerRun) || maxRequestsPerRun < 0) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
degradation: {
|
|
137
|
+
backend: "lsp",
|
|
138
|
+
code: "protocol_error",
|
|
139
|
+
detail: `lsp.maxRequestsPerRun must be a non-negative number, got ${JSON.stringify(obj.maxRequestsPerRun)}`,
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const servers: Partial<Record<CodingGraphLanguage, LspServerLaunchSpec>> = {};
|
|
145
|
+
if (obj.servers !== undefined && obj.servers !== null) {
|
|
146
|
+
if (typeof obj.servers !== "object" || Array.isArray(obj.servers)) {
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
degradation: {
|
|
150
|
+
backend: "lsp",
|
|
151
|
+
code: "protocol_error",
|
|
152
|
+
detail: "lsp.servers must be an object",
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
for (const [lang, spec] of Object.entries(obj.servers as Record<string, unknown>)) {
|
|
157
|
+
if (!knownSet.has(lang as CodingGraphLanguage)) {
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
degradation: {
|
|
161
|
+
backend: "lsp",
|
|
162
|
+
code: "unknown_language",
|
|
163
|
+
detail: `unknown language "${lang}" in lsp.servers; supported: ${knownLanguages.join(", ")}`,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (typeof spec !== "object" || spec === null || Array.isArray(spec)) {
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
degradation: {
|
|
171
|
+
backend: "lsp",
|
|
172
|
+
code: "protocol_error",
|
|
173
|
+
detail: `lsp.servers.${lang} must be an object`,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
const s = spec as Record<string, unknown>;
|
|
178
|
+
if (typeof s.command !== "string" || s.command.length === 0) {
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
degradation: {
|
|
182
|
+
backend: "lsp",
|
|
183
|
+
code: "protocol_error",
|
|
184
|
+
detail: `lsp.servers.${lang}.command must be a non-empty string`,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (!Array.isArray(s.args) || s.args.some((a) => typeof a !== "string")) {
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
degradation: {
|
|
192
|
+
backend: "lsp",
|
|
193
|
+
code: "protocol_error",
|
|
194
|
+
detail: `lsp.servers.${lang}.args must be an array of strings`,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
servers[lang as CodingGraphLanguage] = {
|
|
199
|
+
command: s.command,
|
|
200
|
+
args: [...(s.args as string[])],
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { ok: true, config: { enabled, servers, timeoutMs, maxRequestsPerRun } };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Read the env-var override for `lsp.enabled`. Returns the raw string
|
|
210
|
+
* value or null. The caller decides how to interpret it (gotcha 9:
|
|
211
|
+
* `REMNIC_` primary, `ENGRAM_` fallback).
|
|
212
|
+
*/
|
|
213
|
+
export function readLspEnabledEnv(): string | null {
|
|
214
|
+
return (
|
|
215
|
+
process.env.REMNIC_CODING_GRAPH_LSP_ENABLED ??
|
|
216
|
+
process.env.ENGRAM_CODING_GRAPH_LSP_ENABLED ??
|
|
217
|
+
null
|
|
218
|
+
);
|
|
219
|
+
}
|