weavatrix 0.2.13 → 0.2.15

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 (145) hide show
  1. package/README.md +99 -39
  2. package/SECURITY.md +2 -2
  3. package/docs/releases/v0.2.15.md +37 -0
  4. package/package.json +2 -2
  5. package/skill/SKILL.md +57 -38
  6. package/src/analysis/architecture/contract-graph.js +119 -0
  7. package/src/analysis/architecture/contract-schema.js +110 -0
  8. package/src/analysis/architecture/contract-storage.js +35 -0
  9. package/src/analysis/architecture/contract-verification.js +168 -0
  10. package/src/analysis/architecture-contract.js +16 -343
  11. package/src/analysis/change-classification/diff-parser.js +103 -0
  12. package/src/analysis/change-classification/options.js +40 -0
  13. package/src/analysis/change-classification/symbol-classifier.js +177 -0
  14. package/src/analysis/change-classification.js +120 -519
  15. package/src/analysis/dead-code-review/policy.js +23 -0
  16. package/src/analysis/dead-code-review.js +9 -19
  17. package/src/analysis/dep-check.js +14 -71
  18. package/src/analysis/dep-rules.js +10 -303
  19. package/src/analysis/dependency/conventions.js +16 -0
  20. package/src/analysis/dependency/scoped-dependencies.js +45 -0
  21. package/src/analysis/dependency/source-references.js +21 -0
  22. package/src/analysis/duplicates.tokenize.js +12 -2
  23. package/src/analysis/endpoints/common.js +62 -0
  24. package/src/analysis/endpoints/extract.js +107 -0
  25. package/src/analysis/endpoints/inventory.js +84 -0
  26. package/src/analysis/endpoints/mounts.js +129 -0
  27. package/src/analysis/endpoints-java.js +80 -3
  28. package/src/analysis/endpoints.js +3 -448
  29. package/src/analysis/git-history/analytics.js +177 -0
  30. package/src/analysis/git-history/collector.js +109 -0
  31. package/src/analysis/git-history/options.js +68 -0
  32. package/src/analysis/git-history.js +128 -577
  33. package/src/analysis/health-capabilities.js +82 -0
  34. package/src/analysis/http-contracts/analysis.js +169 -0
  35. package/src/analysis/http-contracts/client-call-detection.js +81 -0
  36. package/src/analysis/http-contracts/client-call-parser.js +255 -0
  37. package/src/analysis/http-contracts/graph-context.js +143 -0
  38. package/src/analysis/http-contracts/matching.js +61 -0
  39. package/src/analysis/http-contracts/shared.js +86 -0
  40. package/src/analysis/http-contracts.js +6 -822
  41. package/src/analysis/internal-audit/dependency-health.js +111 -0
  42. package/src/analysis/internal-audit/python-manifests.js +65 -0
  43. package/src/analysis/internal-audit/repo-files.js +55 -0
  44. package/src/analysis/internal-audit/supply-chain.js +132 -0
  45. package/src/analysis/internal-audit.collect.js +5 -106
  46. package/src/analysis/internal-audit.reach.js +20 -0
  47. package/src/analysis/internal-audit.run.js +114 -200
  48. package/src/analysis/jvm-dependency-evidence.js +69 -0
  49. package/src/analysis/source-correctness/retry-patterns.js +93 -0
  50. package/src/analysis/source-correctness.js +225 -0
  51. package/src/analysis/structure/dependency-graph.js +156 -0
  52. package/src/analysis/structure/findings.js +142 -0
  53. package/src/analysis/task-retrieval.js +3 -14
  54. package/src/graph/builder/go-receiver-resolution.js +112 -0
  55. package/src/graph/builder/js/queries.js +48 -0
  56. package/src/graph/builder/lang-go.js +89 -4
  57. package/src/graph/builder/lang-js.js +4 -44
  58. package/src/graph/builder/pass2-resolution.js +68 -0
  59. package/src/graph/freshness-probe.js +2 -1
  60. package/src/graph/incremental-refresh.js +3 -2
  61. package/src/graph/internal-builder.build.js +22 -183
  62. package/src/graph/internal-builder.pass2.js +161 -0
  63. package/src/graph/internal-builder.resolvers.js +2 -105
  64. package/src/graph/resolvers/rust.js +117 -0
  65. package/src/mcp/actions/advisories.mjs +18 -0
  66. package/src/mcp/actions/graph-lifecycle.mjs +148 -0
  67. package/src/mcp/actions/graph-sync.mjs +195 -0
  68. package/src/mcp/actions/hosted-architecture.mjs +57 -0
  69. package/src/mcp/architecture-bootstrap.mjs +168 -0
  70. package/src/mcp/architecture-starter.mjs +234 -0
  71. package/src/mcp/catalog.mjs +20 -6
  72. package/src/mcp/evidence/bun-lock-graph.mjs +209 -0
  73. package/src/mcp/evidence/duplicate-member-order.mjs +8 -0
  74. package/src/mcp/evidence/package-graph-common.mjs +115 -0
  75. package/src/mcp/evidence/package-lock-graph.mjs +92 -0
  76. package/src/mcp/evidence-snapshot.duplicates.mjs +3 -7
  77. package/src/mcp/evidence-snapshot.package-graph.mjs +5 -474
  78. package/src/mcp/git-output.mjs +10 -0
  79. package/src/mcp/graph/context-core.mjs +111 -0
  80. package/src/mcp/graph/context-seeds.mjs +197 -0
  81. package/src/mcp/graph/context-state.mjs +86 -0
  82. package/src/mcp/graph/reverse-reach.mjs +42 -0
  83. package/src/mcp/graph/tools-core.mjs +143 -0
  84. package/src/mcp/graph/tools-query.mjs +223 -0
  85. package/src/mcp/graph-context.mjs +4 -496
  86. package/src/mcp/health/audit-format.mjs +172 -0
  87. package/src/mcp/health/audit.mjs +171 -0
  88. package/src/mcp/health/dead-code.mjs +110 -0
  89. package/src/mcp/health/duplicates.mjs +83 -0
  90. package/src/mcp/health/endpoints.mjs +42 -0
  91. package/src/mcp/health/structure.mjs +219 -0
  92. package/src/mcp/runtime-version.mjs +32 -0
  93. package/src/mcp/server/auto-refresh.mjs +66 -0
  94. package/src/mcp/server/runtime-config.mjs +43 -0
  95. package/src/mcp/server/shutdown.mjs +40 -0
  96. package/src/mcp/sync/evidence-architecture.mjs +54 -0
  97. package/src/mcp/sync/evidence-common.mjs +88 -0
  98. package/src/mcp/sync/evidence-duplicates.mjs +97 -0
  99. package/src/mcp/sync/evidence-health.mjs +56 -0
  100. package/src/mcp/sync/evidence-packages.mjs +95 -0
  101. package/src/mcp/sync/payload-common.mjs +97 -0
  102. package/src/mcp/sync/payload-v2.mjs +53 -0
  103. package/src/mcp/sync/payload-v3.mjs +79 -0
  104. package/src/mcp/sync-evidence.mjs +7 -402
  105. package/src/mcp/sync-payload.mjs +10 -244
  106. package/src/mcp/tools-actions.mjs +18 -414
  107. package/src/mcp/tools-architecture.mjs +18 -70
  108. package/src/mcp/tools-context.mjs +56 -15
  109. package/src/mcp/tools-endpoints.mjs +4 -1
  110. package/src/mcp/tools-graph.mjs +17 -331
  111. package/src/mcp/tools-health.mjs +24 -705
  112. package/src/mcp/tools-impact-change.mjs +2 -38
  113. package/src/mcp/tools-impact.mjs +2 -43
  114. package/src/mcp-server.mjs +35 -146
  115. package/src/path-classification.js +14 -0
  116. package/src/precision/lsp-client/constants.js +12 -0
  117. package/src/precision/lsp-client/environment.js +20 -0
  118. package/src/precision/lsp-client/errors.js +15 -0
  119. package/src/precision/lsp-client/lifecycle.js +127 -0
  120. package/src/precision/lsp-client/message-parser.js +81 -0
  121. package/src/precision/lsp-client/protocol.js +212 -0
  122. package/src/precision/lsp-client/registry.js +58 -0
  123. package/src/precision/lsp-client/repo-uri.js +60 -0
  124. package/src/precision/lsp-client/stdio-client.js +151 -0
  125. package/src/precision/lsp-client.js +10 -682
  126. package/src/precision/lsp-overlay/build.js +238 -0
  127. package/src/precision/lsp-overlay/contract.js +61 -0
  128. package/src/precision/lsp-overlay/reference-results.js +108 -0
  129. package/src/precision/lsp-overlay/semantic-inputs.js +62 -0
  130. package/src/precision/lsp-overlay/source-session.js +134 -0
  131. package/src/precision/lsp-overlay/store.js +150 -0
  132. package/src/precision/lsp-overlay/target-index.js +147 -0
  133. package/src/precision/lsp-overlay/target-query.js +55 -0
  134. package/src/precision/lsp-overlay.js +11 -868
  135. package/src/precision/typescript-lsp-provider.js +12 -682
  136. package/src/precision/typescript-provider/client.js +69 -0
  137. package/src/precision/typescript-provider/discovery.js +124 -0
  138. package/src/precision/typescript-provider/project-host.js +249 -0
  139. package/src/precision/typescript-provider/project-safety.js +161 -0
  140. package/src/precision/typescript-provider/reference-usage.js +53 -0
  141. package/src/security/malware-heuristics.sweep.js +1 -1
  142. package/src/security/registry-sig.classify.js +2 -1
  143. package/src/security/registry-sig.rules.js +3 -3
  144. package/src/version.js +7 -0
  145. package/docs/releases/v0.2.13.md +0 -48
@@ -1,682 +1,10 @@
1
- import {spawn as spawnChild} from 'node:child_process'
2
- import {realpathSync} from 'node:fs'
3
- import {delimiter, dirname, isAbsolute, join, relative, resolve, sep} from 'node:path'
4
- import {TextDecoder} from 'node:util'
5
- import {fileURLToPath, pathToFileURL} from 'node:url'
6
- import {childProcessEnv} from '../child-env.js'
7
-
8
- const DEFAULT_MAX_MESSAGE_BYTES = 8 * 1024 * 1024
9
- const DEFAULT_MAX_HEADER_BYTES = 16 * 1024
10
- const DEFAULT_REQUEST_TIMEOUT_MS = 10_000
11
- const JSON_RPC_VERSION = '2.0'
12
- const HEADER_DELIMITER = Buffer.from('\r\n\r\n', 'ascii')
13
- const UTF8_DECODER = new TextDecoder('utf-8', {fatal: true})
14
- const ACTIVE_LSP_CLIENTS = new Set()
15
- let ACCEPTING_LSP_CLIENTS = true
16
- const LSP_ENV_ALLOWLIST = new Set([
17
- 'path', 'pathext', 'systemroot', 'windir', 'comspec',
18
- 'temp', 'tmp', 'tmpdir', 'home', 'userprofile', 'localappdata', 'appdata',
19
- 'lang', 'language', 'lc_all', 'lc_ctype',
20
- ])
21
-
22
- export function lspChildProcessEnv(overrides = {}) {
23
- const inherited = childProcessEnv(overrides)
24
- const clean = Object.fromEntries(Object.entries(inherited).filter(([key]) => LSP_ENV_ALLOWLIST.has(key.toLowerCase())))
25
- const safePath = [dirname(process.execPath)]
26
- const systemRoot = inherited.SystemRoot || inherited.SYSTEMROOT || inherited.WINDIR
27
- if (process.platform === 'win32' && systemRoot) safePath.push(join(systemRoot, 'System32'))
28
- clean.PATH = [...new Set(safePath)].join(delimiter)
29
- return clean
30
- }
31
-
32
- export function activeLspClientCount() {
33
- return ACTIVE_LSP_CLIENTS.size
34
- }
35
-
36
- // A graph build can reach the precision phase after MCP stdin has already closed. Flip this gate
37
- // synchronously at shutdown start so no new TLS/tsserver tree can appear after the active set was
38
- // drained. The MCP process is terminal after this transition, so it is intentionally irreversible.
39
- export function beginLspClientShutdown() {
40
- ACCEPTING_LSP_CLIENTS = false
41
- }
42
-
43
- // MCP disconnects can arrive while an automatic graph refresh is still asking tsserver for
44
- // references. Keep process ownership explicit so the stdio shell can drain or terminate every
45
- // bundled semantic-provider tree before its own process exits.
46
- export async function shutdownActiveLspClients({timeoutMs = 3_000} = {}) {
47
- const boundedTimeout = Math.max(250, Math.min(10_000, Number(timeoutMs) || 3_000))
48
- const startedAt = Date.now()
49
- const clients = [...ACTIVE_LSP_CLIENTS]
50
- if (!clients.length) return {requested: 0, remaining: 0, timedOut: false}
51
-
52
- let timer
53
- const gracefulBudget = Math.max(125, Math.floor(boundedTimeout * 0.6))
54
- const graceful = Promise.allSettled(clients.map((client) => client.shutdown({timeoutMs: boundedTimeout})))
55
- const outcome = await Promise.race([
56
- graceful.then(() => 'closed'),
57
- new Promise((resolveOutcome) => { timer = setTimeout(() => resolveOutcome('timeout'), gracefulBudget) }),
58
- ])
59
- if (timer) clearTimeout(timer)
60
-
61
- const survivors = [...ACTIVE_LSP_CLIENTS]
62
- if (survivors.length) {
63
- const reason = new Error(`MCP shutdown timed out with ${ACTIVE_LSP_CLIENTS.size} active LSP client(s)`)
64
- const forceBudget = Math.max(100, boundedTimeout - (Date.now() - startedAt))
65
- // On Windows, wait for taskkill /T /F itself before exiting the MCP parent. Otherwise the TLS
66
- // process can die first while its tsserver child keeps the repository directory open.
67
- await Promise.allSettled(survivors.map(async (client) => {
68
- if (typeof client.killWindowsTreeAndWait === 'function') {
69
- await client.killWindowsTreeAndWait(forceBudget)
70
- }
71
- client.kill(reason)
72
- if (typeof client.waitForExit === 'function') await client.waitForExit(Math.max(100, boundedTimeout - (Date.now() - startedAt)))
73
- }))
74
- }
75
-
76
- return {
77
- requested: clients.length,
78
- remaining: ACTIVE_LSP_CLIENTS.size,
79
- timedOut: outcome === 'timeout' || ACTIVE_LSP_CLIENTS.size > 0,
80
- }
81
- }
82
-
83
- export class LspProtocolError extends Error {
84
- constructor(message, options) {
85
- super(message, options)
86
- this.name = 'LspProtocolError'
87
- }
88
- }
89
-
90
- export class LspTimeoutError extends Error {
91
- constructor(method, timeoutMs) {
92
- super(`LSP request timed out after ${timeoutMs}ms: ${method}`)
93
- this.name = 'LspTimeoutError'
94
- this.method = method
95
- this.timeoutMs = timeoutMs
96
- }
97
- }
98
-
99
- function positiveInteger(value, fallback, label) {
100
- const candidate = value == null ? fallback : Number(value)
101
- if (!Number.isSafeInteger(candidate) || candidate <= 0) throw new TypeError(`${label} must be a positive integer`)
102
- return candidate
103
- }
104
-
105
- function asBuffer(chunk) {
106
- if (Buffer.isBuffer(chunk)) return chunk
107
- if (chunk instanceof Uint8Array) return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)
108
- return Buffer.from(chunk)
109
- }
110
-
111
- function parseHeaders(bytes, maxMessageBytes) {
112
- const text = bytes.toString('ascii')
113
- const lines = text.split('\r\n')
114
- let contentLength = null
115
- for (const line of lines) {
116
- const match = /^([!#$%&'*+\-.^_`|~0-9A-Za-z]+):[ \t]*(.*)$/.exec(line)
117
- if (!match) throw new LspProtocolError('Malformed LSP header line')
118
- const name = match[1].toLowerCase()
119
- if (name !== 'content-length') continue
120
- if (contentLength != null) throw new LspProtocolError('Duplicate Content-Length header')
121
- if (!/^(0|[1-9][0-9]*)$/.test(match[2])) throw new LspProtocolError('Invalid Content-Length header')
122
- contentLength = Number(match[2])
123
- if (!Number.isSafeInteger(contentLength) || contentLength <= 0) throw new LspProtocolError('Content-Length must be positive')
124
- if (contentLength > maxMessageBytes) {
125
- throw new LspProtocolError(`LSP message exceeds ${maxMessageBytes} byte limit`)
126
- }
127
- }
128
- if (contentLength == null) throw new LspProtocolError('Missing Content-Length header')
129
- return contentLength
130
- }
131
-
132
- /** Incremental parser for the Content-Length framed JSON-RPC transport used by LSP. */
133
- export class ContentLengthMessageParser {
134
- constructor({onMessage, maxMessageBytes = DEFAULT_MAX_MESSAGE_BYTES, maxHeaderBytes = DEFAULT_MAX_HEADER_BYTES} = {}) {
135
- if (typeof onMessage !== 'function') throw new TypeError('onMessage must be a function')
136
- this.onMessage = onMessage
137
- this.maxMessageBytes = positiveInteger(maxMessageBytes, DEFAULT_MAX_MESSAGE_BYTES, 'maxMessageBytes')
138
- this.maxHeaderBytes = positiveInteger(maxHeaderBytes, DEFAULT_MAX_HEADER_BYTES, 'maxHeaderBytes')
139
- this.buffer = Buffer.alloc(0)
140
- this.expectedBodyBytes = null
141
- }
142
-
143
- push(chunk) {
144
- const incoming = asBuffer(chunk)
145
- if (incoming.length === 0) return
146
- this.buffer = this.buffer.length === 0 ? incoming : Buffer.concat([this.buffer, incoming])
147
-
148
- while (this.buffer.length > 0) {
149
- if (this.expectedBodyBytes == null) {
150
- const delimiterIndex = this.buffer.indexOf(HEADER_DELIMITER)
151
- if (delimiterIndex < 0) {
152
- if (this.buffer.length > this.maxHeaderBytes) throw new LspProtocolError('LSP header exceeds byte limit')
153
- return
154
- }
155
- if (delimiterIndex === 0) throw new LspProtocolError('Empty LSP header block')
156
- if (delimiterIndex > this.maxHeaderBytes) throw new LspProtocolError('LSP header exceeds byte limit')
157
- this.expectedBodyBytes = parseHeaders(this.buffer.subarray(0, delimiterIndex), this.maxMessageBytes)
158
- this.buffer = this.buffer.subarray(delimiterIndex + HEADER_DELIMITER.length)
159
- }
160
-
161
- if (this.buffer.length < this.expectedBodyBytes) return
162
- const body = this.buffer.subarray(0, this.expectedBodyBytes)
163
- this.buffer = this.buffer.subarray(this.expectedBodyBytes)
164
- this.expectedBodyBytes = null
165
-
166
- let value
167
- try {
168
- value = JSON.parse(UTF8_DECODER.decode(body))
169
- } catch (error) {
170
- throw new LspProtocolError('Invalid UTF-8 JSON in LSP message', {cause: error})
171
- }
172
- if (value == null || typeof value !== 'object' || Array.isArray(value)) {
173
- throw new LspProtocolError('LSP message must be a JSON object')
174
- }
175
- this.onMessage(value)
176
- }
177
- }
178
- }
179
-
180
- function normalizeFilesystemPath(path) {
181
- let normalized = path
182
- if (process.platform === 'win32' && normalized.startsWith('\\\\?\\UNC\\')) normalized = `\\\\${normalized.slice(8)}`
183
- else if (process.platform === 'win32' && normalized.startsWith('\\\\?\\')) normalized = normalized.slice(4)
184
- return resolve(normalized)
185
- }
186
-
187
- function realpathIfPossible(path) {
188
- try {
189
- return normalizeFilesystemPath(realpathSync.native(path))
190
- } catch {
191
- return normalizeFilesystemPath(path)
192
- }
193
- }
194
-
195
- function existingRealpath(path) {
196
- try {
197
- return normalizeFilesystemPath(realpathSync.native(path))
198
- } catch {
199
- return null
200
- }
201
- }
202
-
203
- function pathInside(rootPath, candidatePath) {
204
- const rel = relative(rootPath, candidatePath)
205
- return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
206
- }
207
-
208
- function portableRelative(rootPath, candidatePath) {
209
- return relative(rootPath, candidatePath).split(sep).join('/')
210
- }
211
-
212
- /**
213
- * Creates the only path/URI conversion surface used by the client. Existing symlinks are
214
- * canonicalized before the repository-boundary check, so an in-repo symlink cannot expose files
215
- * outside the repository through definition/reference results.
216
- */
217
- export function createRepoUriNormalizer(repoRoot) {
218
- if (typeof repoRoot !== 'string' || repoRoot.trim() === '') throw new TypeError('repoRoot is required')
219
- const lexicalRoot = normalizeFilesystemPath(resolve(repoRoot))
220
- const absoluteRoot = realpathIfPossible(lexicalRoot)
221
-
222
- const toAbsolute = (filePath) => {
223
- if (typeof filePath !== 'string' || filePath.trim() === '') throw new TypeError('filePath is required')
224
- const lexicalPath = normalizeFilesystemPath(resolve(lexicalRoot, filePath))
225
- const canonicalPath = existingRealpath(lexicalPath)
226
- if (canonicalPath == null) {
227
- if (!pathInside(lexicalRoot, lexicalPath)) throw new RangeError('LSP path is outside the repository')
228
- return lexicalPath
229
- }
230
- if (!pathInside(absoluteRoot, canonicalPath)) throw new RangeError('LSP path resolves outside the repository')
231
- return canonicalPath
232
- }
233
-
234
- const fromUri = (uri) => {
235
- if (typeof uri !== 'string' || !uri.startsWith('file:')) throw new RangeError('Only file: LSP URIs are accepted')
236
- let filePath
237
- try {
238
- filePath = fileURLToPath(uri)
239
- } catch (error) {
240
- throw new RangeError('Invalid file: LSP URI', {cause: error})
241
- }
242
- const absolutePath = toAbsolute(filePath)
243
- return {file: portableRelative(absoluteRoot, absolutePath), absolutePath, uri: pathToFileURL(absolutePath).href}
244
- }
245
-
246
- const toUri = (filePath) => {
247
- const absolutePath = toAbsolute(filePath)
248
- return {file: portableRelative(absoluteRoot, absolutePath), absolutePath, uri: pathToFileURL(absolutePath).href}
249
- }
250
-
251
- return {rootPath: absoluteRoot, rootUri: pathToFileURL(absoluteRoot).href, toAbsolute, toUri, fromUri}
252
- }
253
-
254
- function assertPosition(position) {
255
- if (position == null || typeof position !== 'object') throw new TypeError('position is required')
256
- const line = Number(position.line)
257
- const character = Number(position.character)
258
- if (!Number.isSafeInteger(line) || line < 0 || !Number.isSafeInteger(character) || character < 0) {
259
- throw new TypeError('position line and character must be non-negative integers')
260
- }
261
- return {line, character}
262
- }
263
-
264
- function normalizeRange(range) {
265
- if (range == null || typeof range !== 'object') throw new LspProtocolError('LSP location is missing a range')
266
- return {start: assertPosition(range.start), end: assertPosition(range.end)}
267
- }
268
-
269
- function jsonRpcError(error) {
270
- const message = error?.message || 'LSP request failed'
271
- const wrapped = new Error(message)
272
- wrapped.name = 'LspResponseError'
273
- wrapped.code = error?.code
274
- wrapped.data = error?.data
275
- return wrapped
276
- }
277
-
278
- function locationArray(result) {
279
- if (result == null) return []
280
- return Array.isArray(result) ? result : [result]
281
- }
282
-
283
- export class StdioLspClient {
284
- constructor({
285
- repoRoot,
286
- executablePath = process.execPath,
287
- args = [],
288
- env = {},
289
- requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
290
- maxMessageBytes = DEFAULT_MAX_MESSAGE_BYTES,
291
- maxHeaderBytes = DEFAULT_MAX_HEADER_BYTES,
292
- onNotification,
293
- onServerRequest,
294
- spawn = spawnChild,
295
- } = {}) {
296
- if (typeof executablePath !== 'string' || !isAbsolute(executablePath)) {
297
- throw new TypeError('LSP executablePath must be absolute')
298
- }
299
- if (!Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) throw new TypeError('LSP args must be strings')
300
- if (typeof spawn !== 'function') throw new TypeError('spawn must be a function')
301
- if (!ACCEPTING_LSP_CLIENTS) throw new Error('LSP process creation is disabled during MCP shutdown')
302
- this.normalizer = createRepoUriNormalizer(repoRoot)
303
- this.requestTimeoutMs = positiveInteger(requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, 'requestTimeoutMs')
304
- this.maxMessageBytes = positiveInteger(maxMessageBytes, DEFAULT_MAX_MESSAGE_BYTES, 'maxMessageBytes')
305
- this.onNotification = typeof onNotification === 'function' ? onNotification : null
306
- this.onServerRequest = typeof onServerRequest === 'function' ? onServerRequest : null
307
- this.pending = new Map()
308
- this.openDocuments = new Map()
309
- this.nextId = 1
310
- this.state = 'starting'
311
- this.stderrTail = ''
312
- this.shutdownPromise = null
313
-
314
- this.child = spawn(executablePath, args, {
315
- cwd: this.normalizer.rootPath,
316
- // The semantic provider needs only OS/process basics. Do not expose package-registry,
317
- // cloud, hosted-sync, proxy, API-key, NODE_OPTIONS, or arbitrary repo-shell environment.
318
- env: lspChildProcessEnv(env),
319
- shell: false,
320
- // On POSIX, make TLS the leader of a private process group so forced cleanup can also
321
- // terminate its tsserver child. Piped stdio stays owned by this client; we never unref.
322
- detached: process.platform !== 'win32',
323
- windowsHide: true,
324
- stdio: ['pipe', 'pipe', 'pipe'],
325
- })
326
- this.processGroupPid = process.platform !== 'win32' && Number.isInteger(this.child.pid)
327
- ? this.child.pid : null
328
- ACTIVE_LSP_CLIENTS.add(this)
329
- this.parser = new ContentLengthMessageParser({
330
- maxMessageBytes: this.maxMessageBytes,
331
- maxHeaderBytes,
332
- onMessage: (message) => this.handleMessage(message),
333
- })
334
- this.child.stdout?.on('data', (chunk) => {
335
- try {
336
- this.parser.push(chunk)
337
- } catch (error) {
338
- this.fail(error)
339
- }
340
- })
341
- this.child.stderr?.setEncoding('utf8')
342
- this.child.stderr?.on('data', (chunk) => {
343
- this.stderrTail = `${this.stderrTail}${chunk}`.slice(-16_000)
344
- })
345
- this.spawned = new Promise((resolveSpawn, rejectSpawn) => {
346
- this.child.once('spawn', () => {
347
- if (this.state === 'starting') this.state = 'running'
348
- resolveSpawn(this)
349
- })
350
- this.child.once('error', (error) => {
351
- this.fail(error)
352
- rejectSpawn(error)
353
- })
354
- })
355
- this.exited = new Promise((resolveExit) => {
356
- let settled = false
357
- const finishExit = (code, signal) => {
358
- if (settled) return
359
- settled = true
360
- const wasStopping = this.state === 'stopping' || this.state === 'closed'
361
- this.state = 'closed'
362
- const suffix = this.stderrTail ? `\n${this.stderrTail}` : ''
363
- this.rejectPending(new Error(`LSP server exited (code=${code}, signal=${signal})${suffix}`))
364
- if (!wasStopping && code !== 0) this.openDocuments.clear()
365
- ACTIVE_LSP_CLIENTS.delete(this)
366
- resolveExit({code, signal})
367
- }
368
- // spawn failures emit error + close without an exit event. Listen to both so the process
369
- // registry cannot retain a client that never started.
370
- this.child.once('exit', finishExit)
371
- this.child.once('close', finishExit)
372
- })
373
- }
374
-
375
- async start() {
376
- await this.spawned
377
- return this
378
- }
379
-
380
- assertWritable() {
381
- if (!['running', 'initialized', 'stopping'].includes(this.state) || this.child.stdin?.destroyed) {
382
- throw new Error(`LSP client is not writable (state=${this.state})`)
383
- }
384
- }
385
-
386
- writeMessage(message) {
387
- this.assertWritable()
388
- const body = Buffer.from(JSON.stringify(message), 'utf8')
389
- if (body.length > this.maxMessageBytes) throw new LspProtocolError(`Outgoing LSP message exceeds ${this.maxMessageBytes} byte limit`)
390
- const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii')
391
- return new Promise((resolveWrite, rejectWrite) => {
392
- this.child.stdin.write(Buffer.concat([header, body]), (error) => error ? rejectWrite(error) : resolveWrite())
393
- })
394
- }
395
-
396
- async request(method, params = null, {timeoutMs = this.requestTimeoutMs} = {}) {
397
- if (typeof method !== 'string' || method === '') throw new TypeError('LSP method is required')
398
- const boundedTimeout = positiveInteger(timeoutMs, this.requestTimeoutMs, 'timeoutMs')
399
- const id = this.nextId++
400
- let entry
401
- const response = new Promise((resolveResponse, rejectResponse) => {
402
- const timer = setTimeout(() => {
403
- if (this.pending.get(id) !== entry) return
404
- this.pending.delete(id)
405
- void this.writeMessage({jsonrpc: JSON_RPC_VERSION, method: '$/cancelRequest', params: {id}}).catch(() => {})
406
- rejectResponse(new LspTimeoutError(method, boundedTimeout))
407
- }, boundedTimeout)
408
- entry = {method, timer, resolve: resolveResponse, reject: rejectResponse}
409
- this.pending.set(id, entry)
410
- })
411
- try {
412
- await this.writeMessage({jsonrpc: JSON_RPC_VERSION, id, method, params})
413
- } catch (error) {
414
- if (this.pending.get(id) === entry) {
415
- clearTimeout(entry.timer)
416
- this.pending.delete(id)
417
- entry.reject(error)
418
- }
419
- }
420
- return response
421
- }
422
-
423
- async notify(method, params = null) {
424
- if (typeof method !== 'string' || method === '') throw new TypeError('LSP method is required')
425
- await this.writeMessage({jsonrpc: JSON_RPC_VERSION, method, params})
426
- }
427
-
428
- async initialize({capabilities = {}, initializationOptions, clientInfo = {name: 'weavatrix', version: '0.2.6'}} = {}) {
429
- if (this.state !== 'running') throw new Error(`LSP initialize is invalid in state=${this.state}`)
430
- const result = await this.request('initialize', {
431
- processId: process.pid,
432
- clientInfo,
433
- rootUri: this.normalizer.rootUri,
434
- capabilities,
435
- initializationOptions,
436
- workspaceFolders: [{uri: this.normalizer.rootUri, name: this.normalizer.rootPath.split(/[\\/]/).pop() || 'repository'}],
437
- })
438
- await this.notify('initialized', {})
439
- this.state = 'initialized'
440
- return result
441
- }
442
-
443
- async openDocument({filePath, text, languageId, version = 1}) {
444
- if (this.state !== 'initialized') throw new Error('LSP client is not initialized')
445
- if (typeof text !== 'string') throw new TypeError('document text must be a string')
446
- if (typeof languageId !== 'string' || languageId === '') throw new TypeError('languageId is required')
447
- if (!Number.isSafeInteger(version) || version < 0) throw new TypeError('document version must be a non-negative integer')
448
- const normalized = this.normalizer.toUri(filePath)
449
- await this.notify('textDocument/didOpen', {textDocument: {uri: normalized.uri, languageId, version, text}})
450
- this.openDocuments.set(normalized.uri, {file: normalized.file, version, languageId})
451
- return {file: normalized.file, version, languageId}
452
- }
453
-
454
- async closeDocument(filePath) {
455
- const normalized = this.normalizer.toUri(filePath)
456
- if (!this.openDocuments.has(normalized.uri)) return false
457
- await this.notify('textDocument/didClose', {textDocument: {uri: normalized.uri}})
458
- this.openDocuments.delete(normalized.uri)
459
- return true
460
- }
461
-
462
- normalizeLocations(result) {
463
- const locations = []
464
- for (const raw of locationArray(result)) {
465
- if (raw == null || typeof raw !== 'object') continue
466
- const uri = raw.uri || raw.targetUri
467
- const range = raw.range || raw.targetSelectionRange || raw.targetRange
468
- try {
469
- const normalized = this.normalizer.fromUri(uri)
470
- locations.push({file: normalized.file, range: normalizeRange(range)})
471
- } catch (error) {
472
- if (error instanceof RangeError) continue
473
- throw error
474
- }
475
- }
476
- return locations
477
- }
478
-
479
- async definition({filePath, position, timeoutMs}) {
480
- const normalized = this.normalizer.toUri(filePath)
481
- const result = await this.request('textDocument/definition', {
482
- textDocument: {uri: normalized.uri},
483
- position: assertPosition(position),
484
- }, {timeoutMs: timeoutMs ?? this.requestTimeoutMs})
485
- return this.normalizeLocations(result)
486
- }
487
-
488
- async references({filePath, position, includeDeclaration = true, timeoutMs}) {
489
- const normalized = this.normalizer.toUri(filePath)
490
- const result = await this.request('textDocument/references', {
491
- textDocument: {uri: normalized.uri},
492
- position: assertPosition(position),
493
- context: {includeDeclaration: Boolean(includeDeclaration)},
494
- }, {timeoutMs: timeoutMs ?? this.requestTimeoutMs})
495
- return this.normalizeLocations(result)
496
- }
497
-
498
- async defaultServerRequest(method, params) {
499
- if (method === 'workspace/configuration') {
500
- return Array.isArray(params?.items) ? params.items.map(() => null) : []
501
- }
502
- if (method === 'workspace/workspaceFolders') {
503
- return [{uri: this.normalizer.rootUri, name: this.normalizer.rootPath.split(/[\\/]/).pop() || 'repository'}]
504
- }
505
- if (method === 'client/registerCapability' || method === 'client/unregisterCapability' || method === 'window/workDoneProgress/create') return null
506
- if (method === 'workspace/applyEdit') return {applied: false, failureReason: 'Weavatrix precision provider is read-only'}
507
- const error = new Error(`Unsupported LSP server request: ${method}`)
508
- error.code = -32601
509
- throw error
510
- }
511
-
512
- async handleServerRequest(message) {
513
- try {
514
- const result = this.onServerRequest
515
- ? await this.onServerRequest(message.method, message.params, this)
516
- : await this.defaultServerRequest(message.method, message.params)
517
- await this.writeMessage({jsonrpc: JSON_RPC_VERSION, id: message.id, result: result ?? null})
518
- } catch (error) {
519
- try {
520
- await this.writeMessage({
521
- jsonrpc: JSON_RPC_VERSION,
522
- id: message.id,
523
- error: {code: Number.isInteger(error?.code) ? error.code : -32603, message: error?.message || 'LSP client error'},
524
- })
525
- } catch {
526
- // The process may have exited while the request handler was running.
527
- }
528
- }
529
- }
530
-
531
- handleMessage(message) {
532
- if (message.jsonrpc !== JSON_RPC_VERSION) throw new LspProtocolError('Unsupported JSON-RPC version from LSP server')
533
- if (typeof message.method === 'string') {
534
- if (message.id != null) void this.handleServerRequest(message)
535
- else if (this.onNotification) this.onNotification(message.method, message.params, this)
536
- return
537
- }
538
- if (message.id == null) throw new LspProtocolError('LSP response is missing an id')
539
- const entry = this.pending.get(message.id)
540
- if (!entry) return
541
- this.pending.delete(message.id)
542
- clearTimeout(entry.timer)
543
- if (message.error != null) entry.reject(jsonRpcError(message.error))
544
- else entry.resolve(message.result)
545
- }
546
-
547
- rejectPending(error) {
548
- for (const entry of this.pending.values()) {
549
- clearTimeout(entry.timer)
550
- entry.reject(error)
551
- }
552
- this.pending.clear()
553
- }
554
-
555
- fail(error) {
556
- if (this.state === 'closed' || this.state === 'failed') return
557
- this.state = 'failed'
558
- this.rejectPending(error)
559
- this.kill(error)
560
- }
561
-
562
- kill(reason = new Error('LSP client was killed')) {
563
- if (this.state !== 'closed') this.state = 'closed'
564
- this.rejectPending(reason)
565
- this.openDocuments.clear()
566
- try { this.child.stdin?.destroy() } catch { /* already closed */ }
567
- if (process.platform !== 'win32' && this.processGroupPid) {
568
- try {
569
- process.kill(-this.processGroupPid, 'SIGKILL')
570
- return
571
- } catch {
572
- // A custom spawn implementation or an already-reaped group can make group kill
573
- // unavailable. Fall through to the direct child handle as a bounded fallback.
574
- }
575
- }
576
- if (this.child.exitCode != null || this.child.signalCode != null) return
577
- if (process.platform === 'win32' && this.child.pid) {
578
- // typescript-language-server owns a tsserver child. Kill the complete tree on timeout or
579
- // protocol failure so reconnects never accumulate orphaned semantic providers.
580
- try {
581
- const killer = spawnChild('taskkill', ['/pid', String(this.child.pid), '/T', '/F'], {
582
- shell: false, windowsHide: true, env: lspChildProcessEnv(), stdio: 'ignore',
583
- })
584
- const fallback = () => {
585
- try { this.child.kill('SIGKILL') } catch { /* already exited */ }
586
- }
587
- killer.once('error', fallback)
588
- // Sandboxed/locked-down Windows hosts can start taskkill but deny termination. Its
589
- // non-zero exit must fall back to the direct child handle instead of looking successful.
590
- killer.once('exit', (code) => { if (code !== 0) fallback() })
591
- } catch {
592
- try { this.child.kill('SIGKILL') } catch { /* already exited */ }
593
- }
594
- } else {
595
- try { this.child.kill('SIGKILL') } catch { /* already exited */ }
596
- }
597
- }
598
-
599
- async killWindowsTreeAndWait(timeoutMs = 3_000) {
600
- if (process.platform !== 'win32' || !this.child.pid || this.child.exitCode != null) return
601
- await new Promise((resolveKill) => {
602
- let settled = false
603
- const done = () => {
604
- if (settled) return
605
- settled = true
606
- clearTimeout(timer)
607
- resolveKill()
608
- }
609
- const timer = setTimeout(() => {
610
- try { this.child.kill() } catch { /* already exited */ }
611
- done()
612
- }, Math.max(250, Math.min(5_000, Number(timeoutMs) || 3_000)))
613
- try {
614
- const killer = spawnChild('taskkill', ['/pid', String(this.child.pid), '/T', '/F'], {
615
- shell: false, windowsHide: true, env: lspChildProcessEnv(), stdio: 'ignore',
616
- })
617
- const fallback = () => {
618
- try { this.child.kill('SIGKILL') } catch { /* already exited */ }
619
- }
620
- killer.once('error', () => { fallback(); done() })
621
- killer.once('exit', (code) => {
622
- if (code !== 0) fallback()
623
- done()
624
- })
625
- } catch {
626
- try { this.child.kill('SIGKILL') } catch { /* already exited */ }
627
- done()
628
- }
629
- })
630
- }
631
-
632
- async waitForExit(timeoutMs = this.requestTimeoutMs) {
633
- if (!ACTIVE_LSP_CLIENTS.has(this)) return true
634
- const boundedTimeout = Math.max(100, Math.min(10_000, Number(timeoutMs) || this.requestTimeoutMs))
635
- let timer
636
- const outcome = await Promise.race([
637
- this.exited.then(() => true),
638
- new Promise((resolveExit) => { timer = setTimeout(() => resolveExit(false), boundedTimeout) }),
639
- ])
640
- if (timer) clearTimeout(timer)
641
- return outcome
642
- }
643
-
644
- async shutdown(options = {}) {
645
- if (this.shutdownPromise) return this.shutdownPromise
646
- this.shutdownPromise = this.shutdownOnce(options)
647
- return this.shutdownPromise
648
- }
649
-
650
- async shutdownOnce({timeoutMs = this.requestTimeoutMs} = {}) {
651
- const boundedTimeout = positiveInteger(timeoutMs, this.requestTimeoutMs, 'timeoutMs')
652
- try {
653
- if (this.state === 'starting') await this.spawned
654
- if (this.state === 'closed') {
655
- await this.waitForExit(boundedTimeout)
656
- return
657
- }
658
- if (this.state === 'initialized') await this.request('shutdown', null, {timeoutMs: boundedTimeout})
659
- this.state = 'stopping'
660
- await this.notify('exit', null)
661
- // TLS owns a separate tsserver process. On Windows, wait for a whole-tree termination
662
- // after the protocol shutdown so watched repositories are released before close resolves.
663
- await this.killWindowsTreeAndWait(Math.min(3_000, boundedTimeout))
664
- if (process.platform !== 'win32' && this.processGroupPid) {
665
- // TLS may exit while its tsserver child remains alive. Give the protocol exit a
666
- // short grace period, then reap the complete private process group unconditionally.
667
- await this.waitForExit(Math.min(1_000, boundedTimeout))
668
- try { process.kill(-this.processGroupPid, 'SIGKILL') } catch { /* group already exited */ }
669
- }
670
- if (!await this.waitForExit(boundedTimeout)) this.kill(new LspTimeoutError('exit', boundedTimeout))
671
- } catch (error) {
672
- this.kill(error)
673
- await this.killWindowsTreeAndWait(Math.min(2_000, boundedTimeout))
674
- await this.waitForExit(Math.min(2_000, boundedTimeout))
675
- }
676
- }
677
- }
678
-
679
- export async function startStdioLspClient(options) {
680
- const client = new StdioLspClient(options)
681
- return client.start()
682
- }
1
+ export {
2
+ activeLspClientCount,
3
+ beginLspClientShutdown,
4
+ shutdownActiveLspClients,
5
+ } from './lsp-client/registry.js'
6
+ export {lspChildProcessEnv} from './lsp-client/environment.js'
7
+ export {LspProtocolError, LspTimeoutError} from './lsp-client/errors.js'
8
+ export {ContentLengthMessageParser} from './lsp-client/message-parser.js'
9
+ export {createRepoUriNormalizer} from './lsp-client/repo-uri.js'
10
+ export {StdioLspClient, startStdioLspClient} from './lsp-client/stdio-client.js'