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:
Roo Code 2025-07-23 07:15:13 +00:00
parent 2411c8faa4
commit 29d1bb8619
3 changed files with 369 additions and 5 deletions

View file

@ -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`)
}
}
})
})

View file

@ -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.

View file

@ -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"