@mrclrchtr/supi-code-intelligence 5.0.0 → 6.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 (139) hide show
  1. package/README.md +2 -2
  2. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  3. package/node_modules/@mrclrchtr/supi-core/package.json +2 -2
  4. package/node_modules/@mrclrchtr/supi-core/src/api.ts +1 -1
  5. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +0 -3
  6. package/node_modules/@mrclrchtr/supi-core/src/debug.ts +9 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/index.ts +1 -1
  8. package/node_modules/@mrclrchtr/supi-lsp/README.md +34 -3
  9. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  10. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/package.json +2 -2
  11. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/api.ts +1 -1
  12. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +0 -3
  13. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/debug.ts +9 -0
  14. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/index.ts +1 -1
  15. package/node_modules/@mrclrchtr/supi-lsp/package.json +3 -3
  16. package/node_modules/@mrclrchtr/supi-lsp/src/api.ts +12 -1
  17. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-cache.ts +67 -28
  18. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-collection.ts +63 -6
  19. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-evidence.ts +46 -5
  20. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-host.ts +1 -0
  21. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-publication.ts +214 -0
  22. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-refresh.ts +278 -79
  23. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-timing.ts +29 -8
  24. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostics.ts +101 -19
  25. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-document-state.ts +19 -1
  26. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-document-sync.ts +49 -3
  27. package/node_modules/@mrclrchtr/supi-lsp/src/client/client.ts +73 -15
  28. package/node_modules/@mrclrchtr/supi-lsp/src/client/transport.ts +1 -1
  29. package/node_modules/@mrclrchtr/supi-lsp/src/config/capabilities.ts +2 -2
  30. package/node_modules/@mrclrchtr/supi-lsp/src/config/config.ts +5 -5
  31. package/node_modules/@mrclrchtr/supi-lsp/src/config/server-config.ts +3 -0
  32. package/node_modules/@mrclrchtr/supi-lsp/src/config/tsconfig-extends.ts +68 -0
  33. package/node_modules/@mrclrchtr/supi-lsp/src/config/tsconfig-path.ts +8 -0
  34. package/node_modules/@mrclrchtr/supi-lsp/src/config/tsconfig-scope.ts +176 -24
  35. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/evidence.ts +4 -0
  36. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/workspace-sentinels.ts +46 -10
  37. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-diagnostics.ts +4 -3
  38. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-workspace-recovery.ts +65 -17
  39. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager.ts +143 -21
  40. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-diagnostic-surface.ts +2 -0
  41. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-diagnostics.ts +43 -1
  42. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-registry.ts +31 -0
  43. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  44. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/package.json +2 -2
  45. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/api.ts +1 -1
  46. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +0 -3
  47. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/debug.ts +9 -0
  48. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/index.ts +1 -1
  49. package/node_modules/@mrclrchtr/supi-tree-sitter/package.json +3 -3
  50. package/package.json +5 -5
  51. package/src/analysis/health/diagnostics.ts +58 -126
  52. package/src/analysis/health/file-scope.ts +184 -0
  53. package/src/analysis/health/recovery.ts +8 -1
  54. package/src/analysis/readiness.ts +4 -0
  55. package/src/analysis/search/ast-scan.ts +4 -1
  56. package/src/analysis/search/pattern-analysis.ts +10 -3
  57. package/src/analysis/search/pattern.ts +6 -1
  58. package/src/config.ts +35 -1
  59. package/src/extension.ts +57 -14
  60. package/src/overview/overview-data.ts +9 -2
  61. package/src/overview/overview.ts +49 -30
  62. package/src/overview/types.ts +2 -0
  63. package/src/session/find-types.ts +2 -2
  64. package/src/session/find-workflow.ts +3 -2
  65. package/src/session/health-refresh.ts +76 -49
  66. package/src/session/health-types.ts +7 -1
  67. package/src/session/health-workflow.ts +35 -18
  68. package/src/session/input/workflows.ts +2 -2
  69. package/src/session/inspect-workflow.ts +2 -1
  70. package/src/session/orientation/collect.ts +2 -2
  71. package/src/session/orientation-types.ts +2 -0
  72. package/src/session/orientation-workflow.ts +8 -2
  73. package/src/session/refactor-workflow.ts +2 -1
  74. package/src/session/session.ts +25 -1
  75. package/src/substrate/lsp/maintenance.ts +105 -23
  76. package/src/substrate/lsp/recovery.ts +11 -5
  77. package/src/tool/code_find/execute.ts +23 -0
  78. package/src/tool/code_find/guidance.ts +8 -0
  79. package/src/tool/{find → code_find}/render.ts +1 -1
  80. package/src/tool/{result/find.ts → code_find/result.ts} +36 -11
  81. package/src/tool/code_find/spec.ts +37 -0
  82. package/src/tool/code_graph/execute.ts +24 -0
  83. package/src/tool/code_graph/guidance.ts +6 -0
  84. package/src/tool/{graph → code_graph}/markdown.ts +1 -1
  85. package/src/tool/{result/graph.ts → code_graph/result.ts} +69 -4
  86. package/src/tool/code_graph/spec.ts +39 -0
  87. package/src/tool/{health → code_health}/execute.ts +2 -18
  88. package/src/tool/code_health/guidance.ts +8 -0
  89. package/src/tool/{health → code_health}/markdown.ts +68 -18
  90. package/src/tool/{result/health.ts → code_health/result.ts} +29 -4
  91. package/src/tool/code_health/spec.ts +41 -0
  92. package/src/tool/code_inspect/execute.ts +20 -0
  93. package/src/tool/code_inspect/guidance.ts +6 -0
  94. package/src/tool/{inspect → code_inspect}/markdown.ts +1 -1
  95. package/src/tool/{result/inspect.ts → code_inspect/result.ts} +32 -3
  96. package/src/tool/code_inspect/spec.ts +25 -0
  97. package/src/tool/code_orientation/execute.ts +25 -0
  98. package/src/tool/code_orientation/guidance.ts +9 -0
  99. package/src/tool/{orientation → code_orientation}/markdown.ts +1 -1
  100. package/src/tool/{result/orientation.ts → code_orientation/result.ts} +67 -3
  101. package/src/tool/code_orientation/spec.ts +25 -0
  102. package/src/tool/code_refactor_apply/execute.ts +17 -0
  103. package/src/tool/code_refactor_apply/guidance.ts +5 -0
  104. package/src/tool/{refactor-apply/execute.ts → code_refactor_apply/result.ts} +5 -11
  105. package/src/tool/code_refactor_apply/spec.ts +26 -0
  106. package/src/tool/code_refactor_plan/execute.ts +27 -0
  107. package/src/tool/code_refactor_plan/guidance.ts +8 -0
  108. package/src/tool/{refactor-plan/execute.ts → code_refactor_plan/result.ts} +9 -22
  109. package/src/tool/code_refactor_plan/spec.ts +25 -0
  110. package/src/tool/code_resolve/execute.ts +19 -0
  111. package/src/tool/code_resolve/guidance.ts +8 -0
  112. package/src/tool/{resolve → code_resolve}/markdown.ts +1 -1
  113. package/src/tool/{result/resolve.ts → code_resolve/result.ts} +37 -24
  114. package/src/tool/code_resolve/spec.ts +25 -0
  115. package/src/tool/guidance.ts +71 -52
  116. package/src/tool/infra/truncate.ts +40 -0
  117. package/src/tool/{refactor-plan/markdown.ts → refactor-markdown.ts} +4 -7
  118. package/src/tool/register.ts +9 -97
  119. package/src/tool/result/refactor.ts +1 -4
  120. package/src/tool/schemas.ts +26 -157
  121. package/src/tool/specs.ts +40 -126
  122. package/src/tool/find/execute.ts +0 -43
  123. package/src/tool/graph/execute.ts +0 -82
  124. package/src/tool/inspect/execute.ts +0 -40
  125. package/src/tool/orientation/execute.ts +0 -80
  126. package/src/tool/resolve/execute.ts +0 -53
  127. /package/src/tool/{find → code_find}/ast-kinds.ts +0 -0
  128. /package/src/tool/{find → code_find}/markdown.ts +0 -0
  129. /package/src/tool/{find → code_find}/modes.ts +0 -0
  130. /package/src/tool/{find → code_find}/tui.ts +0 -0
  131. /package/src/tool/{graph → code_graph}/tui.ts +0 -0
  132. /package/src/tool/{health → code_health}/refresh-status.ts +0 -0
  133. /package/src/tool/{health → code_health}/semantic-state.ts +0 -0
  134. /package/src/tool/{health → code_health}/tui.ts +0 -0
  135. /package/src/tool/{inspect → code_inspect}/tui.ts +0 -0
  136. /package/src/tool/{orientation → code_orientation}/tui.ts +0 -0
  137. /package/src/tool/{refactor-apply → code_refactor_apply}/tui.ts +0 -0
  138. /package/src/tool/{refactor-plan → code_refactor_plan}/tui.ts +0 -0
  139. /package/src/tool/{resolve → code_resolve}/tui.ts +0 -0
@@ -6,8 +6,20 @@ type DiagnosticCollection = "cache" | "fallback" | "none" | "pull" | "push";
6
6
  type DiagnosticFreshness = "not-observed" | "observed";
7
7
  type DiagnosticOutcome = "completed" | "incomplete" | "skipped" | "timed-out";
8
8
  type DiagnosticPullOutcome = "completed" | "failed" | "not-supported" | "not-used" | "timed-out";
9
- type DiagnosticPushOutcome = "not-used" | "published" | "released" | "settled" | "timed-out";
10
- type DiagnosticSettleOutcome = "not-used" | "published" | "quiet" | "released" | "timed-out";
9
+ type DiagnosticPushOutcome =
10
+ | "not-used"
11
+ | "published"
12
+ | "tentative"
13
+ | "released"
14
+ | "settled"
15
+ | "timed-out";
16
+ type DiagnosticSettleOutcome =
17
+ | "not-used"
18
+ | "published"
19
+ | "tentative"
20
+ | "quiet"
21
+ | "released"
22
+ | "timed-out";
11
23
  type DiagnosticTimingOperation = "refresh-open" | "sync-file";
12
24
 
13
25
  /** Result of waiting for a quiet push-diagnostic window. */
@@ -17,7 +29,7 @@ export interface DiagnosticSettleResult {
17
29
  }
18
30
 
19
31
  /** Result of waiting for one file's push diagnostics. */
20
- export type DiagnosticPushWaitOutcome = "published" | "released" | "timed-out";
32
+ export type DiagnosticPushWaitOutcome = "published" | "tentative" | "released" | "timed-out";
21
33
 
22
34
  interface DiagnosticTimingData {
23
35
  readonly collection: DiagnosticCollection;
@@ -134,10 +146,15 @@ export class DiagnosticObserver {
134
146
  this.#timer.mark("pull");
135
147
  }
136
148
 
137
- /** Record that the reopen-resync fallback re-opened unconfirmed documents. */
149
+ /**
150
+ * Record that the reopen-resync fallback re-opened unconfirmed documents.
151
+ *
152
+ * The mark fires when the second settle window starts, so the measured
153
+ * phase is the preceding first settle window, not the reopen work.
154
+ */
138
155
  reopened(count: number): void {
139
156
  this.#reopened += count;
140
- this.#timer.mark("reopen");
157
+ this.#timer.mark("first-settle");
141
158
  }
142
159
 
143
160
  pushSettled(documentCount: number, settle: DiagnosticSettleResult): void {
@@ -161,14 +178,18 @@ export class DiagnosticObserver {
161
178
  }
162
179
 
163
180
  pushWaitCompleted(documentCount: number, push: DiagnosticPushWaitOutcome): void {
164
- const timedOut = push === "timed-out";
181
+ const confirmed = push === "published";
182
+ // A tentative result used the full wait budget without a confirming
183
+ // republish. Keep its distinct push value and report the budget expiry.
184
+ const timedOut = push === "tentative" || push === "timed-out";
185
+ const observed = push === "published" || push === "tentative";
165
186
  this.#finish(
166
187
  {
167
188
  collection: this.supportsPull ? "fallback" : "push",
168
189
  documentCount,
169
190
  fallback: this.supportsPull,
170
- freshness: push === "published" ? "observed" : "not-observed",
171
- outcome: push === "published" ? "completed" : timedOut ? "timed-out" : "incomplete",
191
+ freshness: observed ? "observed" : "not-observed",
192
+ outcome: confirmed ? "completed" : timedOut ? "timed-out" : "incomplete",
172
193
  pull: this.#pull,
173
194
  push,
174
195
  reopen: this.#reopened,
@@ -15,12 +15,17 @@ import { collectSynchronizedFileDiagnostics } from "./client-diagnostic-collecti
15
15
  import {
16
16
  type DiagnosticCacheEntry,
17
17
  type DiagnosticSynchronization,
18
+ hasCurrentEvidence,
18
19
  hasFreshPush,
19
20
  isCurrentSynchronization,
20
21
  isValidPublishDiagnosticsParams,
21
22
  nextDocumentVersion,
22
23
  } from "./client-diagnostic-evidence.ts";
23
24
  import type { ClientDiagnosticsHost } from "./client-diagnostic-host.ts";
25
+ import {
26
+ DiagnosticPublicationTracker,
27
+ trackerFileIdentityFor,
28
+ } from "./client-diagnostic-publication.ts";
24
29
  import {
25
30
  pullClientDiagnosticEvidenceFromHost,
26
31
  refreshClientOpenDiagnostics,
@@ -32,7 +37,7 @@ import {
32
37
  type ClientDiagnosticSnapshot,
33
38
  type DiagnosticEntry,
34
39
  fingerprintDocumentContent,
35
- hasCurrentDiagnosticEvidence,
40
+ hasConfirmedDiagnosticEvidence,
36
41
  type OpenDocumentState,
37
42
  } from "./client-document-state.ts";
38
43
  import {
@@ -52,6 +57,8 @@ export class ClientDiagnostics {
52
57
  readonly #waiters = new DiagnosticWaitRegistry();
53
58
  readonly #versionHistory = new Map<string, number>();
54
59
  readonly #failedUris = new Set<string>();
60
+ /** Bounded push-publication telemetry for one client's diagnostic state. */
61
+ readonly #publications: DiagnosticPublicationTracker;
55
62
  /** Client-side sync moment per URI: unversioned pushes before it stay rejected. */
56
63
  readonly #unversionedPushSyncMoments = new Map<string, number>();
57
64
  /** URIs closed by a lifecycle operation: versioned pushes stay fail-closed. */
@@ -59,7 +66,12 @@ export class ClientDiagnostics {
59
66
  #evidenceRevision = 0;
60
67
  #nextSynchronizationId = 0;
61
68
 
62
- constructor(private readonly host: ClientDiagnosticsHost) {}
69
+ constructor(private readonly host: ClientDiagnosticsHost) {
70
+ this.#publications = new DiagnosticPublicationTracker(
71
+ { server: host.server, cwd: host.cwd },
72
+ trackerFileIdentityFor(host.cwd),
73
+ );
74
+ }
63
75
 
64
76
  get openFiles(): string[] {
65
77
  return Array.from(this.#openDocs.keys()).map(uriToFile);
@@ -105,6 +117,7 @@ export class ClientDiagnostics {
105
117
  version,
106
118
  synchronizationId: ++this.#nextSynchronizationId,
107
119
  evidenceRevision: this.#evidenceRevision,
120
+ content,
108
121
  contentFingerprint: fingerprintDocumentContent(content),
109
122
  });
110
123
  this.host.sendNotification("textDocument/didOpen", {
@@ -135,6 +148,7 @@ export class ClientDiagnostics {
135
148
  nextVersion: () => nextDocumentVersion(this.#versionHistory, uri),
136
149
  nextSynchronizationId: () => ++this.#nextSynchronizationId,
137
150
  evidenceRevision: this.#evidenceRevision,
151
+ incrementalSync: this.host.usesIncrementalDocumentSync(),
138
152
  waiters: this.#waiters,
139
153
  sendNotification: (method, params) => this.host.sendNotification(method, params),
140
154
  markUnversionedSyncMoment: () => this.#unversionedPushSyncMoments.set(uri, Date.now()),
@@ -157,7 +171,11 @@ export class ClientDiagnostics {
157
171
  }
158
172
 
159
173
  pruneMissingFiles(): string[] {
160
- const uris = new Set([...this.#openDocs.keys(), ...this.#diagnosticStore.keys()]);
174
+ const uris = new Set([
175
+ ...this.#openDocs.keys(),
176
+ ...this.#diagnosticStore.keys(),
177
+ ...this.#failedUris,
178
+ ]);
161
179
  const removedFiles: string[] = [];
162
180
 
163
181
  for (const uri of uris) {
@@ -165,7 +183,7 @@ export class ClientDiagnostics {
165
183
  if (getDiagnosticFileState(filePath) !== "removed") continue;
166
184
 
167
185
  const wasOpen = this.#openDocs.has(uri);
168
- this.#failedUris.add(uri);
186
+ this.#failedUris.delete(uri);
169
187
  this.#unversionedPushSyncMoments.delete(uri);
170
188
  this.#closedVersionedBarrier.add(uri);
171
189
  clearTrackedDocumentState(this.#openDocs, this.#diagnosticStore, this.#waiters, uri);
@@ -224,23 +242,49 @@ export class ClientDiagnostics {
224
242
 
225
243
  handlePublishDiagnostics(params: unknown): void {
226
244
  if (!isValidPublishDiagnosticsParams(params)) return;
227
- if (
228
- !applyPushDiagnostics({
229
- store: this.#diagnosticStore,
230
- openDocuments: this.#openDocs,
231
- params,
232
- evidenceRevision: this.#evidenceRevision,
233
- unversionedSyncMoment: this.#unversionedPushSyncMoments.get(params.uri),
234
- closedVersionedBarrier: this.#closedVersionedBarrier.has(params.uri),
235
- })
236
- ) {
237
- return;
245
+ const result = applyPushDiagnostics({
246
+ store: this.#diagnosticStore,
247
+ openDocuments: this.#openDocs,
248
+ params,
249
+ evidenceRevision: this.#evidenceRevision,
250
+ unversionedSyncMoment: this.#unversionedPushSyncMoments.get(params.uri),
251
+ closedVersionedBarrier: this.#closedVersionedBarrier.has(params.uri),
252
+ });
253
+ if (!result.accepted) return;
254
+ const entry = this.#diagnosticStore.get(params.uri);
255
+ if (entry?.synchronizationId !== undefined && entry.evidenceRevision !== undefined) {
256
+ this.#publications.record(
257
+ params.uri,
258
+ entry.synchronizationId,
259
+ entry.evidenceRevision,
260
+ entry.receivedAt,
261
+ );
262
+ if (result.promoted) {
263
+ this.#publications.promoted(
264
+ params.uri,
265
+ entry.synchronizationId,
266
+ entry.evidenceRevision,
267
+ entry.receivedAt,
268
+ );
269
+ }
238
270
  }
239
271
  this.#waiters.releaseFile(params.uri, "published");
240
272
  this.#waiters.notifySettle();
241
273
  }
242
274
  async refreshOpenDiagnostics(
243
275
  options: { maxWaitMs?: number; quietMs?: number } & CodeRequestControl = {},
276
+ ): Promise<DiagnosticEvidenceSummary> {
277
+ return this.#refreshOpenDiagnostics(options, false);
278
+ }
279
+
280
+ /** Force a full document resynchronization for a server refresh request. */
281
+ async refreshForServerRequest(): Promise<DiagnosticEvidenceSummary> {
282
+ return this.#refreshOpenDiagnostics({}, true);
283
+ }
284
+
285
+ async #refreshOpenDiagnostics(
286
+ options: { maxWaitMs?: number; quietMs?: number } & CodeRequestControl,
287
+ forceResynchronize: boolean,
244
288
  ): Promise<DiagnosticEvidenceSummary> {
245
289
  const requestedFiles = Array.from(
246
290
  new Set([...this.#openDocs.keys(), ...this.#diagnosticStore.keys(), ...this.#failedUris]),
@@ -262,14 +306,26 @@ export class ClientDiagnostics {
262
306
  if (document) document.evidenceRevision = -1;
263
307
  },
264
308
  clearFile: (uri) => {
265
- this.#failedUris.add(uri);
309
+ this.#failedUris.delete(uri);
266
310
  this.#unversionedPushSyncMoments.delete(uri);
267
311
  this.#closedVersionedBarrier.add(uri);
268
312
  clearTrackedDocumentState(this.#openDocs, this.#diagnosticStore, this.#waiters, uri);
269
313
  },
270
314
  markUnversionedSyncMoment: (uri) => this.#unversionedPushSyncMoments.set(uri, Date.now()),
271
315
  clearFailedFile: (uri) => this.#failedUris.delete(uri),
316
+ forceResynchronize,
272
317
  options,
318
+ publications: {
319
+ emitSummary: (summaryOptions) =>
320
+ this.#publications.emitSummary({
321
+ ...summaryOptions,
322
+ identity: {
323
+ server: this.host.server,
324
+ cwd: this.host.cwd,
325
+ ...summaryOptions.identity,
326
+ },
327
+ }),
328
+ },
273
329
  });
274
330
  }
275
331
 
@@ -296,8 +352,9 @@ export class ClientDiagnostics {
296
352
  const contentUnchanged =
297
353
  openDocument?.contentFingerprint === fingerprintDocumentContent(content);
298
354
  const synchronizationCurrent = openDocument?.evidenceRevision === this.#evidenceRevision;
299
- const cacheCurrent = hasCurrentDiagnosticEvidence(openDocument, cached, this.#evidenceRevision);
300
- if (!contentUnchanged || !synchronizationCurrent || (cached !== undefined && !cacheCurrent)) {
355
+ // Equivalent content joins the current synchronization. Cached evidence
356
+ // cannot force a no-op didChange that cancels an in-flight push or pull.
357
+ if (!contentUnchanged || !synchronizationCurrent) {
301
358
  synchronizeTrackedDocument({
302
359
  uri,
303
360
  content,
@@ -305,6 +362,7 @@ export class ClientDiagnostics {
305
362
  nextVersion: () => nextDocumentVersion(this.#versionHistory, uri),
306
363
  nextSynchronizationId: () => ++this.#nextSynchronizationId,
307
364
  evidenceRevision: this.#evidenceRevision,
365
+ incrementalSync: this.host.usesIncrementalDocumentSync(),
308
366
  waiters: this.#waiters,
309
367
  sendNotification: (method, params) => this.host.sendNotification(method, params),
310
368
  markUnversionedSyncMoment: () => this.#unversionedPushSyncMoments.set(uri, Date.now()),
@@ -319,7 +377,7 @@ export class ClientDiagnostics {
319
377
  }
320
378
  if (
321
379
  contentUnchanged &&
322
- hasCurrentDiagnosticEvidence(synchronization, cached, this.#evidenceRevision)
380
+ hasConfirmedDiagnosticEvidence(synchronization, cached, this.#evidenceRevision)
323
381
  ) {
324
382
  observer.cacheReused(1);
325
383
  return completedCodeQuery(cachedDiagnostics ?? []);
@@ -390,6 +448,13 @@ export class ClientDiagnostics {
390
448
  current: () => isCurrentSynchronization(this.#openDocs, attemptRequest),
391
449
  freshPush: () =>
392
450
  hasFreshPush(this.#diagnosticStore, attemptRequest, this.#evidenceRevision),
451
+ currentPushReceivedAt: () => {
452
+ const entry = this.#diagnosticStore.get(attemptRequest.uri);
453
+ return entry?.source === "push" &&
454
+ hasCurrentEvidence(this.#diagnosticStore, attemptRequest, this.#evidenceRevision)
455
+ ? entry.receivedAt
456
+ : undefined;
457
+ },
393
458
  diagnostics: () => this.getDiagnostics(filePath),
394
459
  pullDiagnostics: (timeoutMs, signal) =>
395
460
  pullClientDiagnosticEvidenceFromHost({
@@ -454,6 +519,23 @@ export class ClientDiagnostics {
454
519
  attemptMaxWaitMs = REOPEN_EVIDENCE_WAIT_MS;
455
520
  }
456
521
  observer.pushWaitCompleted(1, pushOutcome ?? "timed-out");
522
+ this.#publications.emitSummary({
523
+ operation: "sync-file",
524
+ identity: {
525
+ server: this.host.server,
526
+ cwd: this.host.cwd,
527
+ file: relativeDiagnosticFile(this.host.cwd, filePath),
528
+ },
529
+ synchronizations: [
530
+ {
531
+ uri,
532
+ synchronizationId: attemptRequest.synchronizationId,
533
+ evidenceRevision: attemptRequest.evidenceRevision ?? this.#evidenceRevision,
534
+ confirmed: result.kind === "completed",
535
+ },
536
+ ],
537
+ operationId: control?.operationId,
538
+ });
457
539
  return result;
458
540
  }
459
541
  }
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { Diagnostic } from "../config/types.ts";
3
- import type { DiagnosticCacheEntry } from "./client-diagnostic-evidence.ts";
3
+ import { type DiagnosticCacheEntry, isTentativePushEntry } from "./client-diagnostic-evidence.ts";
4
4
 
5
5
  /** One file's stored diagnostics as consumed by manager-level collection. */
6
6
  export interface DiagnosticEntry {
@@ -28,6 +28,8 @@ export interface OpenDocumentState {
28
28
  version: number;
29
29
  synchronizationId: number;
30
30
  evidenceRevision: number;
31
+ /** Last text sent to the server. Used to form incremental changes. */
32
+ content: string;
31
33
  contentFingerprint: string;
32
34
  }
33
35
 
@@ -50,3 +52,19 @@ export function hasCurrentDiagnosticEvidence(
50
52
  entry.evidenceRevision === evidenceRevision,
51
53
  );
52
54
  }
55
+
56
+ /**
57
+ * Test whether cached diagnostics confirm the current document state.
58
+ *
59
+ * A current tentative push matches the document state but cannot support a
60
+ * clean-result or cache-reuse claim until a republish confirms it (ADR 0021).
61
+ */
62
+ export function hasConfirmedDiagnosticEvidence(
63
+ document: OpenDocumentState | undefined,
64
+ entry: DiagnosticCacheEntry | undefined,
65
+ evidenceRevision: number,
66
+ ): boolean {
67
+ return (
68
+ hasCurrentDiagnosticEvidence(document, entry, evidenceRevision) && !isTentativePushEntry(entry)
69
+ );
70
+ }
@@ -14,10 +14,39 @@ interface SynchronizeDocumentOptions {
14
14
  version: number;
15
15
  synchronizationId: number;
16
16
  evidenceRevision: number;
17
+ incrementalSync: boolean;
17
18
  waiters: DiagnosticWaitRegistry;
18
19
  sendNotification: NotificationSender;
19
20
  }
20
21
 
22
+ function endPosition(content: string): { line: number; character: number } {
23
+ const lines = content.split(/\r\n|\r|\n/);
24
+ return { line: lines.length - 1, character: lines.at(-1)?.length ?? 0 };
25
+ }
26
+
27
+ /**
28
+ * For incremental sync, replace the old full range with the new text.
29
+ * This is a valid incremental edit and avoids a second text-diff engine.
30
+ */
31
+ function contentChanges(
32
+ previousContent: string,
33
+ content: string,
34
+ incrementalSync: boolean,
35
+ ): unknown[] {
36
+ if (incrementalSync) {
37
+ return [
38
+ {
39
+ range: {
40
+ start: { line: 0, character: 0 },
41
+ end: endPosition(previousContent),
42
+ },
43
+ text: content,
44
+ },
45
+ ];
46
+ }
47
+ return [{ text: content }];
48
+ }
49
+
21
50
  /** Advance and publish one explicit document synchronization. */
22
51
  export function synchronizeDocument(options: SynchronizeDocumentOptions): void {
23
52
  options.waiters.releaseFile(options.uri);
@@ -25,13 +54,19 @@ export function synchronizeDocument(options: SynchronizeDocumentOptions): void {
25
54
  options.document.version = options.version;
26
55
  options.document.synchronizationId = options.synchronizationId;
27
56
  options.document.evidenceRevision = options.evidenceRevision;
57
+ const changes = contentChanges(
58
+ options.document.content,
59
+ options.content,
60
+ options.incrementalSync,
61
+ );
62
+ options.document.content = options.content;
28
63
  options.document.contentFingerprint = fingerprintDocumentContent(options.content);
29
64
  options.sendNotification("textDocument/didChange", {
30
65
  textDocument: {
31
66
  uri: options.uri,
32
67
  version: options.version,
33
68
  } satisfies VersionedTextDocumentIdentifier,
34
- contentChanges: [{ text: options.content }],
69
+ contentChanges: changes,
35
70
  });
36
71
  }
37
72
 
@@ -74,6 +109,7 @@ export function reopenDocument(options: {
74
109
  options.document.version = version;
75
110
  options.document.synchronizationId = options.nextSynchronizationId();
76
111
  options.document.evidenceRevision = options.evidenceRevision;
112
+ options.document.content = options.content;
77
113
  options.document.contentFingerprint = fingerprintDocumentContent(options.content);
78
114
  options.sendNotification("textDocument/didOpen", {
79
115
  textDocument: {
@@ -93,6 +129,7 @@ export function synchronizeTrackedDocument(options: {
93
129
  nextVersion(): number;
94
130
  nextSynchronizationId(): number;
95
131
  evidenceRevision: number;
132
+ incrementalSync: boolean;
96
133
  waiters: DiagnosticWaitRegistry;
97
134
  sendNotification: NotificationSender;
98
135
  markUnversionedSyncMoment?(): void;
@@ -111,6 +148,7 @@ export function synchronizeTrackedDocument(options: {
111
148
  version: options.nextVersion(),
112
149
  synchronizationId: options.nextSynchronizationId(),
113
150
  evidenceRevision: options.evidenceRevision,
151
+ incrementalSync: options.incrementalSync,
114
152
  waiters: options.waiters,
115
153
  sendNotification: options.sendNotification,
116
154
  });
@@ -123,8 +161,11 @@ interface ResynchronizeDocumentsOptions {
123
161
  nextVersion(uri: string): number;
124
162
  nextSynchronizationId(): number;
125
163
  evidenceRevision: number;
164
+ incrementalSync: boolean;
126
165
  sendNotification: NotificationSender;
127
166
  uriToFile(uri: string): string;
167
+ /** Disk content already read by classification; avoids a second read. */
168
+ preloadedContent?: ReadonlyMap<string, string>;
128
169
  clearFile(uri: string): void;
129
170
  invalidateEvidence(uri: string): void;
130
171
  markUnversionedSyncMoment(uri: string): void;
@@ -135,6 +176,8 @@ interface ResynchronizeDocumentsOptions {
135
176
  export interface ResynchronizeDocumentsResult {
136
177
  /** Documents that were synchronized and can receive fresh evidence. */
137
178
  synchronizations: DiagnosticSynchronization[];
179
+ /** URIs that received a new didChange synchronization in this pass. */
180
+ resynchronizedUris: ReadonlySet<string>;
138
181
  /** Existing tracked documents removed because their files no longer exist. */
139
182
  removedFiles: string[];
140
183
  /** Existing tracked documents that could not be read or synchronized. */
@@ -146,6 +189,7 @@ export function resynchronizeOpenDocuments(
146
189
  options: ResynchronizeDocumentsOptions,
147
190
  ): ResynchronizeDocumentsResult {
148
191
  const synchronizations: DiagnosticSynchronization[] = [];
192
+ const resynchronizedUris = new Set<string>();
149
193
  const removedFiles: string[] = [];
150
194
  const failedFiles: string[] = [];
151
195
  for (const [uri, document] of options.openDocuments) {
@@ -154,15 +198,17 @@ export function resynchronizeOpenDocuments(
154
198
  options.markUnversionedSyncMoment(uri);
155
199
  synchronizeDocument({
156
200
  uri,
157
- content: readFileSync(filePath, "utf-8"),
201
+ content: options.preloadedContent?.get(uri) ?? readFileSync(filePath, "utf-8"),
158
202
  document,
159
203
  version: options.nextVersion(uri),
160
204
  synchronizationId: options.nextSynchronizationId(),
161
205
  evidenceRevision: options.evidenceRevision,
206
+ incrementalSync: options.incrementalSync,
162
207
  waiters: options.waiters,
163
208
  sendNotification: options.sendNotification,
164
209
  });
165
210
  options.clearFailedFile(uri);
211
+ resynchronizedUris.add(uri);
166
212
  synchronizations.push({
167
213
  uri,
168
214
  synchronizationId: document.synchronizationId,
@@ -181,5 +227,5 @@ export function resynchronizeOpenDocuments(
181
227
  // Keep the document open when a transient read fails.
182
228
  }
183
229
  }
184
- return { synchronizations, removedFiles, failedFiles };
230
+ return { synchronizations, resynchronizedUris, removedFiles, failedFiles };
185
231
  }
@@ -13,7 +13,7 @@ import {
13
13
  unavailableCodeQuery,
14
14
  } from "@mrclrchtr/supi-code-runtime/api";
15
15
  import { recordDebugEvent } from "@mrclrchtr/supi-core/debug";
16
- import type { ProgressToken } from "vscode-languageserver-protocol";
16
+ import { type ProgressToken, TextDocumentSyncKind } from "vscode-languageserver-protocol";
17
17
  import { CLIENT_CAPABILITIES } from "../config/capabilities.ts";
18
18
  import type {
19
19
  CodeAction,
@@ -198,6 +198,7 @@ export class LspClient {
198
198
  cwd: cwd,
199
199
  isOperational: () => this.rpc !== null && this._status === "running",
200
200
  supportsPullDiagnostics: () => this.hasDiagnosticProvider,
201
+ usesIncrementalDocumentSync: () => this.usesIncrementalDocumentSync,
201
202
  sendNotification: (method, params) => {
202
203
  if (this.rpc) void this.rpc.sendNotification(method, params);
203
204
  },
@@ -237,6 +238,13 @@ export class LspClient {
237
238
  return this.capabilities;
238
239
  }
239
240
 
241
+ /** Whether the server requires range-based document changes. */
242
+ get usesIncrementalDocumentSync(): boolean {
243
+ const sync = this.capabilities?.textDocumentSync;
244
+ const change = typeof sync === "number" ? sync : sync?.change;
245
+ return change === TextDocumentSyncKind.Incremental;
246
+ }
247
+
240
248
  /** Whether the server is currently not indexing and ready to serve queries. */
241
249
  get ready(): boolean {
242
250
  return this._isReady;
@@ -262,7 +270,7 @@ export class LspClient {
262
270
  this.process = spawn(cmd, args, {
263
271
  cwd: this.root,
264
272
  stdio: ["pipe", "pipe", "pipe"],
265
- env: { ...process.env },
273
+ env: { ...process.env, ...this.config.env },
266
274
  // Run the server in its own process group so we can atomically
267
275
  // kill the entire tree (server + subprocesses like tsserver)
268
276
  // with `process.kill(-pid, signal)` on Unix or `taskkill /T` on Windows.
@@ -313,6 +321,12 @@ export class LspClient {
313
321
  const result = (await this.rpc.sendRequest("initialize", {
314
322
  processId: process.pid,
315
323
  rootUri: fileToUri(this.root),
324
+ workspaceFolders: [
325
+ {
326
+ uri: fileToUri(this.root),
327
+ name: path.basename(this.root) || this.root,
328
+ },
329
+ ],
316
330
  capabilities: CLIENT_CAPABILITIES,
317
331
  initializationOptions: this.config.initializationOptions,
318
332
  })) as InitializeResult;
@@ -322,7 +336,7 @@ export class LspClient {
322
336
  throw new Error(`Server selected unsupported position encoding "${positionEncoding}".`);
323
337
  }
324
338
  this.capabilities = result.capabilities;
325
- void this.rpc.sendNotification("initialized", {});
339
+ await this.rpc.sendNotification("initialized", {});
326
340
  this._status = "running";
327
341
  this.publishLifecycle("startup");
328
342
 
@@ -708,11 +722,10 @@ export class LspClient {
708
722
  case "client/unregisterCapability":
709
723
  return this.handleUnregisterCapability(params);
710
724
  case "workspace/diagnostic/refresh":
711
- // The client does not advertise refresh support, but pyright sends
712
- // this request anyway when a document opens in pull mode. Answering
713
- // with an error crashes pyright (exit 1); answering null keeps the
714
- // server alive and pull-on-demand still serves fresh diagnostics.
715
- return null;
725
+ // Refresh the tracked diagnostic set without blocking the server's
726
+ // request. The LSP response is always null; the pass records its
727
+ // terminal result asynchronously for local protocol diagnosis.
728
+ return this.handleServerDiagnosticRefreshRequest();
716
729
  case "window/workDoneProgress/create": {
717
730
  // A create reserves a token; it does not prove active work. The
718
731
  // token stays pending and readiness is untouched until a begin
@@ -727,6 +740,51 @@ export class LspClient {
727
740
  }
728
741
  }
729
742
 
743
+ /** Run a server-requested refresh through the forced diagnostic path. */
744
+ private refreshForServerRequest(): Promise<DiagnosticEvidenceSummary> {
745
+ return this.diagnostics.refreshForServerRequest();
746
+ }
747
+
748
+ private handleServerDiagnosticRefreshRequest(): null {
749
+ // Defer the pass before invoking it. Its setup rereads and resynchronizes
750
+ // tracked documents, so even an async method can otherwise delay the null
751
+ // response on the JSON-RPC request stack.
752
+ void Promise.resolve()
753
+ .then(() => this.refreshForServerRequest())
754
+ .then(
755
+ (evidence) => this.recordDiagnosticRefreshRequest("completed", evidence),
756
+ () => this.recordDiagnosticRefreshRequest("failed"),
757
+ )
758
+ // Consume failures from the telemetry callback as well as the pass.
759
+ .catch(() => {});
760
+ return null;
761
+ }
762
+
763
+ private recordDiagnosticRefreshRequest(
764
+ outcome: "completed" | "failed",
765
+ evidence?: DiagnosticEvidenceSummary,
766
+ ): void {
767
+ recordDebugEvent({
768
+ source: "lsp",
769
+ level: "debug",
770
+ category: "diagnostics.refresh-request",
771
+ message: `LSP diagnostic refresh request ${outcome}`,
772
+ cwd: boundCwd(this.cwd),
773
+ data:
774
+ outcome === "completed" && evidence
775
+ ? {
776
+ outcome,
777
+ server: truncateIdentity(this.name),
778
+ requested: evidence.requested,
779
+ confirmed: evidence.confirmed,
780
+ unconfirmed: evidence.unconfirmed,
781
+ failed: evidence.failed,
782
+ removed: evidence.removed,
783
+ }
784
+ : { outcome, server: truncateIdentity(this.name) },
785
+ });
786
+ }
787
+
730
788
  private buildWorkspaceConfigurationResult(params: unknown): unknown[] {
731
789
  if (!params || typeof params !== "object") return [];
732
790
  const items = (params as { items?: unknown }).items;
@@ -836,7 +894,7 @@ export class LspClient {
836
894
  category: "readiness.progress-begin",
837
895
  message: `Readiness progress begin for ${this.name}`,
838
896
  cwd: boundCwd(this.cwd),
839
- data: { server: this.name, root: truncateIdentity(this.root) },
897
+ data: { server: truncateIdentity(this.name), root: truncateIdentity(this.root) },
840
898
  });
841
899
  // begin is the only transition that proves active work: it cancels
842
900
  // the no-progress grace timer, blocks readiness, and arms the
@@ -866,7 +924,7 @@ export class LspClient {
866
924
  category: "readiness.progress-end",
867
925
  message: `Readiness progress end for ${this.name}`,
868
926
  cwd: boundCwd(this.cwd),
869
- data: { server: this.name, root: truncateIdentity(this.root) },
927
+ data: { server: truncateIdentity(this.name), root: truncateIdentity(this.root) },
870
928
  });
871
929
  const state = this.trackedTokens.get(token);
872
930
  if (state === undefined) return; // Unknown token: ignore fail-closed.
@@ -919,7 +977,7 @@ export class LspClient {
919
977
  category: "readiness.resolved",
920
978
  message: `LSP client ${this.name} is ready`,
921
979
  cwd: boundCwd(this.cwd),
922
- data: { server: this.name, root: truncateIdentity(this.root) },
980
+ data: { server: truncateIdentity(this.name), root: truncateIdentity(this.root) },
923
981
  });
924
982
  }
925
983
 
@@ -958,7 +1016,7 @@ export class LspClient {
958
1016
  message: `LSP client ${this.name} readiness rejected: ${reason.message}`,
959
1017
  cwd: boundCwd(this.cwd),
960
1018
  data: {
961
- server: this.name,
1019
+ server: truncateIdentity(this.name),
962
1020
  root: truncateIdentity(this.root),
963
1021
  status: this._status,
964
1022
  },
@@ -983,7 +1041,7 @@ export class LspClient {
983
1041
  category: "readiness.token-timeout",
984
1042
  message: `Readiness per-token timeout fired for ${this.name} after ${timeoutMs}ms`,
985
1043
  cwd: boundCwd(this.cwd),
986
- data: { server: this.name, root: truncateIdentity(this.root), timeoutMs },
1044
+ data: { server: truncateIdentity(this.name), root: truncateIdentity(this.root), timeoutMs },
987
1045
  });
988
1046
  this.checkAllTokensEnded();
989
1047
  }, timeoutMs);
@@ -1010,7 +1068,7 @@ export class LspClient {
1010
1068
  category: "readiness.no-progress-cancelled",
1011
1069
  message: `No-progress grace timer cancelled for ${this.name}`,
1012
1070
  cwd: boundCwd(this.cwd),
1013
- data: { server: this.name, root: truncateIdentity(this.root) },
1071
+ data: { server: truncateIdentity(this.name), root: truncateIdentity(this.root) },
1014
1072
  });
1015
1073
  }
1016
1074
  }
@@ -1033,7 +1091,7 @@ export class LspClient {
1033
1091
  category: "readiness.no-progress-resolved",
1034
1092
  message: `No-progress grace timer resolved for ${this.name}`,
1035
1093
  cwd: boundCwd(this.cwd),
1036
- data: { server: this.name, root: truncateIdentity(this.root) },
1094
+ data: { server: truncateIdentity(this.name), root: truncateIdentity(this.root) },
1037
1095
  });
1038
1096
  this.resolveReady();
1039
1097
  }