@mrclrchtr/supi-lsp 1.3.1 → 1.5.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.
Files changed (64) hide show
  1. package/README.md +58 -39
  2. package/node_modules/@mrclrchtr/supi-core/README.md +52 -41
  3. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  4. package/node_modules/@mrclrchtr/supi-core/src/api.ts +15 -13
  5. package/node_modules/@mrclrchtr/supi-core/src/{config-settings.ts → config/config-settings.ts} +2 -2
  6. package/node_modules/@mrclrchtr/supi-core/src/{context-provider-registry.ts → context/context-provider-registry.ts} +1 -1
  7. package/node_modules/@mrclrchtr/supi-core/src/extension.ts +1 -1
  8. package/node_modules/@mrclrchtr/supi-core/src/index.ts +15 -13
  9. package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +40 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +42 -10
  11. package/node_modules/@mrclrchtr/supi-core/src/{settings-registry.ts → settings/settings-registry.ts} +1 -1
  12. package/package.json +3 -2
  13. package/src/api.ts +16 -3
  14. package/src/client/client-refresh.ts +1 -1
  15. package/src/client/client.ts +27 -3
  16. package/src/client/transport.ts +61 -5
  17. package/src/config/tsconfig-scope.ts +244 -0
  18. package/src/{types.ts → config/types.ts} +4 -2
  19. package/src/coordinates.ts +11 -0
  20. package/src/diagnostics/diagnostic-augmentation.ts +5 -5
  21. package/src/diagnostics/diagnostic-context.ts +115 -0
  22. package/src/diagnostics/diagnostic-display.ts +1 -1
  23. package/src/diagnostics/diagnostic-summary.ts +3 -2
  24. package/src/diagnostics/diagnostics.ts +1 -1
  25. package/src/diagnostics/stale-diagnostics.ts +1 -1
  26. package/src/diagnostics/suppression-diagnostics.ts +1 -1
  27. package/src/{workspace-sentinels.ts → diagnostics/workspace-sentinels.ts} +2 -2
  28. package/src/format.ts +2 -23
  29. package/src/index.ts +18 -5
  30. package/src/lsp.ts +72 -120
  31. package/src/manager/manager-diagnostics.ts +1 -1
  32. package/src/manager/manager-helpers.ts +4 -2
  33. package/src/manager/manager-project-info.ts +10 -21
  34. package/src/manager/manager-workspace-recovery.ts +1 -1
  35. package/src/manager/manager-workspace-symbol.ts +158 -6
  36. package/src/manager/manager.ts +202 -43
  37. package/src/{lsp-state.ts → session/lsp-state.ts} +22 -11
  38. package/src/{scanner.ts → session/scanner.ts} +3 -3
  39. package/src/{service-registry.ts → session/service-registry.ts} +109 -33
  40. package/src/{settings-registration.ts → session/settings-registration.ts} +1 -1
  41. package/src/session/tree-persist.ts +75 -0
  42. package/src/summary.ts +1 -1
  43. package/src/tool/guidance.ts +78 -0
  44. package/src/tool/names.ts +19 -0
  45. package/src/{overrides.ts → tool/overrides.ts} +55 -24
  46. package/src/tool/register-tools.ts +71 -0
  47. package/src/tool/service-actions.ts +258 -0
  48. package/src/tool/tool-specs.ts +248 -0
  49. package/src/{ui.ts → ui/ui.ts} +4 -4
  50. package/src/utils.ts +5 -23
  51. package/src/guidance.ts +0 -163
  52. package/src/search-fallback.ts +0 -98
  53. package/src/tool-actions.ts +0 -430
  54. package/src/tree-persist.ts +0 -48
  55. package/src/tsconfig-scope.ts +0 -156
  56. /package/node_modules/@mrclrchtr/supi-core/src/{config.ts → config/config.ts} +0 -0
  57. /package/node_modules/@mrclrchtr/supi-core/src/{context-messages.ts → context/context-messages.ts} +0 -0
  58. /package/node_modules/@mrclrchtr/supi-core/src/{context-tag.ts → context/context-tag.ts} +0 -0
  59. /package/node_modules/@mrclrchtr/supi-core/src/{settings-command.ts → settings/settings-command.ts} +0 -0
  60. /package/node_modules/@mrclrchtr/supi-core/src/{settings-ui.ts → settings/settings-ui.ts} +0 -0
  61. /package/src/{capabilities.ts → config/capabilities.ts} +0 -0
  62. /package/src/{config.ts → config/config.ts} +0 -0
  63. /package/src/{defaults.json → config/defaults.json} +0 -0
  64. /package/src/{renderer.ts → ui/renderer.ts} +0 -0
@@ -4,7 +4,8 @@
4
4
  // biome-ignore lint/nursery/noExcessiveLinesPerFile: LspClient remains a cohesive stateful wrapper; refresh logic is already split out.
5
5
  import { type ChildProcess, spawn } from "node:child_process";
6
6
  import { existsSync } from "node:fs";
7
- import { CLIENT_CAPABILITIES } from "../capabilities.ts";
7
+ import * as path from "node:path";
8
+ import { CLIENT_CAPABILITIES } from "../config/capabilities.ts";
8
9
  import type {
9
10
  CodeAction,
10
11
  CodeActionContext,
@@ -27,9 +28,9 @@ import type {
27
28
  VersionedTextDocumentIdentifier,
28
29
  WorkspaceEdit,
29
30
  WorkspaceSymbol,
30
- } from "../types.ts";
31
+ } from "../config/types.ts";
31
32
  import { detectLanguageId, fileToUri, uriToFile } from "../utils.ts";
32
- import { JsonRpcClient } from "./transport.ts";
33
+ import { JsonRpcClient, JsonRpcRequestError } from "./transport.ts";
33
34
 
34
35
  const SHUTDOWN_TIMEOUT_MS = 5_000;
35
36
  const DIAGNOSTIC_WAIT_MS = 3_000;
@@ -119,6 +120,7 @@ export class LspClient {
119
120
  this.handlePublishDiagnostics(params as PublishDiagnosticsParams);
120
121
  }
121
122
  });
123
+ this.rpc.onRequest((method, params) => this.handleServerRequest(method, params));
122
124
 
123
125
  // Handle crashes
124
126
  this.process.on("exit", (_code) => {
@@ -469,6 +471,28 @@ export class LspClient {
469
471
  }
470
472
  }
471
473
 
474
+ private handleServerRequest(method: string, params: unknown): unknown {
475
+ switch (method) {
476
+ case "workspace/configuration":
477
+ return this.buildWorkspaceConfigurationResult(params);
478
+ case "workspace/workspaceFolders":
479
+ return [{ uri: fileToUri(this.root), name: path.basename(this.root) || this.root }];
480
+ case "client/registerCapability":
481
+ case "client/unregisterCapability":
482
+ case "window/workDoneProgress/create":
483
+ return null;
484
+ default:
485
+ throw new JsonRpcRequestError(-32601, `Method not found: ${method}`);
486
+ }
487
+ }
488
+
489
+ private buildWorkspaceConfigurationResult(params: unknown): unknown[] {
490
+ if (!params || typeof params !== "object") return [];
491
+ const items = (params as { items?: unknown }).items;
492
+ if (!Array.isArray(items)) return [];
493
+ return items.map(() => null);
494
+ }
495
+
472
496
  private handlePublishDiagnostics(params: PublishDiagnosticsParams): void {
473
497
  // If the publication includes a version and we have a newer synced
474
498
  // version for this open document, ignore the stale publication.
@@ -2,11 +2,12 @@
2
2
 
3
3
  import type { Readable, Writable } from "node:stream";
4
4
  import type {
5
+ JsonRpcId,
5
6
  JsonRpcMessage,
6
7
  JsonRpcNotification,
7
8
  JsonRpcRequest,
8
9
  JsonRpcResponse,
9
- } from "../types.ts";
10
+ } from "../config/types.ts";
10
11
 
11
12
  const CONTENT_LENGTH = "Content-Length: ";
12
13
  const HEADER_DELIMITER = "\r\n\r\n";
@@ -15,6 +16,7 @@ const DEFAULT_TIMEOUT_MS = 30_000;
15
16
  // ── Types ─────────────────────────────────────────────────────────────
16
17
 
17
18
  export type NotificationHandler = (method: string, params: unknown) => void;
19
+ export type RequestHandler = (method: string, params: unknown) => Promise<unknown> | unknown;
18
20
 
19
21
  interface PendingRequest {
20
22
  resolve: (result: unknown) => void;
@@ -22,13 +24,25 @@ interface PendingRequest {
22
24
  timer: ReturnType<typeof setTimeout>;
23
25
  }
24
26
 
27
+ export class JsonRpcRequestError extends Error {
28
+ constructor(
29
+ readonly code: number,
30
+ message: string,
31
+ readonly data?: unknown,
32
+ ) {
33
+ super(message);
34
+ this.name = "JsonRpcRequestError";
35
+ }
36
+ }
37
+
25
38
  // ── JsonRpcClient ─────────────────────────────────────────────────────
26
39
 
27
40
  export class JsonRpcClient {
28
41
  private nextId = 1;
29
42
  private buffer = Buffer.alloc(0);
30
- private pending = new Map<number, PendingRequest>();
43
+ private pending = new Map<JsonRpcId, PendingRequest>();
31
44
  private notificationHandler: NotificationHandler | null = null;
45
+ private requestHandler: RequestHandler | null = null;
32
46
  private closed = false;
33
47
  private readonly timeoutMs: number;
34
48
 
@@ -48,6 +62,11 @@ export class JsonRpcClient {
48
62
  this.notificationHandler = handler;
49
63
  }
50
64
 
65
+ /** Register a handler for server-initiated requests. */
66
+ onRequest(handler: RequestHandler): void {
67
+ this.requestHandler = handler;
68
+ }
69
+
51
70
  /** Send a request and wait for the correlated response, optionally overriding the timeout. */
52
71
  sendRequest(
53
72
  method: string,
@@ -147,9 +166,11 @@ export class JsonRpcClient {
147
166
  // Response (has id, has result or error)
148
167
  if ("id" in msg && msg.id != null && ("result" in msg || "error" in msg)) {
149
168
  const response = msg as JsonRpcResponse;
150
- const pending = this.pending.get(response.id);
169
+ const id = response.id;
170
+ if (id === null) return;
171
+ const pending = this.pending.get(id);
151
172
  if (pending) {
152
- this.pending.delete(response.id);
173
+ this.pending.delete(id);
153
174
  clearTimeout(pending.timer);
154
175
  if (response.error) {
155
176
  pending.reject(new Error(`LSP error ${response.error.code}: ${response.error.message}`));
@@ -164,9 +185,44 @@ export class JsonRpcClient {
164
185
  if ("method" in msg && !("id" in msg)) {
165
186
  const notification = msg as JsonRpcNotification;
166
187
  this.notificationHandler?.(notification.method, notification.params);
188
+ return;
167
189
  }
168
190
 
169
- // Request from server (has id + method) — we don't handle server→client requests yet
191
+ // Request from server (has id + method)
192
+ if ("method" in msg && "id" in msg && msg.id != null) {
193
+ void this.handleInboundRequest(msg as JsonRpcRequest);
194
+ }
195
+ }
196
+
197
+ private async handleInboundRequest(request: JsonRpcRequest): Promise<void> {
198
+ if (this.closed) return;
199
+
200
+ try {
201
+ if (!this.requestHandler) {
202
+ throw new JsonRpcRequestError(-32601, `Method not found: ${request.method}`);
203
+ }
204
+
205
+ const result = await this.requestHandler(request.method, request.params);
206
+ this.writeMessage({
207
+ jsonrpc: "2.0",
208
+ id: request.id,
209
+ result: result ?? null,
210
+ } satisfies JsonRpcResponse);
211
+ } catch (error) {
212
+ const failure =
213
+ error instanceof JsonRpcRequestError
214
+ ? error
215
+ : new JsonRpcRequestError(-32603, error instanceof Error ? error.message : String(error));
216
+ this.writeMessage({
217
+ jsonrpc: "2.0",
218
+ id: request.id,
219
+ error: {
220
+ code: failure.code,
221
+ message: failure.message,
222
+ ...(failure.data !== undefined ? { data: failure.data } : {}),
223
+ },
224
+ } satisfies JsonRpcResponse);
225
+ }
170
226
  }
171
227
 
172
228
  private onClose(): void {
@@ -0,0 +1,244 @@
1
+ // tsconfig-aware file scope detection.
2
+ //
3
+ // Determines whether a file is within the compilation scope of its nearest
4
+ // tsconfig.json or jsconfig.json using the TypeScript compiler's own config
5
+ // parsing APIs. Used by the diagnostic filter to suppress LSP errors on files
6
+ // that TypeScript itself would not include in the project.
7
+
8
+ import * as path from "node:path";
9
+ import ts from "typescript";
10
+
11
+ interface ParsedProjectConfig {
12
+ configPath: string;
13
+ configDir: string;
14
+ fileNames: Set<string>;
15
+ explicitFiles: Set<string> | null;
16
+ includeFilePattern: RegExp | null;
17
+ excludePattern: RegExp | null;
18
+ supportedExtensions: Set<string>;
19
+ usesDefaultInclude: boolean;
20
+ }
21
+
22
+ const nearestConfigCache = new Map<string, string | null>();
23
+ const parsedConfigCache = new Map<string, ParsedProjectConfig | null>();
24
+
25
+ const tsInternal = ts as typeof ts & {
26
+ getFileMatcherPatterns?: (
27
+ configDir: string,
28
+ excludes: readonly string[] | undefined,
29
+ includes: readonly string[] | undefined,
30
+ useCaseSensitiveFileNames: boolean,
31
+ currentDirectory: string,
32
+ ) => {
33
+ includeFilePattern?: string;
34
+ excludePattern?: string;
35
+ };
36
+ getSupportedExtensions?: (
37
+ options: ts.CompilerOptions,
38
+ extraFileExtensions?: unknown,
39
+ ) => ReadonlyArray<ReadonlyArray<string>>;
40
+ };
41
+
42
+ /**
43
+ * Check whether a file is excluded by its nearest tsconfig.json or jsconfig.json.
44
+ *
45
+ * @param filePath - Project-relative file path (e.g., "packages/foo/__tests__/x.test.ts")
46
+ * @param cwd - Absolute project root directory
47
+ * @returns `true` if the file is excluded from compilation scope
48
+ */
49
+ export function isFileExcludedByTsconfig(filePath: string, cwd: string): boolean {
50
+ const absolutePath = path.resolve(cwd, filePath);
51
+ const configPath = findNearestProjectConfig(path.dirname(absolutePath), cwd);
52
+ if (!configPath) return false;
53
+
54
+ const parsed = parseProjectConfig(configPath);
55
+ if (!parsed) return false;
56
+
57
+ return !isFileInProjectScope(parsed, absolutePath);
58
+ }
59
+
60
+ /**
61
+ * Find the nearest tsconfig.json or jsconfig.json walking upward from `startDir`,
62
+ * stopping at `rootDir`.
63
+ */
64
+ function findNearestProjectConfig(startDir: string, rootDir: string): string | null {
65
+ let dir = path.resolve(startDir);
66
+ const resolvedRoot = path.resolve(rootDir);
67
+
68
+ while (true) {
69
+ const cacheKey = `${normalizePath(dir)}::${normalizePath(resolvedRoot)}`;
70
+ const cached = nearestConfigCache.get(cacheKey);
71
+ if (cached !== undefined) return cached;
72
+
73
+ const configPath = getLocalProjectConfig(dir);
74
+ if (configPath) {
75
+ const resolvedConfigPath = path.resolve(configPath);
76
+ nearestConfigCache.set(cacheKey, resolvedConfigPath);
77
+ return resolvedConfigPath;
78
+ }
79
+
80
+ if (path.relative(resolvedRoot, dir).startsWith("..") || dir === resolvedRoot) {
81
+ nearestConfigCache.set(cacheKey, null);
82
+ return null;
83
+ }
84
+
85
+ const parent = path.dirname(dir);
86
+ if (parent === dir) {
87
+ nearestConfigCache.set(cacheKey, null);
88
+ return null;
89
+ }
90
+ dir = parent;
91
+ }
92
+ }
93
+
94
+ function getLocalProjectConfig(directory: string): string | null {
95
+ const tsconfigPath = path.join(directory, "tsconfig.json");
96
+ if (ts.sys.fileExists(tsconfigPath)) return tsconfigPath;
97
+
98
+ const jsconfigPath = path.join(directory, "jsconfig.json");
99
+ if (ts.sys.fileExists(jsconfigPath)) return jsconfigPath;
100
+
101
+ return null;
102
+ }
103
+
104
+ function parseProjectConfig(configPath: string): ParsedProjectConfig | null {
105
+ const normalizedConfigPath = normalizePath(configPath);
106
+ const cached = parsedConfigCache.get(normalizedConfigPath);
107
+ if (cached !== undefined) return cached;
108
+
109
+ const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, createParseConfigHost());
110
+ if (!parsed) {
111
+ parsedConfigCache.set(normalizedConfigPath, null);
112
+ return null;
113
+ }
114
+
115
+ const configDir = path.dirname(path.resolve(configPath));
116
+ const explicitFiles = extractExplicitFiles(parsed.raw.files, configDir);
117
+ const usesDefaultInclude = explicitFiles === null && !Array.isArray(parsed.raw.include);
118
+ const { includeFilePattern, excludePattern } = createFileMatchers(
119
+ configDir,
120
+ parsed.raw.include,
121
+ parsed.raw.exclude,
122
+ usesDefaultInclude,
123
+ );
124
+ const supportedExtensions = new Set(getSupportedExtensions(parsed.options));
125
+ if (parsed.options.resolveJsonModule) supportedExtensions.add(".json");
126
+
127
+ const result = {
128
+ configPath: path.resolve(configPath),
129
+ configDir,
130
+ fileNames: new Set(parsed.fileNames.map(normalizePath)),
131
+ explicitFiles,
132
+ includeFilePattern,
133
+ excludePattern,
134
+ supportedExtensions,
135
+ usesDefaultInclude,
136
+ } satisfies ParsedProjectConfig;
137
+ parsedConfigCache.set(normalizedConfigPath, result);
138
+ return result;
139
+ }
140
+
141
+ function extractExplicitFiles(rawFiles: unknown, configDir: string): Set<string> | null {
142
+ if (!Array.isArray(rawFiles)) return null;
143
+ return new Set(
144
+ rawFiles
145
+ .filter((entry): entry is string => typeof entry === "string")
146
+ .map((entry) => normalizePath(path.resolve(configDir, entry))),
147
+ );
148
+ }
149
+
150
+ function createFileMatchers(
151
+ configDir: string,
152
+ rawInclude: unknown,
153
+ rawExclude: unknown,
154
+ useDefaultInclude: boolean,
155
+ ): {
156
+ includeFilePattern: RegExp | null;
157
+ excludePattern: RegExp | null;
158
+ } {
159
+ const includeSpecs = Array.isArray(rawInclude)
160
+ ? rawInclude.filter((entry): entry is string => typeof entry === "string")
161
+ : undefined;
162
+ const excludeSpecs = Array.isArray(rawExclude)
163
+ ? rawExclude.filter((entry): entry is string => typeof entry === "string")
164
+ : undefined;
165
+ const matcherPatterns = getFileMatcherPatterns(
166
+ configDir,
167
+ excludeSpecs,
168
+ useDefaultInclude ? ["**/*"] : includeSpecs,
169
+ );
170
+
171
+ return {
172
+ includeFilePattern: matcherPatterns.includeFilePattern
173
+ ? new RegExp(matcherPatterns.includeFilePattern)
174
+ : null,
175
+ excludePattern: matcherPatterns.excludePattern
176
+ ? new RegExp(matcherPatterns.excludePattern)
177
+ : null,
178
+ };
179
+ }
180
+
181
+ function isFileInProjectScope(parsed: ParsedProjectConfig, absolutePath: string): boolean {
182
+ const normalizedPath = normalizePath(absolutePath);
183
+ if (parsed.fileNames.has(normalizedPath)) return true;
184
+
185
+ const extension = path.extname(absolutePath).toLowerCase();
186
+ if (!parsed.supportedExtensions.has(extension)) return false;
187
+
188
+ if (parsed.explicitFiles) return parsed.explicitFiles.has(normalizedPath);
189
+ if (!isWithinOrEqual(parsed.configDir, absolutePath)) return false;
190
+ if (parsed.excludePattern?.test(normalizedPath)) return false;
191
+ if (parsed.usesDefaultInclude) return true;
192
+ return parsed.includeFilePattern ? parsed.includeFilePattern.test(normalizedPath) : false;
193
+ }
194
+
195
+ function getSupportedExtensions(options: ts.CompilerOptions): string[] {
196
+ return tsInternal.getSupportedExtensions
197
+ ? [...tsInternal.getSupportedExtensions(options, undefined).flat()]
198
+ : [".ts", ".tsx", ".d.ts", ".cts", ".d.cts", ".mts", ".d.mts"];
199
+ }
200
+
201
+ function getFileMatcherPatterns(
202
+ configDir: string,
203
+ excludeSpecs: readonly string[] | undefined,
204
+ includeSpecs: readonly string[] | undefined,
205
+ ): { includeFilePattern?: string; excludePattern?: string } {
206
+ return tsInternal.getFileMatcherPatterns
207
+ ? tsInternal.getFileMatcherPatterns(
208
+ configDir,
209
+ excludeSpecs,
210
+ includeSpecs,
211
+ ts.sys.useCaseSensitiveFileNames,
212
+ path.parse(configDir).root,
213
+ )
214
+ : {};
215
+ }
216
+
217
+ function createParseConfigHost(): ts.ParseConfigFileHost {
218
+ return {
219
+ ...ts.sys,
220
+ onUnRecoverableConfigFileDiagnostic: () => {
221
+ // Treat invalid configs as unsupported rather than surfacing a secondary
222
+ // filter failure to the agent.
223
+ },
224
+ };
225
+ }
226
+
227
+ function isWithinOrEqual(root: string, target: string): boolean {
228
+ const relative = path.relative(root, target);
229
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== "..");
230
+ }
231
+
232
+ function normalizePath(target: string): string {
233
+ const resolved = path.resolve(target).replaceAll("\\", "/");
234
+ return ts.sys.useCaseSensitiveFileNames ? resolved : resolved.toLowerCase();
235
+ }
236
+
237
+ /**
238
+ * Clear cached nearest-config lookups and parsed project config state.
239
+ * Useful for testing and after workspace file changes.
240
+ */
241
+ export function clearTsconfigCache(): void {
242
+ nearestConfigCache.clear();
243
+ parsedConfigCache.clear();
244
+ }
@@ -348,16 +348,18 @@ export interface TextDocumentPositionParams {
348
348
 
349
349
  // ── JSON-RPC ──────────────────────────────────────────────────────────
350
350
 
351
+ export type JsonRpcId = number | string;
352
+
351
353
  export interface JsonRpcRequest {
352
354
  jsonrpc: "2.0";
353
- id: number;
355
+ id: JsonRpcId;
354
356
  method: string;
355
357
  params?: unknown;
356
358
  }
357
359
 
358
360
  export interface JsonRpcResponse {
359
361
  jsonrpc: "2.0";
360
- id: number;
362
+ id: JsonRpcId | null;
361
363
  result?: unknown;
362
364
  error?: { code: number; message: string; data?: unknown };
363
365
  }
@@ -0,0 +1,11 @@
1
+ import type { Position } from "./config/types.ts";
2
+
3
+ /** Convert public 1-based coordinates into a 0-based LSP position. */
4
+ export function toLspPosition(line: number, character: number): Position {
5
+ return { line: line - 1, character: character - 1 };
6
+ }
7
+
8
+ /** Convert a 0-based LSP position into 1-based display coordinates. */
9
+ export function toOneBasedPosition(position: Position): { line: number; character: number } {
10
+ return { line: position.line + 1, character: position.character + 1 };
11
+ }
@@ -1,6 +1,6 @@
1
- import * as path from "node:path";
1
+ import type { Diagnostic, Hover, MarkedString, MarkupContent } from "../config/types.ts";
2
2
  import type { LspManager } from "../manager/manager.ts";
3
- import type { Diagnostic, Hover, MarkedString, MarkupContent } from "../types.ts";
3
+ import { resolveSessionPath } from "../utils.ts";
4
4
 
5
5
  const AUGMENT_TIMEOUT_MS = 500;
6
6
 
@@ -12,13 +12,13 @@ export async function augmentDiagnostics(
12
12
  filePath: string,
13
13
  diags: Diagnostic[],
14
14
  manager: LspManager,
15
- _cwd: string,
15
+ cwd: string,
16
16
  ): Promise<string | null> {
17
17
  const firstError = diags.find((d) => d.severity === 1);
18
18
  if (!firstError) return null;
19
19
 
20
- const resolvedPath = path.resolve(filePath);
21
- const client = await manager.getClientForFile(filePath);
20
+ const resolvedPath = resolveSessionPath(cwd, filePath);
21
+ const client = await manager.getClientForFile(resolvedPath);
22
22
  if (!client) return null;
23
23
 
24
24
  const pos = firstError.range.start;
@@ -0,0 +1,115 @@
1
+ // Diagnostic context formatting for the LSP extension.
2
+ // Extracted from guidance.ts to keep prompt surfaces separate from formatting logic.
3
+
4
+ import type { Diagnostic } from "../config/types.ts";
5
+ import type { OutstandingDiagnosticSummaryEntry } from "../manager/manager-types.ts";
6
+ import { splitSuppressionDiagnostics } from "./suppression-diagnostics.ts";
7
+
8
+ export const MAX_DETAILED_DIAGNOSTICS = 5;
9
+ const MAX_DETAIL_LINES_PER_FILE = 3;
10
+
11
+ interface DetailedDiagnostics {
12
+ file: string;
13
+ diagnostics: Diagnostic[];
14
+ }
15
+
16
+ export function formatDiagnosticsContext(
17
+ diagnostics: OutstandingDiagnosticSummaryEntry[],
18
+ maxFiles: number = 3,
19
+ detailed?: DetailedDiagnostics[],
20
+ staleWarning?: string | null,
21
+ ): string | null {
22
+ if (diagnostics.length === 0) return null;
23
+
24
+ const totalDiags = diagnostics.reduce((sum, d) => sum + d.total, 0);
25
+ const detailMap = buildDetailMap(totalDiags, detailed);
26
+
27
+ const lines: string[] = [];
28
+ if (staleWarning) lines.push(staleWarning);
29
+ const visible = diagnostics.slice(0, maxFiles);
30
+
31
+ for (const entry of visible) {
32
+ lines.push(`- ${entry.file}: ${formatCounts(entry)}`);
33
+ appendDetailLines(lines, detailMap?.get(entry.file));
34
+ }
35
+
36
+ const remaining = diagnostics.length - visible.length;
37
+ if (remaining > 0) {
38
+ lines.push(`- +${remaining} more file${remaining === 1 ? "" : "s"}`);
39
+ }
40
+
41
+ appendSuppressionCleanup(
42
+ lines,
43
+ visible.map((entry) => entry.file),
44
+ detailMap,
45
+ );
46
+
47
+ return [
48
+ '<extension-context source="supi-lsp">',
49
+ "Outstanding diagnostics — fix these before proceeding:",
50
+ ...lines,
51
+ "</extension-context>",
52
+ ].join("\n");
53
+ }
54
+
55
+ function buildDetailMap(
56
+ totalDiags: number,
57
+ detailed?: DetailedDiagnostics[],
58
+ ): Map<string, Diagnostic[]> | null {
59
+ if (totalDiags > MAX_DETAILED_DIAGNOSTICS || !detailed || detailed.length === 0) return null;
60
+ return new Map(detailed.map((d) => [d.file, d.diagnostics]));
61
+ }
62
+
63
+ function appendDetailLines(lines: string[], details?: Diagnostic[]): void {
64
+ if (!details) return;
65
+ for (const d of details.slice(0, MAX_DETAIL_LINES_PER_FILE)) {
66
+ const line = d.range.start.line + 1;
67
+ const char = d.range.start.character + 1;
68
+ const source = d.source ? ` ${d.source}` : "";
69
+ lines.push(` L${line} C${char}${source}: ${d.message}`);
70
+ }
71
+ if (details.length > MAX_DETAIL_LINES_PER_FILE) {
72
+ const extra = details.length - MAX_DETAIL_LINES_PER_FILE;
73
+ lines.push(` +${extra} more`);
74
+ }
75
+ }
76
+
77
+ function appendSuppressionCleanup(
78
+ lines: string[],
79
+ visibleFiles: string[],
80
+ detailMap: Map<string, Diagnostic[]> | null,
81
+ ): void {
82
+ if (!detailMap) return;
83
+
84
+ const suppressionLines: string[] = [];
85
+ for (const file of visibleFiles) {
86
+ const diagnostics = detailMap.get(file);
87
+ if (!diagnostics) continue;
88
+
89
+ const { suppressions } = splitSuppressionDiagnostics(diagnostics, 1);
90
+ if (suppressions.length === 0) continue;
91
+
92
+ suppressionLines.push(`- ${file}`);
93
+ appendDetailLines(suppressionLines, suppressions);
94
+ }
95
+
96
+ if (suppressionLines.length === 0) return;
97
+ lines.push("", "Stale suppression comments — clean these up:", ...suppressionLines);
98
+ }
99
+
100
+ export function diagnosticsContextFingerprint(content: string | null): string | null {
101
+ return content;
102
+ }
103
+
104
+ function formatCounts(entry: OutstandingDiagnosticSummaryEntry): string {
105
+ const counts: string[] = [];
106
+ if (entry.errors > 0) counts.push(pluralize(entry.errors, "error"));
107
+ if (entry.warnings > 0) counts.push(pluralize(entry.warnings, "warning"));
108
+ if (entry.information > 0) counts.push(pluralize(entry.information, "info"));
109
+ if (entry.hints > 0) counts.push(pluralize(entry.hints, "hint"));
110
+ return counts.join(", ");
111
+ }
112
+
113
+ function pluralize(count: number, word: string): string {
114
+ return `${count} ${word}${count === 1 ? "" : "s"}`;
115
+ }
@@ -1,5 +1,5 @@
1
+ import type { Diagnostic } from "../config/types.ts";
1
2
  import type { OutstandingDiagnosticSummaryEntry } from "../manager/manager-types.ts";
2
- import type { Diagnostic } from "../types.ts";
3
3
 
4
4
  export function formatDiagnosticsDisplayContent(
5
5
  diagnostics: OutstandingDiagnosticSummaryEntry[],
@@ -1,7 +1,8 @@
1
+ import { type Diagnostic, DiagnosticSeverity } from "../config/types.ts";
1
2
  import type { OutstandingDiagnosticSummaryEntry } from "../manager/manager-types.ts";
2
3
  import { isGlobMatch } from "../pattern-matcher.ts";
3
4
  import { displayRelativeFilePath, shouldIgnoreLspPath } from "../summary.ts";
4
- import { type Diagnostic, DiagnosticSeverity } from "../types.ts";
5
+ import { uriToFile } from "../utils.ts";
5
6
 
6
7
  export function collectDiagnosticSummaryCounts(
7
8
  fileDiags: Map<string, { errors: number; warnings: number }>,
@@ -52,7 +53,7 @@ export function accumulateOutstandingDiagnostics(
52
53
  }
53
54
 
54
55
  export function relativeFilePathFromUri(uri: string, cwd: string): string {
55
- return displayRelativeFilePath(uri.replace("file://", ""), cwd);
56
+ return displayRelativeFilePath(uriToFile(uri), cwd);
56
57
  }
57
58
 
58
59
  function isDiagnosticWithinThreshold(
@@ -1,7 +1,7 @@
1
1
  // Diagnostic formatting and severity utilities.
2
2
 
3
3
  import * as path from "node:path";
4
- import type { Diagnostic } from "../types.ts";
4
+ import type { Diagnostic } from "../config/types.ts";
5
5
 
6
6
  /** Map severity number to label. */
7
7
  export function severityLabel(severity: number | undefined): string {
@@ -1,4 +1,4 @@
1
- import type { Diagnostic } from "../types.ts";
1
+ import type { Diagnostic } from "../config/types.ts";
2
2
 
3
3
  export interface StaleDiagnosticAssessment {
4
4
  suspected: boolean;
@@ -1,4 +1,4 @@
1
- import type { Diagnostic } from "../types.ts";
1
+ import type { Diagnostic } from "../config/types.ts";
2
2
 
3
3
  const SUPPRESSION_WARNING_SEVERITY = 2;
4
4
 
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { FileChangeType, type FileEvent } from "./types.ts";
4
- import { fileToUri } from "./utils.ts";
3
+ import { FileChangeType, type FileEvent } from "../config/types.ts";
4
+ import { fileToUri } from "../utils.ts";
5
5
 
6
6
  const IGNORED_DIRECTORIES = new Set(["node_modules", ".pnpm", ".git", "dist", "coverage"]);
7
7
  const ROOT_LOCKFILES = ["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"];