@builder.io/ai-utils 0.55.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 +1 -1
- package/src/claw.d.ts +6 -7
- package/src/claw.js +6 -15
- package/src/claw.spec.js +13 -7
- package/src/codegen.d.ts +71 -12
- package/src/diff-hunks.d.ts +47 -0
- package/src/diff-hunks.js +269 -0
- package/src/diff-hunks.spec.d.ts +1 -0
- package/src/diff-hunks.spec.js +558 -0
- package/src/events.d.ts +15 -1
- package/src/events.js +4 -0
- package/src/index.d.ts +1 -0
- package/src/index.js +1 -0
package/package.json
CHANGED
package/src/claw.d.ts
CHANGED
|
@@ -70,19 +70,14 @@ export declare function parseChannelId(channelId: string): ParsedChannelId;
|
|
|
70
70
|
* slack/dm/TEAM_ID/USER_ID
|
|
71
71
|
* → https://slack.com/app_redirect?team=TEAM_ID&channel=USER_ID
|
|
72
72
|
*
|
|
73
|
-
* Jira:
|
|
74
|
-
* Returns null - needs integration.baseUrl from database
|
|
75
|
-
* (Future: jira/comment/CLOUD_ID/ISSUE_KEY → {baseUrl}/browse/ISSUE_KEY)
|
|
76
|
-
*
|
|
77
73
|
* Returns null for unsupported platforms or malformed channel IDs.
|
|
78
|
-
*
|
|
79
|
-
* TODO: Accept optional integration metadata parameter to construct proper
|
|
80
|
-
* workspace-specific URLs (Slack teamDomain, Jira baseUrl).
|
|
81
74
|
*/
|
|
82
75
|
export declare function convertChannelIdToUrl(channelId: string): string | null;
|
|
83
76
|
export interface WorkerReportOptions {
|
|
84
77
|
/** The original user's channel that triggered this work. */
|
|
85
78
|
originChannelId?: string;
|
|
79
|
+
/** Clickable URL for the origin channel, if the integration already has one. */
|
|
80
|
+
channelUrl?: string;
|
|
86
81
|
/** The report content. */
|
|
87
82
|
content: string;
|
|
88
83
|
/** Agent/tool ID (for sub-agent reports). */
|
|
@@ -91,6 +86,8 @@ export interface WorkerReportOptions {
|
|
|
91
86
|
export interface WorkerMessageOptions {
|
|
92
87
|
/** The original user's channel that triggered this work. */
|
|
93
88
|
originChannelId?: string;
|
|
89
|
+
/** Clickable URL for the origin channel, if the integration already has one. */
|
|
90
|
+
channelUrl?: string;
|
|
94
91
|
/** The report content. */
|
|
95
92
|
content: string;
|
|
96
93
|
/** Project ID (for branch reports). */
|
|
@@ -129,6 +126,8 @@ export declare function formatSystemMessage(opts: SystemMessageOptions): string;
|
|
|
129
126
|
export interface IncomingMessageOptions {
|
|
130
127
|
/** The source channel (e.g. slack/thread/TEAM/CHANNEL/TS). */
|
|
131
128
|
channelId: string;
|
|
129
|
+
/** Clickable URL for channelId, if the integration already has one. */
|
|
130
|
+
channelUrl?: string;
|
|
132
131
|
/** DM channel ID, if the message was a direct message. */
|
|
133
132
|
dmId?: string;
|
|
134
133
|
/** Display name of the sender. */
|
package/src/claw.js
CHANGED
|
@@ -30,14 +30,7 @@ export function parseChannelId(channelId) {
|
|
|
30
30
|
* slack/dm/TEAM_ID/USER_ID
|
|
31
31
|
* → https://slack.com/app_redirect?team=TEAM_ID&channel=USER_ID
|
|
32
32
|
*
|
|
33
|
-
* Jira:
|
|
34
|
-
* Returns null - needs integration.baseUrl from database
|
|
35
|
-
* (Future: jira/comment/CLOUD_ID/ISSUE_KEY → {baseUrl}/browse/ISSUE_KEY)
|
|
36
|
-
*
|
|
37
33
|
* Returns null for unsupported platforms or malformed channel IDs.
|
|
38
|
-
*
|
|
39
|
-
* TODO: Accept optional integration metadata parameter to construct proper
|
|
40
|
-
* workspace-specific URLs (Slack teamDomain, Jira baseUrl).
|
|
41
34
|
*/
|
|
42
35
|
export function convertChannelIdToUrl(channelId) {
|
|
43
36
|
let parsed;
|
|
@@ -51,11 +44,6 @@ export function convertChannelIdToUrl(channelId) {
|
|
|
51
44
|
if (platform === "slack") {
|
|
52
45
|
return slackChannelIdToUrl(type, ids);
|
|
53
46
|
}
|
|
54
|
-
// Jira URLs need integration.baseUrl from database - not available here
|
|
55
|
-
// TODO: Add integration metadata parameter to support Jira URLs
|
|
56
|
-
if (platform === "jira") {
|
|
57
|
-
return null;
|
|
58
|
-
}
|
|
59
47
|
return null;
|
|
60
48
|
}
|
|
61
49
|
function slackChannelIdToUrl(type, ids) {
|
|
@@ -105,10 +93,11 @@ const WORKER_REPORT_TRAILER = "This is a report from a background worker, NOT a
|
|
|
105
93
|
* correct user channel.
|
|
106
94
|
*/
|
|
107
95
|
export function formatWorkerReport(opts) {
|
|
96
|
+
var _a;
|
|
108
97
|
let xml = `<worker_report>\n`;
|
|
109
98
|
if (opts.originChannelId) {
|
|
110
99
|
xml += `<origin_channel_id>${opts.originChannelId}</origin_channel_id>\n`;
|
|
111
|
-
const url = convertChannelIdToUrl(opts.originChannelId);
|
|
100
|
+
const url = (_a = opts.channelUrl) !== null && _a !== void 0 ? _a : convertChannelIdToUrl(opts.originChannelId);
|
|
112
101
|
if (url) {
|
|
113
102
|
xml += `<origin_channel_url>${url}</origin_channel_url>\n`;
|
|
114
103
|
}
|
|
@@ -120,10 +109,11 @@ export function formatWorkerReport(opts) {
|
|
|
120
109
|
return xml;
|
|
121
110
|
}
|
|
122
111
|
export function formatWorkerMessage(opts) {
|
|
112
|
+
var _a;
|
|
123
113
|
let xml = `<worker_message>\n`;
|
|
124
114
|
if (opts.originChannelId) {
|
|
125
115
|
xml += `<origin_channel_id>${opts.originChannelId}</origin_channel_id>\n`;
|
|
126
|
-
const url = convertChannelIdToUrl(opts.originChannelId);
|
|
116
|
+
const url = (_a = opts.channelUrl) !== null && _a !== void 0 ? _a : convertChannelIdToUrl(opts.originChannelId);
|
|
127
117
|
if (url) {
|
|
128
118
|
xml += `<origin_channel_url>${url}</origin_channel_url>\n`;
|
|
129
119
|
}
|
|
@@ -165,13 +155,14 @@ export function formatSystemMessage(opts) {
|
|
|
165
155
|
* The org-agent uses `<channel_id>` to reply in the same medium.
|
|
166
156
|
*/
|
|
167
157
|
export function formatIncomingMessage(opts) {
|
|
158
|
+
var _a;
|
|
168
159
|
let result = "";
|
|
169
160
|
if (opts.messageContext) {
|
|
170
161
|
result += `${opts.messageContext}\n\n`;
|
|
171
162
|
}
|
|
172
163
|
result += `<incoming_message>\n`;
|
|
173
164
|
result += `<channel_id>${opts.channelId}</channel_id>\n`;
|
|
174
|
-
const channelUrl = convertChannelIdToUrl(opts.channelId);
|
|
165
|
+
const channelUrl = (_a = opts.channelUrl) !== null && _a !== void 0 ? _a : convertChannelIdToUrl(opts.channelId);
|
|
175
166
|
if (channelUrl) {
|
|
176
167
|
result += `<channel_url>${channelUrl}</channel_url>\n`;
|
|
177
168
|
}
|
package/src/claw.spec.js
CHANGED
|
@@ -45,10 +45,13 @@ describe("convertChannelIdToUrl", () => {
|
|
|
45
45
|
});
|
|
46
46
|
});
|
|
47
47
|
describe("jira/comment format", () => {
|
|
48
|
-
it("returns null
|
|
48
|
+
it("returns null because Jira URLs are provided by the integration", () => {
|
|
49
49
|
const url = convertChannelIdToUrl("jira/comment/cloud-id/PROJ-123");
|
|
50
50
|
expect(url).toBeNull();
|
|
51
51
|
});
|
|
52
|
+
it("returns null even when the issue key is present", () => {
|
|
53
|
+
expect(convertChannelIdToUrl("jira/comment/cloud-id/PROJ-123")).toBeNull();
|
|
54
|
+
});
|
|
52
55
|
});
|
|
53
56
|
describe("unsupported platforms", () => {
|
|
54
57
|
it("returns null for telegram channel IDs", () => {
|
|
@@ -90,14 +93,15 @@ describe("formatIncomingMessage", () => {
|
|
|
90
93
|
});
|
|
91
94
|
expect(result).toContain("<channel_url>https://slack.com/app_redirect?team=TTEAM&channel=CCHAN</channel_url>");
|
|
92
95
|
});
|
|
93
|
-
it("
|
|
96
|
+
it("includes channel_url when provided by the integration", () => {
|
|
94
97
|
const result = formatIncomingMessage({
|
|
95
98
|
channelId: "jira/comment/cloud-id/PROJ-123",
|
|
99
|
+
channelUrl: "https://test.atlassian.net/browse/PROJ-123",
|
|
96
100
|
sender: "Charlie",
|
|
97
101
|
timestamp: "Wednesday, January 3, 2024 at 12:00 PM PST",
|
|
98
102
|
content: "Jira comment",
|
|
99
103
|
});
|
|
100
|
-
expect(result).
|
|
104
|
+
expect(result).toContain("<channel_url>https://test.atlassian.net/browse/PROJ-123</channel_url>");
|
|
101
105
|
});
|
|
102
106
|
it("does not include channel_url for unsupported platforms", () => {
|
|
103
107
|
const result = formatIncomingMessage({
|
|
@@ -142,12 +146,13 @@ describe("formatWorkerMessage", () => {
|
|
|
142
146
|
});
|
|
143
147
|
expect(result).toContain("<origin_channel_url>https://slack.com/app_redirect?team=TTEAM&channel=CCHAN&message_ts=1234567890.000100</origin_channel_url>");
|
|
144
148
|
});
|
|
145
|
-
it("
|
|
149
|
+
it("includes origin_channel_url when provided by the integration", () => {
|
|
146
150
|
const result = formatWorkerMessage({
|
|
147
151
|
originChannelId: "jira/comment/cloud-id/PROJ-456",
|
|
152
|
+
channelUrl: "https://test.atlassian.net/browse/PROJ-456",
|
|
148
153
|
content: "Worker result",
|
|
149
154
|
});
|
|
150
|
-
expect(result).
|
|
155
|
+
expect(result).toContain("<origin_channel_url>https://test.atlassian.net/browse/PROJ-456</origin_channel_url>");
|
|
151
156
|
});
|
|
152
157
|
it("does not include origin_channel_url for unsupported platforms", () => {
|
|
153
158
|
const result = formatWorkerMessage({
|
|
@@ -181,13 +186,14 @@ describe("formatWorkerReport", () => {
|
|
|
181
186
|
});
|
|
182
187
|
expect(result).toContain("<origin_channel_url>https://slack.com/app_redirect?team=TTEAM&channel=CCHAN</origin_channel_url>");
|
|
183
188
|
});
|
|
184
|
-
it("
|
|
189
|
+
it("includes origin_channel_url when provided by the integration", () => {
|
|
185
190
|
const result = formatWorkerReport({
|
|
186
191
|
originChannelId: "jira/comment/cloud-id/PROJ-789",
|
|
192
|
+
channelUrl: "https://test.atlassian.net/browse/PROJ-789",
|
|
187
193
|
content: "Report content",
|
|
188
194
|
agentId: "agent-xyz",
|
|
189
195
|
});
|
|
190
|
-
expect(result).
|
|
196
|
+
expect(result).toContain("<origin_channel_url>https://test.atlassian.net/browse/PROJ-789</origin_channel_url>");
|
|
191
197
|
});
|
|
192
198
|
it("does not include origin_channel_url for unsupported platforms", () => {
|
|
193
199
|
const result = formatWorkerReport({
|
package/src/codegen.d.ts
CHANGED
|
@@ -35,6 +35,18 @@ export interface CustomInstruction {
|
|
|
35
35
|
isSkill?: boolean;
|
|
36
36
|
disableModelInvocation?: boolean;
|
|
37
37
|
userInvocable?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Where this instruction was discovered. Drives precedence on name
|
|
40
|
+
* collision: `project` > `user` > `plugin`. Set by the discovery loader,
|
|
41
|
+
* not by the parsed file itself.
|
|
42
|
+
*/
|
|
43
|
+
scope?: "project" | "user" | "plugin";
|
|
44
|
+
/**
|
|
45
|
+
* Name of the plugin that contributed this instruction, if any. Set by
|
|
46
|
+
* the plugin loader (Phase 2); always `undefined` for project-level and
|
|
47
|
+
* user-level standalone files (Phase 1).
|
|
48
|
+
*/
|
|
49
|
+
pluginName?: string;
|
|
38
50
|
}
|
|
39
51
|
/** Reasoning effort level for LLM completions. */
|
|
40
52
|
export declare const ReasoningEffortSchema: z.ZodEnum<{
|
|
@@ -98,13 +110,31 @@ export interface CustomAgentDefinition {
|
|
|
98
110
|
maxCompletions?: number;
|
|
99
111
|
/** Default reasoning effort level for this agent type. Overrides the session default. */
|
|
100
112
|
reasoning?: ReasoningEffort;
|
|
113
|
+
/**
|
|
114
|
+
* Where this agent was discovered. Drives precedence on name collision:
|
|
115
|
+
* `project` > `user` > `plugin`. Set by the discovery loader, not by the
|
|
116
|
+
* parsed file itself.
|
|
117
|
+
*/
|
|
118
|
+
scope?: "project" | "user" | "plugin";
|
|
119
|
+
/**
|
|
120
|
+
* Name of the plugin that contributed this agent, if any. Set by the
|
|
121
|
+
* plugin loader (Phase 2); always `undefined` for project-level and
|
|
122
|
+
* user-level standalone files (Phase 1).
|
|
123
|
+
*/
|
|
124
|
+
pluginName?: string;
|
|
101
125
|
}
|
|
102
126
|
export type CodeGenFramework = "react" | "html" | "mitosis" | "react-native" | "angular" | "vue" | "svelte" | "qwik" | "solid" | "marko" | "swiftui" | "jetpack-compose" | "flutter";
|
|
103
127
|
export type CodeGenStyleLibrary = "tailwind" | "tailwind-precise" | "emotion" | "styled-components" | "styled-jsx" | "react-native" | undefined;
|
|
104
128
|
export type CompletionStopReason = "max_tokens" | "stop_sequence" | "tool_use" | "end_turn" | "content_filter" | "error" | "aborted" | "pause_turn" | "refusal" | "compaction" | "model_context_window_exceeded" | null;
|
|
105
129
|
export interface ReadToolInput {
|
|
130
|
+
/**
|
|
131
|
+
* Path of the file. Accepts a path relative to the project working directory,
|
|
132
|
+
* an absolute path (e.g. `/Users/.../skill.md`), or a tilde path
|
|
133
|
+
* (e.g. `~/.builder/skills/.../SKILL.md`). User-level Builder state under
|
|
134
|
+
* `~/.builder/**` is allowed by default for plugin operations; other absolute
|
|
135
|
+
* paths require an explicit ACL policy.
|
|
136
|
+
*/
|
|
106
137
|
file_path: string;
|
|
107
|
-
view_range?: [number, number];
|
|
108
138
|
offset?: number | null;
|
|
109
139
|
limit?: number | null;
|
|
110
140
|
}
|
|
@@ -159,11 +189,23 @@ export interface WebSearchToolInput {
|
|
|
159
189
|
}
|
|
160
190
|
export interface WriteFileInput {
|
|
161
191
|
title: string;
|
|
192
|
+
/**
|
|
193
|
+
* Path of the file. Accepts a path relative to the project working directory,
|
|
194
|
+
* an absolute path, or a tilde path (e.g. `~/.builder/...`). User-level Builder
|
|
195
|
+
* state under `~/.builder/**` is allowed by default; other absolute paths
|
|
196
|
+
* require an explicit ACL policy.
|
|
197
|
+
*/
|
|
162
198
|
file_path: string;
|
|
163
199
|
content: string;
|
|
164
200
|
}
|
|
165
201
|
export interface SearchReplaceInput {
|
|
166
202
|
title: string;
|
|
203
|
+
/**
|
|
204
|
+
* Path of the file. Accepts a path relative to the project working directory,
|
|
205
|
+
* an absolute path, or a tilde path (e.g. `~/.builder/...`). User-level Builder
|
|
206
|
+
* state under `~/.builder/**` is allowed by default; other absolute paths
|
|
207
|
+
* require an explicit ACL policy.
|
|
208
|
+
*/
|
|
167
209
|
file_path: string;
|
|
168
210
|
old_str: string;
|
|
169
211
|
new_str: string;
|
|
@@ -172,6 +214,12 @@ export interface SearchReplaceInput {
|
|
|
172
214
|
}
|
|
173
215
|
export interface MultiSearchReplaceInput {
|
|
174
216
|
title: string;
|
|
217
|
+
/**
|
|
218
|
+
* Path of the file. Accepts a path relative to the project working directory,
|
|
219
|
+
* an absolute path, or a tilde path (e.g. `~/.builder/...`). User-level Builder
|
|
220
|
+
* state under `~/.builder/**` is allowed by default; other absolute paths
|
|
221
|
+
* require an explicit ACL policy.
|
|
222
|
+
*/
|
|
175
223
|
file_path: string;
|
|
176
224
|
edits: {
|
|
177
225
|
old_str: string;
|
|
@@ -720,6 +768,12 @@ export interface PRReviewComment {
|
|
|
720
768
|
file_path: string;
|
|
721
769
|
line: number;
|
|
722
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";
|
|
723
777
|
title: string;
|
|
724
778
|
body: string;
|
|
725
779
|
severity: ReviewSeverity;
|
|
@@ -814,6 +868,11 @@ export interface CodeGenToolMap {
|
|
|
814
868
|
EscalateToPlanner: EscalateToPlanner;
|
|
815
869
|
PullPrototype: PullPrototypeToolInput;
|
|
816
870
|
ConnectMCP: ConnectMCPToolInput;
|
|
871
|
+
EnsurePR: EnsurePRToolInput;
|
|
872
|
+
}
|
|
873
|
+
export interface EnsurePRToolInput {
|
|
874
|
+
project_id: string;
|
|
875
|
+
branch_name: string;
|
|
817
876
|
}
|
|
818
877
|
export interface EscalateToPlanner {
|
|
819
878
|
/** What's blocking execution */
|
|
@@ -1006,17 +1065,6 @@ export interface CodeGenInputOptions {
|
|
|
1006
1065
|
branchName?: string;
|
|
1007
1066
|
repoHash?: string;
|
|
1008
1067
|
repoBranch?: string;
|
|
1009
|
-
/**
|
|
1010
|
-
* Server-side branch.agentType cached from middleware Firestore lookup.
|
|
1011
|
-
* Used to avoid redundant getBranch calls for billing exemption checks.
|
|
1012
|
-
* @internal - Set by middleware, not by clients
|
|
1013
|
-
*/
|
|
1014
|
-
branchAgentType?: string | null;
|
|
1015
|
-
/**
|
|
1016
|
-
* True when middleware performed a Firestore branch lookup; do not use source-based exemption.
|
|
1017
|
-
* @internal - Set by middleware, not by clients
|
|
1018
|
-
*/
|
|
1019
|
-
branchAgentTypeChecked?: boolean;
|
|
1020
1068
|
/** Immediate parent session id when this completion runs inside a sub-agent. */
|
|
1021
1069
|
parentSessionId?: string;
|
|
1022
1070
|
/**
|
|
@@ -1967,6 +2015,17 @@ export interface MCPServerDefinition {
|
|
|
1967
2015
|
env?: Record<string, string>;
|
|
1968
2016
|
envFile?: string;
|
|
1969
2017
|
retries?: number;
|
|
2018
|
+
/**
|
|
2019
|
+
* Where this server was discovered. Drives precedence on name collision:
|
|
2020
|
+
* `project` > `user` > `plugin`. Set by the loader, not by the config file.
|
|
2021
|
+
*/
|
|
2022
|
+
scope?: "project" | "user" | "plugin";
|
|
2023
|
+
/**
|
|
2024
|
+
* Name of the plugin that contributed this server, if any. Set by the
|
|
2025
|
+
* plugin loader (Phase 2); always `undefined` for project-level and
|
|
2026
|
+
* user-level standalone configs (Phase 1).
|
|
2027
|
+
*/
|
|
2028
|
+
pluginName?: string;
|
|
1970
2029
|
}
|
|
1971
2030
|
export type MCPServerConfig = Record<string, MCPServerDefinition>;
|
|
1972
2031
|
export interface FusionConfig {
|
|
@@ -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
|
@@ -623,6 +623,8 @@ export type ClawMessageSentV1 = FusionEventVariant<"claw.message.sent", {
|
|
|
623
623
|
senderId?: string;
|
|
624
624
|
correlationId?: string;
|
|
625
625
|
channelId?: string;
|
|
626
|
+
/** Optional clickable URL for channelId. */
|
|
627
|
+
channelUrl?: string;
|
|
626
628
|
dmId?: string;
|
|
627
629
|
senderDisplayName?: string;
|
|
628
630
|
agentBranchName?: string;
|
|
@@ -765,12 +767,24 @@ export declare const VideoRecordingCompletedV1: {
|
|
|
765
767
|
eventName: "video.recording.completed";
|
|
766
768
|
version: "1";
|
|
767
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
|
+
};
|
|
768
780
|
export interface SendMessageToOrgAgentInput {
|
|
769
781
|
agentBranchName?: string;
|
|
770
782
|
agentProjectId?: string;
|
|
771
783
|
content: string;
|
|
772
784
|
senderType?: "user" | "sub-agent" | "system";
|
|
773
785
|
channelId?: string;
|
|
786
|
+
/** Optional clickable URL for channelId. */
|
|
787
|
+
channelUrl?: string;
|
|
774
788
|
senderDisplayName?: string;
|
|
775
789
|
messageContext?: string;
|
|
776
790
|
senderId?: string;
|
|
@@ -856,7 +870,7 @@ export declare const ClientDevtoolsToolResultV1: {
|
|
|
856
870
|
eventName: "client.devtools.tool.result";
|
|
857
871
|
version: "1";
|
|
858
872
|
};
|
|
859
|
-
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;
|
|
860
874
|
export interface ModelPermissionRequiredEvent {
|
|
861
875
|
type: "assistant.model.permission.required";
|
|
862
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";
|