@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/render.js ADDED
@@ -0,0 +1,468 @@
1
+ /**
2
+ * Deterministic aggregate renderer for per-exec LSP diagnostics notices.
3
+ *
4
+ * Owns the shared single-line path sanitizer, the unique
5
+ * `(renderPath, String(targetKey), canonicalUri)` Unicode code-point file
6
+ * comparator (also the coordinator's scheduling oracle), the strict
7
+ * post-eligibility discriminated-union validation, the single global
8
+ * `maxDiagnostics` count budget, the unique canonical aggregate grammar with
9
+ * its conditional single global advisory, and the final `maxResultChars`
10
+ * Unicode code-point cap with the fixed `…(truncated)` marker.
11
+ *
12
+ * @module @banbolee/dsh-lsp-diagnostics/render
13
+ */
14
+
15
+ /**
16
+ * @typedef {object} NormalizedPosition
17
+ * @property {number} line - LSP 0-based UTF-16 line.
18
+ * @property {number} character - LSP 0-based UTF-16 character.
19
+ */
20
+
21
+ /**
22
+ * @typedef {object} NormalizedRange
23
+ * @property {NormalizedPosition} start
24
+ * @property {NormalizedPosition} end
25
+ */
26
+
27
+ /**
28
+ * @typedef {object} NormalizedDiagnostic
29
+ * @property {string} uri
30
+ * @property {NormalizedRange} range
31
+ * @property {'error' | 'warning' | 'info' | 'hint' | 'unknown'} severity
32
+ * @property {0 | 1 | 2 | 3 | 4} severityRank
33
+ * @property {string} code
34
+ * @property {string} source
35
+ * @property {string} message
36
+ */
37
+
38
+ /**
39
+ * @typedef {'server not found' | 'server crashed' | 'timeout' | 'malformed response' | 'document too large' | 'diagnostics unavailable'} UnavailableReason
40
+ */
41
+
42
+ /**
43
+ * @typedef {object} RenderTarget
44
+ * @property {string} renderPath - frozen single-line render path.
45
+ * @property {string} targetKey - opaque stable FsTargetKey.
46
+ * @property {string} canonicalUri - canonical document URI (audit/third sort column only).
47
+ * @property {'diagnostics' | 'clean' | 'unavailable'} kind
48
+ * @property {NormalizedDiagnostic[] | undefined} [diagnostics] - kind 'diagnostics' only.
49
+ * @property {UnavailableReason | undefined} [reason] - kind 'unavailable' only.
50
+ */
51
+
52
+ /** The unique aggregate title. */
53
+ const TITLE = '[LSP diagnostics after write]'
54
+
55
+ /** The single conditional global advisory. */
56
+ const ADVISORY = 'Fix these diagnostics before considering the change complete.'
57
+
58
+ /** The fixed truncation marker appended after the code-point char cap. */
59
+ const TRUNCATION_MARKER = '…(truncated)'
60
+
61
+ /** Severity token → sort rank: error < warning < info < hint < unknown. */
62
+ /** @type {Record<string, 0 | 1 | 2 | 3 | 4>} */
63
+ const SEVERITY_RANK = { error: 0, warning: 1, info: 2, hint: 3, unknown: 4 }
64
+
65
+ /** The closed set of unavailable reasons (workspace reasons are not in the union). */
66
+ const UNAVAILABLE_REASONS = new Set([
67
+ 'server not found',
68
+ 'server crashed',
69
+ 'timeout',
70
+ 'malformed response',
71
+ 'document too large',
72
+ 'diagnostics unavailable',
73
+ ])
74
+
75
+ /**
76
+ * Fail loud on an input that violates the renderer contract. The renderer
77
+ * only receives coordinator-constructed data; an unknown field or shape
78
+ * mismatch is an implementation bug, not a server condition.
79
+ * @param {string} message - the failure detail.
80
+ * @returns {never}
81
+ */
82
+ function failLoud(message) {
83
+ throw new Error(`@banbolee/dsh-lsp-diagnostics render: ${message}`)
84
+ }
85
+
86
+ /**
87
+ * Require a plain object (not null, not an array).
88
+ * @param {unknown} value - the value to check.
89
+ * @param {string} what - field name for error messages.
90
+ * @returns {Record<string, unknown>}
91
+ */
92
+ function assertRecord(value, what) {
93
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
94
+ failLoud(`${what} must be an object`)
95
+ }
96
+ return /** @type {Record<string, unknown>} */ (value)
97
+ }
98
+
99
+ /**
100
+ * Require a string field.
101
+ * @param {unknown} value - the value to check.
102
+ * @param {string} what - field name for error messages.
103
+ * @returns {string}
104
+ */
105
+ function assertString(value, what) {
106
+ if (typeof value !== 'string') failLoud(`${what} must be a string`)
107
+ return value
108
+ }
109
+
110
+ /**
111
+ * Require exactly the given keys (no unknown, no missing).
112
+ * @param {Record<string, unknown>} record - the object to check.
113
+ * @param {readonly string[]} allowed - the allowed key set.
114
+ * @param {string} what - field name for error messages.
115
+ */
116
+ function assertExactKeys(record, allowed, what) {
117
+ const keys = Object.keys(record).sort()
118
+ const expected = [...allowed].sort()
119
+ if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
120
+ failLoud(`${what} must have exactly the fields: ${allowed.join(', ')}`)
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Validate a normalized position: 0-based UTF-16, integer in
126
+ * `[0, MAX_SAFE_INTEGER)`.
127
+ * @param {unknown} value - the raw position.
128
+ * @param {string} what - field name for error messages.
129
+ * @returns {NormalizedPosition}
130
+ */
131
+ function assertPosition(value, what) {
132
+ const record = assertRecord(value, what)
133
+ assertExactKeys(record, ['line', 'character'], what)
134
+ const line = record.line
135
+ const character = record.character
136
+ if (typeof line !== 'number' || !Number.isSafeInteger(line) || line < 0 || line >= Number.MAX_SAFE_INTEGER) {
137
+ failLoud(`${what}.line must be an integer in [0, MAX_SAFE_INTEGER)`)
138
+ }
139
+ if (typeof character !== 'number' || !Number.isSafeInteger(character) || character < 0 || character >= Number.MAX_SAFE_INTEGER) {
140
+ failLoud(`${what}.character must be an integer in [0, MAX_SAFE_INTEGER)`)
141
+ }
142
+ return { line, character }
143
+ }
144
+
145
+ /**
146
+ * Validate a normalized range; `end` must not precede `start`.
147
+ * @param {unknown} value - the raw range.
148
+ * @param {string} what - field name for error messages.
149
+ * @returns {NormalizedRange}
150
+ */
151
+ function assertRange(value, what) {
152
+ const record = assertRecord(value, what)
153
+ assertExactKeys(record, ['start', 'end'], what)
154
+ const start = assertPosition(record.start, `${what}.start`)
155
+ const end = assertPosition(record.end, `${what}.end`)
156
+ if (end.line < start.line || (end.line === start.line && end.character < start.character)) {
157
+ failLoud(`${what}.end must not precede start`)
158
+ }
159
+ return { start, end }
160
+ }
161
+
162
+ /**
163
+ * Validate one strict normalized diagnostic (immutable consumed-field
164
+ * schema; unknown fields are implementation bugs).
165
+ * @param {unknown} value - the raw diagnostic.
166
+ * @param {string} what - field name for error messages.
167
+ * @returns {NormalizedDiagnostic}
168
+ */
169
+ function assertDiagnostic(value, what) {
170
+ const record = assertRecord(value, what)
171
+ assertExactKeys(record, ['uri', 'range', 'severity', 'severityRank', 'code', 'source', 'message'], what)
172
+ const uri = assertString(record.uri, `${what}.uri`)
173
+ const range = assertRange(record.range, `${what}.range`)
174
+ const severity = assertString(record.severity, `${what}.severity`)
175
+ if (!(severity in SEVERITY_RANK)) failLoud(`${what}.severity must be a severity token`)
176
+ const severityRank = record.severityRank
177
+ if (typeof severityRank !== 'number' || !Number.isInteger(severityRank) || severityRank !== SEVERITY_RANK[severity]) {
178
+ failLoud(`${what}.severityRank must match the severity token`)
179
+ }
180
+ const code = assertString(record.code, `${what}.code`)
181
+ const source = assertString(record.source, `${what}.source`)
182
+ const message = assertString(record.message, `${what}.message`)
183
+ return {
184
+ uri,
185
+ range,
186
+ severity: /** @type {'error' | 'warning' | 'info' | 'hint' | 'unknown'} */ (severity),
187
+ severityRank: /** @type {0 | 1 | 2 | 3 | 4} */ (severityRank),
188
+ code,
189
+ source,
190
+ message,
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Validate one discriminated-union entry; unknown fields or shape mismatches
196
+ * fail loud.
197
+ * @param {unknown} value - the raw entry.
198
+ * @param {number} index - entry index for error messages.
199
+ * @returns {RenderTarget}
200
+ */
201
+ function assertEntry(value, index) {
202
+ const record = assertRecord(value, `entries[${index}]`)
203
+ const kind = record.kind
204
+ if (kind !== 'diagnostics' && kind !== 'clean' && kind !== 'unavailable') {
205
+ failLoud(`entries[${index}].kind must be 'diagnostics' | 'clean' | 'unavailable'`)
206
+ }
207
+ if (kind === 'diagnostics') {
208
+ assertExactKeys(record, ['renderPath', 'targetKey', 'canonicalUri', 'kind', 'diagnostics'], `entries[${index}]`)
209
+ } else if (kind === 'clean') {
210
+ assertExactKeys(record, ['renderPath', 'targetKey', 'canonicalUri', 'kind'], `entries[${index}]`)
211
+ } else {
212
+ assertExactKeys(record, ['renderPath', 'targetKey', 'canonicalUri', 'kind', 'reason'], `entries[${index}]`)
213
+ }
214
+ const renderPath = assertString(record.renderPath, `entries[${index}].renderPath`)
215
+ const targetKey = assertString(record.targetKey, `entries[${index}].targetKey`)
216
+ const canonicalUri = assertString(record.canonicalUri, `entries[${index}].canonicalUri`)
217
+ /** @type {NormalizedDiagnostic[] | undefined} */
218
+ let diagnostics
219
+ /** @type {UnavailableReason | undefined} */
220
+ let reason
221
+ if (kind === 'diagnostics') {
222
+ if (!Array.isArray(record.diagnostics)) failLoud(`entries[${index}].diagnostics must be an array`)
223
+ diagnostics = record.diagnostics.map((item, diagIndex) => assertDiagnostic(item, `entries[${index}].diagnostics[${diagIndex}]`))
224
+ }
225
+ if (kind === 'unavailable') {
226
+ const rawReason = record.reason
227
+ if (typeof rawReason !== 'string' || !UNAVAILABLE_REASONS.has(rawReason)) {
228
+ failLoud(`entries[${index}].reason must be one of the closed unavailable reasons`)
229
+ }
230
+ reason = /** @type {UnavailableReason} */ (rawReason)
231
+ }
232
+ return { renderPath, targetKey, canonicalUri, kind, diagnostics, reason }
233
+ }
234
+
235
+ /**
236
+ * Validate the renderer-facing config fields.
237
+ * @param {unknown} value - the validated plugin configuration.
238
+ * @returns {{ maxDiagnostics: number, maxResultChars: number, reportClean: boolean }}
239
+ */
240
+ function assertConfig(value) {
241
+ const record = assertRecord(value, 'config')
242
+ const maxDiagnostics = record.maxDiagnostics
243
+ const maxResultChars = record.maxResultChars
244
+ const reportClean = record.reportClean
245
+ if (typeof maxDiagnostics !== 'number' || !Number.isSafeInteger(maxDiagnostics) || maxDiagnostics < 1) {
246
+ failLoud('maxDiagnostics must be a positive safe integer')
247
+ }
248
+ if (typeof maxResultChars !== 'number' || !Number.isSafeInteger(maxResultChars) || maxResultChars < 1) {
249
+ failLoud('maxResultChars must be a positive safe integer')
250
+ }
251
+ if (typeof reportClean !== 'boolean') failLoud('reportClean must be a boolean')
252
+ return { maxDiagnostics, maxResultChars, reportClean }
253
+ }
254
+
255
+ /**
256
+ * Sanitize one string to single-line safe text with the shared rule: each
257
+ * CRLF sequence, lone CR, LF, U+2028, or U+2029 becomes one space; every
258
+ * other C0/C1 control character and every lone surrogate becomes U+FFFD.
259
+ * Never adds output lines.
260
+ * @param {string} value - the raw string.
261
+ * @returns {string}
262
+ */
263
+ function sanitizeToSingleLine(value) {
264
+ let out = ''
265
+ for (let index = 0; index < value.length; index += 1) {
266
+ const code = value.charCodeAt(index)
267
+ const char = value.charAt(index)
268
+ if (char === '\r') {
269
+ if (value.charAt(index + 1) === '\n') index += 1
270
+ out += ' '
271
+ continue
272
+ }
273
+ if (char === '\n' || char === '\u2028' || char === '\u2029') {
274
+ out += ' '
275
+ continue
276
+ }
277
+ if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) {
278
+ out += '\uFFFD'
279
+ continue
280
+ }
281
+ if (code >= 0xd800 && code <= 0xdbff) {
282
+ const next = value.charCodeAt(index + 1)
283
+ if (next >= 0xdc00 && next <= 0xdfff) {
284
+ out += char + value.charAt(index + 1)
285
+ index += 1
286
+ continue
287
+ }
288
+ out += '\uFFFD'
289
+ continue
290
+ }
291
+ if (code >= 0xdc00 && code <= 0xdfff) {
292
+ out += '\uFFFD'
293
+ continue
294
+ }
295
+ out += char
296
+ }
297
+ return out
298
+ }
299
+
300
+ /**
301
+ * Compare two strings by Unicode code points: numerically per code point,
302
+ * first differing code point decides; after a common prefix the shorter
303
+ * string sorts first. Never uses `localeCompare`, UTF-16 code-unit default
304
+ * order, or a binary fallback.
305
+ * @param {string} a - first string.
306
+ * @param {string} b - second string.
307
+ * @returns {number} negative when `a` sorts first, positive when `b` does.
308
+ */
309
+ function compareCodePoints(a, b) {
310
+ let aIndex = 0
311
+ let bIndex = 0
312
+ for (;;) {
313
+ const aCode = a.codePointAt(aIndex)
314
+ const bCode = b.codePointAt(bIndex)
315
+ if (aCode === undefined && bCode === undefined) return 0
316
+ if (aCode === undefined) return -1
317
+ if (bCode === undefined) return 1
318
+ if (aCode !== bCode) return aCode < bCode ? -1 : 1
319
+ aIndex += aCode > 0xffff ? 2 : 1
320
+ bIndex += bCode > 0xffff ? 2 : 1
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Compare per-file diagnostics by
326
+ * `(start.line, start.character, severityRank, source, code, message,
327
+ * end.line, end.character)` ascending; strings compare by code points.
328
+ * @param {NormalizedDiagnostic} a - first diagnostic.
329
+ * @param {NormalizedDiagnostic} b - second diagnostic.
330
+ * @returns {number}
331
+ */
332
+ export function compareDiagnostics(a, b) {
333
+ const byStartLine = a.range.start.line - b.range.start.line
334
+ if (byStartLine !== 0) return byStartLine
335
+ const byStartCharacter = a.range.start.character - b.range.start.character
336
+ if (byStartCharacter !== 0) return byStartCharacter
337
+ const byRank = a.severityRank - b.severityRank
338
+ if (byRank !== 0) return byRank
339
+ const bySource = compareCodePoints(a.source, b.source)
340
+ if (bySource !== 0) return bySource
341
+ const byCode = compareCodePoints(a.code, b.code)
342
+ if (byCode !== 0) return byCode
343
+ const byMessage = compareCodePoints(a.message, b.message)
344
+ if (byMessage !== 0) return byMessage
345
+ const byEndLine = a.range.end.line - b.range.end.line
346
+ if (byEndLine !== 0) return byEndLine
347
+ return a.range.end.character - b.range.end.character
348
+ }
349
+
350
+ /**
351
+ * Render one diagnostic line: 0-based UTF-16 coordinates converted to
352
+ * 1-based, severity token, JSON-quoted source/code, sanitized message.
353
+ * @param {NormalizedDiagnostic} diagnostic - the strict normalized diagnostic.
354
+ * @returns {string}
355
+ */
356
+ function renderDiagnosticLine(diagnostic) {
357
+ const startLine = diagnostic.range.start.line + 1
358
+ const startCharacter = diagnostic.range.start.character + 1
359
+ const endLine = diagnostic.range.end.line + 1
360
+ const endCharacter = diagnostic.range.end.character + 1
361
+ const source = JSON.stringify(sanitizeToSingleLine(diagnostic.source))
362
+ const code = JSON.stringify(sanitizeToSingleLine(diagnostic.code))
363
+ const message = sanitizeToSingleLine(diagnostic.message)
364
+ return `- ${diagnostic.severity} ${startLine}:${startCharacter}-${endLine}:${endCharacter} source=${source} code=${code} ${message}`
365
+ }
366
+
367
+ /**
368
+ * Apply the `maxResultChars` code-point cap to the already-complete canonical
369
+ * aggregate: at or below the cap return it verbatim with no marker; above the
370
+ * cap replace the truncated suffix with the fixed `…(truncated)` marker,
371
+ * never splitting a surrogate pair and never adding a newline. When the cap
372
+ * is smaller than the marker itself, return only the marker's first `cap`
373
+ * code points.
374
+ * @param {string} text - the full canonical aggregate.
375
+ * @param {number} cap - the positive code-point cap.
376
+ * @returns {string}
377
+ */
378
+ function applyCharCap(text, cap) {
379
+ const points = Array.from(text)
380
+ if (points.length <= cap) return text
381
+ const marker = Array.from(TRUNCATION_MARKER)
382
+ if (cap >= marker.length) {
383
+ return points.slice(0, cap - marker.length).join('') + TRUNCATION_MARKER
384
+ }
385
+ return marker.slice(0, cap).join('')
386
+ }
387
+
388
+ /**
389
+ * Sanitize a display path into a single-line render path.
390
+ * @param {unknown} displayPath - the raw display path.
391
+ * @returns {string} the single-line render path.
392
+ */
393
+ export function sanitizeDisplayPath(displayPath) {
394
+ return sanitizeToSingleLine(assertString(displayPath, 'displayPath'))
395
+ }
396
+
397
+ /**
398
+ * Compare eligible targets by the unique file ordering oracle
399
+ * `TOTAL_FILE_ORDER = (renderPath, String(targetKey), canonicalUri)`, each
400
+ * column by Unicode code points. The coordinator schedules diagnoses and the
401
+ * renderer orders final sections with this same function.
402
+ * @param {unknown} a - first eligible target.
403
+ * @param {unknown} b - second eligible target.
404
+ * @returns {number} negative when `a` sorts first, positive when `b` does.
405
+ */
406
+ export function compareEligibleTargets(a, b) {
407
+ const left = assertRecord(a, 'a')
408
+ const right = assertRecord(b, 'b')
409
+ const aPath = assertString(left.renderPath, 'a.renderPath')
410
+ const bPath = assertString(right.renderPath, 'b.renderPath')
411
+ const aKey = assertString(left.targetKey, 'a.targetKey')
412
+ const bKey = assertString(right.targetKey, 'b.targetKey')
413
+ const aUri = assertString(left.canonicalUri, 'a.canonicalUri')
414
+ const bUri = assertString(right.canonicalUri, 'b.canonicalUri')
415
+ const byPath = compareCodePoints(aPath, bPath)
416
+ if (byPath !== 0) return byPath
417
+ const byKey = compareCodePoints(String(aKey), String(bKey))
418
+ if (byKey !== 0) return byKey
419
+ return compareCodePoints(aUri, bUri)
420
+ }
421
+
422
+ /**
423
+ * Render the canonical aggregate notice for one tool execution.
424
+ *
425
+ * Processing order: strict-validate all entries (removing clean entries when
426
+ * `reportClean` is false), re-sort by the shared three-column comparator,
427
+ * sort each file's diagnostics by the unique per-file key, allocate the
428
+ * single global `maxDiagnostics` budget across files (clean/unavailable never
429
+ * consume it; a diagnostics file left with zero lines drops its whole
430
+ * section), build the complete canonical aggregate with at most one global
431
+ * advisory, and only then apply the `maxResultChars` code-point cap with the
432
+ * fixed truncation marker.
433
+ *
434
+ * @param {unknown} entries - validated eligible-target entries.
435
+ * @param {unknown} config - validated plugin configuration.
436
+ * @returns {{ text: string | null, diagnosticCount: number, fileCount: number }}
437
+ * `text` is the canonical aggregate (or null when nothing survives the
438
+ * count cap), `diagnosticCount` the cap-retained diagnostic line count and
439
+ * `fileCount` the char-cap-preceding retained section count.
440
+ */
441
+ export function renderDiagnostics(entries, config) {
442
+ if (!Array.isArray(entries)) failLoud('entries must be an array')
443
+ const { maxDiagnostics, maxResultChars, reportClean } = assertConfig(config)
444
+ let targets = entries.map((entry, index) => assertEntry(entry, index))
445
+ if (!reportClean) targets = targets.filter((target) => target.kind !== 'clean')
446
+ targets.sort(compareEligibleTargets)
447
+ const sections = []
448
+ let diagnosticCount = 0
449
+ let budget = maxDiagnostics
450
+ for (const target of targets) {
451
+ const renderPath = sanitizeDisplayPath(target.renderPath)
452
+ if (target.kind === 'diagnostics') {
453
+ const kept = (target.diagnostics ?? []).slice().sort(compareDiagnostics).slice(0, budget)
454
+ budget -= kept.length
455
+ if (kept.length === 0) continue
456
+ diagnosticCount += kept.length
457
+ sections.push(`File: ${renderPath}\n${kept.map(renderDiagnosticLine).join('\n')}`)
458
+ } else if (target.kind === 'clean') {
459
+ sections.push(`File: ${renderPath}\nStatus: clean`)
460
+ } else {
461
+ sections.push(`File: ${renderPath}\nStatus: diagnostics unavailable (${target.reason ?? 'diagnostics unavailable'})`)
462
+ }
463
+ }
464
+ if (sections.length === 0) return { text: null, diagnosticCount: 0, fileCount: 0 }
465
+ let text = `${TITLE}\n${sections.join('\n\n')}`
466
+ if (diagnosticCount > 0) text += `\n\n${ADVISORY}`
467
+ return { text: applyCharCap(text, maxResultChars), diagnosticCount, fileCount: sections.length }
468
+ }