@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/README.md +297 -0
- package/collector.js +220 -0
- package/coordinator.js +686 -0
- package/cordis.patch.yml +4 -0
- package/framing.js +170 -0
- package/index.js +581 -0
- package/package.json +70 -0
- package/render.js +468 -0
- package/runtime.js +1613 -0
- package/tool.js +496 -0
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
|
+
}
|