getgloss 0.5.0 → 0.6.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.
@@ -1,13 +1,6 @@
1
1
  // src/server/daemon.ts
2
2
  import { serve } from "@hono/node-server";
3
3
 
4
- // src/cli/lifecycle.ts
5
- import { spawn } from "child_process";
6
- import { existsSync, openSync } from "fs";
7
- import { readFile, rm, writeFile } from "fs/promises";
8
- import { fileURLToPath } from "url";
9
- import getPort from "get-port";
10
-
11
4
  // src/shared/paths.ts
12
5
  import { mkdir } from "fs/promises";
13
6
  import { homedir } from "os";
@@ -16,7 +9,7 @@ import path from "path";
16
9
  // package.json
17
10
  var package_default = {
18
11
  name: "getgloss",
19
- version: "0.5.0",
12
+ version: "0.6.0",
20
13
  description: "Local browser-based diff review for coding-agent loops.",
21
14
  type: "module",
22
15
  packageManager: "pnpm@10.33.2",
@@ -46,31 +39,31 @@ var package_default = {
46
39
  node: ">=20"
47
40
  },
48
41
  dependencies: {
49
- "@hono/node-server": "^1.14.4",
50
- "@pierre/diffs": "^1.2.1",
51
- "@tailwindcss/vite": "^4.1.7",
52
- commander: "^14.0.0",
53
- execa: "^9.5.3",
54
- "get-port": "^7.1.0",
55
- hono: "^4.7.10",
56
- "lucide-react": "^1.16.0",
57
- open: "^10.1.2",
58
- react: "^19.1.0",
59
- "react-dom": "^19.1.0",
60
- ulid: "^3.0.0",
61
- zustand: "^5.0.5"
42
+ "@hono/node-server": "1.19.14",
43
+ "@tailwindcss/vite": "4.3.0",
44
+ commander: "14.0.3",
45
+ execa: "9.6.1",
46
+ "get-port": "7.2.0",
47
+ hono: "4.12.21",
48
+ "lucide-react": "1.16.0",
49
+ open: "10.2.0",
50
+ react: "19.2.6",
51
+ "react-dom": "19.2.6",
52
+ ulid: "3.0.2",
53
+ zustand: "5.0.13"
62
54
  },
63
55
  devDependencies: {
64
- "@biomejs/biome": "^2.0.6",
65
- "@types/node": "^24.0.1",
66
- "@types/react": "^19.1.6",
67
- "@types/react-dom": "^19.1.5",
68
- "@vitejs/plugin-react": "^4.5.2",
69
- tsup: "^8.5.0",
70
- tsx: "^4.20.3",
71
- typescript: "^5.8.3",
72
- vite: "^6.3.5",
73
- vitest: "^3.2.3"
56
+ "@biomejs/biome": "2.4.15",
57
+ "@types/node": "24.12.4",
58
+ "@types/react": "19.2.15",
59
+ "@types/react-dom": "19.2.3",
60
+ "@vitejs/plugin-react": "4.7.0",
61
+ tailwindcss: "4.3.0",
62
+ tsup: "8.5.1",
63
+ tsx: "4.22.3",
64
+ typescript: "5.9.3",
65
+ vite: "6.4.2",
66
+ vitest: "3.2.4"
74
67
  },
75
68
  keywords: [
76
69
  "diff",
@@ -134,32 +127,195 @@ async function ensureDir(dir) {
134
127
  await mkdir(dir, { recursive: true });
135
128
  }
136
129
 
137
- // src/cli/lifecycle.ts
130
+ // src/shared/server-info.ts
131
+ import { readFile } from "fs/promises";
132
+
133
+ // src/shared/json.ts
134
+ import { writeFile } from "fs/promises";
135
+ function serializeJson(value) {
136
+ return `${JSON.stringify(value, null, 2)}
137
+ `;
138
+ }
139
+ async function writeJsonFile(filePath, value) {
140
+ await writeFile(filePath, serializeJson(value));
141
+ }
142
+
143
+ // src/shared/validation.ts
144
+ var reviewStatuses = ["pending", "submitted", "cancelled", "resolved"];
145
+ var resolutionStatuses = ["partial", "resolved"];
146
+ var sides = ["L", "R"];
147
+ var diffLineTypes = ["context", "add", "delete"];
148
+ var diffScopeModes = ["working", "branch", "explicit"];
149
+ var diffFallbackReasons = ["working-tree-clean", "missing-branch-base"];
150
+ function parseJson(raw, guard, label) {
151
+ const parsed = JSON.parse(raw);
152
+ return parseJsonValue(parsed, guard, label);
153
+ }
154
+ function parseJsonValue(value, guard, label) {
155
+ if (!guard(value)) {
156
+ throw new Error(`Invalid ${label}`);
157
+ }
158
+ return value;
159
+ }
160
+ function isSubmitReviewRequest(value) {
161
+ return isRecord(value) && isArrayOf(value.comments, isComment);
162
+ }
163
+ function isResolutionRequest(value) {
164
+ return isRecord(value) && isOptionalString(value.summary);
165
+ }
166
+ function isStoredReviewMeta(value) {
167
+ return isRecord(value) && isString(value.id) && isString(value.cwd) && isBaseRef(value.base) && isNullableString(value.branch) && isReviewStatus(value.status) && isString(value.createdAt) && isOptionalString(value.submittedAt) && isOptionalString(value.resolvedAt) && isOptionalString(value.artifactDir) && isOptionalString(value.feedbackPath) && isOptionalString(value.markdownPath);
168
+ }
169
+ function isDiffPayload(value) {
170
+ return isRecord(value) && isBaseRef(value.base) && isNullableString(value.branch) && isString(value.cwd) && isDiffScope(value.scope) && isDiffStats(value.stats) && isString(value.rawDiff) && isArrayOf(value.files, isDiffFile) && isString(value.capturedAt);
171
+ }
172
+ function isFeedbackBundle(value) {
173
+ return isRecord(value) && value.version === 1 && isString(value.reviewId) && isString(value.timestamp) && isBaseRef(value.base) && isNullableString(value.branch) && isArrayOf(value.comments, isComment);
174
+ }
175
+ function isResolutionBundle(value) {
176
+ return isRecord(value) && isString(value.reviewId) && isResolutionStatus(value.status) && isNullableString(value.summary) && isNullableString(value.resolvedAt) && isArrayOf(value.comments, isResolvedComment);
177
+ }
178
+ function isDiffScope(value) {
179
+ return isRecord(value) && isOneOf(value.mode, diffScopeModes) && isNullableString(value.requestedBase) && isBaseRef(value.base) && isDiffRef(value.comparison) && (value.fallbackReason === null || isOneOf(value.fallbackReason, diffFallbackReasons));
180
+ }
181
+ function isDiffRef(value) {
182
+ return isRecord(value) && isString(value.ref) && isNullableString(value.sha);
183
+ }
184
+ function isBaseRef(value) {
185
+ return isRecord(value) && isString(value.ref) && isString(value.sha);
186
+ }
187
+ function isDiffStats(value) {
188
+ return isRecord(value) && isNumber(value.files) && isNumber(value.additions) && isNumber(value.deletions);
189
+ }
190
+ function isDiffFile(value) {
191
+ return isRecord(value) && isString(value.path) && isNullableString(value.oldPath) && isNumber(value.additions) && isNumber(value.deletions) && isBoolean(value.isBinary) && isBoolean(value.isDeleted) && isBoolean(value.isNew) && isBoolean(value.isRenamed) && isNullableString(value.language) && isArrayOf(value.hunks, isDiffHunk);
192
+ }
193
+ function isDiffHunk(value) {
194
+ return isRecord(value) && isNumber(value.oldStart) && isNumber(value.oldLines) && isNumber(value.newStart) && isNumber(value.newLines) && isString(value.header) && isArrayOf(value.lines, isDiffLine);
195
+ }
196
+ function isDiffLine(value) {
197
+ return isRecord(value) && isOneOf(value.type, diffLineTypes) && isNullableNumber(value.oldLine) && isNullableNumber(value.newLine) && isString(value.content);
198
+ }
199
+ function isComment(value) {
200
+ return isRecord(value) && isString(value.id) && isString(value.filePath) && isNumber(value.startLine) && isNumber(value.endLine) && isOneOf(value.side, sides) && isString(value.body) && isString(value.originalSnippet) && isString(value.createdAt);
201
+ }
202
+ function isResolvedComment(value) {
203
+ return isRecord(value) && isString(value.commentId) && value.status === "resolved" && isOptionalString(value.summary) && isString(value.resolvedAt);
204
+ }
205
+ function isReviewStatus(value) {
206
+ return isOneOf(value, reviewStatuses);
207
+ }
208
+ function isResolutionStatus(value) {
209
+ return isOneOf(value, resolutionStatuses);
210
+ }
211
+ function isRecord(value) {
212
+ return typeof value === "object" && value !== null && !Array.isArray(value);
213
+ }
214
+ function isArrayOf(value, guard) {
215
+ return Array.isArray(value) && value.every(guard);
216
+ }
217
+ function isString(value) {
218
+ return typeof value === "string";
219
+ }
220
+ function isOptionalString(value) {
221
+ return value === void 0 || isString(value);
222
+ }
223
+ function isNullableString(value) {
224
+ return value === null || isString(value);
225
+ }
226
+ function isNumber(value) {
227
+ return typeof value === "number" && Number.isFinite(value);
228
+ }
229
+ function isNullableNumber(value) {
230
+ return value === null || isNumber(value);
231
+ }
232
+ function isBoolean(value) {
233
+ return typeof value === "boolean";
234
+ }
235
+ function isOneOf(value, options) {
236
+ return typeof value === "string" && options.includes(value);
237
+ }
238
+
239
+ // src/shared/server-info.ts
138
240
  async function writeServerInfo(info) {
139
241
  await ensureDir(globalStateDir());
140
- await writeFile(globalServerFile(), `${JSON.stringify(info, null, 2)}
141
- `);
242
+ await writeJsonFile(globalServerFile(), info);
142
243
  }
143
244
 
144
245
  // src/server/index.ts
145
246
  import { readFile as readFile3 } from "fs/promises";
146
- import path2 from "path";
147
- import { fileURLToPath as fileURLToPath2 } from "url";
247
+ import path3 from "path";
248
+ import { fileURLToPath } from "url";
148
249
  import { Hono } from "hono";
149
250
  import { streamSSE } from "hono/streaming";
150
251
 
252
+ // src/shared/comments.ts
253
+ function compareCommentsByLocation(a, b) {
254
+ return a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine || a.endLine - b.endLine || a.side.localeCompare(b.side);
255
+ }
256
+ function countCommentFiles(comments) {
257
+ return new Set(comments.map((comment) => comment.filePath)).size;
258
+ }
259
+ function formatLineRange(range, options = {}) {
260
+ const startLine = Math.min(range.startLine, range.endLine);
261
+ const endLine = Math.max(range.startLine, range.endLine);
262
+ if (startLine === endLine) {
263
+ return `${range.side}${startLine}`;
264
+ }
265
+ const endPrefix = options.repeatSideOnEnd === false ? "" : range.side;
266
+ return `${range.side}${startLine}-${endPrefix}${endLine}`;
267
+ }
268
+ function resolutionCounts(feedback, resolvedComments = []) {
269
+ const comments = feedback?.comments ?? [];
270
+ const resolvedIds = new Set(resolvedComments.map((comment) => comment.commentId));
271
+ const resolved = comments.filter((comment) => resolvedIds.has(comment.id)).length;
272
+ return {
273
+ total: comments.length,
274
+ resolved,
275
+ open: comments.length - resolved
276
+ };
277
+ }
278
+
279
+ // src/shared/reviews.ts
280
+ function isResolvableReviewStatus(status) {
281
+ return status === "submitted" || status === "resolved";
282
+ }
283
+
151
284
  // src/server/store.ts
152
- import { readdir, readFile as readFile2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
285
+ import { readdir, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
153
286
  import { ulid } from "ulid";
154
287
 
155
- // src/shared/markdown.ts
156
- function formatLineRange(comment) {
157
- const prefix = comment.side;
158
- if (comment.startLine === comment.endLine) {
159
- return `${prefix}${comment.startLine}`;
288
+ // src/shared/language.ts
289
+ import path2 from "path";
290
+ var languageByExtension = {
291
+ cjs: "js",
292
+ css: "css",
293
+ go: "go",
294
+ html: "html",
295
+ js: "js",
296
+ json: "json",
297
+ jsx: "jsx",
298
+ md: "markdown",
299
+ mjs: "js",
300
+ py: "python",
301
+ rb: "ruby",
302
+ rs: "rust",
303
+ sh: "bash",
304
+ swift: "swift",
305
+ ts: "ts",
306
+ tsx: "tsx",
307
+ yaml: "yaml",
308
+ yml: "yaml"
309
+ };
310
+ function languageForPath(filePath) {
311
+ const ext = path2.extname(filePath).slice(1).toLowerCase();
312
+ if (!ext) {
313
+ return null;
160
314
  }
161
- return `${prefix}${comment.startLine}-${prefix}${comment.endLine}`;
315
+ return languageByExtension[ext] ?? ext;
162
316
  }
317
+
318
+ // src/shared/markdown.ts
163
319
  function fenceFor(snippet) {
164
320
  let fence = "```";
165
321
  while (snippet.includes(fence)) {
@@ -167,40 +323,13 @@ function fenceFor(snippet) {
167
323
  }
168
324
  return fence;
169
325
  }
170
- function languageForPath(filePath) {
171
- const ext = filePath.split(".").pop()?.toLowerCase();
172
- const map = {
173
- cjs: "js",
174
- css: "css",
175
- go: "go",
176
- html: "html",
177
- js: "js",
178
- json: "json",
179
- jsx: "jsx",
180
- md: "markdown",
181
- mjs: "js",
182
- py: "python",
183
- rb: "ruby",
184
- rs: "rust",
185
- sh: "bash",
186
- swift: "swift",
187
- ts: "ts",
188
- tsx: "tsx",
189
- yaml: "yaml",
190
- yml: "yaml"
191
- };
192
- return ext ? map[ext] ?? ext : "";
193
- }
194
326
  function languageForSnippet(filePath, snippet) {
195
327
  const lines = snippet.split("\n").filter((line) => line.length > 0);
196
328
  const looksLikeUnifiedDiff = lines.length > 0 && lines.some((line) => line.startsWith("+") || line.startsWith("-")) && lines.every((line) => line.startsWith("+") || line.startsWith("-") || line.startsWith(" "));
197
- return looksLikeUnifiedDiff ? "diff" : languageForPath(filePath);
198
- }
199
- function byFileThenLine(a, b) {
200
- return a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine || a.endLine - b.endLine || a.side.localeCompare(b.side);
329
+ return looksLikeUnifiedDiff ? "diff" : languageForPath(filePath) ?? "";
201
330
  }
202
331
  function serializeFeedbackMarkdown(bundle) {
203
- const comments = [...bundle.comments].sort(byFileThenLine);
332
+ const comments = [...bundle.comments].sort(compareCommentsByLocation);
204
333
  const files = [...new Set(comments.map((comment) => comment.filePath))];
205
334
  const lines = [
206
335
  `# Gloss feedback - ${bundle.timestamp}`,
@@ -270,9 +399,7 @@ var ReviewStore = class {
270
399
  timestamp,
271
400
  base: record.diff.base,
272
401
  branch: record.diff.branch,
273
- comments: [...comments].sort(
274
- (a, b) => a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine || a.endLine - b.endLine || a.side.localeCompare(b.side)
275
- )
402
+ comments: [...comments].sort(compareCommentsByLocation)
276
403
  };
277
404
  record.feedback = feedback;
278
405
  record.meta = { ...record.meta, status: "submitted", submittedAt: timestamp };
@@ -288,17 +415,15 @@ var ReviewStore = class {
288
415
  };
289
416
  await ensureDir(artifactDir);
290
417
  await Promise.all([
291
- writeFile2(globalReviewMetaFile(id), `${JSON.stringify(record.meta, null, 2)}
292
- `),
293
- writeFile2(feedbackPath, `${JSON.stringify(feedback, null, 2)}
294
- `),
418
+ writeJsonFile(globalReviewMetaFile(id), record.meta),
419
+ writeJsonFile(feedbackPath, feedback),
295
420
  writeFile2(markdownPath, serializeFeedbackMarkdown(feedback))
296
421
  ]);
297
422
  this.emit({
298
423
  type: "review.submitted",
299
424
  reviewId: id,
300
425
  counts: {
301
- files: new Set(feedback.comments.map((comment) => comment.filePath)).size,
426
+ files: countCommentFiles(feedback.comments),
302
427
  comments: feedback.comments.length
303
428
  }
304
429
  });
@@ -360,7 +485,7 @@ var ReviewStore = class {
360
485
  ],
361
486
  record
362
487
  );
363
- const counts = this.resolutionCounts(record, comments);
488
+ const counts = resolutionCounts(record.feedback, comments);
364
489
  const fullyResolved = counts.total === counts.resolved;
365
490
  const resolution = {
366
491
  reviewId: id,
@@ -383,7 +508,7 @@ var ReviewStore = class {
383
508
  (record.resolution?.comments ?? []).filter((comment) => comment.commentId !== commentId),
384
509
  record
385
510
  );
386
- const counts = this.resolutionCounts(record, comments);
511
+ const counts = resolutionCounts(record.feedback, comments);
387
512
  const fullyResolved = counts.total > 0 && counts.total === counts.resolved;
388
513
  const resolvedAt = fullyResolved ? (/* @__PURE__ */ new Date()).toISOString() : null;
389
514
  const resolution = {
@@ -416,10 +541,8 @@ var ReviewStore = class {
416
541
  const dir = globalReviewDir(record.meta.id);
417
542
  await ensureDir(dir);
418
543
  await Promise.all([
419
- writeFile2(globalReviewMetaFile(record.meta.id), `${JSON.stringify(record.meta, null, 2)}
420
- `),
421
- writeFile2(globalReviewDiffFile(record.meta.id), `${JSON.stringify(record.diff, null, 2)}
422
- `)
544
+ writeJsonFile(globalReviewMetaFile(record.meta.id), record.meta),
545
+ writeJsonFile(globalReviewDiffFile(record.meta.id), record.diff)
423
546
  ]);
424
547
  }
425
548
  async loadKnownReview(id) {
@@ -433,56 +556,65 @@ var ReviewStore = class {
433
556
  let entries;
434
557
  try {
435
558
  entries = await readdir(globalReviewsDir(), { withFileTypes: true });
436
- } catch {
437
- return;
559
+ } catch (error) {
560
+ if (isFileNotFound(error)) {
561
+ return;
562
+ }
563
+ throw new Error(
564
+ `Could not read reviews directory at ${globalReviewsDir()}: ${formatError(error)}`,
565
+ {
566
+ cause: error
567
+ }
568
+ );
438
569
  }
439
570
  await Promise.all(
440
571
  entries.filter((entry) => entry.isDirectory()).map((entry) => this.loadReview(entry.name))
441
572
  );
442
573
  }
443
574
  async loadReview(id) {
575
+ const metaPath = globalReviewMetaFile(id);
576
+ const diffPath = globalReviewDiffFile(id);
577
+ let metaRaw;
578
+ let diffRaw;
444
579
  try {
445
- const [metaRaw, diffRaw] = await Promise.all([
446
- readFile2(globalReviewMetaFile(id), "utf8"),
447
- readFile2(globalReviewDiffFile(id), "utf8")
580
+ [metaRaw, diffRaw] = await Promise.all([
581
+ readFile2(metaPath, "utf8"),
582
+ readFile2(diffPath, "utf8")
448
583
  ]);
449
- const meta = JSON.parse(metaRaw);
450
- const diff = JSON.parse(diffRaw);
451
- let feedback;
452
- let resolution;
453
- try {
454
- feedback = JSON.parse(
455
- await readFile2(globalReviewFeedbackFile(id), "utf8")
456
- );
457
- } catch {
458
- feedback = void 0;
459
- }
460
- try {
461
- resolution = JSON.parse(
462
- await readFile2(globalReviewResolvedFile(id), "utf8")
463
- );
464
- } catch {
465
- resolution = void 0;
584
+ } catch (error) {
585
+ if (isFileNotFound(error)) {
586
+ return null;
466
587
  }
467
- const record = {
468
- meta: {
469
- ...meta,
470
- artifactDir: meta.artifactDir ?? globalReviewDir(id),
471
- feedbackPath: meta.feedbackPath ?? (feedback ? globalReviewFeedbackFile(id) : void 0),
472
- markdownPath: meta.markdownPath ?? (feedback ? globalReviewMarkdownFile(id) : void 0)
473
- },
474
- diff,
475
- feedback,
476
- resolution
477
- };
478
- this.reviews.set(id, record);
479
- return record;
480
- } catch {
481
- return null;
588
+ throw new Error(`Could not load review ${id}: ${formatError(error)}`, { cause: error });
482
589
  }
590
+ const meta = parseJsonFile(metaRaw, isStoredReviewMeta, "review metadata", metaPath);
591
+ const diff = parseJsonFile(diffRaw, isDiffPayload, "review diff", diffPath);
592
+ const feedback = await readOptionalJsonFile(
593
+ globalReviewFeedbackFile(id),
594
+ isFeedbackBundle,
595
+ "review feedback"
596
+ );
597
+ const resolution = await readOptionalJsonFile(
598
+ globalReviewResolvedFile(id),
599
+ isResolutionBundle,
600
+ "review resolution"
601
+ );
602
+ const record = {
603
+ meta: {
604
+ ...meta,
605
+ artifactDir: meta.artifactDir ?? globalReviewDir(id),
606
+ feedbackPath: meta.feedbackPath ?? (feedback ? globalReviewFeedbackFile(id) : void 0),
607
+ markdownPath: meta.markdownPath ?? (feedback ? globalReviewMarkdownFile(id) : void 0)
608
+ },
609
+ diff,
610
+ feedback,
611
+ resolution
612
+ };
613
+ this.reviews.set(id, record);
614
+ return record;
483
615
  }
484
616
  assertResolvable(record, id) {
485
- if (record.meta.status !== "submitted" && record.meta.status !== "resolved") {
617
+ if (!isResolvableReviewStatus(record.meta.status)) {
486
618
  throw new Error(`Review ${id} is ${record.meta.status} and cannot be resolved`);
487
619
  }
488
620
  if (!record.feedback) {
@@ -500,17 +632,15 @@ var ReviewStore = class {
500
632
  const resolvedPath = globalReviewResolvedFile(record.meta.id);
501
633
  await ensureDir(globalReviewDir(record.meta.id));
502
634
  await Promise.all([
503
- writeFile2(resolvedPath, `${JSON.stringify(resolution, null, 2)}
504
- `),
505
- writeFile2(globalReviewMetaFile(record.meta.id), `${JSON.stringify(record.meta, null, 2)}
506
- `)
635
+ writeJsonFile(resolvedPath, resolution),
636
+ writeJsonFile(globalReviewMetaFile(record.meta.id), record.meta)
507
637
  ]);
508
638
  const result = {
509
639
  ok: true,
510
640
  reviewId: record.meta.id,
511
641
  status: record.meta.status,
512
642
  resolutionStatus: resolution.status,
513
- comments: this.resolutionCounts(record, resolution.comments),
643
+ comments: resolutionCounts(record.feedback, resolution.comments),
514
644
  path: resolvedPath,
515
645
  resolution
516
646
  };
@@ -532,23 +662,38 @@ var ReviewStore = class {
532
662
  (a, b) => (feedbackIndex.get(a.commentId) ?? Number.MAX_SAFE_INTEGER) - (feedbackIndex.get(b.commentId) ?? Number.MAX_SAFE_INTEGER)
533
663
  );
534
664
  }
535
- resolutionCounts(record, comments) {
536
- const total = record.feedback.comments.length;
537
- const resolvedIds = new Set(comments.map((comment) => comment.commentId));
538
- const resolved = record.feedback.comments.filter(
539
- (comment) => resolvedIds.has(comment.id)
540
- ).length;
541
- return {
542
- total,
543
- resolved,
544
- open: total - resolved
545
- };
546
- }
547
665
  };
666
+ async function readOptionalJsonFile(filePath, guard, label) {
667
+ let raw;
668
+ try {
669
+ raw = await readFile2(filePath, "utf8");
670
+ } catch (error) {
671
+ if (isFileNotFound(error)) {
672
+ return void 0;
673
+ }
674
+ throw new Error(`Could not read ${label} at ${filePath}: ${formatError(error)}`, {
675
+ cause: error
676
+ });
677
+ }
678
+ return parseJsonFile(raw, guard, label, filePath);
679
+ }
680
+ function parseJsonFile(raw, guard, label, filePath) {
681
+ try {
682
+ return parseJson(raw, guard, label);
683
+ } catch (error) {
684
+ throw new Error(`Invalid ${label} at ${filePath}: ${formatError(error)}`, { cause: error });
685
+ }
686
+ }
687
+ function isFileNotFound(error) {
688
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
689
+ }
690
+ function formatError(error) {
691
+ return error instanceof Error ? error.message : String(error);
692
+ }
548
693
  var reviewStore = new ReviewStore();
549
694
 
550
695
  // src/server/index.ts
551
- var webRoot = fileURLToPath2(new URL("../web", import.meta.url));
696
+ var webRoot = fileURLToPath(new URL("../web", import.meta.url));
552
697
  var eventStreamHeartbeatMs = 15e3;
553
698
  var mimeTypes = {
554
699
  ".css": "text/css; charset=utf-8",
@@ -564,17 +709,29 @@ function createApp(origin2) {
564
709
  const app = new Hono();
565
710
  app.get("/api/health", async (c) => {
566
711
  const reviews = await reviewStore.list();
567
- return c.json({
712
+ const response = {
568
713
  ok: true,
569
714
  version: packageVersion,
570
715
  activeReviews: reviews.filter((review) => review.status === "pending").length
571
- });
716
+ };
717
+ return c.json(response);
718
+ });
719
+ app.get("/api/reviews", async (c) => {
720
+ const response = { reviews: await reviewStore.list() };
721
+ return c.json(response);
572
722
  });
573
- app.get("/api/reviews", async (c) => c.json({ reviews: await reviewStore.list() }));
574
723
  app.post("/api/reviews", async (c) => {
575
- const diff = await c.req.json();
724
+ const parsed = await readJsonBody(c, isDiffPayload, "review diff");
725
+ if (!parsed.ok) {
726
+ return parsed.response;
727
+ }
728
+ const diff = parsed.body;
576
729
  const record = await reviewStore.create(diff);
577
- return c.json({ meta: record.meta, url: `${origin2}/review/${record.meta.id}` }, 201);
730
+ const response = {
731
+ meta: record.meta,
732
+ url: `${origin2}/review/${record.meta.id}`
733
+ };
734
+ return c.json(response, 201);
578
735
  });
579
736
  app.get("/api/reviews/:id", async (c) => {
580
737
  const record = await reviewStore.get(c.req.param("id"));
@@ -621,9 +778,11 @@ function createApp(origin2) {
621
778
  };
622
779
  const unsubscribe = reviewStore.subscribe(id, send);
623
780
  const heartbeat = setInterval(() => {
624
- pending = pending.then(() => stream.write(`: keep-alive ${Date.now()}
781
+ pending = pending.then(async () => {
782
+ await stream.write(`: keep-alive ${Date.now()}
625
783
 
626
- `));
784
+ `);
785
+ });
627
786
  void pending.catch(() => close?.());
628
787
  }, eventStreamHeartbeatMs);
629
788
  cleanup = () => {
@@ -632,12 +791,12 @@ function createApp(origin2) {
632
791
  };
633
792
  stream.onAbort(() => close?.());
634
793
  send({ type: "review.opened", reviewId: id });
635
- if ((record.meta.status === "submitted" || record.meta.status === "resolved") && record.feedback) {
794
+ if (isResolvableReviewStatus(record.meta.status) && record.feedback) {
636
795
  send({
637
796
  type: "review.submitted",
638
797
  reviewId: id,
639
798
  counts: {
640
- files: new Set(record.feedback.comments.map((comment) => comment.filePath)).size,
799
+ files: countCommentFiles(record.feedback.comments),
641
800
  comments: record.feedback.comments.length
642
801
  }
643
802
  });
@@ -654,20 +813,22 @@ function createApp(origin2) {
654
813
  if (existing.meta.status !== "pending") {
655
814
  return c.json({ error: `review is ${existing.meta.status} and cannot be submitted` }, 409);
656
815
  }
657
- const body = await c.req.json();
658
- const { record, feedbackPath, markdownPath } = await reviewStore.submit(
659
- id,
660
- body.comments ?? []
661
- );
662
- return c.json({
816
+ const parsed = await readJsonBody(c, isSubmitReviewRequest, "submit review request");
817
+ if (!parsed.ok) {
818
+ return parsed.response;
819
+ }
820
+ const body = parsed.body;
821
+ const { record, feedbackPath, markdownPath } = await reviewStore.submit(id, body.comments);
822
+ const response = {
663
823
  reviewId: id,
664
824
  url: `${origin2}/review/${id}`,
665
825
  files: record.diff.files.length,
666
- comments: body.comments?.length ?? 0,
826
+ comments: body.comments.length,
667
827
  artifactDir: record.meta.artifactDir,
668
828
  feedbackPath,
669
829
  markdownPath
670
- });
830
+ };
831
+ return c.json(response);
671
832
  });
672
833
  app.post("/api/reviews/:id/resolved", async (c) => {
673
834
  const id = c.req.param("id");
@@ -675,13 +836,17 @@ function createApp(origin2) {
675
836
  if (!existing) {
676
837
  return c.json({ error: "review not found" }, 404);
677
838
  }
678
- if (existing.meta.status !== "submitted" && existing.meta.status !== "resolved") {
839
+ if (!isResolvableReviewStatus(existing.meta.status)) {
679
840
  return c.json({ error: `review is ${existing.meta.status} and cannot be resolved` }, 409);
680
841
  }
681
842
  if (!existing.feedback) {
682
843
  return c.json({ error: "submitted feedback not found" }, 409);
683
844
  }
684
- const body = await c.req.json().catch(() => ({}));
845
+ const parsed = await readJsonBody(c, isResolutionRequest, "resolution request");
846
+ if (!parsed.ok) {
847
+ return parsed.response;
848
+ }
849
+ const body = parsed.body;
685
850
  return c.json(await reviewStore.markResolved(id, body.summary));
686
851
  });
687
852
  app.post("/api/reviews/:id/comments/:commentId/resolved", async (c) => {
@@ -691,13 +856,17 @@ function createApp(origin2) {
691
856
  if (!existing) {
692
857
  return c.json({ error: "review not found" }, 404);
693
858
  }
694
- if (existing.meta.status !== "submitted" && existing.meta.status !== "resolved") {
859
+ if (!isResolvableReviewStatus(existing.meta.status)) {
695
860
  return c.json({ error: `review is ${existing.meta.status} and cannot be resolved` }, 409);
696
861
  }
697
862
  if (!existing.feedback?.comments.some((comment) => comment.id === commentId)) {
698
863
  return c.json({ error: "comment not found" }, 404);
699
864
  }
700
- const body = await c.req.json().catch(() => ({}));
865
+ const parsed = await readJsonBody(c, isResolutionRequest, "resolution request");
866
+ if (!parsed.ok) {
867
+ return parsed.response;
868
+ }
869
+ const body = parsed.body;
701
870
  return c.json(await reviewStore.resolveComment(id, commentId, body.summary));
702
871
  });
703
872
  app.delete("/api/reviews/:id/comments/:commentId/resolved", async (c) => {
@@ -707,7 +876,7 @@ function createApp(origin2) {
707
876
  if (!existing) {
708
877
  return c.json({ error: "review not found" }, 404);
709
878
  }
710
- if (existing.meta.status !== "submitted" && existing.meta.status !== "resolved") {
879
+ if (!isResolvableReviewStatus(existing.meta.status)) {
711
880
  return c.json({ error: `review is ${existing.meta.status} and cannot be resolved` }, 409);
712
881
  }
713
882
  if (!existing.feedback?.comments.some((comment) => comment.id === commentId)) {
@@ -730,13 +899,13 @@ function createApp(origin2) {
730
899
  }
731
900
  async function serveAsset(c) {
732
901
  const requestPath = new URL(c.req.url).pathname.replace(/^\/assets\//, "");
733
- const normalized = path2.normalize(requestPath).replace(/^(\.\.(\/|\\|$))+/, "");
734
- const assetPath = path2.join(webRoot, "assets", normalized);
902
+ const normalized = path3.normalize(requestPath).replace(/^(\.\.(\/|\\|$))+/, "");
903
+ const assetPath = path3.join(webRoot, "assets", normalized);
735
904
  try {
736
905
  const body = await readFile3(assetPath);
737
906
  return new Response(body, {
738
907
  headers: {
739
- "content-type": mimeTypes[path2.extname(assetPath)] ?? "application/octet-stream"
908
+ "content-type": mimeTypes[path3.extname(assetPath)] ?? "application/octet-stream"
740
909
  }
741
910
  });
742
911
  } catch {
@@ -745,7 +914,7 @@ async function serveAsset(c) {
745
914
  }
746
915
  async function serveIndex() {
747
916
  try {
748
- const body = await readFile3(path2.join(webRoot, "index.html"));
917
+ const body = await readFile3(path3.join(webRoot, "index.html"));
749
918
  return new Response(body, {
750
919
  headers: { "content-type": "text/html; charset=utf-8" }
751
920
  });
@@ -756,7 +925,7 @@ async function serveIndex() {
756
925
  function serveRootFile(fileName, contentType) {
757
926
  return async () => {
758
927
  try {
759
- const body = await readFile3(path2.join(webRoot, fileName));
928
+ const body = await readFile3(path3.join(webRoot, fileName));
760
929
  return new Response(body, {
761
930
  headers: { "content-type": contentType }
762
931
  });
@@ -765,6 +934,28 @@ function serveRootFile(fileName, contentType) {
765
934
  }
766
935
  };
767
936
  }
937
+ async function readJsonBody(c, guard, label) {
938
+ let body;
939
+ try {
940
+ body = await c.req.json();
941
+ } catch (error) {
942
+ return {
943
+ ok: false,
944
+ response: c.json({ error: `invalid JSON body: ${formatError2(error)}` }, 400)
945
+ };
946
+ }
947
+ try {
948
+ return { ok: true, body: parseJsonValue(body, guard, label) };
949
+ } catch (error) {
950
+ return {
951
+ ok: false,
952
+ response: c.json({ error: formatError2(error) }, 400)
953
+ };
954
+ }
955
+ }
956
+ function formatError2(error) {
957
+ return error instanceof Error ? error.message : String(error);
958
+ }
768
959
 
769
960
  // src/server/daemon.ts
770
961
  var port = Number(process.env.GLOSS_PORT ?? "0");