@mrclrchtr/supi-lsp 11.2.0 → 11.4.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 (30) hide show
  1. package/README.md +40 -3
  2. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  3. package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +2 -1
  4. package/node_modules/@mrclrchtr/supi-code-runtime/src/capability/types.ts +3 -1
  5. package/node_modules/@mrclrchtr/supi-code-runtime/src/declaration-level.ts +34 -0
  6. package/node_modules/@mrclrchtr/supi-code-runtime/src/query-result.ts +9 -0
  7. package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +13 -5
  8. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  9. package/node_modules/@mrclrchtr/supi-core/src/child-web-tools.ts +110 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/session.ts +11 -0
  11. package/node_modules/semver/classes/range.js +2 -2
  12. package/node_modules/semver/internal/constants.js +30 -0
  13. package/node_modules/semver/internal/trim-comparator-whitespace.js +438 -0
  14. package/node_modules/semver/package.json +1 -1
  15. package/package.json +3 -3
  16. package/src/client/client-diagnostic-collection.ts +11 -4
  17. package/src/client/client-diagnostics.ts +77 -6
  18. package/src/client/client-input-admission.ts +149 -0
  19. package/src/client/client-typescript-project.ts +40 -0
  20. package/src/client/client.ts +151 -44
  21. package/src/config/tsconfig-scope.ts +10 -0
  22. package/src/diagnostics/evidence.ts +2 -2
  23. package/src/manager/manager-typescript-coverage.ts +118 -0
  24. package/src/manager/manager-workspace-symbol.ts +157 -23
  25. package/src/manager/manager.ts +318 -58
  26. package/src/manager/typescript-file-local.ts +87 -0
  27. package/src/provider/semantic-symbol-mapper.ts +22 -17
  28. package/src/session/runtime-registry.ts +68 -6
  29. package/src/session/runtime-rename.ts +1 -0
  30. 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
 
@@ -127,6 +128,13 @@ import type {
127
128
  OutstandingDiagnosticSummaryEntry,
128
129
  ServerStatus,
129
130
  } from "./manager-types.ts";
131
+ import {
132
+ discoverTypeScriptProjectConfigs,
133
+ findSkippedImplementationProjects,
134
+ findUnloadedTypeScriptProjects,
135
+ singleProjectImplementationReason,
136
+ typeScriptCoverageGapReason,
137
+ } from "./manager-typescript-coverage.ts";
130
138
  import {
131
139
  recoverWorkspaceDiagnostics as recoverWorkspaceDiagnosticsImpl,
132
140
  type WorkspaceRecoveryResult,
@@ -135,9 +143,12 @@ import {
135
143
  collectWorkspaceSymbols,
136
144
  findWorkspaceSymbolWarmTargets,
137
145
  getWorkspaceSymbolWarmPosition,
146
+ isWithinAnyScope,
138
147
  type WorkspaceSymbolCollection,
139
148
  workspaceSymbolCollectionResult,
149
+ workspaceSymbolCoverageGap,
140
150
  } from "./manager-workspace-symbol.ts";
151
+ import { isFileLocalTopLevelDeclaration } from "./typescript-file-local.ts";
141
152
 
142
153
  type UnavailableReason = "missing-command" | "start-failed" | "runtime-error";
143
154
 
@@ -251,6 +262,8 @@ export interface ManagerLifecycleTransition {
251
262
  type ManagerLifecycleListener = (transition: ManagerLifecycleTransition) => void;
252
263
 
253
264
  type FileClientRequestOptions = {
265
+ /** Reject known input changes while this call's first open waits to dispatch original parameters. */
266
+ protectInputs?: boolean;
254
267
  /** Allow this request to start the route's one process-crash recovery. */
255
268
  recoverProcessCrash?: boolean;
256
269
  /** Control only this caller's wait for a shared replacement. */
@@ -397,12 +410,18 @@ export class LspManager {
397
410
  private invalidationEpoch = 0;
398
411
  /** Invalidation generation in which each route last received a recovery restart. */
399
412
  private recoveryRestartEpochs = new Map<string, number>();
400
- /** Files already probed for workspace-symbol warm-up. */
401
- private warmedWorkspaceSymbolFiles = new Set<string>();
413
+ /**
414
+ * Files already probed for workspace-symbol warm-up, with the project
415
+ * membership the server reported. `inferred` probes stay recorded so they
416
+ * are not reopened, but they do not prove project coverage.
417
+ */
418
+ private warmedWorkspaceSymbolFiles = new Map<string, ProjectMembership>();
402
419
  /** Project roots whose semantic state was warmed with a readiness probe. */
403
420
  private warmedSemanticProjects = new Set<string>();
404
421
  /** In-flight warm-up probes keyed by project key so concurrent callers share one probe. */
405
422
  private pendingWarmProbes = new Map<string, Promise<void>>();
423
+ /** Loadable TypeScript project configs per route root; cleared when a config changes. */
424
+ private typeScriptProjectConfigs = new Map<string, string[]>();
406
425
  /** One process-crash recovery budget and shared replacement per route. */
407
426
  private processCrashRecoveries = new Map<string, ProcessCrashRecoveryState>();
408
427
  /** Client generations that completed the initialize handshake. */
@@ -434,6 +453,19 @@ export class LspManager {
434
453
  detected.filter((entry) => this.automaticPathPolicy.isEligible(entry.root, "directory")),
435
454
  );
436
455
  }
456
+ /** Check configured routing only, independently of executable availability or startup state. */
457
+ hasConfiguredServerForFile(filePath: string): boolean {
458
+ return getServerForFile(this.config, resolveSessionPath(this.cwd, filePath)) !== null;
459
+ }
460
+
461
+ /** Observe a retained initial-start failure without retrying or conflating process-crash recovery. */
462
+ hasFailedStartupForFile(filePath: string): boolean {
463
+ const route = this.resolveFileRoute(filePath);
464
+ if (!route || this.processCrashRecoveries.has(route.key)) return false;
465
+ const reason = this.unavailable.get(route.key);
466
+ return reason === "start-failed" || reason === "missing-command";
467
+ }
468
+
437
469
  /** Check whether a file path has an available LSP server for explicit semantic operations. */
438
470
  canServeFile(filePath: string): boolean {
439
471
  const route = this.resolveFileRoute(filePath);
@@ -550,6 +582,53 @@ export class LspManager {
550
582
  );
551
583
  }
552
584
 
585
+ /**
586
+ * Coverage gap for a project-wide TypeScript request answered by `client`.
587
+ *
588
+ * Returns a partial-result reason when a TypeScript project that includes
589
+ * `filePath` owns neither an open file nor a file in `resultFiles`, so
590
+ * tsserver may not have searched it; otherwise null. Non-TypeScript routes
591
+ * always return null.
592
+ *
593
+ * Implementation requests not known to use the native backend report every
594
+ * other project instead: tsserver searches only the file's own project.
595
+ *
596
+ * No gap applies when `position` (zero-based) names a top-level declaration
597
+ * its module does not export: no other project can reference it.
598
+ */
599
+ typeScriptProjectCoverageGap(
600
+ client: LspClient,
601
+ filePath: string,
602
+ request: "references" | "implementation",
603
+ evidence: {
604
+ readonly resultFiles?: readonly string[];
605
+ readonly position?: { readonly line: number; readonly character: number };
606
+ } = {},
607
+ ): string | null {
608
+ const { resultFiles = [], position } = evidence;
609
+ if (client.name !== "typescript") return null;
610
+ const rootKey = path.resolve(client.root);
611
+ let configs = this.typeScriptProjectConfigs.get(rootKey);
612
+ if (!configs) {
613
+ configs = discoverTypeScriptProjectConfigs(rootKey, this.automaticPathPolicy);
614
+ this.typeScriptProjectConfigs.set(rootKey, configs);
615
+ }
616
+ const file = resolveSessionPath(this.cwd, filePath);
617
+ if (position && isFileLocalTopLevelDeclaration(file, position)) return null;
618
+ if (request === "implementation" && client.typescriptSelection?.backend !== "native") {
619
+ const skipped = findSkippedImplementationProjects({ file, cwd: this.cwd, configs });
620
+ return skipped.length > 0 ? singleProjectImplementationReason(skipped) : null;
621
+ }
622
+ const unloaded = findUnloadedTypeScriptProjects({
623
+ file,
624
+ cwd: this.cwd,
625
+ openFiles: client.openFiles,
626
+ resultFiles,
627
+ configs,
628
+ });
629
+ return unloaded.length > 0 ? typeScriptCoverageGapReason(unloaded, this.cwd) : null;
630
+ }
631
+
553
632
  /** Get or create an LSP client for the given file. */
554
633
  async getClientForFile(
555
634
  filePath: string,
@@ -1314,7 +1393,13 @@ export class LspManager {
1314
1393
  recovery.statusReason = undefined;
1315
1394
  recovery.failureMessage = undefined;
1316
1395
  this.publishLifecycle("recovery");
1317
- this.restoreTrackedDocuments(started, recovery.files);
1396
+ await this.restoreTrackedDocuments(started, recovery.files);
1397
+ if (
1398
+ this.isProcessCrashRecoveryCancelled(recovery, key) ||
1399
+ this.clients.get(key) !== started ||
1400
+ started.status !== "running"
1401
+ )
1402
+ return null;
1318
1403
  this.recordProcessCrashRecoveryEvent(
1319
1404
  route.serverName,
1320
1405
  route.root,
@@ -1399,20 +1484,16 @@ export class LspManager {
1399
1484
  }
1400
1485
 
1401
1486
  /** Restore route documents from disk and retain failed-document evidence. */
1402
- private restoreTrackedDocuments(client: LspClient, files: readonly string[]): void {
1403
- const failedFiles: string[] = [];
1487
+ private async restoreTrackedDocuments(
1488
+ client: LspClient,
1489
+ files: readonly string[],
1490
+ ): Promise<void> {
1491
+ const key = clientKey(client.name, client.root);
1492
+ const isCurrent = () => !this.shuttingDown && this.clients.get(key) === client;
1404
1493
  for (const filePath of files) {
1405
- if (!fs.existsSync(filePath)) {
1406
- failedFiles.push(filePath);
1407
- continue;
1408
- }
1409
- try {
1410
- client.didOpen(filePath, fs.readFileSync(filePath, "utf-8"));
1411
- } catch {
1412
- failedFiles.push(filePath);
1413
- }
1494
+ if (!isCurrent()) return;
1495
+ await client.restoreDocument(filePath, () => fs.readFileSync(filePath, "utf-8"), isCurrent);
1414
1496
  }
1415
- for (const filePath of failedFiles) client.markFailedFile(filePath);
1416
1497
  }
1417
1498
 
1418
1499
  private markProcessCrashRecoveryFailed(
@@ -1656,7 +1737,15 @@ export class LspManager {
1656
1737
  void replacement.forceKill().catch(() => {});
1657
1738
  return { files, restarted: false };
1658
1739
  }
1659
- this.restoreTrackedDocuments(replacement, files);
1740
+ await this.restoreTrackedDocuments(replacement, files);
1741
+ if (
1742
+ this.clients.get(key) !== replacement ||
1743
+ this.shuttingDown ||
1744
+ replacement.status !== "running"
1745
+ ) {
1746
+ void replacement.forceKill().catch(() => {});
1747
+ return { files, restarted: false };
1748
+ }
1660
1749
  this.clearUnconsumedProcessCrashState(key, client);
1661
1750
  return { files, restarted: true };
1662
1751
  } catch {
@@ -1766,7 +1855,9 @@ export class LspManager {
1766
1855
  if (options.retryFailedRoute) {
1767
1856
  this.retireMissingProjectRoots();
1768
1857
  const route = this.resolveFileRoute(resolvedPath);
1769
- if (!route || !this.automaticPathPolicy.isEligible(route.root, "directory")) {
1858
+ // Exact-file demand, including refresh, bypasses automatic discovery exclusions.
1859
+ // Keep the original route identity for document synchronization and bounded retries.
1860
+ if (!route) {
1770
1861
  return {
1771
1862
  client: null,
1772
1863
  processCrashRecovery: emptyProcessCrashRecoveryReport(),
@@ -2050,13 +2141,19 @@ export class LspManager {
2050
2141
 
2051
2142
  const serverConfig = this.config.servers[firstReady.name];
2052
2143
  if (serverConfig) {
2053
- const target = findWorkspaceSymbolWarmTargets(
2144
+ // Candidates for the best-ranked marker root; later ones replace a probe
2145
+ // that lands in an inferred project outside the configured program.
2146
+ const targets = findWorkspaceSymbolWarmTargets(
2054
2147
  firstReady.root,
2055
2148
  serverConfig.rootMarkers,
2056
2149
  serverConfig.fileTypes,
2057
2150
  { policy: this.automaticPathPolicy },
2058
- )[0];
2059
- if (target) await this.warmSemanticProject(firstReady, target.file, false, control);
2151
+ );
2152
+ const primaryRoot = targets[0]?.projectRoot;
2153
+ const files = targets
2154
+ .filter((target) => target.projectRoot === primaryRoot)
2155
+ .map((target) => target.file);
2156
+ if (files.length > 0) await this.warmSemanticProject(firstReady, files, false, control);
2060
2157
  // The warm-up request can start server indexing after the initial
2061
2158
  // readiness wait resolves. Observe the resulting progress before
2062
2159
  // reporting the workspace as ready.
@@ -2357,7 +2454,7 @@ export class LspManager {
2357
2454
  */
2358
2455
  noteWorkspaceChanges(changes: FileEvent[]): void {
2359
2456
  if (changes.length > 0) this.invalidationEpoch++;
2360
- invalidateProjectConfigCaches(changes);
2457
+ if (invalidateProjectConfigCaches(changes)) this.typeScriptProjectConfigs.clear();
2361
2458
  this.notifyWorkspaceFileChanges(changes);
2362
2459
  }
2363
2460
 
@@ -2546,6 +2643,7 @@ export class LspManager {
2546
2643
  this.warmedWorkspaceSymbolFiles.clear();
2547
2644
  this.warmedSemanticProjects.clear();
2548
2645
  this.pendingWarmProbes.clear();
2646
+ this.typeScriptProjectConfigs.clear();
2549
2647
  this.pendingRestarts.clear();
2550
2648
  }
2551
2649
  /** Get status of all servers. */
@@ -2899,7 +2997,20 @@ export class LspManager {
2899
2997
  return this.filterWorkspaceSymbolCollection(collection, resolvedScopes);
2900
2998
  };
2901
2999
  const initial = await collect(this.clients.values(), query);
2902
- if (!initial.hasSupport || initial.results.length > 0) {
3000
+ // Allowed out-of-scope rows do not show that the server knows the scope yet.
3001
+ const hasScopedResult = (collection: WorkspaceSymbolCollection) =>
3002
+ collection.hasSupport &&
3003
+ collection.results.some(
3004
+ (result) =>
3005
+ !resolvedScopes?.length ||
3006
+ isWithinAnyScope(uriToFile(result.location.uri), resolvedScopes),
3007
+ );
3008
+ // Rows from a route whose warm-up reached only inferred projects may come
3009
+ // from those files alone, so they cannot end the search early.
3010
+ if (
3011
+ !initial.hasSupport ||
3012
+ (hasScopedResult(initial) && !this.hasInferredOnlyWarmRoute(routeMatches))
3013
+ ) {
2903
3014
  return workspaceSymbolCollectionResult(initial);
2904
3015
  }
2905
3016
 
@@ -2911,11 +3022,32 @@ export class LspManager {
2911
3022
  }),
2912
3023
  getWarmPosition: getWorkspaceSymbolWarmPosition,
2913
3024
  collect,
3025
+ hasResult: hasScopedResult,
2914
3026
  routeMatches,
2915
3027
  query,
2916
3028
  control,
2917
3029
  });
2918
- return workspaceSymbolCollectionResult(warmed.collection ?? initial);
3030
+ const collection = warmed.collection ?? initial;
3031
+ return workspaceSymbolCollectionResult(
3032
+ warmed.coverageConfirmed
3033
+ ? collection
3034
+ : workspaceSymbolCoverageGap(collection, warmed.inferredProbeCount),
3035
+ );
3036
+ }
3037
+
3038
+ /** True when a matching route has inferred warm-up probes and no other probe. */
3039
+ private hasInferredOnlyWarmRoute(routeMatches: (client: LspClient) => boolean): boolean {
3040
+ return Array.from(this.clients.values()).some((client) => {
3041
+ if (!routeMatches(client)) return false;
3042
+ const prefix = `${client.name}:${path.resolve(client.root)}`;
3043
+ let inferred = false;
3044
+ for (const [key, membership] of this.warmedWorkspaceSymbolFiles) {
3045
+ if (key !== prefix && !key.startsWith(`${prefix}${path.sep}`)) continue;
3046
+ if (membership !== "inferred") return false;
3047
+ inferred = true;
3048
+ }
3049
+ return inferred;
3050
+ });
2919
3051
  }
2920
3052
 
2921
3053
  private addWorkspaceSymbolDemandEvidence(
@@ -2938,6 +3070,7 @@ export class LspManager {
2938
3070
  const results = collection.results.filter((result) => {
2939
3071
  const file = uriToFile(result.location.uri);
2940
3072
  if (this.isWorkspaceSymbolResultAllowed(file, scopes)) return true;
3073
+ if (!this.isWorkspaceSymbolExclusionRelevant(file, scopes)) return false;
2941
3074
  excludedCount++;
2942
3075
  const example = displayRelativeFilePath(file, this.cwd);
2943
3076
  if (excludedExamples.size < 5) excludedExamples.add(example);
@@ -2951,6 +3084,26 @@ export class LspManager {
2951
3084
  };
2952
3085
  }
2953
3086
 
3087
+ /** Only proven-disjoint exclusions can be omitted from scoped completeness. */
3088
+ private isWorkspaceSymbolExclusionRelevant(
3089
+ filePath: string,
3090
+ scopes: readonly string[] | undefined,
3091
+ ): boolean {
3092
+ if (!scopes?.length || !path.isAbsolute(filePath)) return true;
3093
+ if (scopes.some((scope) => projectRoots.isWithinOrEqual(scope, filePath))) return true;
3094
+ try {
3095
+ // Observed spellings and physical identities both matter: an excluded
3096
+ // alias may point into scope even when its spelling is outside it.
3097
+ const physicalFile = fs.realpathSync(filePath);
3098
+ return scopes.some((scope) =>
3099
+ projectRoots.isWithinOrEqual(fs.realpathSync(scope), physicalFile),
3100
+ );
3101
+ } catch {
3102
+ // Missing, unreadable or otherwise unresolved paths cannot prove absence.
3103
+ return true;
3104
+ }
3105
+ }
3106
+
2954
3107
  /** Keep explicitly selected excluded scopes inspectable while filtering broad results. */
2955
3108
  private isWorkspaceSymbolResultAllowed(
2956
3109
  filePath: string,
@@ -3045,15 +3198,22 @@ export class LspManager {
3045
3198
  const client = await this.getClientForFile(resolvedPath, options);
3046
3199
  if (!client) return null;
3047
3200
  try {
3048
- const content = fs.readFileSync(resolvedPath, "utf-8");
3049
- // Route startup can be shared. The document open belongs to this
3050
- // caller, so check again after the shared wait before sending didOpen.
3051
- throwIfCodeRequestInterrupted(options.control);
3052
- client.didOpen(resolvedPath, content);
3201
+ await client.openDocument(
3202
+ resolvedPath,
3203
+ () => {
3204
+ try {
3205
+ return fs.readFileSync(resolvedPath, "utf-8");
3206
+ } catch (error) {
3207
+ this.closeFile(resolvedPath);
3208
+ throw error;
3209
+ }
3210
+ },
3211
+ options.control,
3212
+ options.protectInputs,
3213
+ );
3053
3214
  return client;
3054
3215
  } catch (error) {
3055
3216
  if (isCodeRequestInterruption(error, options.control)) throw error;
3056
- this.closeFile(resolvedPath);
3057
3217
  return null;
3058
3218
  }
3059
3219
  }
@@ -3069,13 +3229,29 @@ export class LspManager {
3069
3229
  symbols: import("../config/types.ts").DocumentSymbol[] | SymbolInformation[] | null,
3070
3230
  ) => import("../config/types.ts").Position | null;
3071
3231
  collect: (clients: Iterable<LspClient>, query: string) => Promise<WorkspaceSymbolCollection>;
3232
+ /** Stop warming once a collection contains a row that answers the request. */
3233
+ hasResult: (collection: WorkspaceSymbolCollection) => boolean;
3072
3234
  routeMatches?: (client: LspClient) => boolean;
3073
3235
  query: string;
3074
3236
  control?: CodeRequestControl;
3075
- }): Promise<{ warmedAny: boolean; collection: WorkspaceSymbolCollection | null }> {
3076
- const { findWarmTargets, getWarmPosition, collect, routeMatches, query, control } = options;
3077
- let warmedAny = false;
3237
+ }): Promise<{
3238
+ collection: WorkspaceSymbolCollection | null;
3239
+ /** False only when every probe the server classified landed in an inferred project. */
3240
+ coverageConfirmed: boolean;
3241
+ inferredProbeCount: number;
3242
+ }> {
3243
+ const { findWarmTargets, getWarmPosition, collect, hasResult, routeMatches, query, control } =
3244
+ options;
3078
3245
  let latestCollection: WorkspaceSymbolCollection | null = null;
3246
+ // A route with no inferred probe keeps today's evidence: non-TypeScript
3247
+ // servers index the workspace, and `unknown` cannot prove a gap.
3248
+ const routeCoverage: boolean[] = [];
3249
+ let inferredProbeCount = 0;
3250
+ const outcome = () => ({
3251
+ collection: latestCollection,
3252
+ coverageConfirmed: routeCoverage.every(Boolean),
3253
+ inferredProbeCount,
3254
+ });
3079
3255
 
3080
3256
  for (const client of Array.from(this.clients.values())) {
3081
3257
  if (routeMatches && !routeMatches(client)) continue;
@@ -3091,13 +3267,29 @@ export class LspManager {
3091
3267
  serverConfig.fileTypes,
3092
3268
  ).slice(0, 24);
3093
3269
 
3270
+ // Marker roots whose program an earlier candidate already loaded.
3271
+ const coveredRoots = new Set<string>();
3272
+ let routeInferred = false;
3094
3273
  for (const target of warmTargets) {
3274
+ if (coveredRoots.has(target.projectRoot)) continue;
3095
3275
  // A file probe does not load every program under the route root.
3096
3276
  const fileKey = `${client.name}:${path.resolve(target.file)}`;
3097
- if (this.warmedWorkspaceSymbolFiles.has(fileKey)) continue;
3277
+ const previous = this.warmedWorkspaceSymbolFiles.get(fileKey);
3278
+ if (previous === "inferred") {
3279
+ routeInferred = true;
3280
+ inferredProbeCount++;
3281
+ continue;
3282
+ }
3283
+ if (previous !== undefined) {
3284
+ coveredRoots.add(target.projectRoot);
3285
+ continue;
3286
+ }
3098
3287
 
3099
- const openedClient = await this.ensureFileOpen(target.file);
3100
- if (!openedClient) continue;
3288
+ const openedClient = await this.ensureFileOpen(target.file, { control });
3289
+ if (!openedClient) {
3290
+ this.recordWarmProbeEvent(client.name, target.file, "open-failed", control);
3291
+ continue;
3292
+ }
3101
3293
 
3102
3294
  // A cancelled pass must not mark projects warmed on its way out.
3103
3295
  throwIfCodeRequestInterrupted(control);
@@ -3107,32 +3299,84 @@ export class LspManager {
3107
3299
  getWarmPosition,
3108
3300
  control,
3109
3301
  );
3110
- if (available) {
3111
- this.warmedWorkspaceSymbolFiles.add(fileKey);
3112
- warmedAny = true;
3302
+ if (!available) {
3303
+ this.recordWarmProbeEvent(client.name, target.file, "probe-unavailable", control);
3304
+ continue;
3305
+ }
3306
+ const membership = await openedClient.projectMembership(target.file, control);
3307
+ throwIfCodeRequestInterrupted(control);
3308
+ this.warmedWorkspaceSymbolFiles.set(fileKey, membership);
3309
+ this.recordWarmProbeEvent(client.name, target.file, membership, control);
3310
+ if (membership === "inferred") {
3311
+ // The file loaded only its own inferred project; workspace/symbol
3312
+ // still cannot see the configured program, so try the next file
3313
+ // without collecting: a hit here could come from that inferred
3314
+ // project alone and would hide the configured program's rows.
3315
+ routeInferred = true;
3316
+ inferredProbeCount++;
3317
+ continue;
3113
3318
  }
3114
3319
 
3320
+ coveredRoots.add(target.projectRoot);
3115
3321
  latestCollection = await collect(this.clients.values(), query);
3116
- if (latestCollection.hasSupport && latestCollection.results.length > 0) {
3117
- return { warmedAny, collection: latestCollection };
3322
+ if (hasResult(latestCollection)) {
3323
+ return { ...outcome(), coverageConfirmed: true };
3118
3324
  }
3119
3325
  }
3326
+ routeCoverage.push(coveredRoots.size > 0 || !routeInferred);
3120
3327
  }
3121
3328
 
3329
+ if (inferredProbeCount > 0 && latestCollection === null) {
3330
+ latestCollection = await collect(this.clients.values(), query);
3331
+ }
3122
3332
  // A completed-empty retry replaces an earlier failure too.
3123
- return { warmedAny, collection: latestCollection };
3333
+ return outcome();
3334
+ }
3335
+
3336
+ /**
3337
+ * Record one workspace-symbol warm-up probe: which file was opened and
3338
+ * whether the server placed it in a configured project. `unknown` means the
3339
+ * route cannot report membership (non-TypeScript or native TS7).
3340
+ */
3341
+ private recordWarmProbeEvent(
3342
+ serverName: string,
3343
+ file: string,
3344
+ outcome: ProjectMembership | "open-failed" | "probe-unavailable",
3345
+ control?: CodeRequestControl,
3346
+ ): void {
3347
+ try {
3348
+ recordDebugEvent({
3349
+ operationId: control?.operationId,
3350
+ source: "lsp",
3351
+ level: "debug",
3352
+ category: "workspace-symbol.warm-probe",
3353
+ message: `Workspace-symbol warm-up probe: ${outcome}`,
3354
+ cwd: boundCwd(this.cwd),
3355
+ data: {
3356
+ server: truncateIdentity(serverName),
3357
+ file: truncateIdentity(displayRelativeFilePath(file, this.cwd)),
3358
+ outcome,
3359
+ },
3360
+ });
3361
+ } catch {
3362
+ // Warm-up telemetry must never change query behavior.
3363
+ }
3124
3364
  }
3125
3365
 
3126
3366
  private async warmSemanticProject(
3127
3367
  client: LspClient,
3128
- filePath: string,
3368
+ filePath: string | readonly string[],
3129
3369
  preferExactFile: boolean = false,
3130
3370
  control?: CodeRequestControl,
3131
3371
  ): Promise<void> {
3132
3372
  const serverConfig = this.config.servers[client.name];
3133
3373
  if (!serverConfig) return;
3134
3374
 
3135
- const resolvedFile = resolveSessionPath(this.cwd, filePath);
3375
+ const candidates = (typeof filePath === "string" ? [filePath] : filePath).map((file) =>
3376
+ resolveSessionPath(this.cwd, file),
3377
+ );
3378
+ const resolvedFile = candidates[0];
3379
+ if (!resolvedFile) return;
3136
3380
  const projectRoot = preferExactFile
3137
3381
  ? resolveRootForFile(resolvedFile, client.name, serverConfig.rootMarkers, {
3138
3382
  knownRoots: this.knownRoots,
@@ -3150,7 +3394,7 @@ export class LspManager {
3150
3394
  return;
3151
3395
  }
3152
3396
 
3153
- const probe = this.performWarmProbe(client, resolvedFile, projectKey, control);
3397
+ const probe = this.performWarmProbe(client, candidates, projectKey, control);
3154
3398
  this.pendingWarmProbes.set(projectKey, probe);
3155
3399
  try {
3156
3400
  await raceRequestControl(probe, control);
@@ -3161,28 +3405,40 @@ export class LspManager {
3161
3405
  }
3162
3406
  }
3163
3407
 
3408
+ /**
3409
+ * Probe candidates in order until one loads a non-inferred project.
3410
+ * The project is marked warmed after any successful probe so files that
3411
+ * really sit in inferred projects are not reprobed on every request.
3412
+ */
3164
3413
  private async performWarmProbe(
3165
3414
  client: LspClient,
3166
- resolvedFile: string,
3415
+ candidates: readonly string[],
3167
3416
  projectKey: string,
3168
3417
  control?: CodeRequestControl,
3169
3418
  ): Promise<void> {
3170
3419
  const key = clientKey(client.name, client.root);
3171
- if (this.clients.get(key) !== client) return;
3172
- const openedClient = await this.ensureFileOpen(resolvedFile);
3173
- if (!openedClient || openedClient !== client || this.clients.get(key) !== client) return;
3174
-
3175
3420
  // The probe is shared state for concurrent callers. One caller's
3176
3421
  // cancellation must not cancel the shared in-flight queries, so only the
3177
3422
  // Debug Operation ID is forwarded; each caller races its own wait above.
3178
3423
  const sharedControl = { operationId: control?.operationId };
3179
- const available = await this.probeDocumentSymbols(
3180
- openedClient,
3181
- resolvedFile,
3182
- getWorkspaceSymbolWarmPosition,
3183
- sharedControl,
3184
- );
3185
- if (available && this.clients.get(key) === client) {
3424
+ let warmed = false;
3425
+ for (const [index, resolvedFile] of candidates.entries()) {
3426
+ if (this.clients.get(key) !== client) return;
3427
+ const openedClient = await this.ensureFileOpen(resolvedFile);
3428
+ if (!openedClient || openedClient !== client || this.clients.get(key) !== client) continue;
3429
+ const available = await this.probeDocumentSymbols(
3430
+ openedClient,
3431
+ resolvedFile,
3432
+ getWorkspaceSymbolWarmPosition,
3433
+ sharedControl,
3434
+ );
3435
+ if (!available) continue;
3436
+ warmed = true;
3437
+ if (index === candidates.length - 1) break;
3438
+ const membership = await openedClient.projectMembership(resolvedFile, sharedControl);
3439
+ if (membership !== "inferred") break;
3440
+ }
3441
+ if (warmed && this.clients.get(key) === client) {
3186
3442
  this.warmedSemanticProjects.add(projectKey);
3187
3443
  }
3188
3444
  }
@@ -3213,7 +3469,7 @@ export class LspManager {
3213
3469
 
3214
3470
  private clearWarmedWorkspaceSymbolFiles(serverName: string, root: string): void {
3215
3471
  const prefix = `${serverName}:${path.resolve(root)}`;
3216
- for (const key of Array.from(this.warmedWorkspaceSymbolFiles)) {
3472
+ for (const key of Array.from(this.warmedWorkspaceSymbolFiles.keys())) {
3217
3473
  if (key === prefix || key.startsWith(`${prefix}${path.sep}`)) {
3218
3474
  this.warmedWorkspaceSymbolFiles.delete(key);
3219
3475
  }
@@ -3239,15 +3495,19 @@ export class LspManager {
3239
3495
  }
3240
3496
  }
3241
3497
 
3242
- function invalidateProjectConfigCaches(changes: readonly FileEvent[]): void {
3498
+ /** Invalidate parsed project configs; returns whether any config changed. */
3499
+ function invalidateProjectConfigCaches(changes: readonly FileEvent[]): boolean {
3500
+ let changed = false;
3243
3501
  for (const change of changes) {
3244
3502
  const configPath = uriToFile(change.uri);
3245
3503
  if (!isProjectConfigFileName(path.basename(configPath))) continue;
3504
+ changed = true;
3246
3505
  invalidateTsconfigCacheForConfig(configPath);
3247
3506
  if (change.type === FileChangeType.Created) {
3248
3507
  invalidateTsconfigCacheForConfigDir(path.dirname(configPath));
3249
3508
  }
3250
3509
  }
3510
+ return changed;
3251
3511
  }
3252
3512
 
3253
3513
  function collectClientDiagnosticEvidenceDocuments(