@mrclrchtr/supi-lsp 11.2.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.
@@ -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
 
@@ -135,8 +136,10 @@ import {
135
136
  collectWorkspaceSymbols,
136
137
  findWorkspaceSymbolWarmTargets,
137
138
  getWorkspaceSymbolWarmPosition,
139
+ isWithinAnyScope,
138
140
  type WorkspaceSymbolCollection,
139
141
  workspaceSymbolCollectionResult,
142
+ workspaceSymbolCoverageGap,
140
143
  } from "./manager-workspace-symbol.ts";
141
144
 
142
145
  type UnavailableReason = "missing-command" | "start-failed" | "runtime-error";
@@ -251,6 +254,8 @@ export interface ManagerLifecycleTransition {
251
254
  type ManagerLifecycleListener = (transition: ManagerLifecycleTransition) => void;
252
255
 
253
256
  type FileClientRequestOptions = {
257
+ /** Reject known input changes while this call's first open waits to dispatch original parameters. */
258
+ protectInputs?: boolean;
254
259
  /** Allow this request to start the route's one process-crash recovery. */
255
260
  recoverProcessCrash?: boolean;
256
261
  /** Control only this caller's wait for a shared replacement. */
@@ -397,8 +402,12 @@ export class LspManager {
397
402
  private invalidationEpoch = 0;
398
403
  /** Invalidation generation in which each route last received a recovery restart. */
399
404
  private recoveryRestartEpochs = new Map<string, number>();
400
- /** Files already probed for workspace-symbol warm-up. */
401
- 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>();
402
411
  /** Project roots whose semantic state was warmed with a readiness probe. */
403
412
  private warmedSemanticProjects = new Set<string>();
404
413
  /** In-flight warm-up probes keyed by project key so concurrent callers share one probe. */
@@ -434,6 +443,19 @@ export class LspManager {
434
443
  detected.filter((entry) => this.automaticPathPolicy.isEligible(entry.root, "directory")),
435
444
  );
436
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
+
437
459
  /** Check whether a file path has an available LSP server for explicit semantic operations. */
438
460
  canServeFile(filePath: string): boolean {
439
461
  const route = this.resolveFileRoute(filePath);
@@ -1314,7 +1336,13 @@ export class LspManager {
1314
1336
  recovery.statusReason = undefined;
1315
1337
  recovery.failureMessage = undefined;
1316
1338
  this.publishLifecycle("recovery");
1317
- 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;
1318
1346
  this.recordProcessCrashRecoveryEvent(
1319
1347
  route.serverName,
1320
1348
  route.root,
@@ -1399,20 +1427,16 @@ export class LspManager {
1399
1427
  }
1400
1428
 
1401
1429
  /** Restore route documents from disk and retain failed-document evidence. */
1402
- private restoreTrackedDocuments(client: LspClient, files: readonly string[]): void {
1403
- 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;
1404
1436
  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
- }
1437
+ if (!isCurrent()) return;
1438
+ await client.restoreDocument(filePath, () => fs.readFileSync(filePath, "utf-8"), isCurrent);
1414
1439
  }
1415
- for (const filePath of failedFiles) client.markFailedFile(filePath);
1416
1440
  }
1417
1441
 
1418
1442
  private markProcessCrashRecoveryFailed(
@@ -1656,7 +1680,15 @@ export class LspManager {
1656
1680
  void replacement.forceKill().catch(() => {});
1657
1681
  return { files, restarted: false };
1658
1682
  }
1659
- 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
+ }
1660
1692
  this.clearUnconsumedProcessCrashState(key, client);
1661
1693
  return { files, restarted: true };
1662
1694
  } catch {
@@ -1766,7 +1798,9 @@ export class LspManager {
1766
1798
  if (options.retryFailedRoute) {
1767
1799
  this.retireMissingProjectRoots();
1768
1800
  const route = this.resolveFileRoute(resolvedPath);
1769
- 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) {
1770
1804
  return {
1771
1805
  client: null,
1772
1806
  processCrashRecovery: emptyProcessCrashRecoveryReport(),
@@ -2050,13 +2084,19 @@ export class LspManager {
2050
2084
 
2051
2085
  const serverConfig = this.config.servers[firstReady.name];
2052
2086
  if (serverConfig) {
2053
- 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(
2054
2090
  firstReady.root,
2055
2091
  serverConfig.rootMarkers,
2056
2092
  serverConfig.fileTypes,
2057
2093
  { policy: this.automaticPathPolicy },
2058
- )[0];
2059
- 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);
2060
2100
  // The warm-up request can start server indexing after the initial
2061
2101
  // readiness wait resolves. Observe the resulting progress before
2062
2102
  // reporting the workspace as ready.
@@ -2899,7 +2939,20 @@ export class LspManager {
2899
2939
  return this.filterWorkspaceSymbolCollection(collection, resolvedScopes);
2900
2940
  };
2901
2941
  const initial = await collect(this.clients.values(), query);
2902
- 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
+ ) {
2903
2956
  return workspaceSymbolCollectionResult(initial);
2904
2957
  }
2905
2958
 
@@ -2911,11 +2964,32 @@ export class LspManager {
2911
2964
  }),
2912
2965
  getWarmPosition: getWorkspaceSymbolWarmPosition,
2913
2966
  collect,
2967
+ hasResult: hasScopedResult,
2914
2968
  routeMatches,
2915
2969
  query,
2916
2970
  control,
2917
2971
  });
2918
- 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
+ });
2919
2993
  }
2920
2994
 
2921
2995
  private addWorkspaceSymbolDemandEvidence(
@@ -2938,6 +3012,7 @@ export class LspManager {
2938
3012
  const results = collection.results.filter((result) => {
2939
3013
  const file = uriToFile(result.location.uri);
2940
3014
  if (this.isWorkspaceSymbolResultAllowed(file, scopes)) return true;
3015
+ if (!this.isWorkspaceSymbolExclusionRelevant(file, scopes)) return false;
2941
3016
  excludedCount++;
2942
3017
  const example = displayRelativeFilePath(file, this.cwd);
2943
3018
  if (excludedExamples.size < 5) excludedExamples.add(example);
@@ -2951,6 +3026,26 @@ export class LspManager {
2951
3026
  };
2952
3027
  }
2953
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
+
2954
3049
  /** Keep explicitly selected excluded scopes inspectable while filtering broad results. */
2955
3050
  private isWorkspaceSymbolResultAllowed(
2956
3051
  filePath: string,
@@ -3045,15 +3140,22 @@ export class LspManager {
3045
3140
  const client = await this.getClientForFile(resolvedPath, options);
3046
3141
  if (!client) return null;
3047
3142
  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);
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
+ );
3053
3156
  return client;
3054
3157
  } catch (error) {
3055
3158
  if (isCodeRequestInterruption(error, options.control)) throw error;
3056
- this.closeFile(resolvedPath);
3057
3159
  return null;
3058
3160
  }
3059
3161
  }
@@ -3069,13 +3171,29 @@ export class LspManager {
3069
3171
  symbols: import("../config/types.ts").DocumentSymbol[] | SymbolInformation[] | null,
3070
3172
  ) => import("../config/types.ts").Position | null;
3071
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;
3072
3176
  routeMatches?: (client: LspClient) => boolean;
3073
3177
  query: string;
3074
3178
  control?: CodeRequestControl;
3075
- }): Promise<{ warmedAny: boolean; collection: WorkspaceSymbolCollection | null }> {
3076
- const { findWarmTargets, getWarmPosition, collect, routeMatches, query, control } = options;
3077
- 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;
3078
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
+ });
3079
3197
 
3080
3198
  for (const client of Array.from(this.clients.values())) {
3081
3199
  if (routeMatches && !routeMatches(client)) continue;
@@ -3091,13 +3209,29 @@ export class LspManager {
3091
3209
  serverConfig.fileTypes,
3092
3210
  ).slice(0, 24);
3093
3211
 
3212
+ // Marker roots whose program an earlier candidate already loaded.
3213
+ const coveredRoots = new Set<string>();
3214
+ let routeInferred = false;
3094
3215
  for (const target of warmTargets) {
3216
+ if (coveredRoots.has(target.projectRoot)) continue;
3095
3217
  // A file probe does not load every program under the route root.
3096
3218
  const fileKey = `${client.name}:${path.resolve(target.file)}`;
3097
- 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
+ }
3098
3229
 
3099
- const openedClient = await this.ensureFileOpen(target.file);
3100
- 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
+ }
3101
3235
 
3102
3236
  // A cancelled pass must not mark projects warmed on its way out.
3103
3237
  throwIfCodeRequestInterrupted(control);
@@ -3107,32 +3241,84 @@ export class LspManager {
3107
3241
  getWarmPosition,
3108
3242
  control,
3109
3243
  );
3110
- if (available) {
3111
- this.warmedWorkspaceSymbolFiles.add(fileKey);
3112
- 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;
3113
3260
  }
3114
3261
 
3262
+ coveredRoots.add(target.projectRoot);
3115
3263
  latestCollection = await collect(this.clients.values(), query);
3116
- if (latestCollection.hasSupport && latestCollection.results.length > 0) {
3117
- return { warmedAny, collection: latestCollection };
3264
+ if (hasResult(latestCollection)) {
3265
+ return { ...outcome(), coverageConfirmed: true };
3118
3266
  }
3119
3267
  }
3268
+ routeCoverage.push(coveredRoots.size > 0 || !routeInferred);
3120
3269
  }
3121
3270
 
3271
+ if (inferredProbeCount > 0 && latestCollection === null) {
3272
+ latestCollection = await collect(this.clients.values(), query);
3273
+ }
3122
3274
  // A completed-empty retry replaces an earlier failure too.
3123
- 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
+ }
3124
3306
  }
3125
3307
 
3126
3308
  private async warmSemanticProject(
3127
3309
  client: LspClient,
3128
- filePath: string,
3310
+ filePath: string | readonly string[],
3129
3311
  preferExactFile: boolean = false,
3130
3312
  control?: CodeRequestControl,
3131
3313
  ): Promise<void> {
3132
3314
  const serverConfig = this.config.servers[client.name];
3133
3315
  if (!serverConfig) return;
3134
3316
 
3135
- 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;
3136
3322
  const projectRoot = preferExactFile
3137
3323
  ? resolveRootForFile(resolvedFile, client.name, serverConfig.rootMarkers, {
3138
3324
  knownRoots: this.knownRoots,
@@ -3150,7 +3336,7 @@ export class LspManager {
3150
3336
  return;
3151
3337
  }
3152
3338
 
3153
- const probe = this.performWarmProbe(client, resolvedFile, projectKey, control);
3339
+ const probe = this.performWarmProbe(client, candidates, projectKey, control);
3154
3340
  this.pendingWarmProbes.set(projectKey, probe);
3155
3341
  try {
3156
3342
  await raceRequestControl(probe, control);
@@ -3161,28 +3347,40 @@ export class LspManager {
3161
3347
  }
3162
3348
  }
3163
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
+ */
3164
3355
  private async performWarmProbe(
3165
3356
  client: LspClient,
3166
- resolvedFile: string,
3357
+ candidates: readonly string[],
3167
3358
  projectKey: string,
3168
3359
  control?: CodeRequestControl,
3169
3360
  ): Promise<void> {
3170
3361
  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
3362
  // The probe is shared state for concurrent callers. One caller's
3176
3363
  // cancellation must not cancel the shared in-flight queries, so only the
3177
3364
  // Debug Operation ID is forwarded; each caller races its own wait above.
3178
3365
  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) {
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) {
3186
3384
  this.warmedSemanticProjects.add(projectKey);
3187
3385
  }
3188
3386
  }
@@ -3213,7 +3411,7 @@ export class LspManager {
3213
3411
 
3214
3412
  private clearWarmedWorkspaceSymbolFiles(serverName: string, root: string): void {
3215
3413
  const prefix = `${serverName}:${path.resolve(root)}`;
3216
- for (const key of Array.from(this.warmedWorkspaceSymbolFiles)) {
3414
+ for (const key of Array.from(this.warmedWorkspaceSymbolFiles.keys())) {
3217
3415
  if (key === prefix || key.startsWith(`${prefix}${path.sep}`)) {
3218
3416
  this.warmedWorkspaceSymbolFiles.delete(key);
3219
3417
  }
@@ -139,6 +139,8 @@ function recordScopeDecisionEvent(manager: LspManager): void {
139
139
  }
140
140
  }
141
141
 
142
+ const FILE_STARTUP_FAILURE_REASON = "The routed LSP client could not be started for this file";
143
+
142
144
  class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
143
145
  constructor(private readonly manager: LspManager) {}
144
146
 
@@ -157,6 +159,7 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
157
159
  const resolvedPath = this.resolveFilePath(filePath);
158
160
  const client = await this.manager.ensureFileOpen(resolvedPath, {
159
161
  recoverProcessCrash: true,
162
+ protectInputs: true,
160
163
  control,
161
164
  });
162
165
  if (!client) return unavailableFileQuery("hover", resolvedPath);
@@ -171,6 +174,7 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
171
174
  const resolvedPath = this.resolveFilePath(filePath);
172
175
  const client = await this.manager.ensureFileOpen(resolvedPath, {
173
176
  recoverProcessCrash: true,
177
+ protectInputs: true,
174
178
  control,
175
179
  });
176
180
  if (!client) return unavailableFileQuery("definition", resolvedPath);
@@ -185,6 +189,7 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
185
189
  const resolvedPath = this.resolveFilePath(filePath);
186
190
  const client = await this.manager.ensureFileOpen(resolvedPath, {
187
191
  recoverProcessCrash: true,
192
+ protectInputs: true,
188
193
  control,
189
194
  });
190
195
  if (!client) return unavailableFileQuery("references", resolvedPath);
@@ -202,6 +207,7 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
202
207
  const resolvedPath = this.resolveFilePath(filePath);
203
208
  const client = await this.manager.ensureFileOpen(resolvedPath, {
204
209
  recoverProcessCrash: true,
210
+ protectInputs: true,
205
211
  control,
206
212
  });
207
213
  if (!client) return unavailableFileQuery("implementation", resolvedPath);
@@ -215,6 +221,7 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
215
221
  const resolvedPath = this.resolveFilePath(filePath);
216
222
  const client = await this.manager.ensureFileOpen(resolvedPath, {
217
223
  recoverProcessCrash: true,
224
+ protectInputs: true,
218
225
  control,
219
226
  });
220
227
  if (!client) return unavailableFileQuery("document symbols", resolvedPath);
@@ -260,6 +267,7 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
260
267
  const resolvedPath = this.resolveFilePath(filePath);
261
268
  const client = await this.manager.ensureFileOpen(resolvedPath, {
262
269
  recoverProcessCrash: true,
270
+ protectInputs: true,
263
271
  control,
264
272
  });
265
273
  if (!client) return null;
@@ -309,7 +317,7 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
309
317
  const processCrashRecovery = this.manager.getProcessCrashRecoveryReportForFile(resolvedPath);
310
318
  return {
311
319
  kind: "unavailable",
312
- reason: "No LSP client can serve this file",
320
+ reason: this.fileUnavailableReason(resolvedPath, "No LSP client can serve this file"),
313
321
  ...(processCrashRecovery ? { processCrashRecovery } : {}),
314
322
  };
315
323
  }
@@ -347,7 +355,7 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
347
355
  if (!client) {
348
356
  return {
349
357
  kind: "unavailable",
350
- reason: "The routed LSP client could not be started for this file",
358
+ reason: this.fileUnavailableReason(resolvedPath, FILE_STARTUP_FAILURE_REASON),
351
359
  ...startup,
352
360
  ...recovery,
353
361
  };
@@ -355,6 +363,15 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
355
363
  return { kind: "ready", ...startup, ...recovery };
356
364
  }
357
365
 
366
+ private fileUnavailableReason(filePath: string, configuredRouteReason: string): string {
367
+ if (!this.manager.hasConfiguredServerForFile(filePath)) {
368
+ return "No compatible LSP server is configured for this file";
369
+ }
370
+ return this.manager.hasFailedStartupForFile(filePath)
371
+ ? FILE_STARTUP_FAILURE_REASON
372
+ : configuredRouteReason;
373
+ }
374
+
358
375
  /**
359
376
  * Wait until all started LSP clients are ready for semantic queries.
360
377
  * Performs one representative warm-up probe per client/root.
@@ -21,6 +21,7 @@ export async function runRoutedRename(options: {
21
21
  const { manager, file, position, newName, control } = options;
22
22
  const client = await manager.ensureFileOpen(file, {
23
23
  recoverProcessCrash: true,
24
+ protectInputs: true,
24
25
  control,
25
26
  });
26
27
  if (!client) return null;
@@ -143,7 +143,11 @@ export interface WorkspaceLspRuntime extends WorkspaceLspDiagnosticSurface {
143
143
  filePath: string,
144
144
  control?: CodeRequestControl,
145
145
  ): Promise<CodeQueryResult<DocumentSymbol[] | SymbolInformation[]>>;
146
- /** Collect symbols from routes that can contribute within the optional path scopes. */
146
+ /**
147
+ * Collect symbols from contributing routes; completeness is relative to scopes.
148
+ * Data may include out-of-scope rows, which callers must filter themselves.
149
+ * Only policy exclusions proven outside every scope are irrelevant to completeness.
150
+ */
147
151
  workspaceSymbol(
148
152
  query: string,
149
153
  control?: CodeRequestControl,