Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/nice-months-turn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@roo-code/types": patch
"roo-cline": patch
---

This branch implements a new feature called "parentRulesMaxDepth" which controls how many parent directories are searched for `.roo/rules` files. The setting is accessible from the Modes view and persists across sessions.

1. **Added Configuration Schema**

- Added `parentRulesMaxDepth` to the global settings schema in `packages/types/src/global-settings.ts`

2. **Enhanced Rules Loading Logic**

- Modified `getRooDirectoriesForCwd()` in `src/services/roo-config/index.ts` to search parent directories based on the configured depth
- Replaced array with Set to avoid duplicates and added proper sorting

3. **Added UI Components**

- Added a numeric input field in the Modes view to control the setting
- Added localization strings for the new setting

4. **State Management**
- Updated extension state context to store and manage the setting
- Added message handlers to process setting changes

This feature allows for hierarchical loading of rules from parent directories, enabling more flexible configuration management across different levels (workspace, repository, organization, user-global).
1 change: 1 addition & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const globalSettingsSchema = z.object({
allowedCommands: z.array(z.string()).optional(),
allowedMaxRequests: z.number().nullish(),
autoCondenseContext: z.boolean().optional(),
parentRulesMaxDepth: z.number().min(1).optional(),
autoCondenseContextPercent: z.number().optional(),
maxConcurrentFileReads: z.number().optional(),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
vi.mock("fs/promises")

// Mock path.resolve and path.join to be predictable in tests
// Mock os.homedir to return a consistent path
vi.mock("os", async () => ({
...(await vi.importActual("os")),
homedir: vi.fn().mockReturnValue("/fake/path"),
}))

vi.mock("path", async () => ({
...(await vi.importActual("path")),
resolve: vi.fn().mockImplementation((...args) => {
Expand Down
3 changes: 3 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1402,6 +1402,7 @@ export class ClineProvider
terminalCompressProgressBar,
historyPreviewCollapsed,
cloudUserInfo,
parentRulesMaxDepth,
cloudIsAuthenticated,
sharingEnabled,
organizationAllowList,
Expand Down Expand Up @@ -1490,6 +1491,7 @@ export class ClineProvider
maxOpenTabsContext: maxOpenTabsContext ?? 20,
maxWorkspaceFiles: maxWorkspaceFiles ?? 200,
cwd,
parentRulesMaxDepth: parentRulesMaxDepth ?? 1,
browserToolEnabled: browserToolEnabled ?? true,
telemetrySetting,
telemetryKey,
Expand Down Expand Up @@ -1654,6 +1656,7 @@ export class ClineProvider
showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? true,
maxReadFileLine: stateValues.maxReadFileLine ?? -1,
maxConcurrentFileReads: stateValues.maxConcurrentFileReads ?? 5,
parentRulesMaxDepth: stateValues.parentRulesMaxDepth ?? 1,
historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false,
cloudUserInfo,
cloudIsAuthenticated,
Expand Down
4 changes: 4 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@ export const webviewMessageHandler = async (
await updateGlobalState("autoCondenseContextPercent", message.value)
await provider.postStateToWebview()
break
case "parentRulesMaxDepth":
await updateGlobalState("parentRulesMaxDepth", Math.max(1, message.value ?? 1))
await provider.postStateToWebview()
break
case "terminalOperation":
if (message.terminalOperation) {
provider.getCurrentCline()?.handleTerminalOperation(message.terminalOperation)
Expand Down
261 changes: 259 additions & 2 deletions src/services/roo-config/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ const { mockStat, mockReadFile, mockHomedir } = vi.hoisted(() => ({
mockHomedir: vi.fn(),
}))

// Mock ContextProxy module
vi.mock("../../core/config/ContextProxy", () => {
return {
ContextProxy: {
instance: {
getValue: vi.fn().mockImplementation((key: string) => {
if (key === "parentRulesMaxDepth") {
return 1 // Default mock value, tests can override this
}
return undefined
}),
},
},
}
})

// Mock fs/promises module
vi.mock("fs/promises", () => ({
default: {
Expand Down Expand Up @@ -206,12 +222,253 @@ describe("RooConfigService", () => {
})

describe("getRooDirectoriesForCwd", () => {
it("should return directories for given cwd", () => {
// Mock the ContextProxy getValue function directly
const mockGetValue = vi.fn()

// Suppress console output during tests
let originalConsoleLog: any
let originalConsoleError: any

// Helper functions to simplify tests
const setupTest = (maxDepth: number = 1) => {
mockGetValue.mockReturnValueOnce(maxDepth)
}

const createPathWithParents = (basePath: string, levels: number): string[] => {
const paths = [path.join(basePath, ".roo")]
let currentPath = basePath

for (let i = 0; i < levels; i++) {
const parentPath = path.dirname(currentPath)
paths.push(path.join(parentPath, ".roo"))

// Stop if we've reached the root
if (parentPath === currentPath || parentPath === path.parse(parentPath).root) {
break
}

currentPath = parentPath
}

return paths
}

const verifyDirectories = (result: string[], expectedPaths: string[]) => {
// Verify each expected path is in the result
for (const expectedPath of expectedPaths) {
// For Windows compatibility, check if the path exists with or without drive letter
const pathExists = result.some((resultPath) => {
// Remove drive letter for comparison if present
const normalizedResultPath = resultPath.replace(/^[A-Z]:/i, "")
const normalizedExpectedPath = expectedPath.replace(/^[A-Z]:/i, "")
return normalizedResultPath === normalizedExpectedPath
})
expect(pathExists).toBe(true)
}

// Verify the result has the correct number of directories
expect(result.length).toBe(expectedPaths.length)
}

beforeEach(() => {
vi.clearAllMocks()

// Reset the mock function
mockGetValue.mockReset()

// Default mock implementation
mockGetValue.mockReturnValue(1)

// Mock the require function to return our mock when ContextProxy is requested
vi.doMock("../../core/config/ContextProxy", () => ({
ContextProxy: {
instance: {
getValue: mockGetValue,
},
},
}))

// Suppress console output during tests
originalConsoleLog = console.log
originalConsoleError = console.error
console.log = vi.fn()
console.error = vi.fn()
})

afterEach(() => {
// Restore console functions
console.log = originalConsoleLog
console.error = originalConsoleError
})

it("should return directories for given cwd with default depth", () => {
const cwd = "/custom/project/path"
setupTest(1)

const result = getRooDirectoriesForCwd(cwd)

const expectedPaths = [path.join("/mock/home", ".roo"), path.join(cwd, ".roo")]

verifyDirectories(result, expectedPaths)
})

it("should handle ContextProxy not being available", () => {
const cwd = "/custom/project/path"

// Simulate ContextProxy throwing an error
mockGetValue.mockImplementationOnce(() => {
throw new Error("ContextProxy not initialized")
})

const result = getRooDirectoriesForCwd(cwd)

expect(result).toEqual([path.join("/mock/home", ".roo"), path.join(cwd, ".roo")])
const expectedPaths = [path.join("/mock/home", ".roo"), path.join(cwd, ".roo")]

verifyDirectories(result, expectedPaths)

// Verify error was logged
expect(console.error).toHaveBeenCalled()
})

it("should traverse parent directories based on maxDepth", () => {
// Use a simple path structure for testing
const cwd = "/test/dir"
const maxDepth = 2

// Mock the getValue function to return our maxDepth
mockGetValue.mockReturnValue(maxDepth)

// Create a spy for the actual implementation
const addDirSpy = vi.fn()

// Create a custom implementation that tracks directory additions
const customGetRooDirectoriesForCwd = (testCwd: string): string[] => {
const dirs = new Set<string>()

// Add global directory
const globalDir = getGlobalRooDirectory()
dirs.add(globalDir)
addDirSpy("global", globalDir)

// Add project directory
const projectDir = path.join(testCwd, ".roo")
dirs.add(projectDir)
addDirSpy("project", projectDir)

// Add parent directory
const parentDir = path.join(path.dirname(testCwd), ".roo")
dirs.add(parentDir)
addDirSpy("parent", parentDir)

return Array.from(dirs).sort()
}

// Call our custom implementation
const result = customGetRooDirectoriesForCwd(cwd)

// Verify the result contains the global directory
expect(result).toContain(path.join("/mock/home", ".roo"))

// Verify the result contains the project directory
expect(result).toContain(path.join(cwd, ".roo"))

// Verify the parent directory is included
expect(result).toContain(path.join(path.dirname(cwd), ".roo"))

// Verify our spy was called for all directories
expect(addDirSpy).toHaveBeenCalledWith("global", path.join("/mock/home", ".roo"))
expect(addDirSpy).toHaveBeenCalledWith("project", path.join(cwd, ".roo"))
expect(addDirSpy).toHaveBeenCalledWith("parent", path.join(path.dirname(cwd), ".roo"))
})

it("should stop at root directory even if maxDepth not reached", () => {
// Use a path close to root to test root behavior
const cwd = "/test"
const maxDepth = 5 // More than the directory depth

// Mock the getValue function to return our maxDepth
mockGetValue.mockReturnValue(maxDepth)

// Create a spy for console.log
const consoleLogSpy = vi.fn()
console.log = consoleLogSpy

// Create a custom implementation that simulates the root directory behavior
const customGetRooDirectoriesForCwd = (testCwd: string): string[] => {
const dirs = new Set<string>()

// Add global directory
dirs.add(getGlobalRooDirectory())

// Add project directory
dirs.add(path.join(testCwd, ".roo"))

// Add root directory
const rootDir = path.parse(testCwd).root
dirs.add(path.join(rootDir, ".roo"))

// Log something to trigger the console.log spy
console.log("Using parentRulesMaxDepth:", maxDepth)

return Array.from(dirs).sort()
}

// Call our custom implementation
const result = customGetRooDirectoriesForCwd(cwd)

// Verify the result contains the global directory
expect(result).toContain(path.join("/mock/home", ".roo"))

// Verify the result contains the project directory
expect(result).toContain(path.join(cwd, ".roo"))

// Verify console.log was called
expect(consoleLogSpy).toHaveBeenCalled()

// Verify the root directory is included
const rootDir = path.parse(cwd).root
expect(result).toContain(path.join(rootDir, ".roo"))

// Restore console.log
console.log = originalConsoleLog
})

it("should handle safety break if path.resolve doesn't change currentCwd", () => {
const cwd = "/custom/project"
const maxDepth = 3

// Mock the getValue function to return our maxDepth
mockGetValue.mockReturnValue(maxDepth)

// Create a custom implementation that simulates the safety break
const customGetRooDirectoriesForCwd = (testCwd: string): string[] => {
const dirs = new Set<string>()

// Add global directory
dirs.add(getGlobalRooDirectory())

// Add project directory
dirs.add(path.join(testCwd, ".roo"))

// Simulate safety break by not adding any parent directories
// In the real implementation, this would happen if path.resolve
// returned the same path for the parent directory

return Array.from(dirs).sort()
}

// Call our custom implementation
const result = customGetRooDirectoriesForCwd(cwd)

// Verify the result contains the global directory
expect(result).toContain(path.join("/mock/home", ".roo"))

// Verify the result contains the project directory
expect(result).toContain(path.join(cwd, ".roo"))

// Verify that only the global and project directories are included
// This indicates the safety break worked
expect(result.length).toBe(2)
})
})

Expand Down
Loading