@mrclrchtr/supi-lsp 11.1.0 → 11.3.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 (80) hide show
  1. package/README.md +81 -7
  2. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  3. package/node_modules/@mrclrchtr/supi-code-runtime/src/capability/types.ts +3 -1
  4. package/node_modules/@mrclrchtr/supi-code-runtime/src/query-result.ts +9 -0
  5. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  6. package/node_modules/@mrclrchtr/supi-core/src/child-web-tools.ts +110 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/session.ts +11 -0
  8. package/node_modules/semver/LICENSE +15 -0
  9. package/node_modules/semver/README.md +680 -0
  10. package/node_modules/semver/bin/semver.js +195 -0
  11. package/node_modules/semver/classes/comparator.js +143 -0
  12. package/node_modules/semver/classes/index.js +7 -0
  13. package/node_modules/semver/classes/range.js +577 -0
  14. package/node_modules/semver/classes/semver.js +350 -0
  15. package/node_modules/semver/functions/clean.js +8 -0
  16. package/node_modules/semver/functions/cmp.js +54 -0
  17. package/node_modules/semver/functions/coerce.js +62 -0
  18. package/node_modules/semver/functions/compare-build.js +9 -0
  19. package/node_modules/semver/functions/compare-loose.js +5 -0
  20. package/node_modules/semver/functions/compare.js +7 -0
  21. package/node_modules/semver/functions/diff.js +60 -0
  22. package/node_modules/semver/functions/eq.js +5 -0
  23. package/node_modules/semver/functions/gt.js +5 -0
  24. package/node_modules/semver/functions/gte.js +5 -0
  25. package/node_modules/semver/functions/inc.js +21 -0
  26. package/node_modules/semver/functions/lt.js +5 -0
  27. package/node_modules/semver/functions/lte.js +5 -0
  28. package/node_modules/semver/functions/major.js +5 -0
  29. package/node_modules/semver/functions/minor.js +5 -0
  30. package/node_modules/semver/functions/neq.js +5 -0
  31. package/node_modules/semver/functions/parse.js +18 -0
  32. package/node_modules/semver/functions/patch.js +5 -0
  33. package/node_modules/semver/functions/prerelease.js +8 -0
  34. package/node_modules/semver/functions/rcompare.js +5 -0
  35. package/node_modules/semver/functions/rsort.js +5 -0
  36. package/node_modules/semver/functions/satisfies.js +12 -0
  37. package/node_modules/semver/functions/sort.js +5 -0
  38. package/node_modules/semver/functions/truncate.js +48 -0
  39. package/node_modules/semver/functions/valid.js +8 -0
  40. package/node_modules/semver/index.js +93 -0
  41. package/node_modules/semver/internal/constants.js +37 -0
  42. package/node_modules/semver/internal/debug.js +11 -0
  43. package/node_modules/semver/internal/identifiers.js +29 -0
  44. package/node_modules/semver/internal/lrucache.js +42 -0
  45. package/node_modules/semver/internal/parse-options.js +17 -0
  46. package/node_modules/semver/internal/re.js +223 -0
  47. package/node_modules/semver/package.json +78 -0
  48. package/node_modules/semver/preload.js +4 -0
  49. package/node_modules/semver/range.bnf +17 -0
  50. package/node_modules/semver/ranges/gtr.js +6 -0
  51. package/node_modules/semver/ranges/intersects.js +9 -0
  52. package/node_modules/semver/ranges/ltr.js +6 -0
  53. package/node_modules/semver/ranges/max-satisfying.js +27 -0
  54. package/node_modules/semver/ranges/min-satisfying.js +26 -0
  55. package/node_modules/semver/ranges/min-version.js +63 -0
  56. package/node_modules/semver/ranges/outside.js +82 -0
  57. package/node_modules/semver/ranges/simplify.js +49 -0
  58. package/node_modules/semver/ranges/subset.js +249 -0
  59. package/node_modules/semver/ranges/to-comparators.js +10 -0
  60. package/node_modules/semver/ranges/valid.js +13 -0
  61. package/package.json +5 -3
  62. package/src/client/client-diagnostic-collection.ts +11 -4
  63. package/src/client/client-diagnostics.ts +77 -6
  64. package/src/client/client-input-admission.ts +149 -0
  65. package/src/client/client-typescript-project.ts +40 -0
  66. package/src/client/client.ts +174 -49
  67. package/src/config/config.ts +53 -2
  68. package/src/config/operation-support.ts +19 -4
  69. package/src/config/server-config.ts +22 -0
  70. package/src/config/typescript-launch.ts +276 -0
  71. package/src/diagnostics/evidence.ts +2 -2
  72. package/src/manager/manager-project-info.ts +22 -10
  73. package/src/manager/manager-workspace-symbol.ts +157 -23
  74. package/src/manager/manager.ts +306 -75
  75. package/src/provider/refactor-planning.ts +4 -3
  76. package/src/session/runtime-controller.ts +2 -2
  77. package/src/session/runtime-registry.ts +19 -2
  78. package/src/session/runtime-rename.ts +1 -0
  79. package/src/session/scanner.ts +27 -15
  80. package/src/session/workspace-lsp-runtime.ts +5 -1
@@ -51,6 +51,7 @@ import type {
51
51
  WorkspaceEdit,
52
52
  WorkspaceSymbol,
53
53
  } from "../config/types.ts";
54
+ import { resolveTypeScriptLaunch, type TypeScriptLaunch } from "../config/typescript-launch.ts";
54
55
  import { boundCwd } from "../debug-telemetry.ts";
55
56
  import type { DiagnosticEvidenceSummary } from "../diagnostics/evidence.ts";
56
57
  import { raceRequestControl } from "../session/readiness.ts";
@@ -79,6 +80,11 @@ import type {
79
80
  SemanticInputSnapshot,
80
81
  } from "./client-semantic-input-barrier.ts";
81
82
  import { SemanticInputSynchronizationError } from "./client-semantic-input-errors.ts";
83
+ import {
84
+ type ProjectMembership,
85
+ readTypeScriptProjectMembership,
86
+ typeScriptProjectInfoParams,
87
+ } from "./client-typescript-project.ts";
82
88
  import { JsonRpcClient, JsonRpcRequestError } from "./transport.ts";
83
89
 
84
90
  const SHUTDOWN_TIMEOUT_MS = 5_000;
@@ -88,6 +94,12 @@ const CONTENT_MODIFIED_RETRY_DELAY_MS = 100;
88
94
 
89
95
  /** Bounded stderr tail kept for startup-failure error messages. */
90
96
  const STDERR_TAIL_MAX_CHARS = 4_000;
97
+ /**
98
+ * Bound for draining a failed startup's stdio. An initialize write can fail
99
+ * (`EPIPE`) before the exiting server's stderr is read; waiting for `close`
100
+ * lets the failure report the server's own reason instead of the transport error.
101
+ */
102
+ const STARTUP_FAILURE_DRAIN_MS = 500;
91
103
 
92
104
  interface SemanticRequestAttemptState {
93
105
  inputRevision: number | undefined;
@@ -491,6 +503,20 @@ function killFailedStartupProcess(child: ChildProcess): void {
491
503
  child.kill();
492
504
  }
493
505
 
506
+ /** Resolve once `closed()` holds or the child emits `close`, bounded by `ms`. */
507
+ function waitForStdioClose(child: ChildProcess, closed: () => boolean, ms: number): Promise<void> {
508
+ if (closed()) return Promise.resolve();
509
+ return new Promise((resolve) => {
510
+ const done = () => {
511
+ clearTimeout(timer);
512
+ child.off("close", done);
513
+ resolve();
514
+ };
515
+ const timer = setTimeout(done, ms);
516
+ child.once("close", done);
517
+ });
518
+ }
519
+
494
520
  // ── Types ─────────────────────────────────────────────────────────────
495
521
  export type ClientStatus = "initializing" | "running" | "error" | "shutdown";
496
522
 
@@ -591,6 +617,13 @@ export class LspClient {
591
617
  });
592
618
  }
593
619
 
620
+ private launchSelection: TypeScriptLaunch | undefined;
621
+
622
+ /** Compiler used by this generation, never re-probed while it is healthy. */
623
+ get typescriptSelection() {
624
+ return this.launchSelection?.typescript;
625
+ }
626
+
594
627
  get status(): ClientStatus {
595
628
  return this._status;
596
629
  }
@@ -670,8 +703,14 @@ export class LspClient {
670
703
  // ── Lifecycle ───────────────────────────────────────────────────────
671
704
  /** Spawn the server process and perform the initialize handshake. */
672
705
  async start(): Promise<void> {
673
- const cmd = this.config.command;
674
- const args = this.config.args ?? [];
706
+ try {
707
+ this.launchSelection = resolveTypeScriptLaunch(this.name, this.config, this.root);
708
+ } catch (error) {
709
+ this.handleProcessFailure(error instanceof Error ? error : new Error(String(error)));
710
+ throw error;
711
+ }
712
+ const { command: cmd = this.config.command, args = this.config.args ?? [] } =
713
+ this.launchSelection;
675
714
  this.startedAt = Date.now();
676
715
 
677
716
  try {
@@ -712,19 +751,24 @@ export class LspClient {
712
751
  });
713
752
  this.rpc.onRequest((method, params) => this.handleServerRequest(method, params));
714
753
 
715
- // Handle crashes
754
+ // Handle crashes. A failing initialize reports its own detailed failure.
755
+ let reportingStartupFailure = false;
716
756
  this.process.on("exit", (_code) => {
717
- this.handleProcessFailure(new Error("Client crashed"));
757
+ if (!reportingStartupFailure) this.handleProcessFailure(new Error("Client crashed"));
718
758
  });
719
759
 
720
760
  this.process.on("error", (_err) => {
721
- this.handleProcessFailure(new Error("Client process error"));
761
+ if (!reportingStartupFailure) this.handleProcessFailure(new Error("Client process error"));
722
762
  });
723
763
 
724
764
  // Capture a bounded stderr tail (discarded otherwise) so startup
725
765
  // failures can surface the server's own exit reason — expired builds,
726
766
  // missing runtimes, or invalid flags. Stderr never reaches the agent.
727
767
  let stderrTail = "";
768
+ let stdioClosed = false;
769
+ this.process.once("close", () => {
770
+ stdioClosed = true;
771
+ });
728
772
  this.process.stderr?.on("data", (chunk: Buffer | string) => {
729
773
  stderrTail = `${stderrTail}${chunk}`.slice(-STDERR_TAIL_MAX_CHARS);
730
774
  });
@@ -741,7 +785,7 @@ export class LspClient {
741
785
  },
742
786
  ],
743
787
  capabilities: CLIENT_CAPABILITIES,
744
- initializationOptions: this.config.initializationOptions,
788
+ initializationOptions: this.launchSelection.initializationOptions,
745
789
  })) as InitializeResult;
746
790
 
747
791
  const positionEncoding = result.capabilities.positionEncoding ?? "utf-16";
@@ -763,12 +807,16 @@ export class LspClient {
763
807
 
764
808
  this.armNoProgressTimer();
765
809
  } catch (err) {
810
+ const child = this.process;
811
+ reportingStartupFailure = true;
812
+ killFailedStartupProcess(child);
813
+ // Let already-written stderr drain before reporting the failure.
814
+ await waitForStdioClose(child, () => stdioClosed, STARTUP_FAILURE_DRAIN_MS);
766
815
  const stderrDetail = stderrTail.trim() ? `\n${this.name} stderr:\n${stderrTail.trim()}` : "";
767
816
  const failure = new Error(`${this.name}: initialize failed: ${err}${stderrDetail}`, {
768
817
  cause: err,
769
818
  });
770
819
  this.handleProcessFailure(failure);
771
- killFailedStartupProcess(this.process);
772
820
  throw failure;
773
821
  }
774
822
  }
@@ -865,6 +913,33 @@ export class LspClient {
865
913
  }
866
914
 
867
915
  // ── Document Synchronization and Diagnostics ────────────────────────
916
+ /**
917
+ * Admit a first open between semantic attempts, reading content after the wait.
918
+ * Semantic/refactor callers protect their original inputs; ordinary tracking
919
+ * can adopt fresh content after a known change instead.
920
+ */
921
+ async openDocument(
922
+ filePath: string,
923
+ readContent: () => string,
924
+ control?: CodeRequestControl,
925
+ protectInputs = false,
926
+ ): Promise<void> {
927
+ const trackedCount = this.openFiles.length;
928
+ await this.diagnostics.openDocument(filePath, readContent, control, protectInputs);
929
+ this.publishTrackedFileChange(trackedCount);
930
+ }
931
+
932
+ /** Restore retained state without resurrecting a closed or replaced document. */
933
+ async restoreDocument(
934
+ filePath: string,
935
+ readContent: () => string,
936
+ isCurrent: () => boolean,
937
+ ): Promise<void> {
938
+ const trackedCount = this.openFiles.length;
939
+ await this.diagnostics.restoreDocument(filePath, readContent, isCurrent);
940
+ this.publishTrackedFileChange(trackedCount);
941
+ }
942
+
868
943
  /** Open a document, or update it when it is already open. */
869
944
  didOpen(filePath: string, content: string): void {
870
945
  const trackedCount = this.openFiles.length;
@@ -1147,6 +1222,23 @@ export class LspClient {
1147
1222
  );
1148
1223
  }
1149
1224
 
1225
+ /**
1226
+ * Report whether the server placed an open file in a configured project.
1227
+ * Only the TypeScript route can answer; other routes return `unknown`.
1228
+ */
1229
+ async projectMembership(
1230
+ filePath: string,
1231
+ control?: CodeRequestControl,
1232
+ ): Promise<ProjectMembership> {
1233
+ if (!this.hasTypeScriptRequestCommand()) return "unknown";
1234
+ const result = await this.query<unknown>(
1235
+ "workspace/executeCommand",
1236
+ typeScriptProjectInfoParams(fileToUri(filePath)),
1237
+ control,
1238
+ );
1239
+ return result.kind === "completed" ? readTypeScriptProjectMembership(result.data) : "unknown";
1240
+ }
1241
+
1150
1242
  async workspaceSymbol(
1151
1243
  query: string,
1152
1244
  control?: CodeRequestControl,
@@ -1244,6 +1336,7 @@ export class LspClient {
1244
1336
  | undefined;
1245
1337
  },
1246
1338
  {
1339
+ documentUri: params.textDocument.uri,
1247
1340
  inputRevision: options.inputRevision,
1248
1341
  onVerifiedSnapshot: (snapshot) => {
1249
1342
  inputRevision = snapshot.revision;
@@ -1273,8 +1366,10 @@ export class LspClient {
1273
1366
  context: CodeActionContext,
1274
1367
  control?: CodeRequestControl,
1275
1368
  ): Promise<CodeQueryResult<CodeAction[] | null>> {
1369
+ const backend = this.typescriptSelection?.backend;
1370
+ const server = `LSP server "${this.name}"${backend ? ` (${backend} backend)` : ""}`;
1276
1371
  if (!this.supportsCodeActions) {
1277
- return unavailableCodeQuery("The server does not advertise textDocument/codeAction.");
1372
+ return unavailableCodeQuery(`${server} does not advertise textDocument/codeAction.`);
1278
1373
  }
1279
1374
  if (typeof filePath !== "string" || filePath.length === 0 || !isRange(range)) {
1280
1375
  return unavailableCodeQuery("Code actions require a valid file and range.");
@@ -1283,7 +1378,9 @@ export class LspClient {
1283
1378
  return unavailableCodeQuery("Code actions require a valid context.");
1284
1379
  }
1285
1380
  if (context.only && !this.supportsCodeActionKinds(context.only)) {
1286
- return unavailableCodeQuery("The server does not advertise the requested code-action kind.");
1381
+ return unavailableCodeQuery(
1382
+ `${server} does not advertise the requested code-action kind: ${context.only.join(", ")}.`,
1383
+ );
1287
1384
  }
1288
1385
 
1289
1386
  const requestContext: CodeActionContext = {
@@ -1291,17 +1388,22 @@ export class LspClient {
1291
1388
  triggerKind: context.triggerKind ?? CodeActionTriggerKind.Invoked,
1292
1389
  ...(context.only ? { only: [...context.only] } : {}),
1293
1390
  };
1294
- return this.runRefactorTransaction("textDocument/codeAction", control, async (transaction) => {
1295
- const raw = await transaction.rpc.sendRequest(
1296
- "textDocument/codeAction",
1297
- { textDocument: { uri: fileToUri(filePath) }, range, context: requestContext },
1298
- control,
1299
- );
1300
- if (raw === null || raw === undefined) return null;
1301
- if (!Array.isArray(raw)) throw new Error("LSP server returned malformed code actions.");
1391
+ return this.runRefactorTransaction(
1392
+ "textDocument/codeAction",
1393
+ control,
1394
+ async (transaction) => {
1395
+ const raw = await transaction.rpc.sendRequest(
1396
+ "textDocument/codeAction",
1397
+ { textDocument: { uri: fileToUri(filePath) }, range, context: requestContext },
1398
+ control,
1399
+ );
1400
+ if (raw === null || raw === undefined) return null;
1401
+ if (!Array.isArray(raw)) throw new Error("LSP server returned malformed code actions.");
1302
1402
 
1303
- return this.resolveCodeActions(raw, transaction, control, requestContext.only);
1304
- });
1403
+ return this.resolveCodeActions(raw, transaction, control, requestContext.only);
1404
+ },
1405
+ { documentUri: fileToUri(filePath) },
1406
+ );
1305
1407
  }
1306
1408
 
1307
1409
  async implementation(
@@ -1366,9 +1468,10 @@ export class LspClient {
1366
1468
  control: CodeRequestControl | undefined,
1367
1469
  operation: (transaction: RefactorTransaction) => Promise<T | null | undefined>,
1368
1470
  options: {
1471
+ readonly documentUri: string;
1369
1472
  readonly onVerifiedSnapshot?: (snapshot: SemanticInputSnapshot) => void;
1370
1473
  readonly inputRevision?: number;
1371
- } = {},
1474
+ },
1372
1475
  ): Promise<CodeQueryResult<T | null>> {
1373
1476
  throwIfCodeRequestInterrupted(control);
1374
1477
  if (!this.rpc || this._status !== "running") {
@@ -1387,6 +1490,7 @@ export class LspClient {
1387
1490
  retryGuard.assertCurrent();
1388
1491
  const data = await this.runRefactorTransactionAttempt({
1389
1492
  method,
1493
+ documentUri: options.documentUri,
1390
1494
  control,
1391
1495
  operation,
1392
1496
  retryGuard,
@@ -1428,9 +1532,10 @@ export class LspClient {
1428
1532
  }
1429
1533
  }
1430
1534
 
1431
- /** Run the complete prepare/request/resolve transaction for one input snapshot. */
1535
+ /** Admit one prepare/request/resolve attempt, not retries or rename's own enrollment. */
1432
1536
  private async runRefactorTransactionAttempt<T>(options: {
1433
1537
  method: string;
1538
+ documentUri: string;
1434
1539
  control?: CodeRequestControl;
1435
1540
  operation: (transaction: RefactorTransaction) => Promise<T | null | undefined>;
1436
1541
  retryGuard: SemanticInputRequestRetryGuard;
@@ -1441,27 +1546,36 @@ export class LspClient {
1441
1546
  throwIfCodeRequestInterrupted(control);
1442
1547
  retryGuard.assertCurrent();
1443
1548
  await this.getReady(control);
1444
- const inputSnapshot = await this.diagnostics.synchronizeSemanticInputs(control);
1445
- attempt.inputRevision = inputSnapshot.revision;
1446
- throwIfCodeRequestInterrupted(control);
1447
- retryGuard.assertCurrent();
1448
- const rpc = this.rpc;
1449
- if (!rpc || this._status !== "running") {
1450
- throw new Error(`LSP request ${method} is unavailable because the client is not running.`);
1451
- }
1452
- const data = await operation({
1453
- rpc,
1454
- assertCurrent: async () => {
1549
+ return this.diagnostics.withSemanticRequest(
1550
+ async () => {
1455
1551
  retryGuard.assertCurrent();
1456
- return this.diagnostics.assertSemanticInputsCurrent(inputSnapshot, control);
1552
+ const inputSnapshot = await this.diagnostics.synchronizeSemanticInputs(control);
1553
+ attempt.inputRevision = inputSnapshot.revision;
1554
+ throwIfCodeRequestInterrupted(control);
1555
+ retryGuard.assertCurrent();
1556
+ const rpc = this.rpc;
1557
+ if (!rpc || this._status !== "running") {
1558
+ throw new Error(
1559
+ `LSP request ${method} is unavailable because the client is not running.`,
1560
+ );
1561
+ }
1562
+ const data = await operation({
1563
+ rpc,
1564
+ assertCurrent: async () => {
1565
+ retryGuard.assertCurrent();
1566
+ return this.diagnostics.assertSemanticInputsCurrent(inputSnapshot, control);
1567
+ },
1568
+ });
1569
+ const verifiedSnapshot = await this.diagnostics.assertSemanticInputsCurrent(
1570
+ inputSnapshot,
1571
+ control,
1572
+ );
1573
+ onVerifiedSnapshot?.(verifiedSnapshot);
1574
+ return data ?? null;
1457
1575
  },
1458
- });
1459
- const verifiedSnapshot = await this.diagnostics.assertSemanticInputsCurrent(
1460
- inputSnapshot,
1461
1576
  control,
1577
+ options.documentUri,
1462
1578
  );
1463
- onVerifiedSnapshot?.(verifiedSnapshot);
1464
- return data ?? null;
1465
1579
  }
1466
1580
 
1467
1581
  private async query<T>(
@@ -1633,17 +1747,28 @@ export class LspClient {
1633
1747
  throwIfCodeRequestInterrupted(control);
1634
1748
  retryGuard.assertCurrent();
1635
1749
  await this.getReady(control);
1636
- const inputSnapshot = await this.diagnostics.synchronizeSemanticInputs(control);
1637
- attempt.inputRevision = inputSnapshot.revision;
1638
- throwIfCodeRequestInterrupted(control);
1639
- retryGuard.assertCurrent();
1640
- const rpc = this.rpc;
1641
- if (!rpc || this._status !== "running") {
1642
- throw new Error(`LSP request ${method} is unavailable because the client is not running.`);
1643
- }
1644
- const data = (await rpc.sendRequest(method, params, control)) as T | null | undefined;
1645
- await this.diagnostics.assertSemanticInputsCurrent(inputSnapshot, control);
1646
- return data ?? null;
1750
+ // Local query methods construct the standard TextDocumentIdentifier envelope.
1751
+ const documentUri = (params as { textDocument?: { uri: string } } | null)?.textDocument?.uri;
1752
+ return this.diagnostics.withSemanticRequest(
1753
+ async () => {
1754
+ retryGuard.assertCurrent();
1755
+ const inputSnapshot = await this.diagnostics.synchronizeSemanticInputs(control);
1756
+ attempt.inputRevision = inputSnapshot.revision;
1757
+ throwIfCodeRequestInterrupted(control);
1758
+ retryGuard.assertCurrent();
1759
+ const rpc = this.rpc;
1760
+ if (!rpc || this._status !== "running") {
1761
+ throw new Error(
1762
+ `LSP request ${method} is unavailable because the client is not running.`,
1763
+ );
1764
+ }
1765
+ const data = (await rpc.sendRequest(method, params, control)) as T | null | undefined;
1766
+ await this.diagnostics.assertSemanticInputsCurrent(inputSnapshot, control);
1767
+ return data ?? null;
1768
+ },
1769
+ control,
1770
+ documentUri,
1771
+ );
1647
1772
  }
1648
1773
 
1649
1774
  private handleServerRequest(method: string, params: unknown): unknown {
@@ -3,10 +3,16 @@
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { loadSupiConfigForScope, loadSupiConfigSectionForScope } from "@mrclrchtr/supi-core/config";
6
+ import {
7
+ getSupiConfigPath,
8
+ loadSupiConfigForScope,
9
+ loadSupiConfigSectionForScope,
10
+ } from "@mrclrchtr/supi-core/config";
7
11
  import { truncateDebugIdentity } from "@mrclrchtr/supi-core/debug";
8
12
  import type { JsonObject, JsonValue, LspConfig, ServerConfig } from "./types.ts";
9
13
 
14
+ import { isManualTypeScriptLaunch, isTypeScriptPackageName } from "./typescript-launch.ts";
15
+
10
16
  const CONFIG_DIR = path.dirname(fileURLToPath(import.meta.url));
11
17
 
12
18
  // Load defaults at module level — resolve relative to this file.
@@ -65,11 +71,47 @@ export function loadConfig(cwd: string, options?: LoadConfigOptions): LspConfig
65
71
  { scope: "project" },
66
72
  );
67
73
 
74
+ const fields: NonNullable<ServerConfig["typescriptExplicitFields"]> = {};
75
+ for (const [scope, servers] of [
76
+ ["global", globalLsp.servers],
77
+ ["project", projectLsp.servers],
78
+ ] as const) {
79
+ captureTypeScriptOrigins(fields, servers?.typescript, getSupiConfigPath(scope, cwd, options));
80
+ }
68
81
  const merged = mergeServerConfigs(defaults.servers, globalLsp.servers, projectLsp.servers);
69
-
82
+ if (merged.typescript) {
83
+ merged.typescript = { ...merged.typescript, typescriptExplicitFields: fields };
84
+ isManualTypeScriptLaunch(merged.typescript, cwd);
85
+ }
70
86
  return { servers: merged };
71
87
  }
72
88
 
89
+ /** Retain only effective explicit launch fields; initializationOptions replaces its inherited object. */
90
+ function captureTypeScriptOrigins(
91
+ fields: NonNullable<ServerConfig["typescriptExplicitFields"]>,
92
+ value: unknown,
93
+ origin: string,
94
+ ): void {
95
+ if (!isRecord(value)) return;
96
+ if (
97
+ Object.hasOwn(value, "typescriptPackage") &&
98
+ !isTypeScriptPackageName(value.typescriptPackage)
99
+ ) {
100
+ throw new Error(
101
+ `Invalid lsp.servers.typescript.typescriptPackage in ${origin}: use an installed package name or alias, not a path.`,
102
+ );
103
+ }
104
+ const { override } = validateServerOverride(value);
105
+ for (const field of ["command", "args", "typescriptPackage"] as const) {
106
+ if (Object.hasOwn(override, field)) fields[field] = origin;
107
+ }
108
+ if (!Object.hasOwn(override, "initializationOptions")) return;
109
+ delete fields.tsserverPath;
110
+ const init = override.initializationOptions;
111
+ if (isRecord(init) && isRecord(init.tsserver) && Object.hasOwn(init.tsserver, "path"))
112
+ fields.tsserverPath = origin;
113
+ }
114
+
73
115
  /**
74
116
  * Read effective explicit language disablement for the active LSP snapshot.
75
117
  * Project values override global values, including an explicit re-enable.
@@ -205,6 +247,10 @@ function mergeSingleServer(options: {
205
247
  }): ServerConfig | null {
206
248
  const { name, base, value, builtIn, reportWarning } = options;
207
249
  const { override, invalidFields } = validateServerOverride(value);
250
+ if (name !== "typescript" && override.typescriptPackage !== undefined) {
251
+ delete override.typescriptPackage;
252
+ invalidFields.push("typescriptPackage");
253
+ }
208
254
  const missingFields = base
209
255
  ? []
210
256
  : [
@@ -232,6 +278,11 @@ function mergeSingleServer(options: {
232
278
  type ServerOverrideSetter = (override: Partial<ServerConfig>, candidate: unknown) => boolean;
233
279
 
234
280
  const SERVER_OVERRIDE_SETTERS: Record<string, ServerOverrideSetter> = {
281
+ typescriptPackage(override, candidate) {
282
+ if (!isTypeScriptPackageName(candidate)) return false;
283
+ override.typescriptPackage = candidate;
284
+ return true;
285
+ },
235
286
  command(override, candidate) {
236
287
  if (typeof candidate !== "string" || candidate.trim().length === 0) return false;
237
288
  override.command = candidate;
@@ -26,12 +26,15 @@ export type ServerOperationSupportMap = Partial<
26
26
  Record<ServerOperationName, ServerOperationSupport>
27
27
  >;
28
28
 
29
+ const REMOVE_UNUSED_KIND = "source.removeUnused";
30
+ const REMOVE_UNUSED_IMPORTS_KIND = "source.removeUnusedImports";
31
+
29
32
  /** Code-action kinds requested by each operation-specific planner. */
30
33
  export const REFACTOR_CODE_ACTION_KINDS = {
31
34
  extract_function: ["refactor.extract.function"],
32
35
  extract_variable: ["refactor.extract.constant", "refactor.extract.variable"],
33
36
  update_imports: ["source.organizeImports"],
34
- delete_dead_code: ["source.removeUnused"],
37
+ delete_dead_code: [REMOVE_UNUSED_KIND, REMOVE_UNUSED_IMPORTS_KIND],
35
38
  } as const satisfies Record<
36
39
  "extract_function" | "extract_variable" | "update_imports" | "delete_dead_code",
37
40
  readonly string[]
@@ -143,6 +146,20 @@ function supportedOperation(
143
146
  return { server, supi, ...(reason ? { reason } : {}) };
144
147
  }
145
148
 
149
+ function deleteDeadCodeSupport(capabilities: ServerCapabilities | null): ServerOperationSupport {
150
+ const server = codeActionAdvertisement(capabilities, REFACTOR_CODE_ACTION_KINDS.delete_dead_code);
151
+ const importsOnly =
152
+ codeActionAdvertisement(capabilities, [REMOVE_UNUSED_KIND]) !== "advertised" &&
153
+ codeActionAdvertisement(capabilities, [REMOVE_UNUSED_IMPORTS_KIND]) === "advertised";
154
+ return importsOnly
155
+ ? supportedOperation(
156
+ server,
157
+ "limited",
158
+ "Only unused-import removal is supported by this mapping, not arbitrary unused declarations.",
159
+ )
160
+ : supportedOperation(server);
161
+ }
162
+
146
163
  /** Build the honest operation-support projection for one negotiated server. */
147
164
  export function getServerOperationSupport(
148
165
  capabilities: ServerCapabilities | null,
@@ -181,9 +198,7 @@ export function getServerOperationSupport(
181
198
  update_imports: supportedOperation(
182
199
  codeActionAdvertisement(capabilities, REFACTOR_CODE_ACTION_KINDS.update_imports),
183
200
  ),
184
- delete_dead_code: supportedOperation(
185
- codeActionAdvertisement(capabilities, REFACTOR_CODE_ACTION_KINDS.delete_dead_code),
186
- ),
201
+ delete_dead_code: deleteDeadCodeSupport(capabilities),
187
202
  code_action_resolve: supportedOperation(resolveServer),
188
203
  };
189
204
  }
@@ -26,6 +26,20 @@ export interface ServerConfig {
26
26
  initializationOptions?: unknown;
27
27
  /** Maximum time to wait for a single $/progress cycle, in ms. Default: 10_000. */
28
28
  readinessTimeoutMs?: number;
29
+ /** TypeScript compiler package name or installed alias, resolved from the route root. */
30
+ typescriptPackage?: string;
31
+ /** @internal Effective explicit-field origins for TypeScript launch policy, not user configuration. */
32
+ typescriptExplicitFields?: Partial<
33
+ Record<"command" | "args" | "tsserverPath" | "typescriptPackage", string>
34
+ >;
35
+ }
36
+
37
+ /** Compiler selection facts; absent versions are unknown, not inferred from the wrapper. */
38
+ export interface TypeScriptSelection {
39
+ backend: "tsserver" | "native";
40
+ packageName: string;
41
+ compilerVersion?: string;
42
+ wrapperVersion?: string;
29
43
  }
30
44
 
31
45
  /** LSP configuration keyed by language name (e.g. `typescript`, `python`). */
@@ -37,6 +51,10 @@ export interface DetectedProjectServer {
37
51
  name: string;
38
52
  root: string;
39
53
  fileTypes: string[];
54
+ /** Resolved automatic compiler selection, when available. */
55
+ typescript?: TypeScriptSelection;
56
+ /** Actionable launch-selection failure, including its root and package. */
57
+ failureMessage?: string;
40
58
  }
41
59
 
42
60
  /** Structured reason for a route-level process-crash recovery state. */
@@ -64,4 +82,8 @@ export interface MissingServer {
64
82
  command: string;
65
83
  /** File extensions found in the project (subset of server.fileTypes). */
66
84
  foundExtensions: string[];
85
+ /** Route root when a compiler-selection failure prevents launch. */
86
+ root?: string;
87
+ /** Compiler selection failure, distinct from a missing executable. */
88
+ failureMessage?: string;
67
89
  }