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.
- package/README.md +105 -25
- package/dist/cli/index.js +14 -3
- package/dist/config/index.js +3 -3
- package/dist/config/index.test.d.ts +1 -0
- package/dist/config/index.test.js +330 -0
- package/dist/core/parseUnifiedDiff.test.d.ts +1 -0
- package/dist/core/parseUnifiedDiff.test.js +310 -0
- package/dist/index.js +17 -9
- package/dist/models/base.d.ts +74 -0
- package/dist/models/base.js +236 -0
- package/dist/models/base.test.d.ts +1 -0
- package/dist/models/base.test.js +241 -0
- package/dist/models/index.d.ts +6 -2
- package/dist/models/index.js +9 -2
- package/dist/models/ollama.d.ts +28 -0
- package/dist/models/ollama.js +88 -0
- package/dist/models/ollama.test.d.ts +1 -0
- package/dist/models/ollama.test.js +235 -0
- package/dist/models/openai.d.ts +14 -17
- package/dist/models/openai.js +41 -125
- package/dist/models/openai.test.d.ts +1 -0
- package/dist/models/openai.test.js +209 -0
- package/dist/platforms/index.d.ts +3 -2
- package/dist/platforms/index.js +8 -1
- package/dist/platforms/local.d.ts +41 -0
- package/dist/platforms/local.js +247 -0
- package/dist/schemas/review-response.d.ts +37 -0
- package/dist/schemas/review-response.js +39 -0
- package/dist/schemas/review-response.json +68 -0
- package/dist/schemas/validate.d.ts +27 -0
- package/dist/schemas/validate.js +108 -0
- package/dist/schemas/validate.test.d.ts +1 -0
- package/dist/schemas/validate.test.js +484 -0
- package/dist/types/index.d.ts +7 -2
- package/package.json +12 -3
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const base_1 = require("./base");
|
|
5
|
+
/**
|
|
6
|
+
* Concrete test implementation of BaseReviewModel.
|
|
7
|
+
* Returns whatever content is set via setResponse().
|
|
8
|
+
*/
|
|
9
|
+
class TestModel extends base_1.BaseReviewModel {
|
|
10
|
+
constructor() {
|
|
11
|
+
super(...arguments);
|
|
12
|
+
this.responseContent = "";
|
|
13
|
+
this.lastMessages = [];
|
|
14
|
+
}
|
|
15
|
+
setResponse(content) {
|
|
16
|
+
this.responseContent = content;
|
|
17
|
+
}
|
|
18
|
+
getModelName() {
|
|
19
|
+
return "TestModel";
|
|
20
|
+
}
|
|
21
|
+
supportsStructuredOutput() {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
async callLLM(messages, _config) {
|
|
25
|
+
this.lastMessages = messages;
|
|
26
|
+
return this.responseContent;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function makeConfig(overrides = {}) {
|
|
30
|
+
return {
|
|
31
|
+
provider: "openai",
|
|
32
|
+
model: "test-model",
|
|
33
|
+
gitPlatform: "local",
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function makeFile(overrides = {}) {
|
|
38
|
+
return {
|
|
39
|
+
filename: "src/app.ts",
|
|
40
|
+
status: "modified",
|
|
41
|
+
additions: 1,
|
|
42
|
+
deletions: 0,
|
|
43
|
+
patch: "+const x = 1;",
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
(0, vitest_1.describe)("BaseReviewModel", () => {
|
|
48
|
+
(0, vitest_1.describe)("file filtering", () => {
|
|
49
|
+
(0, vitest_1.it)("should filter files matching glob ignore patterns", async () => {
|
|
50
|
+
const model = new TestModel();
|
|
51
|
+
model.setResponse(JSON.stringify({ summary: "", comments: [] }));
|
|
52
|
+
const result = await model.review([makeFile({ filename: "README.md" })], makeConfig({ ignoreFiles: ["*.md"] }));
|
|
53
|
+
(0, vitest_1.expect)(result).toHaveLength(1);
|
|
54
|
+
(0, vitest_1.expect)(result[0].content).toContain("No files to review");
|
|
55
|
+
});
|
|
56
|
+
(0, vitest_1.it)("should filter files matching exact ignore patterns", async () => {
|
|
57
|
+
const model = new TestModel();
|
|
58
|
+
model.setResponse(JSON.stringify({ summary: "", comments: [] }));
|
|
59
|
+
const result = await model.review([makeFile({ filename: "package-lock.json" })], makeConfig({ ignoreFiles: ["package-lock.json"] }));
|
|
60
|
+
(0, vitest_1.expect)(result).toHaveLength(1);
|
|
61
|
+
(0, vitest_1.expect)(result[0].content).toContain("No files to review");
|
|
62
|
+
});
|
|
63
|
+
(0, vitest_1.it)("should keep files that do not match ignore patterns", async () => {
|
|
64
|
+
const model = new TestModel();
|
|
65
|
+
model.setResponse(JSON.stringify({ summary: "", comments: [] }));
|
|
66
|
+
await model.review([makeFile({ filename: "src/index.ts" })], makeConfig({ ignoreFiles: ["*.md"] }));
|
|
67
|
+
// callLLM was invoked (file was not filtered out)
|
|
68
|
+
(0, vitest_1.expect)(model.lastMessages).toHaveLength(2);
|
|
69
|
+
});
|
|
70
|
+
(0, vitest_1.it)("should pass all files through when no ignore patterns are set", async () => {
|
|
71
|
+
const model = new TestModel();
|
|
72
|
+
model.setResponse(JSON.stringify({ summary: "", comments: [] }));
|
|
73
|
+
await model.review([makeFile(), makeFile({ filename: "src/other.ts" })], makeConfig());
|
|
74
|
+
(0, vitest_1.expect)(model.lastMessages).toHaveLength(2);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
(0, vitest_1.describe)("prompt generation", () => {
|
|
78
|
+
(0, vitest_1.it)("should include file names and diff content in prompt", async () => {
|
|
79
|
+
const model = new TestModel();
|
|
80
|
+
model.setResponse(JSON.stringify({ summary: "", comments: [] }));
|
|
81
|
+
await model.review([makeFile({ filename: "src/foo.ts", patch: "+const y = 2;" })], makeConfig());
|
|
82
|
+
const userMessage = model.lastMessages[1].content;
|
|
83
|
+
(0, vitest_1.expect)(userMessage).toContain("src/foo.ts");
|
|
84
|
+
(0, vitest_1.expect)(userMessage).toContain("+const y = 2;");
|
|
85
|
+
});
|
|
86
|
+
(0, vitest_1.it)("should include custom rules in prompt", async () => {
|
|
87
|
+
const model = new TestModel();
|
|
88
|
+
model.setResponse(JSON.stringify({ summary: "", comments: [] }));
|
|
89
|
+
await model.review([makeFile()], makeConfig({ rules: ["Use const over let", "No magic numbers"] }));
|
|
90
|
+
const userMessage = model.lastMessages[1].content;
|
|
91
|
+
(0, vitest_1.expect)(userMessage).toContain("- Use const over let");
|
|
92
|
+
(0, vitest_1.expect)(userMessage).toContain("- No magic numbers");
|
|
93
|
+
});
|
|
94
|
+
(0, vitest_1.it)("should include custom prompt in user message", async () => {
|
|
95
|
+
const model = new TestModel();
|
|
96
|
+
model.setResponse(JSON.stringify({ summary: "", comments: [] }));
|
|
97
|
+
await model.review([makeFile()], makeConfig({ customPrompt: "Focus on security issues" }));
|
|
98
|
+
const userMessage = model.lastMessages[1].content;
|
|
99
|
+
(0, vitest_1.expect)(userMessage).toContain("Focus on security issues");
|
|
100
|
+
});
|
|
101
|
+
(0, vitest_1.it)("should set system message as first message", async () => {
|
|
102
|
+
const model = new TestModel();
|
|
103
|
+
model.setResponse(JSON.stringify({ summary: "", comments: [] }));
|
|
104
|
+
await model.review([makeFile()], makeConfig());
|
|
105
|
+
(0, vitest_1.expect)(model.lastMessages[0].role).toBe("system");
|
|
106
|
+
(0, vitest_1.expect)(model.lastMessages[0].content).toContain("senior software engineer");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
(0, vitest_1.describe)("structured response parsing", () => {
|
|
110
|
+
(0, vitest_1.it)("should parse valid structured JSON response", async () => {
|
|
111
|
+
const model = new TestModel();
|
|
112
|
+
model.setResponse(JSON.stringify({
|
|
113
|
+
summary: "Good code",
|
|
114
|
+
comments: [
|
|
115
|
+
{
|
|
116
|
+
file: "src/app.ts",
|
|
117
|
+
line: 5,
|
|
118
|
+
severity: "warning",
|
|
119
|
+
category: "bug",
|
|
120
|
+
confidence: 0.9,
|
|
121
|
+
title: "Null check missing",
|
|
122
|
+
explanation: "Variable could be null",
|
|
123
|
+
suggestion: "Add null check",
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
}));
|
|
127
|
+
const result = await model.review([makeFile()], makeConfig());
|
|
128
|
+
(0, vitest_1.expect)(result).toHaveLength(2);
|
|
129
|
+
(0, vitest_1.expect)(result[0].type).toBe("summary");
|
|
130
|
+
(0, vitest_1.expect)(result[0].content).toBe("Good code");
|
|
131
|
+
(0, vitest_1.expect)(result[1].type).toBe("inline");
|
|
132
|
+
(0, vitest_1.expect)(result[1].path).toBe("src/app.ts");
|
|
133
|
+
(0, vitest_1.expect)(result[1].line).toBe(5);
|
|
134
|
+
});
|
|
135
|
+
(0, vitest_1.it)("should filter comments below minimum confidence", async () => {
|
|
136
|
+
const model = new TestModel();
|
|
137
|
+
model.setResponse(JSON.stringify({
|
|
138
|
+
summary: "",
|
|
139
|
+
comments: [
|
|
140
|
+
{
|
|
141
|
+
file: "src/app.ts",
|
|
142
|
+
line: 1,
|
|
143
|
+
severity: "suggestion",
|
|
144
|
+
category: "style",
|
|
145
|
+
confidence: 0.3,
|
|
146
|
+
title: "Low confidence",
|
|
147
|
+
explanation: "Should be filtered",
|
|
148
|
+
suggestion: "",
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
file: "src/app.ts",
|
|
152
|
+
line: 2,
|
|
153
|
+
severity: "warning",
|
|
154
|
+
category: "bug",
|
|
155
|
+
confidence: 0.9,
|
|
156
|
+
title: "High confidence",
|
|
157
|
+
explanation: "Should pass",
|
|
158
|
+
suggestion: "",
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
}));
|
|
162
|
+
const result = await model.review([makeFile()], makeConfig());
|
|
163
|
+
const inlineComments = result.filter((c) => c.type === "inline");
|
|
164
|
+
(0, vitest_1.expect)(inlineComments).toHaveLength(1);
|
|
165
|
+
(0, vitest_1.expect)(inlineComments[0].line).toBe(2);
|
|
166
|
+
});
|
|
167
|
+
(0, vitest_1.it)("should filter comments below minimum severity", async () => {
|
|
168
|
+
const model = new TestModel();
|
|
169
|
+
model.setResponse(JSON.stringify({
|
|
170
|
+
summary: "",
|
|
171
|
+
comments: [
|
|
172
|
+
{
|
|
173
|
+
file: "src/app.ts",
|
|
174
|
+
line: 1,
|
|
175
|
+
severity: "nitpick",
|
|
176
|
+
category: "style",
|
|
177
|
+
confidence: 0.9,
|
|
178
|
+
title: "Nitpick",
|
|
179
|
+
explanation: "Should be filtered when min is suggestion",
|
|
180
|
+
suggestion: "",
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
file: "src/app.ts",
|
|
184
|
+
line: 2,
|
|
185
|
+
severity: "warning",
|
|
186
|
+
category: "bug",
|
|
187
|
+
confidence: 0.9,
|
|
188
|
+
title: "Warning",
|
|
189
|
+
explanation: "Should pass",
|
|
190
|
+
suggestion: "",
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
}));
|
|
194
|
+
const result = await model.review([makeFile()], makeConfig({ severity: "suggestion" }));
|
|
195
|
+
const inlineComments = result.filter((c) => c.type === "inline");
|
|
196
|
+
(0, vitest_1.expect)(inlineComments).toHaveLength(1);
|
|
197
|
+
(0, vitest_1.expect)(inlineComments[0].line).toBe(2);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
(0, vitest_1.describe)("legacy response parsing", () => {
|
|
201
|
+
(0, vitest_1.it)("should parse inline comments from free-text response", async () => {
|
|
202
|
+
const model = new TestModel();
|
|
203
|
+
model.setResponse("src/app.ts:5 — Missing null check");
|
|
204
|
+
const result = await model.review([makeFile()], makeConfig());
|
|
205
|
+
(0, vitest_1.expect)(result).toHaveLength(1);
|
|
206
|
+
(0, vitest_1.expect)(result[0].type).toBe("inline");
|
|
207
|
+
(0, vitest_1.expect)(result[0].path).toBe("src/app.ts");
|
|
208
|
+
(0, vitest_1.expect)(result[0].line).toBe(5);
|
|
209
|
+
(0, vitest_1.expect)(result[0].content).toBe("Missing null check");
|
|
210
|
+
});
|
|
211
|
+
(0, vitest_1.it)("should fall back to summary when no inline comments found", async () => {
|
|
212
|
+
const model = new TestModel();
|
|
213
|
+
model.setResponse("This code looks fine overall.");
|
|
214
|
+
const result = await model.review([makeFile()], makeConfig());
|
|
215
|
+
(0, vitest_1.expect)(result).toHaveLength(1);
|
|
216
|
+
(0, vitest_1.expect)(result[0].type).toBe("summary");
|
|
217
|
+
(0, vitest_1.expect)(result[0].content).toBe("This code looks fine overall.");
|
|
218
|
+
});
|
|
219
|
+
(0, vitest_1.it)("should return summary when commentStyle is summary", async () => {
|
|
220
|
+
const model = new TestModel();
|
|
221
|
+
model.setResponse("src/app.ts:5 — This would normally be inline");
|
|
222
|
+
const result = await model.review([makeFile()], makeConfig({ commentStyle: "summary" }));
|
|
223
|
+
(0, vitest_1.expect)(result).toHaveLength(1);
|
|
224
|
+
(0, vitest_1.expect)(result[0].type).toBe("summary");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
(0, vitest_1.describe)("malformed structured response", () => {
|
|
228
|
+
(0, vitest_1.it)("should fall back to legacy parsing for invalid JSON schema", async () => {
|
|
229
|
+
const warnSpy = vitest_1.vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
230
|
+
const model = new TestModel();
|
|
231
|
+
// Valid JSON but wrong schema (missing required fields)
|
|
232
|
+
model.setResponse(JSON.stringify({ comments: [{ wrong: "fields" }] }));
|
|
233
|
+
const result = await model.review([makeFile()], makeConfig());
|
|
234
|
+
// Falls through to legacy parsing → summary comment
|
|
235
|
+
(0, vitest_1.expect)(result).toHaveLength(1);
|
|
236
|
+
(0, vitest_1.expect)(result[0].type).toBe("summary");
|
|
237
|
+
(0, vitest_1.expect)(warnSpy).toHaveBeenCalled();
|
|
238
|
+
warnSpy.mockRestore();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
});
|
package/dist/models/index.d.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { CodeReviewModel } from "../types";
|
|
2
|
+
import { OpenAIModel } from "./openai";
|
|
3
|
+
import { BaseReviewModel, LLMMessage } from "./base";
|
|
4
|
+
export { BaseReviewModel, LLMMessage };
|
|
5
|
+
export { OpenAIModel };
|
|
2
6
|
/**
|
|
3
7
|
* Get model adapter for the specified AI model
|
|
4
|
-
* @param provider The provider of the AI model (e.g., openai,
|
|
5
|
-
* @param model The specific model to use (e.g., gpt-
|
|
8
|
+
* @param provider The provider of the AI model (e.g., openai, ollama, anthropic)
|
|
9
|
+
* @param model The specific model to use (e.g., gpt-4o, llama3)
|
|
6
10
|
* @param endpoint Optional custom endpoint URL
|
|
7
11
|
* @returns Model adapter instance
|
|
8
12
|
*/
|
package/dist/models/index.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OpenAIModel = exports.BaseReviewModel = void 0;
|
|
3
4
|
exports.getModel = getModel;
|
|
4
5
|
const openai_1 = require("./openai");
|
|
6
|
+
Object.defineProperty(exports, "OpenAIModel", { enumerable: true, get: function () { return openai_1.OpenAIModel; } });
|
|
7
|
+
const ollama_1 = require("./ollama");
|
|
8
|
+
const base_1 = require("./base");
|
|
9
|
+
Object.defineProperty(exports, "BaseReviewModel", { enumerable: true, get: function () { return base_1.BaseReviewModel; } });
|
|
5
10
|
/**
|
|
6
11
|
* Get model adapter for the specified AI model
|
|
7
|
-
* @param provider The provider of the AI model (e.g., openai,
|
|
8
|
-
* @param model The specific model to use (e.g., gpt-
|
|
12
|
+
* @param provider The provider of the AI model (e.g., openai, ollama, anthropic)
|
|
13
|
+
* @param model The specific model to use (e.g., gpt-4o, llama3)
|
|
9
14
|
* @param endpoint Optional custom endpoint URL
|
|
10
15
|
* @returns Model adapter instance
|
|
11
16
|
*/
|
|
@@ -13,6 +18,8 @@ function getModel(provider, model, endpoint) {
|
|
|
13
18
|
switch (provider) {
|
|
14
19
|
case "openai":
|
|
15
20
|
return new openai_1.OpenAIModel(model, endpoint);
|
|
21
|
+
case "ollama":
|
|
22
|
+
return new ollama_1.OllamaModel(model, endpoint);
|
|
16
23
|
default:
|
|
17
24
|
throw new Error(`Unsupported provider: ${provider}`);
|
|
18
25
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { ReviewConfig } from "../types";
|
|
2
|
+
import { BaseReviewModel, LLMMessage } from "./base";
|
|
3
|
+
/**
|
|
4
|
+
* Ollama model adapter for code review.
|
|
5
|
+
* Uses the local Ollama HTTP API with JSON format for structured output.
|
|
6
|
+
*/
|
|
7
|
+
export declare class OllamaModel extends BaseReviewModel {
|
|
8
|
+
private model;
|
|
9
|
+
private endpoint;
|
|
10
|
+
constructor(model: string, endpoint?: string);
|
|
11
|
+
/**
|
|
12
|
+
* Get the model name for this adapter
|
|
13
|
+
*/
|
|
14
|
+
protected getModelName(): string;
|
|
15
|
+
/**
|
|
16
|
+
* Ollama uses format: "json" which guarantees valid JSON syntax
|
|
17
|
+
* but does NOT enforce schema. The base class parseStructuredResponse()
|
|
18
|
+
* handles validation and falls back to legacy parsing if needed.
|
|
19
|
+
*/
|
|
20
|
+
protected supportsStructuredOutput(): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Make an LLM API call to the local Ollama instance
|
|
23
|
+
* @param messages Array of messages to send to the LLM
|
|
24
|
+
* @param config Review configuration
|
|
25
|
+
* @returns Raw response string from the LLM
|
|
26
|
+
*/
|
|
27
|
+
protected callLLM(messages: LLMMessage[], config: ReviewConfig): Promise<string>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OllamaModel = void 0;
|
|
4
|
+
const base_1 = require("./base");
|
|
5
|
+
/**
|
|
6
|
+
* Ollama model adapter for code review.
|
|
7
|
+
* Uses the local Ollama HTTP API with JSON format for structured output.
|
|
8
|
+
*/
|
|
9
|
+
class OllamaModel extends base_1.BaseReviewModel {
|
|
10
|
+
constructor(model, endpoint) {
|
|
11
|
+
super();
|
|
12
|
+
if (!model) {
|
|
13
|
+
throw new Error("Model is required");
|
|
14
|
+
}
|
|
15
|
+
this.model = model;
|
|
16
|
+
this.endpoint = endpoint || "http://localhost:11434";
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Get the model name for this adapter
|
|
20
|
+
*/
|
|
21
|
+
getModelName() {
|
|
22
|
+
return `Ollama (${this.model})`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Ollama uses format: "json" which guarantees valid JSON syntax
|
|
26
|
+
* but does NOT enforce schema. The base class parseStructuredResponse()
|
|
27
|
+
* handles validation and falls back to legacy parsing if needed.
|
|
28
|
+
*/
|
|
29
|
+
supportsStructuredOutput() {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Make an LLM API call to the local Ollama instance
|
|
34
|
+
* @param messages Array of messages to send to the LLM
|
|
35
|
+
* @param config Review configuration
|
|
36
|
+
* @returns Raw response string from the LLM
|
|
37
|
+
*/
|
|
38
|
+
async callLLM(messages, config) {
|
|
39
|
+
const timeoutMs = config.requestTimeout ?? 120000;
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
42
|
+
try {
|
|
43
|
+
const response = await fetch(`${this.endpoint}/api/chat`, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "Content-Type": "application/json" },
|
|
46
|
+
signal: controller.signal,
|
|
47
|
+
body: JSON.stringify({
|
|
48
|
+
model: this.model,
|
|
49
|
+
messages,
|
|
50
|
+
format: "json",
|
|
51
|
+
stream: false,
|
|
52
|
+
options: {
|
|
53
|
+
temperature: 0.1,
|
|
54
|
+
num_predict: 4000,
|
|
55
|
+
},
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
const errorText = await response.text();
|
|
60
|
+
throw new Error(`Ollama API error (${response.status}): ${errorText}`);
|
|
61
|
+
}
|
|
62
|
+
const data = (await response.json());
|
|
63
|
+
const content = data.message?.content;
|
|
64
|
+
if (!content) {
|
|
65
|
+
throw new Error("No response content from Ollama");
|
|
66
|
+
}
|
|
67
|
+
return content;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
71
|
+
throw new Error(`Ollama request timed out after ${timeoutMs / 1000}s. ` +
|
|
72
|
+
`The model may be too slow for this diff size. ` +
|
|
73
|
+
`Try a smaller model or reduce the diff.`);
|
|
74
|
+
}
|
|
75
|
+
if (error instanceof TypeError &&
|
|
76
|
+
(error.message.includes("fetch") ||
|
|
77
|
+
error.message.includes("ECONNREFUSED"))) {
|
|
78
|
+
throw new Error(`Cannot connect to Ollama at ${this.endpoint}. ` +
|
|
79
|
+
`Is Ollama running? Start it with: ollama serve`);
|
|
80
|
+
}
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
clearTimeout(timeout);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
exports.OllamaModel = OllamaModel;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
(0, vitest_1.describe)("OllamaModel constructor validation", () => {
|
|
5
|
+
(0, vitest_1.it)("should throw error if model is not provided", async () => {
|
|
6
|
+
const { OllamaModel } = await import("./ollama.js");
|
|
7
|
+
(0, vitest_1.expect)(() => new OllamaModel("")).toThrow("Model is required");
|
|
8
|
+
});
|
|
9
|
+
(0, vitest_1.it)("should not require any environment variables", async () => {
|
|
10
|
+
const { OllamaModel } = await import("./ollama.js");
|
|
11
|
+
(0, vitest_1.expect)(() => new OllamaModel("llama3")).not.toThrow();
|
|
12
|
+
});
|
|
13
|
+
(0, vitest_1.it)("should accept a custom endpoint", async () => {
|
|
14
|
+
const { OllamaModel } = await import("./ollama.js");
|
|
15
|
+
const model = new OllamaModel("llama3", "http://custom:11434");
|
|
16
|
+
(0, vitest_1.expect)(model).toBeDefined();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
(0, vitest_1.describe)("OllamaModel.review()", () => {
|
|
20
|
+
let originalFetch;
|
|
21
|
+
(0, vitest_1.beforeEach)(() => {
|
|
22
|
+
originalFetch = globalThis.fetch;
|
|
23
|
+
});
|
|
24
|
+
(0, vitest_1.afterEach)(() => {
|
|
25
|
+
globalThis.fetch = originalFetch;
|
|
26
|
+
vitest_1.vi.restoreAllMocks();
|
|
27
|
+
});
|
|
28
|
+
(0, vitest_1.it)("should return skip comment when all files are ignored", async () => {
|
|
29
|
+
const { OllamaModel } = await import("./ollama.js");
|
|
30
|
+
const model = new OllamaModel("llama3");
|
|
31
|
+
const result = await model.review([{ filename: "test.md", status: "modified", additions: 1, deletions: 0, patch: "+hello" }], {
|
|
32
|
+
provider: "ollama",
|
|
33
|
+
model: "llama3",
|
|
34
|
+
gitPlatform: "local",
|
|
35
|
+
ignoreFiles: ["*.md"],
|
|
36
|
+
});
|
|
37
|
+
(0, vitest_1.expect)(result).toHaveLength(1);
|
|
38
|
+
(0, vitest_1.expect)(result[0].type).toBe("summary");
|
|
39
|
+
(0, vitest_1.expect)(result[0].content).toContain("No files to review");
|
|
40
|
+
});
|
|
41
|
+
(0, vitest_1.it)("should call Ollama /api/chat with correct request shape", async () => {
|
|
42
|
+
const { OllamaModel } = await import("./ollama.js");
|
|
43
|
+
const model = new OllamaModel("llama3");
|
|
44
|
+
const mockResponse = {
|
|
45
|
+
model: "llama3",
|
|
46
|
+
created_at: "2024-01-01T00:00:00Z",
|
|
47
|
+
message: {
|
|
48
|
+
role: "assistant",
|
|
49
|
+
content: JSON.stringify({
|
|
50
|
+
summary: "Looks good",
|
|
51
|
+
comments: [],
|
|
52
|
+
}),
|
|
53
|
+
},
|
|
54
|
+
done: true,
|
|
55
|
+
};
|
|
56
|
+
globalThis.fetch = vitest_1.vi.fn().mockResolvedValue({
|
|
57
|
+
ok: true,
|
|
58
|
+
json: () => Promise.resolve(mockResponse),
|
|
59
|
+
});
|
|
60
|
+
await model.review([{ filename: "src/app.ts", status: "modified", additions: 1, deletions: 0, patch: "+const x = 1;" }], {
|
|
61
|
+
provider: "ollama",
|
|
62
|
+
model: "llama3",
|
|
63
|
+
gitPlatform: "local",
|
|
64
|
+
});
|
|
65
|
+
(0, vitest_1.expect)(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
66
|
+
const [url, options] = globalThis.fetch.mock.calls[0];
|
|
67
|
+
(0, vitest_1.expect)(url).toBe("http://localhost:11434/api/chat");
|
|
68
|
+
(0, vitest_1.expect)(options.method).toBe("POST");
|
|
69
|
+
const body = JSON.parse(options.body);
|
|
70
|
+
(0, vitest_1.expect)(body.model).toBe("llama3");
|
|
71
|
+
(0, vitest_1.expect)(body.format).toBe("json");
|
|
72
|
+
(0, vitest_1.expect)(body.stream).toBe(false);
|
|
73
|
+
(0, vitest_1.expect)(body.messages).toHaveLength(2);
|
|
74
|
+
(0, vitest_1.expect)(body.messages[0].role).toBe("system");
|
|
75
|
+
(0, vitest_1.expect)(body.messages[1].role).toBe("user");
|
|
76
|
+
(0, vitest_1.expect)(body.options.temperature).toBe(0.1);
|
|
77
|
+
});
|
|
78
|
+
(0, vitest_1.it)("should use custom endpoint when provided", async () => {
|
|
79
|
+
const { OllamaModel } = await import("./ollama.js");
|
|
80
|
+
const model = new OllamaModel("llama3", "http://remote:9999");
|
|
81
|
+
const mockResponse = {
|
|
82
|
+
model: "llama3",
|
|
83
|
+
created_at: "2024-01-01T00:00:00Z",
|
|
84
|
+
message: {
|
|
85
|
+
role: "assistant",
|
|
86
|
+
content: JSON.stringify({ summary: "", comments: [] }),
|
|
87
|
+
},
|
|
88
|
+
done: true,
|
|
89
|
+
};
|
|
90
|
+
globalThis.fetch = vitest_1.vi.fn().mockResolvedValue({
|
|
91
|
+
ok: true,
|
|
92
|
+
json: () => Promise.resolve(mockResponse),
|
|
93
|
+
});
|
|
94
|
+
await model.review([{ filename: "src/app.ts", status: "modified", additions: 1, deletions: 0, patch: "+x" }], { provider: "ollama", model: "llama3", gitPlatform: "local" });
|
|
95
|
+
const [url] = globalThis.fetch.mock.calls[0];
|
|
96
|
+
(0, vitest_1.expect)(url).toBe("http://remote:9999/api/chat");
|
|
97
|
+
});
|
|
98
|
+
(0, vitest_1.it)("should parse structured JSON response from Ollama", async () => {
|
|
99
|
+
const { OllamaModel } = await import("./ollama.js");
|
|
100
|
+
const model = new OllamaModel("llama3");
|
|
101
|
+
const structuredResponse = {
|
|
102
|
+
summary: "Found an issue",
|
|
103
|
+
comments: [
|
|
104
|
+
{
|
|
105
|
+
file: "src/app.ts",
|
|
106
|
+
line: 5,
|
|
107
|
+
severity: "warning",
|
|
108
|
+
category: "bug",
|
|
109
|
+
confidence: 0.85,
|
|
110
|
+
title: "Potential null reference",
|
|
111
|
+
explanation: "Variable could be null here",
|
|
112
|
+
suggestion: "Add null check",
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
globalThis.fetch = vitest_1.vi.fn().mockResolvedValue({
|
|
117
|
+
ok: true,
|
|
118
|
+
json: () => Promise.resolve({
|
|
119
|
+
model: "llama3",
|
|
120
|
+
created_at: "2024-01-01T00:00:00Z",
|
|
121
|
+
message: {
|
|
122
|
+
role: "assistant",
|
|
123
|
+
content: JSON.stringify(structuredResponse),
|
|
124
|
+
},
|
|
125
|
+
done: true,
|
|
126
|
+
}),
|
|
127
|
+
});
|
|
128
|
+
const result = await model.review([{ filename: "src/app.ts", status: "modified", additions: 1, deletions: 0, patch: "+const x = null;" }], { provider: "ollama", model: "llama3", gitPlatform: "local" });
|
|
129
|
+
// Summary + 1 inline comment
|
|
130
|
+
(0, vitest_1.expect)(result).toHaveLength(2);
|
|
131
|
+
(0, vitest_1.expect)(result[0].type).toBe("summary");
|
|
132
|
+
(0, vitest_1.expect)(result[0].content).toBe("Found an issue");
|
|
133
|
+
(0, vitest_1.expect)(result[1].type).toBe("inline");
|
|
134
|
+
(0, vitest_1.expect)(result[1].path).toBe("src/app.ts");
|
|
135
|
+
(0, vitest_1.expect)(result[1].line).toBe(5);
|
|
136
|
+
});
|
|
137
|
+
(0, vitest_1.it)("should fall back to legacy parsing for non-JSON responses", async () => {
|
|
138
|
+
const { OllamaModel } = await import("./ollama.js");
|
|
139
|
+
const model = new OllamaModel("llama3");
|
|
140
|
+
globalThis.fetch = vitest_1.vi.fn().mockResolvedValue({
|
|
141
|
+
ok: true,
|
|
142
|
+
json: () => Promise.resolve({
|
|
143
|
+
model: "llama3",
|
|
144
|
+
created_at: "2024-01-01T00:00:00Z",
|
|
145
|
+
message: {
|
|
146
|
+
role: "assistant",
|
|
147
|
+
content: "src/app.ts:5 — Missing null check",
|
|
148
|
+
},
|
|
149
|
+
done: true,
|
|
150
|
+
}),
|
|
151
|
+
});
|
|
152
|
+
const result = await model.review([{ filename: "src/app.ts", status: "modified", additions: 1, deletions: 0, patch: "+x" }], { provider: "ollama", model: "llama3", gitPlatform: "local" });
|
|
153
|
+
(0, vitest_1.expect)(result).toHaveLength(1);
|
|
154
|
+
(0, vitest_1.expect)(result[0].type).toBe("inline");
|
|
155
|
+
(0, vitest_1.expect)(result[0].path).toBe("src/app.ts");
|
|
156
|
+
(0, vitest_1.expect)(result[0].line).toBe(5);
|
|
157
|
+
(0, vitest_1.expect)(result[0].content).toBe("Missing null check");
|
|
158
|
+
});
|
|
159
|
+
(0, vitest_1.it)("should throw on HTTP error from Ollama API", async () => {
|
|
160
|
+
const { OllamaModel } = await import("./ollama.js");
|
|
161
|
+
const model = new OllamaModel("llama3");
|
|
162
|
+
globalThis.fetch = vitest_1.vi.fn().mockResolvedValue({
|
|
163
|
+
ok: false,
|
|
164
|
+
status: 404,
|
|
165
|
+
text: () => Promise.resolve("model 'llama3' not found"),
|
|
166
|
+
});
|
|
167
|
+
await (0, vitest_1.expect)(model.review([{ filename: "src/app.ts", status: "modified", additions: 1, deletions: 0, patch: "+x" }], { provider: "ollama", model: "llama3", gitPlatform: "local" })).rejects.toThrow("Ollama API error (404)");
|
|
168
|
+
});
|
|
169
|
+
(0, vitest_1.it)("should throw actionable error when Ollama is not running", async () => {
|
|
170
|
+
const { OllamaModel } = await import("./ollama.js");
|
|
171
|
+
const model = new OllamaModel("llama3");
|
|
172
|
+
globalThis.fetch = vitest_1.vi.fn().mockRejectedValue(new TypeError("fetch failed"));
|
|
173
|
+
await (0, vitest_1.expect)(model.review([{ filename: "src/app.ts", status: "modified", additions: 1, deletions: 0, patch: "+x" }], { provider: "ollama", model: "llama3", gitPlatform: "local" })).rejects.toThrow("Cannot connect to Ollama");
|
|
174
|
+
});
|
|
175
|
+
(0, vitest_1.it)("should throw on empty response content", async () => {
|
|
176
|
+
const { OllamaModel } = await import("./ollama.js");
|
|
177
|
+
const model = new OllamaModel("llama3");
|
|
178
|
+
globalThis.fetch = vitest_1.vi.fn().mockResolvedValue({
|
|
179
|
+
ok: true,
|
|
180
|
+
json: () => Promise.resolve({
|
|
181
|
+
model: "llama3",
|
|
182
|
+
created_at: "2024-01-01T00:00:00Z",
|
|
183
|
+
message: { role: "assistant", content: "" },
|
|
184
|
+
done: true,
|
|
185
|
+
}),
|
|
186
|
+
});
|
|
187
|
+
await (0, vitest_1.expect)(model.review([{ filename: "src/app.ts", status: "modified", additions: 1, deletions: 0, patch: "+x" }], { provider: "ollama", model: "llama3", gitPlatform: "local" })).rejects.toThrow("No response content from Ollama");
|
|
188
|
+
});
|
|
189
|
+
(0, vitest_1.it)("should filter comments below minimum confidence", async () => {
|
|
190
|
+
const { OllamaModel } = await import("./ollama.js");
|
|
191
|
+
const model = new OllamaModel("llama3");
|
|
192
|
+
const structuredResponse = {
|
|
193
|
+
summary: "",
|
|
194
|
+
comments: [
|
|
195
|
+
{
|
|
196
|
+
file: "src/app.ts",
|
|
197
|
+
line: 1,
|
|
198
|
+
severity: "suggestion",
|
|
199
|
+
category: "style",
|
|
200
|
+
confidence: 0.3,
|
|
201
|
+
title: "Low confidence comment",
|
|
202
|
+
explanation: "This should be filtered out",
|
|
203
|
+
suggestion: "",
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
file: "src/app.ts",
|
|
207
|
+
line: 2,
|
|
208
|
+
severity: "warning",
|
|
209
|
+
category: "bug",
|
|
210
|
+
confidence: 0.9,
|
|
211
|
+
title: "High confidence comment",
|
|
212
|
+
explanation: "This should pass through",
|
|
213
|
+
suggestion: "",
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
};
|
|
217
|
+
globalThis.fetch = vitest_1.vi.fn().mockResolvedValue({
|
|
218
|
+
ok: true,
|
|
219
|
+
json: () => Promise.resolve({
|
|
220
|
+
model: "llama3",
|
|
221
|
+
created_at: "2024-01-01T00:00:00Z",
|
|
222
|
+
message: {
|
|
223
|
+
role: "assistant",
|
|
224
|
+
content: JSON.stringify(structuredResponse),
|
|
225
|
+
},
|
|
226
|
+
done: true,
|
|
227
|
+
}),
|
|
228
|
+
});
|
|
229
|
+
const result = await model.review([{ filename: "src/app.ts", status: "modified", additions: 1, deletions: 0, patch: "+x" }], { provider: "ollama", model: "llama3", gitPlatform: "local" });
|
|
230
|
+
// Only the high-confidence comment should pass (low confidence filtered)
|
|
231
|
+
const inlineComments = result.filter((c) => c.type === "inline");
|
|
232
|
+
(0, vitest_1.expect)(inlineComments).toHaveLength(1);
|
|
233
|
+
(0, vitest_1.expect)(inlineComments[0].line).toBe(2);
|
|
234
|
+
});
|
|
235
|
+
});
|