Ver Fonte

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 <[email protected]>
Co-authored-by: Daniel Riccio <[email protected]>
roomote[bot] há 5 meses atrás
pai
commit
b8dc31581c

+ 147 - 0
src/core/prompts/sections/__tests__/custom-instructions.spec.ts

@@ -54,12 +54,14 @@ const readFileMock = vi.fn()
 const statMock = vi.fn()
 const statMock = vi.fn()
 const readdirMock = vi.fn()
 const readdirMock = vi.fn()
 const readlinkMock = vi.fn()
 const readlinkMock = vi.fn()
+const lstatMock = vi.fn()
 
 
 // Replace fs functions with our mocks
 // Replace fs functions with our mocks
 fs.readFile = readFileMock as any
 fs.readFile = readFileMock as any
 fs.stat = statMock as any
 fs.stat = statMock as any
 fs.readdir = readdirMock as any
 fs.readdir = readdirMock as any
 fs.readlink = readlinkMock as any
 fs.readlink = readlinkMock as any
+fs.lstat = lstatMock as any
 
 
 // Mock process.cwd
 // Mock process.cwd
 const originalCwd = process.cwd
 const originalCwd = process.cwd
@@ -509,6 +511,17 @@ describe("addCustomInstructions", () => {
 		// Simulate no .roo/rules-test-mode directory
 		// Simulate no .roo/rules-test-mode directory
 		statMock.mockRejectedValueOnce({ code: "ENOENT" })
 		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) => {
 		readFileMock.mockImplementation((filePath: PathLike) => {
 			const pathStr = filePath.toString()
 			const pathStr = filePath.toString()
 			if (pathStr.endsWith("AGENTS.md")) {
 			if (pathStr.endsWith("AGENTS.md")) {
@@ -558,6 +571,17 @@ describe("addCustomInstructions", () => {
 		// Simulate no .roo/rules-test-mode directory
 		// Simulate no .roo/rules-test-mode directory
 		statMock.mockRejectedValueOnce({ code: "ENOENT" })
 		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) => {
 		readFileMock.mockImplementation((filePath: PathLike) => {
 			const pathStr = filePath.toString()
 			const pathStr = filePath.toString()
 			if (pathStr.endsWith("AGENTS.md")) {
 			if (pathStr.endsWith("AGENTS.md")) {
@@ -602,6 +626,17 @@ describe("addCustomInstructions", () => {
 		// Simulate no .roo/rules-test-mode directory
 		// Simulate no .roo/rules-test-mode directory
 		statMock.mockRejectedValueOnce({ code: "ENOENT" })
 		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) => {
 		readFileMock.mockImplementation((filePath: PathLike) => {
 			const pathStr = filePath.toString()
 			const pathStr = filePath.toString()
 			if (pathStr.endsWith("AGENTS.md")) {
 			if (pathStr.endsWith("AGENTS.md")) {
@@ -628,6 +663,118 @@ describe("addCustomInstructions", () => {
 		expect(result).toContain("Roo rules content")
 		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 () => {
 	it("should return empty string when no instructions provided", async () => {
 		// Simulate no .roo/rules directory
 		// Simulate no .roo/rules directory
 		statMock.mockRejectedValueOnce({ code: "ENOENT" })
 		statMock.mockRejectedValueOnce({ code: "ENOENT" })

+ 24 - 1
src/core/prompts/sections/custom-instructions.ts

@@ -222,7 +222,30 @@ export async function loadRuleFiles(cwd: string): Promise<string> {
 async function loadAgentRulesFile(cwd: string): Promise<string> {
 async function loadAgentRulesFile(cwd: string): Promise<string> {
 	try {
 	try {
 		const agentsPath = path.join(cwd, "AGENTS.md")
 		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) {
 		if (content) {
 			return `# Agent Rules Standard (AGENTS.md):\n${content}`
 			return `# Agent Rules Standard (AGENTS.md):\n${content}`
 		}
 		}