Skip to content

Commit c2fef2d

Browse files
authored
Follow symlinks for rules files (#2421)
1 parent b01615f commit c2fef2d

File tree

2 files changed

+127
-11
lines changed

2 files changed

+127
-11
lines changed

src/core/prompts/sections/__tests__/custom-instructions.test.ts

Lines changed: 99 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ jest.mock("fs/promises")
1010
const readFileMock = jest.fn()
1111
const statMock = jest.fn()
1212
const readdirMock = jest.fn()
13+
const readlinkMock = jest.fn()
1314

1415
// Replace fs functions with our mocks
1516
fs.readFile = readFileMock as any
1617
fs.stat = statMock as any
1718
fs.readdir = readdirMock as any
19+
fs.readlink = readlinkMock as any
1820

1921
// Mock path.resolve and path.join to be predictable in tests
2022
jest.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
581610
describe("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
})

src/core/prompts/sections/custom-instructions.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,36 @@ async function directoryExists(dirPath: string): Promise<boolean> {
3636
*/
3737
async function readTextFilesFromDirectory(dirPath: string): Promise<Array<{ filename: string; content: string }>> {
3838
try {
39-
const files = await fs
40-
.readdir(dirPath, { withFileTypes: true, recursive: true })
41-
.then((files) => files.filter((file) => file.isFile()))
42-
.then((files) => files.map((file) => path.resolve(file.parentPath, file.name)))
39+
const entries = await fs.readdir(dirPath, { withFileTypes: true, recursive: true })
40+
41+
// Process all entries - regular files and symlinks that might point to files
42+
const filePaths: string[] = []
43+
44+
for (const entry of entries) {
45+
const fullPath = path.resolve(entry.parentPath || dirPath, entry.name)
46+
if (entry.isFile()) {
47+
// Regular file
48+
filePaths.push(fullPath)
49+
} else if (entry.isSymbolicLink()) {
50+
try {
51+
// Get the symlink target
52+
const linkTarget = await fs.readlink(fullPath)
53+
// Resolve the target path (relative to the symlink location)
54+
const resolvedTarget = path.resolve(path.dirname(fullPath), linkTarget)
55+
56+
// Check if the target is a file
57+
const stats = await fs.stat(resolvedTarget)
58+
if (stats.isFile()) {
59+
filePaths.push(resolvedTarget)
60+
}
61+
} catch (err) {
62+
// Skip invalid symlinks
63+
}
64+
}
65+
}
4366

4467
const fileContents = await Promise.all(
45-
files.map(async (file) => {
68+
filePaths.map(async (file) => {
4669
try {
4770
// Check if it's a file (not a directory)
4871
const stats = await fs.stat(file)

0 commit comments

Comments
 (0)