@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
@@ -19,6 +19,7 @@ import {
19
19
  withTimeout,
20
20
  } from "../client/client.ts";
21
21
  import type { ClientDiagnosticSnapshot } from "../client/client-document-state.ts";
22
+ import type { ProjectMembership } from "../client/client-typescript-project.ts";
22
23
 
23
24
  export { RECOVERY_CLIENT_STARTUP_BOUND_MS } from "../client/client.ts";
24
25
 
@@ -48,6 +49,7 @@ import type {
48
49
  WorkspaceSymbol,
49
50
  } from "../config/types.ts";
50
51
  import { FileChangeType } from "../config/types.ts";
52
+ import { inspectTypeScriptLaunch } from "../config/typescript-launch.ts";
51
53
  import { boundCwd } from "../debug-telemetry.ts";
52
54
  import {
53
55
  accumulateOutstandingDiagnostics,
@@ -134,8 +136,10 @@ import {
134
136
  collectWorkspaceSymbols,
135
137
  findWorkspaceSymbolWarmTargets,
136
138
  getWorkspaceSymbolWarmPosition,
139
+ isWithinAnyScope,
137
140
  type WorkspaceSymbolCollection,
138
141
  workspaceSymbolCollectionResult,
142
+ workspaceSymbolCoverageGap,
139
143
  } from "./manager-workspace-symbol.ts";
140
144
 
141
145
  type UnavailableReason = "missing-command" | "start-failed" | "runtime-error";
@@ -250,6 +254,8 @@ export interface ManagerLifecycleTransition {
250
254
  type ManagerLifecycleListener = (transition: ManagerLifecycleTransition) => void;
251
255
 
252
256
  type FileClientRequestOptions = {
257
+ /** Reject known input changes while this call's first open waits to dispatch original parameters. */
258
+ protectInputs?: boolean;
253
259
  /** Allow this request to start the route's one process-crash recovery. */
254
260
  recoverProcessCrash?: boolean;
255
261
  /** Control only this caller's wait for a shared replacement. */
@@ -396,8 +402,12 @@ export class LspManager {
396
402
  private invalidationEpoch = 0;
397
403
  /** Invalidation generation in which each route last received a recovery restart. */
398
404
  private recoveryRestartEpochs = new Map<string, number>();
399
- /** Files already probed for workspace-symbol warm-up. */
400
- private warmedWorkspaceSymbolFiles = new Set<string>();
405
+ /**
406
+ * Files already probed for workspace-symbol warm-up, with the project
407
+ * membership the server reported. `inferred` probes stay recorded so they
408
+ * are not reopened, but they do not prove project coverage.
409
+ */
410
+ private warmedWorkspaceSymbolFiles = new Map<string, ProjectMembership>();
401
411
  /** Project roots whose semantic state was warmed with a readiness probe. */
402
412
  private warmedSemanticProjects = new Set<string>();
403
413
  /** In-flight warm-up probes keyed by project key so concurrent callers share one probe. */
@@ -433,12 +443,29 @@ export class LspManager {
433
443
  detected.filter((entry) => this.automaticPathPolicy.isEligible(entry.root, "directory")),
434
444
  );
435
445
  }
446
+ /** Check configured routing only, independently of executable availability or startup state. */
447
+ hasConfiguredServerForFile(filePath: string): boolean {
448
+ return getServerForFile(this.config, resolveSessionPath(this.cwd, filePath)) !== null;
449
+ }
450
+
451
+ /** Observe a retained initial-start failure without retrying or conflating process-crash recovery. */
452
+ hasFailedStartupForFile(filePath: string): boolean {
453
+ const route = this.resolveFileRoute(filePath);
454
+ if (!route || this.processCrashRecoveries.has(route.key)) return false;
455
+ const reason = this.unavailable.get(route.key);
456
+ return reason === "start-failed" || reason === "missing-command";
457
+ }
458
+
436
459
  /** Check whether a file path has an available LSP server for explicit semantic operations. */
437
460
  canServeFile(filePath: string): boolean {
438
461
  const route = this.resolveFileRoute(filePath);
439
462
  if (!route) return false;
440
- if (this.getUnavailableReason(route.key, route.serverConfig.command)) return false;
441
- return this.isServerCommandAvailable(route.serverConfig.command);
463
+ if (this.getUnavailableReason(route.key, route.serverName, route.root)) return false;
464
+ if (this.clients.get(route.key)?.status === "running") return true;
465
+ const launch = inspectTypeScriptLaunch(route.serverName, route.serverConfig, route.root);
466
+ // A broken selection is a route failure, not an absent executable. Let
467
+ // startup record it so lazy readiness and health expose the actual error.
468
+ return Boolean(launch.failureMessage) || this.isServerCommandAvailable(launch.command);
442
469
  }
443
470
 
444
471
  /**
@@ -517,13 +544,21 @@ export class LspManager {
517
544
  return available;
518
545
  }
519
546
 
520
- private getUnavailableReason(key: string, command?: string): UnavailableReason | null {
547
+ private getUnavailableReason(
548
+ key: string,
549
+ serverName: string,
550
+ root: string,
551
+ ): UnavailableReason | null {
521
552
  const reason = this.unavailable.get(key);
522
553
  if (!reason) return null;
523
554
 
524
- if (reason === "missing-command" && command && this.isServerCommandAvailable(command)) {
525
- this.unavailable.delete(key);
526
- return null;
555
+ const config = this.config.servers[serverName];
556
+ if (reason === "missing-command" && config) {
557
+ const launch = inspectTypeScriptLaunch(serverName, config, root);
558
+ if (!launch.failureMessage && this.isServerCommandAvailable(launch.command)) {
559
+ this.unavailable.delete(key);
560
+ return null;
561
+ }
527
562
  }
528
563
 
529
564
  return reason;
@@ -573,7 +608,7 @@ export class LspManager {
573
608
  if (this.shouldBlockRetiredRoute(key, root, retryFailedStart)) return null;
574
609
  const routeGeneration = this.getRouteRetirementGeneration(key);
575
610
  if (retryFailedStart) this.unavailable.delete(key);
576
- if (!retryFailedStart && this.getUnavailableReason(key, serverConfig.command)) return null;
611
+ if (!retryFailedStart && this.getUnavailableReason(key, serverName, root)) return null;
577
612
 
578
613
  // Return existing client
579
614
  const existing = this.clients.get(key);
@@ -1301,7 +1336,13 @@ export class LspManager {
1301
1336
  recovery.statusReason = undefined;
1302
1337
  recovery.failureMessage = undefined;
1303
1338
  this.publishLifecycle("recovery");
1304
- this.restoreTrackedDocuments(started, recovery.files);
1339
+ await this.restoreTrackedDocuments(started, recovery.files);
1340
+ if (
1341
+ this.isProcessCrashRecoveryCancelled(recovery, key) ||
1342
+ this.clients.get(key) !== started ||
1343
+ started.status !== "running"
1344
+ )
1345
+ return null;
1305
1346
  this.recordProcessCrashRecoveryEvent(
1306
1347
  route.serverName,
1307
1348
  route.root,
@@ -1386,20 +1427,16 @@ export class LspManager {
1386
1427
  }
1387
1428
 
1388
1429
  /** Restore route documents from disk and retain failed-document evidence. */
1389
- private restoreTrackedDocuments(client: LspClient, files: readonly string[]): void {
1390
- const failedFiles: string[] = [];
1430
+ private async restoreTrackedDocuments(
1431
+ client: LspClient,
1432
+ files: readonly string[],
1433
+ ): Promise<void> {
1434
+ const key = clientKey(client.name, client.root);
1435
+ const isCurrent = () => !this.shuttingDown && this.clients.get(key) === client;
1391
1436
  for (const filePath of files) {
1392
- if (!fs.existsSync(filePath)) {
1393
- failedFiles.push(filePath);
1394
- continue;
1395
- }
1396
- try {
1397
- client.didOpen(filePath, fs.readFileSync(filePath, "utf-8"));
1398
- } catch {
1399
- failedFiles.push(filePath);
1400
- }
1437
+ if (!isCurrent()) return;
1438
+ await client.restoreDocument(filePath, () => fs.readFileSync(filePath, "utf-8"), isCurrent);
1401
1439
  }
1402
- for (const filePath of failedFiles) client.markFailedFile(filePath);
1403
1440
  }
1404
1441
 
1405
1442
  private markProcessCrashRecoveryFailed(
@@ -1460,22 +1497,30 @@ export class LspManager {
1460
1497
  }
1461
1498
  }
1462
1499
 
1500
+ /** Record a root-aware selection/command failure without starting a client. */
1501
+ private recordUnavailableLaunch(route: FileRoute): boolean {
1502
+ const { serverName, serverConfig, root, key } = route;
1503
+ const launch = inspectTypeScriptLaunch(serverName, serverConfig, root);
1504
+ const command = launch.command;
1505
+ if (!launch.failureMessage && commandExists(command)) return false;
1506
+ this.unavailable.set(key, launch.failureMessage ? "start-failed" : "missing-command");
1507
+ this.failedStarts.set(key, {
1508
+ serverName,
1509
+ root,
1510
+ failureMessage:
1511
+ launch.failureMessage ?? `Configured server command is not available: ${command}`,
1512
+ });
1513
+ return true;
1514
+ }
1515
+
1463
1516
  /**
1464
1517
  * Perform the actual server start — extracted so the public method can
1465
1518
  * deduplicate via pendingStarts without wrapping the entire body.
1466
1519
  */
1467
1520
  private async performStart(route: FileRoute, routeGeneration: number): Promise<LspClient | null> {
1468
1521
  const { serverName, serverConfig, root, key } = route;
1469
- // Validate command exists
1470
- if (!commandExists(serverConfig.command)) {
1471
- this.unavailable.set(key, "missing-command");
1472
- this.failedStarts.set(key, {
1473
- serverName,
1474
- root,
1475
- failureMessage: `Configured server command is not available: ${serverConfig.command}`,
1476
- });
1477
- return null;
1478
- }
1522
+ // Client.start resolves again at actual spawn time, including every retry.
1523
+ if (this.recordUnavailableLaunch(route)) return null;
1479
1524
 
1480
1525
  // Spawn new client
1481
1526
  const client = this.createClient(serverName, serverConfig, root, key);
@@ -1635,7 +1680,15 @@ export class LspManager {
1635
1680
  void replacement.forceKill().catch(() => {});
1636
1681
  return { files, restarted: false };
1637
1682
  }
1638
- this.restoreTrackedDocuments(replacement, files);
1683
+ await this.restoreTrackedDocuments(replacement, files);
1684
+ if (
1685
+ this.clients.get(key) !== replacement ||
1686
+ this.shuttingDown ||
1687
+ replacement.status !== "running"
1688
+ ) {
1689
+ void replacement.forceKill().catch(() => {});
1690
+ return { files, restarted: false };
1691
+ }
1639
1692
  this.clearUnconsumedProcessCrashState(key, client);
1640
1693
  return { files, restarted: true };
1641
1694
  } catch {
@@ -1659,14 +1712,26 @@ export class LspManager {
1659
1712
 
1660
1713
  getProjectServerInfo(serverName: string, root: string, fileTypes: string[]): ProjectServerInfo {
1661
1714
  const key = clientKey(serverName, root);
1715
+ const client = this.clients.get(key);
1716
+ const config = this.config.servers[serverName];
1717
+ const selection: Pick<DetectedProjectServer, "typescript" | "failureMessage"> =
1718
+ client?.status === "running"
1719
+ ? { typescript: client.typescriptSelection }
1720
+ : config
1721
+ ? inspectTypeScriptLaunch(serverName, config, root)
1722
+ : {};
1662
1723
  return buildProjectServerInfo(
1663
1724
  {
1725
+ ...selection,
1726
+ failureMessage:
1727
+ this.failedStarts.get(key)?.failureMessage ??
1728
+ this.processCrashRecoveries.get(key)?.failureMessage ??
1729
+ selection.failureMessage,
1664
1730
  serverName,
1665
1731
  root,
1666
1732
  fileTypes,
1667
1733
  client: this.clients.get(key),
1668
- unavailableReason:
1669
- this.getUnavailableReason(key, this.config.servers[serverName]?.command) ?? undefined,
1734
+ unavailableReason: this.getUnavailableReason(key, serverName, root) ?? undefined,
1670
1735
  statusReason: this.processCrashRecoveries.get(key)?.statusReason,
1671
1736
  includeOpenFile: (file) => this.isAutomaticScopePath(file),
1672
1737
  },
@@ -1733,7 +1798,9 @@ export class LspManager {
1733
1798
  if (options.retryFailedRoute) {
1734
1799
  this.retireMissingProjectRoots();
1735
1800
  const route = this.resolveFileRoute(resolvedPath);
1736
- if (!route || !this.automaticPathPolicy.isEligible(route.root, "directory")) {
1801
+ // Exact-file demand, including refresh, bypasses automatic discovery exclusions.
1802
+ // Keep the original route identity for document synchronization and bounded retries.
1803
+ if (!route) {
1737
1804
  return {
1738
1805
  client: null,
1739
1806
  processCrashRecovery: emptyProcessCrashRecoveryReport(),
@@ -2017,13 +2084,19 @@ export class LspManager {
2017
2084
 
2018
2085
  const serverConfig = this.config.servers[firstReady.name];
2019
2086
  if (serverConfig) {
2020
- const target = findWorkspaceSymbolWarmTargets(
2087
+ // Candidates for the best-ranked marker root; later ones replace a probe
2088
+ // that lands in an inferred project outside the configured program.
2089
+ const targets = findWorkspaceSymbolWarmTargets(
2021
2090
  firstReady.root,
2022
2091
  serverConfig.rootMarkers,
2023
2092
  serverConfig.fileTypes,
2024
2093
  { policy: this.automaticPathPolicy },
2025
- )[0];
2026
- if (target) await this.warmSemanticProject(firstReady, target.file, false, control);
2094
+ );
2095
+ const primaryRoot = targets[0]?.projectRoot;
2096
+ const files = targets
2097
+ .filter((target) => target.projectRoot === primaryRoot)
2098
+ .map((target) => target.file);
2099
+ if (files.length > 0) await this.warmSemanticProject(firstReady, files, false, control);
2027
2100
  // The warm-up request can start server indexing after the initial
2028
2101
  // readiness wait resolves. Observe the resulting progress before
2029
2102
  // reporting the workspace as ready.
@@ -2866,7 +2939,20 @@ export class LspManager {
2866
2939
  return this.filterWorkspaceSymbolCollection(collection, resolvedScopes);
2867
2940
  };
2868
2941
  const initial = await collect(this.clients.values(), query);
2869
- if (!initial.hasSupport || initial.results.length > 0) {
2942
+ // Allowed out-of-scope rows do not show that the server knows the scope yet.
2943
+ const hasScopedResult = (collection: WorkspaceSymbolCollection) =>
2944
+ collection.hasSupport &&
2945
+ collection.results.some(
2946
+ (result) =>
2947
+ !resolvedScopes?.length ||
2948
+ isWithinAnyScope(uriToFile(result.location.uri), resolvedScopes),
2949
+ );
2950
+ // Rows from a route whose warm-up reached only inferred projects may come
2951
+ // from those files alone, so they cannot end the search early.
2952
+ if (
2953
+ !initial.hasSupport ||
2954
+ (hasScopedResult(initial) && !this.hasInferredOnlyWarmRoute(routeMatches))
2955
+ ) {
2870
2956
  return workspaceSymbolCollectionResult(initial);
2871
2957
  }
2872
2958
 
@@ -2878,11 +2964,32 @@ export class LspManager {
2878
2964
  }),
2879
2965
  getWarmPosition: getWorkspaceSymbolWarmPosition,
2880
2966
  collect,
2967
+ hasResult: hasScopedResult,
2881
2968
  routeMatches,
2882
2969
  query,
2883
2970
  control,
2884
2971
  });
2885
- return workspaceSymbolCollectionResult(warmed.collection ?? initial);
2972
+ const collection = warmed.collection ?? initial;
2973
+ return workspaceSymbolCollectionResult(
2974
+ warmed.coverageConfirmed
2975
+ ? collection
2976
+ : workspaceSymbolCoverageGap(collection, warmed.inferredProbeCount),
2977
+ );
2978
+ }
2979
+
2980
+ /** True when a matching route has inferred warm-up probes and no other probe. */
2981
+ private hasInferredOnlyWarmRoute(routeMatches: (client: LspClient) => boolean): boolean {
2982
+ return Array.from(this.clients.values()).some((client) => {
2983
+ if (!routeMatches(client)) return false;
2984
+ const prefix = `${client.name}:${path.resolve(client.root)}`;
2985
+ let inferred = false;
2986
+ for (const [key, membership] of this.warmedWorkspaceSymbolFiles) {
2987
+ if (key !== prefix && !key.startsWith(`${prefix}${path.sep}`)) continue;
2988
+ if (membership !== "inferred") return false;
2989
+ inferred = true;
2990
+ }
2991
+ return inferred;
2992
+ });
2886
2993
  }
2887
2994
 
2888
2995
  private addWorkspaceSymbolDemandEvidence(
@@ -2905,6 +3012,7 @@ export class LspManager {
2905
3012
  const results = collection.results.filter((result) => {
2906
3013
  const file = uriToFile(result.location.uri);
2907
3014
  if (this.isWorkspaceSymbolResultAllowed(file, scopes)) return true;
3015
+ if (!this.isWorkspaceSymbolExclusionRelevant(file, scopes)) return false;
2908
3016
  excludedCount++;
2909
3017
  const example = displayRelativeFilePath(file, this.cwd);
2910
3018
  if (excludedExamples.size < 5) excludedExamples.add(example);
@@ -2918,6 +3026,26 @@ export class LspManager {
2918
3026
  };
2919
3027
  }
2920
3028
 
3029
+ /** Only proven-disjoint exclusions can be omitted from scoped completeness. */
3030
+ private isWorkspaceSymbolExclusionRelevant(
3031
+ filePath: string,
3032
+ scopes: readonly string[] | undefined,
3033
+ ): boolean {
3034
+ if (!scopes?.length || !path.isAbsolute(filePath)) return true;
3035
+ if (scopes.some((scope) => projectRoots.isWithinOrEqual(scope, filePath))) return true;
3036
+ try {
3037
+ // Observed spellings and physical identities both matter: an excluded
3038
+ // alias may point into scope even when its spelling is outside it.
3039
+ const physicalFile = fs.realpathSync(filePath);
3040
+ return scopes.some((scope) =>
3041
+ projectRoots.isWithinOrEqual(fs.realpathSync(scope), physicalFile),
3042
+ );
3043
+ } catch {
3044
+ // Missing, unreadable or otherwise unresolved paths cannot prove absence.
3045
+ return true;
3046
+ }
3047
+ }
3048
+
2921
3049
  /** Keep explicitly selected excluded scopes inspectable while filtering broad results. */
2922
3050
  private isWorkspaceSymbolResultAllowed(
2923
3051
  filePath: string,
@@ -3012,15 +3140,22 @@ export class LspManager {
3012
3140
  const client = await this.getClientForFile(resolvedPath, options);
3013
3141
  if (!client) return null;
3014
3142
  try {
3015
- const content = fs.readFileSync(resolvedPath, "utf-8");
3016
- // Route startup can be shared. The document open belongs to this
3017
- // caller, so check again after the shared wait before sending didOpen.
3018
- throwIfCodeRequestInterrupted(options.control);
3019
- client.didOpen(resolvedPath, content);
3143
+ await client.openDocument(
3144
+ resolvedPath,
3145
+ () => {
3146
+ try {
3147
+ return fs.readFileSync(resolvedPath, "utf-8");
3148
+ } catch (error) {
3149
+ this.closeFile(resolvedPath);
3150
+ throw error;
3151
+ }
3152
+ },
3153
+ options.control,
3154
+ options.protectInputs,
3155
+ );
3020
3156
  return client;
3021
3157
  } catch (error) {
3022
3158
  if (isCodeRequestInterruption(error, options.control)) throw error;
3023
- this.closeFile(resolvedPath);
3024
3159
  return null;
3025
3160
  }
3026
3161
  }
@@ -3036,13 +3171,29 @@ export class LspManager {
3036
3171
  symbols: import("../config/types.ts").DocumentSymbol[] | SymbolInformation[] | null,
3037
3172
  ) => import("../config/types.ts").Position | null;
3038
3173
  collect: (clients: Iterable<LspClient>, query: string) => Promise<WorkspaceSymbolCollection>;
3174
+ /** Stop warming once a collection contains a row that answers the request. */
3175
+ hasResult: (collection: WorkspaceSymbolCollection) => boolean;
3039
3176
  routeMatches?: (client: LspClient) => boolean;
3040
3177
  query: string;
3041
3178
  control?: CodeRequestControl;
3042
- }): Promise<{ warmedAny: boolean; collection: WorkspaceSymbolCollection | null }> {
3043
- const { findWarmTargets, getWarmPosition, collect, routeMatches, query, control } = options;
3044
- let warmedAny = false;
3179
+ }): Promise<{
3180
+ collection: WorkspaceSymbolCollection | null;
3181
+ /** False only when every probe the server classified landed in an inferred project. */
3182
+ coverageConfirmed: boolean;
3183
+ inferredProbeCount: number;
3184
+ }> {
3185
+ const { findWarmTargets, getWarmPosition, collect, hasResult, routeMatches, query, control } =
3186
+ options;
3045
3187
  let latestCollection: WorkspaceSymbolCollection | null = null;
3188
+ // A route with no inferred probe keeps today's evidence: non-TypeScript
3189
+ // servers index the workspace, and `unknown` cannot prove a gap.
3190
+ const routeCoverage: boolean[] = [];
3191
+ let inferredProbeCount = 0;
3192
+ const outcome = () => ({
3193
+ collection: latestCollection,
3194
+ coverageConfirmed: routeCoverage.every(Boolean),
3195
+ inferredProbeCount,
3196
+ });
3046
3197
 
3047
3198
  for (const client of Array.from(this.clients.values())) {
3048
3199
  if (routeMatches && !routeMatches(client)) continue;
@@ -3058,13 +3209,29 @@ export class LspManager {
3058
3209
  serverConfig.fileTypes,
3059
3210
  ).slice(0, 24);
3060
3211
 
3212
+ // Marker roots whose program an earlier candidate already loaded.
3213
+ const coveredRoots = new Set<string>();
3214
+ let routeInferred = false;
3061
3215
  for (const target of warmTargets) {
3216
+ if (coveredRoots.has(target.projectRoot)) continue;
3062
3217
  // A file probe does not load every program under the route root.
3063
3218
  const fileKey = `${client.name}:${path.resolve(target.file)}`;
3064
- if (this.warmedWorkspaceSymbolFiles.has(fileKey)) continue;
3219
+ const previous = this.warmedWorkspaceSymbolFiles.get(fileKey);
3220
+ if (previous === "inferred") {
3221
+ routeInferred = true;
3222
+ inferredProbeCount++;
3223
+ continue;
3224
+ }
3225
+ if (previous !== undefined) {
3226
+ coveredRoots.add(target.projectRoot);
3227
+ continue;
3228
+ }
3065
3229
 
3066
- const openedClient = await this.ensureFileOpen(target.file);
3067
- if (!openedClient) continue;
3230
+ const openedClient = await this.ensureFileOpen(target.file, { control });
3231
+ if (!openedClient) {
3232
+ this.recordWarmProbeEvent(client.name, target.file, "open-failed", control);
3233
+ continue;
3234
+ }
3068
3235
 
3069
3236
  // A cancelled pass must not mark projects warmed on its way out.
3070
3237
  throwIfCodeRequestInterrupted(control);
@@ -3074,32 +3241,84 @@ export class LspManager {
3074
3241
  getWarmPosition,
3075
3242
  control,
3076
3243
  );
3077
- if (available) {
3078
- this.warmedWorkspaceSymbolFiles.add(fileKey);
3079
- warmedAny = true;
3244
+ if (!available) {
3245
+ this.recordWarmProbeEvent(client.name, target.file, "probe-unavailable", control);
3246
+ continue;
3247
+ }
3248
+ const membership = await openedClient.projectMembership(target.file, control);
3249
+ throwIfCodeRequestInterrupted(control);
3250
+ this.warmedWorkspaceSymbolFiles.set(fileKey, membership);
3251
+ this.recordWarmProbeEvent(client.name, target.file, membership, control);
3252
+ if (membership === "inferred") {
3253
+ // The file loaded only its own inferred project; workspace/symbol
3254
+ // still cannot see the configured program, so try the next file
3255
+ // without collecting: a hit here could come from that inferred
3256
+ // project alone and would hide the configured program's rows.
3257
+ routeInferred = true;
3258
+ inferredProbeCount++;
3259
+ continue;
3080
3260
  }
3081
3261
 
3262
+ coveredRoots.add(target.projectRoot);
3082
3263
  latestCollection = await collect(this.clients.values(), query);
3083
- if (latestCollection.hasSupport && latestCollection.results.length > 0) {
3084
- return { warmedAny, collection: latestCollection };
3264
+ if (hasResult(latestCollection)) {
3265
+ return { ...outcome(), coverageConfirmed: true };
3085
3266
  }
3086
3267
  }
3268
+ routeCoverage.push(coveredRoots.size > 0 || !routeInferred);
3087
3269
  }
3088
3270
 
3271
+ if (inferredProbeCount > 0 && latestCollection === null) {
3272
+ latestCollection = await collect(this.clients.values(), query);
3273
+ }
3089
3274
  // A completed-empty retry replaces an earlier failure too.
3090
- return { warmedAny, collection: latestCollection };
3275
+ return outcome();
3276
+ }
3277
+
3278
+ /**
3279
+ * Record one workspace-symbol warm-up probe: which file was opened and
3280
+ * whether the server placed it in a configured project. `unknown` means the
3281
+ * route cannot report membership (non-TypeScript or native TS7).
3282
+ */
3283
+ private recordWarmProbeEvent(
3284
+ serverName: string,
3285
+ file: string,
3286
+ outcome: ProjectMembership | "open-failed" | "probe-unavailable",
3287
+ control?: CodeRequestControl,
3288
+ ): void {
3289
+ try {
3290
+ recordDebugEvent({
3291
+ operationId: control?.operationId,
3292
+ source: "lsp",
3293
+ level: "debug",
3294
+ category: "workspace-symbol.warm-probe",
3295
+ message: `Workspace-symbol warm-up probe: ${outcome}`,
3296
+ cwd: boundCwd(this.cwd),
3297
+ data: {
3298
+ server: truncateIdentity(serverName),
3299
+ file: truncateIdentity(displayRelativeFilePath(file, this.cwd)),
3300
+ outcome,
3301
+ },
3302
+ });
3303
+ } catch {
3304
+ // Warm-up telemetry must never change query behavior.
3305
+ }
3091
3306
  }
3092
3307
 
3093
3308
  private async warmSemanticProject(
3094
3309
  client: LspClient,
3095
- filePath: string,
3310
+ filePath: string | readonly string[],
3096
3311
  preferExactFile: boolean = false,
3097
3312
  control?: CodeRequestControl,
3098
3313
  ): Promise<void> {
3099
3314
  const serverConfig = this.config.servers[client.name];
3100
3315
  if (!serverConfig) return;
3101
3316
 
3102
- const resolvedFile = resolveSessionPath(this.cwd, filePath);
3317
+ const candidates = (typeof filePath === "string" ? [filePath] : filePath).map((file) =>
3318
+ resolveSessionPath(this.cwd, file),
3319
+ );
3320
+ const resolvedFile = candidates[0];
3321
+ if (!resolvedFile) return;
3103
3322
  const projectRoot = preferExactFile
3104
3323
  ? resolveRootForFile(resolvedFile, client.name, serverConfig.rootMarkers, {
3105
3324
  knownRoots: this.knownRoots,
@@ -3117,7 +3336,7 @@ export class LspManager {
3117
3336
  return;
3118
3337
  }
3119
3338
 
3120
- const probe = this.performWarmProbe(client, resolvedFile, projectKey, control);
3339
+ const probe = this.performWarmProbe(client, candidates, projectKey, control);
3121
3340
  this.pendingWarmProbes.set(projectKey, probe);
3122
3341
  try {
3123
3342
  await raceRequestControl(probe, control);
@@ -3128,28 +3347,40 @@ export class LspManager {
3128
3347
  }
3129
3348
  }
3130
3349
 
3350
+ /**
3351
+ * Probe candidates in order until one loads a non-inferred project.
3352
+ * The project is marked warmed after any successful probe so files that
3353
+ * really sit in inferred projects are not reprobed on every request.
3354
+ */
3131
3355
  private async performWarmProbe(
3132
3356
  client: LspClient,
3133
- resolvedFile: string,
3357
+ candidates: readonly string[],
3134
3358
  projectKey: string,
3135
3359
  control?: CodeRequestControl,
3136
3360
  ): Promise<void> {
3137
3361
  const key = clientKey(client.name, client.root);
3138
- if (this.clients.get(key) !== client) return;
3139
- const openedClient = await this.ensureFileOpen(resolvedFile);
3140
- if (!openedClient || openedClient !== client || this.clients.get(key) !== client) return;
3141
-
3142
3362
  // The probe is shared state for concurrent callers. One caller's
3143
3363
  // cancellation must not cancel the shared in-flight queries, so only the
3144
3364
  // Debug Operation ID is forwarded; each caller races its own wait above.
3145
3365
  const sharedControl = { operationId: control?.operationId };
3146
- const available = await this.probeDocumentSymbols(
3147
- openedClient,
3148
- resolvedFile,
3149
- getWorkspaceSymbolWarmPosition,
3150
- sharedControl,
3151
- );
3152
- if (available && this.clients.get(key) === client) {
3366
+ let warmed = false;
3367
+ for (const [index, resolvedFile] of candidates.entries()) {
3368
+ if (this.clients.get(key) !== client) return;
3369
+ const openedClient = await this.ensureFileOpen(resolvedFile);
3370
+ if (!openedClient || openedClient !== client || this.clients.get(key) !== client) continue;
3371
+ const available = await this.probeDocumentSymbols(
3372
+ openedClient,
3373
+ resolvedFile,
3374
+ getWorkspaceSymbolWarmPosition,
3375
+ sharedControl,
3376
+ );
3377
+ if (!available) continue;
3378
+ warmed = true;
3379
+ if (index === candidates.length - 1) break;
3380
+ const membership = await openedClient.projectMembership(resolvedFile, sharedControl);
3381
+ if (membership !== "inferred") break;
3382
+ }
3383
+ if (warmed && this.clients.get(key) === client) {
3153
3384
  this.warmedSemanticProjects.add(projectKey);
3154
3385
  }
3155
3386
  }
@@ -3180,7 +3411,7 @@ export class LspManager {
3180
3411
 
3181
3412
  private clearWarmedWorkspaceSymbolFiles(serverName: string, root: string): void {
3182
3413
  const prefix = `${serverName}:${path.resolve(root)}`;
3183
- for (const key of Array.from(this.warmedWorkspaceSymbolFiles)) {
3414
+ for (const key of Array.from(this.warmedWorkspaceSymbolFiles.keys())) {
3184
3415
  if (key === prefix || key.startsWith(`${prefix}${path.sep}`)) {
3185
3416
  this.warmedWorkspaceSymbolFiles.delete(key);
3186
3417
  }
@@ -87,8 +87,7 @@ export async function runFilteredCodeActionRefactor(options: {
87
87
  if (!response || response.reason) {
88
88
  return {
89
89
  kind: "unavailable",
90
- reason:
91
- response?.reason ?? `No code actions are available for refactor operation "${operation}".`,
90
+ reason: `Refactor operation "${operation}" is unavailable: ${response?.reason ?? "No code actions are available."}`,
92
91
  };
93
92
  }
94
93
  const actions = response.value;
@@ -136,7 +135,9 @@ export function isUpdateImportsCodeAction(action: CodeAction): boolean {
136
135
 
137
136
  export function isDeleteDeadCodeCodeAction(action: CodeAction): boolean {
138
137
  const kind = typeof action?.kind === "string" ? action.kind : "";
139
- return kind === "source.removeUnused" || kind.startsWith("source.removeUnused.");
138
+ return codeActionKindsForOperation("delete_dead_code").some(
139
+ (requested) => kind === requested || kind.startsWith(`${requested}.`),
140
+ );
140
141
  }
141
142
 
142
143
  interface PreciseCodeActionPlan {
@@ -519,8 +519,8 @@ export class LspRuntimeController {
519
519
  return this.#configurationError;
520
520
  }
521
521
 
522
- /** Get the missing servers warning (servers whose binary is not on PATH). */
523
- getMissingServers(): Array<{ name: string; command: string }> {
522
+ /** Report missing executables and root-specific compiler selection failures. */
523
+ getMissingServers(): ReturnType<typeof scanMissingServers> {
524
524
  if (this.#state.kind !== "ready") return [];
525
525
  return scanMissingServers(
526
526
  this.#state.config,