@mrclrchtr/supi-code-intelligence 6.3.0 → 7.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 (136) hide show
  1. package/README.md +164 -82
  2. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  3. package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +8 -1
  4. package/node_modules/@mrclrchtr/supi-core/README.md +8 -0
  5. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  6. package/node_modules/@mrclrchtr/supi-core/src/footer-registry.ts +10 -3
  7. package/node_modules/@mrclrchtr/supi-core/src/index.ts +2 -0
  8. package/node_modules/@mrclrchtr/supi-core/src/llm.ts +141 -16
  9. package/node_modules/@mrclrchtr/supi-lsp/README.md +35 -13
  10. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  11. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +8 -1
  12. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/README.md +8 -0
  13. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  14. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/footer-registry.ts +10 -3
  15. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/index.ts +2 -0
  16. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/llm.ts +141 -16
  17. package/node_modules/@mrclrchtr/supi-lsp/node_modules/ignore/index.js +3 -1
  18. package/node_modules/@mrclrchtr/supi-lsp/node_modules/ignore/legacy.js +3 -1
  19. package/node_modules/@mrclrchtr/supi-lsp/node_modules/ignore/package.json +1 -1
  20. package/node_modules/@mrclrchtr/supi-lsp/package.json +3 -3
  21. package/node_modules/@mrclrchtr/supi-lsp/src/api.ts +16 -0
  22. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-cache.ts +43 -35
  23. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-capabilities.ts +83 -17
  24. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-collection.ts +108 -92
  25. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-evidence.ts +44 -82
  26. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-host.ts +3 -4
  27. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-publication.ts +9 -68
  28. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-pull.ts +45 -58
  29. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-refresh.ts +230 -331
  30. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-request.ts +201 -1
  31. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-timing.ts +129 -70
  32. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-typescript.ts +395 -0
  33. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-waiters.ts +8 -12
  34. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostics.ts +685 -183
  35. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-document-state.ts +2 -2
  36. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-document-sync.ts +60 -60
  37. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-request-enrollment.ts +75 -0
  38. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-semantic-input-barrier.ts +443 -0
  39. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-semantic-input-errors.ts +30 -0
  40. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-semantic-input-file.ts +15 -0
  41. package/node_modules/@mrclrchtr/supi-lsp/src/client/client.ts +290 -37
  42. package/node_modules/@mrclrchtr/supi-lsp/src/client/transport.ts +72 -16
  43. package/node_modules/@mrclrchtr/supi-lsp/src/config/capabilities.ts +1 -0
  44. package/node_modules/@mrclrchtr/supi-lsp/src/config/tsconfig-scope.ts +30 -4
  45. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/evidence.ts +1 -1
  46. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/workspace-sentinels.ts +4 -32
  47. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/workspace-sources.ts +108 -0
  48. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-diagnostics.ts +10 -4
  49. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-process-crash-report.ts +50 -1
  50. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-workspace-recovery.ts +11 -7
  51. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager.ts +770 -56
  52. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-diagnostic-surface.ts +3 -1
  53. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-diagnostics.ts +37 -4
  54. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-registry.ts +48 -12
  55. package/node_modules/@mrclrchtr/supi-lsp/src/session/workspace-lsp-runtime.ts +50 -4
  56. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  57. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +8 -1
  58. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/README.md +8 -0
  59. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  60. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/footer-registry.ts +10 -3
  61. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/index.ts +2 -0
  62. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/llm.ts +141 -16
  63. package/node_modules/@mrclrchtr/supi-tree-sitter/package.json +3 -3
  64. package/node_modules/@mrclrchtr/supi-tree-sitter/src/provider/tree-sitter-provider.ts +2 -0
  65. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/callee-display-name.ts +165 -0
  66. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/callees.ts +67 -9
  67. package/node_modules/@mrclrchtr/supi-tree-sitter/src/types.ts +2 -0
  68. package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +19 -18
  69. package/node_modules/yaml/browser/dist/nodes/Alias.js +25 -23
  70. package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +19 -18
  71. package/node_modules/yaml/dist/nodes/Alias.js +25 -23
  72. package/node_modules/yaml/package.json +1 -1
  73. package/package.json +5 -5
  74. package/src/analysis/health/diagnostics.ts +1 -2
  75. package/src/analysis/health/file-scope.ts +4 -1
  76. package/src/analysis/health/server-status.ts +1 -1
  77. package/src/analysis/relations/callees.ts +17 -8
  78. package/src/analysis/relations/implementations.ts +1 -2
  79. package/src/analysis/relations/references.ts +8 -4
  80. package/src/analysis/relations/types.ts +6 -30
  81. package/src/analysis/target/anchored.ts +9 -1
  82. package/src/analysis/target/symbol.ts +29 -11
  83. package/src/analysis/target/types.ts +14 -6
  84. package/src/api.ts +2 -0
  85. package/src/session/graph/collect.ts +18 -3
  86. package/src/session/health-refresh.ts +27 -11
  87. package/src/session/health-types.ts +21 -4
  88. package/src/session/health-workflow.ts +28 -22
  89. package/src/session/orientation-types.ts +5 -6
  90. package/src/session/orientation-workflow.ts +10 -2
  91. package/src/session/session.ts +12 -5
  92. package/src/session/target-workflow.ts +9 -6
  93. package/src/substrate/lsp/maintenance.ts +53 -47
  94. package/src/substrate/lsp/source-tracking.ts +252 -0
  95. package/src/tool/code_find/guidance.ts +3 -3
  96. package/src/tool/code_find/spec.ts +5 -4
  97. package/src/tool/code_find/tui.ts +7 -1
  98. package/src/tool/code_graph/details.ts +32 -0
  99. package/src/tool/code_graph/display.ts +54 -0
  100. package/src/tool/code_graph/format.ts +60 -0
  101. package/src/tool/code_graph/guidance.ts +2 -2
  102. package/src/tool/code_graph/markdown.ts +76 -198
  103. package/src/tool/code_graph/read-next.ts +65 -0
  104. package/src/tool/code_graph/result.ts +118 -83
  105. package/src/tool/code_graph/tui.ts +216 -54
  106. package/src/tool/code_health/guidance.ts +3 -5
  107. package/src/tool/code_health/markdown.ts +90 -57
  108. package/src/tool/code_health/refresh-outcome.ts +51 -0
  109. package/src/tool/code_health/refresh-status.ts +130 -43
  110. package/src/tool/code_health/scope.ts +78 -0
  111. package/src/tool/code_health/spec.ts +1 -1
  112. package/src/tool/code_health/tui.ts +29 -38
  113. package/src/tool/code_inspect/guidance.ts +5 -3
  114. package/src/tool/code_inspect/tui.ts +7 -1
  115. package/src/tool/code_orientation/guidance.ts +3 -4
  116. package/src/tool/code_orientation/result.ts +31 -8
  117. package/src/tool/code_orientation/tui.ts +26 -5
  118. package/src/tool/code_refactor_apply/guidance.ts +1 -1
  119. package/src/tool/code_refactor_apply/tui.ts +7 -3
  120. package/src/tool/code_refactor_plan/guidance.ts +3 -5
  121. package/src/tool/code_refactor_plan/tui.ts +7 -1
  122. package/src/tool/code_resolve/guidance.ts +3 -5
  123. package/src/tool/code_resolve/result.ts +13 -10
  124. package/src/tool/code_resolve/tui.ts +18 -1
  125. package/src/tool/guidance.ts +11 -9
  126. package/src/tool/infra/truncate.ts +39 -13
  127. package/src/tool/result/candidate-row.ts +27 -0
  128. package/src/tool/result/errors.ts +21 -6
  129. package/src/tool/result/types.ts +7 -3
  130. package/src/tool/schemas.ts +5 -0
  131. package/src/types/execution.ts +2 -0
  132. package/src/types/index.ts +2 -0
  133. package/src/ui/tui/candidate-legacy-body.ts +69 -0
  134. package/src/ui/tui/candidate-selection.ts +366 -0
  135. package/src/ui/tui/common.ts +26 -31
  136. package/src/ui/tui/execution-error.ts +92 -0
@@ -4,6 +4,7 @@ import {
4
4
  type CodeQueryResult,
5
5
  type CodeRequestControl,
6
6
  completedCodeQuery,
7
+ isCodeRequestInterruption,
7
8
  throwIfCodeRequestInterrupted,
8
9
  unavailableCodeQuery,
9
10
  } from "@mrclrchtr/supi-code-runtime/api";
@@ -17,22 +18,24 @@ import {
17
18
  type DiagnosticCacheEntry,
18
19
  type DiagnosticSynchronization,
19
20
  hasCurrentEvidence,
20
- hasFreshPush,
21
+ hasFreshEvidence,
22
+ incompleteDiagnosticResult,
21
23
  isCurrentSynchronization,
22
24
  isValidPublishDiagnosticsParams,
23
25
  nextDocumentVersion,
24
26
  } from "./client-diagnostic-evidence.ts";
25
27
  import type { ClientDiagnosticsHost } from "./client-diagnostic-host.ts";
28
+ import { DiagnosticPublicationTracker } from "./client-diagnostic-publication.ts";
29
+ import { startDiagnosticEvidenceFromAdapter } from "./client-diagnostic-pull.ts";
26
30
  import {
27
- DiagnosticPublicationTracker,
28
- trackerFileIdentityFor,
29
- } from "./client-diagnostic-publication.ts";
30
- import {
31
- pullClientDiagnosticEvidenceFromHost,
32
31
  refreshClientOpenDiagnostics,
33
32
  sendDidCloseNotification,
34
33
  } from "./client-diagnostic-refresh.ts";
35
- import { DiagnosticObserver, type DiagnosticPushWaitOutcome } from "./client-diagnostic-timing.ts";
34
+ import {
35
+ DiagnosticRequestInvalidatedError,
36
+ DiagnosticRequestScheduler,
37
+ } from "./client-diagnostic-request.ts";
38
+ import { DiagnosticObserver } from "./client-diagnostic-timing.ts";
36
39
  import { DiagnosticWaitRegistry } from "./client-diagnostic-waiters.ts";
37
40
  import {
38
41
  type ClientDiagnosticSnapshot,
@@ -41,21 +44,54 @@ import {
41
44
  hasConfirmedDiagnosticEvidence,
42
45
  type OpenDocumentState,
43
46
  } from "./client-document-state.ts";
44
- import {
45
- clearTrackedDocumentState,
46
- reopenDocument,
47
- synchronizeTrackedDocument,
48
- } from "./client-document-sync.ts";
47
+ import { clearTrackedDocumentState, synchronizeTrackedDocument } from "./client-document-sync.ts";
49
48
  import { getDiagnosticFileState } from "./client-file-state.ts";
49
+ import { SemanticInputRequestRetryGuard } from "./client-request-enrollment.ts";
50
+ import {
51
+ SemanticInputBarrier,
52
+ type SemanticInputDocument,
53
+ type SemanticInputSnapshot,
54
+ type SemanticInputUpdate,
55
+ } from "./client-semantic-input-barrier.ts";
56
+ import type {
57
+ SemanticInputChangeKind,
58
+ SemanticInputSynchronizationError,
59
+ } from "./client-semantic-input-errors.ts";
60
+ import { isSemanticInputEnrollmentError } from "./client-semantic-input-errors.ts";
50
61
 
51
62
  const DIAGNOSTIC_WAIT_MS = 3_000;
52
- /** Bounded push wait after a reopen-resync fallback, in milliseconds. */
53
- const REOPEN_EVIDENCE_WAIT_MS = 1_000;
63
+ /** Bound rejoin attempts for one synchronization pass, separate from request retries. */
64
+ const MAX_SEMANTIC_INPUT_SYNCHRONIZATION_RETRIES = 1;
65
+
66
+ /** Bound abandoned adapter work without binding it to one caller's deadline. */
67
+ const DIAGNOSTIC_REQUEST_OWNER_TIMEOUT_MS = 30_000;
68
+
69
+ interface ServerDiagnosticRefreshWork {
70
+ promise: Promise<DiagnosticEvidenceSummary>;
71
+ }
72
+
73
+ type DiagnosticEvidenceCandidate = DiagnosticSynchronization & {
74
+ readonly evidenceRevision: number;
75
+ };
76
+
77
+ interface DiagnosticQueryAttemptState {
78
+ inputRevision: number | undefined;
79
+ }
80
+
81
+ function unavailableAfterEnrollmentRetry(error: unknown): CodeQueryResult<Diagnostic[]> {
82
+ const detail = error instanceof Error ? error.message : String(error);
83
+ return unavailableCodeQuery(`Diagnostic collection failed closed: ${detail}`);
84
+ }
85
+
54
86
  /** Own one client's document and diagnostic evidence; revisions prevent stale reuse. */
55
87
  export class ClientDiagnostics {
56
88
  readonly #openDocs = new Map<string, OpenDocumentState>();
57
89
  readonly #diagnosticStore = new Map<string, DiagnosticCacheEntry>();
58
90
  readonly #waiters = new DiagnosticWaitRegistry();
91
+ /** One shared, bounded request engine for this client route. */
92
+ readonly #requestScheduler = new DiagnosticRequestScheduler();
93
+ /** Invalidation signals for active per-file adapter collections. */
94
+ readonly #requestControllers = new Map<string, AbortController>();
59
95
  readonly #versionHistory = new Map<string, number>();
60
96
  readonly #failedUris = new Set<string>();
61
97
  /** Bounded push-publication telemetry for one client's diagnostic state. */
@@ -64,16 +100,160 @@ export class ClientDiagnostics {
64
100
  readonly #unversionedPushSyncMoments = new Map<string, number>();
65
101
  /** URIs closed by a lifecycle operation: versioned pushes stay fail-closed. */
66
102
  readonly #closedVersionedBarrier = new Set<string>();
103
+ readonly #inputBarrier: SemanticInputBarrier;
67
104
  #evidenceRevision = 0;
68
105
  #nextSynchronizationId = 0;
106
+ /** One active server refresh plus one coalesced newer demand generation. */
107
+ #serverRefreshGeneration = 0;
108
+ #serverRefreshWork: ServerDiagnosticRefreshWork | undefined;
69
109
 
70
110
  constructor(private readonly host: ClientDiagnosticsHost) {
71
- this.#publications = new DiagnosticPublicationTracker(
72
- { server: host.server, cwd: host.cwd },
73
- trackerFileIdentityFor(host.cwd),
111
+ this.#publications = new DiagnosticPublicationTracker({
112
+ server: host.server,
113
+ cwd: host.cwd,
114
+ });
115
+ this.#inputBarrier = new SemanticInputBarrier({
116
+ isOperational: () => this.host.isOperational(),
117
+ getOpenDocuments: () => this.#getSemanticInputDocuments(),
118
+ applyDocumentUpdates: (updates) => this.#applySemanticInputUpdates(updates),
119
+ closeMissingDocument: (filePath) => this.didClose(filePath),
120
+ markUnreadableDocument: (filePath) => this.markFailedFile(filePath),
121
+ });
122
+ }
123
+
124
+ /** Synchronize all open document inputs, rejoining once after a new enrollment. */
125
+ async synchronizeSemanticInputs(
126
+ control?: CodeRequestControl,
127
+ contentOverrides?: ReadonlyMap<string, string>,
128
+ ): Promise<SemanticInputSnapshot> {
129
+ throwIfCodeRequestInterrupted(control);
130
+ for (let synchronizationRetry = 0; ; synchronizationRetry++) {
131
+ try {
132
+ return await this.#inputBarrier.synchronize(control, contentOverrides);
133
+ } catch (error) {
134
+ if (
135
+ synchronizationRetry >= MAX_SEMANTIC_INPUT_SYNCHRONIZATION_RETRIES ||
136
+ !isSemanticInputEnrollmentError(error) ||
137
+ !this.host.isOperational()
138
+ ) {
139
+ throw error;
140
+ }
141
+ throwIfCodeRequestInterrupted(control);
142
+ }
143
+ }
144
+ }
145
+
146
+ /** Return the current typed input change after a request revision, when present. */
147
+ getSemanticInputChangeSince(revision: number): SemanticInputSynchronizationError | undefined {
148
+ return this.#inputBarrier.getChangeSince(
149
+ revision,
150
+ "Semantic input changed while the request was running.",
74
151
  );
75
152
  }
76
153
 
154
+ /** Fail closed when a semantic request observes a changed input generation. */
155
+ assertSemanticInputsCurrent(
156
+ snapshot: SemanticInputSnapshot,
157
+ control?: CodeRequestControl,
158
+ contentOverrides?: ReadonlyMap<string, string>,
159
+ ): Promise<void> {
160
+ throwIfCodeRequestInterrupted(control);
161
+ return this.#inputBarrier.assertCurrent(snapshot, control, contentOverrides);
162
+ }
163
+
164
+ #getSemanticInputDocuments(): SemanticInputDocument[] {
165
+ return Array.from(this.#openDocs, ([uri, document]) => ({
166
+ uri,
167
+ filePath: uriToFile(uri),
168
+ content: document.content,
169
+ contentFingerprint: document.contentFingerprint,
170
+ }));
171
+ }
172
+
173
+ #applySemanticInputUpdates(updates: readonly SemanticInputUpdate[]): void {
174
+ if (updates.length === 0) return;
175
+ this.#advanceEvidenceRevision();
176
+ for (const update of updates) {
177
+ const document = this.#openDocs.get(update.document.uri);
178
+ if (!document) throw new Error("Semantic input changed while synchronization was running.");
179
+ this.#synchronizeTrackedDocument(
180
+ update.document.uri,
181
+ update.document.filePath,
182
+ update.content,
183
+ document,
184
+ );
185
+ this.#failedUris.delete(update.document.uri);
186
+ }
187
+ }
188
+
189
+ /** Apply one tracked-document change with the shared client state. */
190
+ #synchronizeTrackedDocument(
191
+ uri: string,
192
+ filePath: string,
193
+ content: string,
194
+ document: OpenDocumentState,
195
+ ): void {
196
+ synchronizeTrackedDocument({
197
+ uri,
198
+ content,
199
+ document,
200
+ nextVersion: () => nextDocumentVersion(this.#versionHistory, uri),
201
+ nextSynchronizationId: () => ++this.#nextSynchronizationId,
202
+ evidenceRevision: this.#evidenceRevision,
203
+ incrementalSync: this.host.usesIncrementalDocumentSync(),
204
+ waiters: this.#waiters,
205
+ sendNotification: (method, params) => this.host.sendNotification(method, params),
206
+ markUnversionedSyncMoment: () => this.#unversionedPushSyncMoments.set(uri, Date.now()),
207
+ clearFailedFile: () => this.#failedUris.delete(uri),
208
+ open: () => this.didOpen(filePath, content),
209
+ });
210
+ }
211
+
212
+ #initializeDocumentContent(uri: string, content: string): void {
213
+ this.#inputBarrier.initializeDocumentContent(uri, content);
214
+ }
215
+
216
+ #observeDiskContent(uri: string, content: string): void {
217
+ this.#inputBarrier.observeDiskContent(uri, content);
218
+ }
219
+
220
+ #forgetDocumentContent(uri: string): void {
221
+ this.#inputBarrier.forgetDocumentContent(uri);
222
+ }
223
+
224
+ #ensureExplicitDocumentContent(
225
+ filePath: string,
226
+ content: string,
227
+ contentIsAuthoritative: boolean,
228
+ ): void {
229
+ const uri = fileToUri(filePath);
230
+ const document = this.#openDocs.get(uri);
231
+ if (!document) {
232
+ // An untracked file needs an initial protocol document before its barrier
233
+ // read can verify the manager's content.
234
+ this.didOpen(filePath, content);
235
+ return;
236
+ }
237
+ // Manager reads are not authoritative: an already tracked document can
238
+ // change between that read and the barrier's verified read.
239
+ if (
240
+ !contentIsAuthoritative ||
241
+ document.contentFingerprint === fingerprintDocumentContent(content)
242
+ ) {
243
+ return;
244
+ }
245
+ this.#cancelDiagnosticRequest(uri);
246
+ this.didChange(filePath, content);
247
+ }
248
+
249
+ #getDiagnosticContentOverrides(
250
+ uri: string,
251
+ content: string,
252
+ contentIsAuthoritative: boolean,
253
+ ): ReadonlyMap<string, string> | undefined {
254
+ return contentIsAuthoritative ? new Map([[uri, content]]) : undefined;
255
+ }
256
+
77
257
  get openFiles(): string[] {
78
258
  return Array.from(this.#openDocs.keys()).map(uriToFile);
79
259
  }
@@ -91,10 +271,12 @@ export class ClientDiagnostics {
91
271
  this.#openDocs.clear();
92
272
  this.#diagnosticStore.clear();
93
273
  this.#versionHistory.clear();
274
+ this.#inputBarrier.clear();
94
275
  this.#evidenceRevision++;
95
276
  this.#unversionedPushSyncMoments.clear();
96
277
  this.#waiters.releaseAll();
97
278
  this.#waiters.cancelSettle();
279
+ this.#cancelAllDiagnosticRequests();
98
280
  }
99
281
 
100
282
  didOpen(filePath: string, content: string): void {
@@ -103,9 +285,14 @@ export class ClientDiagnostics {
103
285
  const uri = fileToUri(filePath);
104
286
  this.#failedUris.delete(uri);
105
287
  if (this.#openDocs.has(uri)) {
288
+ // An equivalent duplicate open is a no-op. A real content change is
289
+ // cancelled by didChange before it advances the synchronization.
106
290
  this.didChange(filePath, content);
107
291
  return;
108
292
  }
293
+ this.#initializeDocumentContent(uri, content);
294
+ this.#cancelDiagnosticRequest(uri);
295
+ this.#advanceInputRevision(false, false, "enrollment");
109
296
 
110
297
  const languageId = detectLanguageId(filePath);
111
298
  this.#waiters.cancelSettle();
@@ -142,29 +329,25 @@ export class ClientDiagnostics {
142
329
  }
143
330
  const nextFingerprint = fingerprintDocumentContent(content);
144
331
  if (doc.contentFingerprint === nextFingerprint) return;
145
- synchronizeTrackedDocument({
146
- uri,
147
- content,
148
- document: doc,
149
- nextVersion: () => nextDocumentVersion(this.#versionHistory, uri),
150
- nextSynchronizationId: () => ++this.#nextSynchronizationId,
151
- evidenceRevision: this.#evidenceRevision,
152
- incrementalSync: this.host.usesIncrementalDocumentSync(),
153
- waiters: this.#waiters,
154
- sendNotification: (method, params) => this.host.sendNotification(method, params),
155
- markUnversionedSyncMoment: () => this.#unversionedPushSyncMoments.set(uri, Date.now()),
156
- clearFailedFile: () => this.#failedUris.delete(uri),
157
- open: () => this.didOpen(filePath, content),
158
- });
332
+ this.#advanceInputRevision();
333
+ this.#synchronizeTrackedDocument(uri, filePath, content, doc);
159
334
  }
160
335
 
161
336
  didClose(filePath: string): void {
162
337
  const uri = fileToUri(filePath);
163
338
  const wasOpen = this.#openDocs.has(uri);
339
+ const hadState =
340
+ wasOpen ||
341
+ this.#diagnosticStore.has(uri) ||
342
+ this.#failedUris.has(uri) ||
343
+ this.#versionHistory.has(uri);
344
+ if (hadState) this.#advanceInputRevision(true, true, "close");
164
345
  this.#failedUris.delete(uri);
346
+ this.#forgetDocumentContent(uri);
165
347
  this.#unversionedPushSyncMoments.delete(uri);
166
348
  this.#closedVersionedBarrier.add(uri);
167
349
  clearTrackedDocumentState(this.#openDocs, this.#diagnosticStore, this.#waiters, uri);
350
+ this.#cancelDiagnosticRequest(uri);
168
351
 
169
352
  if (wasOpen && this.host.isOperational()) {
170
353
  sendDidCloseNotification(this.host, uri);
@@ -184,9 +367,12 @@ export class ClientDiagnostics {
184
367
  if (getDiagnosticFileState(filePath) !== "removed") continue;
185
368
 
186
369
  const wasOpen = this.#openDocs.has(uri);
370
+ this.#advanceInputRevision(true, true, "close");
187
371
  this.#failedUris.delete(uri);
372
+ this.#forgetDocumentContent(uri);
188
373
  this.#unversionedPushSyncMoments.delete(uri);
189
374
  this.#closedVersionedBarrier.add(uri);
375
+ this.#cancelDiagnosticRequest(uri);
190
376
  clearTrackedDocumentState(this.#openDocs, this.#diagnosticStore, this.#waiters, uri);
191
377
  removedFiles.push(filePath);
192
378
  if (wasOpen && this.host.isOperational()) sendDidCloseNotification(this.host, uri);
@@ -197,6 +383,7 @@ export class ClientDiagnostics {
197
383
  /** Retain a failed document outcome when a replacement cannot reopen it. */
198
384
  markFailedFile(filePath: string): void {
199
385
  const uri = fileToUri(filePath);
386
+ if (!this.#failedUris.has(uri)) this.#advanceInputRevision(true, true, "failure");
200
387
  this.#failedUris.add(uri);
201
388
  this.#unversionedPushSyncMoments.delete(uri);
202
389
  this.#closedVersionedBarrier.add(uri);
@@ -224,7 +411,8 @@ export class ClientDiagnostics {
224
411
  }
225
412
  /** Invalidate cache proof while retaining its data as partial fallback. */
226
413
  invalidateCachedEvidence(): void {
227
- this.#evidenceRevision++;
414
+ this.#advanceInputRevision();
415
+ this.#requestScheduler.clearPending();
228
416
  const knownUris = new Set([
229
417
  ...this.#openDocs.keys(),
230
418
  ...this.#diagnosticStore.keys(),
@@ -236,6 +424,7 @@ export class ClientDiagnostics {
236
424
  // pushes the server sends after the change can become fresh evidence.
237
425
  const moment = Date.now();
238
426
  for (const uri of knownUris) {
427
+ this.#cancelDiagnosticRequest(uri);
239
428
  this.#unversionedPushSyncMoments.set(uri, moment);
240
429
  this.#closedVersionedBarrier.add(uri);
241
430
  }
@@ -243,6 +432,7 @@ export class ClientDiagnostics {
243
432
 
244
433
  handlePublishDiagnostics(params: unknown): void {
245
434
  if (!isValidPublishDiagnosticsParams(params)) return;
435
+ const previousEntry = this.#diagnosticStore.get(params.uri);
246
436
  const result = applyPushDiagnostics({
247
437
  store: this.#diagnosticStore,
248
438
  openDocuments: this.#openDocs,
@@ -253,39 +443,74 @@ export class ClientDiagnostics {
253
443
  });
254
444
  if (!result.accepted) return;
255
445
  const entry = this.#diagnosticStore.get(params.uri);
256
- if (entry?.synchronizationId !== undefined && entry.evidenceRevision !== undefined) {
446
+ if (
447
+ entry !== previousEntry &&
448
+ entry?.source === "push" &&
449
+ entry.synchronizationId !== undefined &&
450
+ entry.evidenceRevision !== undefined
451
+ ) {
257
452
  this.#publications.record(
258
453
  params.uri,
259
454
  entry.synchronizationId,
260
455
  entry.evidenceRevision,
261
456
  entry.receivedAt,
262
457
  );
263
- if (result.promoted) {
264
- this.#publications.promoted(
265
- params.uri,
266
- entry.synchronizationId,
267
- entry.evidenceRevision,
268
- entry.receivedAt,
269
- );
270
- }
271
458
  }
272
- this.#waiters.releaseFile(params.uri, "published");
459
+ this.#waiters.releaseFile(params.uri, "observed");
273
460
  this.#waiters.notifySettle();
274
461
  }
275
462
  async refreshOpenDiagnostics(
276
463
  options: { maxWaitMs?: number; quietMs?: number } & CodeRequestControl = {},
277
464
  ): Promise<DiagnosticEvidenceSummary> {
278
- return this.#refreshOpenDiagnostics(options, false);
465
+ // The refresh helper owns its read, classification, and budget pass. Do
466
+ // not run the query barrier first: one changed file could then invalidate
467
+ // the whole maintenance generation and resend unchanged text.
468
+ return this.#refreshOpenDiagnostics(options);
279
469
  }
280
470
 
281
- /** Force a full document resynchronization for a server refresh request. */
282
- async refreshForServerRequest(): Promise<DiagnosticEvidenceSummary> {
283
- return this.#refreshOpenDiagnostics({}, true);
471
+ /**
472
+ * Refresh evidence after a server request without pretending input changed.
473
+ *
474
+ * A newer request invalidates the active evidence and joins the same owned
475
+ * work. The next pass starts only after active diagnostic transport settles.
476
+ */
477
+ refreshForServerRequest(): Promise<DiagnosticEvidenceSummary> {
478
+ this.#serverRefreshGeneration++;
479
+ this.#invalidateDiagnosticEvidenceForServerRequest();
480
+ const active = this.#serverRefreshWork;
481
+ if (active) return active.promise;
482
+
483
+ const work: ServerDiagnosticRefreshWork = {
484
+ promise: undefined as unknown as Promise<DiagnosticEvidenceSummary>,
485
+ };
486
+ this.#serverRefreshWork = work;
487
+ work.promise = this.#runServerDiagnosticRefresh(work);
488
+ void work.promise.catch(() => {});
489
+ return work.promise;
490
+ }
491
+
492
+ async #runServerDiagnosticRefresh(
493
+ work: ServerDiagnosticRefreshWork,
494
+ ): Promise<DiagnosticEvidenceSummary> {
495
+ for (;;) {
496
+ const generation = this.#serverRefreshGeneration;
497
+ try {
498
+ const evidence = await this.#refreshOpenDiagnostics({});
499
+ if (generation === this.#serverRefreshGeneration) {
500
+ if (this.#serverRefreshWork === work) this.#serverRefreshWork = undefined;
501
+ return evidence;
502
+ }
503
+ } catch (error) {
504
+ if (generation === this.#serverRefreshGeneration) {
505
+ if (this.#serverRefreshWork === work) this.#serverRefreshWork = undefined;
506
+ throw error;
507
+ }
508
+ }
509
+ }
284
510
  }
285
511
 
286
512
  async #refreshOpenDiagnostics(
287
513
  options: { maxWaitMs?: number; quietMs?: number } & CodeRequestControl,
288
- forceResynchronize: boolean,
289
514
  ): Promise<DiagnosticEvidenceSummary> {
290
515
  const requestedFiles = Array.from(
291
516
  new Set([...this.#openDocs.keys(), ...this.#diagnosticStore.keys(), ...this.#failedUris]),
@@ -301,20 +526,45 @@ export class ClientDiagnostics {
301
526
  failedFiles: () => new Set(Array.from(this.#failedUris).map(uriToFile)),
302
527
  isRelatedUriTracked: (uri) => this.#openDocs.has(uri) || this.#versionHistory.has(uri),
303
528
  nextSynchronizationId: () => ++this.#nextSynchronizationId,
529
+ noteInputContentChange: () => this.#noteInputContentChange(),
530
+ observeDiskContent: (uri, content) => this.#observeDiskContent(uri, content),
304
531
  invalidateEvidence: (uri) => {
532
+ this.#advanceInputRevision(false, true, "failure");
533
+ this.#cancelDiagnosticRequest(uri);
305
534
  this.#failedUris.add(uri);
535
+ // Keep old diagnostics as partial data, but do not let unchanged
536
+ // recovery restore their former confirmation generation.
537
+ const entry = this.#diagnosticStore.get(uri);
538
+ if (entry) entry.evidenceRevision = -1;
306
539
  const document = this.#openDocs.get(uri);
307
540
  if (document) document.evidenceRevision = -1;
308
541
  },
309
542
  clearFile: (uri) => {
543
+ this.#advanceInputRevision(false, true, "close");
544
+ this.#cancelDiagnosticRequest(uri);
545
+ this.#forgetDocumentContent(uri);
310
546
  this.#failedUris.delete(uri);
311
547
  this.#unversionedPushSyncMoments.delete(uri);
312
548
  this.#closedVersionedBarrier.add(uri);
313
549
  clearTrackedDocumentState(this.#openDocs, this.#diagnosticStore, this.#waiters, uri);
314
550
  },
315
- markUnversionedSyncMoment: (uri) => this.#unversionedPushSyncMoments.set(uri, Date.now()),
551
+ markUnversionedSyncMoment: (uri) => {
552
+ this.#cancelDiagnosticRequest(uri);
553
+ this.#unversionedPushSyncMoments.set(uri, Date.now());
554
+ },
316
555
  clearFailedFile: (uri) => this.#failedUris.delete(uri),
317
- forceResynchronize,
556
+ requestDiagnostics: (requestOptions) =>
557
+ this.#collectDiagnosticRequestEvidence({
558
+ request: requestOptions.request,
559
+ timeoutMs: requestOptions.timeoutMs,
560
+ control: {
561
+ signal: requestOptions.signal,
562
+ deadline: requestOptions.deadline,
563
+ operationId: requestOptions.operationId,
564
+ },
565
+ deadline: requestOptions.deadline,
566
+ operationId: requestOptions.operationId,
567
+ }),
318
568
  options,
319
569
  publications: {
320
570
  emitSummary: (summaryOptions) =>
@@ -334,192 +584,282 @@ export class ClientDiagnostics {
334
584
  filePath: string,
335
585
  content: string,
336
586
  control?: CodeRequestControl,
587
+ options: { contentIsAuthoritative?: boolean } = {},
337
588
  ): Promise<CodeQueryResult<Diagnostic[]>> {
338
589
  // Reject immediately when the request was already cancelled: no document
339
590
  // synchronization or protocol traffic may start for a caller that no
340
591
  // longer awaits a result.
341
592
  throwIfCodeRequestInterrupted(control);
342
- const supportsPull = this.host.supportsPullDiagnostics();
343
- const observer = new DiagnosticObserver("sync-file", supportsPull, control, {
593
+ const uri = fileToUri(filePath);
594
+ const contentIsAuthoritative = options.contentIsAuthoritative !== false;
595
+ this.#ensureExplicitDocumentContent(filePath, content, contentIsAuthoritative);
596
+ const contentOverrides = this.#getDiagnosticContentOverrides(
597
+ uri,
598
+ content,
599
+ contentIsAuthoritative,
600
+ );
601
+ return this.#runDiagnosticQueryWithEnrollmentRetry({
602
+ filePath,
603
+ content,
604
+ uri,
605
+ control,
606
+ contentOverrides,
607
+ });
608
+ }
609
+
610
+ async #runDiagnosticQueryWithEnrollmentRetry(options: {
611
+ filePath: string;
612
+ content: string;
613
+ uri: string;
614
+ control?: CodeRequestControl;
615
+ contentOverrides: ReadonlyMap<string, string> | undefined;
616
+ }): Promise<CodeQueryResult<Diagnostic[]>> {
617
+ const retryGuard = new SemanticInputRequestRetryGuard((revision) =>
618
+ this.getSemanticInputChangeSince(revision),
619
+ );
620
+ for (;;) {
621
+ const attempt: DiagnosticQueryAttemptState = { inputRevision: undefined };
622
+ try {
623
+ retryGuard.assertCurrent();
624
+ return await this.#runDiagnosticQueryAttempt({
625
+ ...options,
626
+ retryGuard,
627
+ reuseCachedEvidence: !retryGuard.hasRetried,
628
+ attempt,
629
+ });
630
+ } catch (error) {
631
+ throwIfCodeRequestInterrupted(options.control);
632
+ if (isSemanticInputEnrollmentError(error)) {
633
+ // Enrollment can change cross-file diagnostics. Invalidate only the
634
+ // evidence generation; the new request key queues behind owned
635
+ // transport instead of joining an active obsolete request.
636
+ this.#invalidateDiagnosticEvidenceAfterEnrollment();
637
+ }
638
+ const decision = retryGuard.decide(error, attempt.inputRevision);
639
+ if (decision.kind === "retry") {
640
+ throwIfCodeRequestInterrupted(options.control);
641
+ continue;
642
+ }
643
+ return unavailableAfterEnrollmentRetry(decision.error);
644
+ }
645
+ }
646
+ }
647
+
648
+ /** Run one diagnostic synchronization and evidence attempt. */
649
+ async #runDiagnosticQueryAttempt(options: {
650
+ filePath: string;
651
+ content: string;
652
+ uri: string;
653
+ control?: CodeRequestControl;
654
+ contentOverrides: ReadonlyMap<string, string> | undefined;
655
+ reuseCachedEvidence: boolean;
656
+ retryGuard: SemanticInputRequestRetryGuard;
657
+ attempt: DiagnosticQueryAttemptState;
658
+ }): Promise<CodeQueryResult<Diagnostic[]>> {
659
+ const {
660
+ filePath,
661
+ content,
662
+ uri,
663
+ control,
664
+ contentOverrides,
665
+ reuseCachedEvidence,
666
+ retryGuard,
667
+ attempt,
668
+ } = options;
669
+ retryGuard.assertCurrent();
670
+ const synchronized = await this.#synchronizeDiagnosticInputs(uri, control, contentOverrides);
671
+ if ("kind" in synchronized) return synchronized;
672
+ const inputSnapshot = synchronized;
673
+ attempt.inputRevision = inputSnapshot.revision;
674
+ throwIfCodeRequestInterrupted(control);
675
+ retryGuard.assertCurrent();
676
+ const inputRevision = inputSnapshot.revision;
677
+ const requestAdapter = this.host.diagnosticRequestAdapter.supports(uri)
678
+ ? this.host.diagnosticRequestAdapter
679
+ : undefined;
680
+ const observer = new DiagnosticObserver("sync-file", requestAdapter !== undefined, control, {
344
681
  server: this.host.server,
345
682
  cwd: this.host.cwd,
346
683
  file: relativeDiagnosticFile(this.host.cwd, filePath),
347
684
  });
348
- const uri = fileToUri(filePath);
349
685
  const cached = this.#diagnosticStore.get(uri);
350
686
  const cachedDiagnostics = cached ? [...cached.diagnostics] : null;
351
687
  const syncStart = Date.now();
352
688
  const openDocument = this.#openDocs.get(uri);
353
689
  const contentUnchanged =
354
690
  openDocument?.contentFingerprint === fingerprintDocumentContent(content);
355
- const synchronizationCurrent = openDocument?.evidenceRevision === this.#evidenceRevision;
356
- // Equivalent content joins the current synchronization. Cached evidence
357
- // cannot force a no-op didChange that cancels an in-flight push or pull.
358
- if (!contentUnchanged || !synchronizationCurrent) {
359
- synchronizeTrackedDocument({
360
- uri,
361
- content,
362
- document: this.#openDocs.get(uri),
363
- nextVersion: () => nextDocumentVersion(this.#versionHistory, uri),
364
- nextSynchronizationId: () => ++this.#nextSynchronizationId,
365
- evidenceRevision: this.#evidenceRevision,
366
- incrementalSync: this.host.usesIncrementalDocumentSync(),
367
- waiters: this.#waiters,
368
- sendNotification: (method, params) => this.host.sendNotification(method, params),
369
- markUnversionedSyncMoment: () => this.#unversionedPushSyncMoments.set(uri, Date.now()),
370
- clearFailedFile: () => this.#failedUris.delete(uri),
371
- open: () => this.didOpen(filePath, content),
372
- });
373
- }
374
691
  const synchronization = this.#openDocs.get(uri);
375
692
  observer.synchronized();
376
693
  if (!synchronization) {
377
- return unavailableCodeQuery("The document could not be synchronized for diagnostics.");
694
+ return unavailableCodeQuery(
695
+ this.#closedVersionedBarrier.has(uri)
696
+ ? "Diagnostic collection ended before the current document synchronization was confirmed."
697
+ : "The document could not be synchronized for diagnostics.",
698
+ );
378
699
  }
379
700
  if (
701
+ reuseCachedEvidence &&
380
702
  contentUnchanged &&
381
703
  hasConfirmedDiagnosticEvidence(synchronization, cached, this.#evidenceRevision)
382
704
  ) {
383
705
  observer.cacheReused(1);
384
- return completedCodeQuery(cachedDiagnostics ?? []);
706
+ return this.#verifyDiagnosticInputs({
707
+ result: completedCodeQuery(cachedDiagnostics ?? []),
708
+ snapshot: inputSnapshot,
709
+ control,
710
+ contentOverrides,
711
+ uri,
712
+ diagnosticEvidence: this.#captureDiagnosticEvidence({
713
+ uri,
714
+ synchronizationId: synchronization.synchronizationId,
715
+ evidenceRevision: synchronization.evidenceRevision,
716
+ }),
717
+ });
385
718
  }
719
+ const requestEvidenceRevision =
720
+ synchronization.evidenceRevision === this.#evidenceRevision
721
+ ? synchronization.evidenceRevision
722
+ : undefined;
386
723
  const request = {
387
724
  uri,
388
725
  synchronizationId: synchronization.synchronizationId,
389
- evidenceRevision: synchronization.evidenceRevision,
726
+ ...(requestEvidenceRevision !== undefined
727
+ ? { evidenceRevision: requestEvidenceRevision }
728
+ : {}),
390
729
  };
391
- return this.#collectFileDiagnosticsWithReopenRetry(
730
+ return this.#collectFileDiagnostics(
392
731
  {
393
732
  filePath,
394
- content,
395
733
  uri,
396
- supportsPull,
734
+ requestAdapter,
397
735
  syncStart,
398
736
  request,
399
737
  cachedDiagnostics,
400
738
  observer,
739
+ inputRevision,
740
+ contentOverrides,
401
741
  },
402
742
  control,
403
743
  );
404
744
  }
405
745
 
406
- /**
407
- * Collect diagnostics for one synchronized document, retrying once through
408
- * a reopen-resync on push-only routes when the first wait times out.
409
- */
410
- async #collectFileDiagnosticsWithReopenRetry(
746
+ async #synchronizeDiagnosticInputs(
747
+ uri: string,
748
+ control: CodeRequestControl | undefined,
749
+ contentOverrides: ReadonlyMap<string, string> | undefined,
750
+ ): Promise<SemanticInputSnapshot | CodeQueryResult<Diagnostic[]>> {
751
+ try {
752
+ return await this.synchronizeSemanticInputs(control, contentOverrides);
753
+ } catch (error) {
754
+ if (isCodeRequestInterruption(error, control)) throw error;
755
+ if (this.#closedVersionedBarrier.has(uri)) {
756
+ return unavailableCodeQuery(
757
+ "Diagnostic collection ended before the current document synchronization was confirmed.",
758
+ );
759
+ }
760
+ const detail = error instanceof Error ? error.message : String(error);
761
+ return unavailableCodeQuery(`Semantic input synchronization failed: ${detail}`);
762
+ }
763
+ }
764
+
765
+ async #verifyDiagnosticInputs(options: {
766
+ result: CodeQueryResult<Diagnostic[]>;
767
+ snapshot: SemanticInputSnapshot;
768
+ control: CodeRequestControl | undefined;
769
+ contentOverrides: ReadonlyMap<string, string> | undefined;
770
+ uri: string;
771
+ diagnosticEvidence?: DiagnosticEvidenceCandidate;
772
+ }): Promise<CodeQueryResult<Diagnostic[]>> {
773
+ try {
774
+ await this.assertSemanticInputsCurrent(
775
+ options.snapshot,
776
+ options.control,
777
+ options.contentOverrides,
778
+ );
779
+ if (
780
+ options.result.kind === "completed" &&
781
+ (!options.diagnosticEvidence ||
782
+ !this.#isDiagnosticEvidenceCurrent(options.diagnosticEvidence))
783
+ ) {
784
+ return incompleteDiagnosticResult(options.result.data, "timed-out");
785
+ }
786
+ return options.result;
787
+ } catch (error) {
788
+ if (isCodeRequestInterruption(error, options.control)) throw error;
789
+ if (isSemanticInputEnrollmentError(error)) throw error;
790
+ if (options.result.kind === "unavailable" && this.#closedVersionedBarrier.has(options.uri)) {
791
+ return options.result;
792
+ }
793
+ const detail = error instanceof Error ? error.message : String(error);
794
+ return unavailableCodeQuery(`Diagnostic collection failed closed: ${detail}`);
795
+ }
796
+ }
797
+
798
+ /** Collect diagnostics for one synchronized document. */
799
+ async #collectFileDiagnostics(
411
800
  options: {
412
801
  filePath: string;
413
- content: string;
414
802
  uri: string;
415
- supportsPull: boolean;
803
+ requestAdapter: ClientDiagnosticsHost["diagnosticRequestAdapter"] | undefined;
416
804
  syncStart: number;
417
805
  request: DiagnosticSynchronization;
418
806
  cachedDiagnostics: Diagnostic[] | null;
419
807
  observer: DiagnosticObserver;
808
+ inputRevision: number;
809
+ contentOverrides: ReadonlyMap<string, string> | undefined;
420
810
  },
421
811
  control?: CodeRequestControl,
422
812
  ): Promise<CodeQueryResult<Diagnostic[]>> {
423
- const {
424
- filePath,
425
- content,
426
- uri,
427
- supportsPull,
428
- syncStart,
429
- request,
430
- cachedDiagnostics,
431
- observer,
432
- } = options;
433
- let pushOutcome: DiagnosticPushWaitOutcome | undefined;
434
- let attemptRequest = request;
435
- let attemptSyncStart = syncStart;
436
- let attemptMaxWaitMs = DIAGNOSTIC_WAIT_MS;
437
- let reopenedOnce = false;
438
- let result: CodeQueryResult<Diagnostic[]>;
439
- for (;;) {
440
- result = await collectSynchronizedFileDiagnostics(
441
- {
442
- supportsPull,
443
- syncStart: attemptSyncStart,
444
- maxWaitMs: attemptMaxWaitMs,
445
- request: attemptRequest,
446
- cachedDiagnostics,
447
- observer,
448
- waiters: this.#waiters,
449
- current: () => isCurrentSynchronization(this.#openDocs, attemptRequest),
450
- freshPush: () =>
451
- hasFreshPush(this.#diagnosticStore, attemptRequest, this.#evidenceRevision),
452
- currentPushReceivedAt: () => {
453
- const entry = this.#diagnosticStore.get(attemptRequest.uri);
454
- return entry?.source === "push" &&
455
- hasCurrentEvidence(this.#diagnosticStore, attemptRequest, this.#evidenceRevision)
456
- ? entry.receivedAt
457
- : undefined;
458
- },
459
- diagnostics: () => this.getDiagnostics(filePath),
460
- pullDiagnostics: (timeoutMs, signal) =>
461
- pullClientDiagnosticEvidenceFromHost({
462
- host: this.host,
463
- store: this.#diagnosticStore,
464
- openDocuments: this.#openDocs,
465
- currentEvidenceRevision: () => this.#evidenceRevision,
466
- isRelatedUriTracked: (relatedUri) =>
467
- this.#openDocs.has(relatedUri) || this.#versionHistory.has(relatedUri),
468
- request: {
469
- uri,
813
+ const { filePath, uri, requestAdapter, syncStart, request, cachedDiagnostics, observer } =
814
+ options;
815
+ const collected = await collectSynchronizedFileDiagnostics(
816
+ {
817
+ requestDiagnostics: requestAdapter
818
+ ? (timeoutMs, deadline, requestControl) =>
819
+ this.#collectDiagnosticRequestEvidence({
820
+ request,
470
821
  timeoutMs,
471
- synchronizationId: attemptRequest.synchronizationId,
472
- evidenceRevision: attemptRequest.evidenceRevision ?? this.#evidenceRevision,
473
- signal,
474
- deadline: control?.deadline,
822
+ control: requestControl,
823
+ deadline,
475
824
  operationId: control?.operationId,
476
- },
477
- }),
478
- onPushWait: (outcome) => {
479
- pushOutcome = outcome;
480
- },
481
- },
482
- control,
483
- );
484
- // Reopen-resync fallback (R2): on push-only routes a document that
485
- // timed out with no push stays unconfirmed — a clean file gets no
486
- // push on didChange at all. Close and reopen it over the protocol so
487
- // the server publishes on didOpen, then wait once more with a bounded
488
- // budget. The cache entry and version history survive the reopen.
489
- const document = this.#openDocs.get(uri);
490
- if (
491
- supportsPull ||
492
- reopenedOnce ||
493
- result.kind === "completed" ||
494
- pushOutcome !== "timed-out" ||
495
- !document ||
496
- !isCurrentSynchronization(this.#openDocs, attemptRequest)
497
- ) {
498
- break;
499
- }
500
- reopenDocument({
501
- uri,
502
- content,
503
- document,
504
- languageId: detectLanguageId(filePath),
505
- nextVersion: () => nextDocumentVersion(this.#versionHistory, uri),
506
- nextSynchronizationId: () => ++this.#nextSynchronizationId,
507
- evidenceRevision: this.#evidenceRevision,
825
+ })
826
+ : undefined,
827
+ requestSource: requestAdapter?.sourceFor(uri),
828
+ syncStart,
829
+ maxWaitMs: DIAGNOSTIC_WAIT_MS,
830
+ request,
831
+ cachedDiagnostics,
832
+ observer,
508
833
  waiters: this.#waiters,
509
- sendNotification: (method, params) => this.host.sendNotification(method, params),
510
- markUnversionedSyncMoment: () => this.#unversionedPushSyncMoments.set(uri, Date.now()),
511
- });
512
- observer.reopened(1);
513
- reopenedOnce = true;
514
- attemptRequest = {
515
- uri,
516
- synchronizationId: document.synchronizationId,
517
- evidenceRevision: document.evidenceRevision,
518
- };
519
- attemptSyncStart = Date.now();
520
- attemptMaxWaitMs = REOPEN_EVIDENCE_WAIT_MS;
521
- }
522
- observer.pushWaitCompleted(1, pushOutcome ?? "timed-out");
834
+ current: () => isCurrentSynchronization(this.#openDocs, request),
835
+ currentPushObservation: () => {
836
+ const entry = this.#diagnosticStore.get(request.uri);
837
+ return entry?.source === "push" &&
838
+ hasCurrentEvidence(this.#diagnosticStore, request, this.#evidenceRevision)
839
+ ? { receivedAt: entry.receivedAt, hasDiagnostics: entry.diagnostics.length > 0 }
840
+ : undefined;
841
+ },
842
+ diagnostics: () => this.getDiagnostics(filePath),
843
+ },
844
+ control,
845
+ );
846
+ const result = await this.#verifyDiagnosticInputs({
847
+ result: collected,
848
+ snapshot: { revision: options.inputRevision },
849
+ control,
850
+ contentOverrides: options.contentOverrides,
851
+ uri,
852
+ // Capture the candidate before the final input barrier can await a read.
853
+ // The candidate must not follow the mutable document revision.
854
+ diagnosticEvidence:
855
+ collected.kind === "completed"
856
+ ? this.#captureDiagnosticEvidence({
857
+ uri: request.uri,
858
+ synchronizationId: request.synchronizationId,
859
+ evidenceRevision: request.evidenceRevision ?? this.#evidenceRevision,
860
+ })
861
+ : undefined,
862
+ });
523
863
  this.#publications.emitSummary({
524
864
  operation: "sync-file",
525
865
  identity: {
@@ -530,8 +870,8 @@ export class ClientDiagnostics {
530
870
  synchronizations: [
531
871
  {
532
872
  uri,
533
- synchronizationId: attemptRequest.synchronizationId,
534
- evidenceRevision: attemptRequest.evidenceRevision ?? this.#evidenceRevision,
873
+ synchronizationId: request.synchronizationId,
874
+ evidenceRevision: request.evidenceRevision ?? this.#evidenceRevision,
535
875
  confirmed: result.kind === "completed",
536
876
  },
537
877
  ],
@@ -539,6 +879,168 @@ export class ClientDiagnostics {
539
879
  });
540
880
  return result;
541
881
  }
882
+
883
+ /** Capture immutable identity for one request-confirmed diagnostic result. */
884
+ #captureDiagnosticEvidence(options: {
885
+ uri: string;
886
+ synchronizationId: number;
887
+ evidenceRevision: number;
888
+ }): DiagnosticEvidenceCandidate | undefined {
889
+ const entry = this.#diagnosticStore.get(options.uri);
890
+ if (
891
+ !entry ||
892
+ entry.source === "push" ||
893
+ entry.synchronizationId !== options.synchronizationId ||
894
+ entry.evidenceRevision !== options.evidenceRevision ||
895
+ options.evidenceRevision !== this.#evidenceRevision
896
+ ) {
897
+ return undefined;
898
+ }
899
+ return {
900
+ uri: options.uri,
901
+ synchronizationId: options.synchronizationId,
902
+ evidenceRevision: options.evidenceRevision,
903
+ };
904
+ }
905
+
906
+ /** Check a captured diagnostic result against the current live evidence. */
907
+ #isDiagnosticEvidenceCurrent(candidate: DiagnosticEvidenceCandidate): boolean {
908
+ return (
909
+ candidate.evidenceRevision === this.#evidenceRevision &&
910
+ isCurrentSynchronization(this.#openDocs, candidate) &&
911
+ hasFreshEvidence(this.#diagnosticStore, candidate, this.#evidenceRevision)
912
+ );
913
+ }
914
+
915
+ /** Start one shared request and apply its report through the evidence gate. */
916
+ #collectDiagnosticRequestEvidence(options: {
917
+ request: DiagnosticSynchronization;
918
+ timeoutMs: number;
919
+ control?: CodeRequestControl;
920
+ deadline?: number;
921
+ operationId?: string;
922
+ }): Promise<boolean> {
923
+ const adapter = this.host.diagnosticRequestAdapter;
924
+ if (!adapter?.supports(options.request.uri)) return Promise.resolve(false);
925
+ const key = [
926
+ options.request.uri,
927
+ options.request.synchronizationId,
928
+ options.request.evidenceRevision ?? this.#evidenceRevision,
929
+ ].join("\x00");
930
+ const consumerDeadline =
931
+ options.deadline ??
932
+ (Number.isFinite(options.timeoutMs) ? Date.now() + options.timeoutMs : undefined);
933
+ const consumerControl: CodeRequestControl = {
934
+ signal: options.control?.signal,
935
+ ...(consumerDeadline !== undefined ? { deadline: consumerDeadline } : {}),
936
+ };
937
+ return this.#requestScheduler.run(
938
+ key,
939
+ () => {
940
+ if (!isCurrentSynchronization(this.#openDocs, options.request)) {
941
+ return { result: Promise.resolve(false), settled: Promise.resolve() };
942
+ }
943
+ const controller = new AbortController();
944
+ this.#requestControllers.set(options.request.uri, controller);
945
+ // The scheduler already races each caller against its own control.
946
+ // Keep the adapter job alive after that race so another caller can
947
+ // join the same underlying request. Its owner bound is independent
948
+ // of the first caller's deadline.
949
+ const ownerTimeoutMs = Number.isFinite(options.timeoutMs)
950
+ ? Math.max(options.timeoutMs, DIAGNOSTIC_REQUEST_OWNER_TIMEOUT_MS)
951
+ : DIAGNOSTIC_REQUEST_OWNER_TIMEOUT_MS;
952
+ const request = {
953
+ uri: options.request.uri,
954
+ previousResultId: this.#diagnosticStore.get(options.request.uri)?.resultId,
955
+ timeoutMs: ownerTimeoutMs,
956
+ signal: controller.signal,
957
+ operationId: options.operationId,
958
+ };
959
+ const execution = startDiagnosticEvidenceFromAdapter({
960
+ adapter,
961
+ request,
962
+ store: this.#diagnosticStore,
963
+ synchronizationId: options.request.synchronizationId,
964
+ evidenceRevision: options.request.evidenceRevision ?? this.#evidenceRevision,
965
+ currentRevision: () => this.#evidenceRevision,
966
+ isCurrentSynchronization: () => isCurrentSynchronization(this.#openDocs, options.request),
967
+ markEvidenceCurrent: (_uri, synchronizationId) => {
968
+ const document = this.#openDocs.get(options.request.uri);
969
+ if (
970
+ document &&
971
+ synchronizationId !== undefined &&
972
+ document.synchronizationId === synchronizationId
973
+ ) {
974
+ document.evidenceRevision =
975
+ options.request.evidenceRevision ?? this.#evidenceRevision;
976
+ }
977
+ },
978
+ isRelatedUriTracked: (uri) => this.#openDocs.has(uri) || this.#versionHistory.has(uri),
979
+ });
980
+ void execution.settled
981
+ .finally(() => {
982
+ if (this.#requestControllers.get(options.request.uri) === controller) {
983
+ this.#requestControllers.delete(options.request.uri);
984
+ }
985
+ })
986
+ .catch(() => {});
987
+ return execution;
988
+ },
989
+ consumerControl,
990
+ );
991
+ }
992
+
993
+ #noteInputContentChange(): number {
994
+ this.#advanceInputRevision();
995
+ return this.#evidenceRevision;
996
+ }
997
+
998
+ /** Invalidate diagnostic evidence without advancing the semantic input generation. */
999
+ #invalidateDiagnosticEvidenceForServerRequest(): void {
1000
+ // Keep active adapter work owned until it settles. The refresh generation
1001
+ // below discards its evidence and starts the newer pass afterwards.
1002
+ this.#advanceEvidenceRevision(false);
1003
+ this.#waiters.cancelSettle();
1004
+ }
1005
+
1006
+ #advanceInputRevision(
1007
+ invalidateEvidence = true,
1008
+ cancelRequests = true,
1009
+ changeKind: SemanticInputChangeKind = "content",
1010
+ ): void {
1011
+ this.#inputBarrier.noteInputChange(changeKind);
1012
+ if (invalidateEvidence) this.#advanceEvidenceRevision(false);
1013
+ if (cancelRequests) this.#cancelAllDiagnosticRequests();
1014
+ }
1015
+
1016
+ #advanceEvidenceRevision(cancelRequests = true): void {
1017
+ this.#evidenceRevision++;
1018
+ for (const document of this.#openDocs.values()) {
1019
+ document.evidenceRevision = this.#evidenceRevision;
1020
+ }
1021
+ if (cancelRequests) this.#cancelAllDiagnosticRequests();
1022
+ }
1023
+
1024
+ /** Invalidate enrollment-stale evidence without cancelling owned transport. */
1025
+ #invalidateDiagnosticEvidenceAfterEnrollment(): void {
1026
+ this.#advanceEvidenceRevision(false);
1027
+ this.#waiters.cancelSettle();
1028
+ }
1029
+
1030
+ #cancelDiagnosticRequest(uri: string): void {
1031
+ this.#requestControllers
1032
+ .get(uri)
1033
+ ?.abort(new DiagnosticRequestInvalidatedError("Diagnostic request was superseded."));
1034
+ const prefix = `${uri}\x00`;
1035
+ this.#requestScheduler.clearPendingWhere((key) => key.startsWith(prefix));
1036
+ }
1037
+
1038
+ #cancelAllDiagnosticRequests(): void {
1039
+ for (const controller of this.#requestControllers.values()) {
1040
+ controller.abort(new DiagnosticRequestInvalidatedError());
1041
+ }
1042
+ this.#requestScheduler.clearPending();
1043
+ }
542
1044
  }
543
1045
 
544
1046
  /** Return a workspace-relative diagnostic file path for telemetry identity. */