@@ -10,11 +10,13 @@ jest.mock("fs/promises")
1010const readFileMock = jest . fn ( )
1111const statMock = jest . fn ( )
1212const readdirMock = jest . fn ( )
13+ const readlinkMock = jest . fn ( )
1314
1415// Replace fs functions with our mocks
1516fs . readFile = readFileMock as any
1617fs . stat = statMock as any
1718fs . readdir = readdirMock as any
19+ fs . readlink = readlinkMock as any
1820
1921// Mock path.resolve and path.join to be predictable in tests
2022jest . mock ( "path" , ( ) => ( {
@@ -127,8 +129,8 @@ describe("loadRuleFiles", () => {
127129
128130 // Simulate listing files
129131 readdirMock . mockResolvedValueOnce ( [
130- { name : "file1.txt" , isFile : ( ) => true , parentPath : "/fake/path/.roo/rules" } ,
131- { name : "file2.txt" , isFile : ( ) => true , parentPath : "/fake/path/.roo/rules" } ,
132+ { name : "file1.txt" , isFile : ( ) => true , isSymbolicLink : ( ) => false , parentPath : "/fake/path/.roo/rules" } ,
133+ { name : "file2.txt" , isFile : ( ) => true , isSymbolicLink : ( ) => false , parentPath : "/fake/path/.roo/rules" } ,
132134 ] as any )
133135
134136 statMock . mockImplementation (
@@ -154,6 +156,8 @@ describe("loadRuleFiles", () => {
154156 expect ( result ) . toContain ( "# Rules from /fake/path/.roo/rules/file2.txt:" )
155157 expect ( result ) . toContain ( "content of file2" )
156158
159+ // We expect both checks because our new implementation checks the files again for validation
160+ expect ( statMock ) . toHaveBeenCalledWith ( "/fake/path/.roo/rules" )
157161 expect ( statMock ) . toHaveBeenCalledWith ( "/fake/path/.roo/rules/file1.txt" )
158162 expect ( statMock ) . toHaveBeenCalledWith ( "/fake/path/.roo/rules/file2.txt" )
159163 expect ( readFileMock ) . toHaveBeenCalledWith ( "/fake/path/.roo/rules/file1.txt" , "utf-8" )
@@ -210,17 +214,31 @@ describe("loadRuleFiles", () => {
210214
211215 // Simulate listing files including subdirectories
212216 readdirMock . mockResolvedValueOnce ( [
213- { name : "subdir" , isFile : ( ) => false , isDirectory : ( ) => true , parentPath : "/fake/path/.roo/rules" } ,
214- { name : "root.txt" , isFile : ( ) => true , isDirectory : ( ) => false , parentPath : "/fake/path/.roo/rules" } ,
217+ {
218+ name : "subdir" ,
219+ isFile : ( ) => false ,
220+ isSymbolicLink : ( ) => false ,
221+ isDirectory : ( ) => true ,
222+ parentPath : "/fake/path/.roo/rules" ,
223+ } ,
224+ {
225+ name : "root.txt" ,
226+ isFile : ( ) => true ,
227+ isSymbolicLink : ( ) => false ,
228+ isDirectory : ( ) => false ,
229+ parentPath : "/fake/path/.roo/rules" ,
230+ } ,
215231 {
216232 name : "nested1.txt" ,
217233 isFile : ( ) => true ,
234+ isSymbolicLink : ( ) => false ,
218235 isDirectory : ( ) => false ,
219236 parentPath : "/fake/path/.roo/rules/subdir" ,
220237 } ,
221238 {
222239 name : "nested2.txt" ,
223240 isFile : ( ) => true ,
241+ isSymbolicLink : ( ) => false ,
224242 isDirectory : ( ) => false ,
225243 parentPath : "/fake/path/.roo/rules/subdir/subdir2" ,
226244 } ,
@@ -395,8 +413,18 @@ describe("addCustomInstructions", () => {
395413
396414 // Simulate listing files
397415 readdirMock . mockResolvedValueOnce ( [
398- { name : "rule1.txt" , isFile : ( ) => true , parentPath : "/fake/path/.roo/rules-test-mode" } ,
399- { name : "rule2.txt" , isFile : ( ) => true , parentPath : "/fake/path/.roo/rules-test-mode" } ,
416+ {
417+ name : "rule1.txt" ,
418+ isFile : ( ) => true ,
419+ isSymbolicLink : ( ) => false ,
420+ parentPath : "/fake/path/.roo/rules-test-mode" ,
421+ } ,
422+ {
423+ name : "rule2.txt" ,
424+ isFile : ( ) => true ,
425+ isSymbolicLink : ( ) => false ,
426+ parentPath : "/fake/path/.roo/rules-test-mode" ,
427+ } ,
400428 ] as any )
401429
402430 statMock . mockImplementation (
@@ -430,6 +458,7 @@ describe("addCustomInstructions", () => {
430458 expect ( result ) . toContain ( "# Rules from /fake/path/.roo/rules-test-mode/rule2.txt:" )
431459 expect ( result ) . toContain ( "mode specific rule 2" )
432460
461+ expect ( statMock ) . toHaveBeenCalledWith ( "/fake/path/.roo/rules-test-mode" )
433462 expect ( statMock ) . toHaveBeenCalledWith ( "/fake/path/.roo/rules-test-mode/rule1.txt" )
434463 expect ( statMock ) . toHaveBeenCalledWith ( "/fake/path/.roo/rules-test-mode/rule2.txt" )
435464 expect ( readFileMock ) . toHaveBeenCalledWith ( "/fake/path/.roo/rules-test-mode/rule1.txt" , "utf-8" )
@@ -579,6 +608,70 @@ describe("Directory existence checks", () => {
579608
580609// Indirectly test readTextFilesFromDirectory and formatDirectoryContent through loadRuleFiles
581610describe ( "Rules directory reading" , ( ) => {
611+ it ( "should follow symbolic links in the rules directory" , async ( ) => {
612+ // Simulate .roo/rules directory exists
613+ statMock . mockResolvedValueOnce ( {
614+ isDirectory : jest . fn ( ) . mockReturnValue ( true ) ,
615+ } as any )
616+
617+ // Simulate listing files including a symlink
618+ readdirMock . mockResolvedValueOnce ( [
619+ {
620+ name : "regular.txt" ,
621+ isFile : ( ) => true ,
622+ isSymbolicLink : ( ) => false ,
623+ parentPath : "/fake/path/.roo/rules" ,
624+ } ,
625+ { name : "link.txt" , isFile : ( ) => false , isSymbolicLink : ( ) => true , parentPath : "/fake/path/.roo/rules" } ,
626+ ] as any )
627+
628+ // Simulate readlink response
629+ readlinkMock . mockResolvedValueOnce ( "../symlink-target.txt" )
630+
631+ // Reset and set up the stat mock with more granular control
632+ statMock . mockReset ( )
633+ statMock . mockImplementation ( ( path : string ) => {
634+ // For directory check
635+ if ( path === "/fake/path/.roo/rules" ) {
636+ return Promise . resolve ( {
637+ isDirectory : jest . fn ( ) . mockReturnValue ( true ) ,
638+ isFile : jest . fn ( ) . mockReturnValue ( false ) ,
639+ } as any )
640+ }
641+
642+ // For all files
643+ return Promise . resolve ( {
644+ isFile : jest . fn ( ) . mockReturnValue ( true ) ,
645+ isDirectory : jest . fn ( ) . mockReturnValue ( false ) ,
646+ } as any )
647+ } )
648+
649+ // Simulate file content reading
650+ readFileMock . mockImplementation ( ( filePath : PathLike ) => {
651+ if ( filePath . toString ( ) === "/fake/path/.roo/rules/regular.txt" ) {
652+ return Promise . resolve ( "regular file content" )
653+ }
654+ if ( filePath . toString ( ) === "/fake/path/.roo/rules/../symlink-target.txt" ) {
655+ return Promise . resolve ( "symlink target content" )
656+ }
657+ return Promise . reject ( { code : "ENOENT" } )
658+ } )
659+
660+ const result = await loadRuleFiles ( "/fake/path" )
661+
662+ // Verify both regular file and symlink target content are included
663+ expect ( result ) . toContain ( "# Rules from /fake/path/.roo/rules/regular.txt:" )
664+ expect ( result ) . toContain ( "regular file content" )
665+ expect ( result ) . toContain ( "# Rules from /fake/path/.roo/rules/../symlink-target.txt:" )
666+ expect ( result ) . toContain ( "symlink target content" )
667+
668+ // Verify readlink was called with the symlink path
669+ expect ( readlinkMock ) . toHaveBeenCalledWith ( "/fake/path/.roo/rules/link.txt" )
670+
671+ // Verify both files were read
672+ expect ( readFileMock ) . toHaveBeenCalledWith ( "/fake/path/.roo/rules/regular.txt" , "utf-8" )
673+ expect ( readFileMock ) . toHaveBeenCalledWith ( "/fake/path/.roo/rules/../symlink-target.txt" , "utf-8" )
674+ } )
582675 beforeEach ( ( ) => {
583676 jest . clearAllMocks ( )
584677 } )
0 commit comments