@builder.io/ai-utils 0.56.0 → 0.57.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@builder.io/ai-utils",
3
- "version": "0.56.0",
3
+ "version": "0.57.0",
4
4
  "description": "Builder.io AI utils",
5
5
  "files": [
6
6
  "src"
package/src/codegen.d.ts CHANGED
@@ -768,6 +768,12 @@ export interface PRReviewComment {
768
768
  file_path: string;
769
769
  line: number;
770
770
  start_line?: number;
771
+ /**
772
+ * Diff side. RIGHT (default) = NEW file (added/context). LEFT = OLD file
773
+ * (removed/context). Use LEFT only when commenting on deleted code with
774
+ * no semantically related new-side anchor.
775
+ */
776
+ side?: "LEFT" | "RIGHT";
771
777
  title: string;
772
778
  body: string;
773
779
  severity: ReviewSeverity;
@@ -862,6 +868,11 @@ export interface CodeGenToolMap {
862
868
  EscalateToPlanner: EscalateToPlanner;
863
869
  PullPrototype: PullPrototypeToolInput;
864
870
  ConnectMCP: ConnectMCPToolInput;
871
+ EnsurePR: EnsurePRToolInput;
872
+ }
873
+ export interface EnsurePRToolInput {
874
+ project_id: string;
875
+ branch_name: string;
865
876
  }
866
877
  export interface EscalateToPlanner {
867
878
  /** What's blocking execution */
@@ -0,0 +1,47 @@
1
+ import type { PRReviewComment } from "./codegen.js";
2
+ export interface NewSideLine {
3
+ line: number;
4
+ content: string;
5
+ }
6
+ export interface HunkRange {
7
+ oldStart: number;
8
+ oldEnd: number;
9
+ newStart: number;
10
+ newEnd: number;
11
+ /**
12
+ * RIGHT-side commentable lines: added (`+`) and context lines, indexed by
13
+ * the new-file line number.
14
+ */
15
+ newSideLines: NewSideLine[];
16
+ /**
17
+ * LEFT-side commentable lines: removed (`-`) and context lines, indexed by
18
+ * the old-file line number.
19
+ */
20
+ oldSideLines: NewSideLine[];
21
+ }
22
+ export type FileHunkInfo = {
23
+ kind: "binary";
24
+ } | {
25
+ kind: "deleted";
26
+ oldLineCount: number;
27
+ } | {
28
+ kind: "renamed-only";
29
+ } | {
30
+ kind: "new";
31
+ lineCount: number;
32
+ } | {
33
+ kind: "modified";
34
+ hunks: HunkRange[];
35
+ };
36
+ export interface InvalidComment {
37
+ comment: PRReviewComment;
38
+ reason: string;
39
+ validRanges?: string;
40
+ /** Concrete commentable lines (with content) on the relevant side. */
41
+ validLines?: NewSideLine[];
42
+ }
43
+ export declare function parseDiffHunks(diff: string): Map<string, FileHunkInfo>;
44
+ export declare function validateCommentPositions(comments: PRReviewComment[], hunks: Map<string, FileHunkInfo>): {
45
+ valid: PRReviewComment[];
46
+ invalid: InvalidComment[];
47
+ };
@@ -0,0 +1,269 @@
1
+ const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
2
+ export function parseDiffHunks(diff) {
3
+ const map = new Map();
4
+ if (!diff)
5
+ return map;
6
+ const lines = diff.split("\n");
7
+ let path = null;
8
+ let info = null;
9
+ let currentHunk = null;
10
+ let nextNew = 0;
11
+ let nextOld = 0;
12
+ const flush = () => {
13
+ if (path && info)
14
+ map.set(path, info);
15
+ };
16
+ for (const line of lines) {
17
+ if (line.startsWith("diff --git ")) {
18
+ flush();
19
+ const match = line.match(/ b\/(.+)$/);
20
+ path = match ? match[1] : null;
21
+ info = { kind: "modified", hunks: [] };
22
+ currentHunk = null;
23
+ continue;
24
+ }
25
+ if (line.startsWith("new file mode")) {
26
+ info = { kind: "new", lineCount: 0 };
27
+ currentHunk = null;
28
+ continue;
29
+ }
30
+ if (line.startsWith("deleted file mode")) {
31
+ info = { kind: "deleted", oldLineCount: 0 };
32
+ currentHunk = null;
33
+ continue;
34
+ }
35
+ if (line.startsWith("Binary files ")) {
36
+ info = { kind: "binary" };
37
+ currentHunk = null;
38
+ continue;
39
+ }
40
+ if (line.startsWith("similarity index ") &&
41
+ (info === null || info === void 0 ? void 0 : info.kind) === "modified" &&
42
+ info.hunks.length === 0) {
43
+ info = { kind: "renamed-only" };
44
+ continue;
45
+ }
46
+ if (line.startsWith("@@")) {
47
+ const m = line.match(HUNK_HEADER);
48
+ if (!m)
49
+ continue;
50
+ const oldStart = Number(m[1]);
51
+ const oldCount = m[2] !== undefined ? Number(m[2]) : 1;
52
+ const newStart = Number(m[3]);
53
+ const newCount = m[4] !== undefined ? Number(m[4]) : 1;
54
+ if ((info === null || info === void 0 ? void 0 : info.kind) === "new") {
55
+ const end = newStart + newCount - 1;
56
+ if (end > info.lineCount)
57
+ info.lineCount = end;
58
+ currentHunk = null;
59
+ continue;
60
+ }
61
+ if ((info === null || info === void 0 ? void 0 : info.kind) === "deleted") {
62
+ const end = oldStart + oldCount - 1;
63
+ if (end > info.oldLineCount)
64
+ info.oldLineCount = end;
65
+ currentHunk = null;
66
+ continue;
67
+ }
68
+ // A renamed-only file with hunks is actually a rename + edit.
69
+ if ((info === null || info === void 0 ? void 0 : info.kind) === "renamed-only") {
70
+ info = { kind: "modified", hunks: [] };
71
+ }
72
+ if ((info === null || info === void 0 ? void 0 : info.kind) === "modified") {
73
+ // Don't coerce zero-count sides into a 1-line range — a hunk like
74
+ // `@@ -10,3 +11,0 @@` has zero new-side lines and must not accept a
75
+ // RIGHT comment at line 11. Using count directly makes the range
76
+ // empty (newEnd < newStart) when count is 0.
77
+ currentHunk = {
78
+ oldStart,
79
+ oldEnd: oldStart + oldCount - 1,
80
+ newStart,
81
+ newEnd: newStart + newCount - 1,
82
+ newSideLines: [],
83
+ oldSideLines: [],
84
+ };
85
+ info.hunks.push(currentHunk);
86
+ nextNew = newStart;
87
+ nextOld = oldStart;
88
+ }
89
+ continue;
90
+ }
91
+ // Inside a hunk: capture per-line content for both sides.
92
+ if (currentHunk && (info === null || info === void 0 ? void 0 : info.kind) === "modified") {
93
+ if (line.startsWith("+")) {
94
+ currentHunk.newSideLines.push({
95
+ line: nextNew,
96
+ content: line.slice(1),
97
+ });
98
+ nextNew++;
99
+ }
100
+ else if (line.startsWith("-")) {
101
+ currentHunk.oldSideLines.push({
102
+ line: nextOld,
103
+ content: line.slice(1),
104
+ });
105
+ nextOld++;
106
+ }
107
+ else if (line.startsWith("\\")) {
108
+ // "" marker — skip.
109
+ }
110
+ else {
111
+ // Context line — present on both sides at separate numbers.
112
+ const content = line.startsWith(" ") ? line.slice(1) : line;
113
+ currentHunk.newSideLines.push({ line: nextNew, content });
114
+ currentHunk.oldSideLines.push({ line: nextOld, content });
115
+ nextNew++;
116
+ nextOld++;
117
+ }
118
+ }
119
+ }
120
+ flush();
121
+ return map;
122
+ }
123
+ export function validateCommentPositions(comments, hunks) {
124
+ var _a;
125
+ const valid = [];
126
+ const invalid = [];
127
+ for (const comment of comments) {
128
+ const info = hunks.get(comment.file_path);
129
+ const side = (_a = comment.side) !== null && _a !== void 0 ? _a : "RIGHT";
130
+ if (!info) {
131
+ const files = [...hunks.keys()];
132
+ const sample = files.slice(0, 10).join(", ");
133
+ const suffix = files.length > 10 ? `, … (+${files.length - 10} more)` : "";
134
+ invalid.push({
135
+ comment,
136
+ reason: `file is not in the PR diff. Files in diff: ${sample}${suffix}`,
137
+ });
138
+ continue;
139
+ }
140
+ if (info.kind === "binary" || info.kind === "renamed-only") {
141
+ invalid.push({
142
+ comment,
143
+ reason: `file is ${info.kind}; no inline comments possible`,
144
+ });
145
+ continue;
146
+ }
147
+ // GitHub requires start_line < line for multi-line comments. Reject only
148
+ // start_line > line — start_line === line is harmless (utils.ts:1114 drops
149
+ // the redundant start_line and GitHub accepts it as a single-line comment).
150
+ if (comment.start_line !== undefined && comment.start_line > comment.line) {
151
+ invalid.push({
152
+ comment,
153
+ reason: `start_line ${comment.start_line} must be less than line ${comment.line} for multi-line comments`,
154
+ });
155
+ continue;
156
+ }
157
+ // RIGHT-side validation
158
+ if (side === "RIGHT") {
159
+ if (info.kind === "deleted") {
160
+ invalid.push({
161
+ comment,
162
+ reason: "file is deleted; use side: 'LEFT' to comment on the old version",
163
+ });
164
+ continue;
165
+ }
166
+ if (info.kind === "new") {
167
+ if (comment.line < 1 || comment.line > info.lineCount) {
168
+ invalid.push({
169
+ comment,
170
+ reason: `line ${comment.line} outside new file (lines 1–${info.lineCount})`,
171
+ });
172
+ continue;
173
+ }
174
+ if (comment.start_line !== undefined &&
175
+ (comment.start_line < 1 || comment.start_line > info.lineCount)) {
176
+ invalid.push({
177
+ comment,
178
+ reason: `start_line ${comment.start_line} outside new file (lines 1–${info.lineCount})`,
179
+ });
180
+ continue;
181
+ }
182
+ valid.push(comment);
183
+ continue;
184
+ }
185
+ // modified, RIGHT
186
+ const hunk = info.hunks.find((h) => comment.line >= h.newStart && comment.line <= h.newEnd);
187
+ if (!hunk) {
188
+ invalid.push({
189
+ comment,
190
+ reason: `line ${comment.line} is not inside any hunk on the new side`,
191
+ // Skip hunks with empty new side (e.g. `+11,0`) — `newEnd < newStart`
192
+ // would render as nonsense like "11–10".
193
+ validRanges: info.hunks
194
+ .filter((h) => h.newEnd >= h.newStart)
195
+ .map((h) => `${h.newStart}–${h.newEnd}`)
196
+ .join(", "),
197
+ validLines: info.hunks.flatMap((h) => h.newSideLines),
198
+ });
199
+ continue;
200
+ }
201
+ if (comment.start_line !== undefined &&
202
+ (comment.start_line < hunk.newStart || comment.start_line > hunk.newEnd)) {
203
+ invalid.push({
204
+ comment,
205
+ reason: `multi-line range ${comment.start_line}-${comment.line} crosses hunk boundary`,
206
+ validRanges: `${hunk.newStart}–${hunk.newEnd}`,
207
+ validLines: hunk.newSideLines,
208
+ });
209
+ continue;
210
+ }
211
+ valid.push(comment);
212
+ continue;
213
+ }
214
+ // LEFT-side validation
215
+ if (info.kind === "new") {
216
+ invalid.push({
217
+ comment,
218
+ reason: "file is newly added; LEFT side does not exist (use side: 'RIGHT')",
219
+ });
220
+ continue;
221
+ }
222
+ if (info.kind === "deleted") {
223
+ if (comment.line < 1 || comment.line > info.oldLineCount) {
224
+ invalid.push({
225
+ comment,
226
+ reason: `line ${comment.line} outside deleted file (lines 1–${info.oldLineCount})`,
227
+ });
228
+ continue;
229
+ }
230
+ if (comment.start_line !== undefined &&
231
+ (comment.start_line < 1 || comment.start_line > info.oldLineCount)) {
232
+ invalid.push({
233
+ comment,
234
+ reason: `start_line ${comment.start_line} outside deleted file (lines 1–${info.oldLineCount})`,
235
+ });
236
+ continue;
237
+ }
238
+ valid.push(comment);
239
+ continue;
240
+ }
241
+ // modified, LEFT
242
+ const hunk = info.hunks.find((h) => comment.line >= h.oldStart && comment.line <= h.oldEnd);
243
+ if (!hunk) {
244
+ invalid.push({
245
+ comment,
246
+ reason: `line ${comment.line} (LEFT) is not inside any hunk on the old side`,
247
+ // Skip hunks with empty old side (e.g. `-10,0`) — would render as "11–10".
248
+ validRanges: info.hunks
249
+ .filter((h) => h.oldEnd >= h.oldStart)
250
+ .map((h) => `${h.oldStart}–${h.oldEnd}`)
251
+ .join(", "),
252
+ validLines: info.hunks.flatMap((h) => h.oldSideLines),
253
+ });
254
+ continue;
255
+ }
256
+ if (comment.start_line !== undefined &&
257
+ (comment.start_line < hunk.oldStart || comment.start_line > hunk.oldEnd)) {
258
+ invalid.push({
259
+ comment,
260
+ reason: `multi-line range ${comment.start_line}-${comment.line} crosses hunk boundary on LEFT side`,
261
+ validRanges: `${hunk.oldStart}–${hunk.oldEnd}`,
262
+ validLines: hunk.oldSideLines,
263
+ });
264
+ continue;
265
+ }
266
+ valid.push(comment);
267
+ }
268
+ return { valid, invalid };
269
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,558 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseDiffHunks, validateCommentPositions } from "./diff-hunks.js";
3
+ function comment(partial) {
4
+ return {
5
+ title: "x",
6
+ body: "y",
7
+ severity: "medium",
8
+ ...partial,
9
+ };
10
+ }
11
+ describe("parseDiffHunks", () => {
12
+ it("returns empty map for empty diff", () => {
13
+ expect(parseDiffHunks("").size).toBe(0);
14
+ });
15
+ it("parses a new file", () => {
16
+ const diff = [
17
+ "diff --git a/foo.ts b/foo.ts",
18
+ "new file mode 100644",
19
+ "index 0000000..1234567",
20
+ "--- /dev/null",
21
+ "+++ b/foo.ts",
22
+ "@@ -0,0 +1,42 @@",
23
+ "+line1",
24
+ "+line2",
25
+ ].join("\n");
26
+ const map = parseDiffHunks(diff);
27
+ expect(map.get("foo.ts")).toEqual({ kind: "new", lineCount: 42 });
28
+ });
29
+ it("parses a modified file with multiple hunks", () => {
30
+ const diff = [
31
+ "diff --git a/bar.ts b/bar.ts",
32
+ "index aaa..bbb 100644",
33
+ "--- a/bar.ts",
34
+ "+++ b/bar.ts",
35
+ "@@ -212,38 +212,90 @@ class Foo {",
36
+ " context line",
37
+ "+added",
38
+ "@@ -1024,7 +1093,7 @@ method() {",
39
+ " ctx",
40
+ ].join("\n");
41
+ const info = parseDiffHunks(diff).get("bar.ts");
42
+ expect(info === null || info === void 0 ? void 0 : info.kind).toBe("modified");
43
+ if ((info === null || info === void 0 ? void 0 : info.kind) !== "modified")
44
+ throw new Error("unreachable");
45
+ expect(info.hunks).toMatchObject([
46
+ { oldStart: 212, oldEnd: 249, newStart: 212, newEnd: 301 },
47
+ { oldStart: 1024, oldEnd: 1030, newStart: 1093, newEnd: 1099 },
48
+ ]);
49
+ });
50
+ it("treats omitted ,count as 1 in hunk header", () => {
51
+ const diff = [
52
+ "diff --git a/baz.ts b/baz.ts",
53
+ "--- a/baz.ts",
54
+ "+++ b/baz.ts",
55
+ "@@ -3 +5 @@",
56
+ " line",
57
+ ].join("\n");
58
+ const info = parseDiffHunks(diff).get("baz.ts");
59
+ expect(info === null || info === void 0 ? void 0 : info.kind).toBe("modified");
60
+ if ((info === null || info === void 0 ? void 0 : info.kind) !== "modified")
61
+ throw new Error("unreachable");
62
+ expect(info.hunks).toMatchObject([
63
+ { oldStart: 3, oldEnd: 3, newStart: 5, newEnd: 5 },
64
+ ]);
65
+ });
66
+ it("marks binary files", () => {
67
+ const diff = [
68
+ "diff --git a/img.png b/img.png",
69
+ "index aaa..bbb",
70
+ "Binary files a/img.png and b/img.png differ",
71
+ ].join("\n");
72
+ expect(parseDiffHunks(diff).get("img.png")).toEqual({ kind: "binary" });
73
+ });
74
+ it("marks deleted files", () => {
75
+ const diff = [
76
+ "diff --git a/old.ts b/old.ts",
77
+ "deleted file mode 100644",
78
+ "--- a/old.ts",
79
+ "+++ /dev/null",
80
+ "@@ -1,5 +0,0 @@",
81
+ "-removed",
82
+ ].join("\n");
83
+ expect(parseDiffHunks(diff).get("old.ts")).toEqual({
84
+ kind: "deleted",
85
+ oldLineCount: 5,
86
+ });
87
+ });
88
+ it("marks pure renames as renamed-only when there are no hunks", () => {
89
+ const diff = [
90
+ "diff --git a/from.ts b/to.ts",
91
+ "similarity index 100%",
92
+ "rename from from.ts",
93
+ "rename to to.ts",
94
+ ].join("\n");
95
+ expect(parseDiffHunks(diff).get("to.ts")).toEqual({ kind: "renamed-only" });
96
+ });
97
+ it("promotes rename + edits to modified when hunks appear", () => {
98
+ const diff = [
99
+ "diff --git a/from.ts b/to.ts",
100
+ "similarity index 90%",
101
+ "rename from from.ts",
102
+ "rename to to.ts",
103
+ "@@ -10,3 +10,4 @@",
104
+ "+added",
105
+ ].join("\n");
106
+ const info = parseDiffHunks(diff).get("to.ts");
107
+ expect(info === null || info === void 0 ? void 0 : info.kind).toBe("modified");
108
+ if ((info === null || info === void 0 ? void 0 : info.kind) !== "modified")
109
+ throw new Error("unreachable");
110
+ expect(info.hunks).toMatchObject([
111
+ { oldStart: 10, oldEnd: 12, newStart: 10, newEnd: 13 },
112
+ ]);
113
+ });
114
+ it("handles a multi-file diff", () => {
115
+ var _a;
116
+ const diff = [
117
+ "diff --git a/a.ts b/a.ts",
118
+ "new file mode 100644",
119
+ "@@ -0,0 +1,10 @@",
120
+ "+x",
121
+ "diff --git a/b.ts b/b.ts",
122
+ "--- a/b.ts",
123
+ "+++ b/b.ts",
124
+ "@@ -1,2 +1,3 @@",
125
+ "+y",
126
+ ].join("\n");
127
+ const map = parseDiffHunks(diff);
128
+ expect(map.size).toBe(2);
129
+ expect(map.get("a.ts")).toEqual({ kind: "new", lineCount: 10 });
130
+ expect((_a = map.get("b.ts")) === null || _a === void 0 ? void 0 : _a.kind).toBe("modified");
131
+ });
132
+ });
133
+ describe("validateCommentPositions", () => {
134
+ it("accepts in-range lines on a new file", () => {
135
+ const hunks = parseDiffHunks("diff --git a/new.ts b/new.ts\nnew file mode 100644\n@@ -0,0 +1,412 @@\n+x");
136
+ const { valid, invalid } = validateCommentPositions([
137
+ comment({ file_path: "new.ts", line: 1 }),
138
+ comment({ file_path: "new.ts", line: 412 }),
139
+ ], hunks);
140
+ expect(valid).toHaveLength(2);
141
+ expect(invalid).toHaveLength(0);
142
+ });
143
+ it("rejects out-of-range lines on a new file", () => {
144
+ const hunks = parseDiffHunks("diff --git a/new.ts b/new.ts\nnew file mode 100644\n@@ -0,0 +1,412 @@\n+x");
145
+ const { invalid } = validateCommentPositions([comment({ file_path: "new.ts", line: 413 })], hunks);
146
+ expect(invalid).toHaveLength(1);
147
+ expect(invalid[0].reason).toContain("outside new file (lines 1–412)");
148
+ });
149
+ it("rejects start_line outside new file range", () => {
150
+ const hunks = parseDiffHunks("diff --git a/new.ts b/new.ts\nnew file mode 100644\n@@ -0,0 +1,10 @@\n+x");
151
+ const { invalid } = validateCommentPositions([comment({ file_path: "new.ts", line: 5, start_line: 11 })], hunks);
152
+ expect(invalid).toHaveLength(1);
153
+ expect(invalid[0].reason).toContain("start_line");
154
+ });
155
+ it("rejects line outside any hunk on a modified file", () => {
156
+ const hunks = parseDiffHunks([
157
+ "diff --git a/m.ts b/m.ts",
158
+ "--- a/m.ts",
159
+ "+++ b/m.ts",
160
+ "@@ -212,38 +212,90 @@",
161
+ " ctx",
162
+ "@@ -1024,7 +1093,7 @@",
163
+ " ctx",
164
+ ].join("\n"));
165
+ const { invalid } = validateCommentPositions([comment({ file_path: "m.ts", line: 970 })], hunks);
166
+ expect(invalid).toHaveLength(1);
167
+ expect(invalid[0].reason).toContain("not inside any hunk");
168
+ expect(invalid[0].validRanges).toBe("212–301, 1093–1099");
169
+ });
170
+ it("returns concrete valid line content (not just ranges) so the LLM can pick a meaningful anchor", () => {
171
+ var _a;
172
+ // Mirrors the PR #255 case: a deletion-heavy hunk where the new-side has
173
+ // only punctuation. Content lets the LLM see "367: return {" and either
174
+ // pick a better line or drop to summary.
175
+ const hunks = parseDiffHunks([
176
+ "diff --git a/config.model.ts b/config.model.ts",
177
+ "--- a/config.model.ts",
178
+ "+++ b/config.model.ts",
179
+ "@@ -364,22 +362,6 @@",
180
+ " self.aiApiEnv === 'production'",
181
+ " );",
182
+ " },",
183
+ "- get fusionModeWithDefault(): string {",
184
+ "- return 'quality-v3';",
185
+ "- },",
186
+ " }))",
187
+ " .actions(self => {",
188
+ " return {",
189
+ ].join("\n"));
190
+ const { invalid } = validateCommentPositions([comment({ file_path: "config.model.ts", line: 95 })], hunks);
191
+ expect(invalid).toHaveLength(1);
192
+ expect(invalid[0].validLines).toBeDefined();
193
+ const lines = invalid[0].validLines;
194
+ // Six new-side commentable lines (3 context before, 3 context after the deletion)
195
+ expect(lines.map((l) => l.line)).toEqual([362, 363, 364, 365, 366, 367]);
196
+ // The brace-line at 367 — exactly the wrong-anchor PR #255 hit
197
+ expect((_a = lines.find((l) => l.line === 367)) === null || _a === void 0 ? void 0 : _a.content).toContain("return {");
198
+ });
199
+ it("accepts a multi-line comment within one hunk", () => {
200
+ const hunks = parseDiffHunks([
201
+ "diff --git a/m.ts b/m.ts",
202
+ "--- a/m.ts",
203
+ "+++ b/m.ts",
204
+ "@@ -371,15 +327,80 @@",
205
+ " ctx",
206
+ ].join("\n"));
207
+ const { valid, invalid } = validateCommentPositions([comment({ file_path: "m.ts", line: 395, start_line: 330 })], hunks);
208
+ expect(valid).toHaveLength(1);
209
+ expect(invalid).toHaveLength(0);
210
+ });
211
+ it("rejects a multi-line comment that crosses hunk boundary", () => {
212
+ const hunks = parseDiffHunks([
213
+ "diff --git a/m.ts b/m.ts",
214
+ "--- a/m.ts",
215
+ "+++ b/m.ts",
216
+ "@@ -212,38 +212,90 @@",
217
+ " ctx",
218
+ "@@ -371,15 +327,80 @@",
219
+ " ctx",
220
+ ].join("\n"));
221
+ const { invalid } = validateCommentPositions([comment({ file_path: "m.ts", line: 395, start_line: 300 })], hunks);
222
+ expect(invalid).toHaveLength(1);
223
+ expect(invalid[0].reason).toContain("crosses hunk boundary");
224
+ });
225
+ it("rejects RIGHT comments on binary, deleted, and renamed-only files", () => {
226
+ const hunks = parseDiffHunks([
227
+ "diff --git a/img.png b/img.png",
228
+ "Binary files a/img.png and b/img.png differ",
229
+ "diff --git a/old.ts b/old.ts",
230
+ "deleted file mode 100644",
231
+ "@@ -1,1 +0,0 @@",
232
+ "-x",
233
+ "diff --git a/from.ts b/to.ts",
234
+ "similarity index 100%",
235
+ "rename from from.ts",
236
+ "rename to to.ts",
237
+ ].join("\n"));
238
+ const cs = [
239
+ comment({ file_path: "img.png", line: 1 }),
240
+ comment({ file_path: "old.ts", line: 1 }),
241
+ comment({ file_path: "to.ts", line: 1 }),
242
+ ];
243
+ const { invalid, valid } = validateCommentPositions(cs, hunks);
244
+ expect(valid).toHaveLength(0);
245
+ expect(invalid[0].reason).toContain("binary");
246
+ // Deleted file with RIGHT comment is told to switch to LEFT (file is gone)
247
+ expect(invalid[1].reason).toContain("deleted");
248
+ expect(invalid[1].reason).toContain("LEFT");
249
+ expect(invalid[2].reason).toContain("renamed-only");
250
+ });
251
+ it("rejects comments on files not present in the diff and lists the diff's files", () => {
252
+ const hunks = parseDiffHunks("diff --git a/exists.ts b/exists.ts\n--- a/exists.ts\n+++ b/exists.ts\n@@ -1,1 +1,1 @@\n x");
253
+ const { invalid } = validateCommentPositions([comment({ file_path: "missing.ts", line: 1 })], hunks);
254
+ expect(invalid).toHaveLength(1);
255
+ expect(invalid[0].reason).toContain("file is not in the PR diff");
256
+ expect(invalid[0].reason).toContain("exists.ts");
257
+ });
258
+ it("rejects everything when the diff is empty", () => {
259
+ const hunks = parseDiffHunks("");
260
+ const { invalid } = validateCommentPositions([comment({ file_path: "anything.ts", line: 1 })], hunks);
261
+ expect(invalid).toHaveLength(1);
262
+ });
263
+ // ──────────────────────────────────────────────────────────────────
264
+ // PR #517 replay: this is the decisive end-to-end signal.
265
+ // The comment array below is what the LLM submitted on the failing run.
266
+ // The fixture diff mirrors the real PR's hunk headers for the touched files.
267
+ // After this fix lands, validateCommentPositions must surface a precise,
268
+ // per-file error so the LLM corrects in one round-trip.
269
+ // ──────────────────────────────────────────────────────────────────
270
+ describe("PR #517 replay", () => {
271
+ const diff = [
272
+ // record.tsx — modified, hunks include +327,80 covering 327..406
273
+ "diff --git a/record.tsx b/record.tsx",
274
+ "--- a/record.tsx",
275
+ "+++ b/record.tsx",
276
+ "@@ -53,6 +53,13 @@",
277
+ " ctx",
278
+ "@@ -85,9 +92,9 @@",
279
+ " ctx",
280
+ "@@ -237,7 +244,7 @@",
281
+ " ctx",
282
+ "@@ -251,70 +258,17 @@",
283
+ " ctx",
284
+ "@@ -339,8 +293,10 @@",
285
+ " ctx",
286
+ "@@ -371,15 +327,80 @@",
287
+ " ctx",
288
+ "@@ -962,7 +983,12 @@",
289
+ " ctx",
290
+ // camera-visualizer.tsx — new file, 412 lines
291
+ "diff --git a/camera-visualizer.tsx b/camera-visualizer.tsx",
292
+ "new file mode 100644",
293
+ "@@ -0,0 +1,412 @@",
294
+ "+x",
295
+ // [...page].head.ts — modified, small file
296
+ "diff --git a/head.ts b/head.ts",
297
+ "--- a/head.ts",
298
+ "+++ b/head.ts",
299
+ "@@ -1,4 +1,5 @@",
300
+ " ctx",
301
+ "@@ -10,8 +11,17 @@",
302
+ " ctx",
303
+ // recorder-engine.ts — modified
304
+ "diff --git a/recorder-engine.ts b/recorder-engine.ts",
305
+ "--- a/recorder-engine.ts",
306
+ "+++ b/recorder-engine.ts",
307
+ "@@ -212,38 +212,90 @@",
308
+ " ctx",
309
+ "@@ -284,6 +336,21 @@",
310
+ " ctx",
311
+ "@@ -822,10 +889,12 @@",
312
+ " ctx",
313
+ "@@ -1024,7 +1093,7 @@",
314
+ " ctx",
315
+ // microphone-visualizer.tsx — new file, 496 lines
316
+ "diff --git a/microphone-visualizer.tsx b/microphone-visualizer.tsx",
317
+ "new file mode 100644",
318
+ "@@ -0,0 +1,496 @@",
319
+ "+x",
320
+ ].join("\n");
321
+ it("flags recorder-engine.ts:970 with the four valid hunk ranges", () => {
322
+ const hunks = parseDiffHunks(diff);
323
+ const { invalid } = validateCommentPositions([comment({ file_path: "recorder-engine.ts", line: 970 })], hunks);
324
+ expect(invalid).toHaveLength(1);
325
+ expect(invalid[0].validRanges).toBe("212–301, 336–356, 889–900, 1093–1099");
326
+ });
327
+ it("flags microphone-visualizer.tsx:1464 (file-offset confusion) with new-file bound", () => {
328
+ const hunks = parseDiffHunks(diff);
329
+ const { invalid } = validateCommentPositions([comment({ file_path: "microphone-visualizer.tsx", line: 1464 })], hunks);
330
+ expect(invalid).toHaveLength(1);
331
+ expect(invalid[0].reason).toContain("lines 1–496");
332
+ });
333
+ it("accepts record.tsx:330-395 (it really was valid; the batch died on others)", () => {
334
+ const hunks = parseDiffHunks(diff);
335
+ const { valid, invalid } = validateCommentPositions([comment({ file_path: "record.tsx", line: 395, start_line: 330 })], hunks);
336
+ expect(invalid).toHaveLength(0);
337
+ expect(valid).toHaveLength(1);
338
+ });
339
+ it("accepts camera-visualizer.tsx:205 (in-range new file)", () => {
340
+ const hunks = parseDiffHunks(diff);
341
+ const { valid } = validateCommentPositions([comment({ file_path: "camera-visualizer.tsx", line: 205 })], hunks);
342
+ expect(valid).toHaveLength(1);
343
+ });
344
+ });
345
+ // ──────────────────────────────────────────────────────────────────
346
+ // LEFT-side support — added in the LEFT/RIGHT plan. Validates the
347
+ // PR #255 case: comment on the deleted `fusionModeWithDefault` getter
348
+ // by passing { line: 367, side: "LEFT" }, which lands directly on the
349
+ // deletion (where cursor[bot] puts it).
350
+ // ──────────────────────────────────────────────────────────────────
351
+ describe("LEFT-side validation", () => {
352
+ it("accepts a LEFT comment on a removed line in a modified file", () => {
353
+ const hunks = parseDiffHunks([
354
+ "diff --git a/m.ts b/m.ts",
355
+ "--- a/m.ts",
356
+ "+++ b/m.ts",
357
+ "@@ -10,5 +10,2 @@",
358
+ " ctx-a",
359
+ "-removed-1",
360
+ "-removed-2",
361
+ "-removed-3",
362
+ " ctx-b",
363
+ ].join("\n"));
364
+ const { valid, invalid } = validateCommentPositions([comment({ file_path: "m.ts", line: 12, side: "LEFT" })], hunks);
365
+ expect(invalid).toHaveLength(0);
366
+ expect(valid).toHaveLength(1);
367
+ });
368
+ it("rejects a LEFT comment outside any hunk's old-side range, returning oldSideLines", () => {
369
+ var _a;
370
+ const hunks = parseDiffHunks([
371
+ "diff --git a/m.ts b/m.ts",
372
+ "--- a/m.ts",
373
+ "+++ b/m.ts",
374
+ "@@ -10,3 +10,3 @@",
375
+ " ctx",
376
+ "-removed",
377
+ " ctx",
378
+ "@@ -100,3 +100,3 @@",
379
+ " ctx",
380
+ "-removed-2",
381
+ " ctx",
382
+ ].join("\n"));
383
+ const { invalid } = validateCommentPositions([comment({ file_path: "m.ts", line: 50, side: "LEFT" })], hunks);
384
+ expect(invalid).toHaveLength(1);
385
+ expect(invalid[0].reason).toContain("LEFT");
386
+ expect(invalid[0].validRanges).toBe("10–12, 100–102");
387
+ expect(invalid[0].validLines).toBeDefined();
388
+ expect((_a = invalid[0].validLines.find((l) => l.line === 11)) === null || _a === void 0 ? void 0 : _a.content).toBe("removed");
389
+ });
390
+ it("rejects LEFT on a newly added file (no old side exists)", () => {
391
+ const hunks = parseDiffHunks([
392
+ "diff --git a/n.ts b/n.ts",
393
+ "new file mode 100644",
394
+ "@@ -0,0 +1,5 @@",
395
+ "+x",
396
+ ].join("\n"));
397
+ const { invalid } = validateCommentPositions([comment({ file_path: "n.ts", line: 1, side: "LEFT" })], hunks);
398
+ expect(invalid).toHaveLength(1);
399
+ expect(invalid[0].reason).toContain("RIGHT");
400
+ });
401
+ it("accepts LEFT in [1, oldLineCount] of a fully deleted file", () => {
402
+ const hunks = parseDiffHunks([
403
+ "diff --git a/old.ts b/old.ts",
404
+ "deleted file mode 100644",
405
+ "@@ -1,5 +0,0 @@",
406
+ "-a",
407
+ "-b",
408
+ "-c",
409
+ "-d",
410
+ "-e",
411
+ ].join("\n"));
412
+ const { valid: validIn } = validateCommentPositions([comment({ file_path: "old.ts", line: 3, side: "LEFT" })], hunks);
413
+ expect(validIn).toHaveLength(1);
414
+ const { invalid: outOfRange } = validateCommentPositions([comment({ file_path: "old.ts", line: 6, side: "LEFT" })], hunks);
415
+ expect(outOfRange).toHaveLength(1);
416
+ expect(outOfRange[0].reason).toContain("lines 1–5");
417
+ });
418
+ it("rejects start_line > line but accepts start_line === line (normalized to single-line)", () => {
419
+ const hunks = parseDiffHunks([
420
+ "diff --git a/m.ts b/m.ts",
421
+ "--- a/m.ts",
422
+ "+++ b/m.ts",
423
+ "@@ -10,5 +10,5 @@",
424
+ " ctx",
425
+ "+a",
426
+ "+b",
427
+ "+c",
428
+ " ctx",
429
+ ].join("\n"));
430
+ // start_line === line: formattedComments drops the redundant start_line
431
+ // and GitHub accepts it as a single-line comment. Pre-flight must not block.
432
+ const equal = validateCommentPositions([comment({ file_path: "m.ts", line: 12, start_line: 12 })], hunks);
433
+ expect(equal.invalid).toHaveLength(0);
434
+ expect(equal.valid).toHaveLength(1);
435
+ // start_line > line: real error, GitHub would reject. Pre-flight catches.
436
+ const inverted = validateCommentPositions([comment({ file_path: "m.ts", line: 11, start_line: 13 })], hunks);
437
+ expect(inverted.invalid).toHaveLength(1);
438
+ expect(inverted.invalid[0].reason).toContain("must be less than line");
439
+ });
440
+ it("validRanges omits empty-side hunks (e.g. +N,0) instead of rendering inverted '11–10'", () => {
441
+ const hunks = parseDiffHunks([
442
+ "diff --git a/m.ts b/m.ts",
443
+ "--- a/m.ts",
444
+ "+++ b/m.ts",
445
+ // Real (non-empty) hunk on the new side.
446
+ "@@ -1,3 +1,3 @@",
447
+ " ctx",
448
+ "+a",
449
+ " ctx",
450
+ // Empty new side — newCount = 0. validRanges must skip this.
451
+ "@@ -10,3 +11,0 @@",
452
+ "-a",
453
+ "-b",
454
+ "-c",
455
+ ].join("\n"));
456
+ // Submit RIGHT comment at line 50 (not in any hunk) — gets validRanges back.
457
+ const { invalid } = validateCommentPositions([comment({ file_path: "m.ts", line: 50 })], hunks);
458
+ expect(invalid).toHaveLength(1);
459
+ // Must NOT contain the inverted "11–10" range.
460
+ expect(invalid[0].validRanges).not.toContain("11–10");
461
+ // The valid new-side range from hunk #1 must still appear.
462
+ expect(invalid[0].validRanges).toContain("1–3");
463
+ });
464
+ it("rejects RIGHT comment in a zero-count-new-side hunk", () => {
465
+ // @@ -10,3 +11,0 @@ — 3 lines removed, 0 added on new side.
466
+ // Pre-fix: range was synthesized as 11–11 (Math.max(0,1)); now empty.
467
+ const hunks = parseDiffHunks([
468
+ "diff --git a/m.ts b/m.ts",
469
+ "--- a/m.ts",
470
+ "+++ b/m.ts",
471
+ "@@ -10,3 +11,0 @@",
472
+ "-a",
473
+ "-b",
474
+ "-c",
475
+ ].join("\n"));
476
+ const { invalid } = validateCommentPositions([comment({ file_path: "m.ts", line: 11 })], hunks);
477
+ expect(invalid).toHaveLength(1);
478
+ expect(invalid[0].reason).toContain("not inside any hunk");
479
+ });
480
+ it("accepts LEFT in a zero-count-new-side hunk (the removals are still LEFT-commentable)", () => {
481
+ const hunks = parseDiffHunks([
482
+ "diff --git a/m.ts b/m.ts",
483
+ "--- a/m.ts",
484
+ "+++ b/m.ts",
485
+ "@@ -10,3 +11,0 @@",
486
+ "-a",
487
+ "-b",
488
+ "-c",
489
+ ].join("\n"));
490
+ const { valid } = validateCommentPositions([comment({ file_path: "m.ts", line: 11, side: "LEFT" })], hunks);
491
+ expect(valid).toHaveLength(1);
492
+ });
493
+ it("rejects multi-line LEFT that crosses old-side hunk boundary", () => {
494
+ const hunks = parseDiffHunks([
495
+ "diff --git a/m.ts b/m.ts",
496
+ "--- a/m.ts",
497
+ "+++ b/m.ts",
498
+ "@@ -10,3 +10,3 @@",
499
+ " ctx",
500
+ "-r",
501
+ " ctx",
502
+ "@@ -100,3 +100,3 @@",
503
+ " ctx",
504
+ "-r2",
505
+ " ctx",
506
+ ].join("\n"));
507
+ const { invalid } = validateCommentPositions([
508
+ comment({
509
+ file_path: "m.ts",
510
+ line: 101,
511
+ start_line: 11,
512
+ side: "LEFT",
513
+ }),
514
+ ], hunks);
515
+ expect(invalid).toHaveLength(1);
516
+ expect(invalid[0].reason).toContain("crosses hunk boundary on LEFT");
517
+ });
518
+ // PR #255 replay: the decisive end-to-end signal.
519
+ // Removed `fusionModeWithDefault` getter at OLD line 367 — landing comment
520
+ // there is exactly what cursor[bot] does (and what we couldn't do before).
521
+ it("PR #255 replay: LEFT comment lands on the deleted getter", () => {
522
+ const hunks = parseDiffHunks([
523
+ "diff --git a/config.model.ts b/config.model.ts",
524
+ "--- a/config.model.ts",
525
+ "+++ b/config.model.ts",
526
+ "@@ -364,22 +362,6 @@",
527
+ " self.aiApiEnv === 'production'",
528
+ " );",
529
+ " },",
530
+ "- get fusionModeWithDefault(): string {",
531
+ "- if (self.fusionCodegenMode) {",
532
+ "- return self.fusionCodegenMode;",
533
+ "- }",
534
+ "- return 'quality-v3';",
535
+ "- },",
536
+ " }))",
537
+ " .actions(self => {",
538
+ " return {",
539
+ ].join("\n"));
540
+ const { valid, invalid } = validateCommentPositions([
541
+ comment({
542
+ file_path: "config.model.ts",
543
+ line: 367,
544
+ side: "LEFT",
545
+ title: "Removed getter",
546
+ }),
547
+ ], hunks);
548
+ expect(invalid).toHaveLength(0);
549
+ expect(valid).toHaveLength(1);
550
+ // Sanity-check: the parser actually captured the getter line at OLD 367.
551
+ const info = hunks.get("config.model.ts");
552
+ if ((info === null || info === void 0 ? void 0 : info.kind) !== "modified")
553
+ throw new Error("unreachable");
554
+ const lineAt367 = info.hunks[0].oldSideLines.find((l) => l.line === 367);
555
+ expect(lineAt367 === null || lineAt367 === void 0 ? void 0 : lineAt367.content).toContain("fusionModeWithDefault");
556
+ });
557
+ });
558
+ });
package/src/events.d.ts CHANGED
@@ -767,6 +767,16 @@ export declare const VideoRecordingCompletedV1: {
767
767
  eventName: "video.recording.completed";
768
768
  version: "1";
769
769
  };
770
+ export type TimelineRecordingReadyV1 = FusionEventVariant<"timeline.recording.ready", {
771
+ recordingId: string;
772
+ explicitOnly?: boolean;
773
+ }, {
774
+ recordingId: string;
775
+ }, 1>;
776
+ export declare const TimelineRecordingReadyV1: {
777
+ eventName: "timeline.recording.ready";
778
+ version: "1";
779
+ };
770
780
  export interface SendMessageToOrgAgentInput {
771
781
  agentBranchName?: string;
772
782
  agentProjectId?: string;
@@ -860,7 +870,7 @@ export declare const ClientDevtoolsToolResultV1: {
860
870
  eventName: "client.devtools.tool.result";
861
871
  version: "1";
862
872
  };
863
- export type FusionEvent = ClientDevtoolsSessionStartedEvent | ClientDevtoolsSessionIdleEventV1 | ClientDevtoolsToolCallRequestV1 | ClientDevtoolsToolCallV1 | ClientDevtoolsToolResultV1 | FusionProjectCreatedV1 | SetupAgentCompletedV1 | GitPrMergedV1 | GitPrCreatedV1 | GitPrClosedV1 | ForceSetupAgentV1 | ClawMessageSentV1 | CodegenCompletionV1 | CodegenUserPromptV1 | GitWebhooksRegisterV1 | FusionProjectSettingsUpdatedV1 | VideoRecordingCompletedV1 | FusionBranchCreatedV1 | FusionContainerStartedV1 | FusionContainerFailedV1 | FusionBranchFailedV1 | BotMentionExternalPrV1 | ReviewSubmittedV1 | PrReviewRequestedV1;
873
+ export type FusionEvent = ClientDevtoolsSessionStartedEvent | ClientDevtoolsSessionIdleEventV1 | ClientDevtoolsToolCallRequestV1 | ClientDevtoolsToolCallV1 | ClientDevtoolsToolResultV1 | FusionProjectCreatedV1 | SetupAgentCompletedV1 | GitPrMergedV1 | GitPrCreatedV1 | GitPrClosedV1 | ForceSetupAgentV1 | ClawMessageSentV1 | CodegenCompletionV1 | CodegenUserPromptV1 | GitWebhooksRegisterV1 | FusionProjectSettingsUpdatedV1 | VideoRecordingCompletedV1 | TimelineRecordingReadyV1 | FusionBranchCreatedV1 | FusionContainerStartedV1 | FusionContainerFailedV1 | FusionBranchFailedV1 | BotMentionExternalPrV1 | ReviewSubmittedV1 | PrReviewRequestedV1;
864
874
  export interface ModelPermissionRequiredEvent {
865
875
  type: "assistant.model.permission.required";
866
876
  data: {
package/src/events.js CHANGED
@@ -78,6 +78,10 @@ export const VideoRecordingCompletedV1 = {
78
78
  eventName: "video.recording.completed",
79
79
  version: "1",
80
80
  };
81
+ export const TimelineRecordingReadyV1 = {
82
+ eventName: "timeline.recording.ready",
83
+ version: "1",
84
+ };
81
85
  export const PrReviewRequestedV1 = {
82
86
  eventName: "pr.review.requested",
83
87
  version: "1",
package/src/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export * from "./messages.js";
4
4
  export * from "./settings.js";
5
5
  export * from "./mapping.js";
6
6
  export * from "./codegen.js";
7
+ export * from "./diff-hunks.js";
7
8
  export * from "./projects.js";
8
9
  export * from "./repo-indexing.js";
9
10
  export * from "./organization.js";
package/src/index.js CHANGED
@@ -4,6 +4,7 @@ export * from "./messages.js";
4
4
  export * from "./settings.js";
5
5
  export * from "./mapping.js";
6
6
  export * from "./codegen.js";
7
+ export * from "./diff-hunks.js";
7
8
  export * from "./projects.js";
8
9
  export * from "./repo-indexing.js";
9
10
  export * from "./organization.js";