mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: add symlink support for AGENTS.md file loading (#6326)
* feat: add symlink support for AGENTS.md file loading - Add safeReadFileFollowingSymlinks function to handle symlink resolution - Update loadAgentRulesFile to use the new symlink-aware function - Add comprehensive tests for both symlink and regular file scenarios - Ensures AGENTS.md can be a symlink pointing to actual rules file * refactor: use existing symlink resolution pattern for AGENTS.md - Extracted resolveSymlinkPath function to handle symlink resolution - Removed duplicate safeReadFileFollowingSymlinks function - Updated loadAgentRulesFile to use resolveSymlinkPath + safeReadFile - Updated tests to match new implementation - Maintains same functionality while reusing existing patterns * fix: simplify symlink resolution for AGENTS.md to fix Windows compatibility - Remove duplicate resolveSymlinkPath function as suggested by @mrubens - Use simpler inline symlink resolution in loadAgentRulesFile - Update tests to match simplified implementation - This should fix the failing Windows unit tests while maintaining functionality * refactor: use existing resolveSymLink function for AGENTS.md symlink support - Remove duplicate inline symlink resolution logic - Reuse existing resolveSymLink function with MAX_DEPTH protection - Adapt loadAgentRulesFile to work with resolveSymLink's fileInfo interface - Fix test to properly mock fs.stat for resolved symlink targets - All tests pass (36/36) --------- Co-authored-by: Roo Code <roomote@roocode.com> Co-authored-by: Daniel Riccio <ricciodaniel98@gmail.com>
This commit is contained in:
parent
ded0180112
commit
b8dc31581c
2 changed files with 171 additions and 1 deletions
|
|
@ -54,12 +54,14 @@ const readFileMock = vi.fn()
|
|||
const statMock = vi.fn()
|
||||
const readdirMock = vi.fn()
|
||||
const readlinkMock = vi.fn()
|
||||
const lstatMock = vi.fn()
|
||||
|
||||
// Replace fs functions with our mocks
|
||||
fs.readFile = readFileMock as any
|
||||
fs.stat = statMock as any
|
||||
fs.readdir = readdirMock as any
|
||||
fs.readlink = readlinkMock as any
|
||||
fs.lstat = lstatMock as any
|
||||
|
||||
// Mock process.cwd
|
||||
const originalCwd = process.cwd
|
||||
|
|
@ -509,6 +511,17 @@ describe("addCustomInstructions", () => {
|
|||
// Simulate no .roo/rules-test-mode directory
|
||||
statMock.mockRejectedValueOnce({ code: "ENOENT" })
|
||||
|
||||
// Mock lstat to indicate AGENTS.md is NOT a symlink
|
||||
lstatMock.mockImplementation((filePath: PathLike) => {
|
||||
const pathStr = filePath.toString()
|
||||
if (pathStr.endsWith("AGENTS.md")) {
|
||||
return Promise.resolve({
|
||||
isSymbolicLink: vi.fn().mockReturnValue(false),
|
||||
})
|
||||
}
|
||||
return Promise.reject({ code: "ENOENT" })
|
||||
})
|
||||
|
||||
readFileMock.mockImplementation((filePath: PathLike) => {
|
||||
const pathStr = filePath.toString()
|
||||
if (pathStr.endsWith("AGENTS.md")) {
|
||||
|
|
@ -558,6 +571,17 @@ describe("addCustomInstructions", () => {
|
|||
// Simulate no .roo/rules-test-mode directory
|
||||
statMock.mockRejectedValueOnce({ code: "ENOENT" })
|
||||
|
||||
// Mock lstat to indicate AGENTS.md is NOT a symlink
|
||||
lstatMock.mockImplementation((filePath: PathLike) => {
|
||||
const pathStr = filePath.toString()
|
||||
if (pathStr.endsWith("AGENTS.md")) {
|
||||
return Promise.resolve({
|
||||
isSymbolicLink: vi.fn().mockReturnValue(false),
|
||||
})
|
||||
}
|
||||
return Promise.reject({ code: "ENOENT" })
|
||||
})
|
||||
|
||||
readFileMock.mockImplementation((filePath: PathLike) => {
|
||||
const pathStr = filePath.toString()
|
||||
if (pathStr.endsWith("AGENTS.md")) {
|
||||
|
|
@ -602,6 +626,17 @@ describe("addCustomInstructions", () => {
|
|||
// Simulate no .roo/rules-test-mode directory
|
||||
statMock.mockRejectedValueOnce({ code: "ENOENT" })
|
||||
|
||||
// Mock lstat to indicate AGENTS.md is NOT a symlink
|
||||
lstatMock.mockImplementation((filePath: PathLike) => {
|
||||
const pathStr = filePath.toString()
|
||||
if (pathStr.endsWith("AGENTS.md")) {
|
||||
return Promise.resolve({
|
||||
isSymbolicLink: vi.fn().mockReturnValue(false),
|
||||
})
|
||||
}
|
||||
return Promise.reject({ code: "ENOENT" })
|
||||
})
|
||||
|
||||
readFileMock.mockImplementation((filePath: PathLike) => {
|
||||
const pathStr = filePath.toString()
|
||||
if (pathStr.endsWith("AGENTS.md")) {
|
||||
|
|
@ -628,6 +663,118 @@ describe("addCustomInstructions", () => {
|
|||
expect(result).toContain("Roo rules content")
|
||||
})
|
||||
|
||||
it("should follow symlinks when loading AGENTS.md", async () => {
|
||||
// Simulate no .roo/rules-test-mode directory
|
||||
statMock.mockRejectedValueOnce({ code: "ENOENT" })
|
||||
|
||||
// Mock lstat to indicate AGENTS.md is a symlink
|
||||
lstatMock.mockImplementation((filePath: PathLike) => {
|
||||
const pathStr = filePath.toString()
|
||||
if (pathStr.endsWith("AGENTS.md")) {
|
||||
return Promise.resolve({
|
||||
isSymbolicLink: vi.fn().mockReturnValue(true),
|
||||
})
|
||||
}
|
||||
return Promise.reject({ code: "ENOENT" })
|
||||
})
|
||||
|
||||
// Mock readlink to return the symlink target
|
||||
readlinkMock.mockImplementation((filePath: PathLike) => {
|
||||
const pathStr = filePath.toString()
|
||||
if (pathStr.endsWith("AGENTS.md")) {
|
||||
return Promise.resolve("../actual-agents-file.md")
|
||||
}
|
||||
return Promise.reject({ code: "ENOENT" })
|
||||
})
|
||||
|
||||
// Mock stat to indicate the resolved target is a file
|
||||
statMock.mockImplementation((filePath: PathLike) => {
|
||||
const pathStr = filePath.toString()
|
||||
const normalizedPath = pathStr.replace(/\\/g, "/")
|
||||
if (normalizedPath.endsWith("actual-agents-file.md")) {
|
||||
return Promise.resolve({
|
||||
isFile: vi.fn().mockReturnValue(true),
|
||||
})
|
||||
}
|
||||
return Promise.reject({ code: "ENOENT" })
|
||||
})
|
||||
|
||||
// Mock readFile to return content from the resolved path
|
||||
readFileMock.mockImplementation((filePath: PathLike) => {
|
||||
const pathStr = filePath.toString()
|
||||
const normalizedPath = pathStr.replace(/\\/g, "/")
|
||||
if (normalizedPath.endsWith("actual-agents-file.md")) {
|
||||
return Promise.resolve("Agent rules from symlinked file")
|
||||
}
|
||||
return Promise.reject({ code: "ENOENT" })
|
||||
})
|
||||
|
||||
const result = await addCustomInstructions(
|
||||
"mode instructions",
|
||||
"global instructions",
|
||||
"/fake/path",
|
||||
"test-mode",
|
||||
{ settings: { maxConcurrentFileReads: 5, todoListEnabled: true, useAgentRules: true } },
|
||||
)
|
||||
|
||||
expect(result).toContain("# Agent Rules Standard (AGENTS.md):")
|
||||
expect(result).toContain("Agent rules from symlinked file")
|
||||
|
||||
// Verify lstat was called to check if it's a symlink
|
||||
expect(lstatMock).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"))
|
||||
|
||||
// Verify readlink was called to resolve the symlink
|
||||
expect(readlinkMock).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"))
|
||||
|
||||
// Verify the resolved path was read
|
||||
expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining("actual-agents-file.md"), "utf-8")
|
||||
})
|
||||
|
||||
it("should handle AGENTS.md as a regular file when not a symlink", async () => {
|
||||
// Simulate no .roo/rules-test-mode directory
|
||||
statMock.mockRejectedValueOnce({ code: "ENOENT" })
|
||||
|
||||
// Mock lstat to indicate AGENTS.md is NOT a symlink
|
||||
lstatMock.mockImplementation((filePath: PathLike) => {
|
||||
const pathStr = filePath.toString()
|
||||
if (pathStr.endsWith("AGENTS.md")) {
|
||||
return Promise.resolve({
|
||||
isSymbolicLink: vi.fn().mockReturnValue(false),
|
||||
})
|
||||
}
|
||||
return Promise.reject({ code: "ENOENT" })
|
||||
})
|
||||
|
||||
// Mock readFile to return content directly from AGENTS.md
|
||||
readFileMock.mockImplementation((filePath: PathLike) => {
|
||||
const pathStr = filePath.toString()
|
||||
if (pathStr.endsWith("AGENTS.md")) {
|
||||
return Promise.resolve("Agent rules from regular file")
|
||||
}
|
||||
return Promise.reject({ code: "ENOENT" })
|
||||
})
|
||||
|
||||
const result = await addCustomInstructions(
|
||||
"mode instructions",
|
||||
"global instructions",
|
||||
"/fake/path",
|
||||
"test-mode",
|
||||
{ settings: { maxConcurrentFileReads: 5, todoListEnabled: true, useAgentRules: true } },
|
||||
)
|
||||
|
||||
expect(result).toContain("# Agent Rules Standard (AGENTS.md):")
|
||||
expect(result).toContain("Agent rules from regular file")
|
||||
|
||||
// Verify lstat was called
|
||||
expect(lstatMock).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"))
|
||||
|
||||
// Verify readlink was NOT called since it's not a symlink
|
||||
expect(readlinkMock).not.toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"))
|
||||
|
||||
// Verify the file was read directly
|
||||
expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"), "utf-8")
|
||||
})
|
||||
|
||||
it("should return empty string when no instructions provided", async () => {
|
||||
// Simulate no .roo/rules directory
|
||||
statMock.mockRejectedValueOnce({ code: "ENOENT" })
|
||||
|
|
|
|||
|
|
@ -222,7 +222,30 @@ export async function loadRuleFiles(cwd: string): Promise<string> {
|
|||
async function loadAgentRulesFile(cwd: string): Promise<string> {
|
||||
try {
|
||||
const agentsPath = path.join(cwd, "AGENTS.md")
|
||||
const content = await safeReadFile(agentsPath)
|
||||
let resolvedPath = agentsPath
|
||||
|
||||
// Check if AGENTS.md exists and handle symlinks
|
||||
try {
|
||||
const stats = await fs.lstat(agentsPath)
|
||||
if (stats.isSymbolicLink()) {
|
||||
// Create a temporary fileInfo array to use with resolveSymLink
|
||||
const fileInfo: Array<{ originalPath: string; resolvedPath: string }> = []
|
||||
|
||||
// Use the existing resolveSymLink function to handle symlink resolution
|
||||
await resolveSymLink(agentsPath, fileInfo, 0)
|
||||
|
||||
// Extract the resolved path from fileInfo
|
||||
if (fileInfo.length > 0) {
|
||||
resolvedPath = fileInfo[0].resolvedPath
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// If lstat fails (file doesn't exist), return empty
|
||||
return ""
|
||||
}
|
||||
|
||||
// Read the content from the resolved path
|
||||
const content = await safeReadFile(resolvedPath)
|
||||
if (content) {
|
||||
return `# Agent Rules Standard (AGENTS.md):\n${content}`
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue