@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
@@ -0,0 +1,214 @@
1
+ // Bounded push-publication telemetry for one client's diagnostic state.
2
+ //
3
+ // Push-only servers can publish an early result and a later semantic result
4
+ // for one document synchronization (ADR 0021). This tracker records one
5
+ // bounded per-synchronization publication summary per diagnostic operation
6
+ // and one ambient event when a later publication promotes a synchronization
7
+ // that an earlier operation already reported unconfirmed. Events carry only
8
+ // bounded server, workspace, relative-file, synchronization identity, count,
9
+ // and timing data. They never carry diagnostic payloads or source text.
10
+
11
+ import * as path from "node:path";
12
+ import { recordDebugEvent } from "@mrclrchtr/supi-core/debug";
13
+ import { boundCwd, truncateIdentity } from "../debug-telemetry.ts";
14
+ import { uriToFile } from "../utils.ts";
15
+
16
+ /** Maximum tracked synchronizations before the oldest entry is evicted. */
17
+ export const MAX_TRACKED_SYNCHRONIZATIONS = 64;
18
+
19
+ /** Maximum synchronization entries in one bounded publication summary. */
20
+ export const MAX_SUMMARY_SYNCHRONIZATIONS = 16;
21
+
22
+ /** Maximum publication count retained for telemetry; 2 means two or more. */
23
+ const MAX_PUBLICATIONS = 2;
24
+
25
+ /** Bounded identity for one publication telemetry observation. */
26
+ export interface DiagnosticPublicationIdentity {
27
+ /** Configured server name. */
28
+ readonly server?: string;
29
+ /** Absolute workspace root. */
30
+ readonly cwd?: string;
31
+ /** Workspace-relative file path; sync-file operations only. */
32
+ readonly file?: string;
33
+ }
34
+
35
+ /** One awaited synchronization of a completed diagnostic operation. */
36
+ export interface DiagnosticPublicationSynchronization {
37
+ readonly uri: string;
38
+ readonly synchronizationId: number;
39
+ readonly evidenceRevision: number;
40
+ readonly confirmed: boolean;
41
+ }
42
+
43
+ /** One bounded per-synchronization publication summary entry. */
44
+ export interface DiagnosticPublicationSummaryEntry {
45
+ readonly synchronizationId: number;
46
+ readonly publications: number;
47
+ readonly firstReceivedAt: number;
48
+ readonly lastReceivedAt: number;
49
+ readonly confirmed: boolean;
50
+ }
51
+
52
+ interface SynchronizationPublicationState {
53
+ readonly uri: string;
54
+ readonly synchronizationId: number;
55
+ readonly evidenceRevision: number;
56
+ publications: number;
57
+ firstReceivedAt: number;
58
+ lastReceivedAt: number;
59
+ /** When a finished operation first reported this synchronization unconfirmed. */
60
+ unconfirmedAt?: number;
61
+ }
62
+
63
+ function synchronizationKey(
64
+ uri: string,
65
+ synchronizationId: number,
66
+ evidenceRevision: number,
67
+ ): string {
68
+ return `${uri}|${synchronizationId}|${evidenceRevision}`;
69
+ }
70
+
71
+ /** Track bounded push-publication counts for one LSP client. */
72
+ export class DiagnosticPublicationTracker {
73
+ readonly #states = new Map<string, SynchronizationPublicationState>();
74
+
75
+ constructor(
76
+ private readonly identity: { server?: string; cwd?: string },
77
+ private readonly fileFor: (uri: string) => string | undefined,
78
+ ) {}
79
+
80
+ /**
81
+ * Record one accepted push publication for a synchronization.
82
+ *
83
+ * The state map is bounded to {@link MAX_TRACKED_SYNCHRONIZATIONS}; the
84
+ * oldest tracked synchronization is evicted when the bound is exceeded.
85
+ */
86
+ record(
87
+ uri: string,
88
+ synchronizationId: number,
89
+ evidenceRevision: number,
90
+ receivedAt: number = Date.now(),
91
+ ): void {
92
+ const key = synchronizationKey(uri, synchronizationId, evidenceRevision);
93
+ const existing = this.#states.get(key);
94
+ if (existing) {
95
+ existing.publications = Math.min(existing.publications + 1, MAX_PUBLICATIONS);
96
+ existing.lastReceivedAt = receivedAt;
97
+ return;
98
+ }
99
+ this.#states.set(key, {
100
+ uri,
101
+ synchronizationId,
102
+ evidenceRevision,
103
+ publications: 1,
104
+ firstReceivedAt: receivedAt,
105
+ lastReceivedAt: receivedAt,
106
+ });
107
+ if (this.#states.size > MAX_TRACKED_SYNCHRONIZATIONS) {
108
+ const oldest = this.#states.keys().next().value;
109
+ if (oldest !== undefined) this.#states.delete(oldest);
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Emit one bounded per-synchronization publication summary.
115
+ *
116
+ * Synchronizations with no observed push publication are omitted. A
117
+ * synchronization that ends unconfirmed is marked so a later promotion can
118
+ * emit the ambient late-republish event.
119
+ */
120
+ emitSummary(options: {
121
+ readonly operation: "refresh-open" | "sync-file";
122
+ readonly identity: DiagnosticPublicationIdentity;
123
+ readonly synchronizations: readonly DiagnosticPublicationSynchronization[];
124
+ readonly operationId?: string;
125
+ }): void {
126
+ const entries: DiagnosticPublicationSummaryEntry[] = [];
127
+ const now = Date.now();
128
+ for (const synchronization of options.synchronizations) {
129
+ const state = this.#states.get(
130
+ synchronizationKey(
131
+ synchronization.uri,
132
+ synchronization.synchronizationId,
133
+ synchronization.evidenceRevision,
134
+ ),
135
+ );
136
+ if (!state) continue;
137
+ entries.push({
138
+ synchronizationId: state.synchronizationId,
139
+ publications: state.publications,
140
+ firstReceivedAt: state.firstReceivedAt,
141
+ lastReceivedAt: state.lastReceivedAt,
142
+ confirmed: synchronization.confirmed,
143
+ });
144
+ if (!synchronization.confirmed) state.unconfirmedAt ??= now;
145
+ else state.unconfirmedAt = undefined;
146
+ }
147
+ if (entries.length === 0) return;
148
+ recordDebugEvent({
149
+ operationId: options.operationId,
150
+ source: "lsp",
151
+ level: "debug",
152
+ category: "diagnostics.publication",
153
+ message: "LSP diagnostic publication summary",
154
+ cwd: boundCwd(options.identity.cwd),
155
+ data: {
156
+ operation: options.operation,
157
+ synchronizations: entries.slice(0, MAX_SUMMARY_SYNCHRONIZATIONS),
158
+ ...(options.identity.server !== undefined
159
+ ? { server: truncateIdentity(options.identity.server) }
160
+ : {}),
161
+ ...(options.identity.file !== undefined
162
+ ? { file: truncateIdentity(options.identity.file) }
163
+ : {}),
164
+ },
165
+ });
166
+ }
167
+
168
+ /**
169
+ * Observe one promotion of a synchronization by a later publication.
170
+ *
171
+ * The ambient late-republish event fires only when a finished operation
172
+ * previously reported the synchronization unconfirmed. The mark is cleared
173
+ * so one promotion emits at most one event.
174
+ */
175
+ promoted(
176
+ uri: string,
177
+ synchronizationId: number,
178
+ evidenceRevision: number,
179
+ receivedAt: number = Date.now(),
180
+ ): void {
181
+ const state = this.#states.get(synchronizationKey(uri, synchronizationId, evidenceRevision));
182
+ const unconfirmedAt = state?.unconfirmedAt;
183
+ if (!state || state.publications < 2 || unconfirmedAt === undefined) return;
184
+ state.unconfirmedAt = undefined;
185
+ const file = this.fileFor(uri);
186
+ recordDebugEvent({
187
+ source: "lsp",
188
+ level: "debug",
189
+ category: "diagnostics.publication",
190
+ message: "LSP diagnostic late republish",
191
+ cwd: boundCwd(this.identity.cwd),
192
+ data: {
193
+ synchronizationId,
194
+ publications: state.publications,
195
+ receivedAt,
196
+ delayMs: Math.max(0, receivedAt - unconfirmedAt),
197
+ ...(this.identity.server !== undefined
198
+ ? { server: truncateIdentity(this.identity.server) }
199
+ : {}),
200
+ ...(file !== undefined ? { file: truncateIdentity(file) } : {}),
201
+ },
202
+ });
203
+ }
204
+ }
205
+
206
+ /** Return a workspace-relative diagnostic file path for telemetry identity. */
207
+ export function trackerFileIdentityFor(
208
+ cwd: string | undefined,
209
+ ): (uri: string) => string | undefined {
210
+ return (uri) => {
211
+ if (cwd === undefined) return undefined;
212
+ return path.relative(cwd, path.resolve(cwd, uriToFile(uri)));
213
+ };
214
+ }
@@ -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
  }