@demon-utils/playwright 0.1.5 → 0.1.7

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.
@@ -3,116 +3,314 @@ import { describe, test, expect } from "bun:test";
3
3
  import type { SpawnFn } from "./review.ts";
4
4
  import {
5
5
  buildReviewPrompt,
6
+ extractJson,
6
7
  invokeClaude,
7
- parseReviewMetadata,
8
+ parseLlmResponse,
8
9
  } from "./review.ts";
9
10
 
11
+ function makeValidResponse(overrides?: Record<string, unknown>) {
12
+ return {
13
+ demos: [{ file: "login.webm", summary: "Shows a login flow" }],
14
+ review: {
15
+ summary: "Good changes overall",
16
+ highlights: ["Clean implementation"],
17
+ verdict: "approve",
18
+ verdictReason: "No major issues found",
19
+ issues: [],
20
+ },
21
+ ...overrides,
22
+ };
23
+ }
24
+
10
25
  describe("buildReviewPrompt", () => {
11
26
  test("includes all filenames in the prompt", () => {
12
- const prompt = buildReviewPrompt(["login-flow.webm", "signup.webm"]);
13
- expect(prompt).toContain("- login-flow.webm");
14
- expect(prompt).toContain("- signup.webm");
27
+ const stepsMap = {
28
+ "login-flow.webm": [],
29
+ "signup.webm": [],
30
+ };
31
+ const prompt = buildReviewPrompt({ filenames: ["login-flow.webm", "signup.webm"], stepsMap });
32
+ expect(prompt).toContain("login-flow.webm");
33
+ expect(prompt).toContain("signup.webm");
15
34
  });
16
35
 
17
36
  test("includes schema instructions", () => {
18
- const prompt = buildReviewPrompt(["demo.webm"]);
37
+ const stepsMap = { "demo.webm": [] };
38
+ const prompt = buildReviewPrompt({ filenames: ["demo.webm"], stepsMap });
19
39
  expect(prompt).toContain('"demos"');
20
40
  expect(prompt).toContain('"file"');
21
41
  expect(prompt).toContain('"summary"');
22
- expect(prompt).toContain('"annotations"');
23
- expect(prompt).toContain('"timestampSeconds"');
42
+ });
43
+
44
+ test("does not include annotations in schema", () => {
45
+ const stepsMap = { "demo.webm": [] };
46
+ const prompt = buildReviewPrompt({ filenames: ["demo.webm"], stepsMap });
47
+ expect(prompt).not.toContain('"annotations"');
48
+ });
49
+
50
+ test("includes step data in prompt", () => {
51
+ const stepsMap = {
52
+ "login-flow.webm": [
53
+ { text: "Navigate to login", timestampSeconds: 0.5 },
54
+ { text: "Enter credentials", timestampSeconds: 3.2 },
55
+ ],
56
+ };
57
+ const prompt = buildReviewPrompt({ filenames: ["login-flow.webm"], stepsMap });
58
+ expect(prompt).toContain("[0.5s] Navigate to login");
59
+ expect(prompt).toContain("[3.2s] Enter credentials");
60
+ expect(prompt).toContain("Recorded steps:");
61
+ });
62
+
63
+ test("shows (no steps recorded) when steps array is empty", () => {
64
+ const stepsMap = { "demo.webm": [] };
65
+ const prompt = buildReviewPrompt({ filenames: ["demo.webm"], stepsMap });
66
+ expect(prompt).toContain("(no steps recorded)");
67
+ });
68
+
69
+ test("shows (no steps recorded) when video not in stepsMap", () => {
70
+ const stepsMap = { "other.webm": [] };
71
+ const prompt = buildReviewPrompt({ filenames: ["demo.webm"], stepsMap });
72
+ expect(prompt).toContain("(no steps recorded)");
73
+ });
74
+
75
+ test("includes git diff when provided", () => {
76
+ const prompt = buildReviewPrompt({
77
+ filenames: ["demo.webm"],
78
+ stepsMap: { "demo.webm": [] },
79
+ gitDiff: "diff --git a/file.ts\n+added line",
80
+ });
81
+ expect(prompt).toContain("## Git Diff");
82
+ expect(prompt).toContain("+added line");
83
+ });
84
+
85
+ test("does not include git diff section when not provided", () => {
86
+ const prompt = buildReviewPrompt({
87
+ filenames: ["demo.webm"],
88
+ stepsMap: { "demo.webm": [] },
89
+ });
90
+ expect(prompt).not.toContain("## Git Diff");
91
+ });
92
+
93
+ test("includes guidelines when provided", () => {
94
+ const prompt = buildReviewPrompt({
95
+ filenames: ["demo.webm"],
96
+ stepsMap: { "demo.webm": [] },
97
+ guidelines: ["# CLAUDE.md\nUse dependency injection"],
98
+ });
99
+ expect(prompt).toContain("## Coding Guidelines");
100
+ expect(prompt).toContain("Use dependency injection");
101
+ });
102
+
103
+ test("does not include guidelines section when empty", () => {
104
+ const prompt = buildReviewPrompt({
105
+ filenames: ["demo.webm"],
106
+ stepsMap: { "demo.webm": [] },
107
+ guidelines: [],
108
+ });
109
+ expect(prompt).not.toContain("## Coding Guidelines");
110
+ });
111
+
112
+ test("truncates git diff at 50k characters", () => {
113
+ const longDiff = "x".repeat(60_000);
114
+ const prompt = buildReviewPrompt({
115
+ filenames: ["demo.webm"],
116
+ stepsMap: { "demo.webm": [] },
117
+ gitDiff: longDiff,
118
+ });
119
+ expect(prompt).toContain("diff truncated at 50k characters");
120
+ expect(prompt).not.toContain("x".repeat(60_000));
121
+ });
122
+
123
+ test("includes review schema in prompt", () => {
124
+ const prompt = buildReviewPrompt({
125
+ filenames: ["demo.webm"],
126
+ stepsMap: { "demo.webm": [] },
127
+ });
128
+ expect(prompt).toContain('"review"');
129
+ expect(prompt).toContain('"verdict"');
130
+ expect(prompt).toContain('"issues"');
131
+ expect(prompt).toContain('"highlights"');
24
132
  });
25
133
  });
26
134
 
27
- describe("parseReviewMetadata", () => {
28
- const validInput = JSON.stringify({
29
- demos: [
30
- {
31
- file: "login.webm",
32
- summary: "Shows a login flow",
33
- annotations: [{ timestampSeconds: 0, text: "Start" }],
34
- },
35
- ],
135
+ describe("extractJson", () => {
136
+ test("returns raw string when it is valid JSON", () => {
137
+ const json = '{"key": "value"}';
138
+ expect(extractJson(json)).toBe(json);
139
+ });
140
+
141
+ test("strips preamble text before JSON object", () => {
142
+ const raw = 'Here is the review.\n\n{"demos": []}';
143
+ expect(extractJson(raw)).toBe('{"demos": []}');
144
+ });
145
+
146
+ test("strips trailing text after JSON object", () => {
147
+ const raw = '{"demos": []}\n\nHope this helps!';
148
+ expect(extractJson(raw)).toBe('{"demos": []}');
149
+ });
150
+
151
+ test("strips both preamble and trailing text", () => {
152
+ const raw = 'Let me analyze this.\n{"demos": []}\nDone.';
153
+ expect(extractJson(raw)).toBe('{"demos": []}');
154
+ });
155
+
156
+ test("handles nested braces correctly", () => {
157
+ const json = '{"a": {"b": "c"}}';
158
+ const raw = `Some preamble\n${json}\nsome trailing`;
159
+ expect(extractJson(raw)).toBe(json);
160
+ });
161
+
162
+ test("throws when no JSON object found", () => {
163
+ expect(() => extractJson("no json here at all")).toThrow("No JSON object found");
36
164
  });
37
165
 
166
+ test("throws when only opening brace", () => {
167
+ expect(() => extractJson("just { nothing")).toThrow("No JSON object found");
168
+ });
169
+ });
170
+
171
+ describe("parseLlmResponse", () => {
38
172
  test("parses valid input correctly", () => {
39
- const result = parseReviewMetadata(validInput);
173
+ const result = parseLlmResponse(JSON.stringify(makeValidResponse()));
40
174
  expect(result.demos).toHaveLength(1);
41
175
  expect(result.demos[0]!.file).toBe("login.webm");
42
176
  expect(result.demos[0]!.summary).toBe("Shows a login flow");
43
- expect(result.demos[0]!.annotations).toHaveLength(1);
44
- expect(result.demos[0]!.annotations[0]!.timestampSeconds).toBe(0);
45
- expect(result.demos[0]!.annotations[0]!.text).toBe("Start");
177
+ expect(result.review.verdict).toBe("approve");
178
+ expect(result.review.summary).toBe("Good changes overall");
179
+ expect(result.review.highlights).toEqual(["Clean implementation"]);
180
+ expect(result.review.issues).toEqual([]);
181
+ });
182
+
183
+ test("parses response with issues", () => {
184
+ const input = makeValidResponse({
185
+ review: {
186
+ summary: "Needs work",
187
+ highlights: ["Good tests"],
188
+ verdict: "request_changes",
189
+ verdictReason: "Major bug found",
190
+ issues: [
191
+ { severity: "major", description: "Null pointer dereference" },
192
+ { severity: "minor", description: "Missing error message" },
193
+ { severity: "nit", description: "Inconsistent naming" },
194
+ ],
195
+ },
196
+ });
197
+ const result = parseLlmResponse(JSON.stringify(input));
198
+ expect(result.review.issues).toHaveLength(3);
199
+ expect(result.review.issues[0]!.severity).toBe("major");
200
+ expect(result.review.verdict).toBe("request_changes");
201
+ });
202
+
203
+ test("parses response with preamble text before JSON", () => {
204
+ const json = JSON.stringify(makeValidResponse());
205
+ const raw = `Let me review the changes.\n\n${json}`;
206
+ const result = parseLlmResponse(raw);
207
+ expect(result.demos).toHaveLength(1);
208
+ expect(result.review.verdict).toBe("approve");
46
209
  });
47
210
 
48
- test("throws on invalid JSON", () => {
49
- expect(() => parseReviewMetadata("not json")).toThrow("Invalid JSON");
211
+ test("parses response with trailing text after JSON", () => {
212
+ const json = JSON.stringify(makeValidResponse());
213
+ const raw = `${json}\n\nHope this helps!`;
214
+ const result = parseLlmResponse(raw);
215
+ expect(result.demos).toHaveLength(1);
216
+ });
217
+
218
+ test("throws when no JSON object found", () => {
219
+ expect(() => parseLlmResponse("not json at all")).toThrow("No JSON object found");
50
220
  });
51
221
 
52
222
  test("throws when demos array is missing", () => {
53
- expect(() => parseReviewMetadata("{}")).toThrow("Missing 'demos' array");
223
+ expect(() => parseLlmResponse("{}")).toThrow("Missing 'demos' array");
54
224
  });
55
225
 
56
226
  test("throws when demos is not an array", () => {
57
- expect(() => parseReviewMetadata('{"demos": "nope"}')).toThrow(
227
+ expect(() => parseLlmResponse('{"demos": "nope"}')).toThrow(
58
228
  "'demos' must be an array",
59
229
  );
60
230
  });
61
231
 
62
232
  test("throws when demo is missing file", () => {
63
233
  expect(() =>
64
- parseReviewMetadata(
65
- JSON.stringify({ demos: [{ summary: "x", annotations: [] }] }),
234
+ parseLlmResponse(
235
+ JSON.stringify({ demos: [{ summary: "x" }], review: makeValidResponse().review }),
66
236
  ),
67
237
  ).toThrow("'file' string");
68
238
  });
69
239
 
70
240
  test("throws when demo is missing summary", () => {
71
241
  expect(() =>
72
- parseReviewMetadata(
73
- JSON.stringify({ demos: [{ file: "x", annotations: [] }] }),
242
+ parseLlmResponse(
243
+ JSON.stringify({ demos: [{ file: "x" }], review: makeValidResponse().review }),
74
244
  ),
75
245
  ).toThrow("'summary' string");
76
246
  });
77
247
 
78
- test("throws when annotations is missing", () => {
248
+ test("throws when review object is missing", () => {
79
249
  expect(() =>
80
- parseReviewMetadata(
250
+ parseLlmResponse(
81
251
  JSON.stringify({ demos: [{ file: "x", summary: "s" }] }),
82
252
  ),
83
- ).toThrow("'annotations' array");
253
+ ).toThrow("Missing 'review' object");
84
254
  });
85
255
 
86
- test("throws when annotation has wrong timestampSeconds type", () => {
87
- expect(() =>
88
- parseReviewMetadata(
89
- JSON.stringify({
90
- demos: [
91
- {
92
- file: "x",
93
- summary: "s",
94
- annotations: [{ timestampSeconds: "0", text: "t" }],
95
- },
96
- ],
97
- }),
98
- ),
99
- ).toThrow("'timestampSeconds' number");
256
+ test("throws when review.verdict is invalid", () => {
257
+ const input = makeValidResponse({
258
+ review: { ...makeValidResponse().review, verdict: "maybe" },
259
+ });
260
+ expect(() => parseLlmResponse(JSON.stringify(input))).toThrow(
261
+ "review.verdict must be 'approve' or 'request_changes'",
262
+ );
100
263
  });
101
264
 
102
- test("throws when annotation has wrong text type", () => {
103
- expect(() =>
104
- parseReviewMetadata(
105
- JSON.stringify({
106
- demos: [
107
- {
108
- file: "x",
109
- summary: "s",
110
- annotations: [{ timestampSeconds: 0, text: 123 }],
111
- },
112
- ],
113
- }),
114
- ),
115
- ).toThrow("'text' string");
265
+ test("throws when review.highlights is empty", () => {
266
+ const input = makeValidResponse({
267
+ review: { ...makeValidResponse().review, highlights: [] },
268
+ });
269
+ expect(() => parseLlmResponse(JSON.stringify(input))).toThrow(
270
+ "review.highlights must not be empty",
271
+ );
272
+ });
273
+
274
+ test("throws when review.summary is missing", () => {
275
+ const input = makeValidResponse({
276
+ review: { ...makeValidResponse().review, summary: 42 },
277
+ });
278
+ expect(() => parseLlmResponse(JSON.stringify(input))).toThrow(
279
+ "review.summary must be a string",
280
+ );
281
+ });
282
+
283
+ test("throws when review.verdictReason is missing", () => {
284
+ const input = makeValidResponse({
285
+ review: { ...makeValidResponse().review, verdictReason: null },
286
+ });
287
+ expect(() => parseLlmResponse(JSON.stringify(input))).toThrow(
288
+ "review.verdictReason must be a string",
289
+ );
290
+ });
291
+
292
+ test("throws when issue has invalid severity", () => {
293
+ const input = makeValidResponse({
294
+ review: {
295
+ ...makeValidResponse().review,
296
+ issues: [{ severity: "critical", description: "bad" }],
297
+ },
298
+ });
299
+ expect(() => parseLlmResponse(JSON.stringify(input))).toThrow(
300
+ "Each issue severity must be 'major', 'minor', or 'nit'",
301
+ );
302
+ });
303
+
304
+ test("throws when issue is missing description", () => {
305
+ const input = makeValidResponse({
306
+ review: {
307
+ ...makeValidResponse().review,
308
+ issues: [{ severity: "major" }],
309
+ },
310
+ });
311
+ expect(() => parseLlmResponse(JSON.stringify(input))).toThrow(
312
+ "Each issue must have a 'description' string",
313
+ );
116
314
  });
117
315
  });
118
316
 
package/src/review.ts CHANGED
@@ -1,5 +1,3 @@
1
- import type { ReviewMetadata } from "./review-types.ts";
2
-
3
1
  export type SpawnFn = (
4
2
  cmd: string[],
5
3
  ) => { exitCode: Promise<number>; stdout: ReadableStream<Uint8Array> };
@@ -9,33 +7,84 @@ export interface InvokeClaudeOptions {
9
7
  spawn?: SpawnFn;
10
8
  }
11
9
 
12
- export function buildReviewPrompt(filenames: string[]): string {
13
- const fileList = filenames.map((f) => `- ${f}`).join("\n");
10
+ const GIT_DIFF_MAX_CHARS = 50_000;
11
+
12
+ export interface BuildReviewPromptOptions {
13
+ filenames: string[];
14
+ stepsMap: Record<string, Array<{ text: string; timestampSeconds: number }>>;
15
+ gitDiff?: string;
16
+ guidelines?: string[];
17
+ }
18
+
19
+ export function buildReviewPrompt(options: BuildReviewPromptOptions): string {
20
+ const { filenames, stepsMap, gitDiff, guidelines } = options;
14
21
 
15
- return `You are given the following .webm demo video filenames:
22
+ const demoEntries = filenames.map((f) => {
23
+ const steps = stepsMap[f] ?? [];
24
+ const stepLines = steps
25
+ .map((s) => `- [${s.timestampSeconds}s] ${s.text}`)
26
+ .join("\n");
27
+ return `Video: ${f}\nRecorded steps:\n${stepLines || "(no steps recorded)"}`;
28
+ });
16
29
 
17
- ${fileList}
30
+ const sections: string[] = [];
18
31
 
19
- Based on the filenames, generate a JSON object matching this exact schema:
32
+ if (guidelines && guidelines.length > 0) {
33
+ sections.push(`## Coding Guidelines\n\n${guidelines.join("\n\n")}`);
34
+ }
35
+
36
+ if (gitDiff) {
37
+ let diff = gitDiff;
38
+ if (diff.length > GIT_DIFF_MAX_CHARS) {
39
+ diff = diff.slice(0, GIT_DIFF_MAX_CHARS) + "\n\n... (diff truncated at 50k characters)";
40
+ }
41
+ sections.push(`## Git Diff\n\n\`\`\`diff\n${diff}\n\`\`\``);
42
+ }
43
+
44
+ sections.push(`## Demo Recordings\n\n${demoEntries.join("\n\n")}`);
45
+
46
+ return `You are a code reviewer. You are given a git diff, coding guidelines, and demo recordings that show the feature in action.
47
+
48
+ ${sections.join("\n\n")}
49
+
50
+ ## Task
51
+
52
+ Review the code changes and demo recordings. Generate a JSON object matching this exact schema:
20
53
 
21
54
  {
22
55
  "demos": [
23
56
  {
24
57
  "file": "<filename>",
25
- "summary": "<a short sentence describing what the demo likely shows>",
26
- "annotations": [
27
- { "timestampSeconds": <number>, "text": "<annotation text>" }
28
- ]
58
+ "summary": "<a meaningful sentence describing what this demo showcases based on the steps>"
29
59
  }
30
- ]
60
+ ],
61
+ "review": {
62
+ "summary": "<2-3 sentence overview of the changes>",
63
+ "highlights": ["<positive aspect 1>", "<positive aspect 2>"],
64
+ "verdict": "approve" | "request_changes",
65
+ "verdictReason": "<one sentence justifying the verdict>",
66
+ "issues": [
67
+ {
68
+ "severity": "major" | "minor" | "nit",
69
+ "description": "<what the issue is and how to fix it>"
70
+ }
71
+ ]
72
+ }
31
73
  }
32
74
 
33
75
  Rules:
34
76
  - Return ONLY the JSON object, no markdown fences or extra text.
35
77
  - Include one entry in "demos" for each filename, in the same order.
36
- - Infer the summary and annotations from the filename.
37
- - Each demo should have at least one annotation starting at timestampSeconds 0.
38
- - "file" must exactly match the provided filename.`;
78
+ - "file" must exactly match the provided filename.
79
+ - "verdict" must be exactly "approve" or "request_changes".
80
+ - Use "request_changes" if there are any "major" issues.
81
+ - "severity" must be exactly "major", "minor", or "nit".
82
+ - "major": bugs, security issues, broken functionality, guideline violations.
83
+ - "minor": code quality, readability, missing edge cases.
84
+ - "nit": style, naming, trivial improvements.
85
+ - "highlights" must have at least one entry.
86
+ - "issues" can be an empty array if there are no issues.
87
+ - Verify that demo steps demonstrate the acceptance criteria being met.`;
39
88
  }
40
89
 
41
90
  export async function invokeClaude(
@@ -68,10 +117,46 @@ export async function invokeClaude(
68
117
  return output.trim();
69
118
  }
70
119
 
71
- export function parseReviewMetadata(raw: string): ReviewMetadata {
120
+ import type { IssueSeverity, ReviewVerdict } from "./review-types.ts";
121
+
122
+ export interface LlmReviewResponse {
123
+ demos: Array<{ file: string; summary: string }>;
124
+ review: {
125
+ summary: string;
126
+ highlights: string[];
127
+ verdict: ReviewVerdict;
128
+ verdictReason: string;
129
+ issues: Array<{ severity: IssueSeverity; description: string }>;
130
+ };
131
+ }
132
+
133
+ const VALID_VERDICTS: ReadonlySet<string> = new Set(["approve", "request_changes"]);
134
+ const VALID_SEVERITIES: ReadonlySet<string> = new Set(["major", "minor", "nit"]);
135
+
136
+ export function extractJson(raw: string): string {
137
+ // Try raw string first
138
+ try {
139
+ JSON.parse(raw);
140
+ return raw;
141
+ } catch {
142
+ // look for first { and last }
143
+ }
144
+
145
+ const start = raw.indexOf("{");
146
+ const end = raw.lastIndexOf("}");
147
+ if (start === -1 || end === -1 || end <= start) {
148
+ throw new Error(`No JSON object found in LLM response: ${raw.slice(0, 200)}`);
149
+ }
150
+
151
+ return raw.slice(start, end + 1);
152
+ }
153
+
154
+ export function parseLlmResponse(raw: string): LlmReviewResponse {
155
+ const jsonStr = extractJson(raw);
156
+
72
157
  let parsed: unknown;
73
158
  try {
74
- parsed = JSON.parse(raw);
159
+ parsed = JSON.parse(jsonStr);
75
160
  } catch {
76
161
  throw new Error(`Invalid JSON from LLM: ${raw.slice(0, 200)}`);
77
162
  }
@@ -97,39 +182,76 @@ export function parseReviewMetadata(raw: string): ReviewMetadata {
97
182
  if (typeof d["summary"] !== "string") {
98
183
  throw new Error("Each demo must have a 'summary' string");
99
184
  }
100
- if (!Array.isArray(d["annotations"])) {
101
- throw new Error("Each demo must have an 'annotations' array");
185
+ }
186
+
187
+ if (typeof obj["review"] !== "object" || obj["review"] === null) {
188
+ throw new Error("Missing 'review' object in response");
189
+ }
190
+
191
+ const review = obj["review"] as Record<string, unknown>;
192
+
193
+ if (typeof review["summary"] !== "string") {
194
+ throw new Error("review.summary must be a string");
195
+ }
196
+
197
+ if (!Array.isArray(review["highlights"])) {
198
+ throw new Error("review.highlights must be an array");
199
+ }
200
+ if (review["highlights"].length === 0) {
201
+ throw new Error("review.highlights must not be empty");
202
+ }
203
+ for (const h of review["highlights"]) {
204
+ if (typeof h !== "string") {
205
+ throw new Error("Each highlight must be a string");
102
206
  }
207
+ }
103
208
 
104
- for (const ann of d["annotations"] as unknown[]) {
105
- if (typeof ann !== "object" || ann === null) {
106
- throw new Error("Each annotation must be an object");
107
- }
108
- const a = ann as Record<string, unknown>;
109
- if (typeof a["timestampSeconds"] !== "number") {
110
- throw new Error("Each annotation must have a 'timestampSeconds' number");
111
- }
112
- if (typeof a["text"] !== "string") {
113
- throw new Error("Each annotation must have a 'text' string");
114
- }
209
+ if (typeof review["verdict"] !== "string" || !VALID_VERDICTS.has(review["verdict"])) {
210
+ throw new Error("review.verdict must be 'approve' or 'request_changes'");
211
+ }
212
+
213
+ if (typeof review["verdictReason"] !== "string") {
214
+ throw new Error("review.verdictReason must be a string");
215
+ }
216
+
217
+ if (!Array.isArray(review["issues"])) {
218
+ throw new Error("review.issues must be an array");
219
+ }
220
+
221
+ for (const issue of review["issues"] as unknown[]) {
222
+ if (typeof issue !== "object" || issue === null) {
223
+ throw new Error("Each issue must be an object");
224
+ }
225
+ const i = issue as Record<string, unknown>;
226
+ if (typeof i["severity"] !== "string" || !VALID_SEVERITIES.has(i["severity"])) {
227
+ throw new Error("Each issue severity must be 'major', 'minor', or 'nit'");
228
+ }
229
+ if (typeof i["description"] !== "string") {
230
+ throw new Error("Each issue must have a 'description' string");
115
231
  }
116
232
  }
117
233
 
118
- return parsed as ReviewMetadata;
234
+ return parsed as LlmReviewResponse;
119
235
  }
120
236
 
237
+ import { spawn } from "node:child_process";
238
+ import { Readable } from "node:stream";
239
+
121
240
  function defaultSpawn(
122
241
  cmd: string[],
123
242
  ): { exitCode: Promise<number>; stdout: ReadableStream<Uint8Array> } {
124
243
  const [command, ...args] = cmd;
125
- const proc = Bun.spawn([command!, ...args], {
126
- stdout: "pipe",
127
- stderr: "pipe",
244
+ const proc = spawn(command!, args, {
245
+ stdio: ["ignore", "pipe", "pipe"],
128
246
  });
129
- return {
130
- exitCode: proc.exited,
131
- stdout: proc.stdout as unknown as ReadableStream<Uint8Array>,
132
- };
247
+
248
+ const exitCode = new Promise<number>((resolve) => {
249
+ proc.on("close", (code) => resolve(code ?? 1));
250
+ });
251
+
252
+ const stdout = Readable.toWeb(proc.stdout!) as unknown as ReadableStream<Uint8Array>;
253
+
254
+ return { exitCode, stdout };
133
255
  }
134
256
 
135
257
  function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array {