@mrclrchtr/supi-lsp 10.0.0 → 11.0.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 (33) hide show
  1. package/README.md +88 -2
  2. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/LICENSE +21 -0
  3. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/README.md +743 -0
  4. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/index.js +17 -0
  5. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/constants.js +184 -0
  6. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/parse.js +1425 -0
  7. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/picomatch.js +361 -0
  8. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/scan.js +403 -0
  9. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/utils.js +72 -0
  10. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/package.json +83 -0
  11. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/posix.js +3 -0
  12. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +4 -1
  13. package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +11 -0
  14. package/node_modules/@mrclrchtr/supi-code-runtime/src/file-associations.ts +190 -0
  15. package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +22 -0
  16. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  17. package/package.json +3 -3
  18. package/src/api.ts +1 -0
  19. package/src/client/client-diagnostic-host.ts +3 -0
  20. package/src/client/client-diagnostics.ts +6 -4
  21. package/src/client/client-request-enrollment.ts +66 -22
  22. package/src/client/client-semantic-input-barrier.ts +37 -5
  23. package/src/client/client.ts +136 -43
  24. package/src/manager/manager.ts +261 -22
  25. package/src/provider/refactor-planning.ts +13 -0
  26. package/src/provider/semantic-edit-discovery.ts +151 -0
  27. package/src/provider/semantic-edit-normalizer.ts +97 -129
  28. package/src/provider/semantic-edit-protocol.ts +132 -0
  29. package/src/session/runtime-controller.ts +8 -1
  30. package/src/session/runtime-registry.ts +7 -20
  31. package/src/session/runtime-rename.ts +172 -0
  32. package/src/session/workspace-lsp-runtime.ts +14 -0
  33. package/src/utils.ts +12 -11
@@ -3,6 +3,7 @@
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import {
6
+ type CFamilyFileAssociationResolver,
6
7
  type CodeQueryResult,
7
8
  type CodeRequestControl,
8
9
  isCodeRequestInterruption,
@@ -69,6 +70,7 @@ import {
69
70
  scanWorkspaceSources as scanAutomaticWorkspaceSources,
70
71
  type WorkspaceSourceInventory,
71
72
  } from "../diagnostics/workspace-sources.ts";
73
+ import { MAX_RENAME_DOCUMENTS } from "../provider/semantic-edit-discovery.ts";
72
74
  import { raceRequestControl } from "../session/readiness.ts";
73
75
  import {
74
76
  emptyProcessCrashRecoveryReport,
@@ -175,6 +177,57 @@ type FileRoute = {
175
177
  key: string;
176
178
  };
177
179
 
180
+ function validateRenameEnrollmentFiles(
181
+ client: LspClient,
182
+ files: readonly string[],
183
+ routeKey: string,
184
+ routeForFile: (file: string) => string | null,
185
+ ): string | null {
186
+ if (files.length > MAX_RENAME_DOCUMENTS) {
187
+ return "Rename affects too many files to synchronize safely.";
188
+ }
189
+ if (files.length === 0) return "Rename returned no text-edit documents.";
190
+ let canonicalRoot: string;
191
+ try {
192
+ canonicalRoot = fs.realpathSync(client.root);
193
+ if (!fs.statSync(canonicalRoot).isDirectory()) throw new Error("not a directory");
194
+ } catch {
195
+ return "The semantic route root is not available.";
196
+ }
197
+ for (const file of files) {
198
+ if (!path.isAbsolute(file) || path.normalize(file) !== file) {
199
+ return `Rename target "${file}" is not a normalized path.`;
200
+ }
201
+ const pathError = validateRenameTargetFile(file, canonicalRoot);
202
+ if (pathError) return pathError;
203
+ if (routeForFile(file) !== routeKey) {
204
+ return `Rename target "${file}" uses another semantic route.`;
205
+ }
206
+ }
207
+ return null;
208
+ }
209
+
210
+ function validateRenameTargetFile(file: string, canonicalRoot: string): string | null {
211
+ let canonicalFile: string;
212
+ try {
213
+ const canonicalParent = fs.realpathSync(path.dirname(file));
214
+ if (!fs.lstatSync(file).isFile()) {
215
+ return `Rename target "${file}" is not a regular file.`;
216
+ }
217
+ canonicalFile = fs.realpathSync(file);
218
+ if (
219
+ path.resolve(canonicalFile) !== path.resolve(path.join(canonicalParent, path.basename(file)))
220
+ ) {
221
+ return `Rename target "${file}" resolves through a file link.`;
222
+ }
223
+ } catch {
224
+ return `Rename target "${file}" is not a readable file.`;
225
+ }
226
+ return projectRoots.isWithinOrEqual(canonicalRoot, canonicalFile)
227
+ ? null
228
+ : `Rename target "${file}" is outside the semantic route root.`;
229
+ }
230
+
178
231
  type ClientRestartResult = {
179
232
  key: string;
180
233
  serverName: string;
@@ -182,7 +235,10 @@ type ClientRestartResult = {
182
235
  restarted: boolean;
183
236
  };
184
237
 
185
- export type ManagerLifecycleTransitionKind = LspClientLifecycleTransitionKind | "recovery";
238
+ export type ManagerLifecycleTransitionKind =
239
+ | LspClientLifecycleTransitionKind
240
+ | "recovery"
241
+ | "retirement";
186
242
 
187
243
  /** Package-internal aggregate lifecycle snapshot for the runtime controller. */
188
244
  export interface ManagerLifecycleTransition {
@@ -324,6 +380,12 @@ export class LspManager {
324
380
  private commandAvailability = new Map<string, boolean>();
325
381
  /** Guards against concurrent client creation for the same server:root key. */
326
382
  private pendingStarts = new Map<string, Promise<LspClient | null>>();
383
+ /** Prevent retired route owners from publishing work after root removal. */
384
+ private routeRetirementGenerations = new Map<string, number>();
385
+ /** Retired route keys stay blocked until the same path is again a directory. */
386
+ private retiredRoutes = new Set<string>();
387
+ /** Runtime-owned cleanup for clients removed by route retirement. */
388
+ private retiredClientShutdowns = new Set<Promise<void>>();
327
389
  /** Shared diagnostic-recovery restart for each replaceable client route. */
328
390
  private pendingRestarts = new Map<string, Promise<{ files: string[]; restarted: boolean }>>();
329
391
  /** Monotonic ownership generation for each replaceable client route. */
@@ -351,6 +413,7 @@ export class LspManager {
351
413
  /** Coalesce per-file tracking transitions into one bulk transition. */
352
414
  private trackedFilesBatchDepth = 0;
353
415
  private trackedFilesBatchChanged = false;
416
+ // biome-ignore lint/complexity/useMaxParams: the host passes one fixed association snapshot with the existing runtime dependencies
354
417
  constructor(
355
418
  private readonly config: LspConfig,
356
419
  private readonly cwd: string,
@@ -358,6 +421,7 @@ export class LspManager {
358
421
  private readonly automaticPathPolicy: AutomaticLspPathPolicy = createDefaultAutomaticLspPathPolicy(
359
422
  cwd,
360
423
  ),
424
+ private readonly fileAssociations?: CFamilyFileAssociationResolver,
361
425
  ) {}
362
426
  getCwd(): string {
363
427
  return this.cwd;
@@ -480,6 +544,7 @@ export class LspManager {
480
544
  ): Promise<LspClient | null> {
481
545
  throwIfCodeRequestInterrupted(options.control);
482
546
  if (this.shuttingDown) return null;
547
+ if (options.recoverProcessCrash) this.retireMissingProjectRoots();
483
548
  const route = this.resolveFileRoute(filePath);
484
549
  if (!route) return null;
485
550
 
@@ -505,6 +570,8 @@ export class LspManager {
505
570
  if (!serverConfig) return null;
506
571
  const key = clientKey(serverName, root);
507
572
  const retryFailedStart = options.retryFailedStart === true;
573
+ if (this.shouldBlockRetiredRoute(key, root, retryFailedStart)) return null;
574
+ const routeGeneration = this.getRouteRetirementGeneration(key);
508
575
  if (retryFailedStart) this.unavailable.delete(key);
509
576
  if (!retryFailedStart && this.getUnavailableReason(key, serverConfig.command)) return null;
510
577
 
@@ -540,7 +607,10 @@ export class LspManager {
540
607
  const pending = this.pendingStarts.get(key);
541
608
  if (pending) return raceRequestControl(pending, control);
542
609
 
543
- const startPromise = this.performStart(serverName, serverConfig, root, key);
610
+ const startPromise = this.performStart(
611
+ { serverName, serverConfig, root, key },
612
+ routeGeneration,
613
+ );
544
614
  this.pendingStarts.set(key, startPromise);
545
615
  // Keep the shared startup registered until it settles. A cancelled
546
616
  // caller only stops waiting; it must not allow a duplicate route start.
@@ -569,6 +639,7 @@ export class LspManager {
569
639
  this.handleClientLifecycle(key, generation, client, kind);
570
640
  },
571
641
  this.cwd,
642
+ this.fileAssociations,
572
643
  );
573
644
  return client;
574
645
  }
@@ -672,6 +743,7 @@ export class LspManager {
672
743
  control?: CodeRequestControl,
673
744
  ): Promise<ProcessCrashDemandResult> {
674
745
  throwIfCodeRequestInterrupted(control);
746
+ this.retireMissingProjectRoots();
675
747
  const scopes = this.processCrashDiagnosticScopes(demand);
676
748
  const candidates = this.selectExplicitRouteCandidates(scopes);
677
749
  return this.withExplicitRouteAttempts(candidates, control, async (results) => {
@@ -850,7 +922,7 @@ export class LspManager {
850
922
  callback: (results: ExplicitRouteAttemptResult[]) => Promise<T>,
851
923
  ): Promise<T> {
852
924
  throwIfCodeRequestInterrupted(control);
853
- const leased: string[] = [];
925
+ const leased: Array<{ key: string; lease: ExplicitRouteAttemptLease }> = [];
854
926
  try {
855
927
  const promises = candidates.map((candidate) => {
856
928
  throwIfCodeRequestInterrupted(control);
@@ -858,7 +930,7 @@ export class LspManager {
858
930
  const existing = this.explicitRouteAttempts.get(key);
859
931
  if (existing) {
860
932
  existing.users++;
861
- leased.push(key);
933
+ leased.push({ key, lease: existing });
862
934
  return raceRequestControl(existing.promise, control);
863
935
  }
864
936
  const promise = this.performExplicitRouteAttempt(candidate);
@@ -872,17 +944,17 @@ export class LspManager {
872
944
  }
873
945
  })
874
946
  .catch(() => {});
875
- leased.push(key);
947
+ leased.push({ key, lease });
876
948
  return raceRequestControl(promise, control);
877
949
  });
878
950
  const results = await Promise.all(promises);
879
951
  return await callback(results);
880
952
  } finally {
881
- for (const key of leased) {
882
- const lease = this.explicitRouteAttempts.get(key);
883
- if (!lease) continue;
953
+ for (const { key, lease } of leased) {
884
954
  lease.users--;
885
- if (lease.users === 0 && lease.settled) this.explicitRouteAttempts.delete(key);
955
+ if (lease.users === 0 && lease.settled && this.explicitRouteAttempts.get(key) === lease) {
956
+ this.explicitRouteAttempts.delete(key);
957
+ }
886
958
  }
887
959
  }
888
960
  }
@@ -891,11 +963,15 @@ export class LspManager {
891
963
  candidate: ExplicitRouteCandidate,
892
964
  ): Promise<ExplicitRouteAttemptResult> {
893
965
  const { route } = candidate;
966
+ const routeGeneration = this.getRouteRetirementGeneration(route.key);
894
967
  const recovery = this.processCrashRecoveries.get(route.key) ?? candidate.processCrashRecovery;
895
968
  if (recovery?.statusReason) {
896
969
  const client = await this.acquireProcessCrashReplacement(recovery, route, undefined, {
897
970
  allowExhausted: true,
898
971
  });
972
+ if (this.getRouteRetirementGeneration(route.key) !== routeGeneration) {
973
+ return { candidate, kind: "none", client: null, attempted: false };
974
+ }
899
975
  return {
900
976
  candidate,
901
977
  kind: "process-crash",
@@ -913,6 +989,9 @@ export class LspManager {
913
989
  const client = await this.startServerForRoot(route.serverName, route.root, undefined, {
914
990
  retryFailedStart: true,
915
991
  });
992
+ if (this.getRouteRetirementGeneration(route.key) !== routeGeneration) {
993
+ return { candidate, kind: "none", client: null, attempted: false };
994
+ }
916
995
  const failureMessage =
917
996
  this.failedStarts.get(route.key)?.failureMessage ?? candidate.startupFailure?.failureMessage;
918
997
  return {
@@ -975,18 +1054,20 @@ export class LspManager {
975
1054
  control?: CodeRequestControl,
976
1055
  options: { skipWhenNoRetainedFile?: boolean } = {},
977
1056
  ): Promise<ProcessCrashDemandResult> {
1057
+ throwIfCodeRequestInterrupted(control);
1058
+ this.retireMissingProjectRoots();
978
1059
  const { immediate, required } = this.selectProcessCrashDemandRoutes(
979
1060
  this.snapshotProcessCrashRoutes(),
980
1061
  selectFiles,
981
1062
  options,
982
1063
  );
983
- const hasSupport = immediate.length > 0 || required.length > 0;
1064
+ const hasSelectedSupport = immediate.length > 0 || required.length > 0;
984
1065
  if (required.length === 0) {
985
1066
  const failures = immediate
986
1067
  .filter((entry) => !entry.reportOnly)
987
1068
  .map((entry) => this.formatProcessCrashDemandFailure(entry.route, entry.route.outcome));
988
1069
  return {
989
- hasSupport,
1070
+ hasSupport: hasSelectedSupport,
990
1071
  requiresFreshEvidence: false,
991
1072
  startupRetry: emptyStartupRetryReport(),
992
1073
  processCrashRecovery: buildProcessCrashRecoveryReport(
@@ -1039,7 +1120,7 @@ export class LspManager {
1039
1120
  );
1040
1121
  const failedImmediate = immediate.filter((entry) => !entry.reportOnly);
1041
1122
  return {
1042
- hasSupport,
1123
+ hasSupport: immediate.length > 0 || observedRequired.length > 0,
1043
1124
  requiresFreshEvidence: observedRequired.length > 0,
1044
1125
  startupRetry: emptyStartupRetryReport(),
1045
1126
  processCrashRecovery: buildProcessCrashRecoveryReport(
@@ -1383,12 +1464,8 @@ export class LspManager {
1383
1464
  * Perform the actual server start — extracted so the public method can
1384
1465
  * deduplicate via pendingStarts without wrapping the entire body.
1385
1466
  */
1386
- private async performStart(
1387
- serverName: string,
1388
- serverConfig: ServerConfig,
1389
- root: string,
1390
- key: string,
1391
- ): Promise<LspClient | null> {
1467
+ private async performStart(route: FileRoute, routeGeneration: number): Promise<LspClient | null> {
1468
+ const { serverName, serverConfig, root, key } = route;
1392
1469
  // Validate command exists
1393
1470
  if (!commandExists(serverConfig.command)) {
1394
1471
  this.unavailable.set(key, "missing-command");
@@ -1409,7 +1486,7 @@ export class LspManager {
1409
1486
  rememberKnownRoot(this.knownRoots, serverName, root);
1410
1487
  try {
1411
1488
  await client.start();
1412
- if (this.shuttingDown) {
1489
+ if (!this.ownsCurrentStart(key, routeGeneration, client)) {
1413
1490
  await client.forceKill().catch(() => {});
1414
1491
  if (this.clients.get(key) === client) this.clients.delete(key);
1415
1492
  return null;
@@ -1418,6 +1495,11 @@ export class LspManager {
1418
1495
  this.failedStarts.delete(key);
1419
1496
  return client;
1420
1497
  } catch (error) {
1498
+ if (!this.ownsCurrentStart(key, routeGeneration, client)) {
1499
+ await client.forceKill().catch(() => {});
1500
+ if (this.clients.get(key) === client) this.clients.delete(key);
1501
+ return null;
1502
+ }
1421
1503
  this.unavailable.set(key, "start-failed");
1422
1504
  this.failedStarts.set(key, {
1423
1505
  serverName,
@@ -1483,8 +1565,11 @@ export class LspManager {
1483
1565
  key: string,
1484
1566
  ): Promise<{ files: string[]; restarted: boolean }> {
1485
1567
  const restartEpoch = this.invalidationEpoch;
1568
+ const routeGeneration = this.getRouteRetirementGeneration(key);
1486
1569
  const restartPromise = this.restartClient(client).then((result) => {
1487
- this.recoveryRestartEpochs.set(key, restartEpoch);
1570
+ if (this.getRouteRetirementGeneration(key) === routeGeneration) {
1571
+ this.recoveryRestartEpochs.set(key, restartEpoch);
1572
+ }
1488
1573
  return result;
1489
1574
  });
1490
1575
  this.pendingRestarts.set(key, restartPromise);
@@ -1646,6 +1731,7 @@ export class LspManager {
1646
1731
  throwIfCodeRequestInterrupted(control);
1647
1732
  const resolvedPath = resolveSessionPath(this.cwd, filePath);
1648
1733
  if (options.retryFailedRoute) {
1734
+ this.retireMissingProjectRoots();
1649
1735
  const route = this.resolveFileRoute(resolvedPath);
1650
1736
  if (!route || !this.automaticPathPolicy.isEligible(route.root, "directory")) {
1651
1737
  return {
@@ -2092,7 +2178,119 @@ export class LspManager {
2092
2178
  }
2093
2179
  /** Remove any missing files from open-document and diagnostic state. */
2094
2180
  pruneMissingFiles(): string[] {
2095
- return pruneMissingFilesFromClients(this.clients.values());
2181
+ const removed = pruneMissingFilesFromClients(this.clients.values());
2182
+ this.retireMissingProjectRoots();
2183
+ return removed;
2184
+ }
2185
+
2186
+ /** Remove route-owned state only when filesystem evidence confirms root removal. */
2187
+ private retireMissingProjectRoots(): void {
2188
+ if (this.shuttingDown) return;
2189
+ const routes = new Map<string, { serverName: string; root: string }>();
2190
+ const addRoute = (serverName: string, root: string): void => {
2191
+ routes.set(clientKey(serverName, root), { serverName, root });
2192
+ };
2193
+
2194
+ for (const [serverName, roots] of this.knownRoots) {
2195
+ for (const root of roots) addRoute(serverName, root);
2196
+ }
2197
+ for (const [key, client] of this.clients) {
2198
+ if (this.clientGenerations.has(key)) addRoute(client.name, client.root);
2199
+ }
2200
+ for (const failure of this.failedStarts.values()) {
2201
+ addRoute(failure.serverName, failure.root);
2202
+ }
2203
+ for (const recovery of this.processCrashRecoveries.values()) {
2204
+ addRoute(recovery.failedClient.name, recovery.failedClient.root);
2205
+ }
2206
+
2207
+ let retired = false;
2208
+ for (const [key, route] of routes) {
2209
+ if (!this.isMissingProjectRoot(route.root)) continue;
2210
+ this.retireProjectRoute(key, route.serverName, route.root);
2211
+ retired = true;
2212
+ }
2213
+ if (retired) this.publishLifecycle("retirement");
2214
+ }
2215
+
2216
+ private shouldBlockRetiredRoute(key: string, root: string, retryFailedStart: boolean): boolean {
2217
+ if (retryFailedStart) this.retireMissingProjectRoots();
2218
+ return this.isRetiredRoute(key, root);
2219
+ }
2220
+
2221
+ private ownsCurrentStart(key: string, generation: number, client: LspClient): boolean {
2222
+ return (
2223
+ !this.shuttingDown &&
2224
+ this.getRouteRetirementGeneration(key) === generation &&
2225
+ this.clients.get(key) === client
2226
+ );
2227
+ }
2228
+
2229
+ private isMissingProjectRoot(root: string): boolean {
2230
+ try {
2231
+ return !fs.statSync(root).isDirectory();
2232
+ } catch (error) {
2233
+ return isMissingFileError(error);
2234
+ }
2235
+ }
2236
+
2237
+ private isRetiredRoute(key: string, root: string): boolean {
2238
+ if (!this.retiredRoutes.has(key)) return false;
2239
+ try {
2240
+ if (fs.statSync(root).isDirectory()) {
2241
+ this.retiredRoutes.delete(key);
2242
+ return false;
2243
+ }
2244
+ } catch {
2245
+ // Keep the retirement marker until a directory is confirmed at this path.
2246
+ }
2247
+ return true;
2248
+ }
2249
+
2250
+ private getRouteRetirementGeneration(key: string): number {
2251
+ return this.routeRetirementGenerations.get(key) ?? 0;
2252
+ }
2253
+
2254
+ private retireProjectRoute(key: string, serverName: string, root: string): void {
2255
+ this.routeRetirementGenerations.set(key, this.getRouteRetirementGeneration(key) + 1);
2256
+ this.retiredRoutes.add(key);
2257
+ // Detach old work from key-based joins. Promise observers still own cleanup.
2258
+ // Identity checks keep old finalizers from removing replacement entries.
2259
+ this.pendingStarts.delete(key);
2260
+ this.explicitRouteAttempts.delete(key);
2261
+ this.pendingRestarts.delete(key);
2262
+ const roots = this.knownRoots.get(serverName)?.filter((knownRoot) => knownRoot !== root) ?? [];
2263
+ if (roots.length > 0) this.knownRoots.set(serverName, roots);
2264
+ else this.knownRoots.delete(serverName);
2265
+
2266
+ const client = this.clients.get(key);
2267
+ if (client) this.clients.delete(key);
2268
+ this.unavailable.delete(key);
2269
+ this.failedStarts.delete(key);
2270
+ this.processCrashRecoveries.delete(key);
2271
+ this.recoveryRestartEpochs.delete(key);
2272
+ this.clearWarmedWorkspaceSymbolFiles(serverName, root);
2273
+ this.clearWarmedSemanticProjects(serverName, root);
2274
+ this.clearPendingWarmProbes(serverName, root);
2275
+
2276
+ if (client) {
2277
+ const shutdown = this.shutdownRetiredClient(client);
2278
+ this.retiredClientShutdowns.add(shutdown);
2279
+ void shutdown.finally(() => this.retiredClientShutdowns.delete(shutdown)).catch(() => {});
2280
+ }
2281
+ }
2282
+
2283
+ private async shutdownRetiredClient(client: LspClient): Promise<void> {
2284
+ try {
2285
+ await client.shutdown();
2286
+ } catch {
2287
+ try {
2288
+ await client.forceKill();
2289
+ } catch {
2290
+ // Retired-route cleanup is best-effort.
2291
+ }
2292
+ }
2293
+ client.dispose();
2096
2294
  }
2097
2295
  /** Re-sync all open documents across active clients and return exact evidence coverage. */
2098
2296
  async refreshOpenDiagnostics(
@@ -2189,6 +2387,8 @@ export class LspManager {
2189
2387
  processCrashDemand?: { scopes?: readonly string[]; explicit?: boolean };
2190
2388
  control?: CodeRequestControl;
2191
2389
  }): Promise<WorkspaceRecoveryResult> {
2390
+ throwIfCodeRequestInterrupted(options?.control);
2391
+ this.pruneMissingFiles();
2192
2392
  const processCrashDemand = options?.processCrashDemand;
2193
2393
  const demand = processCrashDemand?.explicit
2194
2394
  ? await this.recoverExplicitRouteDemand(processCrashDemand, options?.control)
@@ -2293,7 +2493,8 @@ export class LspManager {
2293
2493
  this.shuttingDown = true;
2294
2494
  const clients = Array.from(this.clients.values());
2295
2495
  const shutdowns = clients.map((client) => client.shutdown().catch(() => {}));
2296
- await Promise.all(shutdowns);
2496
+ await Promise.all([...shutdowns, ...this.retiredClientShutdowns]);
2497
+ this.retiredClientShutdowns.clear();
2297
2498
  // Dispose every client so pending or active progress tokens and
2298
2499
  // readiness promises cannot outlive the manager.
2299
2500
  for (const client of clients) client.dispose();
@@ -2303,6 +2504,8 @@ export class LspManager {
2303
2504
  this.handledCrashGenerations.clear();
2304
2505
  this.processCrashRecoveries.clear();
2305
2506
  this.failedStarts.clear();
2507
+ this.routeRetirementGenerations.clear();
2508
+ this.retiredRoutes.clear();
2306
2509
  this.explicitRouteAttempts.clear();
2307
2510
  this.recoveryRestartEpochs.clear();
2308
2511
  this.unavailable.clear();
@@ -2765,6 +2968,42 @@ export class LspManager {
2765
2968
  );
2766
2969
  });
2767
2970
  }
2971
+ /** Enroll discovered rename documents only after one complete authority check. */
2972
+ async enrollRenameDocuments(
2973
+ client: LspClient,
2974
+ filePaths: readonly string[],
2975
+ control?: CodeRequestControl,
2976
+ ): Promise<{ kind: "enrolled" } | { kind: "unavailable"; reason: string }> {
2977
+ throwIfCodeRequestInterrupted(control);
2978
+ const files = [...new Set(filePaths)].sort();
2979
+ const routeKey = clientKey(client.name, client.root);
2980
+ if (this.clients.get(routeKey) !== client) {
2981
+ return { kind: "unavailable", reason: "The semantic route changed during rename planning." };
2982
+ }
2983
+ const validationError = validateRenameEnrollmentFiles(
2984
+ client,
2985
+ files,
2986
+ routeKey,
2987
+ (file) => this.resolveFileRoute(file)?.key ?? null,
2988
+ );
2989
+ if (validationError) return { kind: "unavailable", reason: validationError };
2990
+
2991
+ for (const file of files) {
2992
+ throwIfCodeRequestInterrupted(control);
2993
+ const openedClient = await this.ensureFileOpen(file, {
2994
+ recoverProcessCrash: true,
2995
+ control,
2996
+ });
2997
+ if (openedClient !== client) {
2998
+ return {
2999
+ kind: "unavailable",
3000
+ reason: "The semantic route changed during rename enrollment.",
3001
+ };
3002
+ }
3003
+ }
3004
+ return { kind: "enrolled" };
3005
+ }
3006
+
2768
3007
  async ensureFileOpen(
2769
3008
  filePath: string,
2770
3009
  options: FileClientRequestOptions = {},
@@ -1,3 +1,4 @@
1
+ import * as path from "node:path";
1
2
  import type {
2
3
  CodePosition,
3
4
  CodeRequestControl,
@@ -38,6 +39,18 @@ export async function runRenameRefactor(options: {
38
39
  { kind: "workspace-edit", edit: response.value },
39
40
  {
40
41
  getOpenDocumentVersion: (candidate) => lsp.getOpenDocumentVersion(candidate),
42
+ getSynchronizedDocumentSnapshot: (candidate) => {
43
+ const snapshot = response.documentSnapshots?.find(
44
+ (document) => path.resolve(document.file) === path.resolve(candidate),
45
+ );
46
+ return snapshot
47
+ ? {
48
+ version: snapshot.version,
49
+ contentFingerprint: snapshot.contentFingerprint,
50
+ diskContentFingerprint: snapshot.diskContentFingerprint,
51
+ }
52
+ : null;
53
+ },
41
54
  authorizedMutationRoots: response.authorizedMutationRoots,
42
55
  },
43
56
  );
@@ -0,0 +1,151 @@
1
+ import {
2
+ asRecord,
3
+ getResourceOperationKind,
4
+ hasNoInheritedAllowedKeys,
5
+ hasOnlyKeys,
6
+ normalizeTextEdits,
7
+ parseTextDocumentEdit,
8
+ toFilePath,
9
+ } from "./semantic-edit-protocol.ts";
10
+
11
+ export const MAX_RENAME_DOCUMENTS = 64;
12
+
13
+ type DiscoveryResult =
14
+ | { readonly kind: "valid"; readonly files: readonly string[] }
15
+ | { readonly kind: "unavailable"; readonly reason: string };
16
+
17
+ type DiscoveredDocument =
18
+ | { readonly kind: "valid"; readonly file: string; readonly editCount: number }
19
+ | { readonly kind: "unavailable"; readonly reason: string };
20
+
21
+ /** Validate a complete workspace edit and list its text-edit document paths. */
22
+ export function discoverSemanticEditFiles(value: unknown): DiscoveryResult {
23
+ const workspaceEdit = parseWorkspaceEdit(value);
24
+ if (typeof workspaceEdit === "string") {
25
+ return { kind: "unavailable", reason: workspaceEdit };
26
+ }
27
+ return Object.hasOwn(workspaceEdit, "documentChanges")
28
+ ? discoverDocumentChanges(workspaceEdit.documentChanges)
29
+ : discoverChangesMap(workspaceEdit.changes);
30
+ }
31
+
32
+ function parseWorkspaceEdit(value: unknown): Record<string, unknown> | string {
33
+ const workspaceEdit = asRecord(value);
34
+ if (
35
+ !workspaceEdit ||
36
+ !hasOnlyKeys(workspaceEdit, ["changes", "documentChanges", "changeAnnotations"]) ||
37
+ !hasNoInheritedAllowedKeys(workspaceEdit, ["changes", "documentChanges", "changeAnnotations"])
38
+ ) {
39
+ return "LSP server returned a malformed workspace edit.";
40
+ }
41
+ if (Object.hasOwn(workspaceEdit, "changeAnnotations")) {
42
+ return "Workspace edit contains unsupported change annotations.";
43
+ }
44
+ return workspaceEdit;
45
+ }
46
+
47
+ function discoverDocumentChanges(value: unknown): DiscoveryResult {
48
+ if (!Array.isArray(value)) {
49
+ return { kind: "unavailable", reason: "Workspace edit has malformed documentChanges." };
50
+ }
51
+ const files = new Set<string>();
52
+ let editCount = 0;
53
+ for (const change of value) {
54
+ const result = discoverDocumentChange(change);
55
+ if (result.kind === "unavailable") return result;
56
+ if (result.editCount > 0) {
57
+ files.add(result.file);
58
+ if (files.size > MAX_RENAME_DOCUMENTS) {
59
+ return {
60
+ kind: "unavailable",
61
+ reason: "Rename affects too many files to synchronize safely.",
62
+ };
63
+ }
64
+ editCount += result.editCount;
65
+ }
66
+ }
67
+ return finishDiscovery(files, editCount);
68
+ }
69
+
70
+ function discoverDocumentChange(value: unknown): DiscoveredDocument {
71
+ const documentEdit = asRecord(value);
72
+ const resourceKind = getResourceOperationKind(documentEdit);
73
+ if (resourceKind) {
74
+ return {
75
+ kind: "unavailable",
76
+ reason: `Workspace edit contains the unsupported "${resourceKind}" resource operation.`,
77
+ };
78
+ }
79
+ const parsed = parseTextDocumentEdit(documentEdit);
80
+ if (!parsed) {
81
+ return { kind: "unavailable", reason: "Workspace edit has a malformed document change." };
82
+ }
83
+ const uri = parsed.textDocument.uri;
84
+ const file = typeof uri === "string" ? toFilePath(uri) : null;
85
+ if (!file) {
86
+ return { kind: "unavailable", reason: "Document change does not contain a valid file URI." };
87
+ }
88
+ if (!hasValidDocumentVersion(parsed.textDocument.version, parsed.textDocument)) {
89
+ return {
90
+ kind: "unavailable",
91
+ reason: `Document change for "${file}" has an invalid version.`,
92
+ };
93
+ }
94
+ const normalized = normalizeTextEdits(file, parsed.edit.edits);
95
+ if (normalized.kind === "unavailable") return normalized;
96
+ return { kind: "valid", file, editCount: normalized.edits.length };
97
+ }
98
+
99
+ function hasValidDocumentVersion(version: unknown, textDocument: Record<string, unknown>): boolean {
100
+ return (
101
+ Object.hasOwn(textDocument, "version") &&
102
+ (version === null || (Number.isInteger(version) && (version as number) >= 0))
103
+ );
104
+ }
105
+
106
+ function discoverChangesMap(value: unknown): DiscoveryResult {
107
+ const changes = asRecord(value);
108
+ if (!changes) {
109
+ return { kind: "unavailable", reason: "Workspace edit contains no supported changes." };
110
+ }
111
+ const files = new Set<string>();
112
+ let editCount = 0;
113
+ for (const uri of Object.keys(changes).sort((left, right) => left.localeCompare(right))) {
114
+ const result = discoverChangesEntry(uri, changes[uri]);
115
+ if (result.kind === "unavailable") return result;
116
+ if (result.editCount > 0) {
117
+ files.add(result.file);
118
+ if (files.size > MAX_RENAME_DOCUMENTS) {
119
+ return {
120
+ kind: "unavailable",
121
+ reason: "Rename affects too many files to synchronize safely.",
122
+ };
123
+ }
124
+ editCount += result.editCount;
125
+ }
126
+ }
127
+ return finishDiscovery(files, editCount);
128
+ }
129
+
130
+ function discoverChangesEntry(uri: string, value: unknown): DiscoveredDocument {
131
+ const file = toFilePath(uri);
132
+ if (!file) {
133
+ return { kind: "unavailable", reason: `Workspace edit URI "${uri}" is not a file URI.` };
134
+ }
135
+ if (!Array.isArray(value)) {
136
+ return { kind: "unavailable", reason: "Workspace edit contains malformed changes." };
137
+ }
138
+ const normalized = normalizeTextEdits(file, value);
139
+ if (normalized.kind === "unavailable") return normalized;
140
+ return { kind: "valid", file, editCount: normalized.edits.length };
141
+ }
142
+
143
+ function finishDiscovery(files: Set<string>, editCount: number): DiscoveryResult {
144
+ if (files.size > MAX_RENAME_DOCUMENTS) {
145
+ return { kind: "unavailable", reason: "Rename affects too many files to synchronize safely." };
146
+ }
147
+ if (editCount === 0) {
148
+ return { kind: "unavailable", reason: "Workspace edit contains no file edits." };
149
+ }
150
+ return { kind: "valid", files: [...files].sort((left, right) => left.localeCompare(right)) };
151
+ }