@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.
@@ -80,6 +80,11 @@ import type {
80
80
  SemanticInputSnapshot,
81
81
  } from "./client-semantic-input-barrier.ts";
82
82
  import { SemanticInputSynchronizationError } from "./client-semantic-input-errors.ts";
83
+ import {
84
+ type ProjectMembership,
85
+ readTypeScriptProjectMembership,
86
+ typeScriptProjectInfoParams,
87
+ } from "./client-typescript-project.ts";
83
88
  import { JsonRpcClient, JsonRpcRequestError } from "./transport.ts";
84
89
 
85
90
  const SHUTDOWN_TIMEOUT_MS = 5_000;
@@ -89,6 +94,12 @@ const CONTENT_MODIFIED_RETRY_DELAY_MS = 100;
89
94
 
90
95
  /** Bounded stderr tail kept for startup-failure error messages. */
91
96
  const STDERR_TAIL_MAX_CHARS = 4_000;
97
+ /**
98
+ * Bound for draining a failed startup's stdio. An initialize write can fail
99
+ * (`EPIPE`) before the exiting server's stderr is read; waiting for `close`
100
+ * lets the failure report the server's own reason instead of the transport error.
101
+ */
102
+ const STARTUP_FAILURE_DRAIN_MS = 500;
92
103
 
93
104
  interface SemanticRequestAttemptState {
94
105
  inputRevision: number | undefined;
@@ -492,6 +503,20 @@ function killFailedStartupProcess(child: ChildProcess): void {
492
503
  child.kill();
493
504
  }
494
505
 
506
+ /** Resolve once `closed()` holds or the child emits `close`, bounded by `ms`. */
507
+ function waitForStdioClose(child: ChildProcess, closed: () => boolean, ms: number): Promise<void> {
508
+ if (closed()) return Promise.resolve();
509
+ return new Promise((resolve) => {
510
+ const done = () => {
511
+ clearTimeout(timer);
512
+ child.off("close", done);
513
+ resolve();
514
+ };
515
+ const timer = setTimeout(done, ms);
516
+ child.once("close", done);
517
+ });
518
+ }
519
+
495
520
  // ── Types ─────────────────────────────────────────────────────────────
496
521
  export type ClientStatus = "initializing" | "running" | "error" | "shutdown";
497
522
 
@@ -726,19 +751,24 @@ export class LspClient {
726
751
  });
727
752
  this.rpc.onRequest((method, params) => this.handleServerRequest(method, params));
728
753
 
729
- // Handle crashes
754
+ // Handle crashes. A failing initialize reports its own detailed failure.
755
+ let reportingStartupFailure = false;
730
756
  this.process.on("exit", (_code) => {
731
- this.handleProcessFailure(new Error("Client crashed"));
757
+ if (!reportingStartupFailure) this.handleProcessFailure(new Error("Client crashed"));
732
758
  });
733
759
 
734
760
  this.process.on("error", (_err) => {
735
- this.handleProcessFailure(new Error("Client process error"));
761
+ if (!reportingStartupFailure) this.handleProcessFailure(new Error("Client process error"));
736
762
  });
737
763
 
738
764
  // Capture a bounded stderr tail (discarded otherwise) so startup
739
765
  // failures can surface the server's own exit reason — expired builds,
740
766
  // missing runtimes, or invalid flags. Stderr never reaches the agent.
741
767
  let stderrTail = "";
768
+ let stdioClosed = false;
769
+ this.process.once("close", () => {
770
+ stdioClosed = true;
771
+ });
742
772
  this.process.stderr?.on("data", (chunk: Buffer | string) => {
743
773
  stderrTail = `${stderrTail}${chunk}`.slice(-STDERR_TAIL_MAX_CHARS);
744
774
  });
@@ -777,12 +807,16 @@ export class LspClient {
777
807
 
778
808
  this.armNoProgressTimer();
779
809
  } catch (err) {
810
+ const child = this.process;
811
+ reportingStartupFailure = true;
812
+ killFailedStartupProcess(child);
813
+ // Let already-written stderr drain before reporting the failure.
814
+ await waitForStdioClose(child, () => stdioClosed, STARTUP_FAILURE_DRAIN_MS);
780
815
  const stderrDetail = stderrTail.trim() ? `\n${this.name} stderr:\n${stderrTail.trim()}` : "";
781
816
  const failure = new Error(`${this.name}: initialize failed: ${err}${stderrDetail}`, {
782
817
  cause: err,
783
818
  });
784
819
  this.handleProcessFailure(failure);
785
- killFailedStartupProcess(this.process);
786
820
  throw failure;
787
821
  }
788
822
  }
@@ -879,6 +913,33 @@ export class LspClient {
879
913
  }
880
914
 
881
915
  // ── Document Synchronization and Diagnostics ────────────────────────
916
+ /**
917
+ * Admit a first open between semantic attempts, reading content after the wait.
918
+ * Semantic/refactor callers protect their original inputs; ordinary tracking
919
+ * can adopt fresh content after a known change instead.
920
+ */
921
+ async openDocument(
922
+ filePath: string,
923
+ readContent: () => string,
924
+ control?: CodeRequestControl,
925
+ protectInputs = false,
926
+ ): Promise<void> {
927
+ const trackedCount = this.openFiles.length;
928
+ await this.diagnostics.openDocument(filePath, readContent, control, protectInputs);
929
+ this.publishTrackedFileChange(trackedCount);
930
+ }
931
+
932
+ /** Restore retained state without resurrecting a closed or replaced document. */
933
+ async restoreDocument(
934
+ filePath: string,
935
+ readContent: () => string,
936
+ isCurrent: () => boolean,
937
+ ): Promise<void> {
938
+ const trackedCount = this.openFiles.length;
939
+ await this.diagnostics.restoreDocument(filePath, readContent, isCurrent);
940
+ this.publishTrackedFileChange(trackedCount);
941
+ }
942
+
882
943
  /** Open a document, or update it when it is already open. */
883
944
  didOpen(filePath: string, content: string): void {
884
945
  const trackedCount = this.openFiles.length;
@@ -1161,6 +1222,23 @@ export class LspClient {
1161
1222
  );
1162
1223
  }
1163
1224
 
1225
+ /**
1226
+ * Report whether the server placed an open file in a configured project.
1227
+ * Only the TypeScript route can answer; other routes return `unknown`.
1228
+ */
1229
+ async projectMembership(
1230
+ filePath: string,
1231
+ control?: CodeRequestControl,
1232
+ ): Promise<ProjectMembership> {
1233
+ if (!this.hasTypeScriptRequestCommand()) return "unknown";
1234
+ const result = await this.query<unknown>(
1235
+ "workspace/executeCommand",
1236
+ typeScriptProjectInfoParams(fileToUri(filePath)),
1237
+ control,
1238
+ );
1239
+ return result.kind === "completed" ? readTypeScriptProjectMembership(result.data) : "unknown";
1240
+ }
1241
+
1164
1242
  async workspaceSymbol(
1165
1243
  query: string,
1166
1244
  control?: CodeRequestControl,
@@ -1258,6 +1336,7 @@ export class LspClient {
1258
1336
  | undefined;
1259
1337
  },
1260
1338
  {
1339
+ documentUri: params.textDocument.uri,
1261
1340
  inputRevision: options.inputRevision,
1262
1341
  onVerifiedSnapshot: (snapshot) => {
1263
1342
  inputRevision = snapshot.revision;
@@ -1309,17 +1388,22 @@ export class LspClient {
1309
1388
  triggerKind: context.triggerKind ?? CodeActionTriggerKind.Invoked,
1310
1389
  ...(context.only ? { only: [...context.only] } : {}),
1311
1390
  };
1312
- return this.runRefactorTransaction("textDocument/codeAction", control, async (transaction) => {
1313
- const raw = await transaction.rpc.sendRequest(
1314
- "textDocument/codeAction",
1315
- { textDocument: { uri: fileToUri(filePath) }, range, context: requestContext },
1316
- control,
1317
- );
1318
- if (raw === null || raw === undefined) return null;
1319
- if (!Array.isArray(raw)) throw new Error("LSP server returned malformed code actions.");
1391
+ return this.runRefactorTransaction(
1392
+ "textDocument/codeAction",
1393
+ control,
1394
+ async (transaction) => {
1395
+ const raw = await transaction.rpc.sendRequest(
1396
+ "textDocument/codeAction",
1397
+ { textDocument: { uri: fileToUri(filePath) }, range, context: requestContext },
1398
+ control,
1399
+ );
1400
+ if (raw === null || raw === undefined) return null;
1401
+ if (!Array.isArray(raw)) throw new Error("LSP server returned malformed code actions.");
1320
1402
 
1321
- return this.resolveCodeActions(raw, transaction, control, requestContext.only);
1322
- });
1403
+ return this.resolveCodeActions(raw, transaction, control, requestContext.only);
1404
+ },
1405
+ { documentUri: fileToUri(filePath) },
1406
+ );
1323
1407
  }
1324
1408
 
1325
1409
  async implementation(
@@ -1384,9 +1468,10 @@ export class LspClient {
1384
1468
  control: CodeRequestControl | undefined,
1385
1469
  operation: (transaction: RefactorTransaction) => Promise<T | null | undefined>,
1386
1470
  options: {
1471
+ readonly documentUri: string;
1387
1472
  readonly onVerifiedSnapshot?: (snapshot: SemanticInputSnapshot) => void;
1388
1473
  readonly inputRevision?: number;
1389
- } = {},
1474
+ },
1390
1475
  ): Promise<CodeQueryResult<T | null>> {
1391
1476
  throwIfCodeRequestInterrupted(control);
1392
1477
  if (!this.rpc || this._status !== "running") {
@@ -1405,6 +1490,7 @@ export class LspClient {
1405
1490
  retryGuard.assertCurrent();
1406
1491
  const data = await this.runRefactorTransactionAttempt({
1407
1492
  method,
1493
+ documentUri: options.documentUri,
1408
1494
  control,
1409
1495
  operation,
1410
1496
  retryGuard,
@@ -1446,9 +1532,10 @@ export class LspClient {
1446
1532
  }
1447
1533
  }
1448
1534
 
1449
- /** Run the complete prepare/request/resolve transaction for one input snapshot. */
1535
+ /** Admit one prepare/request/resolve attempt, not retries or rename's own enrollment. */
1450
1536
  private async runRefactorTransactionAttempt<T>(options: {
1451
1537
  method: string;
1538
+ documentUri: string;
1452
1539
  control?: CodeRequestControl;
1453
1540
  operation: (transaction: RefactorTransaction) => Promise<T | null | undefined>;
1454
1541
  retryGuard: SemanticInputRequestRetryGuard;
@@ -1459,27 +1546,36 @@ export class LspClient {
1459
1546
  throwIfCodeRequestInterrupted(control);
1460
1547
  retryGuard.assertCurrent();
1461
1548
  await this.getReady(control);
1462
- const inputSnapshot = await this.diagnostics.synchronizeSemanticInputs(control);
1463
- attempt.inputRevision = inputSnapshot.revision;
1464
- throwIfCodeRequestInterrupted(control);
1465
- retryGuard.assertCurrent();
1466
- const rpc = this.rpc;
1467
- if (!rpc || this._status !== "running") {
1468
- throw new Error(`LSP request ${method} is unavailable because the client is not running.`);
1469
- }
1470
- const data = await operation({
1471
- rpc,
1472
- assertCurrent: async () => {
1549
+ return this.diagnostics.withSemanticRequest(
1550
+ async () => {
1473
1551
  retryGuard.assertCurrent();
1474
- return this.diagnostics.assertSemanticInputsCurrent(inputSnapshot, control);
1552
+ const inputSnapshot = await this.diagnostics.synchronizeSemanticInputs(control);
1553
+ attempt.inputRevision = inputSnapshot.revision;
1554
+ throwIfCodeRequestInterrupted(control);
1555
+ retryGuard.assertCurrent();
1556
+ const rpc = this.rpc;
1557
+ if (!rpc || this._status !== "running") {
1558
+ throw new Error(
1559
+ `LSP request ${method} is unavailable because the client is not running.`,
1560
+ );
1561
+ }
1562
+ const data = await operation({
1563
+ rpc,
1564
+ assertCurrent: async () => {
1565
+ retryGuard.assertCurrent();
1566
+ return this.diagnostics.assertSemanticInputsCurrent(inputSnapshot, control);
1567
+ },
1568
+ });
1569
+ const verifiedSnapshot = await this.diagnostics.assertSemanticInputsCurrent(
1570
+ inputSnapshot,
1571
+ control,
1572
+ );
1573
+ onVerifiedSnapshot?.(verifiedSnapshot);
1574
+ return data ?? null;
1475
1575
  },
1476
- });
1477
- const verifiedSnapshot = await this.diagnostics.assertSemanticInputsCurrent(
1478
- inputSnapshot,
1479
1576
  control,
1577
+ options.documentUri,
1480
1578
  );
1481
- onVerifiedSnapshot?.(verifiedSnapshot);
1482
- return data ?? null;
1483
1579
  }
1484
1580
 
1485
1581
  private async query<T>(
@@ -1651,17 +1747,28 @@ export class LspClient {
1651
1747
  throwIfCodeRequestInterrupted(control);
1652
1748
  retryGuard.assertCurrent();
1653
1749
  await this.getReady(control);
1654
- const inputSnapshot = await this.diagnostics.synchronizeSemanticInputs(control);
1655
- attempt.inputRevision = inputSnapshot.revision;
1656
- throwIfCodeRequestInterrupted(control);
1657
- retryGuard.assertCurrent();
1658
- const rpc = this.rpc;
1659
- if (!rpc || this._status !== "running") {
1660
- throw new Error(`LSP request ${method} is unavailable because the client is not running.`);
1661
- }
1662
- const data = (await rpc.sendRequest(method, params, control)) as T | null | undefined;
1663
- await this.diagnostics.assertSemanticInputsCurrent(inputSnapshot, control);
1664
- return data ?? null;
1750
+ // Local query methods construct the standard TextDocumentIdentifier envelope.
1751
+ const documentUri = (params as { textDocument?: { uri: string } } | null)?.textDocument?.uri;
1752
+ return this.diagnostics.withSemanticRequest(
1753
+ async () => {
1754
+ retryGuard.assertCurrent();
1755
+ const inputSnapshot = await this.diagnostics.synchronizeSemanticInputs(control);
1756
+ attempt.inputRevision = inputSnapshot.revision;
1757
+ throwIfCodeRequestInterrupted(control);
1758
+ retryGuard.assertCurrent();
1759
+ const rpc = this.rpc;
1760
+ if (!rpc || this._status !== "running") {
1761
+ throw new Error(
1762
+ `LSP request ${method} is unavailable because the client is not running.`,
1763
+ );
1764
+ }
1765
+ const data = (await rpc.sendRequest(method, params, control)) as T | null | undefined;
1766
+ await this.diagnostics.assertSemanticInputsCurrent(inputSnapshot, control);
1767
+ return data ?? null;
1768
+ },
1769
+ control,
1770
+ documentUri,
1771
+ );
1665
1772
  }
1666
1773
 
1667
1774
  private handleServerRequest(method: string, params: unknown): unknown {
@@ -1,6 +1,6 @@
1
- /** Reason for a partial or unavailable single-file result when the push stays tentative. */
1
+ /** Explain the lack of request-based confirmation when only ambient push evidence is available. */
2
2
  export const TENTATIVE_PUSH_UNAVAILABLE_REASON =
3
- "A diagnostic publication is ambient evidence only; it may show issues, but an empty publication cannot confirm that the document is clean.";
3
+ "Only ambient push diagnostics are available for this file; completion of the diagnostic check cannot be confirmed. An empty publication cannot confirm that the document is clean.";
4
4
 
5
5
  /** A final evidence state for one tracked document. */
6
6
  export type DiagnosticEvidenceStatus = "confirmed" | "unconfirmed" | "failed" | "removed";
@@ -1,4 +1,4 @@
1
- import { statSync } from "node:fs";
1
+ import { type Dirent, realpathSync, statSync } from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import {
4
4
  type CodeQueryResult,
@@ -25,6 +25,10 @@ type WorkspaceSymbolLike = SymbolInformation | WorkspaceSymbol;
25
25
 
26
26
  const DEFAULT_WARM_FILE_DEPTH = 4;
27
27
  const DEFAULT_MARKER_SCAN_DEPTH = 6;
28
+ /** Candidates per marker root; later candidates help only when earlier ones land outside the project. */
29
+ const WARM_FILES_PER_ROOT = 3;
30
+ /** Conventional source directories tried before other top-level entries. */
31
+ const PREFERRED_SOURCE_DIRECTORIES = ["src", "lib", "app"];
28
32
 
29
33
  export interface WorkspaceSymbolWarmTarget {
30
34
  projectRoot: string;
@@ -63,6 +67,26 @@ export interface WorkspaceSymbolCollection {
63
67
  excludedCount?: number;
64
68
  /** Bounded paths removed by the automatic path policy. */
65
69
  excludedExamples?: string[];
70
+ /** Known coverage limits: the server may not have loaded every project in scope. */
71
+ coverageGap?: string;
72
+ }
73
+
74
+ /**
75
+ * Mark a collection whose warm-up never reached a configured project.
76
+ * The server answered only from inferred projects of the opened probe files,
77
+ * so an empty or short result does not prove a symbol is absent.
78
+ */
79
+ export function workspaceSymbolCoverageGap(
80
+ collection: WorkspaceSymbolCollection,
81
+ inferredProbeCount: number,
82
+ ): WorkspaceSymbolCollection {
83
+ const noun = `file${inferredProbeCount === 1 ? "" : "s"}`;
84
+ return {
85
+ ...collection,
86
+ coverageGap:
87
+ `Workspace-symbol coverage is limited: ${inferredProbeCount} opened warm-up ${noun} ` +
88
+ "loaded only inferred projects, not a configured project. Open a project source file and retry.",
89
+ };
66
90
  }
67
91
 
68
92
  export async function collectWorkspaceSymbols(
@@ -96,10 +120,8 @@ function workspaceSymbolExclusionReason(collection: WorkspaceSymbolCollection):
96
120
  const excludedCount = collection.excludedCount ?? 0;
97
121
  if (excludedCount === 0) return null;
98
122
  const noun = `workspace-symbol result${excludedCount === 1 ? "" : "s"}`;
99
- const examples = collection.excludedExamples?.length
100
- ? ` Examples: ${collection.excludedExamples.join(", ")}.`
101
- : "";
102
- return `Automatic path policy excluded ${excludedCount} ${noun}.${examples}`;
123
+ // Paths belong to typed metadata so consumers can distinguish observed aliases from navigation.
124
+ return `Automatic path policy excluded ${excludedCount} ${noun}.`;
103
125
  }
104
126
 
105
127
  /** Project one multi-client collection into the shared typed query contract. */
@@ -115,8 +137,9 @@ export function workspaceSymbolCollectionResult(
115
137
  );
116
138
  }
117
139
  const exclusion = workspaceSymbolExclusionReason(collection);
118
- if (collection.failures.length > 0 || exclusion) {
140
+ if (collection.failures.length > 0 || exclusion || collection.coverageGap) {
119
141
  const excludedCount = collection.excludedCount ?? 0;
142
+ const exclusionOnly = collection.failures.length === 0 && !collection.coverageGap;
120
143
  const metadata =
121
144
  excludedCount > 0
122
145
  ? {
@@ -124,17 +147,30 @@ export function workspaceSymbolCollectionResult(
124
147
  excludedCount,
125
148
  examples: [...(collection.excludedExamples ?? [])],
126
149
  },
150
+ ...(exclusionOnly ? { exclusionOnly: true } : {}),
127
151
  }
128
152
  : undefined;
129
153
  return partialCodeQuery(
130
154
  collection.results,
131
- [collection.failures.join("; "), exclusion].filter(Boolean).join("; "),
155
+ [collection.failures.join("; "), collection.coverageGap, exclusion]
156
+ .filter(Boolean)
157
+ .join("; "),
132
158
  metadata,
133
159
  );
134
160
  }
135
161
  return completedCodeQuery(collection.results);
136
162
  }
137
163
 
164
+ /**
165
+ * Choose files to open so a workspace-symbol server loads the projects under `root`.
166
+ *
167
+ * Returns up to three candidates per marker root, interleaved so every root's
168
+ * best candidate comes before any root's fallback; nested roots first. Within a
169
+ * root, conventional source directories come first and hidden entries last:
170
+ * root-level dotfiles and tool configs are often outside the configured program
171
+ * (for example `tsconfig.json` `include`), and opening only such a file loads an
172
+ * unrelated project. Callers try candidates in order.
173
+ */
138
174
  export function findWorkspaceSymbolWarmTargets(
139
175
  root: string,
140
176
  rootMarkers: string[],
@@ -158,7 +194,7 @@ export function findWorkspaceSymbolWarmTargets(
158
194
  return searchRoot
159
195
  ? findWorkspaceSymbolWarmTargets(searchRoot, rootMarkers, fileTypes, {
160
196
  ...options,
161
- policy,
197
+ policy: explicitScopePolicy(policy, scope),
162
198
  scopes: undefined,
163
199
  })
164
200
  : [];
@@ -174,26 +210,76 @@ export function findWorkspaceSymbolWarmTargets(
174
210
  }
175
211
 
176
212
  const markerRoots = collectMarkerRoots(resolvedRoot, rootMarkers, maxMarkerDepth, policy);
177
- const targets = markerRoots
178
- .map((entry) => {
179
- const file = findWarmFileRecursive(entry.root, allowed, maxFileDepth, policy);
180
- return file ? { projectRoot: entry.root, file, priority: entry.priority } : null;
181
- })
182
- .filter((entry): entry is { projectRoot: string; file: string; priority: number } =>
183
- Boolean(entry),
184
- )
213
+ const perRoot = markerRoots
185
214
  .sort(
186
215
  (a, b) =>
187
- a.priority - b.priority ||
188
- a.projectRoot.length - b.projectRoot.length ||
189
- a.projectRoot.localeCompare(b.projectRoot),
216
+ a.priority - b.priority || a.root.length - b.root.length || a.root.localeCompare(b.root),
190
217
  )
191
- .map(({ projectRoot, file }) => ({ projectRoot, file }));
218
+ .map((entry) =>
219
+ findWarmFiles(entry.root, allowed, maxFileDepth, policy).map((file) => ({
220
+ projectRoot: entry.root,
221
+ file,
222
+ })),
223
+ );
224
+ // Interleave by rank so each root's best candidate precedes any root's fallback.
225
+ const targets = Array.from({ length: WARM_FILES_PER_ROOT }, (_, rank) =>
226
+ perRoot.flatMap((candidates) => candidates[rank] ?? []),
227
+ ).flat();
228
+
229
+ if (targets.length > 0) {
230
+ // Keep the first, most specific root for a file a parent root also reaches.
231
+ const seen = new Set<string>();
232
+ return targets.filter((target) => !seen.has(target.file) && Boolean(seen.add(target.file)));
233
+ }
192
234
 
193
- if (targets.length > 0) return targets;
235
+ return findWarmFiles(resolvedRoot, allowed, maxFileDepth, policy).map((file) => ({
236
+ projectRoot: resolvedRoot,
237
+ file,
238
+ }));
239
+ }
240
+
241
+ /**
242
+ * True when a reported file is inside a scope by spelling or physical identity.
243
+ * Servers may report realpaths for files requested through symlinked roots.
244
+ */
245
+ export function isWithinAnyScope(file: string, scopes: readonly string[]): boolean {
246
+ if (scopes.some((scope) => isWithinOrEqual(scope, file))) return true;
247
+ try {
248
+ const physicalFile = realpathSync(file);
249
+ return scopes.some((scope) => {
250
+ try {
251
+ return isWithinOrEqual(realpathSync(scope), physicalFile);
252
+ } catch {
253
+ return false;
254
+ }
255
+ });
256
+ } catch {
257
+ return false;
258
+ }
259
+ }
194
260
 
195
- const fallback = findWarmFileRecursive(resolvedRoot, allowed, maxFileDepth, policy);
196
- return fallback ? [{ projectRoot: resolvedRoot, file: fallback }] : [];
261
+ /**
262
+ * Keep an explicitly selected excluded file or directory warmable, matching the
263
+ * rows workspace-symbol filtering allows for that scope. Exclusions nested in an
264
+ * eligible directory scope stay excluded.
265
+ */
266
+ function explicitScopePolicy(
267
+ policy: AutomaticLspPathPolicy,
268
+ scope: string,
269
+ ): AutomaticLspPathPolicy {
270
+ const kind = statSync(scope, { throwIfNoEntry: false });
271
+ if (!kind) return policy;
272
+ const selected = kind.isFile()
273
+ ? (candidate: string) => path.resolve(candidate) === path.resolve(scope)
274
+ : policy.isEligible(scope, "directory")
275
+ ? null
276
+ : (candidate: string) => isWithinOrEqual(scope, candidate);
277
+ if (!selected) return policy;
278
+ return {
279
+ ...policy,
280
+ isEligible: (candidate, candidateKind) =>
281
+ selected(candidate) ? true : policy.isEligible(candidate, candidateKind),
282
+ };
197
283
  }
198
284
 
199
285
  interface MarkerRootEntry {
@@ -234,6 +320,54 @@ function collectMarkerRoots(
234
320
  }));
235
321
  }
236
322
 
323
+ /**
324
+ * Pick at most {@link WARM_FILES_PER_ROOT} files under `root`, one per top-level
325
+ * group: preferred source directories, other directories, root files, then
326
+ * hidden entries.
327
+ */
328
+ function findWarmFiles(
329
+ root: string,
330
+ allowed: Set<string>,
331
+ depth: number,
332
+ policy: AutomaticLspPathPolicy,
333
+ ): string[] {
334
+ let topLevel: readonly Dirent[] = [];
335
+ walkAutomaticLspTree(policy, root, 0, (_directory, entries) => {
336
+ topLevel = entries;
337
+ return true;
338
+ });
339
+ const isAllowedFile = (entry: Dirent) =>
340
+ (entry.isFile() || entry.isSymbolicLink()) &&
341
+ allowed.has(path.extname(entry.name).slice(1).toLowerCase());
342
+ const rank = (entry: Dirent) => {
343
+ const hidden = entry.name.startsWith(".") ? 3 : 0;
344
+ if (!entry.isDirectory()) return hidden + 2;
345
+ return hidden + (PREFERRED_SOURCE_DIRECTORIES.includes(entry.name) ? 0 : 1);
346
+ };
347
+ const ordered = topLevel
348
+ .filter((entry) => entry.isDirectory() || isAllowedFile(entry))
349
+ .map((entry, index) => ({ entry, index, rank: rank(entry) }))
350
+ .sort((a, b) => a.rank - b.rank || a.index - b.index);
351
+
352
+ const files: string[] = [];
353
+ const rootFileRanks = new Set<number>();
354
+ for (const { entry, rank: entryRank } of ordered) {
355
+ if (files.length >= WARM_FILES_PER_ROOT) break;
356
+ const entryPath = path.join(root, entry.name);
357
+ if (!entry.isDirectory()) {
358
+ // One root file per visibility group is enough evidence for that group.
359
+ if (rootFileRanks.has(entryRank)) continue;
360
+ rootFileRanks.add(entryRank);
361
+ files.push(entryPath);
362
+ continue;
363
+ }
364
+ if (depth <= 0) continue;
365
+ const file = findWarmFileRecursive(entryPath, allowed, depth - 1, policy);
366
+ if (file) files.push(file);
367
+ }
368
+ return files;
369
+ }
370
+
237
371
  function findWarmFileRecursive(
238
372
  directory: string,
239
373
  allowed: Set<string>,