@dev.fast/whiteboard 0.0.0-stage → 0.2.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 (247) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/THIRD_PARTY_NOTICES.md +93 -0
  4. package/dist/account-alias-DOHN1RSH.js +973 -0
  5. package/dist/agent-cli-ChOq0ZuO.js +117 -0
  6. package/dist/agent-cli-DzWfZr6G.js +2 -0
  7. package/dist/agent-client-CPQo7iTI.js +305 -0
  8. package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
  9. package/dist/build-info.json +1 -0
  10. package/dist/cli-hVgHcdsv.js +47 -0
  11. package/dist/cli-runner-D8y2_luU.js +5095 -0
  12. package/dist/cli.d.ts +1 -0
  13. package/dist/cli.js +128 -0
  14. package/dist/client-DFoh50W3.js +950 -0
  15. package/dist/desktop-discovery-DBO-PM2V.js +164 -0
  16. package/dist/error-message-OtiDonty.js +6 -0
  17. package/dist/fs-utils-BMPLt0cr.js +22 -0
  18. package/dist/fuzzy-match-BoAmcyak.js +30 -0
  19. package/dist/headless-host-BJxf4dUU.js +94 -0
  20. package/dist/input-error-OLgB_h31.js +11 -0
  21. package/dist/local-data-CxLk75rx.js +4491 -0
  22. package/dist/mcp-QeF8vkdf.js +140 -0
  23. package/dist/package-paths-B6-zxvIO.js +47 -0
  24. package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
  25. package/dist/profile-4ry2f3AM.js +76 -0
  26. package/dist/profile-Bh5FIrmf.js +2 -0
  27. package/dist/request-origin-D_4QIdoN.js +811 -0
  28. package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
  29. package/dist/review-home-paths-6zZH-1c9.js +2560 -0
  30. package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
  31. package/dist/runtime-CKiWhGrf.js +52 -0
  32. package/dist/runtime.d.ts +9 -0
  33. package/dist/runtime.js +2 -0
  34. package/dist/s3-SzLpeCG6.js +345 -0
  35. package/dist/s3-config-BgxkSoOy.js +2 -0
  36. package/dist/s3-config-CAxhO9u_.js +676 -0
  37. package/dist/server/desktop-host.d.ts +4 -0
  38. package/dist/server/desktop-host.js +1702 -0
  39. package/dist/server-discovery-Dbzn3w6Z.js +66 -0
  40. package/dist/sharing/index.d.ts +1977 -0
  41. package/dist/sharing/index.js +2 -0
  42. package/dist/src-CGP5ytbV.js +283 -0
  43. package/dist/src-CmdiBL20.js +3676 -0
  44. package/dist/src-DnwdaQ2r.js +865 -0
  45. package/dist/src-X9phtB2j.js +68 -0
  46. package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
  47. package/dist/tool-failure-C8zB73HV.js +98 -0
  48. package/dist/tutorial-trace-DrBvkaeL.js +43 -0
  49. package/instructions/authoring.md +36 -0
  50. package/instructions/file-lenses.md +13 -0
  51. package/instructions/scratchpad.md +23 -0
  52. package/instructions/trace-archaeology.md +113 -0
  53. package/onboarding.md +8 -0
  54. package/package.json +106 -3
  55. package/src/agent-selection.ts +100 -0
  56. package/src/agent-session-ref.ts +80 -0
  57. package/src/ask/agents.ts +351 -0
  58. package/src/ask/checkout-files.ts +47 -0
  59. package/src/ask/file-refs.ts +111 -0
  60. package/src/ask/pi-mcp.ts +154 -0
  61. package/src/ask/protocol.ts +244 -0
  62. package/src/ask/thread-state.ts +350 -0
  63. package/src/ask/thread.ts +1400 -0
  64. package/src/ask/threads.ts +219 -0
  65. package/src/ask/watch.ts +79 -0
  66. package/src/cli-install.ts +1041 -0
  67. package/src/cli-runner.ts +1461 -0
  68. package/src/cli-runtime-info.ts +35 -0
  69. package/src/cli.ts +222 -0
  70. package/src/connect-prompts.ts +248 -0
  71. package/src/cursor-deeplink.ts +11 -0
  72. package/src/desktop-discovery.ts +355 -0
  73. package/src/diff-selection-migration.ts +63 -0
  74. package/src/embedded-posthog-key.ts +6 -0
  75. package/src/error-telemetry.ts +247 -0
  76. package/src/evidence.ts +14 -0
  77. package/src/exception-telemetry.ts +126 -0
  78. package/src/fixtures/blocks/call_stack_diff.json +27 -0
  79. package/src/fixtures/blocks/callout.json +11 -0
  80. package/src/fixtures/blocks/code.json +9 -0
  81. package/src/fixtures/blocks/code_peek.json +8 -0
  82. package/src/fixtures/blocks/database_lens.json +51 -0
  83. package/src/fixtures/blocks/divider.json +1 -0
  84. package/src/fixtures/blocks/fixtures.ts +33 -0
  85. package/src/fixtures/blocks/flow_diagram.json +36 -0
  86. package/src/fixtures/blocks/ids.ts +8 -0
  87. package/src/fixtures/blocks/image.json +9 -0
  88. package/src/fixtures/blocks/markdown.json +7 -0
  89. package/src/fixtures/blocks/section.json +11 -0
  90. package/src/fixtures/blocks/sequence.json +31 -0
  91. package/src/fixtures/blocks/software_map.json +7 -0
  92. package/src/fixtures/blocks/trace_quote.json +9 -0
  93. package/src/fixtures/blocks/tutorial.json +51 -0
  94. package/src/fs-utils.ts +32 -0
  95. package/src/fuzzy-match.ts +94 -0
  96. package/src/install.ts +29 -0
  97. package/src/legacy-skills.ts +133 -0
  98. package/src/lens-selection.ts +230 -0
  99. package/src/markdown-latex-math.ts +230 -0
  100. package/src/markdown.ts +81 -0
  101. package/src/package-paths.ts +53 -0
  102. package/src/posthog-capture-client.ts +610 -0
  103. package/src/review-api/README.md +295 -0
  104. package/src/review-api/activity.ts +340 -0
  105. package/src/review-api/agent-cli.ts +219 -0
  106. package/src/review-api/agent-client.ts +188 -0
  107. package/src/review-api/anchor-quotes.ts +88 -0
  108. package/src/review-api/ask-history.ts +242 -0
  109. package/src/review-api/authoring-tools.ts +211 -0
  110. package/src/review-api/blocks/call_stack_diff.ts +75 -0
  111. package/src/review-api/blocks/callout.ts +24 -0
  112. package/src/review-api/blocks/code.ts +9 -0
  113. package/src/review-api/blocks/code_peek.ts +13 -0
  114. package/src/review-api/blocks/database_lens.ts +150 -0
  115. package/src/review-api/blocks/definition.ts +40 -0
  116. package/src/review-api/blocks/divider.ts +6 -0
  117. package/src/review-api/blocks/flow_diagram.ts +111 -0
  118. package/src/review-api/blocks/image.ts +10 -0
  119. package/src/review-api/blocks/index.ts +90 -0
  120. package/src/review-api/blocks/markdown.ts +17 -0
  121. package/src/review-api/blocks/section.ts +24 -0
  122. package/src/review-api/blocks/sequence.ts +56 -0
  123. package/src/review-api/blocks/software_map.ts +9 -0
  124. package/src/review-api/blocks/trace_quote.ts +10 -0
  125. package/src/review-api/blocks/tutorial.ts +39 -0
  126. package/src/review-api/checkout-fs.ts +14 -0
  127. package/src/review-api/client.ts +1 -0
  128. package/src/review-api/comparison-coverage.ts +304 -0
  129. package/src/review-api/component-reference.ts +30 -0
  130. package/src/review-api/diff-lenses.ts +175 -0
  131. package/src/review-api/document-headings.ts +51 -0
  132. package/src/review-api/document-text.ts +200 -0
  133. package/src/review-api/document.ts +906 -0
  134. package/src/review-api/file-lenses.ts +100 -0
  135. package/src/review-api/http.ts +1877 -0
  136. package/src/review-api/image-decode.ts +29 -0
  137. package/src/review-api/input-error.ts +9 -0
  138. package/src/review-api/instructions.ts +89 -0
  139. package/src/review-api/lens-alignment.ts +52 -0
  140. package/src/review-api/local-data.ts +1837 -0
  141. package/src/review-api/map-input.ts +154 -0
  142. package/src/review-api/mcp-client-agent.ts +32 -0
  143. package/src/review-api/mcp.ts +240 -0
  144. package/src/review-api/origin.ts +43 -0
  145. package/src/review-api/profile.ts +146 -0
  146. package/src/review-api/public-tools.ts +102 -0
  147. package/src/review-api/pull-request.ts +389 -0
  148. package/src/review-api/read-schemas.ts +85 -0
  149. package/src/review-api/recovery.ts +2 -0
  150. package/src/review-api/request-origin.ts +33 -0
  151. package/src/review-api/review-progress.ts +382 -0
  152. package/src/review-api/status-tool.ts +10 -0
  153. package/src/review-api/store-schema.ts +22 -0
  154. package/src/review-api/store.ts +1647 -0
  155. package/src/review-api/tool-failure.ts +64 -0
  156. package/src/review-api/trace-schema.ts +13 -0
  157. package/src/review-api/traces.ts +119 -0
  158. package/src/review-api/unsupported-files.integration.ts +91 -0
  159. package/src/review-api/workspaces.ts +692 -0
  160. package/src/review-api/worktree-source.ts +170 -0
  161. package/src/review-api/worktree-structural.integration.ts +320 -0
  162. package/src/review-app-launcher.ts +432 -0
  163. package/src/review-app-picker.ts +168 -0
  164. package/src/review-app.ts +134 -0
  165. package/src/review-bundled-tools.ts +249 -0
  166. package/src/review-checkout-paths.ts +37 -0
  167. package/src/review-diff-files.ts +89 -0
  168. package/src/review-head-checkout.ts +300 -0
  169. package/src/review-home-paths.ts +73 -0
  170. package/src/review-info.ts +59 -0
  171. package/src/review-instances.ts +113 -0
  172. package/src/review-logger.ts +183 -0
  173. package/src/review-preferences.ts +88 -0
  174. package/src/review-prepare.ts +291 -0
  175. package/src/review-stack.ts +112 -0
  176. package/src/review-telemetry.ts +1066 -0
  177. package/src/runtime.ts +74 -0
  178. package/src/server/account-alias.ts +30 -0
  179. package/src/server/bounded-stream.ts +34 -0
  180. package/src/server/bug-report.ts +301 -0
  181. package/src/server/client-error-budget.ts +45 -0
  182. package/src/server/crash-report.ts +273 -0
  183. package/src/server/desktop-host-shutdown.ts +64 -0
  184. package/src/server/desktop-host.ts +190 -0
  185. package/src/server/desktop-server.ts +703 -0
  186. package/src/server/diffr-config.ts +509 -0
  187. package/src/server/diffr-languages.ts +97 -0
  188. package/src/server/global-verb-relay.ts +190 -0
  189. package/src/server/headless-host.ts +149 -0
  190. package/src/server/hono-http.ts +162 -0
  191. package/src/server/http-json.ts +24 -0
  192. package/src/server/json-review-reporting.ts +174 -0
  193. package/src/server/process-error-telemetry.ts +174 -0
  194. package/src/server/review-api-parsers.ts +90 -0
  195. package/src/server/review-lifecycle-telemetry.ts +73 -0
  196. package/src/server/review-open-watchdog.ts +46 -0
  197. package/src/server/review-server-core.ts +290 -0
  198. package/src/server/structural-comparisons.ts +153 -0
  199. package/src/server/structural-diff.ts +186 -0
  200. package/src/server/tutorial-service.ts +241 -0
  201. package/src/server/ui-telemetry.ts +198 -0
  202. package/src/server-discovery.ts +95 -0
  203. package/src/session-markers.ts +132 -0
  204. package/src/sharing/auth.ts +41 -0
  205. package/src/sharing/cli.ts +83 -0
  206. package/src/sharing/client.ts +334 -0
  207. package/src/sharing/export.ts +195 -0
  208. package/src/sharing/host.ts +316 -0
  209. package/src/sharing/import.ts +787 -0
  210. package/src/sharing/index.ts +16 -0
  211. package/src/sharing/repository.ts +145 -0
  212. package/src/sharing/routes.ts +34 -0
  213. package/src/slug.ts +23 -0
  214. package/src/software-map-diff-counts.ts +517 -0
  215. package/src/software-map-model.ts +1147 -0
  216. package/src/software-map-topology-diff.ts +260 -0
  217. package/src/source.ts +92 -0
  218. package/src/startup-trace.ts +232 -0
  219. package/src/stored-document-migration.ts +151 -0
  220. package/src/telemetry-clean-text.ts +257 -0
  221. package/src/telemetry-config.ts +298 -0
  222. package/src/telemetry-debug-sink.ts +38 -0
  223. package/src/telemetry.ts +13 -0
  224. package/src/trace-cli.ts +156 -0
  225. package/src/trace-storage-cli.ts +509 -0
  226. package/src/tutorial-conversation.ts +18 -0
  227. package/src/ui-telemetry-events.ts +765 -0
  228. package/src/unified-diff.ts +71 -0
  229. package/src/viewed-coverage.ts +259 -0
  230. package/src/windows-cli.ts +125 -0
  231. package/tutorial/document.json +275 -0
  232. package/tutorial/runtime-manifest.json +12 -0
  233. package/tutorial/sample-service/package.json +9 -0
  234. package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
  235. package/tutorial/sample-service/src/app.ts +26 -0
  236. package/tutorial/sample-service/src/database/schema.ts +10 -0
  237. package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
  238. package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
  239. package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
  240. package/tutorial/sample-service/src/orders/order-service.ts +34 -0
  241. package/tutorial/sample-service/src/orders/order.ts +21 -0
  242. package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
  243. package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
  244. package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
  245. package/tutorial/sample-service/tsconfig.json +12 -0
  246. package/tutorial/software-map.json +139 -0
  247. package/tutorial/trace.json +20 -0
@@ -0,0 +1,1837 @@
1
+ import { execFile } from "node:child_process";
2
+ import { type FSWatcher, existsSync, watch } from "node:fs";
3
+ import { mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ import {
8
+ type BlobBatchReader,
9
+ type LocalVcs,
10
+ type LocalVcsCommitSummary,
11
+ type LocalVcsDiffFileSummary,
12
+ type LocalVcsKind,
13
+ LocalVcsToolsMissingError,
14
+ createBlobBatchReader,
15
+ detectLocalVcs,
16
+ diffFileSummariesTrees,
17
+ diffFileSummariesWorkingTree,
18
+ diffTrees,
19
+ diffWorkingTree,
20
+ gitCommonDir,
21
+ listCommitRange,
22
+ listTrackedFilesAtCommit,
23
+ readFileAtCommit,
24
+ resolveRepoContext,
25
+ } from "@dev.fast/local-vcs";
26
+ import { structuralChangeCounts } from "@dev.fast/review-protocol";
27
+ import type {
28
+ ReviewLanguageEnvironment,
29
+ ReviewSourceEntry,
30
+ StructuralDiffEvent,
31
+ } from "@dev.fast/review-protocol";
32
+ import { writePrivateJsonAtomic } from "@dev.fast/trace-core";
33
+ import { textIncludesQuote } from "@review/evidence.js";
34
+ import { isMissingFileError } from "@review/fs-utils.js";
35
+ import { reviewManagedCheckoutRoot } from "@review/review-checkout-paths.js";
36
+ import { ensureReviewPinnedCheckout } from "@review/review-head-checkout.js";
37
+ import { StructuralComparisons } from "@review/server/structural-comparisons.js";
38
+ import { resolveSoftwareMapDiffCounts } from "@review/software-map-diff-counts.js";
39
+ import {
40
+ type NormalizedSoftwareModel,
41
+ SoftwareModelValidationError,
42
+ defineSoftwareMap,
43
+ } from "@review/software-map-model.js";
44
+ import {
45
+ SourceRangeError,
46
+ checkSourcePath,
47
+ requireVisibleSource,
48
+ sliceSourceRange,
49
+ } from "@review/source.js";
50
+ import { z } from "zod";
51
+
52
+ import { checkoutFs } from "./checkout-fs.js";
53
+ import {
54
+ type ComparisonCoverage,
55
+ type CoverageMode,
56
+ comparisonCoverage,
57
+ } from "./comparison-coverage.js";
58
+ import { resourceReference } from "./document.js";
59
+ import {
60
+ type Block,
61
+ type FileLineRange,
62
+ type Pins,
63
+ ReviewInputError,
64
+ type ReviewTarget,
65
+ type SourcePins,
66
+ anchorPins,
67
+ elements,
68
+ explicitPins,
69
+ fileLineRangeSchema,
70
+ pinsSchema,
71
+ sourceReferences,
72
+ } from "./document.js";
73
+ import { decodeImage } from "./image-decode.js";
74
+ import { mapInputSchema } from "./map-input.js";
75
+ import {
76
+ type PullRequestDeps,
77
+ defaultPullRequestDeps,
78
+ fetchPullRequest,
79
+ githubRemotes,
80
+ pullRequestAddress,
81
+ readPullRequest,
82
+ } from "./pull-request.js";
83
+ import {
84
+ type ResolvedPullRequest,
85
+ ReviewStore,
86
+ type Snapshot,
87
+ } from "./store.js";
88
+ import { traceSchema } from "./trace-schema.js";
89
+ import { ReviewWorkspaces } from "./workspaces.js";
90
+ import {
91
+ EMPTY_SOURCE,
92
+ inspectWorktree,
93
+ localSourcePath,
94
+ readWorkingFile,
95
+ untrackedFileCount,
96
+ workingFiles,
97
+ } from "./worktree-source.js";
98
+
99
+ export const uploadSchema = z.discriminatedUnion("kind", [
100
+ z.strictObject({
101
+ id: z.uuid(),
102
+ repositoryId: z.string(),
103
+ kind: z.literal("image"),
104
+ base64: z.string(),
105
+ }),
106
+ z.strictObject({
107
+ id: z.uuid(),
108
+ repositoryId: z.string(),
109
+ kind: z.literal("trace"),
110
+ trace: traceSchema,
111
+ }),
112
+ z.strictObject({
113
+ id: z.uuid(),
114
+ repositoryId: z.string(),
115
+ kind: z.literal("map"),
116
+ pins: pinsSchema,
117
+ side: z.enum(["base", "head"]),
118
+ model: mapInputSchema,
119
+ }),
120
+ ]);
121
+
122
+ const unavailableCheckout = () =>
123
+ new ReviewInputError("The selected local checkout is unavailable.", 404);
124
+
125
+ /** File reads cannot name the root or a directory; tree reads can. */
126
+ function checkRelativePath(file: string) {
127
+ inputError(() => checkSourcePath(file));
128
+
129
+ if (file === "" || file.endsWith("/"))
130
+ throw new ReviewInputError(
131
+ "Source file must be a repository-relative path.",
132
+ );
133
+ }
134
+
135
+ function sliceRange(
136
+ file: { commit: string; text: string },
137
+ source: FileLineRange,
138
+ ) {
139
+ return {
140
+ ...source,
141
+ commit: file.commit,
142
+ text: inputError(() => sliceSourceRange(file.text, source)),
143
+ };
144
+ }
145
+
146
+ interface RepositoryVcs {
147
+ detection: Promise<LocalVcs | null>;
148
+ vcs?: LocalVcs;
149
+ }
150
+
151
+ /** Local source/resource boundary, including Desktop-only local language context. */
152
+ export class LocalReviewData {
153
+ private readonly workspaceManager?: ReviewWorkspaces;
154
+
155
+ get workspaces(): ReviewWorkspaces {
156
+ if (!this.workspaceManager)
157
+ throw new ReviewInputError(
158
+ "Open this review in Desktop to prepare language workspaces.",
159
+ 409,
160
+ );
161
+
162
+ return this.workspaceManager;
163
+ }
164
+
165
+ currentEnvironmentIssues(snapshot: Snapshot) {
166
+ if (snapshot.target?.kind !== "commits" || !snapshot.pins) return [];
167
+ const pins = snapshot.pins;
168
+
169
+ return this.workspaces.list(snapshot.reviewId).flatMap((environment) => {
170
+ const side =
171
+ environment.commit === pins.head
172
+ ? "head"
173
+ : environment.commit === pins.base
174
+ ? "base"
175
+ : undefined;
176
+
177
+ return side && environment.issue
178
+ ? [{ side, message: environment.issue }]
179
+ : [];
180
+ });
181
+ }
182
+
183
+ async environmentIssues(snapshot: Snapshot, retryFailed = false) {
184
+ const issues: { side: "base" | "head"; message: string }[] = [];
185
+
186
+ if (!snapshot.pins) return issues;
187
+
188
+ const sides: ("base" | "head")[] =
189
+ snapshot.target?.kind === "commits" &&
190
+ snapshot.pins.base !== snapshot.pins.head
191
+ ? ["head", "base"]
192
+ : ["head"];
193
+
194
+ for (const side of sides) {
195
+ const context = await this.languageEnvironment(
196
+ snapshot,
197
+ side,
198
+ undefined,
199
+ retryFailed,
200
+ );
201
+
202
+ if (context.issue) issues.push({ side, message: context.issue });
203
+ }
204
+
205
+ return issues;
206
+ }
207
+
208
+ private readonly untrackedCounts = new Map<
209
+ string,
210
+ Promise<number | undefined>
211
+ >();
212
+
213
+ /**
214
+ * Untracked files a live worktree review leaves out; undefined otherwise.
215
+ * Counted once per worktree revision, never by the revision poll, so a
216
+ * checkout with many untracked files pays for the walk only when read.
217
+ */
218
+ untrackedFiles(pins: Pins): Promise<number | undefined> {
219
+ if (!pins.worktreeRevision) return Promise.resolve(undefined);
220
+ const key = `${pins.repositoryId}\0${pins.worktreeRevision}`;
221
+ let count = this.untrackedCounts.get(key);
222
+
223
+ if (!count) {
224
+ count = this.vcs(pins.repositoryId).then((vcs) =>
225
+ vcs ? untrackedFileCount(vcs) : undefined,
226
+ );
227
+ this.untrackedCounts.set(key, count);
228
+
229
+ for (const cached of this.untrackedCounts.keys()) {
230
+ if (this.untrackedCounts.size <= 32) break;
231
+ this.untrackedCounts.delete(cached);
232
+ }
233
+ }
234
+
235
+ return count;
236
+ }
237
+
238
+ /** Local checkout context for live worktree targets only. */
239
+ /** Desktop language services borrow the registered checkout, never create one. */
240
+ async liveFile(repositoryId: string, file: string, text: string) {
241
+ try {
242
+ const rootPath = await realpath(this.store.repositoryPath(repositoryId));
243
+ const localPath = await localSourcePath(rootPath, file);
244
+
245
+ if ((await checkoutFs.readFile(localPath, "utf8")) === text)
246
+ return { localPath, localRoot: rootPath };
247
+ } catch {
248
+ /* A moved or changed file can still be displayed without native LSP. */
249
+ }
250
+
251
+ return undefined;
252
+ }
253
+ /** Source bytes and the language workspace have independent lifetimes. */
254
+ async languageEnvironment(
255
+ snapshot: Snapshot,
256
+ side: "base" | "head",
257
+ commit?: string,
258
+ retryFailed = false,
259
+ anchor?: SourcePins,
260
+ ): Promise<ReviewLanguageEnvironment> {
261
+ // A reference with its own pins borrows that repository's registered
262
+ // checkout, as live worktree targets do; no pinned checkout is prepared.
263
+ if (!anchor && snapshot.target?.kind === "commits" && snapshot.pins) {
264
+ const pins = await this.comparison(snapshot.pins, commit);
265
+
266
+ const environment = await this.workspaces.source(
267
+ snapshot.reviewId,
268
+ pins,
269
+ side,
270
+ retryFailed,
271
+ );
272
+
273
+ return {
274
+ rootPath:
275
+ environment.state === "preparing" || environment.state === "pending"
276
+ ? null
277
+ : environment.rootPath,
278
+ identity: environment.generation,
279
+ issue: environment.issue,
280
+ };
281
+ }
282
+
283
+ // Validate selected commits for both target kinds, but never prepare a live checkout.
284
+ if (commit && !anchor)
285
+ await this.comparison(await this.documentPins(snapshot), commit);
286
+
287
+ const repositoryId = anchor?.repositoryId ?? snapshot.pins?.repositoryId;
288
+
289
+ if (!repositoryId)
290
+ throw new ReviewInputError(
291
+ "This document has no source pins of its own.",
292
+ 409,
293
+ );
294
+
295
+ const unavailable: ReviewLanguageEnvironment = {
296
+ rootPath: null,
297
+ identity: `${repositoryId}:unavailable`,
298
+ issue:
299
+ "Could not access the registered language checkout. Check the repository path and permissions, then retry.",
300
+ };
301
+
302
+ let rootPath: string;
303
+
304
+ try {
305
+ rootPath = await realpath(this.store.repositoryPath(repositoryId));
306
+ } catch (error) {
307
+ if (error instanceof ReviewInputError && error.status !== 404)
308
+ throw error;
309
+
310
+ return unavailable;
311
+ }
312
+
313
+ if (!rootPath || !(await this.vcs(repositoryId))) return unavailable;
314
+ const info = await stat(rootPath, { bigint: true }).catch(() => null);
315
+
316
+ return info
317
+ ? {
318
+ rootPath,
319
+ identity: `${repositoryId}:${rootPath}:${info.dev}:${info.ino}:${info.birthtimeNs}`,
320
+ }
321
+ : unavailable;
322
+ }
323
+
324
+ /** Resolve source coordinates after selecting a local or imported snapshot.
325
+ * `anchor` reads at a reference's own pins instead of the document's;
326
+ * `commit` narrows the document comparison to one of its commits. */
327
+ async resolveSource(
328
+ snapshot: Snapshot,
329
+ commit?: string,
330
+ anchor?: SourcePins,
331
+ ) {
332
+ const pins = anchor
333
+ ? await this.anchorSourcePins(anchor)
334
+ : await this.comparison(await this.documentPins(snapshot), commit);
335
+
336
+ return { snapshot, pins };
337
+ }
338
+
339
+ /** Where an Ask agent reads source: the Review's own head checkout, or the
340
+ * registered checkout for a live worktree target, as the navigator does. */
341
+ async agentCheckout(snapshot: Snapshot) {
342
+ const { pins } = await this.resolveSource(snapshot);
343
+
344
+ const rootPath = pins.worktreeRevision
345
+ ? await realpath(this.store.repositoryPath(pins.repositoryId))
346
+ : (await this.workspaces.source(snapshot.reviewId, pins, "head"))
347
+ .rootPath;
348
+
349
+ if (!rootPath)
350
+ throw new ReviewInputError(
351
+ "The head checkout for this review is not ready yet.",
352
+ 409,
353
+ );
354
+
355
+ return { rootPath, head: pins.head, live: !!pins.worktreeRevision };
356
+ }
357
+
358
+ /** Resolve a native workspace without replacing the selected source with today's HEAD. */
359
+ async navigatorWorkspace(
360
+ snapshot: Snapshot,
361
+ source: {
362
+ side?: "base" | "head";
363
+ file?: string;
364
+ empty?: boolean;
365
+ commit?: string;
366
+ anchor?: SourcePins;
367
+ } = {},
368
+ ): Promise<{ workspacePath: string; filePath?: string }> {
369
+ const { pins } = await this.resolveSource(
370
+ snapshot,
371
+ source.commit,
372
+ source.anchor,
373
+ );
374
+
375
+ const repository = this.store.repositoryPath(pins.repositoryId);
376
+ const side = source.side ?? "head";
377
+
378
+ const live =
379
+ !!pins.worktreeRevision &&
380
+ (side === "head" || (source.empty && pins[side] === EMPTY_SOURCE));
381
+
382
+ const checkoutSide =
383
+ pins[side] === EMPTY_SOURCE && source.empty ? "head" : side;
384
+
385
+ const ref = pins[checkoutSide];
386
+
387
+ if (source.file) checkRelativePath(source.file);
388
+
389
+ // Browse the Review's own base/head checkout. The window opens while
390
+ // preparation may still be installing dependencies beside the source.
391
+ const rootPath = live
392
+ ? await realpath(repository)
393
+ : (await this.workspaces.source(snapshot.reviewId, pins, checkoutSide))
394
+ .rootPath;
395
+
396
+ const context = await resolveRepoContext(repository);
397
+
398
+ if (!rootPath || !context)
399
+ throw new ReviewInputError(
400
+ "Could not open the selected source checkout.",
401
+ 409,
402
+ );
403
+
404
+ if (!live) {
405
+ const { stdout } = await promisify(execFile)("git", [
406
+ "-C",
407
+ rootPath,
408
+ "status",
409
+ "--porcelain",
410
+ "--untracked-files=no",
411
+ ]);
412
+
413
+ if (stdout.trim())
414
+ throw new ReviewInputError(
415
+ "The pinned checkout has local changes, possibly from devfast.prepare. Restore those files before browsing this pinned revision.",
416
+ 409,
417
+ );
418
+ }
419
+
420
+ // Name the workspace after the repository, not the registered checkout:
421
+ // a linked worktree's directory is an arbitrary branch slug. This matches
422
+ // the repository label on Home.
423
+ const name =
424
+ context.githubSlug?.split("/").at(-1) ??
425
+ path.basename(path.dirname(context.commonDir));
426
+
427
+ const workspaceDirectory = path.join(
428
+ reviewManagedCheckoutRoot(context.commonDir, snapshot.reviewId),
429
+ "navigator",
430
+ "workspaces",
431
+ live ? "worktree" : ref,
432
+ );
433
+
434
+ // VS Code labels a saved workspace by its file name and identifies its
435
+ // window by the file's path, so the file name is the repository name.
436
+ const workspacePath = path.join(
437
+ workspaceDirectory,
438
+ `${name}.code-workspace`,
439
+ );
440
+
441
+ // A native workspace gives VS Code stable restoration, search scope and
442
+ // editor read-only behavior without changing files in the source checkout.
443
+ // Keep the preferences VS Code and the user add, carrying them over from a
444
+ // workspace previously named after the checkout directory. That file stays
445
+ // in place for any window still open on it.
446
+ const current = await readWorkspace(workspacePath);
447
+
448
+ if (current !== null) {
449
+ const previous =
450
+ current ??
451
+ (await readWorkspace(
452
+ path.join(
453
+ workspaceDirectory,
454
+ `${path.basename(repository)}.code-workspace`,
455
+ ),
456
+ ));
457
+
458
+ const title = `${snapshot.title} — ${live ? "Live source" : side === "base" ? "Base source" : "Source"} — Whiteboard`;
459
+
460
+ const workspace = previous ?? {
461
+ folders: [],
462
+ settings: { "files.readonlyInclude": { "**/*": true } },
463
+ };
464
+
465
+ const next = {
466
+ ...workspace,
467
+ folders: [{ path: rootPath, name }, ...workspace.folders.slice(1)],
468
+ settings: { ...workspace.settings, "window.title": title },
469
+ };
470
+
471
+ if (JSON.stringify(next) !== JSON.stringify(current))
472
+ await writePrivateJsonAtomic(workspacePath, next);
473
+ }
474
+
475
+ let filePath: string | undefined;
476
+
477
+ if (source.file) {
478
+ if (source.empty) {
479
+ // Native diffs need a real empty file for an added/deleted side.
480
+ filePath = path.join(
481
+ path.dirname(workspacePath),
482
+ "empty",
483
+ path.basename(source.file),
484
+ );
485
+ await mkdir(path.dirname(filePath), { recursive: true });
486
+ await writeFile(filePath, "", { mode: 0o600 });
487
+ } else {
488
+ try {
489
+ filePath = await localSourcePath(rootPath, source.file);
490
+ } catch (error) {
491
+ if (isMissingFileError(error))
492
+ throw new ReviewInputError(
493
+ "File is unavailable at the selected revision.",
494
+ 404,
495
+ );
496
+ throw error;
497
+ }
498
+ }
499
+ }
500
+
501
+ return { workspacePath, filePath };
502
+ }
503
+
504
+ /** A document read that needs default pins; 409 when the document has none. */
505
+ async documentPins(snapshot: Snapshot): Promise<Pins> {
506
+ const pins = await this.sourcePins(snapshot);
507
+
508
+ if (!pins)
509
+ throw new ReviewInputError(
510
+ "This document has no source pins of its own.",
511
+ 409,
512
+ );
513
+
514
+ return pins;
515
+ }
516
+
517
+ /** Pins a reference names itself, checked against the registered checkout. */
518
+ async anchorSourcePins(anchor: SourcePins): Promise<Pins> {
519
+ if (!existsSync(this.store.repositoryPath(anchor.repositoryId)))
520
+ throw unavailableCheckout();
521
+
522
+ return anchorPins({ pins: anchor }, undefined);
523
+ }
524
+
525
+ /** References whose own repository is unregistered or gone from disk. */
526
+ async unavailableAnchors(snapshot: Snapshot): Promise<string[]> {
527
+ const missing = new Set<string>();
528
+
529
+ for (const pins of explicitPins(
530
+ sourceReferences(snapshot.document, { tolerant: true }),
531
+ ))
532
+ try {
533
+ await this.anchorSourcePins(pins);
534
+ } catch (error) {
535
+ if (!(error instanceof ReviewInputError)) throw error;
536
+ missing.add(pins.repositoryId);
537
+ }
538
+
539
+ if (!missing.size) return [];
540
+
541
+ return sourceReferences(snapshot.document, { tolerant: true })
542
+ .filter(
543
+ (ref) => ref.source.pins && missing.has(ref.source.pins.repositoryId),
544
+ )
545
+ .map((ref) => ref.id);
546
+ }
547
+
548
+ // A commit's tree never changes, so one listing serves every folder expansion.
549
+ private readonly trackedFiles = new Map<string, Promise<string[]>>();
550
+
551
+ private readonly repositories = new Map<string, RepositoryVcs>();
552
+
553
+ private readonly readers = new Map<string, BlobBatchReader>();
554
+
555
+ private readonly commitRanges = new Map<
556
+ string,
557
+ Promise<LocalVcsCommitSummary[]>
558
+ >();
559
+
560
+ constructor(
561
+ private readonly store: ReviewStore,
562
+ private readonly options: {
563
+ blobReaderIdleTimeoutMs?: number;
564
+ workspaceDatabase?: string;
565
+ manageWorkspaces?: boolean;
566
+ watch?: typeof watch;
567
+ /** gh, git and GitHub API access for pull request targets. */
568
+ pullRequests?: PullRequestDeps;
569
+ } = {},
570
+ ) {
571
+ if (options.manageWorkspaces !== false)
572
+ this.workspaceManager = new ReviewWorkspaces(
573
+ options.workspaceDatabase ?? ":memory:",
574
+ store,
575
+ );
576
+ }
577
+
578
+ async *structuralChanges({
579
+ reviewId,
580
+ pins,
581
+ signal,
582
+ file,
583
+ }: {
584
+ reviewId: string;
585
+ pins: Pins;
586
+ signal: AbortSignal;
587
+ file?: string;
588
+ }): AsyncGenerator<StructuralDiffEvent> {
589
+ if (file !== undefined) checkRelativePath(file);
590
+
591
+ if (pins.worktreeRevision) {
592
+ const { rootPath, baseRef } = await this.worktreeInput(pins);
593
+ yield* this.structuralComparisons.stream({
594
+ repositoryPath: rootPath,
595
+ comparison: {
596
+ kind: "worktree",
597
+ // An unborn checkout compares with Git's empty tree.
598
+ base: baseRef ?? "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
599
+ revision: pins.worktreeRevision,
600
+ },
601
+ paths: file === undefined ? undefined : [file],
602
+ signal,
603
+ });
604
+
605
+ return;
606
+ }
607
+
608
+ // Waiting covers a release already queued. One that starts mid-stream
609
+ // fails this stream, and the reader's next request rebuilds the checkout.
610
+ await this.workspaceManager?.released(reviewId);
611
+
612
+ const rootPath = await ensureReviewPinnedCheckout({
613
+ rootPath: this.store.repositoryPath(pins.repositoryId),
614
+ ref: pins.head,
615
+ reviewUuid: reviewId,
616
+ });
617
+
618
+ if (!rootPath)
619
+ throw new ReviewInputError(
620
+ "Cannot prepare the pinned repository for structural diffing.",
621
+ );
622
+ yield* this.structuralComparisons.stream({
623
+ repositoryPath: rootPath,
624
+ comparison: { kind: "trees", base: pins.base, head: pins.head },
625
+ paths: file === undefined ? undefined : [file],
626
+ signal,
627
+ });
628
+ }
629
+
630
+ private readonly structuralComparisons = new StructuralComparisons();
631
+ private closed = false;
632
+ private readonly worktrees = new Map<
633
+ string,
634
+ {
635
+ epoch: number;
636
+ inspectedEpoch: number;
637
+ watchers: FSWatcher[];
638
+ healthy: boolean;
639
+ inspection?: Awaited<ReturnType<typeof inspectWorktree>>;
640
+ /** Fork points by base ref, valid for the inspected epoch. */
641
+ forks: Map<string, Promise<{ ref: string; commit: string }>>;
642
+ }
643
+ >();
644
+
645
+ private forgetWorktree(repositoryId: string) {
646
+ const entry = this.worktrees.get(repositoryId);
647
+
648
+ if (entry) for (const watcher of entry.watchers) watcher.close();
649
+ this.worktrees.delete(repositoryId);
650
+ }
651
+
652
+ private async worktreeState(repositoryId: string, vcs: LocalVcs) {
653
+ let entry = this.worktrees.get(repositoryId);
654
+
655
+ if (!entry) {
656
+ entry = {
657
+ epoch: 0,
658
+ inspectedEpoch: -1,
659
+ watchers: [],
660
+ healthy: true,
661
+ forks: new Map(),
662
+ };
663
+ this.worktrees.set(repositoryId, entry);
664
+ const state = entry;
665
+ const roots = new Set([vcs.rootPath]);
666
+ const common = await gitCommonDir(vcs.rootPath);
667
+
668
+ if (common) roots.add(common);
669
+
670
+ for (const root of roots) {
671
+ try {
672
+ const watcher = (this.options.watch ?? watch)(
673
+ root,
674
+ { recursive: true },
675
+ () => {
676
+ state.epoch++;
677
+ },
678
+ );
679
+
680
+ watcher.on("error", () => {
681
+ state.epoch++;
682
+ state.inspectedEpoch = -1;
683
+ state.healthy = false;
684
+ watcher.close();
685
+ });
686
+ watcher.unref();
687
+ state.watchers.push(watcher);
688
+ } catch {
689
+ state.healthy = false;
690
+ }
691
+ }
692
+ }
693
+
694
+ if (
695
+ entry.inspection &&
696
+ entry.inspectedEpoch === entry.epoch &&
697
+ entry.healthy &&
698
+ entry.watchers.length
699
+ )
700
+ return { ...entry.inspection, forks: entry.forks };
701
+
702
+ const epoch = entry.epoch;
703
+ const inspected = await inspectWorktree(repositoryId, vcs);
704
+ entry.inspection = inspected;
705
+ entry.inspectedEpoch = epoch;
706
+ entry.forks = new Map();
707
+
708
+ return { ...inspected, forks: entry.forks };
709
+ }
710
+
711
+ async close(): Promise<void> {
712
+ this.coverageAbort.abort();
713
+ clearTimeout(this.coverageNotification);
714
+ this.coverageListeners.clear();
715
+ this.coverageCache.clear();
716
+ this.structuralComparisons.close();
717
+ this.closed = true;
718
+ await this.workspaceManager?.close();
719
+
720
+ for (const entry of this.worktrees.values())
721
+ for (const watcher of entry.watchers) watcher.close();
722
+ this.worktrees.clear();
723
+ const readers = [...this.readers.values()];
724
+
725
+ this.readers.clear();
726
+
727
+ await Promise.all(readers.map((reader) => reader.close()));
728
+ }
729
+
730
+ /** Detected once; dropped when the root vanishes or detection found nothing. */
731
+ private vcs(repositoryId: string): Promise<LocalVcs | null> {
732
+ const cached = this.repositories.get(repositoryId);
733
+
734
+ if (cached && (!cached.vcs || existsSync(cached.vcs.rootPath)))
735
+ return cached.detection;
736
+
737
+ this.closeReader(repositoryId);
738
+ this.forgetWorktree(repositoryId);
739
+ const rootPath = this.store.repositoryPath(repositoryId);
740
+
741
+ const forget = () => {
742
+ this.repositories.delete(repositoryId);
743
+ this.closeReader(repositoryId);
744
+ };
745
+
746
+ const entry: RepositoryVcs = {
747
+ detection: detectLocalVcs(rootPath).then(
748
+ (vcs) => {
749
+ if (vcs) entry.vcs = vcs;
750
+ else forget();
751
+
752
+ return vcs;
753
+ },
754
+ (cause: unknown) => {
755
+ forget();
756
+
757
+ throw cause;
758
+ },
759
+ ),
760
+ };
761
+
762
+ this.repositories.set(repositoryId, entry);
763
+
764
+ return entry.detection;
765
+ }
766
+
767
+ /** None once closed: a read suspended across close() gets its own process. */
768
+ private reader(
769
+ repositoryId: string,
770
+ vcs: LocalVcs,
771
+ ): BlobBatchReader | undefined {
772
+ if (this.closed) return undefined;
773
+ const existing = this.readers.get(repositoryId);
774
+
775
+ if (existing) return existing;
776
+
777
+ const reader = createBlobBatchReader({
778
+ rootPath: vcs.rootPath,
779
+ kind: vcs.kind,
780
+ idleTimeoutMs: this.options.blobReaderIdleTimeoutMs,
781
+ });
782
+
783
+ this.readers.set(repositoryId, reader);
784
+
785
+ return reader;
786
+ }
787
+
788
+ private closeReader(repositoryId: string): Promise<void> | undefined {
789
+ const reader = this.readers.get(repositoryId);
790
+
791
+ if (!reader) return;
792
+ this.readers.delete(repositoryId);
793
+
794
+ return reader.close();
795
+ }
796
+
797
+ async forgetRepository(repositoryId: string) {
798
+ await this.closeReader(repositoryId);
799
+ this.forgetWorktree(repositoryId);
800
+ this.repositories.delete(repositoryId);
801
+
802
+ for (const key of this.trackedFiles.keys())
803
+ if (key.startsWith(repositoryId + "\0")) this.trackedFiles.delete(key);
804
+
805
+ for (const key of this.commitRanges.keys())
806
+ if (key.startsWith(repositoryId + ":")) this.commitRanges.delete(key);
807
+ }
808
+
809
+ private async vcsTarget(
810
+ repositoryId: string,
811
+ ): Promise<{ rootPath: string; kind?: LocalVcsKind }> {
812
+ const vcs = await this.vcs(repositoryId);
813
+
814
+ if (vcs) return { rootPath: vcs.rootPath, kind: vcs.kind };
815
+ const rootPath = this.store.repositoryPath(repositoryId);
816
+
817
+ // local-vcs would report a missing root as a path-bearing 500.
818
+ if (!existsSync(rootPath)) throw unavailableCheckout();
819
+
820
+ return { rootPath };
821
+ }
822
+
823
+ async register(root: string) {
824
+ const resolved = await realpath(root).catch(() => {
825
+ throw new ReviewInputError(
826
+ "Repository path does not exist or is not readable.",
827
+ );
828
+ });
829
+
830
+ const vcs = await detectLocalVcs(resolved).catch((cause: unknown) => {
831
+ if (cause instanceof LocalVcsToolsMissingError)
832
+ throw new ReviewInputError(cause.message);
833
+
834
+ throw cause;
835
+ });
836
+
837
+ if (!vcs) throw new ReviewInputError("Choose a Git or jj repository.");
838
+
839
+ const repository = this.store.registerRepository(
840
+ await realpath(vcs.rootPath),
841
+ );
842
+
843
+ // Registration may follow replacement of a managed repository at the same
844
+ // path (for example resetting the tutorial). Reopen its Git reader too.
845
+ await this.closeReader(repository.id);
846
+ this.repositories.delete(repository.id);
847
+
848
+ return repository;
849
+ }
850
+ async projectSource(snapshot: Snapshot, pins: Pins): Promise<Snapshot> {
851
+ const projected: Snapshot = { ...snapshot, pins, staleSources: [] };
852
+ delete projected.sourceUnavailable;
853
+
854
+ // Live references retain their authored coordinates. Only diagnose ranges
855
+ // that no longer exist; the author decides how to update changed source.
856
+ for (const reference of sourceReferences(snapshot.document, {
857
+ tolerant: true,
858
+ })) {
859
+ try {
860
+ await this.quote(anchorPins(reference.source, pins), reference.source);
861
+ } catch (error) {
862
+ if (!(error instanceof ReviewInputError)) throw error;
863
+ projected.staleSources!.push(reference.id);
864
+ }
865
+ }
866
+
867
+ return projected;
868
+ }
869
+ /** Throws 404 when the checkout behind the snapshot is gone. Undefined
870
+ * for a document without default pins. */
871
+ async sourcePins(snapshot: Snapshot): Promise<Pins | undefined> {
872
+ if (snapshot.target?.kind === "worktree")
873
+ return (await this.resolveTarget(snapshot.target, snapshot.pins)).pins;
874
+
875
+ if (!snapshot.pins) return undefined;
876
+
877
+ if (!existsSync(this.store.repositoryPath(snapshot.pins.repositoryId)))
878
+ throw unavailableCheckout();
879
+
880
+ return snapshot.pins;
881
+ }
882
+ /** Capture a label only when its ref still resolves to these exact pins. */
883
+ async headBranch(pins: Pins, headRef?: string): Promise<string | undefined> {
884
+ const vcs = await this.vcs(pins.repositoryId);
885
+
886
+ if (!vcs || vcs.kind !== "git") return undefined;
887
+
888
+ try {
889
+ const run = promisify(execFile);
890
+
891
+ const { stdout } = await run("git", [
892
+ "-C",
893
+ vcs.rootPath,
894
+ "rev-parse",
895
+ "--symbolic-full-name",
896
+ "--verify",
897
+ "--end-of-options",
898
+ !headRef || /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(headRef)
899
+ ? "HEAD"
900
+ : headRef,
901
+ ]);
902
+
903
+ const ref = stdout.trim();
904
+
905
+ if (!ref.startsWith("refs/heads/") && !ref.startsWith("refs/remotes/"))
906
+ return undefined;
907
+
908
+ const resolved = await vcs.resolveRevision(ref);
909
+
910
+ if (resolved?.commit !== pins.head) return undefined;
911
+
912
+ return ref.replace(/^refs\/(heads|remotes)\//, "");
913
+ } catch {
914
+ // Optional provenance must not block authoring in a detached checkout.
915
+ return undefined;
916
+ }
917
+ }
918
+
919
+ /** `pinned` refreshes a stored target: a worktree base ref resolves to its
920
+ * current fork point, and keeps the pinned one if the ref is gone. */
921
+ async resolveTarget(
922
+ target: ReviewTarget,
923
+ pinned?: Pins,
924
+ ): Promise<{ target: ReviewTarget; pins: Pins }> {
925
+ const vcs = await this.vcs(target.repositoryId);
926
+
927
+ if (!vcs) throw unavailableCheckout();
928
+
929
+ if (target.kind === "commits") {
930
+ const head = await vcs.resolveRevision(target.head);
931
+
932
+ if (!head) throw new ReviewInputError("Head revision does not exist.");
933
+
934
+ const base =
935
+ target.base === undefined
936
+ ? head
937
+ : await vcs.resolveRevision(target.base);
938
+
939
+ if (!base) throw new ReviewInputError("Base revision does not exist.");
940
+
941
+ const resolved = { ...target, head: head.commit };
942
+
943
+ if (target.base !== undefined) resolved.base = base.commit;
944
+
945
+ return {
946
+ target: resolved,
947
+ pins: {
948
+ repositoryId: target.repositoryId,
949
+ base: base.commit,
950
+ head: head.commit,
951
+ },
952
+ };
953
+ }
954
+
955
+ const { revision, commit, forks } = await this.worktreeState(
956
+ target.repositoryId,
957
+ vcs,
958
+ );
959
+
960
+ const resolved = { ...target };
961
+
962
+ const fork = () => {
963
+ const key = `${target.base ?? ""}\0${commit}`;
964
+ let found = forks.get(key);
965
+
966
+ if (!found) {
967
+ found = this.worktreeBase(vcs, target.base, commit);
968
+ forks.set(key, found);
969
+ // Missing refs stay cached until the next epoch; retry other errors.
970
+ found.catch((error) => {
971
+ if (!(error instanceof ReviewInputError)) forks.delete(key);
972
+ });
973
+ }
974
+
975
+ return found;
976
+ };
977
+
978
+ // Stored targets without a base predate default-branch bases and compare
979
+ // against the current HEAD, or with nothing when created unborn.
980
+ const base =
981
+ pinned && target.base === undefined
982
+ ? pinned.base === EMPTY_SOURCE
983
+ ? EMPTY_SOURCE
984
+ : commit
985
+ : commit === EMPTY_SOURCE && target.base === undefined
986
+ ? EMPTY_SOURCE
987
+ : await fork().then(
988
+ (found) => {
989
+ resolved.base = found.ref;
990
+
991
+ return found.commit;
992
+ },
993
+ (error) => {
994
+ if (pinned && error instanceof ReviewInputError)
995
+ return pinned.base;
996
+ throw error;
997
+ },
998
+ );
999
+
1000
+ return {
1001
+ target: resolved,
1002
+ pins: {
1003
+ repositoryId: target.repositoryId,
1004
+ base,
1005
+ head: commit,
1006
+ worktreeRevision: revision,
1007
+ },
1008
+ };
1009
+ }
1010
+ /** The fork point from `ref`, by default the repository's default branch. */
1011
+ private async worktreeBase(
1012
+ vcs: LocalVcs,
1013
+ ref: string | undefined,
1014
+ head: string,
1015
+ ) {
1016
+ const branch = ref ?? (await vcs.defaultBranch())?.ref;
1017
+
1018
+ if (!branch)
1019
+ throw new ReviewInputError(
1020
+ "No default branch found (tried origin/HEAD, origin/main, origin/master, main and master). Supply target.base.",
1021
+ );
1022
+
1023
+ if (!(await vcs.resolveRevision(branch)))
1024
+ throw new ReviewInputError("Base revision does not exist.");
1025
+
1026
+ const fork =
1027
+ head === EMPTY_SOURCE ? null : await vcs.mergeBase(branch, head);
1028
+
1029
+ if (!fork)
1030
+ throw new ReviewInputError(
1031
+ `${branch} shares no history with the checkout's HEAD.`,
1032
+ );
1033
+
1034
+ return { ref: branch, commit: fork.commit };
1035
+ }
1036
+ /** The PR's current comparison, fetched into a registered checkout of its repository. */
1037
+ async resolvePullRequest(
1038
+ url: string,
1039
+ repository: { id?: string; preferred?: string },
1040
+ ): Promise<ResolvedPullRequest> {
1041
+ const deps = this.options.pullRequests ?? defaultPullRequestDeps;
1042
+ const { host, slug, number } = pullRequestAddress(url);
1043
+
1044
+ // Only a host a registered checkout already fetches from is contacted.
1045
+ const checkout = await this.pullRequestCheckout(
1046
+ host,
1047
+ slug,
1048
+ repository,
1049
+ deps,
1050
+ );
1051
+
1052
+ const pullRequest = await readPullRequest(url, deps);
1053
+
1054
+ const { head, base } = await fetchPullRequest(
1055
+ { ...checkout, pullRequest },
1056
+ deps,
1057
+ );
1058
+
1059
+ const repositoryId = checkout.repositoryId;
1060
+
1061
+ return {
1062
+ target: { kind: "commits", repositoryId, head, base },
1063
+ pins: { repositoryId, base, head },
1064
+ title: pullRequest.title.trim() || `PR #${number}`,
1065
+ };
1066
+ }
1067
+ /** A registered checkout with a remote for host/owner/repo, and that remote. */
1068
+ private async pullRequestCheckout(
1069
+ host: string,
1070
+ slug: string,
1071
+ repository: { id?: string; preferred?: string },
1072
+ deps: PullRequestDeps,
1073
+ ) {
1074
+ const registered = this.store.repositories();
1075
+
1076
+ const candidates = repository.id
1077
+ ? registered.filter((entry) => entry.id === repository.id)
1078
+ : [
1079
+ ...registered.filter((entry) => entry.id === repository.preferred),
1080
+ ...registered.filter((entry) => entry.id !== repository.preferred),
1081
+ ];
1082
+
1083
+ if (repository.id && candidates.length === 0)
1084
+ throw new ReviewInputError("Repository is not registered.", 404);
1085
+
1086
+ for (const { id } of candidates) {
1087
+ if (!existsSync(this.store.repositoryPath(id))) continue;
1088
+ const vcs = await this.vcs(id);
1089
+ const gitDir = vcs && (await gitCommonDir(vcs.rootPath));
1090
+
1091
+ if (!vcs || !gitDir) continue;
1092
+
1093
+ const remote = (await githubRemotes(gitDir, deps)).find(
1094
+ (entry) =>
1095
+ entry.host === host &&
1096
+ entry.slug.toLowerCase() === slug.toLowerCase(),
1097
+ );
1098
+
1099
+ if (remote)
1100
+ return {
1101
+ repositoryId: id,
1102
+ rootPath: vcs.rootPath,
1103
+ gitDir,
1104
+ kind: vcs.kind,
1105
+ remote: remote.name,
1106
+ };
1107
+ }
1108
+
1109
+ const name = host === "github.com" ? slug : `${host}/${slug}`;
1110
+
1111
+ throw new ReviewInputError(
1112
+ repository.id
1113
+ ? `That checkout has no GitHub remote for ${name}. Add one, or omit repositoryId.`
1114
+ : `No registered checkout has a GitHub remote for ${name}. Register a checkout of ${name} with review_register_repository first, or pass a target.`,
1115
+ 404,
1116
+ );
1117
+ }
1118
+ async resolvePins(
1119
+ repositoryId: string,
1120
+ base: string,
1121
+ head: string,
1122
+ ): Promise<Pins> {
1123
+ const vcs = await this.vcs(repositoryId);
1124
+
1125
+ const [left, right] = vcs
1126
+ ? await Promise.all([
1127
+ vcs.resolveRevision(base),
1128
+ vcs.resolveRevision(head),
1129
+ ])
1130
+ : [null, null];
1131
+
1132
+ if (!left || !right)
1133
+ throw new ReviewInputError(
1134
+ `Base or head revision does not exist in the local checkout (${!left ? `base: ${base}` : `head: ${head}`}). Fetch the requested commits before authoring; in CI, configure checkout depth to include both revisions.`,
1135
+ );
1136
+
1137
+ return { repositoryId, base: left.commit, head: right.commit };
1138
+ }
1139
+ /** Files each side's commit changed from one set of pins to another, under
1140
+ * both names for a rename. A worktree has no commit to diff from. */
1141
+ async filesChangedBetween(from: Pins, to: Pins) {
1142
+ if (
1143
+ from.repositoryId !== to.repositoryId ||
1144
+ from.worktreeRevision ||
1145
+ to.worktreeRevision
1146
+ )
1147
+ return undefined;
1148
+
1149
+ const target = await this.vcsTarget(to.repositoryId);
1150
+
1151
+ const changed = async (side: "base" | "head") => {
1152
+ if (from[side] === to[side]) return new Set<string>();
1153
+
1154
+ const files = await diffFileSummariesTrees({
1155
+ ...target,
1156
+ baseRef: from[side],
1157
+ headRef: to[side],
1158
+ });
1159
+
1160
+ return new Set(
1161
+ files.flatMap((file) =>
1162
+ file.previousPath ? [file.previousPath, file.path] : [file.path],
1163
+ ),
1164
+ );
1165
+ };
1166
+
1167
+ const [base, head] = await Promise.all([changed("base"), changed("head")]);
1168
+
1169
+ return { base, head };
1170
+ }
1171
+ async validatePins(pins: Pins) {
1172
+ if (pins.worktreeRevision) {
1173
+ if (!(await this.vcs(pins.repositoryId))) throw unavailableCheckout();
1174
+
1175
+ return;
1176
+ }
1177
+
1178
+ const resolved = await this.resolvePins(
1179
+ pins.repositoryId,
1180
+ pins.base,
1181
+ pins.head,
1182
+ );
1183
+
1184
+ if (resolved.base !== pins.base || resolved.head !== pins.head)
1185
+ throw new ReviewInputError(
1186
+ "Use resolved commit IDs, not moving branch names.",
1187
+ );
1188
+ }
1189
+ async file(
1190
+ pins: Pins,
1191
+ side: "base" | "head",
1192
+ file: string,
1193
+ allowBinary = false,
1194
+ ) {
1195
+ checkRelativePath(file);
1196
+ const commit = pins[side];
1197
+ const vcs = await this.vcs(pins.repositoryId);
1198
+
1199
+ const text =
1200
+ pins.worktreeRevision && side === "head"
1201
+ ? vcs
1202
+ ? await readWorkingFile(vcs.rootPath, file)
1203
+ : null
1204
+ : commit === EMPTY_SOURCE
1205
+ ? null
1206
+ : vcs
1207
+ ? await readFileAtCommit({
1208
+ rootPath: vcs.rootPath,
1209
+ kind: vcs.kind,
1210
+ commit,
1211
+ relativePath: file,
1212
+ reader: this.reader(pins.repositoryId, vcs),
1213
+ })
1214
+ : null;
1215
+
1216
+ if (text === null)
1217
+ throw new ReviewInputError(
1218
+ "File is unavailable at the pinned commit.",
1219
+ 404,
1220
+ );
1221
+
1222
+ if (!allowBinary && text.includes("\0"))
1223
+ throw new ReviewInputError(
1224
+ "Binary files cannot be used as code references.",
1225
+ );
1226
+
1227
+ return { file, side, commit, text };
1228
+ }
1229
+
1230
+ async tree(
1231
+ pins: Pins,
1232
+ side: "base" | "head",
1233
+ directory: string,
1234
+ ): Promise<ReviewSourceEntry[]> {
1235
+ inputError(() => checkSourcePath(directory));
1236
+ const prefix = directory ? directory.replace(/\/$/, "") + "/" : "";
1237
+ const entries = new Map<string, ReviewSourceEntry>();
1238
+
1239
+ const vcs = pins.worktreeRevision
1240
+ ? await this.vcs(pins.repositoryId)
1241
+ : undefined;
1242
+
1243
+ if (pins.worktreeRevision && !vcs) throw unavailableCheckout();
1244
+
1245
+ const files =
1246
+ vcs && side === "head"
1247
+ ? await workingFiles(vcs)
1248
+ : pins[side] === EMPTY_SOURCE
1249
+ ? []
1250
+ : await this.trackedFilesAt(pins.repositoryId, pins[side]);
1251
+
1252
+ for (const file of files) {
1253
+ if (!file.startsWith(prefix)) continue;
1254
+ const relative = file.slice(prefix.length);
1255
+ const name = relative.split("/", 1)[0]!;
1256
+ entries.set(name, {
1257
+ path: prefix + name,
1258
+ kind: relative.includes("/") ? "directory" : "file",
1259
+ });
1260
+ }
1261
+
1262
+ if (directory && entries.size === 0)
1263
+ throw new ReviewInputError(
1264
+ "Directory is unavailable at the pinned commit.",
1265
+ 404,
1266
+ );
1267
+
1268
+ return [...entries.values()];
1269
+ }
1270
+ private trackedFilesAt(repositoryId: string, ref: string) {
1271
+ const key = `${repositoryId}\0${ref}`;
1272
+ let files = this.trackedFiles.get(key);
1273
+
1274
+ if (!files) {
1275
+ files = this.vcs(repositoryId).then((vcs) => {
1276
+ // Do not keep the empty listing of a missing repository.
1277
+ if (!vcs) {
1278
+ this.trackedFiles.delete(key);
1279
+
1280
+ return [];
1281
+ }
1282
+
1283
+ return listTrackedFilesAtCommit({
1284
+ rootPath: vcs.rootPath,
1285
+ kind: vcs.kind,
1286
+ commit: ref,
1287
+ });
1288
+ });
1289
+ this.trackedFiles.set(key, files);
1290
+ files.catch(() => this.trackedFiles.delete(key));
1291
+ }
1292
+
1293
+ return files;
1294
+ }
1295
+ async validateSources(pins: Pins, sources: FileLineRange[]) {
1296
+ const files = new Map<string, FileLineRange[]>();
1297
+
1298
+ for (const input of sources) {
1299
+ const source = fileLineRangeSchema.parse(input);
1300
+
1301
+ const key = JSON.stringify([
1302
+ pins[source.side],
1303
+ pins.worktreeRevision ? source.side : null,
1304
+ source.file,
1305
+ ]);
1306
+
1307
+ const ranges = files.get(key);
1308
+
1309
+ if (ranges) ranges.push(source);
1310
+ else files.set(key, [source]);
1311
+ }
1312
+
1313
+ for (const ranges of files.values()) {
1314
+ const first = ranges[0]!;
1315
+ const file = await this.file(pins, first.side, first.file);
1316
+
1317
+ for (const source of ranges) sliceRange(file, source);
1318
+ }
1319
+ }
1320
+ async quote(pins: Pins, source: FileLineRange) {
1321
+ source = fileLineRangeSchema.parse(source);
1322
+
1323
+ return sliceRange(await this.file(pins, source.side, source.file), source);
1324
+ }
1325
+ /** Every source reference must exist at the pins; only code peeks must also
1326
+ * show something. */
1327
+ async validateSource(
1328
+ pins: Pins,
1329
+ source: FileLineRange,
1330
+ options: { peek: boolean },
1331
+ ) {
1332
+ const quote = await this.quote(pins, source);
1333
+
1334
+ if (options.peek)
1335
+ inputError(() => requireVisibleSource(quote.text, source));
1336
+ }
1337
+ private readonly coverageCache = new Map<
1338
+ string,
1339
+ {
1340
+ promise: ReturnType<typeof comparisonCoverage>;
1341
+ state: "pending" | "ready" | "error";
1342
+ error?: unknown;
1343
+ partial?: ComparisonCoverage;
1344
+ }
1345
+ >();
1346
+ private readonly coverageAbort = new AbortController();
1347
+ private readonly coverageListeners = new Set<() => void>();
1348
+ coverageRevision = 0;
1349
+
1350
+ subscribeCoverage(listener: () => void) {
1351
+ this.coverageListeners.add(listener);
1352
+
1353
+ return () => {
1354
+ this.coverageListeners.delete(listener);
1355
+ };
1356
+ }
1357
+
1358
+ /** Start shared coverage work without occupying an HTTP request until it completes. */
1359
+ coveragePending(reviewId: string, pins: Pins, mode: CoverageMode): boolean {
1360
+ const key = JSON.stringify([pins, mode]);
1361
+ const existing = this.coverageCache.get(key);
1362
+
1363
+ if (existing?.state === "error") {
1364
+ this.coverageCache.delete(key);
1365
+ throw existing.error;
1366
+ }
1367
+
1368
+ void this.coverage(reviewId, pins, mode).catch(() => {});
1369
+
1370
+ return this.coverageCache.get(key)!.state === "pending";
1371
+ }
1372
+
1373
+ coverageSnapshot(reviewId: string, pins: Pins, mode: CoverageMode) {
1374
+ const pending = this.coveragePending(reviewId, pins, mode);
1375
+ const entry = this.coverageCache.get(JSON.stringify([pins, mode]))!;
1376
+
1377
+ return {
1378
+ pending,
1379
+ comparison: entry.partial ?? {
1380
+ files: [],
1381
+ fileSources: new Map(),
1382
+ alignments: new Map(),
1383
+ },
1384
+ };
1385
+ }
1386
+
1387
+ private coverageNotification: ReturnType<typeof setTimeout> | undefined;
1388
+ private notifyCoverage() {
1389
+ if (this.closed || this.coverageNotification) return;
1390
+ this.coverageNotification = setTimeout(() => {
1391
+ this.coverageNotification = undefined;
1392
+ this.coverageRevision++;
1393
+
1394
+ for (const listener of this.coverageListeners) listener();
1395
+ }, 50);
1396
+ }
1397
+
1398
+ coverage(reviewId: string, pins: Pins, mode: CoverageMode) {
1399
+ const key = JSON.stringify([pins, mode]);
1400
+ let entry = this.coverageCache.get(key);
1401
+
1402
+ if (!entry || entry.state === "error") {
1403
+ const promise = comparisonCoverage(
1404
+ this,
1405
+ reviewId,
1406
+ pins,
1407
+ mode,
1408
+ this.coverageAbort.signal,
1409
+ (partial) => {
1410
+ const current = this.coverageCache.get(key);
1411
+
1412
+ if (current) current.partial = partial;
1413
+ this.notifyCoverage();
1414
+ },
1415
+ );
1416
+
1417
+ entry = { promise, state: "pending" };
1418
+ this.coverageCache.set(key, entry);
1419
+ const current = entry;
1420
+
1421
+ const settled = (state: "ready" | "error", error?: Error) => {
1422
+ current.state = state;
1423
+ current.error = error;
1424
+ this.notifyCoverage();
1425
+
1426
+ // Never evict shared in-flight work. Trim only settled comparisons.
1427
+ for (const [cachedKey, cached] of this.coverageCache) {
1428
+ if (this.coverageCache.size <= 32) break;
1429
+
1430
+ if (cached.state !== "pending" && cachedKey !== key)
1431
+ this.coverageCache.delete(cachedKey);
1432
+ }
1433
+ };
1434
+
1435
+ void promise.then(
1436
+ (value) => {
1437
+ current.partial = value;
1438
+
1439
+ if (!this.closed)
1440
+ this.store.setDiffStats(
1441
+ pins,
1442
+ {
1443
+ fileCount: value.files.length,
1444
+ additions: value.files.reduce(
1445
+ (sum, file) =>
1446
+ sum + structuralChangeCounts(file.changed).added,
1447
+ 0,
1448
+ ),
1449
+ deletions: value.files.reduce(
1450
+ (sum, file) =>
1451
+ sum + structuralChangeCounts(file.changed).removed,
1452
+ 0,
1453
+ ),
1454
+ },
1455
+ mode,
1456
+ );
1457
+ settled("ready");
1458
+ },
1459
+ (error) => settled("error", error),
1460
+ );
1461
+ }
1462
+
1463
+ return entry.promise;
1464
+ }
1465
+
1466
+ changes(pins: Pins): Promise<LocalVcsDiffFileSummary[]>;
1467
+ changes(pins: Pins, file: string): Promise<string>;
1468
+ changes(
1469
+ pins: Pins,
1470
+ file?: string,
1471
+ ): Promise<LocalVcsDiffFileSummary[] | string>;
1472
+ async changes(pins: Pins, file?: string) {
1473
+ if (file === undefined) return this.summaries(pins);
1474
+ checkRelativePath(file);
1475
+
1476
+ return this.rawPatch(pins, { paths: [file] });
1477
+ }
1478
+ private async summaries(pins: Pins) {
1479
+ if (pins.worktreeRevision) {
1480
+ return diffFileSummariesWorkingTree(await this.worktreeInput(pins));
1481
+ }
1482
+
1483
+ return diffFileSummariesTrees({
1484
+ ...(await this.vcsTarget(pins.repositoryId)),
1485
+ baseRef: pins.base,
1486
+ headRef: pins.head,
1487
+ });
1488
+ }
1489
+ /** Raw Git patch text for exact filenames, or every change. */
1490
+ private async rawPatch(
1491
+ pins: Pins,
1492
+ options: { paths?: string[]; contextLines?: number },
1493
+ ) {
1494
+ if (pins.worktreeRevision)
1495
+ return diffWorkingTree({
1496
+ ...(await this.worktreeInput(pins)),
1497
+ ...options,
1498
+ });
1499
+
1500
+ return diffTrees({
1501
+ ...(await this.vcsTarget(pins.repositoryId)),
1502
+ baseRef: pins.base,
1503
+ headRef: pins.head,
1504
+ ...options,
1505
+ literalPaths: options.paths !== undefined,
1506
+ });
1507
+ }
1508
+ private async worktreeInput(pins: Pins) {
1509
+ const vcs = await this.vcs(pins.repositoryId);
1510
+
1511
+ if (!vcs) throw unavailableCheckout();
1512
+
1513
+ return {
1514
+ rootPath: vcs.rootPath,
1515
+ kind: vcs.kind,
1516
+ baseRef: pins.base === EMPTY_SOURCE ? undefined : pins.base,
1517
+ headRef: pins.head === EMPTY_SOURCE ? undefined : pins.head,
1518
+ };
1519
+ }
1520
+ commits(pins: Pins) {
1521
+ if (
1522
+ pins.base === pins.head ||
1523
+ pins.base === EMPTY_SOURCE ||
1524
+ pins.head === EMPTY_SOURCE
1525
+ )
1526
+ return Promise.resolve([]);
1527
+ const key = `${pins.repositoryId}:${pins.base}:${pins.head}`;
1528
+ const cached = this.commitRanges.get(key);
1529
+
1530
+ if (cached) return cached;
1531
+
1532
+ // Immutable pins: one list per key; a rejection is evicted.
1533
+ const pending = this.readCommitRange(pins).catch((cause: unknown) => {
1534
+ this.commitRanges.delete(key);
1535
+
1536
+ throw cause;
1537
+ });
1538
+
1539
+ this.commitRanges.set(key, pending);
1540
+
1541
+ return pending;
1542
+ }
1543
+ private async readCommitRange(pins: Pins) {
1544
+ return listCommitRange({
1545
+ ...(await this.vcsTarget(pins.repositoryId)),
1546
+ baseRef: pins.base,
1547
+ headRef: pins.head,
1548
+ });
1549
+ }
1550
+ async comparison(pins: Pins, commit?: string): Promise<Pins> {
1551
+ if (!commit) return pins;
1552
+
1553
+ const selected = (await this.commits(pins)).find(
1554
+ (item) => item.commit === commit,
1555
+ );
1556
+
1557
+ if (!selected)
1558
+ throw new ReviewInputError(
1559
+ "The selected commit is not part of this review version.",
1560
+ 404,
1561
+ );
1562
+
1563
+ return {
1564
+ repositoryId: pins.repositoryId,
1565
+ base: selected.parentCommit,
1566
+ head: selected.commit,
1567
+ };
1568
+ }
1569
+ async map(documentPins: Pins | undefined, resourceId: string) {
1570
+ await this.validateResource(documentPins, {
1571
+ type: "software_map",
1572
+ mapVersionId: resourceId,
1573
+ });
1574
+
1575
+ const resource = this.store.resource(resourceId);
1576
+
1577
+ // SAFETY: map resources are normalized and validated by upload before storage.
1578
+ const saved = JSON.parse(Buffer.from(resource.data).toString()) as Pick<
1579
+ NormalizedSoftwareModel,
1580
+ "elements" | "relationships"
1581
+ > & {
1582
+ side: "base" | "head";
1583
+ commit: string;
1584
+ };
1585
+
1586
+ // Without document pins the map is read at the commit it was built from.
1587
+ const pins: Pins = documentPins ?? {
1588
+ repositoryId: resource.repositoryId,
1589
+ base: saved.commit,
1590
+ head: saved.commit,
1591
+ };
1592
+
1593
+ const target = await this.vcsTarget(pins.repositoryId);
1594
+
1595
+ const patch = pins.worktreeRevision
1596
+ ? await diffWorkingTree({
1597
+ ...target,
1598
+ kind: target.kind ?? "git",
1599
+ baseRef: pins.base === EMPTY_SOURCE ? undefined : pins.base,
1600
+ headRef: pins.head === EMPTY_SOURCE ? undefined : pins.head,
1601
+ })
1602
+ : undefined;
1603
+
1604
+ const resolved = await resolveSoftwareMapDiffCounts({
1605
+ patch,
1606
+ sourceRootPath: target.rootPath,
1607
+ sourceVcsKind: target.kind,
1608
+ baseRef: pins.base,
1609
+ headRef: pins.head,
1610
+ side: saved.side,
1611
+ codeElements: saved.elements.filter(
1612
+ (element) => element.type === "codeElement",
1613
+ ),
1614
+ coverageClaims: saved.elements.flatMap((element) =>
1615
+ element.coverage ? [{ path: element.path, ...element.coverage }] : [],
1616
+ ),
1617
+ });
1618
+
1619
+ return {
1620
+ ...saved,
1621
+ countsByElementPath: resolved.countsByElementPath,
1622
+ unmappedByElementPath: resolved.unmappedByElementPath,
1623
+ };
1624
+ }
1625
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Upload boundary: uploadSchema.parse below validates incoming JSON.
1626
+ async upload(value: unknown) {
1627
+ const input = uploadSchema.parse(value);
1628
+ this.store.repositoryPath(input.repositoryId);
1629
+
1630
+ let mimeType = "application/json",
1631
+ data: Uint8Array;
1632
+
1633
+ switch (input.kind) {
1634
+ case "image":
1635
+ data = await decodeImage(Buffer.from(input.base64, "base64"));
1636
+ mimeType = "image/png";
1637
+ break;
1638
+
1639
+ case "trace":
1640
+ if (
1641
+ new Set(input.trace.events.map((event) => event.id)).size !==
1642
+ input.trace.events.length
1643
+ )
1644
+ throw new ReviewInputError("Trace event IDs must be unique.");
1645
+ data = Buffer.from(
1646
+ JSON.stringify({ ...input.trace, provenance: "client_supplied" }),
1647
+ );
1648
+ break;
1649
+ case "map": {
1650
+ if (input.pins.repositoryId !== input.repositoryId)
1651
+ throw new ReviewInputError("Map belongs to a different repository.");
1652
+ await this.validatePins(input.pins);
1653
+ let model;
1654
+
1655
+ try {
1656
+ model = defineSoftwareMap(input.model);
1657
+ } catch (error) {
1658
+ if (error instanceof SoftwareModelValidationError)
1659
+ throw new ReviewInputError(error.message);
1660
+ throw error;
1661
+ }
1662
+
1663
+ // Read each pinned file once, then check every range against it concurrently.
1664
+ const files = new Map<string, ReturnType<LocalReviewData["file"]>>();
1665
+
1666
+ await Promise.all(
1667
+ model.elements.flatMap((element) =>
1668
+ (element.sourceRanges ?? []).map(async (source) => {
1669
+ const range = fileLineRangeSchema.parse({
1670
+ ...source,
1671
+ side: input.side,
1672
+ });
1673
+
1674
+ let read = files.get(range.file);
1675
+
1676
+ if (!read)
1677
+ files.set(
1678
+ range.file,
1679
+ (read = this.file(input.pins, range.side, range.file)),
1680
+ );
1681
+ sliceRange(await read, range);
1682
+ }),
1683
+ ),
1684
+ );
1685
+ data = Buffer.from(
1686
+ JSON.stringify({
1687
+ commit: input.pins[input.side],
1688
+ side: input.side,
1689
+ elements: model.elements,
1690
+ relationships: model.relationships,
1691
+ }),
1692
+ );
1693
+ break;
1694
+ }
1695
+ }
1696
+
1697
+ return this.store.putResource(
1698
+ input.id,
1699
+ input.repositoryId,
1700
+ input.kind,
1701
+ mimeType,
1702
+ data,
1703
+ );
1704
+ }
1705
+ /** A resource must belong to the document's repository when it has one;
1706
+ * a document without pins may use any registered repository's resources. */
1707
+ async validateResource(pins: Pins | undefined, block: Block) {
1708
+ const reference = resourceReference(block);
1709
+
1710
+ if (!reference) return;
1711
+ const { id, kind } = reference;
1712
+ const resource = this.store.resource(id);
1713
+
1714
+ if (
1715
+ (pins && resource.repositoryId !== pins.repositoryId) ||
1716
+ resource.kind !== kind
1717
+ )
1718
+ throw new ReviewInputError(
1719
+ "Resource belongs to a different repository or component type.",
1720
+ );
1721
+
1722
+ if (!pins) this.store.repositoryPath(resource.repositoryId);
1723
+
1724
+ if (block.type === "trace_quote") {
1725
+ const trace = traceSchema.parse(
1726
+ JSON.parse(Buffer.from(resource.data).toString()),
1727
+ );
1728
+
1729
+ const event = trace.events.find((event) => event.id === block.eventId);
1730
+
1731
+ if (!event || !textIncludesQuote(event.text, block.text))
1732
+ throw new ReviewInputError(
1733
+ "Quote does not match the retained trace event.",
1734
+ );
1735
+ }
1736
+
1737
+ if (block.type === "software_map") {
1738
+ // SAFETY: map resources are normalized and validated by upload before storage.
1739
+ const map = JSON.parse(Buffer.from(resource.data).toString()) as {
1740
+ commit: string;
1741
+ side: "base" | "head";
1742
+ elements: { id: string; path: string }[];
1743
+ };
1744
+
1745
+ if (pins && map.commit !== pins[map.side])
1746
+ throw new ReviewInputError(
1747
+ "Map does not match this review's source pins.",
1748
+ );
1749
+
1750
+ if (
1751
+ block.focusElementId &&
1752
+ !map.elements.some(
1753
+ (element) =>
1754
+ element.id === block.focusElementId ||
1755
+ element.path === block.focusElementId,
1756
+ )
1757
+ )
1758
+ throw new ReviewInputError("Map focus element does not exist.");
1759
+ }
1760
+ }
1761
+ }
1762
+
1763
+ /** The pure source checks throw their own error; API clients see it as input. */
1764
+ function inputError<T>(run: () => T): T {
1765
+ try {
1766
+ return run();
1767
+ } catch (error) {
1768
+ if (error instanceof SourceRangeError)
1769
+ throw new ReviewInputError(error.message);
1770
+ throw error;
1771
+ }
1772
+ }
1773
+
1774
+ export function openLocalReviewStore(
1775
+ databasePath: string,
1776
+ options: {
1777
+ blobReaderIdleTimeoutMs?: number;
1778
+ manageWorkspaces?: boolean;
1779
+ watch?: typeof watch;
1780
+ pullRequests?: PullRequestDeps;
1781
+ } = {},
1782
+ ) {
1783
+ const store: ReviewStore = new ReviewStore(databasePath, {
1784
+ projectSource: (snapshot, pins) => data.projectSource(snapshot, pins),
1785
+ resolveTarget: (target, pinned) => data.resolveTarget(target, pinned),
1786
+ resolvePullRequest: (url, repository) =>
1787
+ data.resolvePullRequest(url, repository),
1788
+ headBranch: (pins, headRef) => data.headBranch(pins, headRef),
1789
+ sourcePins: (snapshot) => data.sourcePins(snapshot),
1790
+ unavailableAnchors: (snapshot) => data.unavailableAnchors(snapshot),
1791
+ validatePins: (pins) => data.validatePins(pins),
1792
+ filesChangedBetween: (from, to) => data.filesChangedBetween(from, to),
1793
+ validateSource: (pins, source, options) =>
1794
+ data.validateSource(pins, source, options),
1795
+ validateResource: (pins, block) => data.validateResource(pins, block),
1796
+ });
1797
+
1798
+ let data: LocalReviewData;
1799
+
1800
+ try {
1801
+ data = new LocalReviewData(store, {
1802
+ ...options,
1803
+ workspaceDatabase: `${databasePath}.workspaces`,
1804
+ });
1805
+ } catch (error) {
1806
+ void store.close();
1807
+ throw error;
1808
+ }
1809
+
1810
+ return { store, data };
1811
+ }
1812
+
1813
+ /** The parts of a VS Code workspace file the navigator owns; everything else
1814
+ * VS Code or the user adds is kept as is. */
1815
+ const codeWorkspaceSchema = z.looseObject({
1816
+ folders: z.array(z.json()).default([]),
1817
+ settings: z.record(z.string(), z.json()).default({}),
1818
+ });
1819
+
1820
+ /** A workspace file's contents, undefined when it does not exist, or null when
1821
+ * it is not plain JSON (VS Code accepts comments), which is left untouched. */
1822
+ async function readWorkspace(
1823
+ file: string,
1824
+ ): Promise<z.infer<typeof codeWorkspaceSchema> | null | undefined> {
1825
+ const text = await readFile(file, "utf8").catch((error) => {
1826
+ if (isMissingFileError(error)) return undefined;
1827
+ throw error;
1828
+ });
1829
+
1830
+ if (text === undefined) return undefined;
1831
+
1832
+ try {
1833
+ return codeWorkspaceSchema.safeParse(JSON.parse(text)).data ?? null;
1834
+ } catch {
1835
+ return null;
1836
+ }
1837
+ }