mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
feat: add support for external custom instruction files and extension-based targeting
- Support loading custom instructions from external file paths (e.g., ./.github/copilot-instructions.md) - Add extension-based instruction targeting (e.g., py-instruction.md for Python files) - Add comprehensive tests for both features - Export new loadExtensionSpecificInstructions function for future integration Fixes #6098
This commit is contained in:
parent
2411c8faa4
commit
29d1bb8619
3 changed files with 369 additions and 5 deletions
|
|
@ -3,6 +3,11 @@
|
|||
// Mock fs/promises
|
||||
vi.mock("fs/promises")
|
||||
|
||||
// Mock os module
|
||||
vi.mock("os", () => ({
|
||||
homedir: vi.fn().mockReturnValue("/home/user"),
|
||||
}))
|
||||
|
||||
// Mock path.resolve and path.join to be predictable in tests
|
||||
vi.mock("path", async () => ({
|
||||
...(await vi.importActual("path")),
|
||||
|
|
@ -46,6 +51,7 @@ vi.mock("path", async () => ({
|
|||
|
||||
import fs from "fs/promises"
|
||||
import type { PathLike } from "fs"
|
||||
import * as os from "os"
|
||||
|
||||
import { loadRuleFiles, addCustomInstructions } from "../custom-instructions"
|
||||
|
||||
|
|
@ -1199,3 +1205,280 @@ describe("Rules directory reading", () => {
|
|||
expect(result).toBe("\n# Rules from .roorules:\nfallback content\n")
|
||||
})
|
||||
})
|
||||
|
||||
describe("External file path support", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should load instructions from external file path when provided", async () => {
|
||||
// Mock file existence check
|
||||
statMock.mockImplementation((path) => {
|
||||
const normalizedPath = path.toString().replace(/\\/g, "/")
|
||||
if (normalizedPath === "/fake/path/.github/copilot-instructions.md") {
|
||||
return Promise.resolve({
|
||||
isFile: vi.fn().mockReturnValue(true),
|
||||
isDirectory: vi.fn().mockReturnValue(false),
|
||||
} as any)
|
||||
}
|
||||
// For .roo/rules directory check
|
||||
return Promise.reject({ code: "ENOENT" })
|
||||
})
|
||||
|
||||
// Mock file reading
|
||||
readFileMock.mockImplementation((filePath: PathLike) => {
|
||||
const pathStr = filePath.toString()
|
||||
const normalizedPath = pathStr.replace(/\\/g, "/")
|
||||
if (normalizedPath === "/fake/path/.github/copilot-instructions.md") {
|
||||
return Promise.resolve("External file instructions content")
|
||||
}
|
||||
return Promise.reject({ code: "ENOENT" })
|
||||
})
|
||||
|
||||
const result = await addCustomInstructions(
|
||||
"",
|
||||
"./.github/copilot-instructions.md", // File path reference
|
||||
"/fake/path",
|
||||
"",
|
||||
)
|
||||
|
||||
expect(result).toContain("Global Instructions:\nExternal file instructions content")
|
||||
expect(readFileMock).toHaveBeenCalledWith(
|
||||
process.platform === "win32"
|
||||
? "\\fake\\path\\.github\\copilot-instructions.md"
|
||||
: "/fake/path/.github/copilot-instructions.md",
|
||||
"utf-8",
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle absolute file paths", async () => {
|
||||
const absolutePath = "/absolute/path/to/instructions.md"
|
||||
|
||||
// Mock file existence check
|
||||
statMock.mockImplementation((path) => {
|
||||
const normalizedPath = path.toString().replace(/\\/g, "/")
|
||||
if (normalizedPath === absolutePath) {
|
||||
return Promise.resolve({
|
||||
isFile: vi.fn().mockReturnValue(true),
|
||||
isDirectory: vi.fn().mockReturnValue(false),
|
||||
} as any)
|
||||
}
|
||||
return Promise.reject({ code: "ENOENT" })
|
||||
})
|
||||
|
||||
// Mock file reading
|
||||
readFileMock.mockImplementation((filePath: PathLike) => {
|
||||
const pathStr = filePath.toString()
|
||||
const normalizedPath = pathStr.replace(/\\/g, "/")
|
||||
if (normalizedPath === absolutePath) {
|
||||
return Promise.resolve("Absolute path instructions")
|
||||
}
|
||||
return Promise.reject({ code: "ENOENT" })
|
||||
})
|
||||
|
||||
const result = await addCustomInstructions("", absolutePath, "/fake/path", "")
|
||||
|
||||
expect(result).toContain("Global Instructions:\nAbsolute path instructions")
|
||||
})
|
||||
|
||||
it("should fall back to literal string if file path doesn't exist", async () => {
|
||||
// Mock all file checks to fail
|
||||
statMock.mockRejectedValue({ code: "ENOENT" })
|
||||
readFileMock.mockRejectedValue({ code: "ENOENT" })
|
||||
|
||||
const result = await addCustomInstructions("", "./non-existent/file.md", "/fake/path", "")
|
||||
|
||||
expect(result).toContain("Global Instructions:\n./non-existent/file.md")
|
||||
})
|
||||
|
||||
it("should treat non-path strings as literal instructions", async () => {
|
||||
// Mock to ensure no file operations succeed
|
||||
statMock.mockRejectedValue({ code: "ENOENT" })
|
||||
readFileMock.mockRejectedValue({ code: "ENOENT" })
|
||||
|
||||
const result = await addCustomInstructions("", "This is just a regular instruction string", "/fake/path", "")
|
||||
|
||||
expect(result).toContain("Global Instructions:\nThis is just a regular instruction string")
|
||||
})
|
||||
|
||||
it("should support file paths for mode-specific instructions", async () => {
|
||||
// Mock file existence check
|
||||
statMock.mockImplementation((path) => {
|
||||
const normalizedPath = path.toString().replace(/\\/g, "/")
|
||||
if (normalizedPath === "/fake/path/mode-specific.md") {
|
||||
return Promise.resolve({
|
||||
isFile: vi.fn().mockReturnValue(true),
|
||||
isDirectory: vi.fn().mockReturnValue(false),
|
||||
} as any)
|
||||
}
|
||||
return Promise.reject({ code: "ENOENT" })
|
||||
})
|
||||
|
||||
// Mock file reading
|
||||
readFileMock.mockImplementation((filePath: PathLike) => {
|
||||
const pathStr = filePath.toString()
|
||||
const normalizedPath = pathStr.replace(/\\/g, "/")
|
||||
if (normalizedPath === "/fake/path/mode-specific.md") {
|
||||
return Promise.resolve("Mode specific file content")
|
||||
}
|
||||
return Promise.reject({ code: "ENOENT" })
|
||||
})
|
||||
|
||||
const result = await addCustomInstructions(
|
||||
"./mode-specific.md", // File path for mode instructions
|
||||
"",
|
||||
"/fake/path",
|
||||
"test-mode",
|
||||
)
|
||||
|
||||
expect(result).toContain("Mode-specific Instructions:\nMode specific file content")
|
||||
})
|
||||
|
||||
it("should handle directories gracefully", async () => {
|
||||
// Mock directory check
|
||||
statMock.mockImplementation((path) => {
|
||||
const normalizedPath = path.toString().replace(/\\/g, "/")
|
||||
if (normalizedPath === "/fake/path/.github") {
|
||||
return Promise.resolve({
|
||||
isFile: vi.fn().mockReturnValue(false),
|
||||
isDirectory: vi.fn().mockReturnValue(true),
|
||||
} as any)
|
||||
}
|
||||
return Promise.reject({ code: "ENOENT" })
|
||||
})
|
||||
|
||||
const result = await addCustomInstructions(
|
||||
"",
|
||||
"./.github", // Directory path
|
||||
"/fake/path",
|
||||
"",
|
||||
)
|
||||
|
||||
// Should treat as literal string since it's not a file
|
||||
expect(result).toContain("Global Instructions:\n./.github")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Extension-specific instruction targeting", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should load extension-specific instructions from .roo directory", async () => {
|
||||
// Mock file reading
|
||||
readFileMock.mockImplementation((filePath: PathLike) => {
|
||||
const pathStr = filePath.toString()
|
||||
const normalizedPath = pathStr.replace(/\\/g, "/")
|
||||
|
||||
// Check for both global and project .roo directories
|
||||
if (normalizedPath.includes("/.roo/py-instruction.md")) {
|
||||
if (normalizedPath.includes("/home/") || normalizedPath.includes("/Users/")) {
|
||||
return Promise.resolve("Global Python instructions")
|
||||
} else {
|
||||
return Promise.resolve("Project Python instructions")
|
||||
}
|
||||
}
|
||||
return Promise.reject({ code: "ENOENT" })
|
||||
})
|
||||
|
||||
const { loadExtensionSpecificInstructions } = await import("../custom-instructions")
|
||||
const result = await loadExtensionSpecificInstructions("/fake/path", "py")
|
||||
|
||||
expect(result).toContain("Extension-specific instructions from")
|
||||
expect(result).toContain("py-instruction.md")
|
||||
|
||||
// Should check both global and project directories
|
||||
expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining("py-instruction.md"), "utf-8")
|
||||
})
|
||||
|
||||
it("should load extension-specific instructions from project root", async () => {
|
||||
// Mock file reading - only project root file exists
|
||||
readFileMock.mockImplementation((filePath: PathLike) => {
|
||||
const pathStr = filePath.toString()
|
||||
const normalizedPath = pathStr.replace(/\\/g, "/")
|
||||
|
||||
if (normalizedPath === "/fake/path/ts-instruction.md") {
|
||||
return Promise.resolve("TypeScript specific instructions")
|
||||
}
|
||||
return Promise.reject({ code: "ENOENT" })
|
||||
})
|
||||
|
||||
const { loadExtensionSpecificInstructions } = await import("../custom-instructions")
|
||||
const result = await loadExtensionSpecificInstructions("/fake/path", "ts")
|
||||
|
||||
expect(result).toContain("Extension-specific instructions from")
|
||||
expect(result).toContain("/fake/path/ts-instruction.md")
|
||||
expect(result).toContain("TypeScript specific instructions")
|
||||
})
|
||||
|
||||
it("should return empty string when no extension provided", async () => {
|
||||
const { loadExtensionSpecificInstructions } = await import("../custom-instructions")
|
||||
const result = await loadExtensionSpecificInstructions("/fake/path", "")
|
||||
|
||||
expect(result).toBe("")
|
||||
expect(readFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should return empty string when no extension-specific files found", async () => {
|
||||
// Mock all reads to fail
|
||||
readFileMock.mockRejectedValue({ code: "ENOENT" })
|
||||
|
||||
const { loadExtensionSpecificInstructions } = await import("../custom-instructions")
|
||||
const result = await loadExtensionSpecificInstructions("/fake/path", "java")
|
||||
|
||||
expect(result).toBe("")
|
||||
})
|
||||
|
||||
it("should combine instructions from multiple locations", async () => {
|
||||
// Mock file reading - files exist in multiple locations
|
||||
readFileMock.mockImplementation((filePath: PathLike) => {
|
||||
const pathStr = filePath.toString()
|
||||
const normalizedPath = pathStr.replace(/\\/g, "/")
|
||||
|
||||
if (normalizedPath === "/home/user/.roo/js-instruction.md") {
|
||||
return Promise.resolve("Global JS instructions")
|
||||
} else if (normalizedPath === "/fake/path/.roo/js-instruction.md") {
|
||||
return Promise.resolve("Project .roo JS instructions")
|
||||
} else if (normalizedPath === "/fake/path/js-instruction.md") {
|
||||
return Promise.resolve("Project root JS instructions")
|
||||
}
|
||||
return Promise.reject({ code: "ENOENT" })
|
||||
})
|
||||
|
||||
const { loadExtensionSpecificInstructions } = await import("../custom-instructions")
|
||||
const result = await loadExtensionSpecificInstructions("/fake/path", "js")
|
||||
|
||||
// Should contain instructions from all locations
|
||||
expect(result).toContain("Global JS instructions")
|
||||
expect(result).toContain("Project .roo JS instructions")
|
||||
expect(result).toContain("Project root JS instructions")
|
||||
|
||||
// Verify all locations were checked
|
||||
const calls = readFileMock.mock.calls.map((call) => call[0].toString())
|
||||
expect(calls.some((path) => path.includes("/.roo/js-instruction.md"))).toBe(true)
|
||||
expect(calls.some((path) => path.endsWith("/fake/path/js-instruction.md"))).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle various file extensions correctly", async () => {
|
||||
const extensions = ["py", "ts", "js", "jsx", "tsx", "java", "cpp", "c", "go", "rs", "rb", "php"]
|
||||
|
||||
for (const ext of extensions) {
|
||||
vi.clearAllMocks()
|
||||
|
||||
readFileMock.mockImplementation((filePath: PathLike) => {
|
||||
const pathStr = filePath.toString()
|
||||
if (pathStr.includes(`${ext}-instruction.md`)) {
|
||||
return Promise.resolve(`${ext} instructions`)
|
||||
}
|
||||
return Promise.reject({ code: "ENOENT" })
|
||||
})
|
||||
|
||||
const { loadExtensionSpecificInstructions } = await import("../custom-instructions")
|
||||
const result = await loadExtensionSpecificInstructions("/fake/path", ext)
|
||||
|
||||
if (result) {
|
||||
expect(result).toContain(`${ext} instructions`)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -214,6 +214,35 @@ export async function loadRuleFiles(cwd: string): Promise<string> {
|
|||
return ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Load instruction content from a file path reference
|
||||
* Supports both absolute and relative paths
|
||||
*/
|
||||
async function loadInstructionFromPath(instructionPath: string, cwd: string): Promise<string | null> {
|
||||
try {
|
||||
// Check if the path looks like a file path reference
|
||||
if (!instructionPath.includes("/") && !instructionPath.includes("\\")) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Resolve the path relative to the current working directory
|
||||
const resolvedPath = path.isAbsolute(instructionPath) ? instructionPath : path.resolve(cwd, instructionPath)
|
||||
|
||||
// Check if file exists
|
||||
const stats = await fs.stat(resolvedPath)
|
||||
if (!stats.isFile()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Read the file content
|
||||
const content = await fs.readFile(resolvedPath, "utf-8")
|
||||
return content.trim()
|
||||
} catch (err) {
|
||||
// If file doesn't exist or can't be read, return null
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function addCustomInstructions(
|
||||
modeCustomInstructions: string,
|
||||
globalCustomInstructions: string,
|
||||
|
|
@ -271,14 +300,32 @@ export async function addCustomInstructions(
|
|||
)
|
||||
}
|
||||
|
||||
// Add global instructions first
|
||||
// Process global instructions - check if it's a file path reference
|
||||
let processedGlobalInstructions = globalCustomInstructions
|
||||
if (typeof globalCustomInstructions === "string" && globalCustomInstructions.trim()) {
|
||||
sections.push(`Global Instructions:\n${globalCustomInstructions.trim()}`)
|
||||
const fileContent = await loadInstructionFromPath(globalCustomInstructions.trim(), cwd)
|
||||
if (fileContent !== null) {
|
||||
processedGlobalInstructions = fileContent
|
||||
}
|
||||
}
|
||||
|
||||
// Process mode-specific instructions - check if it's a file path reference
|
||||
let processedModeInstructions = modeCustomInstructions
|
||||
if (typeof modeCustomInstructions === "string" && modeCustomInstructions.trim()) {
|
||||
const fileContent = await loadInstructionFromPath(modeCustomInstructions.trim(), cwd)
|
||||
if (fileContent !== null) {
|
||||
processedModeInstructions = fileContent
|
||||
}
|
||||
}
|
||||
|
||||
// Add global instructions first
|
||||
if (typeof processedGlobalInstructions === "string" && processedGlobalInstructions.trim()) {
|
||||
sections.push(`Global Instructions:\n${processedGlobalInstructions.trim()}`)
|
||||
}
|
||||
|
||||
// Add mode-specific instructions after
|
||||
if (typeof modeCustomInstructions === "string" && modeCustomInstructions.trim()) {
|
||||
sections.push(`Mode-specific Instructions:\n${modeCustomInstructions.trim()}`)
|
||||
if (typeof processedModeInstructions === "string" && processedModeInstructions.trim()) {
|
||||
sections.push(`Mode-specific Instructions:\n${processedModeInstructions.trim()}`)
|
||||
}
|
||||
|
||||
// Add rules - include both mode-specific and generic rules if they exist
|
||||
|
|
@ -321,6 +368,40 @@ ${joinedSections}`
|
|||
: ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Load extension-specific instruction files (e.g., py-instruction.md for Python files)
|
||||
* @param cwd Current working directory
|
||||
* @param fileExtension The file extension to look for (e.g., 'py', 'ts', 'js')
|
||||
* @returns The content of the extension-specific instruction file if found
|
||||
*/
|
||||
export async function loadExtensionSpecificInstructions(cwd: string, fileExtension: string): Promise<string> {
|
||||
if (!fileExtension) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const extensionRules: string[] = []
|
||||
const rooDirectories = getRooDirectoriesForCwd(cwd)
|
||||
|
||||
// Check for extension-specific instruction files in .roo directories
|
||||
for (const rooDir of rooDirectories) {
|
||||
// Check for {ext}-instruction.md files
|
||||
const extensionInstructionFile = path.join(rooDir, `${fileExtension}-instruction.md`)
|
||||
const content = await safeReadFile(extensionInstructionFile)
|
||||
if (content) {
|
||||
extensionRules.push(`# Extension-specific instructions from ${extensionInstructionFile}:\n${content}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Also check in the project root for legacy support
|
||||
const rootExtensionFile = path.join(cwd, `${fileExtension}-instruction.md`)
|
||||
const rootContent = await safeReadFile(rootExtensionFile)
|
||||
if (rootContent) {
|
||||
extensionRules.push(`# Extension-specific instructions from ${rootExtensionFile}:\n${rootContent}`)
|
||||
}
|
||||
|
||||
return extensionRules.join("\n\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file should be included in rule compilation.
|
||||
* Excludes cache files and system files that shouldn't be processed as rules.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export { getRulesSection } from "./rules"
|
||||
export { getSystemInfoSection } from "./system-info"
|
||||
export { getObjectiveSection } from "./objective"
|
||||
export { addCustomInstructions } from "./custom-instructions"
|
||||
export { addCustomInstructions, loadExtensionSpecificInstructions } from "./custom-instructions"
|
||||
export { getSharedToolUseSection } from "./tool-use"
|
||||
export { getMcpServersSection } from "./mcp-servers"
|
||||
export { getToolUseGuidelinesSection } from "./tool-use-guidelines"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue