@mrclrchtr/supi-lsp 7.4.0 → 8.0.1

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 (32) hide show
  1. package/README.md +29 -6
  2. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  3. package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +1 -1
  4. package/node_modules/@mrclrchtr/supi-code-runtime/src/query-result.ts +22 -4
  5. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  6. package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +110 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/config.ts +6 -1
  8. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-schema.ts +2 -1
  9. package/package.json +3 -3
  10. package/src/api.ts +25 -3
  11. package/src/client/client-diagnostic-host.ts +2 -1
  12. package/src/client/client-diagnostic-refresh.ts +5 -2
  13. package/src/client/client-diagnostics.ts +39 -6
  14. package/src/client/client-document-sync.ts +82 -12
  15. package/src/client/client-semantic-input-barrier.ts +28 -5
  16. package/src/client/client.ts +606 -30
  17. package/src/config/capabilities.ts +9 -0
  18. package/src/config/config.ts +69 -8
  19. package/src/config/operation-support.ts +196 -0
  20. package/src/config/server-config.ts +15 -0
  21. package/src/config/types.ts +9 -1
  22. package/src/manager/manager-project-info.ts +2 -0
  23. package/src/manager/manager-workspace-symbol.ts +31 -2
  24. package/src/manager/manager.ts +72 -2
  25. package/src/provider/lsp-refactor-provider.ts +9 -43
  26. package/src/provider/refactor-planning.ts +102 -50
  27. package/src/provider/semantic-edit-normalizer.ts +49 -3
  28. package/src/session/runtime-controller.ts +78 -18
  29. package/src/session/runtime-registry.ts +46 -2
  30. package/src/session/workspace-lsp-runtime.ts +14 -0
  31. package/src/workspace-path-policy.ts +30 -4
  32. package/src/config/lsp-settings.ts +0 -22
@@ -38,14 +38,22 @@ export const CLIENT_CAPABILITIES: ClientCapabilities = {
38
38
  "quickfix",
39
39
  "refactor",
40
40
  "refactor.extract",
41
+ "refactor.extract.function",
42
+ "refactor.extract.constant",
43
+ "refactor.extract.variable",
41
44
  "refactor.inline",
42
45
  "refactor.rewrite",
43
46
  "source",
44
47
  "source.organizeImports",
45
48
  "source.fixAll",
49
+ "source.removeUnused",
46
50
  ],
47
51
  },
48
52
  },
53
+ dataSupport: true,
54
+ // SuPi resolves only the edit property. Commands and other fields
55
+ // must remain server-owned and are never advertised as resolvable.
56
+ resolveSupport: { properties: ["edit"] },
49
57
  },
50
58
  publishDiagnostics: {
51
59
  relatedInformation: true,
@@ -64,6 +72,7 @@ export const CLIENT_CAPABILITIES: ClientCapabilities = {
64
72
  workDoneProgress: true,
65
73
  },
66
74
  workspace: {
75
+ configuration: true,
67
76
  workspaceFolders: true,
68
77
  workspaceEdit: {
69
78
  documentChanges: true,
@@ -3,9 +3,9 @@
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { loadSupiConfigForScope } from "@mrclrchtr/supi-core/config";
6
+ import { loadSupiConfigForScope, loadSupiConfigSectionForScope } from "@mrclrchtr/supi-core/config";
7
7
  import { truncateDebugIdentity } from "@mrclrchtr/supi-core/debug";
8
- import type { LspConfig, ServerConfig } from "./types.ts";
8
+ import type { JsonObject, JsonValue, LspConfig, ServerConfig } from "./types.ts";
9
9
 
10
10
  const CONFIG_DIR = path.dirname(fileURLToPath(import.meta.url));
11
11
 
@@ -18,6 +18,8 @@ const DEFAULTS: LspConfig = JSON.parse(
18
18
 
19
19
  export interface LoadConfigOptions {
20
20
  homeDir?: string;
21
+ /** Whether project-scoped server configuration is trusted. Defaults to true. */
22
+ projectTrusted?: boolean;
21
23
  }
22
24
 
23
25
  /** Map from language alias → canonical config key. */
@@ -25,6 +27,7 @@ export const LANGUAGE_ALIASES: Record<string, string> = {
25
27
  cpp: "c",
26
28
  };
27
29
 
30
+ /** Alias fields override canonical fields, independent of property order. */
28
31
  function resolveAliasesInOverrides(servers: Record<string, unknown>): void {
29
32
  for (const [alias, target] of Object.entries(LANGUAGE_ALIASES)) {
30
33
  if (!Object.hasOwn(servers, alias)) continue;
@@ -52,18 +55,60 @@ export function loadConfig(cwd: string, options?: LoadConfigOptions): LspConfig
52
55
  { servers: {} as Record<string, ServerConfig> },
53
56
  { scope: "global", homeDir: options?.homeDir },
54
57
  );
55
- const projectLsp = loadSupiConfigForScope(
56
- "lsp",
57
- cwd,
58
- { servers: {} as Record<string, ServerConfig> },
59
- { scope: "project" },
60
- );
58
+ const projectLsp =
59
+ options?.projectTrusted === false
60
+ ? { servers: {} as Record<string, ServerConfig> }
61
+ : loadSupiConfigForScope(
62
+ "lsp",
63
+ cwd,
64
+ { servers: {} as Record<string, ServerConfig> },
65
+ { scope: "project" },
66
+ );
61
67
 
62
68
  const merged = mergeServerConfigs(defaults.servers, globalLsp.servers, projectLsp.servers);
63
69
 
64
70
  return { servers: merged };
65
71
  }
66
72
 
73
+ /**
74
+ * Read effective explicit language disablement for the active LSP snapshot.
75
+ * Project values override global values, including an explicit re-enable.
76
+ */
77
+ export function getExplicitlyDisabledLanguages(cwd: string, options?: LoadConfigOptions): string[] {
78
+ const effective = new Map<string, boolean>();
79
+ applyEnabledOverrides(
80
+ effective,
81
+ loadSupiConfigSectionForScope("lsp", cwd, {
82
+ scope: "global",
83
+ homeDir: options?.homeDir,
84
+ }),
85
+ );
86
+ if (options?.projectTrusted !== false) {
87
+ applyEnabledOverrides(
88
+ effective,
89
+ loadSupiConfigSectionForScope("lsp", cwd, { scope: "project" }),
90
+ );
91
+ }
92
+ return [...effective]
93
+ .filter(([, enabled]) => enabled === false)
94
+ .map(([name]) => name)
95
+ .sort((a, b) => a.localeCompare(b));
96
+ }
97
+
98
+ function applyEnabledOverrides(
99
+ effective: Map<string, boolean>,
100
+ section: Record<string, unknown> | null,
101
+ ): void {
102
+ const servers = section?.servers;
103
+ if (!isRecord(servers)) return;
104
+ const normalized = { ...servers };
105
+ resolveAliasesInOverrides(normalized);
106
+ for (const [name, value] of Object.entries(normalized)) {
107
+ if (!isRecord(value) || typeof value.enabled !== "boolean") continue;
108
+ effective.set(name, value.enabled);
109
+ }
110
+ }
111
+
67
112
  /**
68
113
  * Find which server config handles a given file extension.
69
114
  * Returns [languageName, config] or null.
@@ -217,6 +262,11 @@ const SERVER_OVERRIDE_SETTERS: Record<string, ServerOverrideSetter> = {
217
262
  override.env = candidate;
218
263
  return true;
219
264
  },
265
+ settings(override, candidate) {
266
+ if (!isJsonObject(candidate)) return false;
267
+ override.settings = candidate;
268
+ return true;
269
+ },
220
270
  initializationOptions(override, candidate) {
221
271
  override.initializationOptions = candidate;
222
272
  return true;
@@ -262,6 +312,17 @@ function isStringRecord(value: unknown): value is Record<string, string> {
262
312
  return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string");
263
313
  }
264
314
 
315
+ function isJsonObject(value: unknown): value is JsonObject {
316
+ return isJsonValue(value) && isRecord(value);
317
+ }
318
+
319
+ function isJsonValue(value: unknown): value is JsonValue {
320
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
321
+ if (typeof value === "number") return Number.isFinite(value);
322
+ if (Array.isArray(value)) return value.every((entry) => isJsonValue(entry));
323
+ return isRecord(value) && Object.values(value).every((entry) => isJsonValue(entry));
324
+ }
325
+
265
326
  function warnInvalidServerConfig(name: string, fields: readonly string[], skipped: boolean): void {
266
327
  const boundedName = truncateDebugIdentity(name);
267
328
  const boundedFields = truncateDebugIdentity(fields.join(", "));
@@ -0,0 +1,196 @@
1
+ import type { ServerCapabilities } from "vscode-languageserver-protocol";
2
+
3
+ /** Refactor operations that the public LSP runtime can report. */
4
+ export type ServerOperationName =
5
+ | "rename_symbol"
6
+ | "prepare_rename"
7
+ | "extract_function"
8
+ | "extract_variable"
9
+ | "update_imports"
10
+ | "delete_dead_code"
11
+ | "code_action_resolve";
12
+
13
+ /** Whether a server advertises one operation in its negotiated capabilities. */
14
+ export type ServerAdvertisement = "advertised" | "not-advertised" | "unknown";
15
+
16
+ /** SuPi support and server advertisement for one operation. */
17
+ export interface ServerOperationSupport {
18
+ readonly server: ServerAdvertisement;
19
+ readonly supi: "supported" | "limited" | "unsupported";
20
+ /** A stable explanation for a SuPi planning limitation or deferral. */
21
+ readonly reason?: string;
22
+ }
23
+
24
+ /** Operation support projected onto a project-server health entry. */
25
+ export type ServerOperationSupportMap = Partial<
26
+ Record<ServerOperationName, ServerOperationSupport>
27
+ >;
28
+
29
+ /** Code-action kinds requested by each operation-specific planner. */
30
+ export const REFACTOR_CODE_ACTION_KINDS = {
31
+ extract_function: ["refactor.extract.function"],
32
+ extract_variable: ["refactor.extract.constant", "refactor.extract.variable"],
33
+ update_imports: ["source.organizeImports"],
34
+ delete_dead_code: ["source.removeUnused"],
35
+ } as const satisfies Record<
36
+ "extract_function" | "extract_variable" | "update_imports" | "delete_dead_code",
37
+ readonly string[]
38
+ >;
39
+
40
+ const EXTRACTION_NAME_LIMITATION =
41
+ "Standard LSP code actions do not provide a safe exact extraction-name contract.";
42
+
43
+ function isRecord(value: unknown): value is Record<string, unknown> {
44
+ return typeof value === "object" && value !== null && !Array.isArray(value);
45
+ }
46
+
47
+ function codeActionOptions(capabilities: ServerCapabilities | null): {
48
+ kinds?: readonly string[];
49
+ resolveProvider?: boolean;
50
+ } | null {
51
+ const provider = capabilities?.codeActionProvider;
52
+ if (provider === true) return {};
53
+ if (!isRecord(provider)) return null;
54
+ return {
55
+ ...(Array.isArray(provider.codeActionKinds)
56
+ ? {
57
+ kinds: provider.codeActionKinds.filter(
58
+ (kind): kind is string => typeof kind === "string",
59
+ ),
60
+ }
61
+ : {}),
62
+ ...(provider.resolveProvider === true ? { resolveProvider: true } : {}),
63
+ };
64
+ }
65
+
66
+ function renameProvider(
67
+ capabilities: ServerCapabilities | null,
68
+ ): Record<string, unknown> | true | null {
69
+ const provider = capabilities?.renameProvider;
70
+ if (provider === true) return true;
71
+ return isRecord(provider) ? provider : null;
72
+ }
73
+
74
+ /** Return true when a server advertises rename requests. */
75
+ export function supportsRename(capabilities: ServerCapabilities | null): boolean {
76
+ return renameProvider(capabilities) !== null;
77
+ }
78
+
79
+ /** Return true when a server advertises `prepareRename`. */
80
+ export function supportsPrepareRename(capabilities: ServerCapabilities | null): boolean {
81
+ const provider = renameProvider(capabilities);
82
+ return isRecord(provider) && provider.prepareProvider === true;
83
+ }
84
+
85
+ /** Return true when a server advertises code actions. */
86
+ export function supportsCodeActions(capabilities: ServerCapabilities | null): boolean {
87
+ return codeActionOptions(capabilities) !== null;
88
+ }
89
+
90
+ /** Return true when a server advertises code-action resolution. */
91
+ export function supportsCodeActionResolve(capabilities: ServerCapabilities | null): boolean {
92
+ return codeActionOptions(capabilities)?.resolveProvider === true;
93
+ }
94
+
95
+ /** Test the documented hierarchical relationship between two code-action kinds. */
96
+ export function isCodeActionKindInRange(kind: string, requested: string): boolean {
97
+ if (kind === "" || requested === "") return true;
98
+ return kind === requested || kind.startsWith(`${requested}.`) || requested.startsWith(`${kind}.`);
99
+ }
100
+
101
+ /**
102
+ * Check whether a requested kind can be supplied by a server's advertised
103
+ * code-action kinds. An absent kind list means the server did not narrow its
104
+ * provider, so the request remains eligible.
105
+ */
106
+ export function supportsRequestedCodeActionKinds(
107
+ capabilities: ServerCapabilities | null,
108
+ requested: readonly string[],
109
+ ): boolean {
110
+ const options = codeActionOptions(capabilities);
111
+ if (!options || requested.length === 0 || !options.kinds) return options !== null;
112
+ if (options.kinds.length === 0) return false;
113
+ return requested.some((wanted) =>
114
+ options.kinds?.some((kind) => isCodeActionKindInRange(kind, wanted)),
115
+ );
116
+ }
117
+
118
+ function codeActionAdvertisement(
119
+ capabilities: ServerCapabilities | null,
120
+ requested: readonly string[],
121
+ ): ServerAdvertisement {
122
+ if (capabilities === null) return "unknown";
123
+ const options = codeActionOptions(capabilities);
124
+ if (!options) return "not-advertised";
125
+ if (requested.length === 0 || !options.kinds) return "unknown";
126
+ if (!supportsRequestedCodeActionKinds(capabilities, requested)) return "not-advertised";
127
+
128
+ // A generic parent or the empty kind proves eligibility, but it does not
129
+ // prove that this exact operation is implemented by the server.
130
+ const exactOrChild = requested.some((wanted) =>
131
+ options.kinds?.some(
132
+ (kind) => kind === wanted || (wanted !== "" && kind.startsWith(`${wanted}.`)),
133
+ ),
134
+ );
135
+ return exactOrChild ? "advertised" : "unknown";
136
+ }
137
+
138
+ function supportedOperation(
139
+ server: ServerAdvertisement,
140
+ supi: ServerOperationSupport["supi"] = "supported",
141
+ reason?: string,
142
+ ): ServerOperationSupport {
143
+ return { server, supi, ...(reason ? { reason } : {}) };
144
+ }
145
+
146
+ /** Build the honest operation-support projection for one negotiated server. */
147
+ export function getServerOperationSupport(
148
+ capabilities: ServerCapabilities | null,
149
+ ): ServerOperationSupportMap {
150
+ const renameServer =
151
+ capabilities === null
152
+ ? "unknown"
153
+ : supportsRename(capabilities)
154
+ ? "advertised"
155
+ : "not-advertised";
156
+ const prepareServer =
157
+ capabilities === null
158
+ ? "unknown"
159
+ : supportsPrepareRename(capabilities)
160
+ ? "advertised"
161
+ : "not-advertised";
162
+ const resolveServer =
163
+ capabilities === null
164
+ ? "unknown"
165
+ : supportsCodeActionResolve(capabilities)
166
+ ? "advertised"
167
+ : "not-advertised";
168
+ return {
169
+ rename_symbol: supportedOperation(renameServer),
170
+ prepare_rename: supportedOperation(prepareServer),
171
+ extract_function: supportedOperation(
172
+ codeActionAdvertisement(capabilities, REFACTOR_CODE_ACTION_KINDS.extract_function),
173
+ "limited",
174
+ EXTRACTION_NAME_LIMITATION,
175
+ ),
176
+ extract_variable: supportedOperation(
177
+ codeActionAdvertisement(capabilities, REFACTOR_CODE_ACTION_KINDS.extract_variable),
178
+ "limited",
179
+ EXTRACTION_NAME_LIMITATION,
180
+ ),
181
+ update_imports: supportedOperation(
182
+ codeActionAdvertisement(capabilities, REFACTOR_CODE_ACTION_KINDS.update_imports),
183
+ ),
184
+ delete_dead_code: supportedOperation(
185
+ codeActionAdvertisement(capabilities, REFACTOR_CODE_ACTION_KINDS.delete_dead_code),
186
+ ),
187
+ code_action_resolve: supportedOperation(resolveServer),
188
+ };
189
+ }
190
+
191
+ /** Get the exact operation-specific kinds used in a code-action request. */
192
+ export function codeActionKindsForOperation(
193
+ operation: keyof typeof REFACTOR_CODE_ACTION_KINDS,
194
+ ): readonly string[] {
195
+ return REFACTOR_CODE_ACTION_KINDS[operation];
196
+ }
@@ -1,6 +1,16 @@
1
1
  // SuPi-specific server configuration types — not part of the LSP specification.
2
+
3
+ import type { ServerOperationSupportMap } from "./operation-support.ts";
2
4
  // These are our own types for server discovery, configuration, and status tracking.
3
5
 
6
+ /** JSON values accepted by a server's advanced settings object. */
7
+ export type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
8
+
9
+ /** A JSON object passed to a language server as advanced settings. */
10
+ export interface JsonObject {
11
+ readonly [key: string]: JsonValue;
12
+ }
13
+
4
14
  export interface ServerConfig {
5
15
  command: string;
6
16
  args?: string[];
@@ -10,6 +20,9 @@ export interface ServerConfig {
10
20
  enabled?: boolean;
11
21
  /** Environment values added when the server process starts. */
12
22
  env?: Record<string, string>;
23
+ /** Server-owned configuration settings. This object replaces inherited settings as a whole. */
24
+ settings?: JsonObject;
25
+ /** Options sent during initialize; kept separate from `settings`. */
13
26
  initializationOptions?: unknown;
14
27
  /** Maximum time to wait for a single $/progress cycle, in ms. Default: 10_000. */
15
28
  readinessTimeoutMs?: number;
@@ -39,6 +52,8 @@ export interface ProjectServerInfo extends DetectedProjectServer {
39
52
  openFiles: string[];
40
53
  /** Whether the LSP server is currently not indexing and ready to serve queries. */
41
54
  ready: boolean;
55
+ /** Negotiated operation support, when the route has a capability snapshot. */
56
+ operationSupport?: ServerOperationSupportMap;
42
57
  }
43
58
 
44
59
  /** A language whose source files are present but the server binary is missing. */
@@ -24,6 +24,7 @@ export {
24
24
  AnnotatedTextEdit,
25
25
  CodeAction,
26
26
  type CodeActionContext,
27
+ CodeActionTriggerKind,
27
28
  Command,
28
29
  Diagnostic,
29
30
  DiagnosticRelatedInformation,
@@ -48,10 +49,17 @@ export {
48
49
  WorkspaceEdit,
49
50
  WorkspaceSymbol,
50
51
  } from "vscode-languageserver-types";
51
-
52
+ export type {
53
+ ServerAdvertisement,
54
+ ServerOperationName,
55
+ ServerOperationSupport,
56
+ ServerOperationSupportMap,
57
+ } from "./operation-support.ts";
52
58
  // ── SuPi-specific server config ──────────────────────────────────────
53
59
  export type {
54
60
  DetectedProjectServer,
61
+ JsonObject,
62
+ JsonValue,
55
63
  LspConfig,
56
64
  MissingServer,
57
65
  ProjectServerInfo,
@@ -1,4 +1,5 @@
1
1
  import type { LspClient } from "../client/client.ts";
2
+ import { getServerOperationSupport } from "../config/operation-support.ts";
2
3
  import type { ProjectServerInfo, ProjectServerStatusReason } from "../config/server-config.ts";
3
4
  import { displayRelativeFilePath } from "../summary.ts";
4
5
 
@@ -38,5 +39,6 @@ export function buildProjectServerInfo(
38
39
  .filter((file) => input.includeOpenFile?.(file) ?? true)
39
40
  .map((file) => displayRelativeFilePath(file, cwd)) ?? [],
40
41
  ready: input.client?.ready ?? false,
42
+ operationSupport: getServerOperationSupport(input.client?.serverCapabilities ?? null),
41
43
  };
42
44
  }
@@ -55,6 +55,10 @@ export interface WorkspaceSymbolCollection {
55
55
  hasSupport: boolean;
56
56
  completedClientCount: number;
57
57
  failures: string[];
58
+ /** Results removed by the automatic path policy. */
59
+ excludedCount?: number;
60
+ /** Bounded paths removed by the automatic path policy. */
61
+ excludedExamples?: string[];
58
62
  }
59
63
 
60
64
  export async function collectWorkspaceSymbols(
@@ -84,6 +88,16 @@ export async function collectWorkspaceSymbols(
84
88
  return { results, hasSupport, completedClientCount, failures };
85
89
  }
86
90
 
91
+ function workspaceSymbolExclusionReason(collection: WorkspaceSymbolCollection): string | null {
92
+ const excludedCount = collection.excludedCount ?? 0;
93
+ if (excludedCount === 0) return null;
94
+ const noun = `workspace-symbol result${excludedCount === 1 ? "" : "s"}`;
95
+ const examples = collection.excludedExamples?.length
96
+ ? ` Examples: ${collection.excludedExamples.join(", ")}.`
97
+ : "";
98
+ return `Automatic path policy excluded ${excludedCount} ${noun}.${examples}`;
99
+ }
100
+
87
101
  /** Project one multi-client collection into the shared typed query contract. */
88
102
  export function workspaceSymbolCollectionResult(
89
103
  collection: WorkspaceSymbolCollection,
@@ -96,8 +110,23 @@ export function workspaceSymbolCollectionResult(
96
110
  collection.failures.join("; ") || "No workspace-symbol request completed.",
97
111
  );
98
112
  }
99
- if (collection.failures.length > 0) {
100
- return partialCodeQuery(collection.results, collection.failures.join("; "));
113
+ const exclusion = workspaceSymbolExclusionReason(collection);
114
+ if (collection.failures.length > 0 || exclusion) {
115
+ const excludedCount = collection.excludedCount ?? 0;
116
+ const metadata =
117
+ excludedCount > 0
118
+ ? {
119
+ automaticPathExclusion: {
120
+ excludedCount,
121
+ examples: [...(collection.excludedExamples ?? [])],
122
+ },
123
+ }
124
+ : undefined;
125
+ return partialCodeQuery(
126
+ collection.results,
127
+ [collection.failures.join("; "), exclusion].filter(Boolean).join("; "),
128
+ metadata,
129
+ );
101
130
  }
102
131
  return completedCodeQuery(collection.results);
103
132
  }
@@ -1938,6 +1938,10 @@ export class LspManager {
1938
1938
  { policy: this.automaticPathPolicy },
1939
1939
  )[0];
1940
1940
  if (target) await this.warmSemanticProject(firstReady, target.file, false, control);
1941
+ // The warm-up request can start server indexing after the initial
1942
+ // readiness wait resolves. Observe the resulting progress before
1943
+ // reporting the workspace as ready.
1944
+ await firstReady.getReady(control);
1941
1945
  }
1942
1946
 
1943
1947
  throwIfCodeRequestInterrupted(control);
@@ -2126,6 +2130,23 @@ export class LspManager {
2126
2130
  this.notifyWorkspaceFileChanges(changes);
2127
2131
  }
2128
2132
 
2133
+ /** Synchronize tracked files after successful writes and send requested saves. */
2134
+ noteWorkspaceWrites(changes: FileEvent[]): void {
2135
+ if (changes.length === 0) return;
2136
+ this.noteWorkspaceChanges(changes);
2137
+
2138
+ const writtenFiles = [
2139
+ ...new Set(
2140
+ changes
2141
+ .filter((change) => change.type === FileChangeType.Changed)
2142
+ .map((change) => uriToFile(change.uri)),
2143
+ ),
2144
+ ];
2145
+ for (const client of this.clients.values()) {
2146
+ for (const filePath of writtenFiles) client.noteWorkspaceWrite(filePath);
2147
+ }
2148
+ }
2149
+
2129
2150
  /** Return per-route diagnostic evidence capability for recovery targeting. */
2130
2151
  getClientDiagnosticRoutes(): Array<{
2131
2152
  key: string;
@@ -2630,8 +2651,8 @@ export class LspManager {
2630
2651
  : null,
2631
2652
  control,
2632
2653
  );
2633
- const collect = async (clients: Iterable<LspClient>, value: string) =>
2634
- this.addWorkspaceSymbolDemandEvidence(
2654
+ const collect = async (clients: Iterable<LspClient>, value: string) => {
2655
+ const collection = this.addWorkspaceSymbolDemandEvidence(
2635
2656
  await collectWorkspaceSymbols(
2636
2657
  Array.from(clients).filter((client) => routeMatches(client)),
2637
2658
  value,
@@ -2639,6 +2660,8 @@ export class LspManager {
2639
2660
  ),
2640
2661
  demand,
2641
2662
  );
2663
+ return this.filterWorkspaceSymbolCollection(collection, resolvedScopes);
2664
+ };
2642
2665
  const initial = await collect(this.clients.values(), query);
2643
2666
  if (!initial.hasSupport || initial.results.length > 0) {
2644
2667
  return workspaceSymbolCollectionResult(initial);
@@ -2669,6 +2692,53 @@ export class LspManager {
2669
2692
  };
2670
2693
  }
2671
2694
 
2695
+ private filterWorkspaceSymbolCollection(
2696
+ collection: WorkspaceSymbolCollection,
2697
+ scopes: readonly string[] | undefined,
2698
+ ): WorkspaceSymbolCollection {
2699
+ const excludedExamples: string[] = [];
2700
+ let excludedCount = 0;
2701
+ const results = collection.results.filter((result) => {
2702
+ const file = uriToFile(result.location.uri);
2703
+ if (this.isWorkspaceSymbolResultAllowed(file, scopes)) return true;
2704
+ excludedCount++;
2705
+ if (excludedExamples.length < 5) {
2706
+ excludedExamples.push(displayRelativeFilePath(file, this.cwd));
2707
+ }
2708
+ return false;
2709
+ });
2710
+ return {
2711
+ ...collection,
2712
+ results,
2713
+ excludedCount: (collection.excludedCount ?? 0) + excludedCount,
2714
+ excludedExamples: [...(collection.excludedExamples ?? []), ...excludedExamples].slice(0, 5),
2715
+ };
2716
+ }
2717
+
2718
+ /** Keep explicitly selected excluded scopes inspectable while filtering broad results. */
2719
+ private isWorkspaceSymbolResultAllowed(
2720
+ filePath: string,
2721
+ scopes: readonly string[] | undefined,
2722
+ ): boolean {
2723
+ if (this.automaticPathPolicy.isEligible(filePath, "file")) return true;
2724
+ if (!scopes || scopes.length === 0) return false;
2725
+
2726
+ const resolvedFile = path.resolve(filePath);
2727
+ return scopes.some((scope) => {
2728
+ const resolvedScope = path.resolve(scope);
2729
+ if (resolvedScope === resolvedFile) return true;
2730
+ try {
2731
+ if (!fs.statSync(resolvedScope).isDirectory()) return false;
2732
+ } catch {
2733
+ return false;
2734
+ }
2735
+ return (
2736
+ !this.automaticPathPolicy.isEligible(resolvedScope, "directory") &&
2737
+ projectRoots.isWithinOrEqual(resolvedScope, resolvedFile)
2738
+ );
2739
+ });
2740
+ }
2741
+
2672
2742
  private hasWorkspaceSymbolRouteForScopes(scopes: readonly string[]): boolean {
2673
2743
  const clients = [
2674
2744
  ...this.clients.values(),
@@ -4,13 +4,10 @@ import type {
4
4
  RefactorResult,
5
5
  SemanticProvider,
6
6
  } from "@mrclrchtr/supi-code-runtime/api";
7
- import type { CodeAction } from "../config/types.ts";
8
7
  import type { WorkspaceLspRuntime } from "../session/runtime-registry.ts";
9
8
  import {
10
9
  collectCodeActionResults,
11
10
  isDeleteDeadCodeCodeAction,
12
- isExtractFunctionCodeAction,
13
- isExtractVariableCodeAction,
14
11
  isUpdateImportsCodeAction,
15
12
  runFilteredCodeActionRefactor,
16
13
  runRenameRefactor,
@@ -28,7 +25,11 @@ export function createLspRefactorProvider(
28
25
  const response = control
29
26
  ? await lsp.codeActions(file, position, control)
30
27
  : await lsp.codeActions(file, position);
31
- if (!response?.value) return [];
28
+ if (!response) return [];
29
+ if (response.reason && !response.value) {
30
+ return [{ kind: "unavailable", reason: response.reason }];
31
+ }
32
+ if (!response.value) return [];
32
33
  return collectCodeActionResults(response.value, {
33
34
  getOpenDocumentVersion: (candidate) => lsp.getOpenDocumentVersion(candidate),
34
35
  authorizedMutationRoots: response.authorizedMutationRoots,
@@ -58,21 +59,11 @@ async function planRefactor(
58
59
  control,
59
60
  });
60
61
  case "extract_function":
61
- return runExtractRefactor({
62
- lsp,
63
- request,
64
- operation: "extract_function",
65
- matches: isExtractFunctionCodeAction,
66
- control,
67
- });
68
62
  case "extract_variable":
69
- return runExtractRefactor({
70
- lsp,
71
- request,
72
- operation: "extract_variable",
73
- matches: isExtractVariableCodeAction,
74
- control,
75
- });
63
+ return {
64
+ kind: "unavailable",
65
+ reason: `Refactor operation "${request.operation}" is unavailable: standard LSP code actions do not provide a safe exact extraction-name contract.`,
66
+ };
76
67
  case "update_imports":
77
68
  return runFilteredCodeActionRefactor({
78
69
  lsp,
@@ -103,28 +94,3 @@ async function planRefactor(
103
94
  reason: `Refactor operation "${request.operation}" is not supported by the active semantic provider.`,
104
95
  };
105
96
  }
106
-
107
- function runExtractRefactor(options: {
108
- lsp: WorkspaceLspRuntime;
109
- request: RefactorRequest;
110
- operation: "extract_function" | "extract_variable";
111
- matches: (action: CodeAction) => boolean;
112
- control?: CodeRequestControl;
113
- }): Promise<RefactorResult> | RefactorResult {
114
- const { lsp, request, operation, matches, control } = options;
115
- if (!request.range) {
116
- return {
117
- kind: "unavailable",
118
- reason: `Refactor operation "${operation}" requires \`range\`.`,
119
- };
120
- }
121
- return runFilteredCodeActionRefactor({
122
- lsp,
123
- file: request.file,
124
- position: request.position,
125
- range: request.range,
126
- operation,
127
- matches,
128
- control,
129
- });
130
- }