@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.
Files changed (61) hide show
  1. package/README.md +40 -0
  2. package/commands/README.md +137 -0
  3. package/commands/massu-deploy.python-docker.md +170 -0
  4. package/commands/massu-deploy.python-fly.md +189 -0
  5. package/commands/massu-deploy.python-launchd.md +144 -0
  6. package/commands/massu-deploy.python-systemd.md +163 -0
  7. package/commands/massu-deploy.python.md +200 -0
  8. package/commands/massu-scaffold-page.md +172 -59
  9. package/commands/massu-scaffold-page.swift.md +121 -0
  10. package/commands/massu-scaffold-router.python-django.md +153 -0
  11. package/commands/massu-scaffold-router.python-fastapi.md +145 -0
  12. package/commands/massu-scaffold-router.python.md +143 -0
  13. package/dist/cli.js +10170 -4138
  14. package/dist/hooks/auto-learning-pipeline.js +44 -6
  15. package/dist/hooks/classify-failure.js +44 -6
  16. package/dist/hooks/cost-tracker.js +44 -6
  17. package/dist/hooks/fix-detector.js +44 -6
  18. package/dist/hooks/incident-pipeline.js +44 -6
  19. package/dist/hooks/post-edit-context.js +44 -6
  20. package/dist/hooks/post-tool-use.js +44 -6
  21. package/dist/hooks/pre-compact.js +44 -6
  22. package/dist/hooks/pre-delete-check.js +44 -6
  23. package/dist/hooks/quality-event.js +44 -6
  24. package/dist/hooks/rule-enforcement-pipeline.js +44 -6
  25. package/dist/hooks/session-end.js +44 -6
  26. package/dist/hooks/session-start.js +4789 -410
  27. package/dist/hooks/user-prompt.js +44 -6
  28. package/package.json +10 -4
  29. package/src/cli.ts +28 -2
  30. package/src/commands/config-refresh.ts +88 -20
  31. package/src/commands/init.ts +130 -23
  32. package/src/commands/install-commands.ts +482 -42
  33. package/src/commands/refresh-log.ts +37 -0
  34. package/src/commands/show-template.ts +65 -0
  35. package/src/commands/template-engine.ts +262 -0
  36. package/src/commands/watch.ts +430 -0
  37. package/src/config.ts +69 -3
  38. package/src/detect/adapters/nextjs-trpc.ts +166 -0
  39. package/src/detect/adapters/parse-guard.ts +133 -0
  40. package/src/detect/adapters/python-django.ts +208 -0
  41. package/src/detect/adapters/python-fastapi.ts +223 -0
  42. package/src/detect/adapters/query-helpers.ts +170 -0
  43. package/src/detect/adapters/runner.ts +252 -0
  44. package/src/detect/adapters/swift-swiftui.ts +171 -0
  45. package/src/detect/adapters/tree-sitter-loader.ts +348 -0
  46. package/src/detect/adapters/types.ts +174 -0
  47. package/src/detect/codebase-introspector.ts +190 -0
  48. package/src/detect/index.ts +28 -2
  49. package/src/detect/regex-fallback.ts +449 -0
  50. package/src/hooks/session-start.ts +94 -3
  51. package/src/lib/gitToplevel.ts +22 -0
  52. package/src/lib/installLock.ts +179 -0
  53. package/src/lib/pidLiveness.ts +67 -0
  54. package/src/lsp/auto-detect.ts +89 -0
  55. package/src/lsp/client.ts +590 -0
  56. package/src/lsp/enrich.ts +127 -0
  57. package/src/lsp/types.ts +221 -0
  58. package/src/watch/daemon.ts +385 -0
  59. package/src/watch/lockfile-detector.ts +65 -0
  60. package/src/watch/paths.ts +279 -0
  61. 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
+ }