@mrclrchtr/supi-lsp 11.3.0 → 12.0.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 (31) hide show
  1. package/README.md +22 -17
  2. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  3. package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +3 -1
  4. package/node_modules/@mrclrchtr/supi-code-runtime/src/declaration-level.ts +34 -0
  5. package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +64 -28
  6. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  7. package/node_modules/semver/classes/range.js +2 -2
  8. package/node_modules/semver/internal/constants.js +30 -0
  9. package/node_modules/semver/internal/trim-comparator-whitespace.js +438 -0
  10. package/node_modules/semver/package.json +1 -1
  11. package/package.json +3 -3
  12. package/src/api.ts +2 -2
  13. package/src/client/client-diagnostic-capabilities.ts +13 -3
  14. package/src/client/client-diagnostics.ts +25 -9
  15. package/src/client/client-document-sync.ts +7 -5
  16. package/src/client/client-edit-capture.ts +258 -0
  17. package/src/client/client.ts +64 -11
  18. package/src/config/capabilities.ts +8 -0
  19. package/src/config/operation-support.ts +48 -79
  20. package/src/config/tsconfig-scope.ts +10 -0
  21. package/src/manager/manager-typescript-coverage.ts +118 -0
  22. package/src/manager/manager.ts +67 -3
  23. package/src/manager/typescript-file-local.ts +87 -0
  24. package/src/provider/lsp-refactor-provider.ts +21 -35
  25. package/src/provider/refactor-command-capture.ts +139 -0
  26. package/src/provider/refactor-planning.ts +202 -47
  27. package/src/provider/semantic-symbol-mapper.ts +22 -17
  28. package/src/session/route-boundary.ts +26 -0
  29. package/src/session/runtime-registry.ts +106 -16
  30. package/src/session/runtime-rename.ts +3 -0
  31. package/src/session/workspace-lsp-runtime.ts +22 -1
@@ -1,14 +1,28 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import * as path from "node:path";
2
3
  import type {
3
4
  CodePosition,
4
5
  CodeRequestControl,
6
+ DisambiguationCandidate,
7
+ RefactorActionIdentity,
5
8
  RefactorResult,
6
9
  SourceRange,
7
10
  } from "@mrclrchtr/supi-code-runtime/api";
8
11
  import { CodeActionTriggerKind } from "vscode-languageserver-types";
9
- import { codeActionKindsForOperation } from "../config/operation-support.ts";
12
+ import { isCodeActionKindInRange, isCodeActionKindWithin } from "../config/operation-support.ts";
10
13
  import type { CodeAction } from "../config/types.ts";
11
- import type { WorkspaceLspRuntime } from "../session/runtime-registry.ts";
14
+ import { raceRequestControl } from "../session/readiness.ts";
15
+ import type { RoutedCodeActionResponse, WorkspaceLspRuntime } from "../session/runtime-registry.ts";
16
+ import {
17
+ COMMAND_ONLY_ACTION_REASON,
18
+ captureCommandAction,
19
+ captureEligibleActions,
20
+ commandCandidates,
21
+ isCommandOnlyAction,
22
+ } from "./refactor-command-capture.ts";
23
+
24
+ export { COMMAND_ONLY_ACTION_REASON };
25
+
12
26
  import {
13
27
  normalizeSemanticEdit,
14
28
  type SemanticEditNormalizationContext,
@@ -70,48 +84,135 @@ export function collectCodeActionResults(
70
84
  return results;
71
85
  }
72
86
 
73
- export async function runFilteredCodeActionRefactor(options: {
87
+ /** Options for one code- or source-action planning request. */
88
+ export interface CodeActionRefactorOptions {
74
89
  lsp: WorkspaceLspRuntime;
75
90
  file: string;
76
- position: CodePosition;
77
- operation: "update_imports" | "delete_dead_code";
78
- range?: SourceRange;
79
- matches: (action: CodeAction) => boolean;
91
+ range: SourceRange;
92
+ kind: string;
93
+ title?: string;
94
+ /** Operation label used in unavailable reasons. */
95
+ label: "Code action" | "Source action";
80
96
  control?: CodeRequestControl;
81
- }): Promise<RefactorResult> {
82
- const { lsp, file, position, operation, matches } = options;
83
- const response = await lsp.codeActions(file, options.range ?? position, options.control, {
84
- only: codeActionKindsForOperation(operation),
97
+ }
98
+
99
+ /**
100
+ * Plan one code action of the requested kind over a file range.
101
+ *
102
+ * `kind` matches itself and its dotted sub-kinds, per the LSP CodeActionKind
103
+ * hierarchy. Disabled actions never match. `title` narrows matches to one exact
104
+ * title. Distinct precise plans stay ambiguous. One eligible command-only
105
+ * action runs once so its pushed edit can be captured; see
106
+ * `refactor-command-capture.ts`.
107
+ */
108
+ export async function runCodeActionRefactor(
109
+ options: CodeActionRefactorOptions,
110
+ ): Promise<RefactorResult> {
111
+ return (await attemptCodeActionRefactor(options)).result;
112
+ }
113
+
114
+ /** Delays before re-requesting a source action that returned no action of the kind. */
115
+ const SOURCE_ACTION_RETRY_DELAYS_MS = [500, 1000] as const;
116
+
117
+ /**
118
+ * Plan a whole-file source action, retrying briefly when a server that
119
+ * advertises the requested kind returns no action of it.
120
+ *
121
+ * typescript-language-server returns no fix-all or unused-code source actions
122
+ * while diagnostics for a just-opened or just-changed file are pending, and
123
+ * waits itself about 500 ms in the equivalent quick-fix path.
124
+ */
125
+ export async function runSourceActionRefactor(
126
+ options: CodeActionRefactorOptions,
127
+ ): Promise<RefactorResult> {
128
+ let attempt = await attemptCodeActionRefactor(options);
129
+ for (const delayMs of SOURCE_ACTION_RETRY_DELAYS_MS) {
130
+ if (!attempt.retryable) break;
131
+ await raceRequestControl(
132
+ new Promise((resolve) => setTimeout(resolve, delayMs)),
133
+ options.control,
134
+ );
135
+ attempt = await attemptCodeActionRefactor(options);
136
+ }
137
+ return attempt.result;
138
+ }
139
+
140
+ /**
141
+ * One planning attempt. `retryable` marks a zero-match response from a server
142
+ * that advertises the requested kind, so a later request may still return it.
143
+ */
144
+ async function attemptCodeActionRefactor(
145
+ options: CodeActionRefactorOptions,
146
+ ): Promise<{ result: RefactorResult; retryable: boolean }> {
147
+ const { lsp, file, range, kind, title, label } = options;
148
+ const response = await lsp.codeActions(file, range, options.control, {
149
+ only: [kind],
85
150
  triggerKind: CodeActionTriggerKind.Invoked,
86
151
  });
87
- if (!response || response.reason) {
152
+ if (!response) {
88
153
  return {
89
- kind: "unavailable",
90
- reason: `Refactor operation "${operation}" is unavailable: ${response?.reason ?? "No code actions are available."}`,
154
+ result: {
155
+ kind: "unavailable",
156
+ reason: `${label} "${kind}" is unavailable: no routed LSP client.`,
157
+ },
158
+ retryable: false,
91
159
  };
92
160
  }
93
- const actions = response.value;
94
- if (!actions || actions.length === 0) {
95
- return {
96
- kind: "unavailable",
97
- reason: `No code actions are available for refactor operation "${operation}".`,
98
- };
161
+ const roots = [...response.authorizedMutationRoots];
162
+ const unavailable = (reason: string, retryable = false) => ({
163
+ result: { kind: "unavailable" as const, reason, authorizedMutationRoots: roots },
164
+ retryable,
165
+ });
166
+ if (response.reason || !response.value) {
167
+ return unavailable(
168
+ `${label} "${kind}" is unavailable: ${response.reason ?? "no code actions were returned."}`,
169
+ );
99
170
  }
100
171
 
101
- const matching = actions.filter(matches);
172
+ const ofKind = response.value
173
+ .filter(isTitledCodeAction)
174
+ .filter(
175
+ (action) => isCodeActionKindWithin(action.kind, kind) && !Object.hasOwn(action, "disabled"),
176
+ );
177
+ if (ofKind.length === 0) {
178
+ const advertised = advertisedFamilyKinds(response.advertisedKinds, kind);
179
+ return unavailable(
180
+ noKindMatchReason(kind, advertised),
181
+ advertised.kinds.some((candidate) => isCodeActionKindInRange(candidate, kind)),
182
+ );
183
+ }
184
+ const matching = title === undefined ? ofKind : ofKind.filter((action) => action.title === title);
102
185
  if (matching.length === 0) {
103
- return {
104
- kind: "unavailable",
105
- reason: `No matching precise code action is available for refactor operation "${operation}".`,
106
- };
186
+ const titles = [...new Set(ofKind.map((action) => `"${action.title}"`))].join(", ");
187
+ return unavailable(
188
+ `No "${kind}" code action has the title "${title}". Available titles: ${titles}.`,
189
+ );
107
190
  }
108
191
 
109
- const context: SemanticEditNormalizationContext = {
192
+ return { result: await planMatchingActions(options, matching, response), retryable: false };
193
+ }
194
+
195
+ /** Turn the matching actions into one plan, candidates, or an unavailable reason. */
196
+ async function planMatchingActions(
197
+ options: CodeActionRefactorOptions,
198
+ matching: CodeAction[],
199
+ response: RoutedCodeActionResponse,
200
+ ): Promise<RefactorResult> {
201
+ const { lsp, file, kind } = options;
202
+ const roots = [...response.authorizedMutationRoots];
203
+ const unavailable = (reason: string): RefactorResult => ({
204
+ kind: "unavailable",
205
+ reason,
206
+ authorizedMutationRoots: roots,
207
+ });
208
+ const plans = collectPrecisePlans(matching, {
110
209
  getOpenDocumentVersion: (candidate) => lsp.getOpenDocumentVersion(candidate),
111
210
  authorizedMutationRoots: response.authorizedMutationRoots,
112
- };
113
- const plans = collectPrecisePlans(matching, context);
114
- if (plans.precise.length === 1) return plans.precise[0].result;
211
+ });
212
+ const [single] = plans.precise;
213
+ if (plans.precise.length === 1 && single) {
214
+ return { ...single.result, action: { ...actionIdentity(single.action), provenance: "edit" } };
215
+ }
115
216
  if (plans.precise.length > 1) {
116
217
  return {
117
218
  kind: "ambiguous",
@@ -120,26 +221,84 @@ export async function runFilteredCodeActionRefactor(options: {
120
221
  ),
121
222
  };
122
223
  }
123
- return {
124
- kind: "unavailable",
125
- reason: plans.unavailableReason
224
+ if (matching.every(isCommandOnlyAction)) {
225
+ const eligible = captureEligibleActions(matching, response.advertisedCommands);
226
+ const [only] = eligible;
227
+ if (eligible.length === 1 && only) {
228
+ return captureCommandAction({ lsp, file, action: only, control: options.control });
229
+ }
230
+ return eligible.length > 1
231
+ ? commandCandidates(eligible)
232
+ : unavailable(COMMAND_ONLY_ACTION_REASON);
233
+ }
234
+ return unavailable(
235
+ plans.unavailableReason
126
236
  ? `Matching code action is unavailable: ${plans.unavailableReason}`
127
- : `Matching code actions for refactor operation "${operation}" did not produce precise edits.`,
128
- };
237
+ : `Matching "${kind}" code actions did not produce precise edits.`,
238
+ );
129
239
  }
130
240
 
131
- export function isUpdateImportsCodeAction(action: CodeAction): boolean {
132
- const kind = typeof action?.kind === "string" ? action.kind : "";
133
- return kind === "source.organizeImports" || kind.startsWith("source.organizeImports.");
241
+ /** Compute the zero-based UTF-16 range that covers a file's full current disk text. */
242
+ export function wholeDocumentRange(file: string): SourceRange | null {
243
+ let text: string;
244
+ try {
245
+ text = readFileSync(file, "utf-8");
246
+ } catch {
247
+ return null;
248
+ }
249
+ const lines = text.split(/\r\n|\r|\n/);
250
+ return {
251
+ start: { line: 0, character: 0 },
252
+ end: { line: lines.length - 1, character: lines.at(-1)?.length ?? 0 },
253
+ };
134
254
  }
135
255
 
136
- export function isDeleteDeadCodeCodeAction(action: CodeAction): boolean {
137
- const kind = typeof action?.kind === "string" ? action.kind : "";
138
- return codeActionKindsForOperation("delete_dead_code").some(
139
- (requested) => kind === requested || kind.startsWith(`${requested}.`),
256
+ function isTitledCodeAction(value: unknown): value is CodeAction {
257
+ return (
258
+ typeof value === "object" &&
259
+ value !== null &&
260
+ typeof (value as { title?: unknown }).title === "string"
140
261
  );
141
262
  }
142
263
 
264
+ /** Advertised kinds in one CodeActionKind family, such as `source*`. */
265
+ interface AdvertisedFamilyKinds {
266
+ /** Listed non-empty kinds in the requested kind's top-level family. */
267
+ readonly kinds: readonly string[];
268
+ /** The server lists CodeActionKind.Empty or no kinds, so it may return any kind. */
269
+ readonly anyKind: boolean;
270
+ }
271
+
272
+ function advertisedFamilyKinds(
273
+ advertised: readonly string[] | undefined,
274
+ kind: string,
275
+ ): AdvertisedFamilyKinds {
276
+ const family = kind.split(".")[0] ?? kind;
277
+ return {
278
+ kinds: (advertised ?? []).filter(
279
+ (candidate) => candidate !== "" && isCodeActionKindWithin(candidate, family),
280
+ ),
281
+ anyKind: !advertised || advertised.includes(""),
282
+ };
283
+ }
284
+
285
+ function noKindMatchReason(kind: string, advertised: AdvertisedFamilyKinds): string {
286
+ const parts = [`No code action matches kind "${kind}" here.`];
287
+ if (advertised.kinds.length > 0) {
288
+ parts.push(`Server-advertised kinds: ${advertised.kinds.join(", ")}.`);
289
+ }
290
+ if (advertised.anyKind) {
291
+ parts.push("The server may return any code-action kind but returned none of this kind.");
292
+ } else if (advertised.kinds.length === 0) {
293
+ parts.push("The server advertises no kinds in this family.");
294
+ }
295
+ return parts.join(" ");
296
+ }
297
+
298
+ function actionIdentity(action: CodeAction): RefactorActionIdentity {
299
+ return { kind: typeof action.kind === "string" ? action.kind : "", title: action.title };
300
+ }
301
+
143
302
  interface PreciseCodeActionPlan {
144
303
  action: CodeAction;
145
304
  result: Extract<RefactorResult, { kind: "precise" }>;
@@ -187,18 +346,14 @@ function refactorCandidate(
187
346
  result: Extract<RefactorResult, { kind: "precise" }>,
188
347
  index: number,
189
348
  plans: readonly { action: CodeAction }[],
190
- ): {
191
- description: string;
192
- file?: string;
193
- line?: number;
194
- character?: number;
195
- } {
349
+ ): DisambiguationCandidate {
196
350
  const firstEdit = result.edits.edits[0];
197
351
  const sameTitleCount = plans.filter((plan) => plan.action.title === action.title).length;
198
352
  const description =
199
353
  sameTitleCount > 1 ? `${action.title} (alternative ${index + 1})` : action.title;
200
354
  return {
201
355
  description,
356
+ ...actionIdentity(action),
202
357
  ...(firstEdit
203
358
  ? {
204
359
  file: firstEdit.file,
@@ -2,10 +2,11 @@ import { readFile } from "node:fs/promises";
2
2
  import type {
3
3
  CodeLocation,
4
4
  CodeSymbol,
5
- DeclarationNesting,
5
+ DeclarationLevel,
6
6
  DocumentCodeSymbol,
7
7
  SourceRange,
8
8
  } from "@mrclrchtr/supi-code-runtime/api";
9
+ import { childDeclarationLevel } from "@mrclrchtr/supi-code-runtime/api";
9
10
  import { uriToFile } from "@mrclrchtr/supi-core/path";
10
11
  import type {
11
12
  DocumentSymbol,
@@ -100,15 +101,26 @@ export async function mapDocumentSymbols(
100
101
  ): Promise<DocumentCodeSymbol[]> {
101
102
  return flattenDocumentSymbols(symbols, filePath, null, {
102
103
  sourceLines: await readSourceLines(filePath),
103
- nesting: "top-level",
104
+ parent: null,
104
105
  });
105
106
  }
106
107
 
108
+ /** Level of a symbol reported inside `parent`, or top-level without one. */
109
+ function levelWithin(
110
+ parent: { readonly kind: string; readonly level: DeclarationLevel } | null,
111
+ kind: string,
112
+ ): DeclarationLevel {
113
+ return parent ? childDeclarationLevel(parent, kind) : "top-level";
114
+ }
115
+
107
116
  function flattenDocumentSymbols(
108
117
  symbols: DocumentSymbol[] | SymbolInformation[],
109
118
  filePath: string,
110
119
  container: string | null,
111
- context: { sourceLines: readonly string[] | null; nesting: DeclarationNesting },
120
+ context: {
121
+ sourceLines: readonly string[] | null;
122
+ parent: { readonly kind: string; readonly level: DeclarationLevel } | null;
123
+ },
112
124
  ): DocumentCodeSymbol[] {
113
125
  const result: DocumentCodeSymbol[] = [];
114
126
  for (const sym of symbols) {
@@ -116,15 +128,13 @@ function flattenDocumentSymbols(
116
128
  let document: DocumentSymbol | null = null;
117
129
  if (isSymbolInformation(sym)) information = sym;
118
130
  else document = sym;
131
+ const kind = symbolKindName(sym.kind);
132
+ const level = information ? "unknown" : levelWithin(context.parent, kind);
133
+ const childContext = { sourceLines: context.sourceLines, parent: { kind, level } };
119
134
  const declarationRange = toValidRange(document?.range ?? information?.location.range);
120
135
  if (!declarationRange) {
121
136
  if (document?.children?.length) {
122
- result.push(
123
- ...flattenDocumentSymbols(document.children, filePath, sym.name, {
124
- sourceLines: context.sourceLines,
125
- nesting: "nested",
126
- }),
127
- );
137
+ result.push(...flattenDocumentSymbols(document.children, filePath, sym.name, childContext));
128
138
  }
129
139
  continue;
130
140
  }
@@ -134,23 +144,18 @@ function flattenDocumentSymbols(
134
144
  : null;
135
145
  const symbol: DocumentCodeSymbol = {
136
146
  name: sym.name,
137
- kind: symbolKindName(sym.kind),
147
+ kind,
138
148
  file: filePath,
139
149
  declarationAnchor: { line: declStart.line + 1, character: declStart.character + 1 },
140
150
  container: information?.containerName ?? container,
141
- nesting: information ? "unknown" : context.nesting,
151
+ level,
142
152
  };
143
153
  if (nameStart) {
144
154
  symbol.nameAnchor = { line: nameStart.line + 1, character: nameStart.character + 1 };
145
155
  }
146
156
  result.push(symbol);
147
157
  if (document?.children?.length) {
148
- result.push(
149
- ...flattenDocumentSymbols(document.children, filePath, sym.name, {
150
- sourceLines: context.sourceLines,
151
- nesting: "nested",
152
- }),
153
- );
158
+ result.push(...flattenDocumentSymbols(document.children, filePath, sym.name, childContext));
154
159
  }
155
160
  }
156
161
  return result;
@@ -0,0 +1,26 @@
1
+ /** Project-root boundary for mutation requests on one routed client. */
2
+
3
+ import { realpathSync } from "node:fs";
4
+ import { isWithinOrEqual } from "@mrclrchtr/supi-core/project";
5
+
6
+ /**
7
+ * Return why a file cannot be refactored by the client routed to it, or null.
8
+ *
9
+ * A file without its own project markers routes to a server rooted at the
10
+ * session directory. Its edits would fail the apply-time root check, so refuse
11
+ * before the request. Real paths are compared, so a link inside the root
12
+ * cannot reach an outside file.
13
+ */
14
+ export function outsideRouteRootReason(file: string, root: string): string | null {
15
+ return isWithinOrEqual(canonical(root), canonical(file))
16
+ ? null
17
+ : `File ${file} is outside the language server's project root ${root}.`;
18
+ }
19
+
20
+ function canonical(file: string): string {
21
+ try {
22
+ return realpathSync(file);
23
+ } catch {
24
+ return file;
25
+ }
26
+ }
@@ -1,11 +1,13 @@
1
1
  // Shared session-scoped LSP service registry reused by peer extensions.
2
2
 
3
3
  // biome-ignore lint/style/noExcessiveLinesPerFile: session registry, runtime recovery, and telemetry stay in one file; splitting would hide the recovery contract.
4
+ import { fileURLToPath } from "node:url";
4
5
  import {
5
6
  type CodeQueryResult,
6
7
  type CodeRequestControl,
7
8
  isCodeRequestInterruption,
8
9
  mapCodeQueryResult,
10
+ partialCodeQuery,
9
11
  throwIfCodeRequestInterrupted,
10
12
  unavailableCodeQuery,
11
13
  } from "@mrclrchtr/supi-code-runtime/api";
@@ -16,6 +18,8 @@ import {
16
18
  import { resolveToolPath as resolveSessionPath } from "@mrclrchtr/supi-core/path";
17
19
  import { createSessionStateRegistry } from "@mrclrchtr/supi-core/session";
18
20
  import { CodeActionTriggerKind } from "vscode-languageserver-types";
21
+ import type { CommandEditCaptureResult } from "../client/client-edit-capture.ts";
22
+ import { advertisedCodeActionKinds } from "../config/operation-support.ts";
19
23
  import type {
20
24
  CodeAction,
21
25
  Diagnostic,
@@ -37,7 +41,12 @@ import type {
37
41
  WorkspaceSentinelSyncResult,
38
42
  } from "../diagnostics/workspace-sentinels.ts";
39
43
  import type { LspManager } from "../manager/manager.ts";
44
+ import {
45
+ COMMAND_ONLY_ACTION_REASON,
46
+ captureEligibleActions,
47
+ } from "../provider/refactor-command-capture.ts";
40
48
  import { raceReadinessValue } from "./readiness.ts";
49
+ import { outsideRouteRootReason } from "./route-boundary.ts";
41
50
  import type {
42
51
  DiagnosticEvidenceSummary,
43
52
  ProcessCrashRecoveryReport,
@@ -47,6 +56,7 @@ import type {
47
56
  import { runRoutedRename } from "./runtime-rename.ts";
48
57
  import type {
49
58
  BulkTrackFilesResult,
59
+ RoutedCodeActionResponse,
50
60
  RoutedMutationResponse,
51
61
  SemanticReadinessResult,
52
62
  WorkspaceCodeActionRequestOptions,
@@ -54,6 +64,9 @@ import type {
54
64
  WorkspaceLspRuntimeState,
55
65
  } from "./workspace-lsp-runtime.ts";
56
66
 
67
+ /** Upper bound for a server command run to capture its pushed edit. */
68
+ const COMMAND_CAPTURE_TIMEOUT_MS = 10_000;
69
+
57
70
  export type {
58
71
  ProcessCrashDiagnosticDemand,
59
72
  WorkspaceLspDiagnosticSurface,
@@ -82,6 +95,7 @@ export {
82
95
  export type {
83
96
  BulkTrackFileOutcome,
84
97
  BulkTrackFilesResult,
98
+ RoutedCodeActionResponse,
85
99
  RoutedMutationDocumentSnapshot,
86
100
  RoutedMutationResponse,
87
101
  SemanticReadinessResult,
@@ -193,9 +207,14 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
193
207
  control,
194
208
  });
195
209
  if (!client) return unavailableFileQuery("references", resolvedPath);
196
- return mapCodeQueryResult(
197
- await client.references(resolvedPath, position, control),
198
- (data) => data ?? [],
210
+ return this.withProjectCoverage(
211
+ client,
212
+ { file: resolvedPath, position },
213
+ "references",
214
+ mapCodeQueryResult(
215
+ await client.references(resolvedPath, position, control),
216
+ (data) => data ?? [],
217
+ ),
199
218
  );
200
219
  }
201
220
 
@@ -211,7 +230,30 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
211
230
  control,
212
231
  });
213
232
  if (!client) return unavailableFileQuery("implementation", resolvedPath);
214
- return client.implementation(resolvedPath, position, control);
233
+ return this.withProjectCoverage(
234
+ client,
235
+ { file: resolvedPath, position },
236
+ "implementation",
237
+ await client.implementation(resolvedPath, position, control),
238
+ );
239
+ }
240
+
241
+ /**
242
+ * Downgrade a completed project-wide answer to partial when the TypeScript
243
+ * server may not have searched every relevant project.
244
+ */
245
+ private withProjectCoverage<T extends Location[] | Location | LocationLink[] | null>(
246
+ client: NonNullable<Awaited<ReturnType<LspManager["ensureFileOpen"]>>>,
247
+ target: { readonly file: string; readonly position: Position },
248
+ request: "references" | "implementation",
249
+ result: CodeQueryResult<T>,
250
+ ): CodeQueryResult<T> {
251
+ if (result.kind !== "completed") return result;
252
+ const gap = this.manager.typeScriptProjectCoverageGap(client, target.file, request, {
253
+ resultFiles: locationFiles(result.data),
254
+ position: target.position,
255
+ });
256
+ return gap ? partialCodeQuery(result.data, gap) : result;
215
257
  }
216
258
 
217
259
  async documentSymbols(
@@ -258,12 +300,39 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
258
300
  return this.manager.getOpenDocumentVersion(this.resolveFilePath(filePath));
259
301
  }
260
302
 
303
+ async captureCodeActionEdit(
304
+ filePath: string,
305
+ action: CodeAction,
306
+ control?: CodeRequestControl,
307
+ ): Promise<RoutedMutationResponse<CommandEditCaptureResult | null> | null> {
308
+ const resolvedPath = this.resolveFilePath(filePath);
309
+ const client = await this.manager.ensureFileOpen(resolvedPath, {
310
+ recoverProcessCrash: true,
311
+ protectInputs: true,
312
+ control,
313
+ });
314
+ if (!client) return null;
315
+ const authorizedMutationRoots = [client.root];
316
+ const outside = outsideRouteRootReason(resolvedPath, client.root);
317
+ if (outside) return { value: null, authorizedMutationRoots, reason: outside };
318
+ const listed = client.serverCapabilities?.executeCommandProvider?.commands;
319
+ const [eligible] = captureEligibleActions([action], Array.isArray(listed) ? listed : undefined);
320
+ if (!eligible) {
321
+ return { value: null, authorizedMutationRoots, reason: COMMAND_ONLY_ACTION_REASON };
322
+ }
323
+ const value = await client.captureCommandEdits(eligible.command, {
324
+ timeoutMs: COMMAND_CAPTURE_TIMEOUT_MS,
325
+ ...(control ? { control } : {}),
326
+ });
327
+ return { value, authorizedMutationRoots };
328
+ }
329
+
261
330
  async codeActions(
262
331
  filePath: string,
263
332
  positionOrRange: Position | Range,
264
333
  control?: CodeRequestControl,
265
334
  options?: WorkspaceCodeActionRequestOptions,
266
- ): Promise<RoutedMutationResponse<CodeAction[] | null> | null> {
335
+ ): Promise<RoutedCodeActionResponse | null> {
267
336
  const resolvedPath = this.resolveFilePath(filePath);
268
337
  const client = await this.manager.ensureFileOpen(resolvedPath, {
269
338
  recoverProcessCrash: true,
@@ -271,6 +340,10 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
271
340
  control,
272
341
  });
273
342
  if (!client) return null;
343
+ const outside = outsideRouteRootReason(resolvedPath, client.root);
344
+ if (outside) {
345
+ return { value: null, authorizedMutationRoots: [client.root], reason: outside };
346
+ }
274
347
 
275
348
  const range = isRange(positionOrRange)
276
349
  ? positionOrRange
@@ -285,21 +358,23 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
285
358
  triggerKind: options?.triggerKind ?? CodeActionTriggerKind.Invoked,
286
359
  ...(options?.only ? { only: [...options.only] } : {}),
287
360
  };
361
+ const advertisedKinds = advertisedCodeActionKinds(client.serverCapabilities ?? null);
362
+ const advertisedCommands = client.serverCapabilities?.executeCommandProvider?.commands;
363
+ const routed = {
364
+ authorizedMutationRoots: [client.root],
365
+ ...(advertisedKinds ? { advertisedKinds } : {}),
366
+ ...(Array.isArray(advertisedCommands) ? { advertisedCommands } : {}),
367
+ };
288
368
  if (typeof client.codeActionsDetailed === "function") {
289
369
  const result = await client.codeActionsDetailed(resolvedPath, range, context, control);
290
- return {
291
- value: result.kind === "completed" ? result.data : null,
292
- authorizedMutationRoots: [client.root],
293
- ...(result.kind === "unavailable"
294
- ? { reason: result.reason }
295
- : result.data === null
296
- ? { reason: "LSP server returned no code actions." }
297
- : {}),
298
- };
370
+ // A completed `null` result means the server has no actions here (LSP spec).
371
+ return result.kind === "completed"
372
+ ? { ...routed, value: result.data ?? [] }
373
+ : { ...routed, value: null, reason: result.reason };
299
374
  }
300
375
  return {
301
- value: await client.codeActions(resolvedPath, range, context, control),
302
- authorizedMutationRoots: [client.root],
376
+ ...routed,
377
+ value: (await client.codeActions(resolvedPath, range, context, control)) ?? [],
303
378
  };
304
379
  }
305
380
 
@@ -605,6 +680,21 @@ export async function waitForWorkspaceLspRuntime(
605
680
  return state;
606
681
  }
607
682
 
683
+ /** Local file paths of LSP locations or location links; non-file URIs are skipped. */
684
+ function locationFiles(data: Location[] | Location | LocationLink[] | null): string[] {
685
+ const items: (Location | LocationLink)[] =
686
+ data === null ? [] : Array.isArray(data) ? data : [data];
687
+ return items.flatMap((item) => {
688
+ const uri = "targetUri" in item ? item.targetUri : item.uri;
689
+ if (!uri.startsWith("file:")) return [];
690
+ try {
691
+ return [fileURLToPath(uri)];
692
+ } catch {
693
+ return [];
694
+ }
695
+ });
696
+ }
697
+
608
698
  /** Remove the LSP service state for a session cwd. */
609
699
  export function clearWorkspaceLspRuntime(cwd: string): void {
610
700
  registry.clear(cwd);
@@ -5,6 +5,7 @@ import type { SemanticInputDocumentSnapshot } from "../client/client-semantic-in
5
5
  import type { Position, WorkspaceEdit } from "../config/types.ts";
6
6
  import type { LspManager } from "../manager/manager.ts";
7
7
  import { discoverSemanticEditFiles } from "../provider/semantic-edit-discovery.ts";
8
+ import { outsideRouteRootReason } from "./route-boundary.ts";
8
9
  import type {
9
10
  RoutedMutationDocumentSnapshot,
10
11
  RoutedMutationResponse,
@@ -26,6 +27,8 @@ export async function runRoutedRename(options: {
26
27
  });
27
28
  if (!client) return null;
28
29
  const authorizedMutationRoots = [client.root];
30
+ const outside = outsideRouteRootReason(file, client.root);
31
+ if (outside) return { value: null, authorizedMutationRoots, reason: outside };
29
32
 
30
33
  if (typeof client.renameDetailedWithSnapshots === "function") {
31
34
  return renameWithSnapshots({