@mrclrchtr/supi-lsp 10.0.0 → 11.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 (38) hide show
  1. package/README.md +88 -2
  2. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/LICENSE +21 -0
  3. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/README.md +743 -0
  4. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/index.js +17 -0
  5. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/constants.js +184 -0
  6. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/parse.js +1425 -0
  7. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/picomatch.js +361 -0
  8. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/scan.js +403 -0
  9. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/utils.js +72 -0
  10. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/package.json +83 -0
  11. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/posix.js +3 -0
  12. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +4 -1
  13. package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +11 -0
  14. package/node_modules/@mrclrchtr/supi-code-runtime/src/file-associations.ts +190 -0
  15. package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +22 -0
  16. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  17. package/node_modules/ignore/README.md +61 -6
  18. package/node_modules/ignore/index.d.ts +55 -20
  19. package/node_modules/ignore/index.js +93 -67
  20. package/node_modules/ignore/legacy.js +109 -85
  21. package/node_modules/ignore/package.json +1 -1
  22. package/package.json +3 -3
  23. package/src/api.ts +1 -0
  24. package/src/client/client-diagnostic-host.ts +3 -0
  25. package/src/client/client-diagnostics.ts +6 -4
  26. package/src/client/client-request-enrollment.ts +66 -22
  27. package/src/client/client-semantic-input-barrier.ts +37 -5
  28. package/src/client/client.ts +136 -43
  29. package/src/manager/manager.ts +261 -22
  30. package/src/provider/refactor-planning.ts +13 -0
  31. package/src/provider/semantic-edit-discovery.ts +151 -0
  32. package/src/provider/semantic-edit-normalizer.ts +97 -129
  33. package/src/provider/semantic-edit-protocol.ts +132 -0
  34. package/src/session/runtime-controller.ts +8 -1
  35. package/src/session/runtime-registry.ts +7 -20
  36. package/src/session/runtime-rename.ts +172 -0
  37. package/src/session/workspace-lsp-runtime.ts +14 -0
  38. package/src/utils.ts +12 -11
@@ -1,3 +1,4 @@
1
+ import type { CFamilyFileAssociationResolver } from "@mrclrchtr/supi-code-runtime/api";
1
2
  import type { DiagnosticRequestAdapter } from "./client-diagnostic-request.ts";
2
3
  import type { NormalizedDocumentSync } from "./client-document-sync.ts";
3
4
 
@@ -7,6 +8,8 @@ export interface ClientDiagnosticsHost {
7
8
  server: string;
8
9
  /** Absolute workspace root, for debug-telemetry identity. */
9
10
  cwd?: string;
11
+ /** Immutable workspace association snapshot used for didOpen document IDs. */
12
+ fileAssociations?: CFamilyFileAssociationResolver;
10
13
  isOperational(): boolean;
11
14
  /** Native pull and server-specific request evidence share this priority adapter. */
12
15
  diagnosticRequestAdapter: DiagnosticRequestAdapter;
@@ -51,6 +51,7 @@ import { SemanticInputRequestRetryGuard } from "./client-request-enrollment.ts";
51
51
  import {
52
52
  SemanticInputBarrier,
53
53
  type SemanticInputDocument,
54
+ type SemanticInputRevision,
54
55
  type SemanticInputSnapshot,
55
56
  type SemanticInputUpdate,
56
57
  } from "./client-semantic-input-barrier.ts";
@@ -156,10 +157,10 @@ export class ClientDiagnostics {
156
157
 
157
158
  /** Fail closed when a semantic request observes a changed input generation. */
158
159
  assertSemanticInputsCurrent(
159
- snapshot: SemanticInputSnapshot,
160
+ snapshot: SemanticInputRevision,
160
161
  control?: CodeRequestControl,
161
162
  contentOverrides?: ReadonlyMap<string, string>,
162
- ): Promise<void> {
163
+ ): Promise<SemanticInputSnapshot> {
163
164
  throwIfCodeRequestInterrupted(control);
164
165
  return this.#inputBarrier.assertCurrent(snapshot, control, contentOverrides);
165
166
  }
@@ -168,6 +169,7 @@ export class ClientDiagnostics {
168
169
  return Array.from(this.#openDocs, ([uri, document]) => ({
169
170
  uri,
170
171
  filePath: uriToFile(uri),
172
+ version: document.version,
171
173
  content: document.content,
172
174
  contentFingerprint: document.contentFingerprint,
173
175
  }));
@@ -293,7 +295,7 @@ export class ClientDiagnostics {
293
295
  this.#cancelDiagnosticRequest(uri);
294
296
  this.#advanceInputRevision(false, false, "enrollment");
295
297
 
296
- const languageId = detectLanguageId(filePath);
298
+ const languageId = detectLanguageId(filePath, this.host.fileAssociations);
297
299
  this.#waiters.cancelSettle();
298
300
  // A didOpen is a synchronization: pushes the server sends after it are
299
301
  // responses to the open and can become fresh evidence (ADR 0020).
@@ -797,7 +799,7 @@ export class ClientDiagnostics {
797
799
 
798
800
  async #verifyDiagnosticInputs(options: {
799
801
  result: CodeQueryResult<Diagnostic[]>;
800
- snapshot: SemanticInputSnapshot;
802
+ snapshot: SemanticInputRevision;
801
803
  control: CodeRequestControl | undefined;
802
804
  contentOverrides: ReadonlyMap<string, string> | undefined;
803
805
  uri: string;
@@ -3,30 +3,61 @@ import {
3
3
  type SemanticInputSynchronizationError,
4
4
  } from "./client-semantic-input-errors.ts";
5
5
 
6
- const MAX_REQUEST_ENROLLMENT_RETRIES = 1;
6
+ const MAX_REQUEST_RETRIES = 1;
7
+ const CONTENT_MODIFIED_ERROR_CODE = -32_801;
7
8
 
8
- /** A request-level enrollment retry decision owned by one request attempt. */
9
- export type RequestEnrollmentRetryDecision =
10
- | { readonly kind: "retry" }
11
- | { readonly kind: "stop"; readonly error: unknown };
9
+ /** Reason that permits one request retry. */
10
+ export type SemanticInputRequestRetryReason = "content-modified" | "enrollment";
11
+
12
+ /** A request-level retry decision owned by one attempt. */
13
+ export type SemanticInputRequestRetryDecision =
14
+ | { readonly kind: "retry"; readonly reason: SemanticInputRequestRetryReason }
15
+ | {
16
+ readonly kind: "stop";
17
+ readonly error: unknown;
18
+ readonly reason?: SemanticInputRequestRetryReason;
19
+ };
12
20
 
13
21
  /** Look up the current input change after a failed request revision. */
14
22
  export type SemanticInputChangeLookup = (
15
23
  revision: number,
16
24
  ) => SemanticInputSynchronizationError | undefined;
17
25
 
26
+ /** Optional request retry policies. ContentModified retries stay off by default. */
27
+ export interface SemanticInputRequestRetryPolicy {
28
+ readonly retryContentModified?: boolean;
29
+ /** Retain a verified revision across document enrollment and a follow-up request. */
30
+ readonly inputRevision?: number;
31
+ }
32
+
33
+ function isContentModifiedError(error: unknown): boolean {
34
+ return (
35
+ typeof error === "object" &&
36
+ error !== null &&
37
+ "code" in error &&
38
+ (error as { code?: unknown }).code === CONTENT_MODIFIED_ERROR_CODE
39
+ );
40
+ }
41
+
18
42
  /**
19
- * Keep one semantic or diagnostic request's enrollment retry policy cohesive.
43
+ * Keep one LSP request's input retry policy in one place.
20
44
  *
21
- * Synchronization passes have their own rejoin budget. This guard only owns
22
- * the request-level budget and the first failed revision used to classify
23
- * later changes.
45
+ * Synchronization passes have their own rejoin budget. This guard owns one
46
+ * shared request retry budget and a protected revision used to classify later
47
+ * changes. A caller can retain an earlier verified revision across enrollment.
48
+ * Protocol retries are disabled unless the caller opts in.
24
49
  */
25
50
  export class SemanticInputRequestRetryGuard {
26
51
  #requestRetries = 0;
27
- #firstFailedRevision: number | undefined;
52
+ #protectedRevision: number | undefined;
53
+ #retryReason: SemanticInputRequestRetryReason | undefined;
28
54
 
29
- constructor(private readonly currentChangeSince: SemanticInputChangeLookup) {}
55
+ constructor(
56
+ private readonly currentChangeSince: SemanticInputChangeLookup,
57
+ private readonly policy: SemanticInputRequestRetryPolicy = {},
58
+ ) {
59
+ this.#protectedRevision = policy.inputRevision;
60
+ }
30
61
 
31
62
  /** Number of request-level retries permitted for this request so far. */
32
63
  get retryCount(): number {
@@ -38,6 +69,11 @@ export class SemanticInputRequestRetryGuard {
38
69
  return this.#requestRetries > 0;
39
70
  }
40
71
 
72
+ /** Cause of the retry already used by this request, if any. */
73
+ get retryReason(): SemanticInputRequestRetryReason | undefined {
74
+ return this.#retryReason;
75
+ }
76
+
41
77
  /** Throw the current non-enrollment cause when it blocks a retry. */
42
78
  assertCurrent(): void {
43
79
  const blockingChange = this.#getBlockingChange();
@@ -45,31 +81,39 @@ export class SemanticInputRequestRetryGuard {
45
81
  }
46
82
 
47
83
  /** Classify one failed attempt and decide whether it may be retried. */
48
- decide(error: unknown, inputRevision: number | undefined): RequestEnrollmentRetryDecision {
49
- if (!isSemanticInputEnrollmentError(error)) {
50
- return { kind: "stop", error };
51
- }
84
+ decide(error: unknown, inputRevision: number | undefined): SemanticInputRequestRetryDecision {
85
+ const reason = this.#getRetryReason(error);
86
+ if (!reason) return { kind: "stop", error };
52
87
 
53
- if (this.#firstFailedRevision !== undefined) {
88
+ if (this.#protectedRevision !== undefined) {
54
89
  const blockingChange = this.#getBlockingChange();
55
90
  if (blockingChange) return { kind: "stop", error: blockingChange };
56
91
  }
57
92
  if (inputRevision === undefined) return { kind: "stop", error };
58
93
 
59
- this.#firstFailedRevision ??= inputRevision;
94
+ this.#protectedRevision ??= inputRevision;
60
95
  const blockingChange = this.#getBlockingChange();
61
96
  if (blockingChange) return { kind: "stop", error: blockingChange };
62
- if (this.#requestRetries >= MAX_REQUEST_ENROLLMENT_RETRIES) {
63
- return { kind: "stop", error };
97
+ if (this.#requestRetries >= MAX_REQUEST_RETRIES) {
98
+ return { kind: "stop", error, reason };
64
99
  }
65
100
 
66
101
  this.#requestRetries++;
67
- return { kind: "retry" };
102
+ this.#retryReason ??= reason;
103
+ return { kind: "retry", reason };
104
+ }
105
+
106
+ #getRetryReason(error: unknown): SemanticInputRequestRetryReason | undefined {
107
+ if (isSemanticInputEnrollmentError(error)) return "enrollment";
108
+ if (this.policy.retryContentModified && isContentModifiedError(error)) {
109
+ return "content-modified";
110
+ }
111
+ return undefined;
68
112
  }
69
113
 
70
114
  #getBlockingChange(): SemanticInputSynchronizationError | undefined {
71
- if (this.#firstFailedRevision === undefined) return undefined;
72
- const currentChange = this.currentChangeSince(this.#firstFailedRevision);
115
+ if (this.#protectedRevision === undefined) return undefined;
116
+ const currentChange = this.currentChangeSince(this.#protectedRevision);
73
117
  return currentChange?.changeKind === "enrollment" ? undefined : currentChange;
74
118
  }
75
119
  }
@@ -21,10 +21,19 @@ const DEFAULT_MAX_CONCURRENT_READS = 16;
21
21
  export interface SemanticInputDocument {
22
22
  readonly uri: string;
23
23
  readonly filePath: string;
24
+ readonly version: number;
24
25
  readonly content: string;
25
26
  readonly contentFingerprint: string;
26
27
  }
27
28
 
29
+ /** One open buffer and disk fingerprint verified by an input synchronization pass. */
30
+ export interface SemanticInputDocumentSnapshot {
31
+ readonly filePath: string;
32
+ readonly version: number;
33
+ readonly contentFingerprint: string;
34
+ readonly diskContentFingerprint: string;
35
+ }
36
+
28
37
  /** One open document update selected by the barrier after a full read. */
29
38
  export interface SemanticInputUpdate {
30
39
  readonly document: SemanticInputDocument;
@@ -48,11 +57,16 @@ export interface SemanticInputBarrierOptions {
48
57
  readonly readFile?: SemanticInputFileReader;
49
58
  }
50
59
 
51
- /** Revision token returned by one completed input synchronization pass. */
52
- export interface SemanticInputSnapshot {
60
+ /** Revision token used to check one semantic input generation. */
61
+ export interface SemanticInputRevision {
53
62
  readonly revision: number;
54
63
  }
55
64
 
65
+ /** Verified inputs returned by one complete synchronization pass. */
66
+ export interface SemanticInputSnapshot extends SemanticInputRevision {
67
+ readonly documents: readonly SemanticInputDocumentSnapshot[];
68
+ }
69
+
56
70
  interface OpenDocumentRead {
57
71
  readonly document: SemanticInputDocument;
58
72
  readonly result:
@@ -150,10 +164,10 @@ export class SemanticInputBarrier {
150
164
 
151
165
  /** Recheck the full input set after a semantic request completes. */
152
166
  async assertCurrent(
153
- snapshot: SemanticInputSnapshot,
167
+ snapshot: SemanticInputRevision,
154
168
  control?: CodeRequestControl,
155
169
  contentOverrides?: ReadonlyMap<string, string>,
156
- ): Promise<void> {
170
+ ): Promise<SemanticInputSnapshot> {
157
171
  if (!this.#host.isOperational()) {
158
172
  throw new Error("Semantic input synchronization is unavailable because the client stopped.");
159
173
  }
@@ -176,6 +190,7 @@ export class SemanticInputBarrier {
176
190
  )
177
191
  );
178
192
  }
193
+ return current;
179
194
  }
180
195
 
181
196
  /** Return the current typed input change after a revision, when present. */
@@ -305,7 +320,24 @@ export class SemanticInputBarrier {
305
320
  // resulting generation as owned by this completed pass so a late caller
306
321
  // can join it instead of starting a redundant read pass.
307
322
  pending.generation = this.#revision;
308
- return { revision: this.#revision };
323
+ const currentDocuments = this.#host.getOpenDocuments();
324
+ const diskFingerprints = new Map(plan.observedDiskFingerprints);
325
+ return {
326
+ revision: this.#revision,
327
+ documents: currentDocuments.flatMap((document) => {
328
+ const diskContentFingerprint = diskFingerprints.get(document.uri);
329
+ return diskContentFingerprint === undefined
330
+ ? []
331
+ : [
332
+ {
333
+ filePath: document.filePath,
334
+ version: document.version,
335
+ contentFingerprint: document.contentFingerprint,
336
+ diskContentFingerprint,
337
+ },
338
+ ];
339
+ }),
340
+ };
309
341
  }
310
342
 
311
343
  #assertPendingCanRun(pending: PendingSynchronization): void {
@@ -5,6 +5,7 @@
5
5
  import { type ChildProcess, execSync, spawn } from "node:child_process";
6
6
  import * as path from "node:path";
7
7
  import {
8
+ type CFamilyFileAssociationResolver,
8
9
  type CodeQueryResult,
9
10
  type CodeRequestControl,
10
11
  completedCodeQuery,
@@ -69,11 +70,15 @@ import { createTypeScriptDiagnosticRequestAdapter } from "./client-diagnostic-ty
69
70
  import { ClientDiagnostics } from "./client-diagnostics.ts";
70
71
  import type { ClientDiagnosticSnapshot, DiagnosticEntry } from "./client-document-state.ts";
71
72
  import { type NormalizedDocumentSync, normalizeDocumentSync } from "./client-document-sync.ts";
72
- import { SemanticInputRequestRetryGuard } from "./client-request-enrollment.ts";
73
73
  import {
74
- isSemanticInputEnrollmentError,
75
- SemanticInputSynchronizationError,
76
- } from "./client-semantic-input-errors.ts";
74
+ SemanticInputRequestRetryGuard,
75
+ type SemanticInputRequestRetryReason,
76
+ } from "./client-request-enrollment.ts";
77
+ import type {
78
+ SemanticInputDocumentSnapshot,
79
+ SemanticInputSnapshot,
80
+ } from "./client-semantic-input-barrier.ts";
81
+ import { SemanticInputSynchronizationError } from "./client-semantic-input-errors.ts";
77
82
  import { JsonRpcClient, JsonRpcRequestError } from "./transport.ts";
78
83
 
79
84
  const SHUTDOWN_TIMEOUT_MS = 5_000;
@@ -87,7 +92,15 @@ interface SemanticRequestAttemptState {
87
92
 
88
93
  interface RefactorTransaction {
89
94
  rpc: JsonRpcClient;
90
- assertCurrent: () => Promise<void>;
95
+ assertCurrent: () => Promise<SemanticInputSnapshot>;
96
+ }
97
+
98
+ export interface RenameDetailedWithSnapshotsResult {
99
+ readonly result: CodeQueryResult<WorkspaceEdit | null>;
100
+ /** Verified input revision retained when a rename needs document enrollment. */
101
+ readonly inputRevision?: number;
102
+ /** Exact documents synchronized before and rechecked after this request. */
103
+ readonly documentSnapshots: readonly SemanticInputDocumentSnapshot[];
91
104
  }
92
105
 
93
106
  /**
@@ -529,6 +542,8 @@ export class LspClient {
529
542
  private readonly onLifecycleTransition?: LspClientLifecycleListener,
530
543
  /** Absolute workspace root for debug-telemetry identity. */
531
544
  readonly cwd?: string,
545
+ /** Immutable workspace association snapshot for document language IDs. */
546
+ fileAssociations?: CFamilyFileAssociationResolver,
532
547
  ) {
533
548
  this.name = name;
534
549
  this.root = root;
@@ -562,6 +577,7 @@ export class LspClient {
562
577
  this.diagnostics = new ClientDiagnostics({
563
578
  server: name,
564
579
  cwd: cwd,
580
+ fileAssociations,
565
581
  isOperational: () => this.rpc !== null && this._status === "running",
566
582
  diagnosticRequestAdapter: this.diagnosticRequestAdapter,
567
583
  documentSync: () => this.getDocumentSyncOptions(),
@@ -1155,8 +1171,32 @@ export class LspClient {
1155
1171
  newName: string,
1156
1172
  control?: CodeRequestControl,
1157
1173
  ): Promise<CodeQueryResult<WorkspaceEdit | null>> {
1174
+ return (
1175
+ await this.renameDetailedWithSnapshots(filePath, position, newName, {
1176
+ control,
1177
+ snapshotFiles: [filePath],
1178
+ })
1179
+ ).result;
1180
+ }
1181
+
1182
+ /** Run rename and retain the exact synchronized inputs verified by its transaction. */
1183
+ async renameDetailedWithSnapshots(
1184
+ filePath: string,
1185
+ position: Position,
1186
+ newName: string,
1187
+ options: {
1188
+ readonly control?: CodeRequestControl;
1189
+ readonly snapshotFiles?: readonly string[];
1190
+ /** Reject non-enrollment changes since this earlier rename request. */
1191
+ readonly inputRevision?: number;
1192
+ } = {},
1193
+ ): Promise<RenameDetailedWithSnapshotsResult> {
1194
+ const { control, snapshotFiles = [filePath] } = options;
1158
1195
  if (!this.supportsRename) {
1159
- return unavailableCodeQuery("The server does not advertise textDocument/rename.");
1196
+ return {
1197
+ result: unavailableCodeQuery("The server does not advertise textDocument/rename."),
1198
+ documentSnapshots: [],
1199
+ };
1160
1200
  }
1161
1201
  if (
1162
1202
  typeof filePath !== "string" ||
@@ -1165,9 +1205,12 @@ export class LspClient {
1165
1205
  typeof newName !== "string" ||
1166
1206
  newName.trim().length === 0
1167
1207
  ) {
1168
- return unavailableCodeQuery(
1169
- "Rename requires a valid file, position, and non-empty new name.",
1170
- );
1208
+ return {
1209
+ result: unavailableCodeQuery(
1210
+ "Rename requires a valid file, position, and non-empty new name.",
1211
+ ),
1212
+ documentSnapshots: [],
1213
+ };
1171
1214
  }
1172
1215
 
1173
1216
  const params = {
@@ -1175,22 +1218,39 @@ export class LspClient {
1175
1218
  position,
1176
1219
  newName,
1177
1220
  };
1178
- return this.runRefactorTransaction("textDocument/rename", control, async (transaction) => {
1179
- if (this.supportsPrepareRename) {
1180
- const prepared = await transaction.rpc.sendRequest(
1181
- "textDocument/prepareRename",
1182
- { textDocument: { uri: fileToUri(filePath) }, position },
1183
- control,
1184
- );
1185
- const preparationError = validatePrepareRenameResult(prepared, position);
1186
- if (preparationError) throw new Error(preparationError);
1187
- await transaction.assertCurrent();
1188
- }
1189
- return (await transaction.rpc.sendRequest("textDocument/rename", params, control)) as
1190
- | WorkspaceEdit
1191
- | null
1192
- | undefined;
1193
- });
1221
+ let documentSnapshots: readonly SemanticInputDocumentSnapshot[] = [];
1222
+ let inputRevision: number | undefined;
1223
+ const requestedFiles = new Set(snapshotFiles.map((file) => path.resolve(file)));
1224
+ const result = await this.runRefactorTransaction(
1225
+ "textDocument/rename",
1226
+ control,
1227
+ async (transaction) => {
1228
+ if (this.supportsPrepareRename) {
1229
+ const prepared = await transaction.rpc.sendRequest(
1230
+ "textDocument/prepareRename",
1231
+ { textDocument: { uri: fileToUri(filePath) }, position },
1232
+ control,
1233
+ );
1234
+ const preparationError = validatePrepareRenameResult(prepared, position);
1235
+ if (preparationError) throw new Error(preparationError);
1236
+ await transaction.assertCurrent();
1237
+ }
1238
+ return (await transaction.rpc.sendRequest("textDocument/rename", params, control)) as
1239
+ | WorkspaceEdit
1240
+ | null
1241
+ | undefined;
1242
+ },
1243
+ {
1244
+ inputRevision: options.inputRevision,
1245
+ onVerifiedSnapshot: (snapshot) => {
1246
+ inputRevision = snapshot.revision;
1247
+ documentSnapshots = snapshot.documents.filter((document) =>
1248
+ requestedFiles.has(path.resolve(document.filePath)),
1249
+ );
1250
+ },
1251
+ },
1252
+ );
1253
+ return { result, documentSnapshots, inputRevision };
1194
1254
  }
1195
1255
 
1196
1256
  async codeActions(
@@ -1247,7 +1307,9 @@ export class LspClient {
1247
1307
  control?: CodeRequestControl,
1248
1308
  ): Promise<CodeQueryResult<Location | Location[] | LocationLink[] | null>> {
1249
1309
  if (!this.capabilities?.implementationProvider) {
1250
- return unavailableCodeQuery("Implementation requests are not supported by this server.");
1310
+ return unavailableCodeQuery(
1311
+ `LSP server "${this.name}" does not advertise textDocument/implementation.`,
1312
+ );
1251
1313
  }
1252
1314
  return this.query(
1253
1315
  "textDocument/implementation",
@@ -1300,6 +1362,10 @@ export class LspClient {
1300
1362
  method: string,
1301
1363
  control: CodeRequestControl | undefined,
1302
1364
  operation: (transaction: RefactorTransaction) => Promise<T | null | undefined>,
1365
+ options: {
1366
+ readonly onVerifiedSnapshot?: (snapshot: SemanticInputSnapshot) => void;
1367
+ readonly inputRevision?: number;
1368
+ } = {},
1303
1369
  ): Promise<CodeQueryResult<T | null>> {
1304
1370
  throwIfCodeRequestInterrupted(control);
1305
1371
  if (!this.rpc || this._status !== "running") {
@@ -1308,8 +1374,9 @@ export class LspClient {
1308
1374
  );
1309
1375
  }
1310
1376
 
1311
- const retryGuard = new SemanticInputRequestRetryGuard((revision) =>
1312
- this.diagnostics.getSemanticInputChangeSince(revision),
1377
+ const retryGuard = new SemanticInputRequestRetryGuard(
1378
+ (revision) => this.diagnostics.getSemanticInputChangeSince(revision),
1379
+ { inputRevision: options.inputRevision },
1313
1380
  );
1314
1381
  for (;;) {
1315
1382
  const attempt: SemanticRequestAttemptState = { inputRevision: undefined };
@@ -1321,9 +1388,10 @@ export class LspClient {
1321
1388
  operation,
1322
1389
  retryGuard,
1323
1390
  attempt,
1391
+ onVerifiedSnapshot: options.onVerifiedSnapshot,
1324
1392
  });
1325
1393
  if (retryGuard.hasRetried) {
1326
- this.recordSemanticRetry({ method, control, retryGuard, outcome: "completed" });
1394
+ this.recordRequestRetry({ method, control, retryGuard, outcome: "completed" });
1327
1395
  }
1328
1396
  return completedCodeQuery(data);
1329
1397
  } catch (error) {
@@ -1364,8 +1432,9 @@ export class LspClient {
1364
1432
  operation: (transaction: RefactorTransaction) => Promise<T | null | undefined>;
1365
1433
  retryGuard: SemanticInputRequestRetryGuard;
1366
1434
  attempt: SemanticRequestAttemptState;
1435
+ onVerifiedSnapshot?: (snapshot: SemanticInputSnapshot) => void;
1367
1436
  }): Promise<T | null> {
1368
- const { method, control, operation, retryGuard, attempt } = options;
1437
+ const { method, control, operation, retryGuard, attempt, onVerifiedSnapshot } = options;
1369
1438
  throwIfCodeRequestInterrupted(control);
1370
1439
  retryGuard.assertCurrent();
1371
1440
  await this.getReady(control);
@@ -1381,10 +1450,14 @@ export class LspClient {
1381
1450
  rpc,
1382
1451
  assertCurrent: async () => {
1383
1452
  retryGuard.assertCurrent();
1384
- await this.diagnostics.assertSemanticInputsCurrent(inputSnapshot, control);
1453
+ return this.diagnostics.assertSemanticInputsCurrent(inputSnapshot, control);
1385
1454
  },
1386
1455
  });
1387
- await this.diagnostics.assertSemanticInputsCurrent(inputSnapshot, control);
1456
+ const verifiedSnapshot = await this.diagnostics.assertSemanticInputsCurrent(
1457
+ inputSnapshot,
1458
+ control,
1459
+ );
1460
+ onVerifiedSnapshot?.(verifiedSnapshot);
1388
1461
  return data ?? null;
1389
1462
  }
1390
1463
 
@@ -1418,8 +1491,9 @@ export class LspClient {
1418
1491
  params: unknown,
1419
1492
  control?: CodeRequestControl,
1420
1493
  ): Promise<CodeQueryResult<T | null>> {
1421
- const retryGuard = new SemanticInputRequestRetryGuard((revision) =>
1422
- this.diagnostics.getSemanticInputChangeSince(revision),
1494
+ const retryGuard = new SemanticInputRequestRetryGuard(
1495
+ (revision) => this.diagnostics.getSemanticInputChangeSince(revision),
1496
+ { retryContentModified: true },
1423
1497
  );
1424
1498
  for (;;) {
1425
1499
  const attempt: SemanticRequestAttemptState = { inputRevision: undefined };
@@ -1432,7 +1506,7 @@ export class LspClient {
1432
1506
  attempt,
1433
1507
  });
1434
1508
  if (retryGuard.hasRetried) {
1435
- this.recordSemanticRetry({ method, control, retryGuard, outcome: "completed" });
1509
+ this.recordRequestRetry({ method, control, retryGuard, outcome: "completed" });
1436
1510
  }
1437
1511
  return completedCodeQuery(data);
1438
1512
  } catch (error) {
@@ -1452,7 +1526,7 @@ export class LspClient {
1452
1526
  const { method, control, retryGuard, attempt, error } = options;
1453
1527
  if (isCodeRequestInterruption(error, control)) {
1454
1528
  if (retryGuard.hasRetried) {
1455
- this.recordSemanticRetry({ method, control, retryGuard, outcome: "interrupted" });
1529
+ this.recordRequestRetry({ method, control, retryGuard, outcome: "interrupted" });
1456
1530
  }
1457
1531
  throwIfCodeRequestInterrupted(control);
1458
1532
  throw error;
@@ -1461,17 +1535,32 @@ export class LspClient {
1461
1535
  const decision = retryGuard.decide(error, attempt.inputRevision);
1462
1536
  if (decision.kind === "retry") {
1463
1537
  throwIfCodeRequestInterrupted(control);
1464
- this.recordSemanticRetry({ method, control, retryGuard, outcome: "retry", error });
1538
+ this.recordRequestRetry({
1539
+ method,
1540
+ control,
1541
+ retryGuard,
1542
+ outcome: "retry",
1543
+ retryReason: decision.reason,
1544
+ error,
1545
+ });
1465
1546
  return;
1466
1547
  }
1467
1548
  if (retryGuard.hasRetried) {
1468
- const outcome = isSemanticInputEnrollmentError(decision.error) ? "exhausted" : "failed";
1469
- this.recordSemanticRetry({ method, control, retryGuard, outcome, error: decision.error });
1549
+ const outcome = decision.reason ? "exhausted" : "failed";
1550
+ this.recordRequestRetry({
1551
+ method,
1552
+ control,
1553
+ retryGuard,
1554
+ outcome,
1555
+ retryReason: retryGuard.retryReason,
1556
+ failureReason: decision.reason,
1557
+ error: decision.error,
1558
+ });
1470
1559
  const detail =
1471
1560
  decision.error instanceof Error ? decision.error.message : String(decision.error);
1472
1561
  const count = retryGuard.retryCount;
1473
1562
  throw new Error(
1474
- `${detail} Enrollment retry ${outcome} after ${count} ${count === 1 ? "retry" : "retries"}.`,
1563
+ `${detail} Request retry ${outcome} after ${count} ${count === 1 ? "retry" : "retries"}.`,
1475
1564
  {
1476
1565
  cause: decision.error,
1477
1566
  },
@@ -1481,18 +1570,20 @@ export class LspClient {
1481
1570
  }
1482
1571
 
1483
1572
  /** Record request acceptance separately from transport completion, without request content. */
1484
- private recordSemanticRetry(options: {
1573
+ private recordRequestRetry(options: {
1485
1574
  method: string;
1486
1575
  control?: CodeRequestControl;
1487
1576
  retryGuard: SemanticInputRequestRetryGuard;
1488
1577
  outcome: "retry" | "completed" | "exhausted" | "failed" | "interrupted";
1578
+ retryReason?: SemanticInputRequestRetryReason;
1579
+ failureReason?: SemanticInputRequestRetryReason;
1489
1580
  error?: unknown;
1490
1581
  }): void {
1491
1582
  recordDebugEvent({
1492
1583
  source: "lsp",
1493
1584
  level: "debug",
1494
- category: "semantic-request.enrollment-retry",
1495
- message: "LSP semantic request enrollment retry",
1585
+ category: "request.retry",
1586
+ message: "LSP request retry",
1496
1587
  cwd: boundCwd(this.cwd),
1497
1588
  ...(options.control?.operationId ? { operationId: options.control.operationId } : {}),
1498
1589
  data: {
@@ -1500,6 +1591,8 @@ export class LspClient {
1500
1591
  method: truncateIdentity(options.method),
1501
1592
  outcome: options.outcome,
1502
1593
  retryCount: options.retryGuard.retryCount,
1594
+ retryReason: options.retryReason ?? options.retryGuard.retryReason ?? null,
1595
+ failureReason: options.failureReason ?? null,
1503
1596
  changeKind:
1504
1597
  options.error instanceof SemanticInputSynchronizationError
1505
1598
  ? options.error.changeKind