@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
@@ -0,0 +1,276 @@
1
+ import * as fs from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import * as path from "node:path";
4
+ import { major, prerelease, valid } from "semver";
5
+ import type { ServerConfig, TypeScriptSelection } from "./server-config.ts";
6
+
7
+ interface CompilerPackage {
8
+ directory: string;
9
+ name?: string;
10
+ version?: string;
11
+ bin?: unknown;
12
+ }
13
+
14
+ /** Launch options and truthful compiler facts for one root; never cached across starts. */
15
+ export interface TypeScriptLaunch {
16
+ /** Native packages use their declared Node launcher, without a PATH dependency. */
17
+ command?: string;
18
+ args?: string[];
19
+ initializationOptions: unknown;
20
+ typescript?: TypeScriptSelection;
21
+ }
22
+
23
+ /** Retain already-known selection facts when validating launch files fails. */
24
+ class TypeScriptLaunchError extends Error {
25
+ readonly typescript: TypeScriptSelection | undefined;
26
+ constructor(message: string, options: ErrorOptions & { typescript?: TypeScriptSelection }) {
27
+ super(message, options);
28
+ this.typescript = options.typescript;
29
+ }
30
+ }
31
+
32
+ /** True for an installed package name/alias, never a path, URL, or package subpath. */
33
+ export function isTypeScriptPackageName(value: unknown): value is string {
34
+ return (
35
+ typeof value === "string" &&
36
+ /^(?:@[a-z0-9._-]+\/)?[a-z0-9_][a-z0-9._-]*$/i.test(value) &&
37
+ ![".", ".."].includes(value)
38
+ );
39
+ }
40
+
41
+ function record(value: unknown): Record<string, unknown> | undefined {
42
+ return value !== null && typeof value === "object" && !Array.isArray(value)
43
+ ? (value as Record<string, unknown>)
44
+ : undefined;
45
+ }
46
+
47
+ /** Resolve fresh package files using Node's root-relative node_modules search paths.
48
+ * Probing the package directory (rather than require.resolve's cached realpath) both
49
+ * detects broken selections and observes package-manager symlink changes on retry.
50
+ * Package exports need not expose package.json; no compiler code is executed.
51
+ */
52
+ function findPackage(name: string, from: string): CompilerPackage | undefined {
53
+ const require = createRequire(path.join(from, "__supi_lsp_resolve__.cjs"));
54
+ for (const base of require.resolve.paths(name) ?? []) {
55
+ const directory = path.join(base, name);
56
+ try {
57
+ fs.lstatSync(directory);
58
+ } catch (error) {
59
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
60
+ throw error;
61
+ }
62
+ return readCompilerPackage(fs.realpathSync(directory));
63
+ }
64
+ return undefined;
65
+ }
66
+
67
+ function readCompilerPackage(directory: string): CompilerPackage {
68
+ const manifest = record(
69
+ JSON.parse(fs.readFileSync(path.join(directory, "package.json"), "utf8")),
70
+ );
71
+ if (
72
+ !manifest ||
73
+ (manifest.name !== undefined && typeof manifest.name !== "string") ||
74
+ (manifest.version !== undefined &&
75
+ (typeof manifest.version !== "string" || !valid(manifest.version)))
76
+ ) {
77
+ throw new Error(`Invalid package manifest at ${directory}`);
78
+ }
79
+ return {
80
+ directory,
81
+ name: typeof manifest.name === "string" ? manifest.name : undefined,
82
+ version: typeof manifest.version === "string" ? manifest.version : undefined,
83
+ bin: manifest.bin,
84
+ };
85
+ }
86
+
87
+ /** Apply the bounded TypeScript compiler policy without changing the settings snapshot.
88
+ * Programmatic configurations without loader provenance remain manual. Explicit
89
+ * launch fields bypass all package probing; a selector may not coexist with them.
90
+ */
91
+ export function resolveTypeScriptLaunch(
92
+ name: string,
93
+ config: ServerConfig,
94
+ root: string,
95
+ ): TypeScriptLaunch {
96
+ const unchanged = { initializationOptions: config.initializationOptions };
97
+ if (name !== "typescript") return unchanged;
98
+ if (isManualTypeScriptLaunch(config, root)) return unchanged;
99
+ const selector = config.typescriptPackage;
100
+ let selected = selector ?? "typescript";
101
+ try {
102
+ if (!isTypeScriptPackageName(selected))
103
+ throw new Error("Expected an installed package name or alias, not a filesystem path.");
104
+ const probe = (installedName: string) => {
105
+ selected = installedName; // Retain the failing candidate's identity for the error.
106
+ return findPackage(installedName, root);
107
+ };
108
+ const selection =
109
+ selector === undefined ? selectAutomaticCompiler(probe) : { selected, pkg: probe(selected) };
110
+ const { pkg } = selection;
111
+ selected = selection.selected;
112
+ if (!pkg) {
113
+ if (selector !== undefined) throw new Error("Selected package is not installed.");
114
+ return unchanged; // Preserve TLS's legacy lookup when neither candidate exists.
115
+ }
116
+ return compilerLaunch(config.initializationOptions, pkg, selected);
117
+ } catch (error) {
118
+ throw new TypeScriptLaunchError(
119
+ `TypeScript launch at ${root}, package "${selected}": ${error instanceof Error ? error.message : String(error)} Repair/install the selected TypeScript package, or use explicit launch configuration; then refresh health. Reload after changing launch settings.`,
120
+ {
121
+ cause: error,
122
+ typescript: error instanceof TypeScriptLaunchError ? error.typescript : undefined,
123
+ },
124
+ );
125
+ }
126
+ }
127
+
128
+ /** Short-circuit on native metadata; validate only the winner's launch files later. */
129
+ function selectAutomaticCompiler(probe: (name: string) => CompilerPackage | undefined): {
130
+ selected: string;
131
+ pkg?: CompilerPackage;
132
+ } {
133
+ const canonical = probe("typescript");
134
+ if (canonical && compilerBackend(canonical) === "native")
135
+ return { selected: "typescript", pkg: canonical };
136
+ const native = probe("@typescript/native");
137
+ if (native) {
138
+ if (compilerBackend(native) !== "native")
139
+ throw new Error("@typescript/native must alias the stable native typescript package.");
140
+ return { selected: "@typescript/native", pkg: native };
141
+ }
142
+ if (canonical) return { selected: "typescript", pkg: canonical };
143
+ return { selected: "@typescript/typescript6", pkg: probe("@typescript/typescript6") };
144
+ }
145
+
146
+ /** Determine launch authority and reject conflicting selectors without probing installations. */
147
+ export function isManualTypeScriptLaunch(config: ServerConfig, root: string): boolean {
148
+ const fields = config.typescriptExplicitFields ?? { command: "programmatic configuration" };
149
+ const tsserver = record(record(config.initializationOptions)?.tsserver);
150
+ const conflicts = [
151
+ ...(fields.command ? [`command (${fields.command})`] : []),
152
+ ...(fields.args ? [`args (${fields.args})`] : []),
153
+ ...(tsserver && Object.hasOwn(tsserver, "path")
154
+ ? [
155
+ `initializationOptions.tsserver.path (${fields.tsserverPath ?? "programmatic configuration"})`,
156
+ ]
157
+ : []),
158
+ ];
159
+ if (config.typescriptPackage !== undefined && conflicts.length) {
160
+ throw new Error(
161
+ `TypeScript launch at ${root}: typescriptPackage "${config.typescriptPackage}" (${fields.typescriptPackage ?? "programmatic configuration"}) conflicts with ${conflicts.join(", ")}. Remove the selector or the explicit launch fields and reload.`,
162
+ );
163
+ }
164
+ return conflicts.length > 0;
165
+ }
166
+
167
+ /** Classify consulted metadata before considering a lower-priority candidate. */
168
+ function compilerBackend(pkg: CompilerPackage): "native" | "tsserver" {
169
+ if (pkg.name === "@typescript/typescript6" && !pkg.version) return "tsserver";
170
+ if (pkg.name === "typescript" && !pkg.version)
171
+ throw new Error(
172
+ `Selected implementation at ${pkg.directory} has no compiler version in package.json.`,
173
+ );
174
+ const version = pkg.version && !prerelease(pkg.version) ? major(pkg.version) : undefined;
175
+ if (pkg.name === "@typescript/typescript6" && version === 6) return "tsserver";
176
+ if (pkg.name === "typescript" && version !== undefined && version <= 7)
177
+ return version === 7 ? "native" : "tsserver";
178
+ throw new Error(
179
+ `Unsupported automatic TypeScript backend ${pkg.name ?? "(unknown identity)"} ${pkg.version ?? "(unknown version)"}. Select an installed stable TS7 or legacy TypeScript package with typescriptPackage, or configure command/args manually.`,
180
+ );
181
+ }
182
+
183
+ function compilerLaunch(
184
+ initializationOptions: unknown,
185
+ selectedPackage: CompilerPackage,
186
+ selected: string,
187
+ ): TypeScriptLaunch {
188
+ let pkg = selectedPackage;
189
+ const backend = compilerBackend(pkg);
190
+ if (backend === "native") return nativeLaunch(initializationOptions, pkg, selected);
191
+ const wrapper = pkg.name === "@typescript/typescript6" ? pkg : undefined;
192
+ if (wrapper) {
193
+ const underlying = findPackage("@typescript/old", wrapper.directory);
194
+ if (!underlying)
195
+ throw new Error(`Wrapper at ${wrapper.directory} has no @typescript/old dependency.`);
196
+ pkg = underlying;
197
+ }
198
+ // TLS ignores an explicit path without implementation version metadata and
199
+ // tries other installations. Fail here rather than report a compiler it won't use.
200
+ if (!pkg.version)
201
+ throw new Error(
202
+ `Selected implementation at ${pkg.directory} has no compiler version in package.json; TLS would ignore this selection.`,
203
+ );
204
+ if (compilerBackend(pkg) !== "tsserver" || pkg.name !== "typescript")
205
+ throw new Error(`Wrapper implementation at ${pkg.directory} must be legacy TypeScript.`);
206
+ const serverPath = path.join(pkg.directory, "lib/tsserver.js");
207
+ if (!fs.statSync(serverPath).isFile()) throw new Error(`No tsserver.js at ${serverPath}.`);
208
+ const options = record(initializationOptions);
209
+ const tsserver = record(options?.tsserver);
210
+ return {
211
+ initializationOptions: { ...options, tsserver: { ...tsserver, path: serverPath } },
212
+ typescript: {
213
+ backend: "tsserver",
214
+ packageName: selected,
215
+ compilerVersion: pkg.version,
216
+ ...(wrapper?.version ? { wrapperVersion: wrapper.version } : {}),
217
+ },
218
+ };
219
+ }
220
+
221
+ function nativeLaunch(
222
+ initializationOptions: unknown,
223
+ pkg: CompilerPackage,
224
+ selected: string,
225
+ ): TypeScriptLaunch {
226
+ const typescript: TypeScriptSelection = {
227
+ backend: "native",
228
+ packageName: selected,
229
+ compilerVersion: pkg.version,
230
+ };
231
+ try {
232
+ return {
233
+ command: process.execPath,
234
+ args: [nativeLauncher(pkg), "--lsp", "--stdio"],
235
+ initializationOptions,
236
+ typescript,
237
+ };
238
+ } catch (error) {
239
+ throw new TypeScriptLaunchError(error instanceof Error ? error.message : String(error), {
240
+ cause: error,
241
+ typescript,
242
+ });
243
+ }
244
+ }
245
+
246
+ function nativeLauncher(pkg: CompilerPackage): string {
247
+ const executable = record(pkg.bin)?.tsc;
248
+ if (typeof executable !== "string" || !executable || path.isAbsolute(executable))
249
+ throw new Error(
250
+ `Native package at ${pkg.directory} must declare a relative bin.tsc executable.`,
251
+ );
252
+ const launcher = path.resolve(pkg.directory, executable);
253
+ const relative = path.relative(pkg.directory, launcher);
254
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || !fs.statSync(launcher).isFile())
255
+ throw new Error(`Invalid native tsc executable at ${launcher}.`);
256
+ return launcher;
257
+ }
258
+
259
+ /** Non-throwing selection probe shared by discovery and missing-server reporting. */
260
+ export function inspectTypeScriptLaunch(
261
+ name: string,
262
+ config: ServerConfig,
263
+ root: string,
264
+ ): Pick<TypeScriptLaunch, "typescript"> & { command: string; failureMessage?: string } {
265
+ try {
266
+ const { command, typescript } = resolveTypeScriptLaunch(name, config, root);
267
+ return { command: command ?? config.command, ...(typescript ? { typescript } : {}) };
268
+ } catch (error) {
269
+ const typescript = error instanceof TypeScriptLaunchError ? error.typescript : undefined;
270
+ return {
271
+ command: typescript?.backend === "native" ? process.execPath : config.command,
272
+ ...(typescript ? { typescript } : {}),
273
+ failureMessage: error instanceof Error ? error.message : String(error),
274
+ };
275
+ }
276
+ }
@@ -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,6 +1,10 @@
1
1
  import type { LspClient } from "../client/client.ts";
2
2
  import { getServerOperationSupport } from "../config/operation-support.ts";
3
- import type { ProjectServerInfo, ProjectServerStatusReason } from "../config/server-config.ts";
3
+ import type {
4
+ ProjectServerInfo,
5
+ ProjectServerStatusReason,
6
+ TypeScriptSelection,
7
+ } from "../config/server-config.ts";
4
8
  import { displayRelativeFilePath } from "../summary.ts";
5
9
 
6
10
  interface ProjectServerInfoInput {
@@ -10,29 +14,37 @@ interface ProjectServerInfoInput {
10
14
  client: LspClient | undefined;
11
15
  unavailableReason?: "missing-command" | "start-failed" | "runtime-error";
12
16
  statusReason?: ProjectServerStatusReason;
17
+ typescript?: TypeScriptSelection;
18
+ failureMessage?: string;
13
19
  /** Filter files from ambient project status without changing explicit routing. */
14
20
  includeOpenFile?: (file: string) => boolean;
15
21
  }
16
22
 
23
+ function projectStatus(input: ProjectServerInfoInput): ProjectServerInfo["status"] {
24
+ if (input.statusReason || input.failureMessage) return "error";
25
+ if (input.client?.status === "running") return "running";
26
+ if (
27
+ input.client?.status === "error" ||
28
+ input.unavailableReason === "start-failed" ||
29
+ input.unavailableReason === "runtime-error"
30
+ )
31
+ return "error";
32
+ return "unavailable";
33
+ }
34
+
17
35
  export function buildProjectServerInfo(
18
36
  input: ProjectServerInfoInput,
19
37
  cwd: string,
20
38
  ): ProjectServerInfo {
21
- const status = input.statusReason
22
- ? "error"
23
- : input.client?.status === "running"
24
- ? "running"
25
- : input.client?.status === "error" || input.unavailableReason === "start-failed"
26
- ? "error"
27
- : input.unavailableReason === "runtime-error"
28
- ? "error"
29
- : "unavailable";
39
+ const status = projectStatus(input);
30
40
 
31
41
  return {
32
42
  name: input.serverName,
33
43
  root: input.root,
34
44
  fileTypes: input.fileTypes,
35
45
  status,
46
+ ...(input.typescript ? { typescript: input.typescript } : {}),
47
+ ...(input.failureMessage ? { failureMessage: input.failureMessage } : {}),
36
48
  ...(input.statusReason ? { statusReason: input.statusReason } : {}),
37
49
  openFiles:
38
50
  input.client?.openFiles
@@ -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>,