@mrclrchtr/supi-code-intelligence 6.4.0 → 7.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 (129) hide show
  1. package/README.md +164 -82
  2. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  3. package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +8 -1
  4. package/node_modules/@mrclrchtr/supi-core/README.md +8 -0
  5. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  6. package/node_modules/@mrclrchtr/supi-core/src/footer-registry.ts +10 -3
  7. package/node_modules/@mrclrchtr/supi-core/src/index.ts +2 -0
  8. package/node_modules/@mrclrchtr/supi-core/src/llm.ts +141 -16
  9. package/node_modules/@mrclrchtr/supi-lsp/README.md +35 -13
  10. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  11. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +8 -1
  12. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/README.md +8 -0
  13. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  14. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/footer-registry.ts +10 -3
  15. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/index.ts +2 -0
  16. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/llm.ts +141 -16
  17. package/node_modules/@mrclrchtr/supi-lsp/node_modules/ignore/index.js +3 -1
  18. package/node_modules/@mrclrchtr/supi-lsp/node_modules/ignore/legacy.js +3 -1
  19. package/node_modules/@mrclrchtr/supi-lsp/node_modules/ignore/package.json +1 -1
  20. package/node_modules/@mrclrchtr/supi-lsp/package.json +3 -3
  21. package/node_modules/@mrclrchtr/supi-lsp/src/api.ts +6 -0
  22. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-cache.ts +43 -35
  23. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-capabilities.ts +83 -17
  24. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-collection.ts +108 -92
  25. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-evidence.ts +44 -82
  26. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-host.ts +3 -4
  27. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-publication.ts +9 -68
  28. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-pull.ts +45 -58
  29. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-refresh.ts +230 -331
  30. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-request.ts +201 -1
  31. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-timing.ts +129 -70
  32. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-typescript.ts +395 -0
  33. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-waiters.ts +8 -12
  34. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostics.ts +685 -183
  35. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-document-state.ts +2 -2
  36. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-document-sync.ts +60 -60
  37. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-request-enrollment.ts +75 -0
  38. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-semantic-input-barrier.ts +443 -0
  39. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-semantic-input-errors.ts +30 -0
  40. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-semantic-input-file.ts +15 -0
  41. package/node_modules/@mrclrchtr/supi-lsp/src/client/client.ts +290 -37
  42. package/node_modules/@mrclrchtr/supi-lsp/src/client/transport.ts +72 -16
  43. package/node_modules/@mrclrchtr/supi-lsp/src/config/capabilities.ts +1 -0
  44. package/node_modules/@mrclrchtr/supi-lsp/src/config/tsconfig-scope.ts +30 -4
  45. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/evidence.ts +1 -1
  46. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-diagnostics.ts +10 -4
  47. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-process-crash-report.ts +50 -1
  48. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-workspace-recovery.ts +11 -7
  49. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager.ts +608 -45
  50. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-diagnostic-surface.ts +3 -1
  51. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-diagnostics.ts +37 -4
  52. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-registry.ts +31 -11
  53. package/node_modules/@mrclrchtr/supi-lsp/src/session/workspace-lsp-runtime.ts +19 -3
  54. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  55. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +8 -1
  56. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/README.md +8 -0
  57. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  58. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/footer-registry.ts +10 -3
  59. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/index.ts +2 -0
  60. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/llm.ts +141 -16
  61. package/node_modules/@mrclrchtr/supi-tree-sitter/package.json +3 -3
  62. package/node_modules/@mrclrchtr/supi-tree-sitter/src/provider/tree-sitter-provider.ts +2 -0
  63. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/callee-display-name.ts +165 -0
  64. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tool/callees.ts +67 -9
  65. package/node_modules/@mrclrchtr/supi-tree-sitter/src/types.ts +2 -0
  66. package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +19 -18
  67. package/node_modules/yaml/browser/dist/nodes/Alias.js +25 -23
  68. package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +19 -18
  69. package/node_modules/yaml/dist/nodes/Alias.js +25 -23
  70. package/node_modules/yaml/package.json +1 -1
  71. package/package.json +5 -5
  72. package/src/analysis/health/diagnostics.ts +1 -2
  73. package/src/analysis/health/file-scope.ts +4 -1
  74. package/src/analysis/health/server-status.ts +1 -1
  75. package/src/analysis/relations/callees.ts +17 -8
  76. package/src/analysis/relations/implementations.ts +1 -2
  77. package/src/analysis/relations/references.ts +8 -4
  78. package/src/analysis/relations/types.ts +6 -30
  79. package/src/analysis/target/anchored.ts +9 -1
  80. package/src/analysis/target/symbol.ts +29 -11
  81. package/src/analysis/target/types.ts +14 -6
  82. package/src/api.ts +2 -0
  83. package/src/session/graph/collect.ts +18 -3
  84. package/src/session/health-refresh.ts +12 -1
  85. package/src/session/health-types.ts +18 -6
  86. package/src/session/health-workflow.ts +21 -19
  87. package/src/session/orientation-types.ts +5 -6
  88. package/src/session/orientation-workflow.ts +10 -2
  89. package/src/session/target-workflow.ts +9 -6
  90. package/src/substrate/lsp/source-tracking.ts +11 -6
  91. package/src/tool/code_find/guidance.ts +2 -2
  92. package/src/tool/code_find/spec.ts +5 -4
  93. package/src/tool/code_find/tui.ts +7 -1
  94. package/src/tool/code_graph/details.ts +32 -0
  95. package/src/tool/code_graph/display.ts +54 -0
  96. package/src/tool/code_graph/format.ts +60 -0
  97. package/src/tool/code_graph/guidance.ts +1 -1
  98. package/src/tool/code_graph/markdown.ts +76 -198
  99. package/src/tool/code_graph/read-next.ts +65 -0
  100. package/src/tool/code_graph/result.ts +118 -83
  101. package/src/tool/code_graph/tui.ts +216 -54
  102. package/src/tool/code_health/guidance.ts +2 -4
  103. package/src/tool/code_health/markdown.ts +75 -58
  104. package/src/tool/code_health/refresh-outcome.ts +41 -1
  105. package/src/tool/code_health/refresh-status.ts +114 -48
  106. package/src/tool/code_health/scope.ts +78 -0
  107. package/src/tool/code_health/spec.ts +1 -1
  108. package/src/tool/code_health/tui.ts +25 -38
  109. package/src/tool/code_inspect/tui.ts +7 -1
  110. package/src/tool/code_orientation/result.ts +31 -8
  111. package/src/tool/code_orientation/tui.ts +26 -5
  112. package/src/tool/code_refactor_apply/guidance.ts +1 -1
  113. package/src/tool/code_refactor_apply/tui.ts +7 -3
  114. package/src/tool/code_refactor_plan/guidance.ts +1 -1
  115. package/src/tool/code_refactor_plan/tui.ts +7 -1
  116. package/src/tool/code_resolve/guidance.ts +2 -4
  117. package/src/tool/code_resolve/result.ts +13 -10
  118. package/src/tool/code_resolve/tui.ts +18 -1
  119. package/src/tool/infra/truncate.ts +39 -13
  120. package/src/tool/result/candidate-row.ts +27 -0
  121. package/src/tool/result/errors.ts +21 -6
  122. package/src/tool/result/types.ts +7 -3
  123. package/src/tool/schemas.ts +5 -0
  124. package/src/types/execution.ts +2 -0
  125. package/src/types/index.ts +2 -0
  126. package/src/ui/tui/candidate-legacy-body.ts +69 -0
  127. package/src/ui/tui/candidate-selection.ts +366 -0
  128. package/src/ui/tui/common.ts +26 -31
  129. package/src/ui/tui/execution-error.ts +92 -0
@@ -0,0 +1,443 @@
1
+ // biome-ignore lint/style/noExcessiveLinesPerFile: one barrier owns the shared read pass, revision tracking, and freshness checks.
2
+ import {
3
+ type CodeRequestControl,
4
+ throwIfCodeRequestInterrupted,
5
+ } from "@mrclrchtr/supi-code-runtime/api";
6
+ import { raceRequestControl } from "../session/readiness.ts";
7
+ import { fingerprintDocumentContent } from "./client-document-state.ts";
8
+ import {
9
+ type SemanticInputChangeKind,
10
+ SemanticInputSynchronizationError,
11
+ } from "./client-semantic-input-errors.ts";
12
+ import {
13
+ defaultSemanticInputFileReader,
14
+ isMissingFileReadError,
15
+ type SemanticInputFileReader,
16
+ } from "./client-semantic-input-file.ts";
17
+
18
+ const DEFAULT_MAX_CONCURRENT_READS = 16;
19
+
20
+ /** Immutable document facts captured before one input synchronization pass. */
21
+ export interface SemanticInputDocument {
22
+ readonly uri: string;
23
+ readonly filePath: string;
24
+ readonly content: string;
25
+ readonly contentFingerprint: string;
26
+ }
27
+
28
+ /** One open document update selected by the barrier after a full read. */
29
+ export interface SemanticInputUpdate {
30
+ readonly document: SemanticInputDocument;
31
+ readonly content: string;
32
+ }
33
+
34
+ /** Private owner callbacks used by the input barrier. */
35
+ export interface SemanticInputBarrierHost {
36
+ isOperational(): boolean;
37
+ getOpenDocuments(): readonly SemanticInputDocument[];
38
+ applyDocumentUpdates(updates: readonly SemanticInputUpdate[]): void;
39
+ closeMissingDocument(filePath: string): void;
40
+ markUnreadableDocument(filePath: string): void;
41
+ }
42
+
43
+ /** Optional read bound and test seam for one input barrier. */
44
+ export interface SemanticInputBarrierOptions {
45
+ readonly maxConcurrentReads?: number;
46
+ readonly readFile?: SemanticInputFileReader;
47
+ }
48
+
49
+ /** Revision token returned by one completed input synchronization pass. */
50
+ export interface SemanticInputSnapshot {
51
+ readonly revision: number;
52
+ }
53
+
54
+ interface OpenDocumentRead {
55
+ readonly document: SemanticInputDocument;
56
+ readonly result:
57
+ | { readonly kind: "content"; readonly content: string }
58
+ | { readonly kind: "error"; readonly error: unknown };
59
+ }
60
+
61
+ interface SynchronizationPlan {
62
+ readonly updates: SemanticInputUpdate[];
63
+ readonly observedDiskFingerprints: Map<string, string | undefined>;
64
+ }
65
+
66
+ interface PendingSynchronization {
67
+ readonly controller: AbortController;
68
+ generation: number;
69
+ readonly contentOverrides: Map<string, string>;
70
+ promise: Promise<SemanticInputSnapshot>;
71
+ consumers: number;
72
+ abandoned: boolean;
73
+ started: boolean;
74
+ settled: boolean;
75
+ }
76
+
77
+ /**
78
+ * Keep semantic and diagnostic requests on the latest full-content input.
79
+ *
80
+ * The barrier owns one shared asynchronous read pass. Caller controls race
81
+ * only their own wait; the pass stops when no caller remains or its generation
82
+ * changes. Full file contents, rather than metadata, establish freshness.
83
+ */
84
+ export class SemanticInputBarrier {
85
+ readonly #host: SemanticInputBarrierHost;
86
+ readonly #maxConcurrentReads: number;
87
+ readonly #readFile: SemanticInputFileReader;
88
+ /** Last verified disk fingerprint for each URI; undefined means observed missing. */
89
+ readonly #observedDiskFingerprints = new Map<string, string | undefined>();
90
+ #revision = 0;
91
+ /** Latest non-enrollment change, used to classify stale snapshots. */
92
+ #lastNonEnrollmentChange:
93
+ | { readonly revision: number; readonly changeKind: SemanticInputChangeKind }
94
+ | undefined;
95
+ #pending: PendingSynchronization | null = null;
96
+
97
+ constructor(host: SemanticInputBarrierHost, options: SemanticInputBarrierOptions = {}) {
98
+ this.#host = host;
99
+ this.#maxConcurrentReads = Math.max(
100
+ 1,
101
+ Math.floor(options.maxConcurrentReads ?? DEFAULT_MAX_CONCURRENT_READS),
102
+ );
103
+ this.#readFile = options.readFile ?? defaultSemanticInputFileReader;
104
+ }
105
+
106
+ /** Read and apply all open inputs, sharing work for one input generation. */
107
+ async synchronize(
108
+ control?: CodeRequestControl,
109
+ contentOverrides?: ReadonlyMap<string, string>,
110
+ ): Promise<SemanticInputSnapshot> {
111
+ for (;;) {
112
+ const pending = this.#pending;
113
+ if (!pending) break;
114
+ if (this.#canJoin(pending, contentOverrides)) {
115
+ this.#mergeContentOverrides(pending, contentOverrides);
116
+ return this.#waitForPending(pending, control);
117
+ }
118
+
119
+ // A conflicting generation or override cannot join the owner. Wait for
120
+ // the owner's actual reader promises before trying a replacement. The
121
+ // caller may stop waiting, but it must never make the route reusable.
122
+ await this.#waitForOwnerSettlement(pending, control);
123
+ throwIfCodeRequestInterrupted(control);
124
+ }
125
+
126
+ if (!this.#host.isOperational()) {
127
+ throw new Error("Semantic input synchronization is unavailable because the client stopped.");
128
+ }
129
+
130
+ const pending: PendingSynchronization = {
131
+ controller: new AbortController(),
132
+ generation: this.#revision,
133
+ contentOverrides: new Map(contentOverrides),
134
+ promise: undefined as unknown as Promise<SemanticInputSnapshot>,
135
+ consumers: 0,
136
+ abandoned: false,
137
+ started: false,
138
+ settled: false,
139
+ };
140
+ this.#pending = pending;
141
+ // Store and pass the same owner object. Its mutable lifecycle fields must
142
+ // not diverge between the runner, waiters, and settlement callback.
143
+ pending.promise = this.#run(pending);
144
+ void pending.promise.finally(() => this.#finish(pending)).catch(() => {});
145
+ return this.#waitForPending(pending, control);
146
+ }
147
+
148
+ /** Recheck the full input set after a semantic request completes. */
149
+ async assertCurrent(
150
+ snapshot: SemanticInputSnapshot,
151
+ control?: CodeRequestControl,
152
+ contentOverrides?: ReadonlyMap<string, string>,
153
+ ): Promise<void> {
154
+ if (!this.#host.isOperational()) {
155
+ throw new Error("Semantic input synchronization is unavailable because the client stopped.");
156
+ }
157
+ const changedBeforeRead = this.getChangeSince(
158
+ snapshot.revision,
159
+ "Semantic input changed while the request was running.",
160
+ );
161
+ if (changedBeforeRead) throw changedBeforeRead;
162
+ const current = await this.synchronize(control, contentOverrides);
163
+ if (current.revision !== snapshot.revision) {
164
+ const changedAfterRead = this.getChangeSince(
165
+ snapshot.revision,
166
+ "Semantic input changed while the request was running.",
167
+ );
168
+ throw (
169
+ changedAfterRead ??
170
+ new SemanticInputSynchronizationError(
171
+ "content",
172
+ "Semantic input changed while the request was running.",
173
+ )
174
+ );
175
+ }
176
+ }
177
+
178
+ /** Return the current typed input change after a revision, when present. */
179
+ getChangeSince(
180
+ revision: number,
181
+ message = "Semantic input changed while synchronization was running.",
182
+ ): SemanticInputSynchronizationError | undefined {
183
+ if (this.#revision <= revision) return undefined;
184
+ return new SemanticInputSynchronizationError(this.#changeKindSince(revision), message);
185
+ }
186
+
187
+ /** Record a lifecycle or input change that invalidates a pending pass. */
188
+ noteInputChange(changeKind: SemanticInputChangeKind = "content"): void {
189
+ this.#advanceRevision(changeKind);
190
+ const pending = this.#pending;
191
+ if (!pending || pending.settled) return;
192
+ const reason = pending.controller.signal.reason;
193
+ if (reason instanceof SemanticInputSynchronizationError) {
194
+ reason.updateChangeKind(changeKind);
195
+ return;
196
+ }
197
+ pending.controller.abort(new SemanticInputSynchronizationError(changeKind));
198
+ }
199
+
200
+ /** Seed the initial disk baseline from the content used to open a document. */
201
+ initializeDocumentContent(uri: string, content: string): void {
202
+ this.#observedDiskFingerprints.set(uri, fingerprintDocumentContent(content));
203
+ }
204
+
205
+ /** Record content read from disk after a synchronization or refresh pass. */
206
+ observeDiskContent(uri: string, content: string): void {
207
+ this.#observedDiskFingerprints.set(uri, fingerprintDocumentContent(content));
208
+ }
209
+
210
+ /** Forget a document's last observed disk content. */
211
+ forgetDocumentContent(uri: string): void {
212
+ this.#observedDiskFingerprints.delete(uri);
213
+ }
214
+
215
+ /** Stop pending work and discard observed input fingerprints. */
216
+ clear(): void {
217
+ this.#observedDiskFingerprints.clear();
218
+ this.noteInputChange("lifecycle");
219
+ }
220
+
221
+ #canJoin(
222
+ pending: PendingSynchronization,
223
+ contentOverrides: ReadonlyMap<string, string> | undefined,
224
+ ): boolean {
225
+ return (
226
+ !pending.settled &&
227
+ !pending.abandoned &&
228
+ pending.generation === this.#revision &&
229
+ this.#canMergeContentOverrides(pending, contentOverrides)
230
+ );
231
+ }
232
+
233
+ #canMergeContentOverrides(
234
+ pending: PendingSynchronization,
235
+ contentOverrides: ReadonlyMap<string, string> | undefined,
236
+ ): boolean {
237
+ if (!contentOverrides) return true;
238
+ for (const [uri, content] of contentOverrides) {
239
+ const existing = pending.contentOverrides.get(uri);
240
+ if (pending.started && existing === undefined) return false;
241
+ if (existing !== undefined && existing !== content) return false;
242
+ }
243
+ return true;
244
+ }
245
+
246
+ #mergeContentOverrides(
247
+ pending: PendingSynchronization,
248
+ contentOverrides: ReadonlyMap<string, string> | undefined,
249
+ ): void {
250
+ if (!contentOverrides) return;
251
+ for (const [uri, content] of contentOverrides) pending.contentOverrides.set(uri, content);
252
+ }
253
+
254
+ #waitForPending(
255
+ pending: PendingSynchronization,
256
+ control?: CodeRequestControl,
257
+ ): Promise<SemanticInputSnapshot> {
258
+ pending.consumers++;
259
+ const waiting = raceRequestControl(pending.promise, control);
260
+ void waiting.finally(() => this.#release(pending)).catch(() => {});
261
+ return waiting;
262
+ }
263
+
264
+ async #waitForOwnerSettlement(
265
+ pending: PendingSynchronization,
266
+ control?: CodeRequestControl,
267
+ ): Promise<void> {
268
+ try {
269
+ await raceRequestControl(pending.promise, control);
270
+ } catch {
271
+ // A failed or abandoned owner can be replaced after its readers settle.
272
+ // Only this caller's interruption must escape this wait.
273
+ throwIfCodeRequestInterrupted(control);
274
+ }
275
+ throwIfCodeRequestInterrupted(control);
276
+ }
277
+
278
+ #release(pending: PendingSynchronization): void {
279
+ pending.consumers = Math.max(0, pending.consumers - 1);
280
+ if (pending.consumers !== 0 || pending.settled) return;
281
+ pending.abandoned = true;
282
+ pending.controller.abort(new Error("Semantic input synchronization was abandoned."));
283
+ }
284
+
285
+ async #run(pending: PendingSynchronization): Promise<SemanticInputSnapshot> {
286
+ pending.started = true;
287
+ this.#assertPendingCanRun(pending);
288
+ const documents = this.#host.getOpenDocuments();
289
+ const reads = await this.#readOpenDocuments(documents, pending.controller.signal);
290
+ this.#assertPendingCurrent(pending);
291
+ const plan = this.#buildSynchronizationPlan(pending, reads);
292
+ this.#assertPendingCurrent(pending);
293
+ this.#applySynchronizationPlan(plan);
294
+ if (!this.#host.isOperational()) {
295
+ throw new Error("Semantic input synchronization is unavailable because the client stopped.");
296
+ }
297
+ // The revision can advance when this pass applies changed text. Mark that
298
+ // resulting generation as owned by this completed pass so a late caller
299
+ // can join it instead of starting a redundant read pass.
300
+ pending.generation = this.#revision;
301
+ return { revision: this.#revision };
302
+ }
303
+
304
+ #assertPendingCanRun(pending: PendingSynchronization): void {
305
+ if (pending.controller.signal.aborted) throw pending.controller.signal.reason;
306
+ if (!this.#host.isOperational()) {
307
+ throw new Error("Semantic input synchronization is unavailable because the client stopped.");
308
+ }
309
+ }
310
+
311
+ #assertPendingCurrent(pending: PendingSynchronization): void {
312
+ if (pending.controller.signal.aborted) throw pending.controller.signal.reason;
313
+ if (!this.#host.isOperational()) {
314
+ throw new Error("Semantic input changed while synchronization was running.");
315
+ }
316
+ const changed = this.getChangeSince(pending.generation);
317
+ if (changed) throw changed;
318
+ }
319
+
320
+ #buildSynchronizationPlan(
321
+ pending: PendingSynchronization,
322
+ reads: readonly OpenDocumentRead[],
323
+ ): SynchronizationPlan {
324
+ const updates: SemanticInputUpdate[] = [];
325
+ const observedDiskFingerprints = new Map<string, string | undefined>();
326
+ for (const read of reads) {
327
+ const planned = this.#planDocumentRead(pending, read);
328
+ observedDiskFingerprints.set(read.document.uri, planned.observedDiskFingerprint);
329
+ if (planned.update) updates.push(planned.update);
330
+ }
331
+ return { updates, observedDiskFingerprints };
332
+ }
333
+
334
+ #planDocumentRead(
335
+ pending: PendingSynchronization,
336
+ read: OpenDocumentRead,
337
+ ): { observedDiskFingerprint: string | undefined; update?: SemanticInputUpdate } {
338
+ const override = pending.contentOverrides.get(read.document.uri);
339
+ if (read.result.kind === "error") {
340
+ return this.#planReadError(read.document, read.result.error, override);
341
+ }
342
+
343
+ const diskFingerprint = fingerprintDocumentContent(read.result.content);
344
+ const hasObservedDiskContent = this.#observedDiskFingerprints.has(read.document.uri);
345
+ const previousDiskFingerprint = this.#observedDiskFingerprints.get(read.document.uri);
346
+ // An explicit override controls server content; the disk fingerprint remains the next observation.
347
+ const content =
348
+ override ??
349
+ (hasObservedDiskContent && previousDiskFingerprint === diskFingerprint
350
+ ? read.document.content
351
+ : read.result.content);
352
+ const contentFingerprint = fingerprintDocumentContent(content);
353
+ return {
354
+ observedDiskFingerprint: diskFingerprint,
355
+ ...(read.document.contentFingerprint !== contentFingerprint
356
+ ? { update: { document: read.document, content } }
357
+ : {}),
358
+ };
359
+ }
360
+
361
+ #planReadError(
362
+ document: SemanticInputDocument,
363
+ error: unknown,
364
+ override: string | undefined,
365
+ ): { observedDiskFingerprint: string | undefined; update?: SemanticInputUpdate } {
366
+ if (override !== undefined && isMissingFileReadError(error)) {
367
+ return {
368
+ observedDiskFingerprint: undefined,
369
+ ...(document.contentFingerprint !== fingerprintDocumentContent(override)
370
+ ? { update: { document, content: override } }
371
+ : {}),
372
+ };
373
+ }
374
+ if (isMissingFileReadError(error)) {
375
+ this.#host.closeMissingDocument(document.filePath);
376
+ throw new Error(`Semantic input file was removed: ${document.filePath}`);
377
+ }
378
+ this.#host.markUnreadableDocument(document.filePath);
379
+ throw new Error(`Semantic input file could not be read: ${document.filePath}`);
380
+ }
381
+
382
+ #applySynchronizationPlan(plan: SynchronizationPlan): void {
383
+ if (plan.updates.length > 0) {
384
+ this.#host.applyDocumentUpdates(plan.updates);
385
+ this.#advanceRevision("content");
386
+ }
387
+ this.#rememberObservedDiskFingerprints(plan.observedDiskFingerprints);
388
+ }
389
+
390
+ #advanceRevision(changeKind: SemanticInputChangeKind): void {
391
+ this.#revision++;
392
+ if (changeKind !== "enrollment") {
393
+ this.#lastNonEnrollmentChange = { revision: this.#revision, changeKind };
394
+ }
395
+ }
396
+
397
+ #changeKindSince(revision: number): SemanticInputChangeKind {
398
+ const change = this.#lastNonEnrollmentChange;
399
+ return change && change.revision > revision ? change.changeKind : "enrollment";
400
+ }
401
+
402
+ #rememberObservedDiskFingerprints(fingerprints: ReadonlyMap<string, string | undefined>): void {
403
+ for (const [uri, fingerprint] of fingerprints) {
404
+ this.#observedDiskFingerprints.set(uri, fingerprint);
405
+ }
406
+ }
407
+
408
+ async #readOpenDocuments(
409
+ documents: readonly SemanticInputDocument[],
410
+ signal: AbortSignal,
411
+ ): Promise<OpenDocumentRead[]> {
412
+ const reads = new Array<OpenDocumentRead>(documents.length);
413
+ let nextIndex = 0;
414
+ const worker = async (): Promise<void> => {
415
+ for (;;) {
416
+ // An abandoned pass may have readers that ignore AbortSignal. Do not
417
+ // dispatch another unread file after the owner has been abandoned.
418
+ if (signal.aborted) return;
419
+ const index = nextIndex++;
420
+ const document = documents[index];
421
+ if (!document) return;
422
+ try {
423
+ const content = await this.#readFile(document.filePath, signal);
424
+ reads[index] = { document, result: { kind: "content", content } };
425
+ } catch (error) {
426
+ reads[index] = { document, result: { kind: "error", error } };
427
+ }
428
+ }
429
+ };
430
+ const workerCount = Math.min(this.#maxConcurrentReads, documents.length);
431
+ const workers = await Promise.allSettled(Array.from({ length: workerCount }, () => worker()));
432
+ const failure = workers.find(
433
+ (result): result is PromiseRejectedResult => result.status === "rejected",
434
+ );
435
+ if (failure) throw failure.reason;
436
+ return reads.filter((read): read is OpenDocumentRead => read !== undefined);
437
+ }
438
+
439
+ #finish(pending: PendingSynchronization): void {
440
+ pending.settled = true;
441
+ if (this.#pending === pending) this.#pending = null;
442
+ }
443
+ }
@@ -0,0 +1,30 @@
1
+ /** Input change that can supersede a synchronization pass or semantic result. */
2
+ export type SemanticInputChangeKind = "enrollment" | "content" | "close" | "failure" | "lifecycle";
3
+
4
+ /** Typed cause when a synchronization pass or semantic result sees a newer input generation. */
5
+ export class SemanticInputSynchronizationError extends Error {
6
+ changeKind: SemanticInputChangeKind;
7
+
8
+ constructor(
9
+ changeKind: SemanticInputChangeKind,
10
+ message = "Semantic input changed while synchronization was running.",
11
+ ) {
12
+ super(message);
13
+ this.name = "SemanticInputSynchronizationError";
14
+ this.changeKind = changeKind;
15
+ }
16
+
17
+ /** Keep retry eligibility monotonic after a non-enrollment change. */
18
+ updateChangeKind(changeKind: SemanticInputChangeKind): void {
19
+ if (this.changeKind === "enrollment" && changeKind !== "enrollment") {
20
+ this.changeKind = changeKind;
21
+ }
22
+ }
23
+ }
24
+
25
+ /** Test whether a failed pass can rejoin after a new document enrolled. */
26
+ export function isSemanticInputEnrollmentError(
27
+ error: unknown,
28
+ ): error is SemanticInputSynchronizationError {
29
+ return error instanceof SemanticInputSynchronizationError && error.changeKind === "enrollment";
30
+ }
@@ -0,0 +1,15 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ /** Internal file-read seam for one semantic input synchronization pass. */
4
+ export type SemanticInputFileReader = (filePath: string, signal: AbortSignal) => Promise<string>;
5
+
6
+ /** Default full-content reader used by the semantic input barrier. */
7
+ export const defaultSemanticInputFileReader: SemanticInputFileReader = (filePath, signal) =>
8
+ readFile(filePath, { encoding: "utf8", signal });
9
+
10
+ /** Return whether a failed read means that the document no longer exists. */
11
+ export function isMissingFileReadError(error: unknown): boolean {
12
+ if (typeof error !== "object" || error === null || !("code" in error)) return false;
13
+ const code = error.code;
14
+ return code === "ENOENT" || code === "ENOTDIR";
15
+ }