@mrclrchtr/supi-lsp 6.1.0 → 6.2.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 (47) hide show
  1. package/README.md +21 -3
  2. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  3. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  4. package/node_modules/@mrclrchtr/supi-core/src/debug-identity.ts +11 -0
  5. package/node_modules/@mrclrchtr/supi-core/src/debug.ts +5 -0
  6. package/package.json +4 -5
  7. package/src/api.ts +12 -7
  8. package/src/client/client-diagnostic-cache.ts +1 -1
  9. package/src/client/client-diagnostic-capabilities.ts +0 -5
  10. package/src/client/client-diagnostic-publication.ts +6 -3
  11. package/src/client/client-diagnostic-refresh.ts +2 -1
  12. package/src/client/client-diagnostic-timing.ts +5 -2
  13. package/src/client/client-diagnostics.ts +2 -1
  14. package/src/client/client.ts +17 -5
  15. package/src/client/transport.ts +40 -18
  16. package/src/config/config.ts +164 -30
  17. package/src/config/lsp-settings.ts +9 -87
  18. package/src/config/server-config.ts +0 -1
  19. package/src/config/tsconfig-scope.ts +4 -5
  20. package/src/config/types.ts +0 -29
  21. package/src/debug-telemetry.ts +7 -21
  22. package/src/diagnostics/diagnostic-severity.ts +6 -0
  23. package/src/diagnostics/diagnostic-summary.ts +11 -14
  24. package/src/diagnostics/evidence.ts +0 -5
  25. package/src/diagnostics/stale-diagnostics.ts +0 -1
  26. package/src/diagnostics/workspace-sentinels.ts +29 -49
  27. package/src/manager/manager-client-state.ts +1 -1
  28. package/src/manager/manager-diagnostics.ts +4 -8
  29. package/src/manager/manager-helpers.ts +1 -9
  30. package/src/manager/manager-project-info.ts +6 -3
  31. package/src/manager/manager-workspace-recovery.ts +9 -1
  32. package/src/manager/manager-workspace-symbol.ts +26 -41
  33. package/src/manager/manager.ts +275 -99
  34. package/src/provider/lsp-semantic-provider.ts +4 -1
  35. package/src/provider/semantic-symbol-mapper.ts +59 -28
  36. package/src/session/runtime-controller.ts +27 -19
  37. package/src/session/runtime-diagnostics.ts +26 -0
  38. package/src/session/runtime-registration.ts +0 -17
  39. package/src/session/runtime-registry.ts +83 -19
  40. package/src/session/runtime-transition-debug.ts +5 -2
  41. package/src/session/scanner.ts +24 -21
  42. package/src/session/workspace-lsp-runtime.ts +29 -3
  43. package/src/summary.ts +19 -40
  44. package/src/utils.ts +13 -19
  45. package/src/workspace-path-policy.ts +287 -0
  46. package/src/config/server-actions.ts +0 -59
  47. package/src/pattern-matcher.ts +0 -24
package/README.md CHANGED
@@ -82,6 +82,24 @@ Configuration overrides merge with the built-in server definitions. Use `.pi/sup
82
82
 
83
83
  Gopls pull diagnostics stay opt-in while golang/go#70199 is open; without the option the built-in Go configuration stays in push mode. Kotlin's `--stdio` argument is already part of the built-in configuration and needs no override.
84
84
 
85
+ ### Automatic workspace path policy
86
+
87
+ Automatic LSP work uses one path policy that does not change for each workspace runtime. It covers project discovery, route startup, warm-up, sentinel and source-file lists, created-file tracking, runtime guidance, and diagnostic summaries not tied to one request.
88
+
89
+ The policy excludes these directories by default: `.git`, `.cache`, `.pi`, `.pnpm`, `node_modules`, `dist`, `build`, `out`, `coverage`, `.next`, `.nuxt`, `.turbo`, and `__pycache__`. It also applies `lsp.exclude` patterns and root or nested `.gitignore` rules. Patterns use gitignore syntax, including rules relative to each directory and `!` rules. Built-in exclusions cannot be enabled again. Symbolic-link directories are not visited. Other dot-directories, such as `.github` and `.storybook`, remain allowed.
90
+
91
+ Set `lsp.exclude` in project or global SuPi configuration:
92
+
93
+ ```json
94
+ {
95
+ "lsp": {
96
+ "exclude": ["generated/**", "!generated/keep.ts"]
97
+ }
98
+ }
99
+ ```
100
+
101
+ An exact semantic request can still route an excluded file when a compatible server is available. This does not add the file to automatic work. Configured diagnostic suppression still applies to diagnostic output.
102
+
85
103
  ### Custom server configuration
86
104
 
87
105
  A custom server needs a command and at least one file type:
@@ -104,7 +122,7 @@ A custom server needs a command and at least one file type:
104
122
 
105
123
  File types do not include a leading dot. If `rootMarkers` is omitted, the server uses the session root.
106
124
 
107
- The `lsp.enabled` and `lsp.active` settings are deprecated and ignored. Disable one language with `lsp.servers.<language>.enabled: false`. Use `/supi-ci-status` from `@mrclrchtr/supi-code-intelligence` to see detected, running, and missing servers.
125
+ Disable one language with `lsp.servers.<language>.enabled: false`. Use `/supi-ci-status` from `@mrclrchtr/supi-code-intelligence` to see detected, running, and missing servers.
108
126
 
109
127
  ## What it provides
110
128
 
@@ -191,7 +209,7 @@ Detected servers start concurrently. In a polyglot workspace, disable unneeded l
191
209
  }
192
210
  ```
193
211
 
194
- The old `lsp.enabled` and `lsp.active` settings are deprecated and ignored. If every server definition is disabled, the controller publishes an explicit `disabled` runtime state instead of an empty `ready` runtime. When a ready owner has no active client yet, it stays published for lazy routing while semantic capability remains pending.
212
+ If every server definition is disabled, the controller publishes an explicit `disabled` runtime state instead of an empty `ready` runtime. When a ready owner has no active client yet, it stays published for lazy routing while semantic capability remains pending.
195
213
 
196
214
  ## Architecture
197
215
 
@@ -212,7 +230,7 @@ See [`docs/adr/0016-workspace-lsp-runtime-interface.md`](../../docs/adr/0016-wor
212
230
 
213
231
  ## Package exports
214
232
 
215
- - `@mrclrchtr/supi-lsp/api` — runtime/controller/config types and registry operations
233
+ - `@mrclrchtr/supi-lsp/api` — runtime/controller/config types, registry operations, and automatic path-policy helpers
216
234
  - `@mrclrchtr/supi-lsp/provider/lsp-semantic-provider` — semantic provider adapter
217
235
 
218
236
  ## Source
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-code-runtime",
3
- "version": "6.1.0",
3
+ "version": "6.2.0",
4
4
  "description": "Shared workspace context and capability contracts for code intelligence",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "6.1.0",
3
+ "version": "6.2.0",
4
4
  "description": "Shared settings, configuration, reporting, and session infrastructure",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,11 @@
1
+ /** Maximum UTF-16 code-unit length of one debug identity string. */
2
+ export const MAX_DEBUG_IDENTITY_STRING = 512;
3
+
4
+ /** Marker appended to a truncated debug identity string. */
5
+ export const DEBUG_IDENTITY_TRUNCATION_MARKER = "…";
6
+
7
+ /** Bound one debug identity string, including its truncation marker. */
8
+ export function truncateDebugIdentity(value: string): string {
9
+ if (value.length <= MAX_DEBUG_IDENTITY_STRING) return value;
10
+ return `${value.slice(0, MAX_DEBUG_IDENTITY_STRING - 1)}${DEBUG_IDENTITY_TRUNCATION_MARKER}`;
11
+ }
@@ -3,6 +3,11 @@
3
3
  // Kept separate from debug-registry.ts so debug-timing.ts can import the
4
4
  // registry without creating an import cycle through the barrel re-export.
5
5
 
6
+ export {
7
+ DEBUG_IDENTITY_TRUNCATION_MARKER,
8
+ MAX_DEBUG_IDENTITY_STRING,
9
+ truncateDebugIdentity,
10
+ } from "./debug-identity.ts";
6
11
  // biome-ignore lint/performance/noReExportAll: preserve the stable debug domain entry point
7
12
  export * from "./debug-registry.ts";
8
13
  // biome-ignore lint/performance/noReExportAll: preserve the stable debug domain entry point
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-lsp",
3
- "version": "6.1.0",
3
+ "version": "6.2.0",
4
4
  "description": "Language Server Protocol runtime for SuPi code intelligence",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,8 +37,8 @@
37
37
  "vscode-jsonrpc": "^9.0.0",
38
38
  "vscode-languageserver-protocol": "^3.18.0",
39
39
  "vscode-languageserver-types": "^3.18.0",
40
- "@mrclrchtr/supi-code-runtime": "6.1.0",
41
- "@mrclrchtr/supi-core": "6.1.0"
40
+ "@mrclrchtr/supi-code-runtime": "6.2.0",
41
+ "@mrclrchtr/supi-core": "6.2.0"
42
42
  },
43
43
  "bundledDependencies": [
44
44
  "@mrclrchtr/supi-code-runtime",
@@ -53,7 +53,6 @@
53
53
  "exports": {
54
54
  "./api": "./src/api.ts",
55
55
  "./package.json": "./package.json",
56
- "./provider/lsp-semantic-provider": "./src/provider/lsp-semantic-provider.ts",
57
- "./debug-telemetry": "./src/debug-telemetry.ts"
56
+ "./provider/lsp-semantic-provider": "./src/provider/lsp-semantic-provider.ts"
58
57
  }
59
58
  }
package/src/api.ts CHANGED
@@ -1,13 +1,8 @@
1
1
  // Public API surface for the LSP session-scoped service.
2
2
 
3
3
  export { type LoadConfigOptions, loadConfig } from "./config/config.ts";
4
- export type { DeprecatedLspKeys, LspSettings } from "./config/lsp-settings.ts";
5
- export {
6
- getDeprecatedLspKeys,
7
- getLspDisabledMessage,
8
- hasDeprecatedLspKeys,
9
- loadLspSettings,
10
- } from "./config/lsp-settings.ts";
4
+ export type { LspSettings } from "./config/lsp-settings.ts";
5
+ export { loadLspSettings } from "./config/lsp-settings.ts";
11
6
  export {
12
7
  clearTsconfigCache,
13
8
  type FileScopeDecision,
@@ -43,6 +38,8 @@ export { isLikelyStaleDiagnostic } from "./diagnostics/stale-diagnostics.ts";
43
38
  export {
44
39
  scanWorkspaceSentinels,
45
40
  syncWorkspaceSentinelSnapshot,
41
+ type WorkspaceSentinelScanOptions,
42
+ type WorkspaceSentinelSyncResult,
46
43
  } from "./diagnostics/workspace-sentinels.ts";
47
44
  export { raceReadinessValue, raceRequestControl } from "./session/readiness.ts";
48
45
  export type {
@@ -59,6 +56,7 @@ export type {
59
56
  DiagnosticEvidenceSummary,
60
57
  OutstandingDiagnosticSummaryEntry,
61
58
  ProcessCrashDiagnosticDemand,
59
+ ProcessCrashRecoverySummary,
62
60
  RecoverDiagnosticsResult,
63
61
  RoutedMutationResponse,
64
62
  SemanticReadinessResult,
@@ -71,8 +69,15 @@ export type {
71
69
  } from "./session/runtime-registry.ts";
72
70
  export {
73
71
  clearWorkspaceLspRuntime,
72
+ emptyProcessCrashRecoverySummary,
74
73
  getWorkspaceLspRuntime,
75
74
  setWorkspaceLspRuntimeState,
76
75
  waitForWorkspaceLspRuntime,
77
76
  } from "./session/runtime-registry.ts";
78
77
  export { scanMissingServers } from "./session/scanner.ts";
78
+ export {
79
+ AUTOMATIC_LSP_EXCLUDED_DIRECTORIES,
80
+ type AutomaticLspPathPolicy,
81
+ createAutomaticLspPathPolicy,
82
+ createDefaultAutomaticLspPathPolicy,
83
+ } from "./workspace-path-policy.ts";
@@ -1,5 +1,5 @@
1
+ import { uriToFile } from "@mrclrchtr/supi-core/path";
1
2
  import type { PublishDiagnosticsParams } from "../config/types.ts";
2
- import { uriToFile } from "../utils.ts";
3
3
  import {
4
4
  type DiagnosticCacheEntry,
5
5
  isTentativePushEntry,
@@ -66,11 +66,6 @@ export class ClientDynamicRegistrations {
66
66
  return (this.idsByMethod.get(method)?.size ?? 0) > 0;
67
67
  }
68
68
 
69
- /** Snapshot of the active registration ids for a method. */
70
- ids(method: string): readonly string[] {
71
- return [...(this.idsByMethod.get(method) ?? [])];
72
- }
73
-
74
69
  /** Drop all registrations — capability loss on shutdown, crash, or disposal. */
75
70
  clear(): void {
76
71
  this.idsByMethod.clear();
@@ -9,9 +9,12 @@
9
9
  // and timing data. They never carry diagnostic payloads or source text.
10
10
 
11
11
  import * as path from "node:path";
12
- import { recordDebugEvent } from "@mrclrchtr/supi-core/debug";
13
- import { boundCwd, truncateIdentity } from "../debug-telemetry.ts";
14
- import { uriToFile } from "../utils.ts";
12
+ import {
13
+ recordDebugEvent,
14
+ truncateDebugIdentity as truncateIdentity,
15
+ } from "@mrclrchtr/supi-core/debug";
16
+ import { uriToFile } from "@mrclrchtr/supi-core/path";
17
+ import { boundCwd } from "../debug-telemetry.ts";
15
18
 
16
19
  /** Maximum tracked synchronizations before the oldest entry is evicted. */
17
20
  export const MAX_TRACKED_SYNCHRONIZATIONS = 64;
@@ -5,12 +5,13 @@ import {
5
5
  isCodeRequestInterruption,
6
6
  throwIfCodeRequestInterrupted,
7
7
  } from "@mrclrchtr/supi-code-runtime/api";
8
+ import { fileToUri, uriToFile } from "@mrclrchtr/supi-core/path";
8
9
  import type { TextDocumentIdentifier } from "../config/types.ts";
9
10
  import {
10
11
  type DiagnosticEvidenceSummary,
11
12
  summarizeDiagnosticEvidence,
12
13
  } from "../diagnostics/evidence.ts";
13
- import { detectLanguageId, fileToUri, uriToFile } from "../utils.ts";
14
+ import { detectLanguageId } from "../utils.ts";
14
15
  import {
15
16
  type DiagnosticCacheEntry,
16
17
  type DiagnosticSynchronization,
@@ -1,6 +1,9 @@
1
1
  import type { CodeRequestControl } from "@mrclrchtr/supi-code-runtime/api";
2
- import { startDebugTimer } from "@mrclrchtr/supi-core/debug";
3
- import { boundCwd, truncateIdentity } from "../debug-telemetry.ts";
2
+ import {
3
+ startDebugTimer,
4
+ truncateDebugIdentity as truncateIdentity,
5
+ } from "@mrclrchtr/supi-core/debug";
6
+ import { boundCwd } from "../debug-telemetry.ts";
4
7
 
5
8
  type DiagnosticCollection = "cache" | "fallback" | "none" | "pull" | "push";
6
9
  type DiagnosticFreshness = "not-observed" | "observed";
@@ -7,9 +7,10 @@ import {
7
7
  throwIfCodeRequestInterrupted,
8
8
  unavailableCodeQuery,
9
9
  } from "@mrclrchtr/supi-code-runtime/api";
10
+ import { fileToUri, uriToFile } from "@mrclrchtr/supi-core/path";
10
11
  import type { Diagnostic, TextDocumentItem } from "../config/types.ts";
11
12
  import type { DiagnosticEvidenceSummary } from "../diagnostics/evidence.ts";
12
- import { detectLanguageId, fileToUri, uriToFile } from "../utils.ts";
13
+ import { detectLanguageId } from "../utils.ts";
13
14
  import { applyPushDiagnostics, buildClientDiagnosticSnapshot } from "./client-diagnostic-cache.ts";
14
15
  import { collectSynchronizedFileDiagnostics } from "./client-diagnostic-collection.ts";
15
16
  import {
@@ -12,7 +12,11 @@ import {
12
12
  throwIfCodeRequestInterrupted,
13
13
  unavailableCodeQuery,
14
14
  } from "@mrclrchtr/supi-code-runtime/api";
15
- import { recordDebugEvent } from "@mrclrchtr/supi-core/debug";
15
+ import {
16
+ recordDebugEvent,
17
+ truncateDebugIdentity as truncateIdentity,
18
+ } from "@mrclrchtr/supi-core/debug";
19
+ import { fileToUri } from "@mrclrchtr/supi-core/path";
16
20
  import { type ProgressToken, TextDocumentSyncKind } from "vscode-languageserver-protocol";
17
21
  import { CLIENT_CAPABILITIES } from "../config/capabilities.ts";
18
22
  import type {
@@ -35,10 +39,9 @@ import type {
35
39
  WorkspaceEdit,
36
40
  WorkspaceSymbol,
37
41
  } from "../config/types.ts";
38
- import { boundCwd, truncateIdentity } from "../debug-telemetry.ts";
42
+ import { boundCwd } from "../debug-telemetry.ts";
39
43
  import type { DiagnosticEvidenceSummary } from "../diagnostics/evidence.ts";
40
44
  import { raceRequestControl } from "../session/readiness.ts";
41
- import { fileToUri } from "../utils.ts";
42
45
  import {
43
46
  ClientDynamicRegistrations,
44
47
  DOCUMENT_DIAGNOSTIC_METHOD,
@@ -141,6 +144,15 @@ function killProcessTree(pid: number): void {
141
144
  }
142
145
  }
143
146
 
147
+ /** Terminate a failed startup without leaving server descendants running. */
148
+ function killFailedStartupProcess(child: ChildProcess): void {
149
+ if (child.pid) {
150
+ killProcessTree(child.pid);
151
+ return;
152
+ }
153
+ child.kill();
154
+ }
155
+
144
156
  // ── Types ─────────────────────────────────────────────────────────────
145
157
  export type ClientStatus = "initializing" | "running" | "error" | "shutdown";
146
158
 
@@ -285,7 +297,7 @@ export class LspClient {
285
297
  if (!this.process.stdin || !this.process.stdout) {
286
298
  const failure = new Error(`${cmd}: missing stdin/stdout`);
287
299
  this.handleProcessFailure(failure);
288
- this.process.kill();
300
+ killFailedStartupProcess(this.process);
289
301
  throw failure;
290
302
  }
291
303
 
@@ -350,7 +362,7 @@ export class LspClient {
350
362
  } catch (err) {
351
363
  const failure = new Error(`${this.name}: initialize failed: ${err}`, { cause: err });
352
364
  this.handleProcessFailure(failure);
353
- this.process.kill();
365
+ killFailedStartupProcess(this.process);
354
366
  throw failure;
355
367
  }
356
368
  }
@@ -7,7 +7,10 @@ import {
7
7
  type CodeRequestControl,
8
8
  CodeRequestDeadlineError,
9
9
  } from "@mrclrchtr/supi-code-runtime/api";
10
- import { startDebugTimer } from "@mrclrchtr/supi-core/debug";
10
+ import {
11
+ startDebugTimer,
12
+ truncateDebugIdentity as truncateIdentity,
13
+ } from "@mrclrchtr/supi-core/debug";
11
14
  import {
12
15
  CancellationTokenSource,
13
16
  createMessageConnection,
@@ -18,7 +21,7 @@ import {
18
21
  StreamMessageReader,
19
22
  StreamMessageWriter,
20
23
  } from "vscode-jsonrpc/node";
21
- import { boundCwd, LSP_REQUEST_TIMEOUT_ERROR_CODE, truncateIdentity } from "../debug-telemetry.ts";
24
+ import { boundCwd, LSP_REQUEST_TIMEOUT_ERROR_CODE } from "../debug-telemetry.ts";
22
25
 
23
26
  const DEFAULT_TIMEOUT_MS = 30_000;
24
27
 
@@ -112,34 +115,40 @@ export class JsonRpcClient {
112
115
  const methodClass = classifyRequestMethod(method);
113
116
  const boundedMethod = truncateIdentity(method);
114
117
  const timer = startDebugTimer();
118
+ // A request cancelled before dispatch must not produce protocol traffic.
119
+ if (signal?.aborted) {
120
+ return rejectUndispatchedRequest(
121
+ signal.reason ?? new Error(`Request ${method} was cancelled`),
122
+ {
123
+ timer,
124
+ operationId: options?.operationId,
125
+ observation: { method: boundedMethod, methodClass, outcome: "cancelled" },
126
+ identity: { server: this.server, cwd: this.cwd },
127
+ },
128
+ );
129
+ }
115
130
  // An expired absolute deadline must not even send the request: the caller
116
131
  // no longer awaits a result, so no protocol traffic may start.
117
132
  if (deadlineMs !== undefined && deadlineMs <= 0) {
118
- recordRequestTiming(
133
+ return rejectUndispatchedRequest(new CodeRequestDeadlineError(), {
119
134
  timer,
120
- options?.operationId,
121
- {
135
+ operationId: options?.operationId,
136
+ observation: {
122
137
  method: boundedMethod,
123
138
  methodClass,
124
139
  outcome: "timed-out",
125
140
  errorCode: LSP_REQUEST_TIMEOUT_ERROR_CODE,
126
141
  },
127
- { server: this.server, cwd: this.cwd },
128
- );
129
- return Promise.reject(new CodeRequestDeadlineError());
142
+ identity: { server: this.server, cwd: this.cwd },
143
+ });
130
144
  }
131
145
  if (this.closed || !this.connection) {
132
- recordRequestTiming(
146
+ return rejectUndispatchedRequest(new Error("JSON-RPC client is closed"), {
133
147
  timer,
134
- options?.operationId,
135
- {
136
- method: boundedMethod,
137
- methodClass,
138
- outcome: "cancelled",
139
- },
140
- { server: this.server, cwd: this.cwd },
141
- );
142
- return Promise.reject(new Error("JSON-RPC client is closed"));
148
+ operationId: options?.operationId,
149
+ observation: { method: boundedMethod, methodClass, outcome: "cancelled" },
150
+ identity: { server: this.server, cwd: this.cwd },
151
+ });
143
152
  }
144
153
  const tokenSource = new CancellationTokenSource();
145
154
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -344,6 +353,19 @@ function jsonRpcErrorCode(error: unknown): number | undefined {
344
353
  return typeof code === "number" && Number.isInteger(code) ? code : undefined;
345
354
  }
346
355
 
356
+ function rejectUndispatchedRequest(
357
+ error: unknown,
358
+ timing: {
359
+ timer: ReturnType<typeof startDebugTimer>;
360
+ operationId: string | undefined;
361
+ observation: RequestTimingObservation;
362
+ identity: { server?: string; cwd?: string };
363
+ },
364
+ ): Promise<never> {
365
+ recordRequestTiming(timing.timer, timing.operationId, timing.observation, timing.identity);
366
+ return Promise.reject(error);
367
+ }
368
+
347
369
  function recordRequestTiming(
348
370
  timer: ReturnType<typeof startDebugTimer>,
349
371
  operationId: string | undefined,
@@ -4,6 +4,7 @@ import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { loadSupiConfigForScope } from "@mrclrchtr/supi-core/config";
7
+ import { truncateDebugIdentity } from "@mrclrchtr/supi-core/debug";
7
8
  import type { LspConfig, ServerConfig } from "./types.ts";
8
9
 
9
10
  const CONFIG_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -24,17 +25,16 @@ export const LANGUAGE_ALIASES: Record<string, string> = {
24
25
  cpp: "c",
25
26
  };
26
27
 
27
- /** Resolve a language name through aliases. */
28
- export function resolveLanguageAlias(name: string): string {
29
- return LANGUAGE_ALIASES[name] ?? name;
30
- }
31
-
32
- function resolveAliasesInOverrides(servers: Record<string, Partial<ServerConfig>>): void {
28
+ function resolveAliasesInOverrides(servers: Record<string, unknown>): void {
33
29
  for (const [alias, target] of Object.entries(LANGUAGE_ALIASES)) {
34
- if (servers[alias]) {
35
- servers[target] = { ...(servers[target] ?? {}), ...servers[alias] };
36
- delete servers[alias];
37
- }
30
+ if (!Object.hasOwn(servers, alias)) continue;
31
+ const aliasOverride = servers[alias];
32
+ const targetOverride = servers[target];
33
+ servers[target] =
34
+ isRecord(aliasOverride) && isRecord(targetOverride)
35
+ ? { ...targetOverride, ...aliasOverride }
36
+ : aliasOverride;
37
+ delete servers[alias];
38
38
  }
39
39
  }
40
40
 
@@ -49,13 +49,13 @@ export function loadConfig(cwd: string, options?: LoadConfigOptions): LspConfig
49
49
  const globalLsp = loadSupiConfigForScope(
50
50
  "lsp",
51
51
  cwd,
52
- { enabled: true, active: [], servers: {} as Record<string, ServerConfig> },
52
+ { servers: {} as Record<string, ServerConfig> },
53
53
  { scope: "global", homeDir: options?.homeDir },
54
54
  );
55
55
  const projectLsp = loadSupiConfigForScope(
56
56
  "lsp",
57
57
  cwd,
58
- { enabled: true, active: [], servers: {} as Record<string, ServerConfig> },
58
+ { servers: {} as Record<string, ServerConfig> },
59
59
  { scope: "project" },
60
60
  );
61
61
 
@@ -91,6 +91,13 @@ function mergeServerConfigs(
91
91
  projectOverrides: unknown,
92
92
  ): Record<string, ServerConfig> {
93
93
  const merged: Record<string, ServerConfig> = { ...defaults };
94
+ const warnings = new Map<string, { fields: Set<string>; skipped: boolean }>();
95
+ const reportWarning: ServerConfigWarningReporter = (name, fields, skipped) => {
96
+ const warning = warnings.get(name) ?? { fields: new Set<string>(), skipped: false };
97
+ for (const field of fields) warning.fields.add(field);
98
+ warning.skipped ||= skipped;
99
+ warnings.set(name, warning);
100
+ };
94
101
 
95
102
  const globalServers = isServerRecord(globalOverrides) ? globalOverrides : {};
96
103
  const projectServers = isServerRecord(projectOverrides) ? projectOverrides : {};
@@ -100,14 +107,29 @@ function mergeServerConfigs(
100
107
 
101
108
  // Apply global per-key overrides against defaults
102
109
  for (const [lang, override] of Object.entries(globalServers)) {
103
- const result = mergeSingleServer(defaults[lang], override);
110
+ const builtIn = defaults[lang] !== undefined;
111
+ const result = mergeSingleServer({
112
+ name: lang,
113
+ base: defaults[lang],
114
+ value: override,
115
+ builtIn,
116
+ reportWarning,
117
+ });
104
118
  if (result) merged[lang] = result;
105
119
  }
106
120
 
107
121
  // Apply project per-key overrides against the result so far
108
122
  for (const [lang, override] of Object.entries(projectServers)) {
109
- const result = mergeSingleServer(merged[lang] ?? defaults[lang], override);
123
+ const builtIn = defaults[lang] !== undefined;
124
+ const result = mergeSingleServer({
125
+ name: lang,
126
+ base: merged[lang] ?? defaults[lang],
127
+ value: override,
128
+ builtIn,
129
+ reportWarning,
130
+ });
110
131
  if (result) merged[lang] = result;
132
+ else if (!builtIn) delete merged[lang];
111
133
  }
112
134
 
113
135
  // Remove servers whose final merged config has enabled === false
@@ -117,29 +139,141 @@ function mergeServerConfigs(
117
139
  }
118
140
  }
119
141
 
142
+ for (const [name, warning] of warnings) {
143
+ warnInvalidServerConfig(name, [...warning.fields], warning.skipped);
144
+ }
120
145
  return merged;
121
146
  }
122
147
 
123
- function mergeSingleServer(
124
- base: ServerConfig | undefined,
125
- override: Partial<ServerConfig>,
126
- ): ServerConfig | null {
127
- if (!base) {
128
- // A custom server needs a command and at least one file extension.
129
- if (
130
- typeof override.command === "string" &&
131
- override.command.length > 0 &&
132
- Array.isArray(override.fileTypes) &&
133
- override.fileTypes.length > 0 &&
134
- (override.rootMarkers === undefined || Array.isArray(override.rootMarkers))
135
- ) {
136
- return { ...override, rootMarkers: override.rootMarkers ?? [] } as ServerConfig;
137
- }
148
+ type ServerConfigWarningReporter = (
149
+ name: string,
150
+ fields: readonly string[],
151
+ skipped: boolean,
152
+ ) => void;
153
+
154
+ function mergeSingleServer(options: {
155
+ name: string;
156
+ base: ServerConfig | undefined;
157
+ value: unknown;
158
+ builtIn: boolean;
159
+ reportWarning: ServerConfigWarningReporter;
160
+ }): ServerConfig | null {
161
+ const { name, base, value, builtIn, reportWarning } = options;
162
+ const { override, invalidFields } = validateServerOverride(value);
163
+ const missingFields = base
164
+ ? []
165
+ : [
166
+ ...(typeof override.command === "string" ? [] : ["command"]),
167
+ ...(override.fileTypes && override.fileTypes.length > 0 ? [] : ["fileTypes"]),
168
+ ];
169
+ const issues = [...new Set([...invalidFields, ...missingFields])];
170
+ if ((!builtIn && invalidFields.length > 0) || (!base && issues.length > 0)) {
171
+ reportWarning(name, issues, true);
138
172
  return null;
139
173
  }
174
+ if (!base) {
175
+ return {
176
+ ...override,
177
+ command: override.command as string,
178
+ fileTypes: override.fileTypes as string[],
179
+ rootMarkers: override.rootMarkers ?? [],
180
+ };
181
+ }
182
+
183
+ if (invalidFields.length > 0) reportWarning(name, invalidFields, false);
140
184
  return { ...base, ...override };
141
185
  }
142
186
 
143
- function isServerRecord(value: unknown): value is Record<string, Partial<ServerConfig>> {
187
+ type ServerOverrideSetter = (override: Partial<ServerConfig>, candidate: unknown) => boolean;
188
+
189
+ const SERVER_OVERRIDE_SETTERS: Record<string, ServerOverrideSetter> = {
190
+ command(override, candidate) {
191
+ if (typeof candidate !== "string" || candidate.trim().length === 0) return false;
192
+ override.command = candidate;
193
+ return true;
194
+ },
195
+ args(override, candidate) {
196
+ if (!isStringArray(candidate)) return false;
197
+ override.args = candidate;
198
+ return true;
199
+ },
200
+ fileTypes(override, candidate) {
201
+ if (!isNonEmptyStringArray(candidate)) return false;
202
+ override.fileTypes = candidate;
203
+ return true;
204
+ },
205
+ rootMarkers(override, candidate) {
206
+ if (!isStringArray(candidate)) return false;
207
+ override.rootMarkers = candidate;
208
+ return true;
209
+ },
210
+ enabled(override, candidate) {
211
+ if (typeof candidate !== "boolean") return false;
212
+ override.enabled = candidate;
213
+ return true;
214
+ },
215
+ env(override, candidate) {
216
+ if (!isStringRecord(candidate)) return false;
217
+ override.env = candidate;
218
+ return true;
219
+ },
220
+ initializationOptions(override, candidate) {
221
+ override.initializationOptions = candidate;
222
+ return true;
223
+ },
224
+ readinessTimeoutMs(override, candidate) {
225
+ if (typeof candidate !== "number" || !Number.isFinite(candidate) || candidate <= 0) {
226
+ return false;
227
+ }
228
+ override.readinessTimeoutMs = candidate;
229
+ return true;
230
+ },
231
+ };
232
+
233
+ function validateServerOverride(value: unknown): {
234
+ override: Partial<ServerConfig>;
235
+ invalidFields: string[];
236
+ } {
237
+ if (!isRecord(value)) return { override: {}, invalidFields: ["definition"] };
238
+ const override: Partial<ServerConfig> = {};
239
+ const invalidFields: string[] = [];
240
+
241
+ for (const [field, candidate] of Object.entries(value)) {
242
+ const setter = SERVER_OVERRIDE_SETTERS[field];
243
+ if (!setter?.(override, candidate)) invalidFields.push(field);
244
+ }
245
+
246
+ return { override, invalidFields };
247
+ }
248
+
249
+ function isStringArray(value: unknown): value is string[] {
250
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
251
+ }
252
+
253
+ function isNonEmptyStringArray(value: unknown): value is string[] {
254
+ return (
255
+ Array.isArray(value) &&
256
+ value.length > 0 &&
257
+ value.every((entry) => typeof entry === "string" && entry.length > 0)
258
+ );
259
+ }
260
+
261
+ function isStringRecord(value: unknown): value is Record<string, string> {
262
+ return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string");
263
+ }
264
+
265
+ function warnInvalidServerConfig(name: string, fields: readonly string[], skipped: boolean): void {
266
+ const boundedName = truncateDebugIdentity(name);
267
+ const boundedFields = truncateDebugIdentity(fields.join(", "));
268
+ const action = skipped ? "Skipped invalid" : "Ignored invalid fields in";
269
+ // biome-ignore lint/suspicious/noConsole: malformed user configuration must be visible.
270
+ console.warn(`[supi-lsp] ${action} LSP server configuration "${boundedName}": ${boundedFields}`);
271
+ }
272
+
273
+ function isServerRecord(value: unknown): value is Record<string, unknown> {
274
+ return isRecord(value);
275
+ }
276
+
277
+ function isRecord(value: unknown): value is Record<string, unknown> {
144
278
  return typeof value === "object" && value !== null && !Array.isArray(value);
145
279
  }