@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/index.js ADDED
@@ -0,0 +1,581 @@
1
+ /**
2
+ * Public entrypoint of `@banbolee/dsh-lsp-diagnostics`: a Cordis function plugin that
3
+ * appends a single bounded aggregate LSP diagnostics notice to a successful
4
+ * write/edit/str_replace_editor mutation inside the session workspace.
5
+ *
6
+ * @module @banbolee/dsh-lsp-diagnostics
7
+ */
8
+
9
+ import { createMutationCollector } from './collector.js'
10
+ import { createDiagnosticsCoordinator } from './coordinator.js'
11
+ import { DiagnosticsRuntime } from './runtime.js'
12
+ import { createDiagnosticsTool } from './tool.js'
13
+
14
+ /** Bundle row id this plugin is mounted under (`cordis.patch.yml`). */
15
+ export const name = 'lsp-diagnostics'
16
+
17
+ /** The plugin observes mutations and post-executes on the tools pipeline. */
18
+ export const inject = ['fs', 'subprocess', 'tools']
19
+
20
+ const MAX_NODE_TIMER = 2_147_483_647
21
+
22
+ const TOP_LEVEL_KEYS = new Set([
23
+ 'enabled',
24
+ 'timeoutMs',
25
+ 'settleMs',
26
+ 'shutdownTimeoutMs',
27
+ 'killGraceMs',
28
+ 'maxDocumentBytes',
29
+ 'maxMessageBytes',
30
+ 'maxStderrBytes',
31
+ 'maxDiagnostics',
32
+ 'maxResultChars',
33
+ 'reportClean',
34
+ 'servers',
35
+ ])
36
+
37
+ const SERVER_KEYS = new Set(['command', 'args', 'env', 'configuration', 'initializationOptions', 'extensionToLanguage'])
38
+
39
+ /** @typedef {'typescript' | 'go' | 'clangd' | 'rust' | 'python'} ProviderId */
40
+
41
+ /** @type {readonly ProviderId[]} */
42
+ const PROVIDERS = Object.freeze(['typescript', 'go', 'clangd', 'rust', 'python'])
43
+ /** @type {readonly ProviderId[]} */
44
+ const DEFAULT_ENABLED_PROVIDERS = Object.freeze(['typescript', 'go'])
45
+
46
+ /**
47
+ * @typedef {object} ServerConfig
48
+ * @property {string} command - non-empty server executable name.
49
+ * @property {readonly string[]} args - additional argv entries.
50
+ * @property {Record<string, string>} env - string-to-string environment.
51
+ * @property {unknown} configuration - JSON-representable workspace config.
52
+ * @property {unknown} initializationOptions - JSON-representable init options.
53
+ * @property {Record<string, string>} extensionToLanguage - fixed route map.
54
+ */
55
+
56
+ /**
57
+ * @typedef {object} RawConfig
58
+ * @property {boolean} [enabled]
59
+ * @property {number} [timeoutMs]
60
+ * @property {number} [settleMs]
61
+ * @property {number} [shutdownTimeoutMs]
62
+ * @property {number} [killGraceMs]
63
+ * @property {number} [maxDocumentBytes]
64
+ * @property {number} [maxMessageBytes]
65
+ * @property {number} [maxStderrBytes]
66
+ * @property {number} [maxDiagnostics]
67
+ * @property {number} [maxResultChars]
68
+ * @property {boolean} [reportClean]
69
+ * @property {{ typescript?: unknown, go?: unknown, clangd?: unknown, rust?: unknown, python?: unknown }} [servers]
70
+ */
71
+
72
+ /**
73
+ * @typedef {object} PluginConfig
74
+ * @property {boolean} enabled
75
+ * @property {number} timeoutMs
76
+ * @property {number} settleMs
77
+ * @property {number} shutdownTimeoutMs
78
+ * @property {number} killGraceMs
79
+ * @property {number} maxDocumentBytes
80
+ * @property {number} maxMessageBytes
81
+ * @property {number} maxStderrBytes
82
+ * @property {number} maxDiagnostics
83
+ * @property {number} maxResultChars
84
+ * @property {boolean} reportClean
85
+ * @property {Partial<Record<ProviderId, ServerConfig>>} servers
86
+ */
87
+
88
+ /**
89
+ * Canonical, closed extension route: only these mappings may ever exist.
90
+ * @type {Readonly<Record<string, Readonly<{ provider: ProviderId, language: string }>>>}
91
+ */
92
+ const CANONICAL_ROUTE = Object.freeze({
93
+ '.ts': Object.freeze({ provider: 'typescript', language: 'typescript' }),
94
+ '.tsx': Object.freeze({ provider: 'typescript', language: 'typescriptreact' }),
95
+ '.go': Object.freeze({ provider: 'go', language: 'go' }),
96
+ '.c': Object.freeze({ provider: 'clangd', language: 'c' }),
97
+ '.cc': Object.freeze({ provider: 'clangd', language: 'cpp' }),
98
+ '.cpp': Object.freeze({ provider: 'clangd', language: 'cpp' }),
99
+ '.cxx': Object.freeze({ provider: 'clangd', language: 'cpp' }),
100
+ '.h': Object.freeze({ provider: 'clangd', language: 'cpp' }),
101
+ '.hh': Object.freeze({ provider: 'clangd', language: 'cpp' }),
102
+ '.hpp': Object.freeze({ provider: 'clangd', language: 'cpp' }),
103
+ '.hxx': Object.freeze({ provider: 'clangd', language: 'cpp' }),
104
+ '.rs': Object.freeze({ provider: 'rust', language: 'rust' }),
105
+ '.py': Object.freeze({ provider: 'python', language: 'python' }),
106
+ '.pyi': Object.freeze({ provider: 'python', language: 'python' }),
107
+ })
108
+
109
+ /**
110
+ * @type {Readonly<Record<string, Readonly<{ command: string, args: readonly string[], env: Readonly<Record<string, string>>, configuration: unknown, initializationOptions: unknown, extensionToLanguage: Readonly<Record<string, string>> }>>>}
111
+ */
112
+ const DEFAULT_SERVERS = Object.freeze({
113
+ typescript: Object.freeze({
114
+ command: 'typescript-language-server',
115
+ args: Object.freeze(['--stdio']),
116
+ env: Object.freeze({}),
117
+ configuration: Object.freeze({}),
118
+ initializationOptions: null,
119
+ extensionToLanguage: Object.freeze({ '.ts': 'typescript', '.tsx': 'typescriptreact' }),
120
+ }),
121
+ go: Object.freeze({
122
+ command: 'gopls',
123
+ args: Object.freeze([]),
124
+ env: Object.freeze({}),
125
+ configuration: Object.freeze({}),
126
+ initializationOptions: null,
127
+ extensionToLanguage: Object.freeze({ '.go': 'go' }),
128
+ }),
129
+ clangd: Object.freeze({
130
+ command: 'clangd',
131
+ args: Object.freeze([]),
132
+ env: Object.freeze({}),
133
+ configuration: Object.freeze({}),
134
+ initializationOptions: null,
135
+ extensionToLanguage: Object.freeze({
136
+ '.c': 'c',
137
+ '.cc': 'cpp',
138
+ '.cpp': 'cpp',
139
+ '.cxx': 'cpp',
140
+ '.h': 'cpp',
141
+ '.hh': 'cpp',
142
+ '.hpp': 'cpp',
143
+ '.hxx': 'cpp',
144
+ }),
145
+ }),
146
+ rust: Object.freeze({
147
+ command: 'rust-analyzer',
148
+ args: Object.freeze([]),
149
+ env: Object.freeze({}),
150
+ configuration: Object.freeze({}),
151
+ initializationOptions: null,
152
+ extensionToLanguage: Object.freeze({ '.rs': 'rust' }),
153
+ }),
154
+ python: Object.freeze({
155
+ command: 'pyright-langserver',
156
+ args: Object.freeze(['--stdio']),
157
+ env: Object.freeze({}),
158
+ configuration: Object.freeze({}),
159
+ initializationOptions: null,
160
+ extensionToLanguage: Object.freeze({ '.py': 'python', '.pyi': 'python' }),
161
+ }),
162
+ })
163
+
164
+ const DEFAULTS = Object.freeze({
165
+ enabled: true,
166
+ timeoutMs: 5000,
167
+ settleMs: 200,
168
+ shutdownTimeoutMs: 1000,
169
+ killGraceMs: 500,
170
+ maxDocumentBytes: 2_097_152,
171
+ maxMessageBytes: 4_194_304,
172
+ maxStderrBytes: 16_384,
173
+ maxDiagnostics: 50,
174
+ maxResultChars: 8000,
175
+ reportClean: true,
176
+ })
177
+
178
+ /**
179
+ * @param {string} message - the failure detail.
180
+ * @returns {never}
181
+ */
182
+ function fail(message) {
183
+ throw new Error(`@banbolee/dsh-lsp-diagnostics config: ${message}`)
184
+ }
185
+
186
+ /**
187
+ * @param {unknown} value - candidate record.
188
+ * @param {string} path - error path prefix.
189
+ * @returns {Record<string, unknown>}
190
+ */
191
+ function assertRecord(value, path) {
192
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
193
+ fail(`${path} must be an object`)
194
+ }
195
+ return /** @type {Record<string, unknown>} */ (value)
196
+ }
197
+
198
+ /**
199
+ * @param {unknown} value - candidate timer/cap value.
200
+ * @param {string} name - config key name.
201
+ * @returns {number}
202
+ */
203
+ function assertSafeTimer(value, name) {
204
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1 || value > MAX_NODE_TIMER) {
205
+ fail(`${name} must be a positive safe integer <= ${MAX_NODE_TIMER}`)
206
+ }
207
+ return value
208
+ }
209
+
210
+ /**
211
+ * Canonicalize a validated JSON value into a stable plain clone, rejecting
212
+ * anything that is not recursively a standard JSON value: cycles,
213
+ * undefined/function/symbol/bigint, non-finite numbers, and non-plain
214
+ * containers (Map/Set/Date, boxed primitives, class instances, and objects
215
+ * whose prototype is not `Object.prototype` or `null` — including objects
216
+ * with an inherited `toJSON`).
217
+ *
218
+ * The clone is built by reading every own enumerable property exactly once,
219
+ * so later protocol serialization always sees the same plain structure: a
220
+ * stateful getter or `toJSON` can never be re-invoked, produce a second
221
+ * different value, or fail at protocol-write time.
222
+ * @param {unknown} value - the value to canonicalize.
223
+ * @param {string} path - error path prefix.
224
+ * @param {Set<object>} [seen] - container set for cycle detection.
225
+ * @returns {unknown} the canonical plain-JSON clone.
226
+ */
227
+ function canonicalizeJson(value, path, seen = new Set()) {
228
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
229
+ if (typeof value === 'number') {
230
+ if (!Number.isFinite(value)) fail(`${path} must not contain non-finite numbers`)
231
+ return value
232
+ }
233
+ if (typeof value !== 'object') {
234
+ fail(`${path} must not contain ${typeof value}`)
235
+ }
236
+ if (seen.has(value)) fail(`${path} must not contain circular references`)
237
+ seen.add(value)
238
+ if (Array.isArray(value)) {
239
+ const out = new Array(value.length)
240
+ for (let index = 0; index < value.length; index += 1) {
241
+ out[index] = canonicalizeJson(value[index], `${path}[${index}]`, seen)
242
+ }
243
+ seen.delete(value)
244
+ return out
245
+ }
246
+ const proto = Object.getPrototypeOf(value)
247
+ if (proto !== Object.prototype && proto !== null) {
248
+ fail(`${path} must be a plain object, not ${value.constructor?.name ?? 'a non-JSON container'}`)
249
+ }
250
+ const record = /** @type {Record<string, unknown>} */ (value)
251
+ /** @type {Record<string, unknown>} */
252
+ const out = {}
253
+ for (const key of Object.keys(record)) {
254
+ out[key] = canonicalizeJson(record[key], `${path}.${key}`, seen)
255
+ }
256
+ seen.delete(value)
257
+ return out
258
+ }
259
+
260
+ /**
261
+ * Validate a provider's extensionToLanguage map against the canonical route.
262
+ * @param {unknown} map - the extension map.
263
+ * @param {ProviderId} provider - the owning provider id.
264
+ * @returns {Record<string, string>}
265
+ */
266
+ function assertExtensionToLanguage(map, provider) {
267
+ const record = assertRecord(map, `servers.${provider}.extensionToLanguage`)
268
+ /** @type {Map<string, string>} */
269
+ const routes = new Map()
270
+ for (const [extension, language] of Object.entries(record)) {
271
+ if (extension !== extension.toLowerCase()) {
272
+ fail(`servers.${provider}.extensionToLanguage key ${String(extension)} must be ASCII lowercase`)
273
+ }
274
+ const canonical = CANONICAL_ROUTE[extension]
275
+ if (canonical === undefined) {
276
+ fail(`servers.${provider}.extensionToLanguage key ${String(extension)} is not a supported extension`)
277
+ }
278
+ if (canonical.provider !== provider) {
279
+ fail(`extension ${extension} must be declared by provider ${canonical.provider}`)
280
+ }
281
+ if (typeof language !== 'string' || language.length === 0) {
282
+ fail(`servers.${provider}.extensionToLanguage.${extension} must be a non-empty language id`)
283
+ }
284
+ if (language !== canonical.language) {
285
+ fail(`extension ${extension} must map to language ${canonical.language}`)
286
+ }
287
+ routes.set(extension, language)
288
+ }
289
+ const defaults = DEFAULT_SERVERS[provider]
290
+ if (defaults === undefined) fail(`provider ${provider} has no catalog defaults`)
291
+ const expected = defaults.extensionToLanguage
292
+ const expectedExtensions = Object.keys(expected)
293
+ if (
294
+ routes.size !== expectedExtensions.length
295
+ || !expectedExtensions.every((extension) => routes.get(extension) === expected[extension])
296
+ ) {
297
+ fail(`servers.${provider}.extensionToLanguage must equal the canonical provider mapping`)
298
+ }
299
+ return Object.fromEntries(routes)
300
+ }
301
+
302
+ /**
303
+ * Validate one provider's server config, merging per-field defaults.
304
+ * An own property explicitly set to `undefined` is NOT omission: the declared
305
+ * strict type check must run on the actual value and fail loud, exactly like
306
+ * an explicit `null`. Only a truly omitted key (no own enumerable property)
307
+ * falls back to the per-field default.
308
+ * @param {unknown} value - the raw server config.
309
+ * @param {ProviderId} provider - the provider id.
310
+ * @returns {ServerConfig}
311
+ */
312
+ function validateServer(value, provider) {
313
+ const record = assertRecord(value, `servers.${provider}`)
314
+ for (const key of Object.keys(record)) {
315
+ if (!SERVER_KEYS.has(key)) fail(`unknown key "${key}" in servers.${provider}`)
316
+ }
317
+ const defaults = /** @type {ServerConfig} */ (DEFAULT_SERVERS[provider])
318
+ /** @param {string} key @returns {boolean} */
319
+ const own = (key) => Object.prototype.hasOwnProperty.call(record, key)
320
+ const command = own('command') ? record.command : defaults.command
321
+ if (typeof command !== 'string' || command.length === 0) {
322
+ fail(`servers.${provider}.command must be a non-empty string`)
323
+ }
324
+ const args = own('args') ? record.args : defaults.args
325
+ // `Array.prototype.some` skips holes, so a sparse array must be rejected
326
+ // explicitly. Each accepted argv value is then reused from this validated
327
+ // snapshot: getter-backed arrays must not be read a second time after the
328
+ // type check and drift into an unvalidated runtime argv entry.
329
+ if (!Array.isArray(args)) {
330
+ fail(`servers.${provider}.args must be a string array`)
331
+ }
332
+ /** @type {string[]} */
333
+ const argsRecord = []
334
+ for (let index = 0; index < args.length; index += 1) {
335
+ if (!Object.prototype.hasOwnProperty.call(args, index)) {
336
+ fail(`servers.${provider}.args must be a string array without holes`)
337
+ }
338
+ const entry = args[index]
339
+ if (typeof entry !== 'string') {
340
+ fail(`servers.${provider}.args must be a string array without holes`)
341
+ }
342
+ argsRecord.push(entry)
343
+ }
344
+ const env = assertRecord(own('env') ? record.env : defaults.env, `servers.${provider}.env`)
345
+ const envEntries = Object.entries(env)
346
+ for (const [key, entry] of envEntries) {
347
+ if (typeof entry !== 'string') fail(`servers.${provider}.env.${key} must be a string`)
348
+ }
349
+ const envRecord = /** @type {Record<string, string>} */ (Object.fromEntries(envEntries))
350
+ // `configuration` and `initializationOptions` are standard JSON values;
351
+ // explicit `null` is a valid JSON value and must be preserved, never
352
+ // defaulted. Only a truly omitted field (no own enumerable property) falls
353
+ // back to the default; an own property whose value is `undefined` is not a
354
+ // JSON value and fails loud. Every accepted value is canonicalized once into
355
+ // a stable plain clone so protocol serialization is deterministic.
356
+ const hasConfiguration = Object.prototype.hasOwnProperty.call(record, 'configuration')
357
+ const configuration = hasConfiguration
358
+ ? canonicalizeJson(record.configuration, `servers.${provider}.configuration`)
359
+ : canonicalizeJson(defaults.configuration, `servers.${provider}.configuration`)
360
+ const hasInitializationOptions = Object.prototype.hasOwnProperty.call(record, 'initializationOptions')
361
+ const initializationOptions = hasInitializationOptions
362
+ ? canonicalizeJson(record.initializationOptions, `servers.${provider}.initializationOptions`)
363
+ : canonicalizeJson(defaults.initializationOptions, `servers.${provider}.initializationOptions`)
364
+ const extensionToLanguage = assertExtensionToLanguage(
365
+ own('extensionToLanguage') ? record.extensionToLanguage : defaults.extensionToLanguage,
366
+ provider,
367
+ )
368
+ return { command, args: argsRecord, env: envRecord, configuration, initializationOptions, extensionToLanguage }
369
+ }
370
+
371
+ /**
372
+ * Build the default server config for a provider.
373
+ * @param {ProviderId} provider - the provider id.
374
+ * @returns {ServerConfig}
375
+ */
376
+ function cloneDefaultServer(provider) {
377
+ const defaults = /** @type {ServerConfig} */ (DEFAULT_SERVERS[provider])
378
+ return {
379
+ command: defaults.command,
380
+ args: [...defaults.args],
381
+ env: { ...defaults.env },
382
+ configuration: defaults.configuration,
383
+ initializationOptions: defaults.initializationOptions,
384
+ extensionToLanguage: { ...defaults.extensionToLanguage },
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Validate the closed servers catalog. Default-enabled providers are always
390
+ * present; a supplied provider object overlays only that provider's defaults,
391
+ * and an optional provider is activated only when its known key is supplied.
392
+ * An explicit `servers: undefined` (own property) must fail loud, so this
393
+ * function never treats `undefined` as omission; the caller clones the
394
+ * defaults only when the key is truly absent.
395
+ * @param {unknown} value - the raw servers config.
396
+ * @returns {Partial<Record<ProviderId, ServerConfig>>}
397
+ */
398
+ function validateServers(value) {
399
+ const record = assertRecord(value, 'servers')
400
+ for (const provider of Object.keys(record)) {
401
+ if (!PROVIDERS.includes(/** @type {ProviderId} */ (provider))) {
402
+ fail(`unknown provider "${provider}" in servers`)
403
+ }
404
+ }
405
+ /** @type {Partial<Record<ProviderId, ServerConfig>>} */
406
+ const servers = {}
407
+ for (const provider of PROVIDERS) {
408
+ const supplied = Object.prototype.hasOwnProperty.call(record, provider)
409
+ if (!supplied && !DEFAULT_ENABLED_PROVIDERS.includes(provider)) continue
410
+ servers[provider] = supplied ? validateServer(record[provider], provider) : cloneDefaultServer(provider)
411
+ }
412
+ return servers
413
+ }
414
+
415
+ /**
416
+ * Plugin configuration schema: strict, fail-loud validation with fixed
417
+ * defaults. Unknown keys, invalid JSON values, out-of-range timers/caps,
418
+ * `settleMs >= timeoutMs`, and any deviation from the closed extension route
419
+ * all reject at load time.
420
+ */
421
+ export const Config = {
422
+ '~standard': {
423
+ version: /** @type {1} */ (1),
424
+ vendor: '@banbolee/dsh-lsp-diagnostics',
425
+ /**
426
+ * @param {unknown} value - raw user config.
427
+ * @returns {{ value: PluginConfig }} the validated config.
428
+ */
429
+ validate(value) {
430
+ // Explicit `null` is never treated as omission: a null config is
431
+ // rejected by assertRecord, and every typed field distinguishes an
432
+ // omitted key (fall back to the default) from an explicit `null`
433
+ // (rejected by its type check). `configuration`/`initializationOptions`
434
+ // are the only fields where `null` is a valid JSON value and survives.
435
+ const input = value === undefined ? {} : value
436
+ const record = assertRecord(input, 'config')
437
+ for (const key of Object.keys(record)) {
438
+ if (!TOP_LEVEL_KEYS.has(key)) fail(`unknown config key "${key}"`)
439
+ }
440
+ /** @type {RawConfig} */
441
+ const raw = record
442
+ /** @param {string} key @returns {boolean} */
443
+ const own = (key) => Object.prototype.hasOwnProperty.call(record, key)
444
+ const enabled = own('enabled') ? raw.enabled : DEFAULTS.enabled
445
+ if (typeof enabled !== 'boolean') fail('enabled must be a boolean')
446
+ const timeoutMs = assertSafeTimer(own('timeoutMs') ? raw.timeoutMs : DEFAULTS.timeoutMs, 'timeoutMs')
447
+ const settleMs = assertSafeTimer(own('settleMs') ? raw.settleMs : DEFAULTS.settleMs, 'settleMs')
448
+ if (settleMs >= timeoutMs) fail('settleMs must be < timeoutMs')
449
+ const shutdownTimeoutMs = assertSafeTimer(
450
+ own('shutdownTimeoutMs') ? raw.shutdownTimeoutMs : DEFAULTS.shutdownTimeoutMs,
451
+ 'shutdownTimeoutMs',
452
+ )
453
+ const killGraceMs = assertSafeTimer(own('killGraceMs') ? raw.killGraceMs : DEFAULTS.killGraceMs, 'killGraceMs')
454
+ const maxDocumentBytes = assertSafeTimer(
455
+ own('maxDocumentBytes') ? raw.maxDocumentBytes : DEFAULTS.maxDocumentBytes,
456
+ 'maxDocumentBytes',
457
+ )
458
+ const maxMessageBytes = assertSafeTimer(
459
+ own('maxMessageBytes') ? raw.maxMessageBytes : DEFAULTS.maxMessageBytes,
460
+ 'maxMessageBytes',
461
+ )
462
+ const maxStderrBytes = assertSafeTimer(
463
+ own('maxStderrBytes') ? raw.maxStderrBytes : DEFAULTS.maxStderrBytes,
464
+ 'maxStderrBytes',
465
+ )
466
+ const maxDiagnostics = assertSafeTimer(
467
+ own('maxDiagnostics') ? raw.maxDiagnostics : DEFAULTS.maxDiagnostics,
468
+ 'maxDiagnostics',
469
+ )
470
+ const maxResultChars = assertSafeTimer(
471
+ own('maxResultChars') ? raw.maxResultChars : DEFAULTS.maxResultChars,
472
+ 'maxResultChars',
473
+ )
474
+ const reportClean = own('reportClean') ? raw.reportClean : DEFAULTS.reportClean
475
+ if (typeof reportClean !== 'boolean') fail('reportClean must be a boolean')
476
+ const servers = own('servers')
477
+ ? validateServers(raw.servers)
478
+ : validateServers({})
479
+ return {
480
+ value: {
481
+ enabled,
482
+ timeoutMs,
483
+ settleMs,
484
+ shutdownTimeoutMs,
485
+ killGraceMs,
486
+ maxDocumentBytes,
487
+ maxMessageBytes,
488
+ maxStderrBytes,
489
+ maxDiagnostics,
490
+ maxResultChars,
491
+ reportClean,
492
+ servers,
493
+ },
494
+ }
495
+ },
496
+ },
497
+ }
498
+
499
+ /**
500
+ * Apply the plugin. `enabled === false` short-circuits with zero collector/
501
+ * runtime/coordinator, zero listeners/effects, and zero subprocesses.
502
+ *
503
+ * When enabled, assembles one mutation collector, one diagnostics runtime, the
504
+ * post-execute coordinator, and one direct tool owner sharing that runtime.
505
+ * It registers the tool plus exactly two listeners (`fs/observed` and
506
+ * `tools/post-execute`), holds every exact disposer, and registers one cleanup
507
+ * effect: stop direct/coordinator admission → off tool/post/observed → abort
508
+ * both owners → await both owners → await retired I/O → `runtime.dispose()`.
509
+ *
510
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the harness context.
511
+ * @param {PluginConfig} config - validated plugin configuration.
512
+ */
513
+ export function apply(ctx, config) {
514
+ if (config.enabled === false) return
515
+ const collector = createMutationCollector()
516
+ // The public services are consumed through the plugin's loose structural
517
+ // seams: `ctx.fs`/`ctx.subprocess`/`ctx.tools` are harness services and are
518
+ // not statically visible on the cordis Context surface here.
519
+ const services = /** @type {{ fs: unknown, subprocess: unknown, tools: { register(definition: unknown): () => unknown } }} */ (/** @type {unknown} */ (ctx))
520
+ const fs = /** @type {import('./tool.js').FsSeam} */ (services.fs)
521
+ const runtime = new DiagnosticsRuntime({
522
+ fs: /** @type {import('./runtime.js').FsSeam} */ (/** @type {unknown} */ (fs)),
523
+ subprocess: /** @type {import('./runtime.js').SubprocessSeam} */ (services.subprocess),
524
+ config,
525
+ })
526
+ const coordinator = createDiagnosticsCoordinator({
527
+ collector,
528
+ runtime,
529
+ config,
530
+ fs: /** @type {import('./coordinator.js').FsSeam} */ (fs),
531
+ })
532
+ const toolOwner = createDiagnosticsTool({ fs, runtime, config })
533
+ const offTool = services.tools.register(toolOwner.definition)
534
+ const offObserved = ctx.on('fs/observed', (target, observation, actor) => {
535
+ if (collector.observe(actor, target, observation) === true) toolOwner.observeMutation(target)
536
+ })
537
+ const onPostExecute = /** @type {(name: string, listener: unknown) => () => boolean} */ (ctx.on)
538
+ const offPost = onPostExecute('tools/post-execute', coordinator.listener)
539
+ ctx.effect(() => async () => {
540
+ const errors = []
541
+ for (const stop of [toolOwner.stopAdmission, coordinator.stopAdmission]) {
542
+ try {
543
+ stop()
544
+ } catch (error) {
545
+ errors.push(error)
546
+ }
547
+ }
548
+ for (const off of [offTool, offPost, offObserved]) {
549
+ try {
550
+ await Promise.resolve(off())
551
+ } catch (error) {
552
+ errors.push(error)
553
+ }
554
+ }
555
+ for (const abort of [coordinator.abortActiveOperations, toolOwner.abortActiveOperations]) {
556
+ try {
557
+ abort()
558
+ } catch (error) {
559
+ errors.push(error)
560
+ }
561
+ }
562
+ for (const awaitActive of [coordinator.awaitActiveOperations, toolOwner.awaitActiveOperations]) {
563
+ try {
564
+ await awaitActive()
565
+ } catch (error) {
566
+ errors.push(error)
567
+ }
568
+ }
569
+ try {
570
+ await coordinator.awaitRetiredIo()
571
+ } catch (error) {
572
+ errors.push(error)
573
+ }
574
+ try {
575
+ await runtime.dispose()
576
+ } catch (error) {
577
+ errors.push(error)
578
+ }
579
+ if (errors.length > 0) throw new AggregateError(errors)
580
+ }, '@banbolee/dsh-lsp-diagnostics tool, listeners, operations, retired I/O, and runtime teardown')
581
+ }
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@banbolee/dsh-lsp-diagnostics",
3
+ "version": "0.1.0",
4
+ "description": "Automatic post-write and model-callable read-only LSP diagnostics for existing files in DeepSeek Harness.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "files": [
12
+ "index.js",
13
+ "collector.js",
14
+ "framing.js",
15
+ "runtime.js",
16
+ "tool.js",
17
+ "render.js",
18
+ "coordinator.js",
19
+ "cordis.patch.yml",
20
+ "README.md"
21
+ ],
22
+ "dsh": {
23
+ "bundle": {
24
+ "patch": "./cordis.patch.yml"
25
+ }
26
+ },
27
+ "peerDependencies": {
28
+ "@deepseek-ai/cordis": "^4.0.2",
29
+ "@deepseek-ai/dsh-fs": "^0.1.5-rc.1",
30
+ "@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
31
+ "@deepseek-ai/dsh-subprocess": "^0.1.5-rc.1",
32
+ "@deepseek-ai/dsh-tools": "^0.1.5-rc.1"
33
+ },
34
+ "devDependencies": {
35
+ "@deepseek-ai/cordis": "^4.0.2",
36
+ "@deepseek-ai/dsh-agent": "^0.1.5-rc.1",
37
+ "@deepseek-ai/dsh-agent-loop": "^0.1.5-rc.1",
38
+ "@deepseek-ai/dsh-attachment": "^0.1.5-rc.1",
39
+ "@deepseek-ai/dsh-code-runtime": "^0.1.5-rc.1",
40
+ "@deepseek-ai/dsh-code-runtime-worker-thread": "^0.1.5-rc.1",
41
+ "@deepseek-ai/dsh-fs": "^0.1.5-rc.1",
42
+ "@deepseek-ai/dsh-fs-local": "^0.1.5-rc.1",
43
+ "@deepseek-ai/dsh-invariants": "^0.1.5-rc.1",
44
+ "@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
45
+ "@deepseek-ai/dsh-scope": "^0.1.5-rc.1",
46
+ "@deepseek-ai/dsh-session": "^0.1.5-rc.1",
47
+ "@deepseek-ai/dsh-session-persistence": "^0.1.5-rc.1",
48
+ "@deepseek-ai/dsh-subprocess": "^0.1.5-rc.1",
49
+ "@deepseek-ai/dsh-subprocess-local": "^0.1.5-rc.1",
50
+ "@deepseek-ai/dsh-system-prompt": "^0.1.5-rc.1",
51
+ "@deepseek-ai/dsh-tool-fs": "^0.1.5-rc.1",
52
+ "@deepseek-ai/dsh-tool-str-replace-editor": "^0.1.5-rc.1",
53
+ "@deepseek-ai/dsh-tools": "^0.1.5-rc.1",
54
+ "@deepseek-ai/dsh-user-approval": "^0.1.5-rc.1"
55
+ },
56
+ "keywords": [
57
+ "dsh",
58
+ "plugin",
59
+ "lsp",
60
+ "diagnostics"
61
+ ],
62
+ "license": "MIT",
63
+ "publishConfig": {
64
+ "access": "public",
65
+ "registry": "https://registry.npmjs.org"
66
+ },
67
+ "engines": {
68
+ "node": ">=22"
69
+ }
70
+ }