@@ -15,7 +15,22 @@ function rejectWith(err = new Error('not found')) {
1515 mockExecFile . mockImplementationOnce ( ( _cmd , _args , cb ) => cb ( err , '' , '' ) ) ;
1616}
1717
18- const BASE = { workspacePath : '/workspace' , baseSha : 'base-sha' } ;
18+ // Sets up both the git diff (identity map: headLine === baseLine) and git show
19+ // (file content) calls for a single file. Use when line shifts are not relevant.
20+ function mockFile ( filePath , content ) {
21+ const lines = content . split ( '\n' ) ;
22+ const count = lines [ lines . length - 1 ] === '' ? lines . length - 1 : lines . length ;
23+ const diff = [
24+ `--- a/${ filePath } ` ,
25+ `+++ b/${ filePath } ` ,
26+ `@@ -1,${ count } +1,${ count } @@` ,
27+ ...Array ( count ) . fill ( ' x' ) ,
28+ ] . join ( '\n' ) ;
29+ resolveWith ( diff ) ; // for buildLineMapForFile (git diff)
30+ resolveWith ( content ) ; // for gitShow (git show)
31+ }
32+
33+ const BASE = { workspacePath : '/workspace' , baseSha : 'base-sha' , headSha : 'head-sha' } ;
1934
2035const finding = ( overrides = { } ) => ( {
2136 file : 'src/app.js' ,
@@ -37,61 +52,61 @@ describe('suppressFindings()', () => {
3752 } ) ;
3853
3954 it ( 'keeps a finding when git show fails (new file)' , async ( ) => {
40- rejectWith ( new Error ( 'fatal: path not in tree' ) ) ;
55+ resolveWith ( '' ) ; // diff: empty map → fall back to head line
56+ rejectWith ( new Error ( 'fatal: path not in tree' ) ) ; // show: fails
4157 const f = finding ( ) ;
4258 const result = await suppressFindings ( [ f ] , BASE ) ;
4359 expect ( result ) . toEqual ( [ f ] ) ;
4460 } ) ;
4561
4662 it ( 'keeps a finding when there is no SECURITY: comment at base' , async ( ) => {
47- resolveWith ( 'line1\nline2\nline3\nline4\nline5\nline6\n' ) ;
63+ mockFile ( 'src/app.js' , 'line1\nline2\nline3\nline4\nline5\nline6\n' ) ;
4864 const f = finding ( { line : 5 } ) ;
4965 const result = await suppressFindings ( [ f ] , BASE ) ;
5066 expect ( result ) . toEqual ( [ f ] ) ;
5167 } ) ;
5268
5369 it ( 'suppresses a finding when // SECURITY: reason is on the same line' , async ( ) => {
54- resolveWith ( 'line1\nline2\nline3\nline4\nconst x = 1; // SECURITY: reviewed by alice\nline6\n' ) ;
70+ mockFile ( 'src/app.js' , 'line1\nline2\nline3\nline4\nconst x = 1; // SECURITY: reviewed by alice\nline6\n' ) ;
5571 const result = await suppressFindings ( [ finding ( { line : 5 } ) ] , BASE ) ;
5672 expect ( result ) . toEqual ( [ ] ) ;
5773 } ) ;
5874
5975 it ( 'suppresses a finding when // SECURITY: reason is on the line immediately above' , async ( ) => {
60- resolveWith ( 'line1\nline2\nline3\n// SECURITY: approved in issue #42\nconst x = 1;\nline6\n' ) ;
76+ mockFile ( 'src/app.js' , 'line1\nline2\nline3\n// SECURITY: approved in issue #42\nconst x = 1;\nline6\n' ) ;
6177 const result = await suppressFindings ( [ finding ( { line : 5 } ) ] , BASE ) ;
6278 expect ( result ) . toEqual ( [ ] ) ;
6379 } ) ;
6480
6581 it ( 'suppresses a finding with # SECURITY: (YAML/shell comment style)' , async ( ) => {
66- resolveWith ( 'line1\nline2\nline3\nline4\n# SECURITY: checked for injection\nline6\n' ) ;
82+ mockFile ( 'src/app.js' , 'line1\nline2\nline3\nline4\n# SECURITY: checked for injection\nline6\n' ) ;
6783 const result = await suppressFindings ( [ finding ( { line : 5 } ) ] , BASE ) ;
6884 expect ( result ) . toEqual ( [ ] ) ;
6985 } ) ;
7086
7187 it ( 'does NOT suppress when // SECURITY: has no text after the colon' , async ( ) => {
72- resolveWith ( 'line1\nline2\nline3\nline4\n// SECURITY:\nline6\n' ) ;
88+ mockFile ( 'src/app.js' , 'line1\nline2\nline3\nline4\n// SECURITY:\nline6\n' ) ;
7389 const f = finding ( { line : 5 } ) ;
7490 const result = await suppressFindings ( [ f ] , BASE ) ;
7591 expect ( result ) . toEqual ( [ f ] ) ;
7692 } ) ;
7793
78- it ( 'calls execFile only once for two findings in the same file (cache hit)' , async ( ) => {
79- resolveWith ( 'line1\nline2\nline3\nline4\nline5\n' ) ;
94+ it ( 'calls execFile exactly twice for two findings in the same file (cache hit)' , async ( ) => {
95+ mockFile ( 'src/app.js' , 'line1\nline2\nline3\nline4\nline5\n' ) ;
8096 const f1 = finding ( { line : 2 } ) ;
8197 const f2 = finding ( { line : 4 } ) ;
8298 await suppressFindings ( [ f1 , f2 ] , BASE ) ;
83- expect ( mockExecFile ) . toHaveBeenCalledTimes ( 1 ) ;
99+ expect ( mockExecFile ) . toHaveBeenCalledTimes ( 2 ) ;
84100 } ) ;
85101
86102 it ( 'does not crash and keeps the finding when finding.line === 1' , async ( ) => {
87- resolveWith ( 'const x = 1;\nline2\n' ) ;
103+ mockFile ( 'src/app.js' , 'const x = 1;\nline2\n' ) ;
88104 const f = finding ( { line : 1 } ) ;
89105 const result = await suppressFindings ( [ f ] , BASE ) ;
90106 expect ( result ) . toEqual ( [ f ] ) ;
91107 } ) ;
92108
93109 it ( 'does not suppress an unvalidated Claude finding' , async ( ) => {
94- resolveWith ( 'line1\n// SECURITY: unrelated prior approval\nline3\n' ) ;
95110 const f = finding ( {
96111 tool : 'claude' ,
97112 line : 2 ,
@@ -103,10 +118,11 @@ describe('suppressFindings()', () => {
103118 const result = await suppressFindings ( [ f ] , BASE ) ;
104119
105120 expect ( result ) . toEqual ( [ f ] ) ;
121+ expect ( mockExecFile ) . not . toHaveBeenCalled ( ) ;
106122 } ) ;
107123
108124 it ( 'uses suppressionLine when checking for SECURITY comments' , async ( ) => {
109- resolveWith ( 'line1\nline2\n// SECURITY: approved in prior PR\nline4\n' ) ;
125+ mockFile ( 'src/app.js' , 'line1\nline2\n// SECURITY: approved in prior PR\nline4\n' ) ;
110126
111127 const result = await suppressFindings ( [ finding ( {
112128 tool : 'claude' ,
@@ -122,14 +138,8 @@ describe('suppressFindings()', () => {
122138 } ) ;
123139
124140 it ( 'returns the correct filtered array for a mixed batch' , async ( ) => {
125- // file A: has a SECURITY comment above line 3
126- const fileAContent = 'line1\n// SECURITY: reviewed by bob\nconst y = 2;\nline4\n' ;
127- // file B: no SECURITY comment
128- const fileBContent = 'line1\nline2\nline3\n' ;
129-
130- mockExecFile
131- . mockImplementationOnce ( ( _cmd , _args , cb ) => cb ( null , fileAContent , '' ) )
132- . mockImplementationOnce ( ( _cmd , _args , cb ) => cb ( null , fileBContent , '' ) ) ;
141+ mockFile ( 'src/a.js' , 'line1\n// SECURITY: reviewed by bob\nconst y = 2;\nline4\n' ) ;
142+ mockFile ( 'src/b.js' , 'line1\nline2\nline3\n' ) ;
133143
134144 const fA = finding ( { file : 'src/a.js' , line : 3 } ) ; // should be suppressed
135145 const fB = finding ( { file : 'src/b.js' , line : 1 } ) ; // should be kept
@@ -139,8 +149,9 @@ describe('suppressFindings()', () => {
139149 } ) ;
140150
141151 it ( 'handles git show failure for file A independently from success for file B' , async ( ) => {
142- rejectWith ( new Error ( 'not found' ) ) ;
143- resolveWith ( 'line1\nline2\nline3\n' ) ;
152+ resolveWith ( '' ) ; // fA: diff returns empty → fall back to head line
153+ rejectWith ( new Error ( 'not found' ) ) ; // fA: show fails
154+ mockFile ( 'src/b.js' , 'line1\nline2\nline3\n' ) ; // fB: diff + show succeed
144155
145156 const fA = finding ( { file : 'src/a.js' , line : 2 } ) ;
146157 const fB = finding ( { file : 'src/b.js' , line : 2 } ) ;
@@ -149,4 +160,72 @@ describe('suppressFindings()', () => {
149160 // fA kept (git show failed), fB kept (no SECURITY comment)
150161 expect ( result ) . toEqual ( [ fA , fB ] ) ;
151162 } ) ;
163+
164+ // --- Line-shift tests ---
165+
166+ it ( 'suppresses a finding on a shifted pre-existing line' , async ( ) => {
167+ // PR inserts one line at the top; everything shifts down by 1.
168+ // Finding is at head line 5 (ignoreSsrfValidation) → maps to base line 4.
169+ // SECURITY comment is at base line 3 (lineAbove base line 4) → suppressed.
170+ const diff = [
171+ '--- a/src/app.js' ,
172+ '+++ b/src/app.js' ,
173+ '@@ -1,5 +1,6 @@' ,
174+ '+INSERTED' ,
175+ ' line1' ,
176+ ' line2' ,
177+ ' // SECURITY: approved in prior PR' ,
178+ ' ignoreSsrfValidation: true' ,
179+ ' line5' ,
180+ ] . join ( '\n' ) ;
181+ resolveWith ( diff ) ;
182+ resolveWith ( 'line1\nline2\n// SECURITY: approved in prior PR\nignoreSsrfValidation: true\nline5\n' ) ;
183+
184+ const result = await suppressFindings ( [ finding ( { line : 5 } ) ] , BASE ) ;
185+ expect ( result ) . toEqual ( [ ] ) ;
186+ } ) ;
187+
188+ it ( 'keeps a finding on a newly added line' , async ( ) => {
189+ // ignoreSsrfValidation: true is a + line in the diff — must not be suppressed
190+ // even though a SECURITY comment exists nearby.
191+ const diff = [
192+ '--- a/src/app.js' ,
193+ '+++ b/src/app.js' ,
194+ '@@ -1,4 +1,5 @@' ,
195+ ' line1' ,
196+ ' line2' ,
197+ '+ignoreSsrfValidation: true' ,
198+ ' // SECURITY: pre-existing comment for something else' ,
199+ ' line4' ,
200+ ] . join ( '\n' ) ;
201+ resolveWith ( diff ) ; // git show is never reached for a newly added line
202+
203+ const result = await suppressFindings ( [ finding ( { line : 3 } ) ] , BASE ) ;
204+ expect ( result ) . toEqual ( [ finding ( { line : 3 } ) ] ) ;
205+ expect ( mockExecFile ) . toHaveBeenCalledTimes ( 1 ) ;
206+ } ) ;
207+
208+ it ( 'keeps a finding when there is no SECURITY comment at the correct mapped base line' , async ( ) => {
209+ // PR inserts one line at the top; finding at head line 5 → base line 4.
210+ // Base line 5 has a SECURITY comment — the old (broken) suppressor would
211+ // have checked base line 5 (using the head line number directly) and
212+ // wrongly suppressed. The new suppressor checks base line 4 and correctly keeps.
213+ const diff = [
214+ '--- a/src/app.js' ,
215+ '+++ b/src/app.js' ,
216+ '@@ -1,5 +1,6 @@' ,
217+ '+INSERTED' ,
218+ ' line1' ,
219+ ' line2' ,
220+ ' line3' ,
221+ ' ignoreSsrfValidation: true' ,
222+ ' // SECURITY: comment at base line 5, not line 3' ,
223+ ] . join ( '\n' ) ;
224+ resolveWith ( diff ) ;
225+ resolveWith ( 'line1\nline2\nline3\nignoreSsrfValidation: true\n// SECURITY: comment at base line 5, not line 3\n' ) ;
226+
227+ // head line 5 → base line 4; lineAbove = base line 3 = 'line3' → no match
228+ const result = await suppressFindings ( [ finding ( { line : 5 } ) ] , BASE ) ;
229+ expect ( result ) . toEqual ( [ finding ( { line : 5 } ) ] ) ;
230+ } ) ;
152231} ) ;
0 commit comments