@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.
Files changed (86) hide show
  1. package/README.md +130 -0
  2. package/dist/chunk-5I2DBHOQ.js +1042 -0
  3. package/dist/chunk-5I2DBHOQ.js.map +1 -0
  4. package/dist/chunk-CPYJACC5.js +1838 -0
  5. package/dist/chunk-CPYJACC5.js.map +1 -0
  6. package/dist/chunk-ZVCMIM4T.js +216 -0
  7. package/dist/chunk-ZVCMIM4T.js.map +1 -0
  8. package/dist/cypher/query-parser.d.ts +253 -0
  9. package/dist/cypher/query-parser.js +17 -0
  10. package/dist/cypher/query-parser.js.map +1 -0
  11. package/dist/graph-schema.d.ts +84 -0
  12. package/dist/graph-schema.js +17 -0
  13. package/dist/graph-schema.js.map +1 -0
  14. package/dist/graph-store.d.ts +938 -0
  15. package/dist/graph-store.js +16 -0
  16. package/dist/graph-store.js.map +1 -0
  17. package/dist/index.d.ts +1953 -0
  18. package/dist/index.js +3509 -0
  19. package/dist/index.js.map +1 -0
  20. package/grammars/tree-sitter-bash.wasm +0 -0
  21. package/grammars/tree-sitter-c.wasm +0 -0
  22. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  23. package/grammars/tree-sitter-cpp.wasm +0 -0
  24. package/grammars/tree-sitter-go.wasm +0 -0
  25. package/grammars/tree-sitter-java.wasm +0 -0
  26. package/grammars/tree-sitter-javascript.wasm +0 -0
  27. package/grammars/tree-sitter-kotlin.wasm +0 -0
  28. package/grammars/tree-sitter-php.wasm +0 -0
  29. package/grammars/tree-sitter-python.wasm +0 -0
  30. package/grammars/tree-sitter-ruby.wasm +0 -0
  31. package/grammars/tree-sitter-rust.wasm +0 -0
  32. package/grammars/tree-sitter-swift.wasm +0 -0
  33. package/grammars/tree-sitter-tsx.wasm +0 -0
  34. package/grammars/tree-sitter-typescript.wasm +0 -0
  35. package/package.json +79 -0
  36. package/src/co-change.test.ts +175 -0
  37. package/src/co-change.ts +167 -0
  38. package/src/cypher/query-parser.test.ts +1107 -0
  39. package/src/cypher/query-parser.ts +1692 -0
  40. package/src/detect-changes.test.ts +533 -0
  41. package/src/detect-changes.ts +367 -0
  42. package/src/engine/emit.ts +556 -0
  43. package/src/engine/engine.test.ts +1417 -0
  44. package/src/engine/engine.ts +182 -0
  45. package/src/engine/extractors.ts +486 -0
  46. package/src/engine/fixtures.ts +364 -0
  47. package/src/engine/language-sniff.ts +56 -0
  48. package/src/engine/parser-backend.ts +206 -0
  49. package/src/engine/utf16-offsets.ts +68 -0
  50. package/src/git-invoker.test.ts +116 -0
  51. package/src/git-invoker.ts +426 -0
  52. package/src/graph-schema.test.ts +541 -0
  53. package/src/graph-schema.ts +383 -0
  54. package/src/graph-store-pr2.test.ts +1879 -0
  55. package/src/graph-store.test.ts +1420 -0
  56. package/src/graph-store.ts +3489 -0
  57. package/src/index-status.test.ts +303 -0
  58. package/src/index-status.ts +135 -0
  59. package/src/index.ts +384 -0
  60. package/src/lsp/byte-position.ts +173 -0
  61. package/src/lsp/characterization.test.ts +174 -0
  62. package/src/lsp/client.test.ts +275 -0
  63. package/src/lsp/client.ts +484 -0
  64. package/src/lsp/config.ts +219 -0
  65. package/src/lsp/degradation.ts +86 -0
  66. package/src/lsp/fixtures/fake-server.mjs +198 -0
  67. package/src/lsp/framing.test.ts +180 -0
  68. package/src/lsp/framing.ts +177 -0
  69. package/src/lsp/resolution.test.ts +497 -0
  70. package/src/lsp/resolution.ts +483 -0
  71. package/src/lsp/status.ts +140 -0
  72. package/src/lsp/types.ts +167 -0
  73. package/src/reindex.test.ts +1038 -0
  74. package/src/reindex.ts +908 -0
  75. package/src/row-types.ts +45 -0
  76. package/src/semantic/canonical-text.test.ts +150 -0
  77. package/src/semantic/canonical-text.ts +219 -0
  78. package/src/semantic/config.ts +235 -0
  79. package/src/semantic/index.ts +78 -0
  80. package/src/semantic/minhash.test.ts +197 -0
  81. package/src/semantic/minhash.ts +261 -0
  82. package/src/semantic/semantic-query.ts +173 -0
  83. package/src/semantic/semantic.test.ts +1315 -0
  84. package/src/semantic/similarity.ts +268 -0
  85. package/src/semantic/types.ts +145 -0
  86. package/src/semantic/vectors.ts +235 -0
@@ -0,0 +1,86 @@
1
+ /**
2
+ * LSP degradation codes — shaped like {@link SearchDegradation} from
3
+ * @remnic/core/search/port (issue #1536, CLAUDE.md rule 34).
4
+ *
5
+ * Every failure mode produces a DISTINCT code so callers (index_status,
6
+ * remnic doctor) can render per-language LSP state without conflating
7
+ * "server not installed" with "server timed out" or "protocol violation".
8
+ *
9
+ * The codes are the load-bearing signal — `detail` is optional and never
10
+ * carries `error.message` (which may contain absolute paths — rule 11).
11
+ */
12
+
13
+ /**
14
+ * Backend identifier — always `"lsp"` so callers can distinguish LSP
15
+ * degradations from QMD/search degradations in a unified handler.
16
+ */
17
+ export type LspBackend = "lsp";
18
+
19
+ /**
20
+ * Distinct failure codes (rule 34 — never `[]`-on-error).
21
+ *
22
+ * - `server_missing` — the configured server binary is not on PATH
23
+ * or is not executable (probe phase).
24
+ * - `handshake_timeout` — `initialize` did not complete within
25
+ * `lsp.timeoutMs`.
26
+ * - `handshake_error` — server responded to `initialize` with an
27
+ * error or a malformed response.
28
+ * - `request_timeout` — a `textDocument/definition` request did
29
+ * not receive a response within the
30
+ * per-request deadline.
31
+ * - `request_error` — server returned a JSON-RPC error response
32
+ * for a resolution request.
33
+ * - `protocol_error` — malformed JSON-RPC frame (bad header,
34
+ * unparseable JSON, unknown method).
35
+ * - `server_crashed` — the child process exited unexpectedly
36
+ * mid-run.
37
+ * - `budget_exhausted` — `lsp.maxRequestsPerRun` reached; remaining
38
+ * call sites keep their Phase A resolution.
39
+ * - `not_enabled` — `lsp.enabled` is `false`; no probe attempted.
40
+ * - `unknown_language` — the file's language has no registered
41
+ * server spec and none was overridden in
42
+ * `lsp.servers`.
43
+ */
44
+ export type LspDegradationCode =
45
+ | "server_missing"
46
+ | "handshake_timeout"
47
+ | "handshake_error"
48
+ | "request_timeout"
49
+ | "request_error"
50
+ | "protocol_error"
51
+ | "server_crashed"
52
+ | "budget_exhausted"
53
+ | "not_enabled"
54
+ | "unknown_language";
55
+
56
+ /**
57
+ * Tagged degradation — mirrors the shape of {@link SearchDegradation}.
58
+ * `ok: false` results from the client/registry/resolution surface carry
59
+ * this object so consumers can switch on `code` programmatically.
60
+ */
61
+ export interface LspDegradation {
62
+ readonly backend: LspBackend;
63
+ readonly code: LspDegradationCode;
64
+ readonly detail?: string;
65
+ }
66
+
67
+ /**
68
+ * Tagged-result helpers — the shared discriminated-union pattern used
69
+ * throughout the coding-graph package (rule 34). Every LSP surface
70
+ * returns `{ ok: true; … }` on success or `{ ok: false; degradation }`
71
+ * on failure — never throws, never returns `[]` to mean "error".
72
+ */
73
+ export type LspResult<T> =
74
+ | ({ readonly ok: true } & T)
75
+ | { readonly ok: false; readonly degradation: LspDegradation };
76
+
77
+ /**
78
+ * Construct a degradation object. Kept as a factory rather than a class
79
+ * so callers can spread it into a result without `new`.
80
+ */
81
+ export function lspDegradation(
82
+ code: LspDegradationCode,
83
+ detail?: string,
84
+ ): LspDegradation {
85
+ return detail !== undefined ? { backend: "lsp", code, detail } : { backend: "lsp", code };
86
+ }
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Fake LSP server — a scripted JSON-RPC-over-stdio responder for tests.
4
+ *
5
+ * Checked into the package's test fixtures. Synthetic responses only —
6
+ * it does NOT analyze code. Instead it reads a scenario from argv and
7
+ * produces canned LSP-shaped responses that match the real protocol
8
+ * wire format (rule 33 — cite spec version: LSP 3.17).
9
+ *
10
+ * Scenario is passed as the LAST argv element (after `--`):
11
+ *
12
+ * node fake-server.mjs -- <scenario-name>
13
+ *
14
+ * Scenarios:
15
+ * happy — full handshake + returns definition locations.
16
+ * missing — exits immediately with code 1.
17
+ * handshake_timeout — accepts initialize request but never responds.
18
+ * request_timeout — completes handshake but never responds to definition.
19
+ * protocol_error — sends a malformed frame after receiving initialize.
20
+ * crash_after_start — completes handshake, then exits mid-run.
21
+ */
22
+
23
+ import process from "node:process";
24
+
25
+ const scenario = process.argv[process.argv.length - 1];
26
+
27
+ // ──────────────────────────────────────────────────────────────────────────
28
+ // Persistent stdin buffer — survives across readFrame calls so data from
29
+ // a combined chunk that contains multiple frames is not lost.
30
+ // ──────────────────────────────────────────────────────────────────────────
31
+
32
+ let stdinBuf = "";
33
+ let stdinWaiters = [];
34
+
35
+ process.stdin.setEncoding("utf8");
36
+ process.stdin.on("data", (chunk) => {
37
+ stdinBuf += chunk;
38
+ // Notify all waiters that new data arrived.
39
+ const waiters = stdinWaiters;
40
+ stdinWaiters = [];
41
+ for (const w of waiters) w();
42
+ });
43
+ process.stdin.on("end", () => {
44
+ const waiters = stdinWaiters;
45
+ stdinWaiters = [];
46
+ for (const w of waiters) w();
47
+ });
48
+
49
+ /** Wait for more stdin data to arrive. */
50
+ function waitForData() {
51
+ const { promise, resolve } = Promise.withResolvers();
52
+ stdinWaiters.push(resolve);
53
+ return promise;
54
+ }
55
+
56
+ // ──────────────────────────────────────────────────────────────────────────
57
+ // Frame I/O helpers — read/write Content-Length-prefixed messages.
58
+ // ──────────────────────────────────────────────────────────────────────────
59
+
60
+ function writeFrame(obj) {
61
+ const body = JSON.stringify(obj);
62
+ const byteLength = Buffer.byteLength(body, "utf8");
63
+ process.stdout.write(`Content-Length: ${byteLength}\r\n\r\n${body}`);
64
+ }
65
+
66
+ async function readFrame() {
67
+ // Loop until we have a complete frame in the persistent buffer.
68
+ // eslint-disable-next-line no-constant-condition
69
+ while (true) {
70
+ const sep = stdinBuf.indexOf("\r\n\r\n");
71
+ if (sep < 0) {
72
+ // No complete header yet — wait for more data.
73
+ if (stdinBuf === null) return null; // stdin ended
74
+ await waitForData();
75
+ continue;
76
+ }
77
+ const header = stdinBuf.slice(0, sep);
78
+ const match = /Content-Length:\s*(\d+)/i.exec(header);
79
+ if (!match) {
80
+ // Malformed — treat as end of stream.
81
+ return null;
82
+ }
83
+ const contentLength = Number(match[1]);
84
+ const bodyStart = sep + 4;
85
+ const bodyEnd = bodyStart + contentLength;
86
+ if (stdinBuf.length < bodyEnd) {
87
+ // Body not fully received — wait for more.
88
+ await waitForData();
89
+ continue;
90
+ }
91
+ const body = stdinBuf.slice(bodyStart, bodyEnd);
92
+ // Consume the frame from the persistent buffer, keeping any residual.
93
+ stdinBuf = stdinBuf.slice(bodyEnd);
94
+ try {
95
+ return JSON.parse(body);
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+ }
101
+
102
+ // ──────────────────────────────────────────────────────────────────────────
103
+ // Scenario handlers
104
+ // ──────────────────────────────────────────────────────────────────────────
105
+
106
+ async function main() {
107
+ switch (scenario) {
108
+ case "missing":
109
+ process.exit(1);
110
+
111
+ case "handshake_timeout":
112
+ await readFrame(); // consume initialize, never respond
113
+ await new Promise(() => {}); // hang forever
114
+ return;
115
+
116
+ case "request_timeout": {
117
+ const init = await readFrame();
118
+ writeFrame({
119
+ jsonrpc: "2.0",
120
+ id: init.id,
121
+ result: {
122
+ capabilities: { definitionProvider: true },
123
+ serverInfo: { name: "fake-timeout" },
124
+ },
125
+ });
126
+ await readFrame(); // initialized
127
+ await readFrame(); // definition request — never respond
128
+ await new Promise(() => {});
129
+ return;
130
+ }
131
+
132
+ case "protocol_error": {
133
+ await readFrame(); // initialize
134
+ process.stdout.write("THIS IS NOT A VALID LSP FRAME\r\n\r\n");
135
+ process.exit(0);
136
+ return;
137
+ }
138
+
139
+ case "crash_after_start": {
140
+ const init = await readFrame();
141
+ writeFrame({
142
+ jsonrpc: "2.0",
143
+ id: init.id,
144
+ result: {
145
+ capabilities: { definitionProvider: true },
146
+ serverInfo: { name: "fake-crash" },
147
+ },
148
+ });
149
+ await readFrame(); // initialized
150
+ process.exit(1); // crash mid-run
151
+ return;
152
+ }
153
+
154
+ case "happy":
155
+ default: {
156
+ const init = await readFrame();
157
+ if (!init) return;
158
+ writeFrame({
159
+ jsonrpc: "2.0",
160
+ id: init.id,
161
+ result: {
162
+ capabilities: { definitionProvider: true },
163
+ serverInfo: { name: "fake-happy", version: "1.0.0" },
164
+ },
165
+ });
166
+ await readFrame(); // initialized
167
+ await readFrame(); // didOpen
168
+
169
+ // Answer definition/shutdown/exit until stdin closes.
170
+ // eslint-disable-next-line no-constant-condition
171
+ while (true) {
172
+ const req = await readFrame();
173
+ if (!req) return;
174
+ if (req.method === "textDocument/definition") {
175
+ const envLoc = process.env.FAKE_LSP_DEFINITION;
176
+ const result = envLoc
177
+ ? JSON.parse(envLoc)
178
+ : [
179
+ {
180
+ uri: "file:///fake/src/target.ts",
181
+ range: {
182
+ start: { line: 0, character: 0 },
183
+ end: { line: 0, character: 10 },
184
+ },
185
+ },
186
+ ];
187
+ writeFrame({ jsonrpc: "2.0", id: req.id, result });
188
+ } else if (req.method === "shutdown") {
189
+ writeFrame({ jsonrpc: "2.0", id: req.id, result: null });
190
+ } else if (req.method === "exit") {
191
+ process.exit(0);
192
+ }
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ main().catch(() => process.exit(1));
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Framing edge-case tests (issue #1555 step 2 — prove-fail-before).
3
+ *
4
+ * Covers the off-by-one and split-buffer traps that make LSP framing
5
+ * the most error-prone part of the client:
6
+ * - single complete frame
7
+ * - frame split across multiple chunks (body boundary)
8
+ * - header split across chunks (separator boundary)
9
+ * - multiple frames in one chunk
10
+ * - partial frame (body not yet complete) → zero messages
11
+ * - Content-Length counts UTF-8 BYTES not UTF-16 code units
12
+ * - malformed header (no Content-Length) → decode error
13
+ * - invalid JSON body → decode error
14
+ * - empty feed → zero messages, no throw
15
+ */
16
+ import assert from "node:assert/strict";
17
+ import test from "node:test";
18
+
19
+ import {
20
+ LspFrameDecoder,
21
+ encodeLspFrame,
22
+ } from "./framing.js";
23
+
24
+ test("encodeLspFrame: Content-Length header counts UTF-8 bytes, not UTF-16 code units", () => {
25
+ // The emoji 𝕏 is 4 UTF-8 bytes (F0 9D 95 8F) but 2 UTF-16 code units.
26
+ const frame = encodeLspFrame({ method: "test", params: { text: "𝕏" } });
27
+ // The body is the JSON-serialized object. We check that the header
28
+ // Content-Length matches Buffer.byteLength of the body.
29
+ const headerEnd = frame.indexOf("\r\n\r\n");
30
+ const header = frame.slice(0, headerEnd);
31
+ const body = frame.slice(headerEnd + 4);
32
+ const match = /Content-Length:\s*(\d+)/i.exec(header);
33
+ assert.ok(match, "header must contain Content-Length");
34
+ const declared = Number(match[1]);
35
+ const actual = Buffer.byteLength(body, "utf8");
36
+ assert.equal(declared, actual, "Content-Length must match UTF-8 byte count");
37
+ });
38
+
39
+ test("decode: single complete frame in one chunk", () => {
40
+ const dec = new LspFrameDecoder();
41
+ const frame = encodeLspFrame({ jsonrpc: "2.0", id: 1, result: { ok: true } });
42
+ const result = dec.feed(frame);
43
+ assert.equal(result.ok, true);
44
+ if (result.ok) {
45
+ assert.equal(result.messages.length, 1);
46
+ assert.deepEqual(result.messages[0], { jsonrpc: "2.0", id: 1, result: { ok: true } });
47
+ }
48
+ assert.equal(dec.hasResidual, false);
49
+ });
50
+
51
+ test("decode: frame body split across two chunks", () => {
52
+ const dec = new LspFrameDecoder();
53
+ const frame = encodeLspFrame({ jsonrpc: "2.0", id: 1, result: { ok: true } });
54
+ const headerEnd = frame.indexOf("\r\n\r\n") + 4;
55
+ const header = frame.slice(0, headerEnd);
56
+ const body = frame.slice(headerEnd);
57
+ const mid = Math.floor(body.length / 2);
58
+
59
+ // First chunk: header + half the body → zero messages.
60
+ let result = dec.feed(header + body.slice(0, mid));
61
+ assert.equal(result.ok, true);
62
+ if (result.ok) assert.equal(result.messages.length, 0);
63
+
64
+ // Second chunk: rest of body → one message.
65
+ result = dec.feed(body.slice(mid));
66
+ assert.equal(result.ok, true);
67
+ if (result.ok) {
68
+ assert.equal(result.messages.length, 1);
69
+ assert.deepEqual(result.messages[0], { jsonrpc: "2.0", id: 1, result: { ok: true } });
70
+ }
71
+ });
72
+
73
+ test("decode: header separator split across two chunks", () => {
74
+ const dec = new LspFrameDecoder();
75
+ const frame = encodeLspFrame({ jsonrpc: "2.0", id: 1, result: "x" });
76
+ // Split right in the middle of \r\n\r\n.
77
+ const sepIdx = frame.indexOf("\r\n\r\n");
78
+ const splitPoint = sepIdx + 2; // after first \r\n, before second \r\n
79
+
80
+ let result = dec.feed(frame.slice(0, splitPoint));
81
+ assert.equal(result.ok, true);
82
+ if (result.ok) assert.equal(result.messages.length, 0);
83
+
84
+ result = dec.feed(frame.slice(splitPoint));
85
+ assert.equal(result.ok, true);
86
+ if (result.ok) {
87
+ assert.equal(result.messages.length, 1);
88
+ assert.deepEqual(result.messages[0], { jsonrpc: "2.0", id: 1, result: "x" });
89
+ }
90
+ });
91
+
92
+ test("decode: multiple frames in one chunk", () => {
93
+ const dec = new LspFrameDecoder();
94
+ const f1 = encodeLspFrame({ jsonrpc: "2.0", id: 1, result: "a" });
95
+ const f2 = encodeLspFrame({ jsonrpc: "2.0", id: 2, result: "b" });
96
+ const f3 = encodeLspFrame({ jsonrpc: "2.0", id: 3, result: "c" });
97
+
98
+ const result = dec.feed(f1 + f2 + f3);
99
+ assert.equal(result.ok, true);
100
+ if (result.ok) {
101
+ assert.equal(result.messages.length, 3);
102
+ assert.deepEqual(result.messages[0], { jsonrpc: "2.0", id: 1, result: "a" });
103
+ assert.deepEqual(result.messages[1], { jsonrpc: "2.0", id: 2, result: "b" });
104
+ assert.deepEqual(result.messages[2], { jsonrpc: "2.0", id: 3, result: "c" });
105
+ }
106
+ assert.equal(dec.hasResidual, false);
107
+ });
108
+
109
+ test("decode: partial header (no separator yet) → zero messages, no throw", () => {
110
+ const dec = new LspFrameDecoder();
111
+ const result = dec.feed("Content-Length: 10\r\n");
112
+ assert.equal(result.ok, true);
113
+ if (result.ok) assert.equal(result.messages.length, 0);
114
+ assert.equal(dec.hasResidual, true);
115
+ });
116
+
117
+ test("decode: malformed header (no Content-Length) → decode error", () => {
118
+ const dec = new LspFrameDecoder();
119
+ const result = dec.feed("Content-Type: text/plain\r\n\r\n{}");
120
+ assert.equal(result.ok, false);
121
+ if (!result.ok) {
122
+ assert.equal(result.error.kind, "malformed_header");
123
+ }
124
+ });
125
+
126
+ test("decode: invalid JSON body → decode error", () => {
127
+ const dec = new LspFrameDecoder();
128
+ const body = "{ not valid json";
129
+ const byteLength = Buffer.byteLength(body, "utf8");
130
+ const frame = `Content-Length: ${byteLength}\r\n\r\n${body}`;
131
+ const result = dec.feed(frame);
132
+ assert.equal(result.ok, false);
133
+ if (!result.ok) {
134
+ assert.equal(result.error.kind, "json_parse_error");
135
+ }
136
+ });
137
+
138
+ test("decode: empty feed → zero messages, no throw", () => {
139
+ const dec = new LspFrameDecoder();
140
+ const result = dec.feed("");
141
+ assert.equal(result.ok, true);
142
+ if (result.ok) assert.equal(result.messages.length, 0);
143
+ assert.equal(dec.hasResidual, false);
144
+ });
145
+
146
+ test("decode: UTF-8 multi-byte body — Content-Length matches byte count", () => {
147
+ const dec = new LspFrameDecoder();
148
+ const obj = { jsonrpc: "2.0", id: 1, result: "𝕏𝕏𝕏" };
149
+ const frame = encodeLspFrame(obj);
150
+ const result = dec.feed(frame);
151
+ assert.equal(result.ok, true);
152
+ if (result.ok) {
153
+ assert.equal(result.messages.length, 1);
154
+ assert.deepEqual(result.messages[0], obj);
155
+ }
156
+ });
157
+
158
+ test("decode: sequential frames after partial — scan offset resets correctly", () => {
159
+ // Regression guard: after extracting a frame that was split, the
160
+ // scanOffset must reset so the next frame's separator is found from
161
+ // the beginning of the new buffer, not from a stale offset.
162
+ const dec = new LspFrameDecoder();
163
+ const f1 = encodeLspFrame({ id: 1 });
164
+ const f2 = encodeLspFrame({ id: 2 });
165
+
166
+ // Feed f1 in two chunks to force a split-extract cycle.
167
+ const mid = Math.floor(f1.length / 2);
168
+ dec.feed(f1.slice(0, mid));
169
+ let result = dec.feed(f1.slice(mid));
170
+ assert.equal(result.ok, true);
171
+ if (result.ok) assert.equal(result.messages.length, 1);
172
+
173
+ // Now feed f2 — it should parse cleanly from the reset offset.
174
+ result = dec.feed(f2);
175
+ assert.equal(result.ok, true);
176
+ if (result.ok) {
177
+ assert.equal(result.messages.length, 1);
178
+ assert.deepEqual(result.messages[0], { id: 2 });
179
+ }
180
+ });
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Length-prefixed LSP framing — `Content-Length: <N>\r\n\r\n` headers
3
+ * followed by exactly `<N>` bytes of JSON body (LSP 3.17 §6.1 — Base Protocol).
4
+ *
5
+ * This is the one module where off-by-one and split-buffer bugs hide.
6
+ * The parser tracks a RUNNING OFFSET — it never re-scans bytes a
7
+ * previous scan already confirmed separator-free (rule 32).
8
+ */
9
+
10
+ // ──────────────────────────────────────────────────────────────────────────
11
+ // Encoder — serialize a JSON-RPC message into a Content-Length frame.
12
+ // ──────────────────────────────────────────────────────────────────────────
13
+
14
+ /**
15
+ * Encode a JSON-RPC message as a Content-Length-prefixed frame ready to
16
+ * write to the server's stdin. Uses UTF-8 — the LSP base protocol
17
+ * mandates UTF-8 content encoding (§6.1).
18
+ */
19
+ export function encodeLspFrame(message: unknown): string {
20
+ const body = JSON.stringify(message);
21
+ // Content-Length counts UTF-8 BYTES, not JS UTF-16 code units. Node's
22
+ // Buffer.byteLength accounts for multi-byte sequences.
23
+ const byteLength = Buffer.byteLength(body, "utf8");
24
+ return `Content-Length: ${byteLength}\r\n\r\n${body}`;
25
+ }
26
+
27
+ // ──────────────────────────────────────────────────────────────────────────
28
+ // Decoder — streaming parser that accumulates chunks and yields complete
29
+ // messages. Never throws on partial input — it just returns fewer messages
30
+ // and retains the residual for the next feed() call.
31
+ // ──────────────────────────────────────────────────────────────────────────
32
+
33
+ /**
34
+ * Reason for a decode failure. A `protocol_error` degradation surfaces
35
+ * the specific reason so the caller can distinguish "bad header" from
36
+ * "unparseable JSON body".
37
+ */
38
+ export type FrameDecodeErrorKind =
39
+ | "malformed_header" // header line is not `Content-Length: <digits>`
40
+ | "json_parse_error"; // body is not valid JSON
41
+
42
+ export interface FrameDecodeError {
43
+ readonly kind: FrameDecodeErrorKind;
44
+ readonly detail: string;
45
+ }
46
+
47
+ export interface FrameDecodeSuccess {
48
+ readonly ok: true;
49
+ readonly messages: unknown[];
50
+ }
51
+
52
+ export type FrameDecodeResult = FrameDecodeSuccess | { readonly ok: false; readonly error: FrameDecodeError };
53
+
54
+ // Sentinel for the header/body separator — `\r\n\r\n` (as bytes).
55
+ const HEADER_SEPARATOR = Buffer.from("\r\n\r\n");
56
+ const HEADER_SEPARATOR_LEN = HEADER_SEPARATOR.length;
57
+
58
+ /**
59
+ * Streaming LSP frame decoder. Feed raw Buffer or string chunks via
60
+ * {@link feed}; each call returns the complete JSON messages parsed
61
+ * since the last call, plus any error if a frame was malformed.
62
+ *
63
+ * Works with BYTES internally because Content-Length counts UTF-8 bytes
64
+ * (LSP 3.17 §6.1), not UTF-16 code units. A string-based buffer would
65
+ * mis-slice any body containing multi-byte characters (𝕏, emoji, CJK).
66
+ *
67
+ * The decoder maintains a running byte-buffer and a scan offset. After
68
+ * each feed, consumed bytes are sliced away so the buffer never grows
69
+ * unbounded across a long session (rule 11 — no unbounded state).
70
+ */
71
+ export class LspFrameDecoder {
72
+ private buffer = Buffer.alloc(0);
73
+ /**
74
+ * Scan offset into {@link buffer}. The header scan resumes here on
75
+ * the next feed() — never re-scans bytes already confirmed to not
76
+ * contain the separator (rule 32). Reset to 0 after each consumed
77
+ * frame because slicing the buffer discards those bytes.
78
+ */
79
+ private scanOffset = 0;
80
+
81
+ /**
82
+ * Feed a raw chunk (Buffer or string) from the server's stdout.
83
+ * Returns all complete messages parsed from the accumulated buffer
84
+ * since the last call, or the first decode error encountered (the
85
+ * decoder stops on error — a protocol violation means the stream is
86
+ * corrupt and further parsing is undefined).
87
+ */
88
+ feed(chunk: Buffer | string): FrameDecodeResult {
89
+ const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
90
+ this.buffer = this.buffer.length === 0 ? Buffer.from(buf) : Buffer.concat([this.buffer, buf]);
91
+ const messages: unknown[] = [];
92
+
93
+ // Loop: extract as many complete frames as the current buffer holds.
94
+ // eslint-disable-next-line no-constant-condition
95
+ while (true) {
96
+ const sepIdx = this.buffer.indexOf(HEADER_SEPARATOR, this.scanOffset);
97
+ if (sepIdx < 0) {
98
+ // No complete header yet. Advance scanOffset past the bytes
99
+ // already confirmed separator-free (rule 32). The separator is
100
+ // 4 bytes, so a partial match could start up to 3 bytes before
101
+ // the current end.
102
+ this.scanOffset = Math.max(0, this.buffer.length - (HEADER_SEPARATOR_LEN - 1));
103
+ return { ok: true, messages };
104
+ }
105
+
106
+ const headerBlock = this.buffer.subarray(0, sepIdx).toString("utf8");
107
+ const contentLength = parseContentLength(headerBlock);
108
+ if (contentLength === null) {
109
+ return {
110
+ ok: false,
111
+ error: {
112
+ kind: "malformed_header",
113
+ detail: `header block has no valid Content-Length: ${JSON.stringify(headerBlock)}`,
114
+ },
115
+ };
116
+ }
117
+
118
+ const bodyStart = sepIdx + HEADER_SEPARATOR_LEN;
119
+ const bodyEnd = bodyStart + contentLength;
120
+ if (this.buffer.length < bodyEnd) {
121
+ // Body not fully received yet. Back up scanOffset to just before
122
+ // the separator so the next feed re-finds it.
123
+ this.scanOffset = Math.max(0, sepIdx - (HEADER_SEPARATOR_LEN - 1));
124
+ return { ok: true, messages };
125
+ }
126
+
127
+ const bodyBytes = this.buffer.subarray(bodyStart, bodyEnd);
128
+ let parsed: unknown;
129
+ try {
130
+ parsed = JSON.parse(bodyBytes.toString("utf8"));
131
+ } catch (e) {
132
+ return {
133
+ ok: false,
134
+ error: {
135
+ kind: "json_parse_error",
136
+ detail: `body is not valid JSON: ${e instanceof Error ? e.message : String(e)}`,
137
+ },
138
+ };
139
+ }
140
+ messages.push(parsed);
141
+
142
+ // Slice consumed bytes off the front — subarray returns a view
143
+ // into the same memory; concat on the next feed replaces it.
144
+ this.buffer = this.buffer.subarray(bodyEnd);
145
+ this.scanOffset = 0;
146
+ }
147
+ }
148
+
149
+ /** True if there is un-consumed residual data in the buffer. */
150
+ get hasResidual(): boolean {
151
+ return this.buffer.length > 0;
152
+ }
153
+
154
+ /** Reset the decoder to a clean state (test seam). */
155
+ reset(): void {
156
+ this.buffer = Buffer.alloc(0);
157
+ this.scanOffset = 0;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Parse the `Content-Length` value from a header block. Returns the byte
163
+ * count or null if the header is absent or malformed. LSP headers are
164
+ * case-insensitive and may appear in any order, but Content-Length is
165
+ * mandatory (§6.1).
166
+ */
167
+ function parseContentLength(headerBlock: string): number | null {
168
+ const lines = headerBlock.split("\r\n");
169
+ for (const line of lines) {
170
+ const match = /^Content-Length:\s*(\d+)\s*$/i.exec(line);
171
+ if (match) {
172
+ const n = Number(match[1]);
173
+ return Number.isFinite(n) && n >= 0 ? n : null;
174
+ }
175
+ }
176
+ return null;
177
+ }