@elyracode/lsp-typescript 0.9.18 → 0.9.20
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/CHANGELOG.md +7 -0
- package/extensions/index.ts +168 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.9.20] - 2026-07-19
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Auto-diagnostics and blast radius on edit: after an `edit`, the extension now also reports fresh TypeScript errors for the edited file (via LSP publishDiagnostics, skipped when the core edit tool already ran tsc) and lists call sites of the edited function/class in other files (via `textDocument/documentSymbol` + `references`), alongside the existing related-symbol summaries
|
|
7
|
+
|
|
8
|
+
## [0.9.19] - 2026-07-18
|
|
9
|
+
|
|
3
10
|
## [0.9.18] - 2026-07-18
|
|
4
11
|
|
|
5
12
|
### Added
|
package/extensions/index.ts
CHANGED
|
@@ -158,6 +158,82 @@ function summarizeHover(result: unknown): string | undefined {
|
|
|
158
158
|
return firstLine.length > 160 ? `${firstLine.slice(0, 160)}\u2026` : firstLine;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
// LSP SymbolKind values that can meaningfully own a blast radius:
|
|
162
|
+
// Class, Method, Constructor, Enum, Interface, Function, Variable, Constant, Struct.
|
|
163
|
+
const BLAST_SYMBOL_KINDS = new Set([5, 6, 9, 10, 11, 12, 13, 14, 23]);
|
|
164
|
+
|
|
165
|
+
interface FlatSymbol {
|
|
166
|
+
name: string;
|
|
167
|
+
kind: number;
|
|
168
|
+
startLine: number;
|
|
169
|
+
endLine: number;
|
|
170
|
+
depth: number;
|
|
171
|
+
/** 0-based position of the symbol's name (for references lookups). */
|
|
172
|
+
selLine: number;
|
|
173
|
+
selChar: number;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Flatten a documentSymbol response (hierarchical DocumentSymbol[] or flat SymbolInformation[]). */
|
|
177
|
+
function flattenSymbols(result: unknown): FlatSymbol[] {
|
|
178
|
+
if (!Array.isArray(result)) return [];
|
|
179
|
+
const out: FlatSymbol[] = [];
|
|
180
|
+
const walk = (nodes: unknown[], depth: number): void => {
|
|
181
|
+
for (const n of nodes) {
|
|
182
|
+
if (!n || typeof n !== "object") continue;
|
|
183
|
+
const sym = n as {
|
|
184
|
+
name?: unknown;
|
|
185
|
+
kind?: unknown;
|
|
186
|
+
range?: LspRange;
|
|
187
|
+
selectionRange?: LspRange;
|
|
188
|
+
location?: { range?: LspRange };
|
|
189
|
+
children?: unknown[];
|
|
190
|
+
};
|
|
191
|
+
const range = sym.range ?? sym.location?.range;
|
|
192
|
+
if (typeof sym.name === "string" && typeof sym.kind === "number" && range) {
|
|
193
|
+
const sel = sym.selectionRange ?? range;
|
|
194
|
+
out.push({
|
|
195
|
+
name: sym.name,
|
|
196
|
+
kind: sym.kind,
|
|
197
|
+
startLine: range.start.line,
|
|
198
|
+
endLine: range.end.line,
|
|
199
|
+
depth,
|
|
200
|
+
selLine: sel.start.line,
|
|
201
|
+
selChar: sel.start.character,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
if (Array.isArray(sym.children)) walk(sym.children, depth + 1);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
walk(result, 0);
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Find the innermost symbol (by line span, then nesting depth) containing a 0-based line. */
|
|
212
|
+
function findEnclosingSymbol(symbols: FlatSymbol[], line0: number): FlatSymbol | undefined {
|
|
213
|
+
let best: FlatSymbol | undefined;
|
|
214
|
+
for (const s of symbols) {
|
|
215
|
+
if (!BLAST_SYMBOL_KINDS.has(s.kind)) continue;
|
|
216
|
+
if (line0 < s.startLine || line0 > s.endLine) continue;
|
|
217
|
+
if (!best) {
|
|
218
|
+
best = s;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const bestSpan = best.endLine - best.startLine;
|
|
222
|
+
const span = s.endLine - s.startLine;
|
|
223
|
+
if (span < bestSpan || (span === bestSpan && s.depth > best.depth)) best = s;
|
|
224
|
+
}
|
|
225
|
+
return best;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** True when the edit tool's own result already contains a diagnostics section. */
|
|
229
|
+
function hasExistingDiagnostics(content: ReadonlyArray<unknown>): boolean {
|
|
230
|
+
return content.some((c) => {
|
|
231
|
+
if (!c || typeof c !== "object") return false;
|
|
232
|
+
const item = c as { type?: unknown; text?: unknown };
|
|
233
|
+
return item.type === "text" && typeof item.text === "string" && item.text.includes("\nDiagnostics (");
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
161
237
|
function findBinary(workingDir: string): string | undefined {
|
|
162
238
|
const local = join(workingDir, "node_modules", ".bin", "typescript-language-server");
|
|
163
239
|
if (existsSync(local)) return local;
|
|
@@ -187,6 +263,7 @@ export default function (elyra: ExtensionAPI): void {
|
|
|
187
263
|
let cwd = "";
|
|
188
264
|
const openedFiles = new Set<string>();
|
|
189
265
|
const diagnosticsByUri = new Map<string, LspDiagnostic[]>();
|
|
266
|
+
const diagnosticsWaiters = new Map<string, Array<() => void>>();
|
|
190
267
|
|
|
191
268
|
// ── JSON-RPC Client ─────────────────────────────────────────────────
|
|
192
269
|
|
|
@@ -275,6 +352,11 @@ export default function (elyra: ExtensionAPI): void {
|
|
|
275
352
|
if (msg.method === "textDocument/publishDiagnostics" && msg.params) {
|
|
276
353
|
const p = msg.params as { uri: string; diagnostics: LspDiagnostic[] };
|
|
277
354
|
diagnosticsByUri.set(p.uri, p.diagnostics);
|
|
355
|
+
const waiters = diagnosticsWaiters.get(p.uri);
|
|
356
|
+
if (waiters) {
|
|
357
|
+
diagnosticsWaiters.delete(p.uri);
|
|
358
|
+
for (const w of waiters) w();
|
|
359
|
+
}
|
|
278
360
|
}
|
|
279
361
|
}
|
|
280
362
|
}
|
|
@@ -316,6 +398,28 @@ export default function (elyra: ExtensionAPI): void {
|
|
|
316
398
|
openFile(filePath);
|
|
317
399
|
}
|
|
318
400
|
|
|
401
|
+
/** Resolve when the server publishes diagnostics for `uri`, or after `timeoutMs`. */
|
|
402
|
+
function waitForDiagnostics(uri: string, timeoutMs: number): Promise<void> {
|
|
403
|
+
return new Promise<void>((resolveWait) => {
|
|
404
|
+
const waiter = () => {
|
|
405
|
+
clearTimeout(timer);
|
|
406
|
+
resolveWait();
|
|
407
|
+
};
|
|
408
|
+
const timer = setTimeout(() => {
|
|
409
|
+
const arr = diagnosticsWaiters.get(uri);
|
|
410
|
+
if (arr) {
|
|
411
|
+
const remaining = arr.filter((w) => w !== waiter);
|
|
412
|
+
if (remaining.length > 0) diagnosticsWaiters.set(uri, remaining);
|
|
413
|
+
else diagnosticsWaiters.delete(uri);
|
|
414
|
+
}
|
|
415
|
+
resolveWait();
|
|
416
|
+
}, timeoutMs);
|
|
417
|
+
const list = diagnosticsWaiters.get(uri) ?? [];
|
|
418
|
+
list.push(waiter);
|
|
419
|
+
diagnosticsWaiters.set(uri, list);
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
319
423
|
function formatLocations(result: unknown, workingDir: string): string {
|
|
320
424
|
if (!result) return "No results found.";
|
|
321
425
|
|
|
@@ -535,7 +639,7 @@ export default function (elyra: ExtensionAPI): void {
|
|
|
535
639
|
},
|
|
536
640
|
});
|
|
537
641
|
|
|
538
|
-
// ── Hook: auto-context
|
|
642
|
+
// ── Hook: auto-context, auto-diagnostics, and blast radius on edit ──
|
|
539
643
|
elyra.on("tool_result", async (event) => {
|
|
540
644
|
if (!initialized || !isEditToolResult(event) || event.isError) return undefined;
|
|
541
645
|
|
|
@@ -549,14 +653,14 @@ export default function (elyra: ExtensionAPI): void {
|
|
|
549
653
|
const fileText = readFileSync(absPath, "utf-8");
|
|
550
654
|
const details = event.details as EditToolDetails | undefined;
|
|
551
655
|
const fromLine = details?.firstChangedLine ?? 1;
|
|
656
|
+
const uri = fileUri(absPath);
|
|
657
|
+
const sections: string[] = [];
|
|
552
658
|
|
|
659
|
+
// 1. Related symbols: hover summaries for referenced types.
|
|
553
660
|
const candidates = new Set<string>();
|
|
554
661
|
for (const edit of input.edits) {
|
|
555
662
|
for (const s of extractCandidateSymbols(edit.newText, TS_SYMBOL_STOPLIST, 4)) candidates.add(s);
|
|
556
663
|
}
|
|
557
|
-
if (candidates.size === 0) return undefined;
|
|
558
|
-
|
|
559
|
-
const uri = fileUri(absPath);
|
|
560
664
|
const lookups = [...candidates].slice(0, 5).map(async (symbol) => {
|
|
561
665
|
const pos = findSymbolPosition(fileText, symbol, fromLine);
|
|
562
666
|
if (!pos) return undefined;
|
|
@@ -572,10 +676,67 @@ export default function (elyra: ExtensionAPI): void {
|
|
|
572
676
|
}
|
|
573
677
|
});
|
|
574
678
|
|
|
575
|
-
|
|
576
|
-
|
|
679
|
+
// 2. Blast radius: references to the edited symbol outside this file.
|
|
680
|
+
const blastRadius = (async () => {
|
|
681
|
+
try {
|
|
682
|
+
const symbolResult = await sendRequest("textDocument/documentSymbol", {
|
|
683
|
+
textDocument: { uri },
|
|
684
|
+
});
|
|
685
|
+
const enclosing = findEnclosingSymbol(flattenSymbols(symbolResult), fromLine - 1);
|
|
686
|
+
if (!enclosing) return undefined;
|
|
687
|
+
|
|
688
|
+
const refsResult = await sendRequest("textDocument/references", {
|
|
689
|
+
textDocument: { uri },
|
|
690
|
+
position: { line: enclosing.selLine, character: enclosing.selChar },
|
|
691
|
+
context: { includeDeclaration: false },
|
|
692
|
+
});
|
|
693
|
+
if (!Array.isArray(refsResult)) return undefined;
|
|
694
|
+
|
|
695
|
+
const external = (refsResult as LspLocation[]).filter((loc) => uriToPath(loc.uri) !== absPath);
|
|
696
|
+
if (external.length === 0) return undefined;
|
|
697
|
+
|
|
698
|
+
const prefix = `${cwd}/`;
|
|
699
|
+
const shown = external.slice(0, 8).map((loc) => {
|
|
700
|
+
const p = uriToPath(loc.uri);
|
|
701
|
+
const rel = p.startsWith(prefix) ? p.slice(prefix.length) : p;
|
|
702
|
+
return `${rel}:${loc.range.start.line + 1}`;
|
|
703
|
+
});
|
|
704
|
+
const more = external.length > shown.length ? ` (+${external.length - shown.length} more)` : "";
|
|
705
|
+
return `${enclosing.name} is referenced in ${external.length} place${external.length === 1 ? "" : "s"} outside this file:\n${shown.map((s) => `- ${s}`).join("\n")}${more}`;
|
|
706
|
+
} catch {
|
|
707
|
+
return undefined;
|
|
708
|
+
}
|
|
709
|
+
})();
|
|
710
|
+
|
|
711
|
+
// 3. Fresh diagnostics for the edited file (skip if the edit tool already ran tsc).
|
|
712
|
+
const freshDiagnostics = (async () => {
|
|
713
|
+
if (hasExistingDiagnostics(event.content)) return undefined;
|
|
714
|
+
try {
|
|
715
|
+
await waitForDiagnostics(uri, 1500);
|
|
716
|
+
const errors = (diagnosticsByUri.get(uri) ?? []).filter((d) => (d.severity ?? 1) === 1);
|
|
717
|
+
if (errors.length === 0) return undefined;
|
|
718
|
+
const shown = errors.slice(0, 8).map((d) => {
|
|
719
|
+
const code = d.code ? ` [${d.code}]` : "";
|
|
720
|
+
return `- ${input.path}:${d.range.start.line + 1}:${d.range.start.character + 1}${code}: ${d.message.split("\n")[0]}`;
|
|
721
|
+
});
|
|
722
|
+
const more = errors.length > shown.length ? ` (+${errors.length - shown.length} more)` : "";
|
|
723
|
+
return `${errors.length} error${errors.length === 1 ? "" : "s"} in this file after the edit:\n${shown.join("\n")}${more}`;
|
|
724
|
+
} catch {
|
|
725
|
+
return undefined;
|
|
726
|
+
}
|
|
727
|
+
})();
|
|
728
|
+
|
|
729
|
+
const [hoverLines, blast, diag] = await Promise.all([Promise.all(lookups), blastRadius, freshDiagnostics]);
|
|
730
|
+
|
|
731
|
+
const related = hoverLines.filter((l): l is string => !!l);
|
|
732
|
+
if (diag) sections.push(`[Diagnostics \u2014 auto-checked via LSP]\n${diag}`);
|
|
733
|
+
if (blast) sections.push(`[Blast radius \u2014 auto-resolved via LSP]\n${blast}`);
|
|
734
|
+
if (related.length > 0) {
|
|
735
|
+
sections.push(`[Related symbols \u2014 auto-resolved via LSP, no extra tool call needed]\n${related.join("\n")}`);
|
|
736
|
+
}
|
|
737
|
+
if (sections.length === 0) return undefined;
|
|
577
738
|
|
|
578
|
-
const contextBlock = `\n\n
|
|
739
|
+
const contextBlock = `\n\n${sections.join("\n\n")}`;
|
|
579
740
|
return { content: [...event.content, { type: "text" as const, text: contextBlock }] };
|
|
580
741
|
} catch {
|
|
581
742
|
return undefined;
|