@banbolee/dsh-lsp-diagnostics 0.1.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/tool.js ADDED
@@ -0,0 +1,496 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+ import { compareDiagnostics, sanitizeDisplayPath } from './render.js'
3
+
4
+ /** Closed runtime reasons exposed by the canonical tool output. */
5
+ const UNAVAILABLE_REASONS = /** @type {const} */ ([
6
+ 'server not found',
7
+ 'server crashed',
8
+ 'timeout',
9
+ 'malformed response',
10
+ 'document too large',
11
+ 'diagnostics unavailable',
12
+ ])
13
+
14
+ const POSITION_SCHEMA = /** @type {const} */ ({
15
+ type: 'object',
16
+ additionalProperties: false,
17
+ properties: {
18
+ line: { type: 'integer', required: true },
19
+ character: { type: 'integer', required: true },
20
+ },
21
+ })
22
+
23
+ const RANGE_SCHEMA = /** @type {const} */ ({
24
+ type: 'object',
25
+ additionalProperties: false,
26
+ properties: {
27
+ start: { ...POSITION_SCHEMA, required: true },
28
+ end: { ...POSITION_SCHEMA, required: true },
29
+ },
30
+ })
31
+
32
+ const DIAGNOSTIC_SCHEMA = /** @type {const} */ ({
33
+ type: 'object',
34
+ additionalProperties: false,
35
+ properties: {
36
+ range: { ...RANGE_SCHEMA, required: true },
37
+ severity: {
38
+ type: 'string',
39
+ required: true,
40
+ enum: ['error', 'warning', 'info', 'hint', 'unknown'],
41
+ },
42
+ code: { type: 'string', required: true },
43
+ source: { type: 'string', required: true },
44
+ message: { type: 'string', required: true },
45
+ },
46
+ })
47
+
48
+ /** @satisfies {import('@deepseek-ai/dsh-tools').ValueSchemaSpec} */
49
+ const OUTPUT_SCHEMA = /** @type {const} */ ({
50
+ oneOf: [
51
+ {
52
+ type: 'object',
53
+ additionalProperties: false,
54
+ properties: {
55
+ kind: { type: 'string', required: true, const: 'diagnostics' },
56
+ file_path: { type: 'string', required: true },
57
+ diagnostics: {
58
+ type: 'array',
59
+ required: true,
60
+ items: DIAGNOSTIC_SCHEMA,
61
+ },
62
+ omitted_diagnostics: { type: 'integer', required: true },
63
+ },
64
+ },
65
+ {
66
+ type: 'object',
67
+ additionalProperties: false,
68
+ properties: {
69
+ kind: { type: 'string', required: true, const: 'no_diagnostics' },
70
+ file_path: { type: 'string', required: true },
71
+ },
72
+ },
73
+ {
74
+ type: 'object',
75
+ additionalProperties: false,
76
+ properties: {
77
+ kind: { type: 'string', required: true, const: 'unavailable' },
78
+ file_path: { type: 'string', required: true },
79
+ reason: {
80
+ type: 'string',
81
+ required: true,
82
+ enum: UNAVAILABLE_REASONS,
83
+ },
84
+ },
85
+ },
86
+ ],
87
+ })
88
+
89
+ /**
90
+ * @typedef {object} CanonicalDiagnostic
91
+ * @property {import('./runtime.js').NormalizedRange} range
92
+ * @property {import('./runtime.js').SeverityToken} severity
93
+ * @property {string} code
94
+ * @property {string} source
95
+ * @property {string} message
96
+ */
97
+
98
+ /**
99
+ * @typedef {{ kind: 'diagnostics', file_path: string, diagnostics: CanonicalDiagnostic[], omitted_diagnostics: number } |
100
+ * { kind: 'no_diagnostics', file_path: string } |
101
+ * { kind: 'unavailable', file_path: string, reason: import('./runtime.js').UnavailableReason }} CanonicalResult
102
+ */
103
+
104
+ /**
105
+ * @typedef {object} FsSeam
106
+ * @property {(path: string, opts?: { cwd?: string, signal?: AbortSignal }) => Promise<import('@deepseek-ai/dsh-fs').FsTarget>} resolve
107
+ * @property {(target: import('@deepseek-ai/dsh-fs').FsTarget, signal?: AbortSignal) => Promise<import('@deepseek-ai/dsh-fs').FsInfo | undefined>} stat
108
+ * @property {(target: import('@deepseek-ai/dsh-fs').FsTarget) => string} fileUrl
109
+ */
110
+
111
+ /**
112
+ * @typedef {object} RuntimeSeam
113
+ * @property {(target: import('@deepseek-ai/dsh-fs').FsTarget, canonicalWorkspace: import('@deepseek-ai/dsh-fs').FsTarget, canonicalUri: string, signal?: AbortSignal, expectedVersion?: string) => Promise<import('./runtime.js').DiagnosisOutcome>} diagnoseTarget
114
+ */
115
+
116
+ /**
117
+ * @typedef {object} ToolConfig
118
+ * @property {number} timeoutMs
119
+ * @property {number} maxDiagnostics
120
+ * @property {number} maxResultChars
121
+ * @property {Record<string, { extensionToLanguage: Record<string, string> }>} servers
122
+ */
123
+
124
+ /**
125
+ * @typedef {object} ActiveOperation
126
+ * @property {AbortController} controller
127
+ * @property {AbortSignal} callerSignal
128
+ * @property {boolean} callerAborted
129
+ * @property {boolean} cleanupAborted
130
+ * @property {boolean} deadlineFired
131
+ * @property {number} deadlineAt
132
+ * @property {string} filePath
133
+ * @property {ReturnType<typeof setTimeout> | undefined} [timer]
134
+ * @property {() => void} onCallerAbort
135
+ * @property {Promise<unknown>} promise
136
+ */
137
+
138
+ /**
139
+ * Return the lowercase final suffix of a canonical file URI.
140
+ * @param {string} uri
141
+ * @returns {string}
142
+ */
143
+ function extensionOf(uri) {
144
+ try {
145
+ const pathname = new URL(uri).pathname
146
+ const slash = pathname.lastIndexOf('/')
147
+ const base = slash === -1 ? pathname : pathname.slice(slash + 1)
148
+ const dot = base.lastIndexOf('.')
149
+ return dot === -1 ? '' : base.slice(dot).toLowerCase()
150
+ } catch {
151
+ return ''
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Build a consistent cancellation error after owned work reaches quiescence.
157
+ * @param {AbortSignal} signal
158
+ * @returns {Error}
159
+ */
160
+ function abortError(signal) {
161
+ const error = new Error('lsp_diagnostics operation aborted')
162
+ error.name = 'AbortError'
163
+ if (signal.reason !== undefined) {
164
+ try {
165
+ error.cause = signal.reason
166
+ } catch {
167
+ // A hostile reason getter must not break cancellation.
168
+ }
169
+ }
170
+ return error
171
+ }
172
+
173
+ /**
174
+ * Sanitize one model-rendered field without adding lines.
175
+ * @param {string} value
176
+ * @returns {string}
177
+ */
178
+ function singleLine(value) {
179
+ return value.replace(/[\r\n\u2028\u2029]+/gu, ' ')
180
+ }
181
+
182
+ /**
183
+ * Keep a complete result inside the configured Unicode code-point cap.
184
+ * @param {string} text
185
+ * @param {number} maxResultChars
186
+ * @returns {string}
187
+ */
188
+ function capResult(text, maxResultChars) {
189
+ const points = Array.from(text)
190
+ if (points.length <= maxResultChars) return text
191
+ const marker = Array.from('…(truncated)')
192
+ if (maxResultChars < marker.length) return marker.slice(0, maxResultChars).join('')
193
+ return points.slice(0, maxResultChars - marker.length).join('') + marker.join('')
194
+ }
195
+
196
+ /**
197
+ * Pure model rendering of one canonical diagnostics result.
198
+ * @param {CanonicalResult} value
199
+ * @param {ToolConfig} config
200
+ * @returns {string}
201
+ */
202
+ function renderResult(value, config) {
203
+ const lines = ['[LSP diagnostics]', `File: ${singleLine(value.file_path)}`]
204
+ if (value.kind === 'no_diagnostics') {
205
+ lines.push('No diagnostics reported for this file snapshot.')
206
+ return capResult(lines.join('\n'), config.maxResultChars)
207
+ }
208
+ if (value.kind === 'unavailable') {
209
+ lines.push(`Diagnostics unavailable (${value.reason}).`)
210
+ return capResult(lines.join('\n'), config.maxResultChars)
211
+ }
212
+ lines.push(...value.diagnostics.map((diagnostic) => {
213
+ const start = diagnostic.range.start
214
+ const end = diagnostic.range.end
215
+ return `- ${diagnostic.severity} ${start.line + 1}:${start.character + 1}-${end.line + 1}:${end.character + 1} source=${JSON.stringify(singleLine(diagnostic.source))} code=${JSON.stringify(singleLine(diagnostic.code))} ${singleLine(diagnostic.message)}`
216
+ }))
217
+ if (value.omitted_diagnostics > 0) {
218
+ const omitted = value.omitted_diagnostics
219
+ lines.push(`… ${omitted} more diagnostic${omitted === 1 ? '' : 's'} omitted (limit ${config.maxDiagnostics}).`)
220
+ }
221
+ return capResult(lines.join('\n'), config.maxResultChars)
222
+ }
223
+
224
+ /**
225
+ * Project a runtime diagnostic onto the strict canonical tool shape.
226
+ * @param {import('./runtime.js').NormalizedDiagnostic} diagnostic
227
+ * @returns {CanonicalDiagnostic}
228
+ */
229
+ function projectDiagnostic(diagnostic) {
230
+ return {
231
+ range: {
232
+ start: {
233
+ line: diagnostic.range.start.line,
234
+ character: diagnostic.range.start.character,
235
+ },
236
+ end: {
237
+ line: diagnostic.range.end.line,
238
+ character: diagnostic.range.end.character,
239
+ },
240
+ },
241
+ severity: diagnostic.severity,
242
+ code: diagnostic.code,
243
+ source: diagnostic.source,
244
+ message: diagnostic.message,
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Build the reusable model-facing tool and its direct-operation lifecycle owner.
250
+ * Registration and plugin cleanup deliberately remain outside this pure factory.
251
+ *
252
+ * @param {{ fs: FsSeam, runtime: RuntimeSeam, config: ToolConfig, now?: () => number }} deps
253
+ * @returns {{
254
+ * definition: import('@deepseek-ai/dsh-tools').ToolDefinition,
255
+ * stopAdmission(): void,
256
+ * abortActiveOperations(): void,
257
+ * awaitActiveOperations(): Promise<void>,
258
+ * observeMutation(target: any): void,
259
+ * }}
260
+ */
261
+ export function createDiagnosticsTool({ fs, runtime, config, now = () => performance.now() }) {
262
+ const supportedExtensions = new Set()
263
+ for (const server of Object.values(config.servers)) {
264
+ for (const extension of Object.keys(server.extensionToLanguage)) supportedExtensions.add(extension)
265
+ }
266
+
267
+ /** @type {Set<ActiveOperation>} */
268
+ const activeOperations = new Set()
269
+ /** @type {Map<string, object>} */
270
+ const mutationEpochs = new Map()
271
+ let admissionOpen = true
272
+
273
+ /**
274
+ * Start and track one operation before its first filesystem microtask.
275
+ * @param {AbortSignal} callerSignal
276
+ * @param {(operation: ActiveOperation) => Promise<CanonicalResult>} run
277
+ * @returns {Promise<CanonicalResult>}
278
+ */
279
+ function beginOperation(callerSignal, run) {
280
+ const controller = new AbortController()
281
+ /** @type {ActiveOperation} */
282
+ const operation = {
283
+ controller,
284
+ callerSignal,
285
+ callerAborted: callerSignal.aborted,
286
+ cleanupAborted: false,
287
+ deadlineFired: false,
288
+ deadlineAt: now() + config.timeoutMs,
289
+ filePath: '',
290
+ onCallerAbort: () => {},
291
+ promise: Promise.resolve(),
292
+ }
293
+ operation.onCallerAbort = () => {
294
+ operation.callerAborted = true
295
+ if (!controller.signal.aborted) controller.abort(callerSignal.reason)
296
+ }
297
+ if (callerSignal.aborted) operation.onCallerAbort()
298
+ else callerSignal.addEventListener('abort', operation.onCallerAbort, { once: true })
299
+ operation.timer = setTimeout(() => expireDeadline(operation), config.timeoutMs)
300
+ const promise = Promise.resolve()
301
+ .then(() => run(operation))
302
+ .finally(() => {
303
+ clearTimeout(operation.timer)
304
+ callerSignal.removeEventListener('abort', operation.onCallerAbort)
305
+ activeOperations.delete(operation)
306
+ })
307
+ operation.promise = promise
308
+ activeOperations.add(operation)
309
+ return promise
310
+ }
311
+
312
+ /**
313
+ * Run the owned deadline transition once.
314
+ * @param {ActiveOperation} operation
315
+ * @returns {void}
316
+ */
317
+ function expireDeadline(operation) {
318
+ if (operation.deadlineFired) return
319
+ operation.deadlineFired = true
320
+ if (!operation.controller.signal.aborted) {
321
+ operation.controller.abort(new Error('lsp_diagnostics deadline exceeded'))
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Throw external cancellation; report an owned deadline as canonical unavailable.
327
+ * Every gate checks the absolute monotonic deadline, so event-loop starvation
328
+ * cannot extend the operation while its timer callback is still queued.
329
+ * @param {ActiveOperation} operation
330
+ * @returns {Extract<CanonicalResult, { kind: 'unavailable' }> | undefined}
331
+ */
332
+ function abortOutcome(operation) {
333
+ if (!operation.deadlineFired && now() >= operation.deadlineAt) expireDeadline(operation)
334
+ if (operation.callerAborted || operation.cleanupAborted) throw abortError(operation.callerSignal)
335
+ if (operation.deadlineFired) {
336
+ return { kind: 'unavailable', file_path: operation.filePath, reason: 'timeout' }
337
+ }
338
+ return undefined
339
+ }
340
+
341
+ /**
342
+ * Resolve, validate, diagnose, and freshness-check one target.
343
+ * @param {string} filePath
344
+ * @param {string} workspaceRoot
345
+ * @param {ActiveOperation} operation
346
+ * @returns {Promise<CanonicalResult>}
347
+ */
348
+ async function executeCall(filePath, workspaceRoot, operation) {
349
+ operation.filePath = sanitizeDisplayPath(filePath)
350
+ try {
351
+ let early = abortOutcome(operation)
352
+ if (early !== undefined) return early
353
+ const signal = operation.controller.signal
354
+ const workspace = await fs.resolve(workspaceRoot, { signal })
355
+ early = abortOutcome(operation)
356
+ if (early !== undefined) return early
357
+ const workspaceInfo = await fs.stat(workspace, signal)
358
+ early = abortOutcome(operation)
359
+ if (early !== undefined) return early
360
+ if (workspaceInfo === undefined || workspaceInfo.type !== 'directory') {
361
+ throw new Error('lsp_diagnostics: session workspace is not an existing directory')
362
+ }
363
+
364
+ const target = await fs.resolve(filePath, { cwd: workspaceRoot, signal })
365
+ operation.filePath = sanitizeDisplayPath(target.displayPath)
366
+ early = abortOutcome(operation)
367
+ if (early !== undefined) return early
368
+ const targetKey = String(target.targetKey ?? '')
369
+ const mutationEpoch = mutationEpochs.get(targetKey)
370
+ const before = await fs.stat(target, signal)
371
+ early = abortOutcome(operation)
372
+ if (early !== undefined) return early
373
+ if (before === undefined) throw new Error(`lsp_diagnostics: target does not exist: ${filePath}`)
374
+ if (before.type !== 'file') throw new Error(`lsp_diagnostics: target is not a regular file: ${filePath}`)
375
+
376
+ const canonicalUri = fs.fileUrl(target)
377
+ const extension = extensionOf(canonicalUri)
378
+ if (!supportedExtensions.has(extension)) {
379
+ const label = extension.length === 0 ? '(none)' : extension
380
+ throw new Error(`lsp_diagnostics: no configured diagnostics provider for extension ${label}`)
381
+ }
382
+
383
+ const outcome = await runtime.diagnoseTarget(
384
+ target,
385
+ workspace,
386
+ canonicalUri,
387
+ signal,
388
+ before.version,
389
+ )
390
+ early = abortOutcome(operation)
391
+ if (early !== undefined) return early
392
+ if (outcome.kind === 'stale') {
393
+ throw new Error(`lsp_diagnostics: target changed during diagnosis: ${filePath}`)
394
+ }
395
+
396
+ const after = await fs.stat(target, signal)
397
+ early = abortOutcome(operation)
398
+ if (early !== undefined) return early
399
+ if (
400
+ after === undefined
401
+ || after.type !== 'file'
402
+ || after.version !== before.version
403
+ || mutationEpochs.get(targetKey) !== mutationEpoch
404
+ ) {
405
+ throw new Error(`lsp_diagnostics: target changed during diagnosis: ${filePath}`)
406
+ }
407
+ if (outcome.kind === 'unavailable') {
408
+ return { kind: 'unavailable', file_path: operation.filePath, reason: outcome.reason }
409
+ }
410
+ if (outcome.diagnostics.length === 0) {
411
+ return { kind: 'no_diagnostics', file_path: operation.filePath }
412
+ }
413
+ const diagnostics = outcome.diagnostics.slice().sort(compareDiagnostics)
414
+ const retained = diagnostics.slice(0, config.maxDiagnostics)
415
+ return {
416
+ kind: 'diagnostics',
417
+ file_path: operation.filePath,
418
+ diagnostics: retained.map(projectDiagnostic),
419
+ omitted_diagnostics: diagnostics.length - retained.length,
420
+ }
421
+ } catch (error) {
422
+ const early = abortOutcome(operation)
423
+ if (early !== undefined) return early
424
+ throw error
425
+ }
426
+ }
427
+
428
+ const definition = defineTool({
429
+ name: 'lsp_diagnostics',
430
+ description: 'Diagnose an existing file explicitly with its configured language server. This read-only tool is especially useful after shell commands, formatters, or generators bypass automatic write/edit feedback; avoid redundant calls when fresh automatic feedback already exists. file_path may be workspace-relative or absolute.',
431
+ parameters: {
432
+ file_path: {
433
+ type: 'string',
434
+ required: true,
435
+ description: 'The source file to diagnose, relative to the session workspace or absolute.',
436
+ },
437
+ },
438
+ output: {
439
+ schema: OUTPUT_SCHEMA,
440
+ render(_args, value) {
441
+ return [{ type: 'text', text: renderResult(value, config) }]
442
+ },
443
+ },
444
+ async execute(args, exec) {
445
+ if (args.file_path.trim().length === 0) {
446
+ throw new Error('lsp_diagnostics: file_path must be a non-empty string')
447
+ }
448
+ const workspaceRoot = exec.agent?.session.header.cwd
449
+ if (typeof workspaceRoot !== 'string' || workspaceRoot.trim().length === 0) {
450
+ throw new Error('lsp_diagnostics requires a session workspace cwd')
451
+ }
452
+ if (!admissionOpen) throw new Error('lsp_diagnostics is shutting down')
453
+ return beginOperation(exec.signal, (operation) => executeCall(args.file_path, workspaceRoot, operation))
454
+ },
455
+ presentCall(args) {
456
+ return {
457
+ card: 'generic',
458
+ kind: 'search',
459
+ title: `LSP diagnostics ${args.file_path}`,
460
+ locations: [{ path: args.file_path }],
461
+ }
462
+ },
463
+ })
464
+
465
+ function stopAdmission() {
466
+ admissionOpen = false
467
+ }
468
+
469
+ function abortActiveOperations() {
470
+ for (const operation of activeOperations) {
471
+ operation.cleanupAborted = true
472
+ if (!operation.controller.signal.aborted) {
473
+ operation.controller.abort(new Error('lsp_diagnostics is shutting down'))
474
+ }
475
+ }
476
+ }
477
+
478
+ async function awaitActiveOperations() {
479
+ while (activeOperations.size > 0) {
480
+ await Promise.allSettled([...activeOperations].map((operation) => operation.promise))
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Advance the ABA guard after an accepted mutating fs/observed event. The
486
+ * registration layer must apply the same mutating-actor filter as the
487
+ * collector; reads and absent observations must never call this hook.
488
+ * @param {import('@deepseek-ai/dsh-fs').FsTarget} target
489
+ */
490
+ function observeMutation(target) {
491
+ const targetKey = String(target?.targetKey ?? '')
492
+ if (targetKey.length > 0) mutationEpochs.set(targetKey, {})
493
+ }
494
+
495
+ return { definition, stopAdmission, abortActiveOperations, awaitActiveOperations, observeMutation }
496
+ }