diff-hound 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,30 +1,27 @@
1
- import { CodeReviewModel, FileChange, AIComment, ReviewConfig } from "../types";
1
+ import { ReviewConfig } from "../types";
2
+ import { BaseReviewModel, LLMMessage } from "./base";
2
3
  /**
3
4
  * OpenAI model adapter for code review
5
+ * Uses structured JSON output via response_format for reliable parsing
4
6
  */
5
- export declare class OpenAIModel implements CodeReviewModel {
7
+ export declare class OpenAIModel extends BaseReviewModel {
6
8
  private client;
7
9
  private model;
10
+ private endpoint?;
8
11
  constructor(model: string, endpoint?: string);
9
12
  /**
10
- * Generate a code review prompt for the given diff
11
- * @param diff File changes to review
12
- * @param config Review configuration
13
- * @returns Prompt for the AI
13
+ * Get the model name for this adapter
14
14
  */
15
- private generatePrompt;
15
+ protected getModelName(): string;
16
16
  /**
17
- * Parse the AI response into comments
18
- * @param response AI generated response
19
- * @param config Review configuration
20
- * @returns List of comments
17
+ * OpenAI supports structured JSON output via response_format
21
18
  */
22
- private parseResponse;
19
+ protected supportsStructuredOutput(): boolean;
23
20
  /**
24
- * Review code changes and generate comments
25
- * @param diff File changes to review
26
- * @param config Review configuration
27
- * @returns List of AI comments
21
+ * Make an LLM API call to OpenAI
22
+ * @param messages Array of messages to send to the LLM
23
+ * @param _config Review configuration (unused but kept for interface consistency)
24
+ * @returns Raw response string from the LLM
28
25
  */
29
- review(diff: FileChange[], config: ReviewConfig): Promise<AIComment[]>;
26
+ protected callLLM(messages: LLMMessage[], _config: ReviewConfig): Promise<string>;
30
27
  }
@@ -5,11 +5,16 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.OpenAIModel = void 0;
7
7
  const openai_1 = __importDefault(require("openai"));
8
+ const base_1 = require("./base");
9
+ // Import the JSON schema for OpenAI
10
+ const review_response_json_1 = __importDefault(require("../schemas/review-response.json"));
8
11
  /**
9
12
  * OpenAI model adapter for code review
13
+ * Uses structured JSON output via response_format for reliable parsing
10
14
  */
11
- class OpenAIModel {
15
+ class OpenAIModel extends base_1.BaseReviewModel {
12
16
  constructor(model, endpoint) {
17
+ super();
13
18
  const apiKey = process.env.OPENAI_API_KEY;
14
19
  if (!apiKey) {
15
20
  throw new Error("OPENAI_API_KEY environment variable is required");
@@ -18,145 +23,56 @@ class OpenAIModel {
18
23
  throw new Error("Model is required");
19
24
  }
20
25
  this.model = model;
26
+ this.endpoint = endpoint;
21
27
  this.client = new openai_1.default({
22
28
  apiKey,
23
29
  baseURL: endpoint,
24
30
  });
25
31
  }
26
32
  /**
27
- * Generate a code review prompt for the given diff
28
- * @param diff File changes to review
29
- * @param config Review configuration
30
- * @returns Prompt for the AI
33
+ * Get the model name for this adapter
31
34
  */
32
- generatePrompt(diff, config) {
33
- const rules = config.rules && config.rules.length > 0
34
- ? `\nApply these specific rules:\n${config.rules
35
- .map((rule) => `- ${rule}`)
36
- .join("\n")}`
37
- : "";
38
- const diffText = diff
39
- .map((file) => {
40
- return `File: ${file.filename} (${file.status})
41
- ${file.patch || "No changes"}
42
- `;
43
- })
44
- .join("\n\n");
45
- return `
46
- Only provide brief, actionable, file-specific feedback related to the actual code diff.
47
- Do not include general advice, documentation-style summaries, or best practices unless they directly relate to the diff.
48
- Use a direct tone. No greetings. No summaries. No repeated advice.
49
-
50
- Important formatting rules:
51
- - Do not comment on lines that are unchanged or just context unless it's directly impacted by a change.
52
- ${config.commentStyle === "inline"
53
- ? "- Output only inline comments, Use this format: 'filename.py:<line number in the new file> — comment'"
54
- : "- Provide a single short summary of your review."}
55
-
56
- ${rules}
57
-
58
- Here are the changes to review:
59
-
60
- ${diffText}
61
-
62
- ${config.customPrompt || ""}`;
35
+ getModelName() {
36
+ return `OpenAI (${this.model})`;
63
37
  }
64
38
  /**
65
- * Parse the AI response into comments
66
- * @param response AI generated response
67
- * @param config Review configuration
68
- * @returns List of comments
39
+ * OpenAI supports structured JSON output via response_format
69
40
  */
70
- parseResponse(response, config) {
71
- const comments = [];
72
- if (config.commentStyle === "summary") {
73
- // Generate a single summary comment
74
- comments.push({
75
- type: "summary",
76
- content: response.trim(),
77
- severity: config.severity,
78
- });
79
- return comments;
80
- }
81
- // Look for patterns like "filename.ext:123 — comment text"
82
- const inlineCommentRegex = /([\w/.-]+):(\d+)\s*[—–-]\s*(.*?)(?=\s+[\w/.-]+:\d+\s*[—–-]|$)/gs;
83
- let match;
84
- while ((match = inlineCommentRegex.exec(response + "\n\n")) !== null) {
85
- const [, path, lineStr, content] = match;
86
- const line = parseInt(lineStr, 10);
87
- comments.push({
88
- type: "inline",
89
- path,
90
- line,
91
- content: content.trim(),
92
- severity: config.severity,
93
- });
94
- }
95
- // If no inline comments were parsed, create a summary comment
96
- if (comments.length === 0) {
97
- comments.push({
98
- type: "summary",
99
- content: response.trim(),
100
- severity: config.severity,
101
- });
102
- }
103
- return comments;
41
+ supportsStructuredOutput() {
42
+ return true;
104
43
  }
105
44
  /**
106
- * Review code changes and generate comments
107
- * @param diff File changes to review
108
- * @param config Review configuration
109
- * @returns List of AI comments
45
+ * Make an LLM API call to OpenAI
46
+ * @param messages Array of messages to send to the LLM
47
+ * @param _config Review configuration (unused but kept for interface consistency)
48
+ * @returns Raw response string from the LLM
110
49
  */
111
- async review(diff, config) {
112
- // Filter out ignored files
113
- if (config.ignoreFiles && config.ignoreFiles.length > 0) {
114
- diff = diff.filter((file) => {
115
- return !config.ignoreFiles?.some((pattern) => {
116
- // Basic glob pattern matching for *.ext
117
- if (pattern.startsWith("*") && pattern.indexOf(".") > 0) {
118
- const ext = pattern.substring(1);
119
- return file.filename.endsWith(ext);
120
- }
121
- return file.filename === pattern;
122
- });
123
- });
124
- }
125
- // Skip if no files to review
126
- if (diff.length === 0) {
127
- return [
128
- {
129
- type: "summary",
130
- content: "No files to review after applying ignore patterns.",
131
- severity: "suggestion",
50
+ async callLLM(messages, _config) {
51
+ // Use structured output with JSON schema
52
+ const response = await this.client.chat.completions.create({
53
+ model: this.model,
54
+ messages: messages.map((m) => ({
55
+ role: m.role,
56
+ content: m.content,
57
+ })),
58
+ temperature: 0.1,
59
+ max_tokens: 4000,
60
+ store: true,
61
+ response_format: {
62
+ type: "json_schema",
63
+ json_schema: {
64
+ name: "review_response",
65
+ description: "Structured code review response",
66
+ schema: review_response_json_1.default,
67
+ strict: true,
132
68
  },
133
- ];
134
- }
135
- const prompt = this.generatePrompt(diff, config);
136
- try {
137
- const response = await this.client.chat.completions.create({
138
- model: this.model,
139
- messages: [
140
- {
141
- role: "system",
142
- content: "You are a senior software engineer doing a peer code review. Your job is to spot all logic, syntax, and semantic issues in a code diff.",
143
- },
144
- { role: "user", content: prompt },
145
- ],
146
- temperature: 0.1,
147
- max_tokens: 4000,
148
- store: true,
149
- });
150
- const content = response.choices[0]?.message.content;
151
- if (!content) {
152
- throw new Error("No response from OpenAI");
153
- }
154
- return this.parseResponse(content, config);
155
- }
156
- catch (error) {
157
- console.error("Error generating review with OpenAI:", error);
158
- throw error;
69
+ },
70
+ });
71
+ const content = response.choices[0]?.message.content;
72
+ if (!content) {
73
+ throw new Error("No response from OpenAI");
159
74
  }
75
+ return content;
160
76
  }
161
77
  }
162
78
  exports.OpenAIModel = OpenAIModel;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,209 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vitest_1 = require("vitest");
4
+ // Test the response parsing function directly
5
+ // We need to extract the parseResponse function for testing
6
+ // Re-implement the parsing logic for testing (same as in openai.ts)
7
+ function parseResponse(response, commentStyle, severity) {
8
+ const comments = [];
9
+ if (commentStyle === "summary") {
10
+ comments.push({
11
+ type: "summary",
12
+ content: response.trim(),
13
+ severity,
14
+ });
15
+ return comments;
16
+ }
17
+ // Look for patterns like "filename.ext:123 — comment text"
18
+ const inlineCommentRegex = /([\w/.-]+):(\d+)\s*[—–-]\s*(.*?)(?=\s+[\w/.-]+:\d+\s*[—–-]|$)/gs;
19
+ let match;
20
+ while ((match = inlineCommentRegex.exec(response + "\n\n")) !== null) {
21
+ const [, path, lineStr, content] = match;
22
+ const line = parseInt(lineStr, 10);
23
+ comments.push({
24
+ type: "inline",
25
+ path,
26
+ line,
27
+ content: content.trim(),
28
+ severity,
29
+ });
30
+ }
31
+ // If no inline comments were parsed, create a summary comment
32
+ if (comments.length === 0) {
33
+ comments.push({
34
+ type: "summary",
35
+ content: response.trim(),
36
+ severity,
37
+ });
38
+ }
39
+ return comments;
40
+ }
41
+ (0, vitest_1.describe)("OpenAI Response Parsing", () => {
42
+ const severity = "suggestion";
43
+ (0, vitest_1.describe)("summary style", () => {
44
+ (0, vitest_1.it)("should return summary comment when commentStyle is summary", () => {
45
+ const summaryContent = "This is a summary of the changes.";
46
+ const result = parseResponse(summaryContent, "summary", severity);
47
+ (0, vitest_1.expect)(result).toHaveLength(1);
48
+ (0, vitest_1.expect)(result[0]).toEqual({
49
+ type: "summary",
50
+ content: summaryContent,
51
+ severity: "suggestion",
52
+ });
53
+ });
54
+ });
55
+ (0, vitest_1.describe)("inline style", () => {
56
+ (0, vitest_1.it)("should parse inline comments correctly", () => {
57
+ const response = `src/utils.ts:3 — Missing null check on the user object
58
+ src/utils.ts:8 — Unused import 'fs'
59
+ src/utils.ts:15 — Consider using const instead of let`;
60
+ const result = parseResponse(response, "inline", severity);
61
+ (0, vitest_1.expect)(result).toHaveLength(3);
62
+ (0, vitest_1.expect)(result[0]).toEqual({
63
+ type: "inline",
64
+ path: "src/utils.ts",
65
+ line: 3,
66
+ content: "Missing null check on the user object",
67
+ severity: "suggestion",
68
+ });
69
+ (0, vitest_1.expect)(result[1]).toEqual({
70
+ type: "inline",
71
+ path: "src/utils.ts",
72
+ line: 8,
73
+ content: "Unused import 'fs'",
74
+ severity: "suggestion",
75
+ });
76
+ (0, vitest_1.expect)(result[2]).toEqual({
77
+ type: "inline",
78
+ path: "src/utils.ts",
79
+ line: 15,
80
+ content: "Consider using const instead of let",
81
+ severity: "suggestion",
82
+ });
83
+ });
84
+ (0, vitest_1.it)("should handle em-dash separator in inline comments", () => {
85
+ const response = `src/file.ts:10 — Comment with em-dash`;
86
+ const result = parseResponse(response, "inline", severity);
87
+ (0, vitest_1.expect)(result).toHaveLength(1);
88
+ (0, vitest_1.expect)(result[0].line).toBe(10);
89
+ (0, vitest_1.expect)(result[0].content).toBe("Comment with em-dash");
90
+ });
91
+ (0, vitest_1.it)("should handle en-dash separator in inline comments", () => {
92
+ const response = `src/file.ts:5 – Comment with en-dash`;
93
+ const result = parseResponse(response, "inline", severity);
94
+ (0, vitest_1.expect)(result).toHaveLength(1);
95
+ (0, vitest_1.expect)(result[0].line).toBe(5);
96
+ });
97
+ (0, vitest_1.it)("should handle hyphen separator in inline comments", () => {
98
+ const response = `src/file.ts:5 - Comment with hyphen`;
99
+ const result = parseResponse(response, "inline", severity);
100
+ (0, vitest_1.expect)(result).toHaveLength(1);
101
+ (0, vitest_1.expect)(result[0].line).toBe(5);
102
+ });
103
+ (0, vitest_1.it)("should fall back to summary if no inline comments are parsed", () => {
104
+ const response = "This is just a general review comment without specific line numbers.";
105
+ const result = parseResponse(response, "inline", severity);
106
+ (0, vitest_1.expect)(result).toHaveLength(1);
107
+ (0, vitest_1.expect)(result[0].type).toBe("summary");
108
+ (0, vitest_1.expect)(result[0].content).toBe(response);
109
+ });
110
+ (0, vitest_1.it)("should handle comments with colons in content", () => {
111
+ const response = `src/file.ts:10 — Warning: this is a warning with: multiple colons`;
112
+ const result = parseResponse(response, "inline", severity);
113
+ (0, vitest_1.expect)(result).toHaveLength(1);
114
+ (0, vitest_1.expect)(result[0].line).toBe(10);
115
+ (0, vitest_1.expect)(result[0].content).toContain("Warning:");
116
+ (0, vitest_1.expect)(result[0].content).toContain("multiple colons");
117
+ });
118
+ (0, vitest_1.it)("should handle file paths with dots and dashes", () => {
119
+ const response = `src/my-component.test.ts:42 — Test comment`;
120
+ const result = parseResponse(response, "inline", severity);
121
+ (0, vitest_1.expect)(result).toHaveLength(1);
122
+ (0, vitest_1.expect)(result[0].path).toBe("src/my-component.test.ts");
123
+ (0, vitest_1.expect)(result[0].line).toBe(42);
124
+ });
125
+ (0, vitest_1.it)("should handle file paths with nested directories", () => {
126
+ const response = `src/components/user/profile/settings.ts:100 — Deeply nested file`;
127
+ const result = parseResponse(response, "inline", severity);
128
+ (0, vitest_1.expect)(result).toHaveLength(1);
129
+ (0, vitest_1.expect)(result[0].path).toBe("src/components/user/profile/settings.ts");
130
+ (0, vitest_1.expect)(result[0].line).toBe(100);
131
+ });
132
+ (0, vitest_1.it)("should handle comments at line 1", () => {
133
+ const response = `src/file.ts:1 — Comment on first line`;
134
+ const result = parseResponse(response, "inline", severity);
135
+ (0, vitest_1.expect)(result).toHaveLength(1);
136
+ (0, vitest_1.expect)(result[0].line).toBe(1);
137
+ });
138
+ (0, vitest_1.it)("should handle comments at high line numbers", () => {
139
+ const response = `src/file.ts:9999 — Comment on high line number`;
140
+ const result = parseResponse(response, "inline", severity);
141
+ (0, vitest_1.expect)(result).toHaveLength(1);
142
+ (0, vitest_1.expect)(result[0].line).toBe(9999);
143
+ });
144
+ (0, vitest_1.it)("should handle empty content", () => {
145
+ const response = "";
146
+ const result = parseResponse(response, "inline", severity);
147
+ (0, vitest_1.expect)(result).toHaveLength(1);
148
+ (0, vitest_1.expect)(result[0].type).toBe("summary");
149
+ (0, vitest_1.expect)(result[0].content).toBe("");
150
+ });
151
+ (0, vitest_1.it)("should handle whitespace-only content", () => {
152
+ const response = " \n\n ";
153
+ const result = parseResponse(response, "inline", severity);
154
+ (0, vitest_1.expect)(result).toHaveLength(1);
155
+ (0, vitest_1.expect)(result[0].type).toBe("summary");
156
+ });
157
+ (0, vitest_1.it)("should handle multiple files in same response", () => {
158
+ const response = `src/utils.ts:5 — First file comment
159
+ src/helpers.ts:10 — Second file comment
160
+ src/constants.ts:1 — Third file comment`;
161
+ const result = parseResponse(response, "inline", severity);
162
+ (0, vitest_1.expect)(result).toHaveLength(3);
163
+ (0, vitest_1.expect)(result[0].path).toBe("src/utils.ts");
164
+ (0, vitest_1.expect)(result[1].path).toBe("src/helpers.ts");
165
+ (0, vitest_1.expect)(result[2].path).toBe("src/constants.ts");
166
+ });
167
+ (0, vitest_1.it)("should trim whitespace from comment content", () => {
168
+ const response = `src/file.ts:10 — Comment with extra spaces `;
169
+ const result = parseResponse(response, "inline", severity);
170
+ (0, vitest_1.expect)(result).toHaveLength(1);
171
+ (0, vitest_1.expect)(result[0].content).toBe("Comment with extra spaces");
172
+ });
173
+ (0, vitest_1.it)("should handle comments with quotes and special characters", () => {
174
+ const response = `src/file.ts:10 — This has "quotes" and 'apostrophes' and \`backticks\``;
175
+ const result = parseResponse(response, "inline", severity);
176
+ (0, vitest_1.expect)(result).toHaveLength(1);
177
+ (0, vitest_1.expect)(result[0].content).toContain('"quotes"');
178
+ (0, vitest_1.expect)(result[0].content).toContain("'apostrophes'");
179
+ (0, vitest_1.expect)(result[0].content).toContain("`backticks`");
180
+ });
181
+ (0, vitest_1.it)("should handle comments with parentheses and brackets", () => {
182
+ const response = `src/file.ts:10 — Check the array[index] and call function(arg1, arg2)`;
183
+ const result = parseResponse(response, "inline", severity);
184
+ (0, vitest_1.expect)(result).toHaveLength(1);
185
+ (0, vitest_1.expect)(result[0].content).toContain("array[index]");
186
+ (0, vitest_1.expect)(result[0].content).toContain("function(arg1, arg2)");
187
+ });
188
+ (0, vitest_1.it)("should handle comments with URL-like paths", () => {
189
+ const response = `src/file.ts:10 — See https://example.com/docs for more info`;
190
+ const result = parseResponse(response, "inline", severity);
191
+ (0, vitest_1.expect)(result).toHaveLength(1);
192
+ (0, vitest_1.expect)(result[0].content).toContain("https://example.com/docs");
193
+ });
194
+ });
195
+ });
196
+ (0, vitest_1.describe)("OpenAIModel constructor validation", () => {
197
+ // We need to dynamically import the OpenAIModel to avoid module-level side effects
198
+ (0, vitest_1.it)("should throw error if OPENAI_API_KEY is not set", async () => {
199
+ delete process.env.OPENAI_API_KEY;
200
+ const { OpenAIModel } = await import("./openai.js");
201
+ (0, vitest_1.expect)(() => new OpenAIModel("gpt-4o")).toThrow("OPENAI_API_KEY environment variable is required");
202
+ });
203
+ (0, vitest_1.it)("should throw error if model is not provided", async () => {
204
+ process.env.OPENAI_API_KEY = "test-key";
205
+ const { OpenAIModel } = await import("./openai.js");
206
+ (0, vitest_1.expect)(() => new OpenAIModel("")).toThrow("Model is required");
207
+ delete process.env.OPENAI_API_KEY;
208
+ });
209
+ });
@@ -1,7 +1,8 @@
1
- import { Platform, CodeReviewPlatform } from "../types";
1
+ import { Platform, CodeReviewPlatform, ReviewConfig } from "../types";
2
2
  /**
3
3
  * Get platform adapter for the specified platform
4
4
  * @param platform Platform to use
5
+ * @param config Full review config (needed for local platform options)
5
6
  * @returns Platform adapter instance
6
7
  */
7
- export declare function getPlatform(platform: Platform): Promise<CodeReviewPlatform>;
8
+ export declare function getPlatform(platform: Platform, config?: ReviewConfig): Promise<CodeReviewPlatform>;
@@ -2,15 +2,22 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getPlatform = getPlatform;
4
4
  const github_1 = require("./github");
5
+ const local_1 = require("./local");
5
6
  /**
6
7
  * Get platform adapter for the specified platform
7
8
  * @param platform Platform to use
9
+ * @param config Full review config (needed for local platform options)
8
10
  * @returns Platform adapter instance
9
11
  */
10
- async function getPlatform(platform) {
12
+ async function getPlatform(platform, config) {
11
13
  switch (platform) {
12
14
  case "github":
13
15
  return await github_1.GithubPlatform.init();
16
+ case "local":
17
+ if (!config) {
18
+ throw new Error("ReviewConfig is required for local platform");
19
+ }
20
+ return await local_1.LocalPlatform.create(config);
14
21
  default:
15
22
  throw new Error(`Unsupported platform: ${platform}`);
16
23
  }
@@ -0,0 +1,41 @@
1
+ import { CodeReviewPlatform, PullRequest, FileChange, AIComment, ReviewConfig } from "../types";
2
+ /**
3
+ * Local platform adapter — reviews local git diffs without any remote API calls.
4
+ * Always operates in dry-run mode (output to stdout).
5
+ */
6
+ export declare class LocalPlatform implements CodeReviewPlatform {
7
+ private repoPath;
8
+ private base;
9
+ private head;
10
+ private patchFile?;
11
+ constructor(config: ReviewConfig);
12
+ static create(config: ReviewConfig): Promise<LocalPlatform>;
13
+ /**
14
+ * Determine a sensible default base ref:
15
+ * 1. If current branch has an upstream, use the merge base with it
16
+ * 2. Otherwise, use HEAD~1
17
+ */
18
+ private resolveDefaultBase;
19
+ private validateRef;
20
+ /**
21
+ * Returns a single synthetic PullRequest from local git metadata.
22
+ */
23
+ getPullRequests(_repo: string): Promise<PullRequest[]>;
24
+ /**
25
+ * Get file changes by running `git diff` or reading a patch file.
26
+ */
27
+ getPullRequestDiff(_repo: string, _prId: string | number): Promise<FileChange[]>;
28
+ /**
29
+ * Parse raw `git diff` output into FileChange objects.
30
+ * This parses the full unified diff format including file headers.
31
+ */
32
+ private parseGitDiff;
33
+ /**
34
+ * In local mode, "posting" a comment means printing to stdout.
35
+ */
36
+ postComment(_repo: string, _prId: string | number, _comment: AIComment): Promise<void>;
37
+ /**
38
+ * Always returns false — no duplicate tracking in local mode.
39
+ */
40
+ hasAICommented(_repo: string, _prId: string | number): Promise<boolean>;
41
+ }