@massu/core 1.2.1 → 1.4.0-soak.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.
- package/README.md +40 -0
- package/commands/README.md +137 -0
- package/commands/massu-deploy.python-docker.md +170 -0
- package/commands/massu-deploy.python-fly.md +189 -0
- package/commands/massu-deploy.python-launchd.md +144 -0
- package/commands/massu-deploy.python-systemd.md +163 -0
- package/commands/massu-deploy.python.md +200 -0
- package/commands/massu-scaffold-page.md +172 -59
- package/commands/massu-scaffold-page.swift.md +121 -0
- package/commands/massu-scaffold-router.python-django.md +153 -0
- package/commands/massu-scaffold-router.python-fastapi.md +145 -0
- package/commands/massu-scaffold-router.python.md +143 -0
- package/dist/cli.js +10170 -4138
- package/dist/hooks/auto-learning-pipeline.js +44 -6
- package/dist/hooks/classify-failure.js +44 -6
- package/dist/hooks/cost-tracker.js +44 -6
- package/dist/hooks/fix-detector.js +44 -6
- package/dist/hooks/incident-pipeline.js +44 -6
- package/dist/hooks/post-edit-context.js +44 -6
- package/dist/hooks/post-tool-use.js +44 -6
- package/dist/hooks/pre-compact.js +44 -6
- package/dist/hooks/pre-delete-check.js +44 -6
- package/dist/hooks/quality-event.js +44 -6
- package/dist/hooks/rule-enforcement-pipeline.js +44 -6
- package/dist/hooks/session-end.js +44 -6
- package/dist/hooks/session-start.js +4789 -410
- package/dist/hooks/user-prompt.js +44 -6
- package/package.json +10 -4
- package/src/cli.ts +28 -2
- package/src/commands/config-refresh.ts +88 -20
- package/src/commands/init.ts +130 -23
- package/src/commands/install-commands.ts +482 -42
- package/src/commands/refresh-log.ts +37 -0
- package/src/commands/show-template.ts +65 -0
- package/src/commands/template-engine.ts +262 -0
- package/src/commands/watch.ts +430 -0
- package/src/config.ts +69 -3
- package/src/detect/adapters/nextjs-trpc.ts +166 -0
- package/src/detect/adapters/parse-guard.ts +133 -0
- package/src/detect/adapters/python-django.ts +208 -0
- package/src/detect/adapters/python-fastapi.ts +223 -0
- package/src/detect/adapters/query-helpers.ts +170 -0
- package/src/detect/adapters/runner.ts +252 -0
- package/src/detect/adapters/swift-swiftui.ts +171 -0
- package/src/detect/adapters/tree-sitter-loader.ts +348 -0
- package/src/detect/adapters/types.ts +174 -0
- package/src/detect/codebase-introspector.ts +190 -0
- package/src/detect/index.ts +28 -2
- package/src/detect/regex-fallback.ts +449 -0
- package/src/hooks/session-start.ts +94 -3
- package/src/lib/gitToplevel.ts +22 -0
- package/src/lib/installLock.ts +179 -0
- package/src/lib/pidLiveness.ts +67 -0
- package/src/lsp/auto-detect.ts +89 -0
- package/src/lsp/client.ts +590 -0
- package/src/lsp/enrich.ts +127 -0
- package/src/lsp/types.ts +221 -0
- package/src/watch/daemon.ts +385 -0
- package/src/watch/lockfile-detector.ts +65 -0
- package/src/watch/paths.ts +279 -0
- package/src/watch/state.ts +178 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Plan 3b — Phase 4: Minimal JSON-RPC LSP client.
|
|
6
|
+
*
|
|
7
|
+
* Methods supported: `initialize`, `textDocument/documentSymbol`,
|
|
8
|
+
* `workspace/symbol`, `textDocument/definition`, `shutdown`.
|
|
9
|
+
*
|
|
10
|
+
* Wire transport: stdio JSON-RPC via `child_process.spawn` with the
|
|
11
|
+
* `Content-Length: N\r\n\r\n<body>` framing required by LSP.
|
|
12
|
+
*
|
|
13
|
+
* Security guarantees:
|
|
14
|
+
* - `command` MUST be a pre-split `[argv0, ...args]` array (no shell). The
|
|
15
|
+
* factory rejects shell-string input — the caller in `auto-detect.ts`
|
|
16
|
+
* splits commands safely. (Phase 3.5 finding #4)
|
|
17
|
+
* - Refuses paths containing `..`. Refuses non-absolute paths unless
|
|
18
|
+
* `allowRelativePath: true`.
|
|
19
|
+
* - Per-server method-support matrix: capabilities checked from the
|
|
20
|
+
* `initialize` response; methods whose `*Provider` capability is
|
|
21
|
+
* absent/false are SKIPPED (not sent). (audit-iter-2 fix N6)
|
|
22
|
+
* - MethodNotFound (-32601) for a method we did send → that single
|
|
23
|
+
* capability is marked unavailable for the lifetime of this client
|
|
24
|
+
* instance.
|
|
25
|
+
* - Every response payload is validated against the Zod schema from
|
|
26
|
+
* `types.ts` before the consumer sees it. Validation failure logs to
|
|
27
|
+
* stderr (per VR-USER-ERROR-MESSAGES item 2) and returns null.
|
|
28
|
+
* - 5s per-request timeout. On timeout: log info, return null.
|
|
29
|
+
* - Max body size 5MB. Oversized → log warning, abort, return null.
|
|
30
|
+
* - Mismatched response ids (response-injection) are silently dropped.
|
|
31
|
+
*
|
|
32
|
+
* Library purity: never terminates the process; never touches the memory DB.
|
|
33
|
+
* ESM imports throughout.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { spawn, type ChildProcess } from 'child_process';
|
|
37
|
+
import { isAbsolute } from 'path';
|
|
38
|
+
import {
|
|
39
|
+
DefinitionResponseSchema,
|
|
40
|
+
DocumentSymbolResponseSchema,
|
|
41
|
+
InitializeResponseSchema,
|
|
42
|
+
LSPErrorCode,
|
|
43
|
+
LSPMessageEnvelopeSchema,
|
|
44
|
+
WorkspaceSymbolResponseSchema,
|
|
45
|
+
type DefinitionResponse,
|
|
46
|
+
type DocumentSymbolResponse,
|
|
47
|
+
type InitializeResponse,
|
|
48
|
+
type Position,
|
|
49
|
+
type ServerCapabilities,
|
|
50
|
+
type WorkspaceSymbolResponse,
|
|
51
|
+
} from './types.ts';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Maximum body size (bytes) for any LSP response. Protection against memory
|
|
55
|
+
* exhaustion via oversized responses (Phase 3.5 finding #2).
|
|
56
|
+
*/
|
|
57
|
+
const MAX_RESPONSE_BODY_BYTES = 5 * 1024 * 1024;
|
|
58
|
+
/** Default per-request timeout (ms). LSP unresponsive → degrade to AST-only. */
|
|
59
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
|
|
60
|
+
/**
|
|
61
|
+
* Maximum cumulative bytes the parser will buffer waiting for a header to
|
|
62
|
+
* arrive. A malicious LSP that drips characters forever without ever
|
|
63
|
+
* producing `\r\n\r\n` would otherwise grow the inbound buffer unbounded.
|
|
64
|
+
* 1MB is far more than any legitimate header. (Phase 3.5 finding #2)
|
|
65
|
+
*/
|
|
66
|
+
const MAX_HEADER_BUFFER_BYTES = 1 * 1024 * 1024;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Strip prototype-pollution keys from any object before it crosses the
|
|
70
|
+
* trust boundary. Zod's `.passthrough()` accepts arbitrary keys including
|
|
71
|
+
* `__proto__` and `constructor.prototype`; we sanitise here so consumers
|
|
72
|
+
* never observe a polluted object. (Phase 3.5 finding #2 — prototype
|
|
73
|
+
* pollution.)
|
|
74
|
+
*/
|
|
75
|
+
function sanitizePolluted(value: unknown): unknown {
|
|
76
|
+
if (value === null || typeof value !== 'object') return value;
|
|
77
|
+
if (Array.isArray(value)) {
|
|
78
|
+
return value.map(sanitizePolluted);
|
|
79
|
+
}
|
|
80
|
+
const out: Record<string, unknown> = {};
|
|
81
|
+
for (const k of Object.keys(value as Record<string, unknown>)) {
|
|
82
|
+
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
|
|
83
|
+
out[k] = sanitizePolluted((value as Record<string, unknown>)[k]);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ============================================================
|
|
89
|
+
// Transport contract — pluggable for tests
|
|
90
|
+
// ============================================================
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* In-memory transport interface. Tests inject a stub; production wires the
|
|
94
|
+
* stdio of a spawned LSP process. The contract is:
|
|
95
|
+
* - `send(jsonText)`: client → server; framed by the transport.
|
|
96
|
+
* - `onMessage(fn)`: server → client; one parsed envelope per call.
|
|
97
|
+
* - `close()`: terminate cleanly.
|
|
98
|
+
*/
|
|
99
|
+
export interface LSPTransport {
|
|
100
|
+
send(json: string): void;
|
|
101
|
+
onMessage(handler: (envelope: unknown) => void): void;
|
|
102
|
+
onError(handler: (err: Error) => void): void;
|
|
103
|
+
close(): void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Stdio-framed transport over a spawned child process. Produced by
|
|
108
|
+
* `LSPClient.fromCommand()` for production use; tests use `LSPClient.with(...)`.
|
|
109
|
+
*/
|
|
110
|
+
function createStdioTransport(child: ChildProcess): LSPTransport {
|
|
111
|
+
let messageHandler: ((env: unknown) => void) | null = null;
|
|
112
|
+
let errorHandler: ((err: Error) => void) | null = null;
|
|
113
|
+
let buffer = Buffer.alloc(0);
|
|
114
|
+
|
|
115
|
+
const stdout = child.stdout;
|
|
116
|
+
const stdin = child.stdin;
|
|
117
|
+
if (!stdout || !stdin) {
|
|
118
|
+
throw new Error('LSP child process is missing stdio handles');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
stdout.on('data', (chunk: Buffer) => {
|
|
122
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
123
|
+
while (buffer.length > 0) {
|
|
124
|
+
// Parse `Content-Length: N\r\n\r\n<body>` framing.
|
|
125
|
+
const headerEnd = buffer.indexOf('\r\n\r\n');
|
|
126
|
+
if (headerEnd === -1) {
|
|
127
|
+
// No complete header yet. Cap buffer growth so a malicious LSP
|
|
128
|
+
// that drips bytes without `\r\n\r\n` cannot exhaust memory.
|
|
129
|
+
if (buffer.length > MAX_HEADER_BUFFER_BYTES) {
|
|
130
|
+
process.stderr.write(
|
|
131
|
+
`[massu/lsp] WARN: header buffer exceeded ${MAX_HEADER_BUFFER_BYTES} bytes without framing — dropping. (Phase 3.5 mitigation)\n`,
|
|
132
|
+
);
|
|
133
|
+
buffer = Buffer.alloc(0);
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const headerText = buffer.subarray(0, headerEnd).toString('utf-8');
|
|
138
|
+
const match = /Content-Length:\s*(\d+)/i.exec(headerText);
|
|
139
|
+
if (!match) {
|
|
140
|
+
// Malformed framing — drop everything and continue (server may be
|
|
141
|
+
// emitting non-LSP chatter on stdout; LSP says it shouldn't, but be
|
|
142
|
+
// forgiving).
|
|
143
|
+
buffer = buffer.subarray(headerEnd + 4);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const len = parseInt(match[1] ?? '0', 10);
|
|
147
|
+
if (Number.isNaN(len) || len < 0) {
|
|
148
|
+
buffer = buffer.subarray(headerEnd + 4);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (len > MAX_RESPONSE_BODY_BYTES) {
|
|
152
|
+
// Oversized — log and drop (don't try to read it).
|
|
153
|
+
process.stderr.write(
|
|
154
|
+
`[massu/lsp] WARN: oversized LSP response body (${len} > ${MAX_RESPONSE_BODY_BYTES} bytes) — dropping. (Phase 3.5 mitigation)\n`
|
|
155
|
+
);
|
|
156
|
+
// Skip the header + body; still need len bytes available before we
|
|
157
|
+
// can drop them. If not all here yet, wait — but cap waiting by
|
|
158
|
+
// returning early and letting the next `data` event re-enter.
|
|
159
|
+
if (buffer.length < headerEnd + 4 + len) return;
|
|
160
|
+
buffer = buffer.subarray(headerEnd + 4 + len);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (buffer.length < headerEnd + 4 + len) return;
|
|
164
|
+
const body = buffer.subarray(headerEnd + 4, headerEnd + 4 + len).toString('utf-8');
|
|
165
|
+
buffer = buffer.subarray(headerEnd + 4 + len);
|
|
166
|
+
|
|
167
|
+
let parsed: unknown;
|
|
168
|
+
try {
|
|
169
|
+
parsed = JSON.parse(body);
|
|
170
|
+
} catch (e) {
|
|
171
|
+
if (errorHandler) errorHandler(e instanceof Error ? e : new Error(String(e)));
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (messageHandler) messageHandler(parsed);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
stdout.on('error', (err: Error) => {
|
|
179
|
+
if (errorHandler) errorHandler(err);
|
|
180
|
+
});
|
|
181
|
+
stdin.on('error', (err: Error) => {
|
|
182
|
+
if (errorHandler) errorHandler(err);
|
|
183
|
+
});
|
|
184
|
+
child.on('error', (err: Error) => {
|
|
185
|
+
if (errorHandler) errorHandler(err);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
send(json: string) {
|
|
190
|
+
const body = Buffer.from(json, 'utf-8');
|
|
191
|
+
const header = `Content-Length: ${body.length}\r\n\r\n`;
|
|
192
|
+
stdin.write(header + json);
|
|
193
|
+
},
|
|
194
|
+
onMessage(fn) {
|
|
195
|
+
messageHandler = fn;
|
|
196
|
+
},
|
|
197
|
+
onError(fn) {
|
|
198
|
+
errorHandler = fn;
|
|
199
|
+
},
|
|
200
|
+
close() {
|
|
201
|
+
try { stdin.end(); } catch { /* ignore */ }
|
|
202
|
+
try { child.kill(); } catch { /* ignore */ }
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ============================================================
|
|
208
|
+
// LSP server spec (config -> client factory input)
|
|
209
|
+
// ============================================================
|
|
210
|
+
|
|
211
|
+
export interface LSPServerSpec {
|
|
212
|
+
/** Logical language name (matches `lsp.servers[].language`). */
|
|
213
|
+
language: string;
|
|
214
|
+
/** Pre-split argv. First element is the executable path. */
|
|
215
|
+
argv: string[];
|
|
216
|
+
/** When true, allow non-absolute argv[0]. Default false (security). */
|
|
217
|
+
allowRelativePath?: boolean;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ============================================================
|
|
221
|
+
// LSPClient
|
|
222
|
+
// ============================================================
|
|
223
|
+
|
|
224
|
+
interface PendingRequest {
|
|
225
|
+
resolve: (value: unknown) => void;
|
|
226
|
+
reject: (reason: Error) => void;
|
|
227
|
+
timer: NodeJS.Timeout;
|
|
228
|
+
method: string;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Optional client configuration. `requestTimeoutMs` lets tests run timeout
|
|
233
|
+
* scenarios without waiting the full 5s default; production callers should
|
|
234
|
+
* always use the default.
|
|
235
|
+
*/
|
|
236
|
+
export interface LSPClientOptions {
|
|
237
|
+
requestTimeoutMs?: number;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Minimal LSP client. Construct via `LSPClient.fromCommand(spec)` for the
|
|
242
|
+
* production stdio path, or `LSPClient.with(transport)` for tests.
|
|
243
|
+
*/
|
|
244
|
+
export class LSPClient {
|
|
245
|
+
private nextId = 1;
|
|
246
|
+
private pending = new Map<number, PendingRequest>();
|
|
247
|
+
private capabilities: ServerCapabilities = {};
|
|
248
|
+
private initialized = false;
|
|
249
|
+
/** Methods that returned MethodNotFound at runtime — never call again. */
|
|
250
|
+
private deadMethods = new Set<string>();
|
|
251
|
+
private closed = false;
|
|
252
|
+
private requestTimeoutMs: number;
|
|
253
|
+
|
|
254
|
+
private constructor(private transport: LSPTransport, options: LSPClientOptions = {}) {
|
|
255
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
256
|
+
this.transport.onMessage((env) => this.handleMessage(env));
|
|
257
|
+
this.transport.onError((err) => {
|
|
258
|
+
// Errors are non-fatal; pending requests resolve null on timeout.
|
|
259
|
+
process.stderr.write(`[massu/lsp] WARN: transport error: ${err.message}\n`);
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Wire a pre-built transport (used by tests that swap stdin/stdout for an
|
|
265
|
+
* in-memory shim). Production callers should use `fromCommand`.
|
|
266
|
+
*/
|
|
267
|
+
static with(transport: LSPTransport, options: LSPClientOptions = {}): LSPClient {
|
|
268
|
+
return new LSPClient(transport, options);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Spawn the LSP server via `child_process.spawn` with argv array form
|
|
273
|
+
* (NEVER a shell string).
|
|
274
|
+
*
|
|
275
|
+
* Security:
|
|
276
|
+
* - `spec.argv` MUST be a pre-split array. We don't accept a shell-string
|
|
277
|
+
* `command` field — the caller pre-splits it.
|
|
278
|
+
* - argv[0] MUST be absolute unless `spec.allowRelativePath === true`.
|
|
279
|
+
* - argv[0] MUST NOT contain `..`.
|
|
280
|
+
* - Any argv element MUST NOT contain `..` (defense in depth).
|
|
281
|
+
*/
|
|
282
|
+
static fromCommand(spec: LSPServerSpec, options: LSPClientOptions = {}): LSPClient {
|
|
283
|
+
if (!Array.isArray(spec.argv) || spec.argv.length === 0) {
|
|
284
|
+
throw new Error('LSPClient.fromCommand: spec.argv must be a non-empty array');
|
|
285
|
+
}
|
|
286
|
+
const exe = spec.argv[0];
|
|
287
|
+
if (typeof exe !== 'string' || exe.length === 0) {
|
|
288
|
+
throw new Error('LSPClient.fromCommand: spec.argv[0] (executable) must be a non-empty string');
|
|
289
|
+
}
|
|
290
|
+
for (const a of spec.argv) {
|
|
291
|
+
if (typeof a !== 'string') {
|
|
292
|
+
throw new Error('LSPClient.fromCommand: every spec.argv element must be a string');
|
|
293
|
+
}
|
|
294
|
+
if (a.includes('..')) {
|
|
295
|
+
throw new Error(`LSPClient.fromCommand: refused argv element containing "..": ${a}`);
|
|
296
|
+
}
|
|
297
|
+
// Phase 3.5 finding #4 — null-byte injection. Node's spawn refuses
|
|
298
|
+
// strings containing NUL on most platforms, but we add an explicit
|
|
299
|
+
// check so the failure is descriptive and can never be silently
|
|
300
|
+
// mishandled by a kernel-level argv split.
|
|
301
|
+
if (a.includes('\0')) {
|
|
302
|
+
throw new Error(`LSPClient.fromCommand: refused argv element containing NUL byte`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (!spec.allowRelativePath && !isAbsolute(exe)) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
`LSPClient.fromCommand: refused non-absolute executable "${exe}". ` +
|
|
308
|
+
`Pass an absolute path or set allowRelativePath: true to opt in.`
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const child = spawn(exe, spec.argv.slice(1), {
|
|
313
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
314
|
+
// Explicitly NO `shell: true` — argv array form is the security
|
|
315
|
+
// contract.
|
|
316
|
+
shell: false,
|
|
317
|
+
// Phase 3.5 finding #4: drop the parent's environment so the
|
|
318
|
+
// spawned LSP cannot leak ambient secrets via env. Carry only PATH
|
|
319
|
+
// (LSP servers commonly expect it for resolving sub-tools) and
|
|
320
|
+
// HOME (some servers use it for cache directories).
|
|
321
|
+
env: {
|
|
322
|
+
PATH: process.env.PATH ?? '',
|
|
323
|
+
HOME: process.env.HOME ?? '',
|
|
324
|
+
LANG: process.env.LANG ?? 'C.UTF-8',
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
return new LSPClient(createStdioTransport(child), options);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// --------------------------------------------------------
|
|
331
|
+
// Public API
|
|
332
|
+
// --------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Initialize the server. Stores `ServerCapabilities` for later capability
|
|
336
|
+
* gating. Returns null on timeout / Zod validation failure.
|
|
337
|
+
*/
|
|
338
|
+
async initialize(rootUri: string | null = null): Promise<InitializeResponse | null> {
|
|
339
|
+
const params = {
|
|
340
|
+
processId: process.pid,
|
|
341
|
+
rootUri,
|
|
342
|
+
capabilities: {},
|
|
343
|
+
};
|
|
344
|
+
const raw = await this.sendRequest('initialize', params);
|
|
345
|
+
if (raw === null) return null;
|
|
346
|
+
const parsed = InitializeResponseSchema.safeParse(sanitizePolluted(raw));
|
|
347
|
+
if (!parsed.success) {
|
|
348
|
+
process.stderr.write(
|
|
349
|
+
`[massu/lsp] WARN: initialize response failed Zod validation: ${parsed.error.message}\n`
|
|
350
|
+
);
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
this.capabilities = parsed.data.capabilities;
|
|
354
|
+
this.initialized = true;
|
|
355
|
+
// LSP requires a notification after initialize.
|
|
356
|
+
this.sendNotification('initialized', {});
|
|
357
|
+
return parsed.data;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Document symbols for a single file. Returns null when:
|
|
362
|
+
* - capability `documentSymbolProvider` is false/absent (skip request)
|
|
363
|
+
* - method previously returned MethodNotFound
|
|
364
|
+
* - timeout
|
|
365
|
+
* - Zod validation failure
|
|
366
|
+
*/
|
|
367
|
+
async documentSymbol(uri: string): Promise<DocumentSymbolResponse | null> {
|
|
368
|
+
if (!this.checkCapability('documentSymbolProvider', 'textDocument/documentSymbol')) {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
const raw = await this.sendRequest('textDocument/documentSymbol', {
|
|
372
|
+
textDocument: { uri },
|
|
373
|
+
});
|
|
374
|
+
if (raw === null) return null;
|
|
375
|
+
const parsed = DocumentSymbolResponseSchema.safeParse(sanitizePolluted(raw));
|
|
376
|
+
if (!parsed.success) {
|
|
377
|
+
process.stderr.write(
|
|
378
|
+
`[massu/lsp] WARN: textDocument/documentSymbol response failed Zod validation — falling back to AST-only for this file. (${parsed.error.message})\n`
|
|
379
|
+
);
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
return parsed.data;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Workspace symbol search. Returns null when capability is missing/false
|
|
387
|
+
* (e.g., sourcekit-lsp's `workspaceSymbolProvider: false` per plan line 151
|
|
388
|
+
* — empty result is INCONCLUSIVE; we don't even send the request).
|
|
389
|
+
*/
|
|
390
|
+
async workspaceSymbol(query: string): Promise<WorkspaceSymbolResponse | null> {
|
|
391
|
+
if (!this.checkCapability('workspaceSymbolProvider', 'workspace/symbol')) {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
const raw = await this.sendRequest('workspace/symbol', { query });
|
|
395
|
+
if (raw === null) return null;
|
|
396
|
+
const parsed = WorkspaceSymbolResponseSchema.safeParse(sanitizePolluted(raw));
|
|
397
|
+
if (!parsed.success) {
|
|
398
|
+
process.stderr.write(
|
|
399
|
+
`[massu/lsp] WARN: workspace/symbol response failed Zod validation. (${parsed.error.message})\n`
|
|
400
|
+
);
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
return parsed.data;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Resolve a symbol's defining location. */
|
|
407
|
+
async definition(uri: string, position: Position): Promise<DefinitionResponse | null> {
|
|
408
|
+
if (!this.checkCapability('definitionProvider', 'textDocument/definition')) {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
const raw = await this.sendRequest('textDocument/definition', {
|
|
412
|
+
textDocument: { uri },
|
|
413
|
+
position,
|
|
414
|
+
});
|
|
415
|
+
if (raw === null) return null;
|
|
416
|
+
const parsed = DefinitionResponseSchema.safeParse(sanitizePolluted(raw));
|
|
417
|
+
if (!parsed.success) {
|
|
418
|
+
process.stderr.write(
|
|
419
|
+
`[massu/lsp] WARN: textDocument/definition response failed Zod validation. (${parsed.error.message})\n`
|
|
420
|
+
);
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
return parsed.data;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Send `shutdown` then `exit`, then close transport. Idempotent. */
|
|
427
|
+
async shutdown(): Promise<void> {
|
|
428
|
+
if (this.closed) return;
|
|
429
|
+
this.closed = true;
|
|
430
|
+
try {
|
|
431
|
+
// Best-effort — don't block on shutdown if the server is unresponsive.
|
|
432
|
+
await Promise.race([
|
|
433
|
+
this.sendRequest('shutdown', null),
|
|
434
|
+
new Promise((r) => setTimeout(r, 1000)),
|
|
435
|
+
]);
|
|
436
|
+
} catch {
|
|
437
|
+
/* ignore */
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
this.sendNotification('exit', null);
|
|
441
|
+
} catch {
|
|
442
|
+
/* ignore */
|
|
443
|
+
}
|
|
444
|
+
// Reject all in-flight requests.
|
|
445
|
+
for (const [id, p] of this.pending) {
|
|
446
|
+
clearTimeout(p.timer);
|
|
447
|
+
p.resolve(null);
|
|
448
|
+
this.pending.delete(id);
|
|
449
|
+
}
|
|
450
|
+
this.transport.close();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Read-only view of captured capabilities (post-initialize). */
|
|
454
|
+
getCapabilities(): ServerCapabilities {
|
|
455
|
+
return { ...this.capabilities };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// --------------------------------------------------------
|
|
459
|
+
// Internals
|
|
460
|
+
// --------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Returns true if the method should be sent. Returns false (and the caller
|
|
464
|
+
* returns null) when the capability is missing/false OR was previously
|
|
465
|
+
* marked dead via MethodNotFound.
|
|
466
|
+
*/
|
|
467
|
+
private checkCapability(
|
|
468
|
+
capabilityName: keyof ServerCapabilities,
|
|
469
|
+
method: string
|
|
470
|
+
): boolean {
|
|
471
|
+
if (this.deadMethods.has(method)) return false;
|
|
472
|
+
if (!this.initialized) {
|
|
473
|
+
// Pre-initialize calls are programmer errors — don't crash, just skip.
|
|
474
|
+
process.stderr.write(
|
|
475
|
+
`[massu/lsp] WARN: ${method} called before initialize() — skipping.\n`
|
|
476
|
+
);
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
const cap = this.capabilities[capabilityName];
|
|
480
|
+
// `*Provider: true | { ...options }` → supported. `false | undefined` → not.
|
|
481
|
+
if (cap === undefined || cap === false) return false;
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Send a JSON-RPC request and resolve with the `result` field (raw, not yet
|
|
487
|
+
* Zod-validated). Returns null on timeout, MethodNotFound (for graceful
|
|
488
|
+
* degrade), or any other LSP error.
|
|
489
|
+
*/
|
|
490
|
+
private sendRequest(method: string, params: unknown): Promise<unknown> {
|
|
491
|
+
const id = this.nextId++;
|
|
492
|
+
const envelope = {
|
|
493
|
+
jsonrpc: '2.0' as const,
|
|
494
|
+
id,
|
|
495
|
+
method,
|
|
496
|
+
params,
|
|
497
|
+
};
|
|
498
|
+
return new Promise<unknown>((resolve) => {
|
|
499
|
+
const timer = setTimeout(() => {
|
|
500
|
+
this.pending.delete(id);
|
|
501
|
+
process.stderr.write(
|
|
502
|
+
`[massu/lsp] INFO: ${method} timed out after ${this.requestTimeoutMs}ms — degrading to AST-only for this field.\n`
|
|
503
|
+
);
|
|
504
|
+
resolve(null);
|
|
505
|
+
}, this.requestTimeoutMs);
|
|
506
|
+
|
|
507
|
+
this.pending.set(id, {
|
|
508
|
+
resolve: (value) => resolve(value),
|
|
509
|
+
reject: (err) => {
|
|
510
|
+
process.stderr.write(`[massu/lsp] WARN: ${method} rejected: ${err.message}\n`);
|
|
511
|
+
resolve(null);
|
|
512
|
+
},
|
|
513
|
+
timer,
|
|
514
|
+
method,
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
this.transport.send(JSON.stringify(envelope));
|
|
519
|
+
} catch (e) {
|
|
520
|
+
clearTimeout(timer);
|
|
521
|
+
this.pending.delete(id);
|
|
522
|
+
process.stderr.write(
|
|
523
|
+
`[massu/lsp] WARN: failed to send ${method}: ${e instanceof Error ? e.message : String(e)}\n`
|
|
524
|
+
);
|
|
525
|
+
resolve(null);
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Fire-and-forget notification (no response expected). */
|
|
531
|
+
private sendNotification(method: string, params: unknown): void {
|
|
532
|
+
const envelope = {
|
|
533
|
+
jsonrpc: '2.0' as const,
|
|
534
|
+
method,
|
|
535
|
+
params,
|
|
536
|
+
};
|
|
537
|
+
try {
|
|
538
|
+
this.transport.send(JSON.stringify(envelope));
|
|
539
|
+
} catch (e) {
|
|
540
|
+
process.stderr.write(
|
|
541
|
+
`[massu/lsp] WARN: notification ${method} failed to send: ${e instanceof Error ? e.message : String(e)}\n`
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Dispatch an inbound message. Validates the envelope, ignores rogue
|
|
548
|
+
* responses with mismatched ids (response-injection mitigation), and
|
|
549
|
+
* marks methods as dead on MethodNotFound.
|
|
550
|
+
*/
|
|
551
|
+
private handleMessage(raw: unknown): void {
|
|
552
|
+
const env = LSPMessageEnvelopeSchema.safeParse(raw);
|
|
553
|
+
if (!env.success) {
|
|
554
|
+
process.stderr.write(
|
|
555
|
+
`[massu/lsp] WARN: ignored malformed LSP envelope: ${env.error.message}\n`
|
|
556
|
+
);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const e = env.data;
|
|
560
|
+
if (e.id === undefined) {
|
|
561
|
+
// Notification — ignore (we don't subscribe to anything).
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (typeof e.id !== 'number') {
|
|
565
|
+
// We only ever send numeric ids.
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const pending = this.pending.get(e.id);
|
|
569
|
+
if (!pending) {
|
|
570
|
+
// Mismatched id → response-injection or duplicate. Drop silently.
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
clearTimeout(pending.timer);
|
|
574
|
+
this.pending.delete(e.id);
|
|
575
|
+
|
|
576
|
+
if (e.error) {
|
|
577
|
+
if (e.error.code === LSPErrorCode.MethodNotFound) {
|
|
578
|
+
// Mark this method dead for the lifetime of this client. Future calls
|
|
579
|
+
// short-circuit via `deadMethods` check.
|
|
580
|
+
this.deadMethods.add(pending.method);
|
|
581
|
+
process.stderr.write(
|
|
582
|
+
`[massu/lsp] INFO: server reported ${pending.method} not implemented — disabling for this session.\n`
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
pending.resolve(null);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
pending.resolve(e.result ?? null);
|
|
589
|
+
}
|
|
590
|
+
}
|