@mrclrchtr/supi-code-intelligence 5.0.0 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +4 -2
  2. package/node_modules/@mrclrchtr/supi-code-runtime/README.md +2 -0
  3. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  4. package/node_modules/@mrclrchtr/supi-core/README.md +2 -0
  5. package/node_modules/@mrclrchtr/supi-core/package.json +2 -2
  6. package/node_modules/@mrclrchtr/supi-core/src/api.ts +1 -1
  7. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +0 -3
  8. package/node_modules/@mrclrchtr/supi-core/src/debug.ts +9 -0
  9. package/node_modules/@mrclrchtr/supi-core/src/index.ts +1 -1
  10. package/node_modules/@mrclrchtr/supi-lsp/README.md +36 -3
  11. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/README.md +2 -0
  12. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  13. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/README.md +2 -0
  14. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/package.json +2 -2
  15. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/api.ts +1 -1
  16. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +0 -3
  17. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/debug.ts +9 -0
  18. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/index.ts +1 -1
  19. package/node_modules/@mrclrchtr/supi-lsp/package.json +3 -3
  20. package/node_modules/@mrclrchtr/supi-lsp/src/api.ts +12 -1
  21. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-cache.ts +67 -28
  22. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-collection.ts +63 -6
  23. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-evidence.ts +46 -5
  24. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-host.ts +1 -0
  25. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-publication.ts +214 -0
  26. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-refresh.ts +278 -79
  27. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-timing.ts +29 -8
  28. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostics.ts +101 -19
  29. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-document-state.ts +19 -1
  30. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-document-sync.ts +49 -3
  31. package/node_modules/@mrclrchtr/supi-lsp/src/client/client.ts +73 -15
  32. package/node_modules/@mrclrchtr/supi-lsp/src/client/transport.ts +1 -1
  33. package/node_modules/@mrclrchtr/supi-lsp/src/config/capabilities.ts +2 -2
  34. package/node_modules/@mrclrchtr/supi-lsp/src/config/config.ts +5 -5
  35. package/node_modules/@mrclrchtr/supi-lsp/src/config/server-config.ts +3 -0
  36. package/node_modules/@mrclrchtr/supi-lsp/src/config/tsconfig-extends.ts +68 -0
  37. package/node_modules/@mrclrchtr/supi-lsp/src/config/tsconfig-path.ts +8 -0
  38. package/node_modules/@mrclrchtr/supi-lsp/src/config/tsconfig-scope.ts +176 -24
  39. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/evidence.ts +4 -0
  40. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/workspace-sentinels.ts +46 -10
  41. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-diagnostics.ts +4 -3
  42. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-workspace-recovery.ts +65 -17
  43. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager.ts +143 -21
  44. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-diagnostic-surface.ts +2 -0
  45. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-diagnostics.ts +43 -1
  46. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-registry.ts +31 -0
  47. package/node_modules/@mrclrchtr/supi-tree-sitter/README.md +2 -0
  48. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/README.md +2 -0
  49. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  50. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/README.md +2 -0
  51. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/package.json +2 -2
  52. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/api.ts +1 -1
  53. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +0 -3
  54. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/debug.ts +9 -0
  55. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/index.ts +1 -1
  56. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/debug/web-tree-sitter.wasm +0 -0
  57. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/debug/web-tree-sitter.wasm.map +4 -4
  58. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/package.json +1 -1
  59. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.wasm +0 -0
  60. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.wasm.map +4 -4
  61. package/node_modules/@mrclrchtr/supi-tree-sitter/package.json +3 -3
  62. package/package.json +5 -5
  63. package/src/analysis/health/diagnostics.ts +58 -126
  64. package/src/analysis/health/file-scope.ts +184 -0
  65. package/src/analysis/health/recovery.ts +8 -1
  66. package/src/analysis/readiness.ts +4 -0
  67. package/src/analysis/search/ast-scan.ts +4 -1
  68. package/src/analysis/search/pattern-analysis.ts +10 -3
  69. package/src/analysis/search/pattern.ts +6 -1
  70. package/src/config.ts +35 -1
  71. package/src/extension.ts +57 -14
  72. package/src/overview/overview-data.ts +9 -2
  73. package/src/overview/overview.ts +49 -30
  74. package/src/overview/types.ts +2 -0
  75. package/src/session/find-types.ts +2 -2
  76. package/src/session/find-workflow.ts +3 -2
  77. package/src/session/health-refresh.ts +76 -49
  78. package/src/session/health-types.ts +7 -1
  79. package/src/session/health-workflow.ts +35 -18
  80. package/src/session/input/workflows.ts +2 -2
  81. package/src/session/inspect-workflow.ts +2 -1
  82. package/src/session/orientation/collect.ts +2 -2
  83. package/src/session/orientation-types.ts +2 -0
  84. package/src/session/orientation-workflow.ts +8 -2
  85. package/src/session/refactor-workflow.ts +2 -1
  86. package/src/session/session.ts +25 -1
  87. package/src/substrate/lsp/maintenance.ts +105 -23
  88. package/src/substrate/lsp/recovery.ts +11 -5
  89. package/src/tool/code_find/execute.ts +23 -0
  90. package/src/tool/code_find/guidance.ts +8 -0
  91. package/src/tool/{find → code_find}/render.ts +1 -1
  92. package/src/tool/{result/find.ts → code_find/result.ts} +36 -11
  93. package/src/tool/code_find/spec.ts +37 -0
  94. package/src/tool/code_graph/execute.ts +24 -0
  95. package/src/tool/code_graph/guidance.ts +6 -0
  96. package/src/tool/{graph → code_graph}/markdown.ts +1 -1
  97. package/src/tool/{result/graph.ts → code_graph/result.ts} +69 -4
  98. package/src/tool/code_graph/spec.ts +39 -0
  99. package/src/tool/{health → code_health}/execute.ts +2 -18
  100. package/src/tool/code_health/guidance.ts +8 -0
  101. package/src/tool/{health → code_health}/markdown.ts +68 -18
  102. package/src/tool/{result/health.ts → code_health/result.ts} +29 -4
  103. package/src/tool/code_health/spec.ts +41 -0
  104. package/src/tool/code_inspect/execute.ts +20 -0
  105. package/src/tool/code_inspect/guidance.ts +6 -0
  106. package/src/tool/{inspect → code_inspect}/markdown.ts +1 -1
  107. package/src/tool/{result/inspect.ts → code_inspect/result.ts} +32 -3
  108. package/src/tool/code_inspect/spec.ts +25 -0
  109. package/src/tool/code_orientation/execute.ts +25 -0
  110. package/src/tool/code_orientation/guidance.ts +9 -0
  111. package/src/tool/{orientation → code_orientation}/markdown.ts +1 -1
  112. package/src/tool/{result/orientation.ts → code_orientation/result.ts} +67 -3
  113. package/src/tool/code_orientation/spec.ts +25 -0
  114. package/src/tool/code_refactor_apply/execute.ts +17 -0
  115. package/src/tool/code_refactor_apply/guidance.ts +5 -0
  116. package/src/tool/{refactor-apply/execute.ts → code_refactor_apply/result.ts} +5 -11
  117. package/src/tool/code_refactor_apply/spec.ts +26 -0
  118. package/src/tool/code_refactor_plan/execute.ts +27 -0
  119. package/src/tool/code_refactor_plan/guidance.ts +8 -0
  120. package/src/tool/{refactor-plan/execute.ts → code_refactor_plan/result.ts} +9 -22
  121. package/src/tool/code_refactor_plan/spec.ts +25 -0
  122. package/src/tool/code_resolve/execute.ts +19 -0
  123. package/src/tool/code_resolve/guidance.ts +8 -0
  124. package/src/tool/{resolve → code_resolve}/markdown.ts +1 -1
  125. package/src/tool/{result/resolve.ts → code_resolve/result.ts} +37 -24
  126. package/src/tool/code_resolve/spec.ts +25 -0
  127. package/src/tool/guidance.ts +71 -52
  128. package/src/tool/infra/truncate.ts +40 -0
  129. package/src/tool/{refactor-plan/markdown.ts → refactor-markdown.ts} +4 -7
  130. package/src/tool/register.ts +9 -97
  131. package/src/tool/result/refactor.ts +1 -4
  132. package/src/tool/schemas.ts +26 -157
  133. package/src/tool/specs.ts +40 -126
  134. package/src/tool/find/execute.ts +0 -43
  135. package/src/tool/graph/execute.ts +0 -82
  136. package/src/tool/inspect/execute.ts +0 -40
  137. package/src/tool/orientation/execute.ts +0 -80
  138. package/src/tool/resolve/execute.ts +0 -53
  139. /package/src/tool/{find → code_find}/ast-kinds.ts +0 -0
  140. /package/src/tool/{find → code_find}/markdown.ts +0 -0
  141. /package/src/tool/{find → code_find}/modes.ts +0 -0
  142. /package/src/tool/{find → code_find}/tui.ts +0 -0
  143. /package/src/tool/{graph → code_graph}/tui.ts +0 -0
  144. /package/src/tool/{health → code_health}/refresh-status.ts +0 -0
  145. /package/src/tool/{health → code_health}/semantic-state.ts +0 -0
  146. /package/src/tool/{health → code_health}/tui.ts +0 -0
  147. /package/src/tool/{inspect → code_inspect}/tui.ts +0 -0
  148. /package/src/tool/{orientation → code_orientation}/tui.ts +0 -0
  149. /package/src/tool/{refactor-apply → code_refactor_apply}/tui.ts +0 -0
  150. /package/src/tool/{refactor-plan → code_refactor_plan}/tui.ts +0 -0
  151. /package/src/tool/{resolve → code_resolve}/tui.ts +0 -0
@@ -14,14 +14,19 @@ import { detectLanguageId, fileToUri, uriToFile } from "../utils.ts";
14
14
  import {
15
15
  type DiagnosticCacheEntry,
16
16
  type DiagnosticSynchronization,
17
+ hasCurrentEvidence,
17
18
  hasFreshEvidence,
18
19
  hasFreshPush,
19
20
  isCurrentSynchronization,
20
- latestFreshEvidenceReceivedAt,
21
+ latestCurrentEvidenceReceivedAt,
21
22
  nextDocumentVersion,
22
23
  raceDiagnosticPull,
23
24
  } from "./client-diagnostic-evidence.ts";
24
25
  import type { ClientDiagnosticsHost } from "./client-diagnostic-host.ts";
26
+ import type {
27
+ DiagnosticPublicationIdentity,
28
+ DiagnosticPublicationSynchronization,
29
+ } from "./client-diagnostic-publication.ts";
25
30
  import { pullDiagnosticEvidence } from "./client-diagnostic-pull.ts";
26
31
  import type { DiagnosticPullRequest } from "./client-diagnostic-request.ts";
27
32
  import {
@@ -30,7 +35,11 @@ import {
30
35
  isDiagnosticTimeout,
31
36
  } from "./client-diagnostic-timing.ts";
32
37
  import type { DiagnosticStateWait, DiagnosticWaitRegistry } from "./client-diagnostic-waiters.ts";
33
- import type { OpenDocumentState } from "./client-document-state.ts";
38
+ import {
39
+ fingerprintDocumentContent,
40
+ hasConfirmedDiagnosticEvidence,
41
+ type OpenDocumentState,
42
+ } from "./client-document-state.ts";
34
43
  import {
35
44
  type ResynchronizeDocumentsResult,
36
45
  reopenDocument,
@@ -60,12 +69,15 @@ export function buildDiagnosticRefreshEvidence(options: {
60
69
  currentEvidenceRevision: number;
61
70
  openDocuments: ReadonlyMap<string, unknown>;
62
71
  diagnosticStore: ReadonlyMap<string, DiagnosticCacheEntry>;
72
+ /** Required quiet time after the latest push for this refresh result. */
73
+ pushQuietMs?: number;
63
74
  }): DiagnosticEvidenceSummary {
64
75
  const synchronizationByFile = new Map(
65
76
  options.synchronizations.map((item) => [uriToFile(item.uri), item]),
66
77
  );
67
78
  const removedFiles = new Set(options.resynchronization.removedFiles);
68
79
  const failedFiles = new Set(options.resynchronization.failedFiles);
80
+ const observedAt = Date.now();
69
81
  const documents = options.requestedFiles.map((file) => {
70
82
  const uri = fileToUri(file);
71
83
  const synchronization = synchronizationByFile.get(file);
@@ -84,7 +96,13 @@ export function buildDiagnosticRefreshEvidence(options: {
84
96
  }
85
97
  if (
86
98
  synchronization &&
87
- hasFreshEvidence(options.diagnosticStore, synchronization, options.currentEvidenceRevision)
99
+ hasSettledRefreshEvidence({
100
+ store: options.diagnosticStore,
101
+ synchronization,
102
+ currentEvidenceRevision: options.currentEvidenceRevision,
103
+ pushQuietMs: options.pushQuietMs,
104
+ observedAt,
105
+ })
88
106
  ) {
89
107
  return { file, status: "confirmed" as const };
90
108
  }
@@ -96,6 +114,25 @@ export function buildDiagnosticRefreshEvidence(options: {
96
114
  return summarizeDiagnosticEvidence(documents);
97
115
  }
98
116
 
117
+ /** Test whether fresh evidence can confirm this refresh result. */
118
+ function hasSettledRefreshEvidence(options: {
119
+ store: ReadonlyMap<string, DiagnosticCacheEntry>;
120
+ synchronization: DiagnosticSynchronization;
121
+ currentEvidenceRevision: number;
122
+ pushQuietMs: number | undefined;
123
+ observedAt: number;
124
+ }): boolean {
125
+ if (!hasFreshEvidence(options.store, options.synchronization, options.currentEvidenceRevision)) {
126
+ return false;
127
+ }
128
+ const entry = options.store.get(options.synchronization.uri);
129
+ return (
130
+ options.pushQuietMs === undefined ||
131
+ entry?.source !== "push" ||
132
+ options.observedAt - entry.receivedAt >= options.pushQuietMs
133
+ );
134
+ }
135
+
99
136
  /** Pull one document and reject evidence from a stale client generation. */
100
137
  export function pullClientDiagnosticEvidence(
101
138
  options: Omit<DiagnosticPullRequest, "previousResultId"> & {
@@ -214,6 +251,70 @@ export async function pullDiagnosticsForOpenDocuments(options: {
214
251
  }
215
252
  }
216
253
 
254
+ /**
255
+ * Classify open documents for a refresh by disk content.
256
+ *
257
+ * A document whose disk content still matches its open fingerprint stays in
258
+ * the server's current state:
259
+ * - with current evidence it is reusable without document synchronization;
260
+ * - without current evidence it is retained: it keeps its synchronization and
261
+ * collection waits for the server's existing pipeline instead of forcing a
262
+ * no-op didChange. Large push-only servers (typescript-language-server)
263
+ * skip empty-to-empty publishes, so a no-op didChange of a clean file can
264
+ * never confirm — it only invalidates in-progress evidence and restarts
265
+ * full-program checks (#344).
266
+ *
267
+ * Documents outside the current evidence revision were invalidated by a
268
+ * workspace change and must resynchronize: their stale revision cannot
269
+ * produce fresh evidence without an explicit sync (ADR 0020).
270
+ */
271
+ function classifyReusableDocuments(options: {
272
+ openDocuments: ReadonlyMap<string, OpenDocumentState>;
273
+ diagnosticStore: ReadonlyMap<string, DiagnosticCacheEntry>;
274
+ evidenceRevision: number;
275
+ failedFiles: ReadonlySet<string>;
276
+ }): {
277
+ reusableUris: Set<string>;
278
+ retainedUris: Set<string>;
279
+ preloadedContent: Map<string, string>;
280
+ } {
281
+ const reusableUris = new Set<string>();
282
+ const retainedUris = new Set<string>();
283
+ const preloadedContent = new Map<string, string>();
284
+ for (const [uri, document] of options.openDocuments) {
285
+ const filePath = uriToFile(uri);
286
+ if (options.failedFiles.has(filePath)) continue;
287
+ let content: string;
288
+ try {
289
+ content = readFileSync(filePath, "utf-8");
290
+ } catch {
291
+ // The resynchronization path classifies removed and unreadable files.
292
+ continue;
293
+ }
294
+ if (document.evidenceRevision !== options.evidenceRevision) {
295
+ // Invalidated generation: the resync didChange re-establishes proof.
296
+ preloadedContent.set(uri, content);
297
+ continue;
298
+ }
299
+ if (fingerprintDocumentContent(content) !== document.contentFingerprint) {
300
+ preloadedContent.set(uri, content);
301
+ continue;
302
+ }
303
+ if (
304
+ hasConfirmedDiagnosticEvidence(
305
+ document,
306
+ options.diagnosticStore.get(uri),
307
+ options.evidenceRevision,
308
+ )
309
+ ) {
310
+ reusableUris.add(uri);
311
+ } else {
312
+ retainedUris.add(uri);
313
+ }
314
+ }
315
+ return { reusableUris, retainedUris, preloadedContent };
316
+ }
317
+
217
318
  interface ClientDiagnosticRefreshOptions {
218
319
  readonly host: ClientDiagnosticsHost;
219
320
  readonly openDocuments: Map<string, OpenDocumentState>;
@@ -229,7 +330,98 @@ interface ClientDiagnosticRefreshOptions {
229
330
  readonly invalidateEvidence: (uri: string) => void;
230
331
  readonly markUnversionedSyncMoment: (uri: string) => void;
231
332
  readonly clearFailedFile: (uri: string) => void;
333
+ /** Server-requested refreshes bypass normal push-only evidence reuse. */
334
+ readonly forceResynchronize?: boolean;
232
335
  readonly options: { maxWaitMs?: number; quietMs?: number } & CodeRequestControl;
336
+ /** Push-publication telemetry surface for this client. */
337
+ readonly publications: {
338
+ emitSummary(options: {
339
+ readonly operation: "refresh-open";
340
+ readonly identity: DiagnosticPublicationIdentity;
341
+ readonly synchronizations: readonly DiagnosticPublicationSynchronization[];
342
+ readonly operationId?: string;
343
+ }): void;
344
+ };
345
+ }
346
+
347
+ interface PreparedRefreshDocuments {
348
+ readonly resynchronization: ResynchronizeDocumentsResult;
349
+ readonly synchronizations: DiagnosticSynchronization[];
350
+ readonly fullyReusable: boolean;
351
+ }
352
+
353
+ /** Classify reusable documents, then resynchronize only the remaining set. */
354
+ function prepareRefreshDocuments(
355
+ options: ClientDiagnosticRefreshOptions,
356
+ supportsPull: boolean,
357
+ evidenceRevision: number,
358
+ ): PreparedRefreshDocuments {
359
+ const reuseEnabled = !options.forceResynchronize;
360
+ const classification = reuseEnabled
361
+ ? classifyReusableDocuments({
362
+ openDocuments: options.openDocuments,
363
+ diagnosticStore: options.diagnosticStore,
364
+ evidenceRevision,
365
+ failedFiles: options.failedFiles(),
366
+ })
367
+ : undefined;
368
+ const reusableUris = classification?.reusableUris ?? new Set<string>();
369
+ const retainedUris = classification?.retainedUris ?? new Set<string>();
370
+ const documentsToResynchronize = new Map(
371
+ Array.from(options.openDocuments).filter(
372
+ ([uri]) => !reusableUris.has(uri) && !retainedUris.has(uri),
373
+ ),
374
+ );
375
+ const resynchronization = resynchronizeOpenDocuments({
376
+ openDocuments: documentsToResynchronize,
377
+ waiters: options.waiters,
378
+ nextVersion: (uri) => nextDocumentVersion(options.versionHistory, uri),
379
+ nextSynchronizationId: options.nextSynchronizationId,
380
+ evidenceRevision,
381
+ incrementalSync: options.host.usesIncrementalDocumentSync(),
382
+ sendNotification: (method, params) => options.host.sendNotification(method, params),
383
+ uriToFile,
384
+ preloadedContent: classification?.preloadedContent,
385
+ clearFile: options.clearFile,
386
+ invalidateEvidence: options.invalidateEvidence,
387
+ markUnversionedSyncMoment: options.markUnversionedSyncMoment,
388
+ clearFailedFile: options.clearFailedFile,
389
+ });
390
+ const reusableSynchronizations: DiagnosticSynchronization[] = [];
391
+ for (const uri of reusableUris) {
392
+ const document = options.openDocuments.get(uri);
393
+ if (!document) continue;
394
+ reusableSynchronizations.push({
395
+ uri,
396
+ synchronizationId: document.synchronizationId,
397
+ evidenceRevision: document.evidenceRevision,
398
+ });
399
+ }
400
+ // Retained documents keep their current synchronization: settle waits for
401
+ // the server's existing pipeline without any protocol work.
402
+ const retainedSynchronizations: DiagnosticSynchronization[] = [];
403
+ for (const uri of retainedUris) {
404
+ const document = options.openDocuments.get(uri);
405
+ if (!document) continue;
406
+ retainedSynchronizations.push({
407
+ uri,
408
+ synchronizationId: document.synchronizationId,
409
+ evidenceRevision: document.evidenceRevision,
410
+ });
411
+ }
412
+ const synchronizations = [
413
+ ...reusableSynchronizations,
414
+ ...retainedSynchronizations,
415
+ ...resynchronization.synchronizations,
416
+ ];
417
+ // Pull-capable routes still request current diagnostics even when every
418
+ // document synchronization is reusable.
419
+ const fullyReusable =
420
+ reuseEnabled &&
421
+ !supportsPull &&
422
+ options.openDocuments.size > 0 &&
423
+ reusableUris.size === options.openDocuments.size;
424
+ return { resynchronization, synchronizations, fullyReusable };
233
425
  }
234
426
 
235
427
  /** Refresh one client and return exact document evidence for that attempt. */
@@ -259,25 +451,11 @@ export async function refreshClientOpenDiagnostics(
259
451
  const maxWaitMs = options.options.maxWaitMs ?? 3_000;
260
452
  const quietMs = options.options.quietMs ?? 200;
261
453
  const syncStart = Date.now();
262
- const resynchronization = resynchronizeOpenDocuments({
263
- openDocuments: options.openDocuments,
264
- waiters: options.waiters,
265
- nextVersion: (uri) => nextDocumentVersion(options.versionHistory, uri),
266
- nextSynchronizationId: options.nextSynchronizationId,
267
- evidenceRevision: options.evidenceRevision(),
268
- sendNotification: (method, params) => options.host.sendNotification(method, params),
269
- uriToFile,
270
- clearFile: options.clearFile,
271
- invalidateEvidence: options.invalidateEvidence,
272
- markUnversionedSyncMoment: options.markUnversionedSyncMoment,
273
- clearFailedFile: options.clearFailedFile,
274
- });
275
- let synchronizations = resynchronization.synchronizations;
276
- const settleEpoch = options.waiters.settleEpoch;
277
- observer.synchronized();
278
- const documentCount = synchronizations.length;
454
+ const prepared = prepareRefreshDocuments(options, supportsPull, options.evidenceRevision());
455
+ const resynchronization = prepared.resynchronization;
456
+ let synchronizations = prepared.synchronizations;
279
457
  let failedPullUris: ReadonlySet<string> = new Set();
280
- const buildEvidence = () =>
458
+ const buildEvidence = (pushQuietMs?: number) =>
281
459
  buildDiagnosticRefreshEvidence({
282
460
  requestedFiles: options.requestedFiles,
283
461
  resynchronization,
@@ -288,8 +466,19 @@ export async function refreshClientOpenDiagnostics(
288
466
  currentEvidenceRevision: options.evidenceRevision(),
289
467
  openDocuments: options.openDocuments,
290
468
  diagnosticStore: options.diagnosticStore,
469
+ pushQuietMs,
291
470
  });
292
- if (documentCount === 0) {
471
+
472
+ // A fully reusable push-only refresh has no protocol work to collect.
473
+ // Preserve the existing cache timing event format and return current evidence.
474
+ if (prepared.fullyReusable) {
475
+ observer.cacheReused(synchronizations.length);
476
+ return buildEvidence();
477
+ }
478
+
479
+ const settleEpoch = options.waiters.settleEpoch;
480
+ observer.synchronized();
481
+ if (synchronizations.length === 0) {
293
482
  observer.skipped(0);
294
483
  return buildEvidence();
295
484
  }
@@ -306,60 +495,64 @@ export async function refreshClientOpenDiagnostics(
306
495
  if (pull.completed) return buildEvidence();
307
496
  }
308
497
 
309
- let finalSettle = await options.waiters.waitForSettle(
310
- {
311
- syncStart,
312
- maxWaitMs,
313
- quietMs,
314
- settleEpoch,
315
- isComplete: () =>
316
- synchronizations.every((item) =>
317
- hasFreshEvidence(options.diagnosticStore, item, options.evidenceRevision()),
318
- ),
319
- latestReceived: () =>
320
- latestFreshEvidenceReceivedAt(
321
- options.diagnosticStore,
322
- synchronizations,
323
- options.evidenceRevision(),
324
- ),
325
- },
326
- options.options,
327
- );
498
+ const waitForDiagnosticSettle = (settleStart: number, settleGeneration: number) =>
499
+ options.waiters.waitForSettle(
500
+ {
501
+ syncStart: settleStart,
502
+ maxWaitMs,
503
+ quietMs,
504
+ settleEpoch: settleGeneration,
505
+ isComplete: () =>
506
+ synchronizations.every((item) =>
507
+ hasFreshEvidence(options.diagnosticStore, item, options.evidenceRevision()),
508
+ ),
509
+ latestReceived: () =>
510
+ latestCurrentEvidenceReceivedAt(
511
+ options.diagnosticStore,
512
+ synchronizations,
513
+ options.evidenceRevision(),
514
+ ),
515
+ },
516
+ options.options,
517
+ );
518
+
519
+ let finalSettle = await waitForDiagnosticSettle(syncStart, settleEpoch);
328
520
  if (!supportsPull && finalSettle.outcome === "timed-out") {
329
521
  const reopen = await reopenUnconfirmedDocuments({
330
522
  options,
331
523
  synchronizations,
332
- maxWaitMs,
333
- quietMs,
524
+ reopenCandidates: resynchronization.resynchronizedUris,
334
525
  observer,
335
526
  });
336
527
  if (reopen.performed) {
337
528
  synchronizations = reopen.synchronizations;
338
- finalSettle = await options.waiters.waitForSettle(
339
- {
340
- syncStart: reopen.startedAt,
341
- maxWaitMs: reopen.budgetMs,
342
- quietMs,
343
- settleEpoch: options.waiters.settleEpoch,
344
- isComplete: () =>
345
- synchronizations.every((item) =>
346
- hasFreshEvidence(options.diagnosticStore, item, options.evidenceRevision()),
347
- ),
348
- latestReceived: () =>
349
- latestFreshEvidenceReceivedAt(
350
- options.diagnosticStore,
351
- synchronizations,
352
- options.evidenceRevision(),
353
- ),
354
- },
355
- options.options,
356
- );
529
+ // A large push-only project may still be processing the reopen batch.
530
+ // The replacement pass uses the same collection budget as the initial pass.
531
+ finalSettle = await waitForDiagnosticSettle(reopen.startedAt, options.waiters.settleEpoch);
357
532
  }
358
533
  }
359
- observer.pushSettled(documentCount, finalSettle);
534
+ observer.pushSettled(synchronizations.length, finalSettle);
360
535
  // A cancelled settle must not publish evidence the caller no longer awaits.
361
536
  throwIfCodeRequestInterrupted(options.options);
362
- return buildEvidence();
537
+ options.publications.emitSummary({
538
+ operation: "refresh-open",
539
+ identity: {
540
+ server: options.host.server,
541
+ cwd: options.host.cwd,
542
+ },
543
+ synchronizations: synchronizations.map((synchronization) => ({
544
+ uri: synchronization.uri,
545
+ synchronizationId: synchronization.synchronizationId,
546
+ evidenceRevision: synchronization.evidenceRevision ?? options.evidenceRevision(),
547
+ confirmed: hasFreshEvidence(
548
+ options.diagnosticStore,
549
+ synchronization,
550
+ options.evidenceRevision(),
551
+ ),
552
+ })),
553
+ operationId: options.options.operationId,
554
+ });
555
+ return buildEvidence(quietMs);
363
556
  }
364
557
 
365
558
  /**
@@ -420,31 +613,38 @@ async function collectPullEvidenceForRefresh(options: {
420
613
  }
421
614
 
422
615
  /**
423
- * Reopen-resync fallback (R2): on push-only routes an open document that
424
-
425
- * stays unconfirmed after the settle window receives no further push a
426
- * clean file gets no push on didChange at all. Close and reopen each
427
- * unconfirmed document so the server publishes on didOpen, then settle
428
- * again within a bounded second window. The cache entry and version
429
- * history survive the reopen; other documents keep their server state.
616
+ * Reopen-resync fallback (R2): on push-only routes a document that was
617
+ * didChange-synchronized and stays unconfirmed after the settle window may
618
+ * have been skipped by the server a clean file gets no push on didChange
619
+ * at all, but the server publishes on didOpen. Close and reopen each such
620
+ * document so the server publishes, then settle again within a bounded
621
+ * second window. The cache entry and version history survive the reopen;
622
+ * other documents keep their server state.
623
+ *
624
+ * Only documents this pass resynchronized are candidates: retained documents
625
+ * wait for the server's existing pipeline, and reopening them would cancel
626
+ * in-progress server work without fixing any publish gap (#344).
430
627
  */
431
628
  async function reopenUnconfirmedDocuments(options: {
432
629
  options: ClientDiagnosticRefreshOptions;
433
630
  synchronizations: readonly DiagnosticSynchronization[];
434
- maxWaitMs: number;
435
- quietMs: number;
631
+ /** URIs that received a didChange in this pass and may need a reopen push. */
632
+ reopenCandidates: ReadonlySet<string>;
436
633
  observer: DiagnosticObserver;
437
634
  }): Promise<{
438
635
  performed: boolean;
439
636
  startedAt: number;
440
- budgetMs: number;
441
637
  synchronizations: DiagnosticSynchronization[];
442
638
  }> {
443
- const { options: refresh, synchronizations, maxWaitMs, quietMs, observer } = options;
639
+ const { options: refresh, synchronizations, reopenCandidates, observer } = options;
444
640
  const startedAt = Date.now();
445
- const budgetMs = Math.min(maxWaitMs, quietMs * 4);
446
641
  const unconfirmed = synchronizations.filter(
447
- (item) => !hasFreshEvidence(refresh.diagnosticStore, item, refresh.evidenceRevision()),
642
+ (item) =>
643
+ reopenCandidates.has(item.uri) &&
644
+ // A document with any current publication — tentative included — is
645
+ // not a reopen candidate: its server pipeline is alive and a republish
646
+ // can still promote the retained cache (ADR 0021).
647
+ !hasCurrentEvidence(refresh.diagnosticStore, item, refresh.evidenceRevision()),
448
648
  );
449
649
  const reopenedSynchronizations: DiagnosticSynchronization[] = [];
450
650
  for (const item of unconfirmed) {
@@ -478,14 +678,13 @@ async function reopenUnconfirmedDocuments(options: {
478
678
  });
479
679
  }
480
680
  if (reopenedSynchronizations.length === 0) {
481
- return { performed: false, startedAt, budgetMs, synchronizations: [...synchronizations] };
681
+ return { performed: false, startedAt, synchronizations: [...synchronizations] };
482
682
  }
483
683
  observer.reopened(reopenedSynchronizations.length);
484
684
  const reopenedByUri = new Map(reopenedSynchronizations.map((item) => [item.uri, item]));
485
685
  return {
486
686
  performed: true,
487
687
  startedAt,
488
- budgetMs,
489
688
  synchronizations: synchronizations.map((item) => reopenedByUri.get(item.uri) ?? item),
490
689
  };
491
690
  }
@@ -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,