@aaronshaf/ger 0.1.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.
Files changed (91) hide show
  1. package/.ast-grep/rules/no-as-casting.yml +13 -0
  2. package/.eslintrc.js +12 -0
  3. package/.github/workflows/ci-simple.yml +53 -0
  4. package/.github/workflows/ci.yml +171 -0
  5. package/.github/workflows/claude-code-review.yml +78 -0
  6. package/.github/workflows/claude.yml +64 -0
  7. package/.github/workflows/dependency-update.yml +84 -0
  8. package/.github/workflows/release.yml +166 -0
  9. package/.github/workflows/security-scan.yml +113 -0
  10. package/.github/workflows/security.yml +96 -0
  11. package/.husky/pre-commit +16 -0
  12. package/.husky/pre-push +25 -0
  13. package/.lintstagedrc.json +6 -0
  14. package/.tool-versions +1 -0
  15. package/CLAUDE.md +103 -0
  16. package/DEVELOPMENT.md +361 -0
  17. package/LICENSE +21 -0
  18. package/README.md +325 -0
  19. package/bin/ger +3 -0
  20. package/biome.json +36 -0
  21. package/bun.lock +688 -0
  22. package/bunfig.toml +8 -0
  23. package/oxlint.json +24 -0
  24. package/package.json +55 -0
  25. package/scripts/check-coverage.ts +69 -0
  26. package/scripts/check-file-size.ts +38 -0
  27. package/scripts/fix-test-mocks.ts +55 -0
  28. package/src/api/gerrit.ts +466 -0
  29. package/src/cli/commands/abandon.ts +65 -0
  30. package/src/cli/commands/comment.ts +460 -0
  31. package/src/cli/commands/comments.ts +85 -0
  32. package/src/cli/commands/diff.ts +71 -0
  33. package/src/cli/commands/incoming.ts +226 -0
  34. package/src/cli/commands/init.ts +164 -0
  35. package/src/cli/commands/mine.ts +115 -0
  36. package/src/cli/commands/open.ts +57 -0
  37. package/src/cli/commands/review.ts +593 -0
  38. package/src/cli/commands/setup.ts +230 -0
  39. package/src/cli/commands/show.ts +303 -0
  40. package/src/cli/commands/status.ts +35 -0
  41. package/src/cli/commands/workspace.ts +200 -0
  42. package/src/cli/index.ts +420 -0
  43. package/src/prompts/default-review.md +80 -0
  44. package/src/prompts/system-inline-review.md +88 -0
  45. package/src/prompts/system-overall-review.md +152 -0
  46. package/src/schemas/config.test.ts +245 -0
  47. package/src/schemas/config.ts +75 -0
  48. package/src/schemas/gerrit.ts +455 -0
  49. package/src/services/ai-enhanced.ts +167 -0
  50. package/src/services/ai.ts +182 -0
  51. package/src/services/config.test.ts +414 -0
  52. package/src/services/config.ts +206 -0
  53. package/src/test-utils/mock-generator.ts +73 -0
  54. package/src/utils/comment-formatters.ts +153 -0
  55. package/src/utils/diff-context.ts +103 -0
  56. package/src/utils/diff-formatters.ts +141 -0
  57. package/src/utils/formatters.ts +85 -0
  58. package/src/utils/message-filters.ts +26 -0
  59. package/src/utils/shell-safety.ts +117 -0
  60. package/src/utils/status-indicators.ts +100 -0
  61. package/src/utils/url-parser.test.ts +123 -0
  62. package/src/utils/url-parser.ts +91 -0
  63. package/tests/abandon.test.ts +163 -0
  64. package/tests/ai-service.test.ts +489 -0
  65. package/tests/comment-batch-advanced.test.ts +431 -0
  66. package/tests/comment-gerrit-api-compliance.test.ts +414 -0
  67. package/tests/comment.test.ts +707 -0
  68. package/tests/comments.test.ts +323 -0
  69. package/tests/config-service-simple.test.ts +100 -0
  70. package/tests/diff.test.ts +419 -0
  71. package/tests/helpers/config-mock.ts +27 -0
  72. package/tests/incoming.test.ts +357 -0
  73. package/tests/interactive-incoming.test.ts +173 -0
  74. package/tests/mine.test.ts +318 -0
  75. package/tests/mocks/fetch-mock.ts +139 -0
  76. package/tests/mocks/msw-handlers.ts +80 -0
  77. package/tests/open.test.ts +233 -0
  78. package/tests/review.test.ts +669 -0
  79. package/tests/setup.ts +13 -0
  80. package/tests/show.test.ts +439 -0
  81. package/tests/unit/schemas/gerrit.test.ts +85 -0
  82. package/tests/unit/test-utils/mock-generator.test.ts +154 -0
  83. package/tests/unit/utils/comment-formatters.test.ts +415 -0
  84. package/tests/unit/utils/diff-context.test.ts +171 -0
  85. package/tests/unit/utils/diff-formatters.test.ts +165 -0
  86. package/tests/unit/utils/formatters.test.ts +411 -0
  87. package/tests/unit/utils/message-filters.test.ts +227 -0
  88. package/tests/unit/utils/prompt-helpers.test.ts +175 -0
  89. package/tests/unit/utils/shell-safety.test.ts +230 -0
  90. package/tests/unit/utils/status-indicators.test.ts +137 -0
  91. package/tsconfig.json +40 -0
@@ -0,0 +1,415 @@
1
+ import { describe, expect, test, mock, beforeEach, afterEach } from 'bun:test'
2
+ import {
3
+ formatCommentsPretty,
4
+ formatCommentsXml,
5
+ CommentWithContext,
6
+ } from '@/utils/comment-formatters'
7
+ import type { CommentInfo } from '@/schemas/gerrit'
8
+
9
+ describe('Comment Formatters', () => {
10
+ const mockComment: CommentInfo = {
11
+ id: 'comment1',
12
+ path: 'src/main.ts',
13
+ author: {
14
+ _account_id: 1000123,
15
+ name: 'John Doe',
16
+ email: 'john.doe@example.com',
17
+ },
18
+ updated: '2023-12-01 12:30:00.000000000',
19
+ message: 'This looks good to me!',
20
+ }
21
+
22
+ const mockLineComment: CommentInfo = {
23
+ ...mockComment,
24
+ id: 'linecomment1',
25
+ line: 42,
26
+ range: {
27
+ start_line: 42,
28
+ start_character: 0,
29
+ end_line: 42,
30
+ end_character: 20,
31
+ },
32
+ message: 'Consider using a more descriptive variable name.',
33
+ }
34
+
35
+ describe('formatCommentsPretty', () => {
36
+ // Mock console.log to capture output
37
+ const originalConsoleLog = console.log
38
+ let consoleOutput: string[] = []
39
+
40
+ beforeEach(() => {
41
+ consoleOutput = []
42
+ console.log = mock((...args: any[]) => {
43
+ consoleOutput.push(args.join(' '))
44
+ })
45
+ })
46
+
47
+ afterEach(() => {
48
+ console.log = originalConsoleLog
49
+ })
50
+
51
+ test('should handle empty comments array', () => {
52
+ formatCommentsPretty([])
53
+
54
+ expect(consoleOutput).toContain('No comments found on this change')
55
+ })
56
+
57
+ test('should format single comment', () => {
58
+ const commentWithContext: CommentWithContext = {
59
+ comment: mockComment,
60
+ }
61
+
62
+ formatCommentsPretty([commentWithContext])
63
+
64
+ const output = consoleOutput.join('\n')
65
+ expect(output).toContain('Found 1 comment:')
66
+ expect(output).toContain('John Doe')
67
+ expect(output).toContain('This looks good to me!')
68
+ expect(output).toContain('src/main.ts')
69
+ })
70
+
71
+ test('should format multiple comments', () => {
72
+ const comments: CommentWithContext[] = [
73
+ { comment: mockComment },
74
+ { comment: { ...mockComment, id: 'comment2', message: 'Another comment' } },
75
+ ]
76
+
77
+ formatCommentsPretty(comments)
78
+
79
+ const output = consoleOutput.join('\n')
80
+ expect(output).toContain('Found 2 comments:')
81
+ expect(output).toContain('This looks good to me!')
82
+ expect(output).toContain('Another comment')
83
+ })
84
+
85
+ test('should show line information for line comments', () => {
86
+ const commentWithContext: CommentWithContext = {
87
+ comment: mockLineComment,
88
+ }
89
+
90
+ formatCommentsPretty([commentWithContext])
91
+
92
+ const output = consoleOutput.join('\n')
93
+ expect(output).toContain('Line 42:')
94
+ })
95
+
96
+ test('should show diff context when available', () => {
97
+ const commentWithContext: CommentWithContext = {
98
+ comment: mockLineComment,
99
+ context: {
100
+ before: [' const oldValue = getValue();'],
101
+ line: ' const newValue = getBetterValue();',
102
+ after: [' return newValue;'],
103
+ },
104
+ }
105
+
106
+ formatCommentsPretty([commentWithContext])
107
+
108
+ const output = consoleOutput.join('\n')
109
+ expect(output).toContain('const oldValue')
110
+ expect(output).toContain('const newValue')
111
+ expect(output).toContain('return newValue')
112
+ })
113
+
114
+ test('should handle unresolved comments', () => {
115
+ const unresolvedComment: CommentWithContext = {
116
+ comment: {
117
+ ...mockComment,
118
+ unresolved: true,
119
+ },
120
+ }
121
+
122
+ formatCommentsPretty([unresolvedComment])
123
+
124
+ const output = consoleOutput.join('\n')
125
+ expect(output).toContain('[UNRESOLVED]')
126
+ })
127
+
128
+ test('should handle comments without author name', () => {
129
+ const commentWithoutName: CommentWithContext = {
130
+ comment: {
131
+ ...mockComment,
132
+ author: {
133
+ _account_id: 1000123,
134
+ email: 'anonymous@example.com',
135
+ },
136
+ },
137
+ }
138
+
139
+ formatCommentsPretty([commentWithoutName])
140
+
141
+ const output = consoleOutput.join('\n')
142
+ expect(output).toContain('Unknown')
143
+ })
144
+
145
+ test('should handle multiline messages', () => {
146
+ const multilineComment: CommentWithContext = {
147
+ comment: {
148
+ ...mockComment,
149
+ message: 'This is line 1\nThis is line 2\nThis is line 3',
150
+ },
151
+ }
152
+
153
+ formatCommentsPretty([multilineComment])
154
+
155
+ const output = consoleOutput.join('\n')
156
+ expect(output).toContain('This is line 1')
157
+ expect(output).toContain('This is line 2')
158
+ expect(output).toContain('This is line 3')
159
+ })
160
+
161
+ test('should group comments by file path', () => {
162
+ const comments: CommentWithContext[] = [
163
+ { comment: { ...mockComment, path: 'src/file1.ts' } },
164
+ { comment: { ...mockComment, path: 'src/file1.ts', id: 'comment2' } },
165
+ { comment: { ...mockComment, path: 'src/file2.ts', id: 'comment3' } },
166
+ ]
167
+
168
+ formatCommentsPretty(comments)
169
+
170
+ const output = consoleOutput.join('\n')
171
+ expect(output).toContain('src/file1.ts')
172
+ expect(output).toContain('src/file2.ts')
173
+ })
174
+
175
+ test('should handle context with only before lines', () => {
176
+ const commentWithContext: CommentWithContext = {
177
+ comment: mockLineComment,
178
+ context: {
179
+ before: [' // Previous line 1', ' // Previous line 2'],
180
+ after: [],
181
+ },
182
+ }
183
+
184
+ formatCommentsPretty([commentWithContext])
185
+
186
+ const output = consoleOutput.join('\n')
187
+ expect(output).toContain('Previous line 1')
188
+ expect(output).toContain('Previous line 2')
189
+ })
190
+
191
+ test('should handle context with only after lines', () => {
192
+ const commentWithContext: CommentWithContext = {
193
+ comment: mockLineComment,
194
+ context: {
195
+ before: [],
196
+ after: [' // Next line 1', ' // Next line 2'],
197
+ },
198
+ }
199
+
200
+ formatCommentsPretty([commentWithContext])
201
+
202
+ const output = consoleOutput.join('\n')
203
+ expect(output).toContain('Next line 1')
204
+ expect(output).toContain('Next line 2')
205
+ })
206
+ })
207
+
208
+ describe('formatCommentsXml', () => {
209
+ const originalConsoleLog = console.log
210
+ let consoleOutput: string[] = []
211
+
212
+ beforeEach(() => {
213
+ consoleOutput = []
214
+ console.log = mock((...args: any[]) => {
215
+ consoleOutput.push(args.join(' '))
216
+ })
217
+ })
218
+
219
+ afterEach(() => {
220
+ console.log = originalConsoleLog
221
+ })
222
+
223
+ test('should format basic XML structure', () => {
224
+ const commentWithContext: CommentWithContext = {
225
+ comment: mockComment,
226
+ }
227
+
228
+ formatCommentsXml('12345', [commentWithContext])
229
+
230
+ const output = consoleOutput.join('\n')
231
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
232
+ expect(output).toContain('<comments_result>')
233
+ expect(output).toContain('<change_id>12345</change_id>')
234
+ expect(output).toContain('<comment_count>1</comment_count>')
235
+ expect(output).toContain('<comments>')
236
+ expect(output).toContain('</comments_result>')
237
+ })
238
+
239
+ test('should format comment details in XML', () => {
240
+ const commentWithContext: CommentWithContext = {
241
+ comment: mockLineComment,
242
+ }
243
+
244
+ formatCommentsXml('12345', [commentWithContext])
245
+
246
+ const output = consoleOutput.join('\n')
247
+ expect(output).toContain('<id>linecomment1</id>')
248
+ expect(output).toContain('<path><![CDATA[src/main.ts]]></path>')
249
+ expect(output).toContain('<line>42</line>')
250
+ expect(output).toContain('<name><![CDATA[John Doe]]></name>')
251
+ expect(output).toContain('<email>john.doe@example.com</email>')
252
+ expect(output).toContain('<account_id>1000123</account_id>')
253
+ expect(output).toContain('<updated>2023-12-01 12:30:00.000000000</updated>')
254
+ expect(output).toContain(
255
+ '<message><![CDATA[Consider using a more descriptive variable name.]]></message>',
256
+ )
257
+ })
258
+
259
+ test('should format range information in XML', () => {
260
+ const commentWithRange: CommentWithContext = {
261
+ comment: {
262
+ ...mockLineComment,
263
+ range: {
264
+ start_line: 42,
265
+ start_character: 5,
266
+ end_line: 44,
267
+ end_character: 15,
268
+ },
269
+ },
270
+ }
271
+
272
+ formatCommentsXml('12345', [commentWithRange])
273
+
274
+ const output = consoleOutput.join('\n')
275
+ expect(output).toContain('<range>')
276
+ expect(output).toContain('<start_line>42</start_line>')
277
+ expect(output).toContain('<start_character>5</start_character>')
278
+ expect(output).toContain('<end_line>44</end_line>')
279
+ expect(output).toContain('<end_character>15</end_character>')
280
+ expect(output).toContain('</range>')
281
+ })
282
+
283
+ test('should format diff context in XML', () => {
284
+ const commentWithContext: CommentWithContext = {
285
+ comment: mockComment,
286
+ context: {
287
+ before: [' const oldCode = getValue();'],
288
+ line: ' const newCode = getBetterValue();',
289
+ after: [' return newCode;'],
290
+ },
291
+ }
292
+
293
+ formatCommentsXml('12345', [commentWithContext])
294
+
295
+ const output = consoleOutput.join('\n')
296
+ expect(output).toContain('<diff_context>')
297
+ expect(output).toContain('<before>')
298
+ expect(output).toContain('<![CDATA[ const oldCode = getValue();]]>')
299
+ expect(output).toContain(
300
+ '<target_line><![CDATA[ const newCode = getBetterValue();]]></target_line>',
301
+ )
302
+ expect(output).toContain('<after>')
303
+ expect(output).toContain('<![CDATA[ return newCode;]]>')
304
+ expect(output).toContain('</diff_context>')
305
+ })
306
+
307
+ test('should handle unresolved comments in XML', () => {
308
+ const unresolvedComment: CommentWithContext = {
309
+ comment: {
310
+ ...mockComment,
311
+ unresolved: true,
312
+ },
313
+ }
314
+
315
+ formatCommentsXml('12345', [unresolvedComment])
316
+
317
+ const output = consoleOutput.join('\n')
318
+ expect(output).toContain('<unresolved>true</unresolved>')
319
+ })
320
+
321
+ test('should handle in_reply_to in XML', () => {
322
+ const replyComment: CommentWithContext = {
323
+ comment: {
324
+ ...mockComment,
325
+ in_reply_to: 'parent_comment_id',
326
+ },
327
+ }
328
+
329
+ formatCommentsXml('12345', [replyComment])
330
+
331
+ const output = consoleOutput.join('\n')
332
+ expect(output).toContain('<in_reply_to>parent_comment_id</in_reply_to>')
333
+ })
334
+
335
+ test('should escape special characters in change ID', () => {
336
+ const commentWithContext: CommentWithContext = {
337
+ comment: mockComment,
338
+ }
339
+
340
+ formatCommentsXml('project~main~I123&<>', [commentWithContext])
341
+
342
+ const output = consoleOutput.join('\n')
343
+ expect(output).toContain('<change_id>project~main~I123&amp;&lt;&gt;</change_id>')
344
+ })
345
+
346
+ test('should handle comments without optional fields', () => {
347
+ const minimalComment: CommentWithContext = {
348
+ comment: {
349
+ id: 'minimal',
350
+ author: {
351
+ _account_id: 1000123,
352
+ },
353
+ updated: '2023-12-01 12:30:00.000000000',
354
+ message: 'Minimal comment',
355
+ },
356
+ }
357
+
358
+ formatCommentsXml('12345', [minimalComment])
359
+
360
+ const output = consoleOutput.join('\n')
361
+ expect(output).toContain('<id>minimal</id>')
362
+ expect(output).toContain('<account_id>1000123</account_id>')
363
+ expect(output).toContain('<message><![CDATA[Minimal comment]]></message>')
364
+ expect(output).not.toContain('<path>')
365
+ expect(output).not.toContain('<line>')
366
+ expect(output).not.toContain('<name>')
367
+ expect(output).not.toContain('<email>')
368
+ })
369
+
370
+ test('should handle range without optional character positions', () => {
371
+ const commentWithBasicRange: CommentWithContext = {
372
+ comment: {
373
+ ...mockLineComment,
374
+ range: {
375
+ start_line: 42,
376
+ end_line: 44,
377
+ },
378
+ },
379
+ }
380
+
381
+ formatCommentsXml('12345', [commentWithBasicRange])
382
+
383
+ const output = consoleOutput.join('\n')
384
+ expect(output).toContain('<start_line>42</start_line>')
385
+ expect(output).toContain('<end_line>44</end_line>')
386
+ expect(output).not.toContain('<start_character>')
387
+ expect(output).not.toContain('<end_character>')
388
+ })
389
+
390
+ test('should handle multiple comments correctly', () => {
391
+ const comments: CommentWithContext[] = [
392
+ { comment: mockComment },
393
+ { comment: { ...mockComment, id: 'comment2', message: 'Second comment' } },
394
+ ]
395
+
396
+ formatCommentsXml('12345', comments)
397
+
398
+ const output = consoleOutput.join('\n')
399
+ expect(output).toContain('<comment_count>2</comment_count>')
400
+ expect(output).toContain('<id>comment1</id>')
401
+ expect(output).toContain('<id>comment2</id>')
402
+ expect(output).toContain('This looks good to me!')
403
+ expect(output).toContain('Second comment')
404
+ })
405
+
406
+ test('should handle empty comments array', () => {
407
+ formatCommentsXml('12345', [])
408
+
409
+ const output = consoleOutput.join('\n')
410
+ expect(output).toContain('<comment_count>0</comment_count>')
411
+ expect(output).toContain('<comments>')
412
+ expect(output).toContain('</comments>')
413
+ })
414
+ })
415
+ })
@@ -0,0 +1,171 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import type { FileDiffContent } from '@/schemas/gerrit'
3
+ import { extractDiffContext } from '@/utils/diff-context'
4
+
5
+ describe('extractDiffContext', () => {
6
+ it('should extract context around a simple line', () => {
7
+ const diff: FileDiffContent = {
8
+ content: [
9
+ {
10
+ ab: ['line 1', 'line 2', 'line 3', 'line 4', 'line 5'],
11
+ },
12
+ ],
13
+ }
14
+
15
+ const context = extractDiffContext(diff, 3, 2)
16
+
17
+ expect(context.before).toEqual(['line 1', 'line 2'])
18
+ expect(context.line).toBe('line 3')
19
+ expect(context.after).toEqual(['line 4', 'line 5'])
20
+ })
21
+
22
+ it('should handle added lines correctly', () => {
23
+ const diff: FileDiffContent = {
24
+ content: [
25
+ {
26
+ ab: ['line 1', 'line 2'],
27
+ },
28
+ {
29
+ b: ['added 1', 'added 2', 'added 3'],
30
+ },
31
+ {
32
+ ab: ['line 3', 'line 4'],
33
+ },
34
+ ],
35
+ }
36
+
37
+ // Target line 4 (added 2)
38
+ const context = extractDiffContext(diff, 4, 1)
39
+
40
+ expect(context.before).toEqual(['added 1'])
41
+ expect(context.line).toBe('added 2')
42
+ expect(context.after).toEqual(['added 3'])
43
+ })
44
+
45
+ it('should handle removed lines correctly (they dont affect new line numbers)', () => {
46
+ const diff: FileDiffContent = {
47
+ content: [
48
+ {
49
+ ab: ['line 1', 'line 2'],
50
+ },
51
+ {
52
+ a: ['removed 1', 'removed 2'], // These don't count in new file
53
+ },
54
+ {
55
+ ab: ['line 3', 'line 4'],
56
+ },
57
+ ],
58
+ }
59
+
60
+ // Line 3 in new file is 'line 3' (removed lines don't count)
61
+ const context = extractDiffContext(diff, 3, 1)
62
+
63
+ expect(context.before).toEqual(['line 2'])
64
+ expect(context.line).toBe('line 3')
65
+ expect(context.after).toEqual(['line 4'])
66
+ })
67
+
68
+ it('should handle skip sections correctly', () => {
69
+ const diff: FileDiffContent = {
70
+ content: [
71
+ {
72
+ ab: ['line 1', 'line 2'],
73
+ },
74
+ {
75
+ skip: 100, // Lines 3-102 are skipped
76
+ },
77
+ {
78
+ ab: ['line 103', 'line 104'],
79
+ },
80
+ ],
81
+ }
82
+
83
+ // Line in skipped section - should return empty context
84
+ const context1 = extractDiffContext(diff, 50, 2)
85
+ expect(context1.before).toEqual([])
86
+ expect(context1.line).toBeUndefined()
87
+ expect(context1.after).toEqual([])
88
+
89
+ // Line after skip
90
+ const context2 = extractDiffContext(diff, 103, 1)
91
+ expect(context2.line).toBe('line 103')
92
+ expect(context2.after).toEqual(['line 104'])
93
+ })
94
+
95
+ it('should handle edge cases at file boundaries', () => {
96
+ const diff: FileDiffContent = {
97
+ content: [
98
+ {
99
+ ab: ['line 1', 'line 2', 'line 3'],
100
+ },
101
+ ],
102
+ }
103
+
104
+ // First line
105
+ const context1 = extractDiffContext(diff, 1, 2)
106
+ expect(context1.before).toEqual([])
107
+ expect(context1.line).toBe('line 1')
108
+ expect(context1.after).toEqual(['line 2', 'line 3'])
109
+
110
+ // Last line
111
+ const context2 = extractDiffContext(diff, 3, 2)
112
+ expect(context2.before).toEqual(['line 1', 'line 2'])
113
+ expect(context2.line).toBe('line 3')
114
+ expect(context2.after).toEqual([])
115
+ })
116
+
117
+ it('should return empty context for non-existent lines', () => {
118
+ const diff: FileDiffContent = {
119
+ content: [
120
+ {
121
+ ab: ['line 1', 'line 2'],
122
+ },
123
+ ],
124
+ }
125
+
126
+ const context = extractDiffContext(diff, 999, 2)
127
+ expect(context.before).toEqual([])
128
+ expect(context.line).toBeUndefined()
129
+ expect(context.after).toEqual([])
130
+ })
131
+
132
+ it('should handle complex mixed diff sections', () => {
133
+ const diff: FileDiffContent = {
134
+ content: [
135
+ {
136
+ ab: ['unchanged 1'], // Line 1
137
+ },
138
+ {
139
+ a: ['old only'], // Not in new file
140
+ },
141
+ {
142
+ b: ['new only'], // Line 2
143
+ },
144
+ {
145
+ ab: ['unchanged 2'], // Line 3
146
+ },
147
+ {
148
+ skip: 10, // Lines 4-13 skipped
149
+ },
150
+ {
151
+ ab: ['unchanged 3'], // Line 14
152
+ },
153
+ {
154
+ b: ['added at end'], // Line 15
155
+ },
156
+ ],
157
+ }
158
+
159
+ // Test line 2 (new only)
160
+ const context1 = extractDiffContext(diff, 2, 1)
161
+ expect(context1.before).toEqual(['unchanged 1'])
162
+ expect(context1.line).toBe('new only')
163
+ expect(context1.after).toEqual(['unchanged 2'])
164
+
165
+ // Test line 15 (added at end)
166
+ const context2 = extractDiffContext(diff, 15, 1)
167
+ expect(context2.before).toEqual(['unchanged 3'])
168
+ expect(context2.line).toBe('added at end')
169
+ expect(context2.after).toEqual([])
170
+ })
171
+ })