diff --git a/.changeset/orange-foxes-shine.md b/.changeset/orange-foxes-shine.md new file mode 100644 index 0000000..6c17b34 --- /dev/null +++ b/.changeset/orange-foxes-shine.md @@ -0,0 +1,13 @@ +--- +"layne": patch +--- + +fix(semgrep): capture end.line so multi-line findings span the full annotation range + +Semgrep emits both `start.line` and `end.line` in its JSON output, but +the adapter only read `start.line` and left `startLine`/`endLine` unset. +Every Semgrep annotation therefore collapsed to a single line even for +rules that match multi-line constructs (e.g. multi-line function calls, +object literals, imports). Added `end?: { line: number }` to the raw +result interface and populate `startLine`/`endLine` in `toFinding`. +Falls back to `startLine` when `end` is absent. diff --git a/src/__tests__/adapters/semgrep.test.ts b/src/__tests__/adapters/semgrep.test.ts index a635096..1eebefa 100644 --- a/src/__tests__/adapters/semgrep.test.ts +++ b/src/__tests__/adapters/semgrep.test.ts @@ -238,4 +238,29 @@ describe('runSemgrep()', () => { const jsonIdx = args.indexOf('--json'); expect(jsonIdx).toBe(scanIdx + 1); }); + + describe('startLine / endLine from Semgrep end.line', () => { + it('sets startLine and endLine from start.line and end.line on a single-line finding', async () => { + stubStdout(semgrepOutput([SEMGREP_RESULT])); + const [f] = await runSemgrep({ workspacePath: '/tmp/ws', changedFiles: CHANGED_FILES }); + expect(f.startLine).toBe(10); + expect(f.endLine).toBe(10); + }); + + it('sets startLine and endLine from start.line and end.line on a multi-line finding', async () => { + const multiLine = { ...SEMGREP_RESULT, start: { line: 10, col: 1 }, end: { line: 14, col: 1 } }; + stubStdout(semgrepOutput([multiLine])); + const [f] = await runSemgrep({ workspacePath: '/tmp/ws', changedFiles: CHANGED_FILES }); + expect(f.startLine).toBe(10); + expect(f.endLine).toBe(14); + }); + + it('falls back endLine to startLine when end is missing from Semgrep output', async () => { + const noEnd = { check_id: SEMGREP_RESULT.check_id, path: SEMGREP_RESULT.path, start: { line: 7 }, extra: SEMGREP_RESULT.extra }; + stubStdout(semgrepOutput([noEnd])); + const [f] = await runSemgrep({ workspacePath: '/tmp/ws', changedFiles: CHANGED_FILES }); + expect(f.startLine).toBe(7); + expect(f.endLine).toBe(7); + }); + }); }); diff --git a/src/adapters/semgrep.ts b/src/adapters/semgrep.ts index 259802e..1598bf2 100644 --- a/src/adapters/semgrep.ts +++ b/src/adapters/semgrep.ts @@ -59,6 +59,7 @@ interface SemgrepRawResult { check_id: string; path: string; start?: { line: number }; + end?: { line: number }; extra?: { severity?: string; message?: string }; } @@ -69,12 +70,16 @@ const SEVERITY_MAP: Record = { }; function toFinding(result: SemgrepRawResult, workspacePath: string): SemgrepFinding { + const startLine = result.start?.line ?? 1; + const endLine = result.end?.line ?? startLine; return { - file: stripPrefix(result.path, workspacePath), - line: result.start?.line ?? 1, - severity: SEVERITY_MAP[result.extra?.severity ?? ''] ?? 'low', - message: result.extra?.message ?? 'Semgrep finding', - ruleId: result.check_id, - tool: 'semgrep', + file: stripPrefix(result.path, workspacePath), + line: startLine, + startLine, + endLine, + severity: SEVERITY_MAP[result.extra?.severity ?? ''] ?? 'low', + message: result.extra?.message ?? 'Semgrep finding', + ruleId: result.check_id, + tool: 'semgrep', }; }