Roo-Code/src/services/code-index/processors/file-watcher.ts
Roo Code ba42cbdf87 fix: enforce .rooignore rules for codebase indexing, file reads, and file listing
- Harden validateAccess() to fall back to original path when realpath
  resolves outside cwd (fixes submodule/symlink bypass)
- Change error handling in validateAccess() to fail closed (deny access)
  instead of fail open
- Add .rooignore post-filtering in CodebaseSearchTool to exclude ignored
  files from search results even if they were previously indexed
- Pass RooIgnoreController from manager through service-factory to scanner
  so the scanner reuses the workspace-root controller instead of creating
  its own from the scan directory
- Fix FileWatcher to initialize fallback RooIgnoreController in
  initialize() so .rooignore rules load even when manager controller
  is not passed
- Add tests for realpath-outside-cwd fallback, fail-closed error handling,
  CodebaseSearchTool rooignore filtering, and scanner controller passthrough

Fixes #11797
2026-02-28 15:42:32 +00:00

618 lines
20 KiB
TypeScript

import * as vscode from "vscode"
import {
QDRANT_CODE_BLOCK_NAMESPACE,
MAX_FILE_SIZE_BYTES,
BATCH_SEGMENT_THRESHOLD,
MAX_BATCH_RETRIES,
INITIAL_RETRY_DELAY_MS,
} from "../constants"
import { createHash } from "crypto"
import { RooIgnoreController } from "../../../core/ignore/RooIgnoreController"
import { v5 as uuidv5 } from "uuid"
import { Ignore } from "ignore"
import { scannerExtensions } from "../shared/supported-extensions"
import {
IFileWatcher,
FileProcessingResult,
IEmbedder,
IVectorStore,
PointStruct,
BatchProcessingSummary,
} from "../interfaces"
import { codeParser } from "./parser"
import { CacheManager } from "../cache-manager"
import { generateNormalizedAbsolutePath, generateRelativeFilePath } from "../shared/get-relative-path"
import { isPathInIgnoredDirectory } from "../../glob/ignore-utils"
import { TelemetryService } from "@roo-code/telemetry"
import { TelemetryEventName } from "@roo-code/types"
import { sanitizeErrorMessage } from "../shared/validation-helpers"
import { Package } from "../../../shared/package"
/**
* Implementation of the file watcher interface
*/
export class FileWatcher implements IFileWatcher {
private ignoreInstance?: Ignore
private fileWatcher?: vscode.FileSystemWatcher
private ignoreController: RooIgnoreController
private accumulatedEvents: Map<string, { uri: vscode.Uri; type: "create" | "change" | "delete" }> = new Map()
private batchProcessDebounceTimer?: NodeJS.Timeout
private readonly BATCH_DEBOUNCE_DELAY_MS = 500
private readonly FILE_PROCESSING_CONCURRENCY_LIMIT = 10
private readonly batchSegmentThreshold: number
private readonly _onDidStartBatchProcessing = new vscode.EventEmitter<string[]>()
private readonly _onBatchProgressUpdate = new vscode.EventEmitter<{
processedInBatch: number
totalInBatch: number
currentFile?: string
}>()
private readonly _onDidFinishBatchProcessing = new vscode.EventEmitter<BatchProcessingSummary>()
/**
* Event emitted when a batch of files begins processing
*/
public readonly onDidStartBatchProcessing = this._onDidStartBatchProcessing.event
/**
* Event emitted to report progress during batch processing
*/
public readonly onBatchProgressUpdate = this._onBatchProgressUpdate.event
/**
* Event emitted when a batch of files has finished processing
*/
public readonly onDidFinishBatchProcessing = this._onDidFinishBatchProcessing.event
/**
* Creates a new file watcher
* @param workspacePath Path to the workspace
* @param context VS Code extension context
* @param embedder Optional embedder
* @param vectorStore Optional vector store
* @param cacheManager Cache manager
*/
private ignoreControllerIsOwned = false
constructor(
private workspacePath: string,
private context: vscode.ExtensionContext,
private readonly cacheManager: CacheManager,
private embedder?: IEmbedder,
private vectorStore?: IVectorStore,
ignoreInstance?: Ignore,
ignoreController?: RooIgnoreController,
batchSegmentThreshold?: number,
) {
if (ignoreController) {
this.ignoreController = ignoreController
} else {
// Fallback: create a local controller. It must be initialized in initialize()
// before it can enforce .rooignore rules. Prefer passing the manager's controller.
this.ignoreController = new RooIgnoreController(workspacePath)
this.ignoreControllerIsOwned = true
}
if (ignoreInstance) {
this.ignoreInstance = ignoreInstance
}
// Get the configurable batch size from VSCode settings, fallback to default
// If not provided in constructor, try to get from VSCode settings
if (batchSegmentThreshold !== undefined) {
this.batchSegmentThreshold = batchSegmentThreshold
} else {
try {
this.batchSegmentThreshold = vscode.workspace
.getConfiguration(Package.name)
.get<number>("codeIndex.embeddingBatchSize", BATCH_SEGMENT_THRESHOLD)
} catch {
// In test environment, vscode.workspace might not be available
this.batchSegmentThreshold = BATCH_SEGMENT_THRESHOLD
}
}
}
/**
* Initializes the file watcher
*/
async initialize(): Promise<void> {
// If we created a fallback RooIgnoreController in the constructor,
// initialize it now so it loads .rooignore patterns before use.
if (this.ignoreControllerIsOwned) {
await this.ignoreController.initialize()
}
// Create file watcher
const filePattern = new vscode.RelativePattern(
this.workspacePath,
`**/*{${scannerExtensions.map((e) => e.substring(1)).join(",")}}`,
)
this.fileWatcher = vscode.workspace.createFileSystemWatcher(filePattern)
// Register event handlers
this.fileWatcher.onDidCreate(this.handleFileCreated.bind(this))
this.fileWatcher.onDidChange(this.handleFileChanged.bind(this))
this.fileWatcher.onDidDelete(this.handleFileDeleted.bind(this))
}
/**
* Disposes the file watcher
*/
dispose(): void {
this.fileWatcher?.dispose()
if (this.batchProcessDebounceTimer) {
clearTimeout(this.batchProcessDebounceTimer)
}
this._onDidStartBatchProcessing.dispose()
this._onBatchProgressUpdate.dispose()
this._onDidFinishBatchProcessing.dispose()
this.accumulatedEvents.clear()
}
/**
* Handles file creation events
* @param uri URI of the created file
*/
private async handleFileCreated(uri: vscode.Uri): Promise<void> {
this.accumulatedEvents.set(uri.fsPath, { uri, type: "create" })
this.scheduleBatchProcessing()
}
/**
* Handles file change events
* @param uri URI of the changed file
*/
private async handleFileChanged(uri: vscode.Uri): Promise<void> {
this.accumulatedEvents.set(uri.fsPath, { uri, type: "change" })
this.scheduleBatchProcessing()
}
/**
* Handles file deletion events
* @param uri URI of the deleted file
*/
private async handleFileDeleted(uri: vscode.Uri): Promise<void> {
this.accumulatedEvents.set(uri.fsPath, { uri, type: "delete" })
this.scheduleBatchProcessing()
}
/**
* Schedules batch processing with debounce
*/
private scheduleBatchProcessing(): void {
if (this.batchProcessDebounceTimer) {
clearTimeout(this.batchProcessDebounceTimer)
}
this.batchProcessDebounceTimer = setTimeout(() => this.triggerBatchProcessing(), this.BATCH_DEBOUNCE_DELAY_MS)
}
/**
* Triggers processing of accumulated events
*/
private async triggerBatchProcessing(): Promise<void> {
if (this.accumulatedEvents.size === 0) {
return
}
const eventsToProcess = new Map(this.accumulatedEvents)
this.accumulatedEvents.clear()
const filePathsInBatch = Array.from(eventsToProcess.keys())
this._onDidStartBatchProcessing.fire(filePathsInBatch)
await this.processBatch(eventsToProcess)
}
/**
* Processes a batch of accumulated events
* @param eventsToProcess Map of events to process
*/
private async _handleBatchDeletions(
batchResults: FileProcessingResult[],
processedCountInBatch: number,
totalFilesInBatch: number,
pathsToExplicitlyDelete: string[],
filesToUpsertDetails: Array<{ path: string; uri: vscode.Uri; originalType: "create" | "change" }>,
): Promise<{ overallBatchError?: Error; clearedPaths: Set<string>; processedCount: number }> {
let overallBatchError: Error | undefined
const allPathsToClearFromDB = new Set<string>(pathsToExplicitlyDelete)
for (const fileDetail of filesToUpsertDetails) {
if (fileDetail.originalType === "change") {
allPathsToClearFromDB.add(fileDetail.path)
}
}
if (allPathsToClearFromDB.size > 0 && this.vectorStore) {
try {
await this.vectorStore.deletePointsByMultipleFilePaths(Array.from(allPathsToClearFromDB))
for (const path of pathsToExplicitlyDelete) {
this.cacheManager.deleteHash(path)
batchResults.push({ path, status: "success" })
processedCountInBatch++
this._onBatchProgressUpdate.fire({
processedInBatch: processedCountInBatch,
totalInBatch: totalFilesInBatch,
currentFile: path,
})
}
} catch (error: any) {
const errorStatus = error?.status || error?.response?.status || error?.statusCode
const errorMessage = error instanceof Error ? error.message : String(error)
// Log telemetry for deletion error
TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, {
error: sanitizeErrorMessage(errorMessage),
location: "deletePointsByMultipleFilePaths",
errorType: "deletion_error",
errorStatus: errorStatus,
})
// Mark all paths as error
overallBatchError = error as Error
for (const path of pathsToExplicitlyDelete) {
batchResults.push({ path, status: "error", error: error as Error })
processedCountInBatch++
this._onBatchProgressUpdate.fire({
processedInBatch: processedCountInBatch,
totalInBatch: totalFilesInBatch,
currentFile: path,
})
}
}
}
return { overallBatchError, clearedPaths: allPathsToClearFromDB, processedCount: processedCountInBatch }
}
private async _processFilesAndPrepareUpserts(
filesToUpsertDetails: Array<{ path: string; uri: vscode.Uri; originalType: "create" | "change" }>,
batchResults: FileProcessingResult[],
processedCountInBatch: number,
totalFilesInBatch: number,
pathsToExplicitlyDelete: string[],
): Promise<{
pointsForBatchUpsert: PointStruct[]
successfullyProcessedForUpsert: Array<{ path: string; newHash?: string }>
processedCount: number
}> {
const pointsForBatchUpsert: PointStruct[] = []
const successfullyProcessedForUpsert: Array<{ path: string; newHash?: string }> = []
const filesToProcessConcurrently = [...filesToUpsertDetails]
for (let i = 0; i < filesToProcessConcurrently.length; i += this.FILE_PROCESSING_CONCURRENCY_LIMIT) {
const chunkToProcess = filesToProcessConcurrently.slice(i, i + this.FILE_PROCESSING_CONCURRENCY_LIMIT)
const chunkProcessingPromises = chunkToProcess.map(async (fileDetail) => {
this._onBatchProgressUpdate.fire({
processedInBatch: processedCountInBatch,
totalInBatch: totalFilesInBatch,
currentFile: fileDetail.path,
})
try {
const result = await this.processFile(fileDetail.path)
return { path: fileDetail.path, result: result, error: undefined }
} catch (e) {
const error = e as Error
console.error(`[FileWatcher] Unhandled exception processing file ${fileDetail.path}:`, e)
return { path: fileDetail.path, result: undefined, error: error }
}
})
const settledChunkResults = await Promise.allSettled(chunkProcessingPromises)
for (const settledResult of settledChunkResults) {
let resultPath: string | undefined
if (settledResult.status === "fulfilled") {
const { path, result, error: directError } = settledResult.value
resultPath = path
if (directError) {
batchResults.push({ path, status: "error", error: directError })
} else if (result) {
if (result.status === "skipped" || result.status === "local_error") {
batchResults.push(result)
} else if (result.status === "processed_for_batching" && result.pointsToUpsert) {
pointsForBatchUpsert.push(...result.pointsToUpsert)
if (result.path && result.newHash) {
successfullyProcessedForUpsert.push({ path: result.path, newHash: result.newHash })
} else if (result.path && !result.newHash) {
successfullyProcessedForUpsert.push({ path: result.path })
}
} else {
batchResults.push({
path,
status: "error",
error: new Error(
`Unexpected result status from processFile: ${result.status} for file ${path}`,
),
})
}
} else {
batchResults.push({
path,
status: "error",
error: new Error(`Fulfilled promise with no result or error for file ${path}`),
})
}
} else {
const error = settledResult.reason as Error
const rejectedPath = (settledResult.reason as any)?.path || "unknown"
console.error("[FileWatcher] A file processing promise was rejected:", settledResult.reason)
batchResults.push({
path: rejectedPath,
status: "error",
error: error,
})
}
if (!pathsToExplicitlyDelete.includes(resultPath || "")) {
processedCountInBatch++
}
this._onBatchProgressUpdate.fire({
processedInBatch: processedCountInBatch,
totalInBatch: totalFilesInBatch,
currentFile: resultPath,
})
}
}
return {
pointsForBatchUpsert,
successfullyProcessedForUpsert,
processedCount: processedCountInBatch,
}
}
private async _executeBatchUpsertOperations(
pointsForBatchUpsert: PointStruct[],
successfullyProcessedForUpsert: Array<{ path: string; newHash?: string }>,
batchResults: FileProcessingResult[],
overallBatchError?: Error,
): Promise<Error | undefined> {
if (pointsForBatchUpsert.length > 0 && this.vectorStore && !overallBatchError) {
try {
for (let i = 0; i < pointsForBatchUpsert.length; i += this.batchSegmentThreshold) {
const batch = pointsForBatchUpsert.slice(i, i + this.batchSegmentThreshold)
let retryCount = 0
let upsertError: Error | undefined
while (retryCount < MAX_BATCH_RETRIES) {
try {
await this.vectorStore.upsertPoints(batch)
break
} catch (error) {
upsertError = error as Error
retryCount++
if (retryCount === MAX_BATCH_RETRIES) {
// Log telemetry for upsert failure
TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, {
error: sanitizeErrorMessage(upsertError.message),
location: "upsertPoints",
errorType: "upsert_retry_exhausted",
retryCount: MAX_BATCH_RETRIES,
})
throw new Error(
`Failed to upsert batch after ${MAX_BATCH_RETRIES} retries: ${upsertError.message}`,
)
}
await new Promise((resolve) =>
setTimeout(resolve, INITIAL_RETRY_DELAY_MS * Math.pow(2, retryCount - 1)),
)
}
}
}
for (const { path, newHash } of successfullyProcessedForUpsert) {
if (newHash) {
this.cacheManager.updateHash(path, newHash)
}
batchResults.push({ path, status: "success" })
}
} catch (error) {
const err = error as Error
overallBatchError = overallBatchError || err
// Log telemetry for batch upsert error
TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, {
error: sanitizeErrorMessage(err.message),
location: "executeBatchUpsertOperations",
errorType: "batch_upsert_error",
affectedFiles: successfullyProcessedForUpsert.length,
})
for (const { path } of successfullyProcessedForUpsert) {
batchResults.push({ path, status: "error", error: err })
}
}
} else if (overallBatchError && pointsForBatchUpsert.length > 0) {
for (const { path } of successfullyProcessedForUpsert) {
batchResults.push({ path, status: "error", error: overallBatchError })
}
}
return overallBatchError
}
private async processBatch(
eventsToProcess: Map<string, { uri: vscode.Uri; type: "create" | "change" | "delete" }>,
): Promise<void> {
const batchResults: FileProcessingResult[] = []
let processedCountInBatch = 0
const totalFilesInBatch = eventsToProcess.size
let overallBatchError: Error | undefined
// Initial progress update
this._onBatchProgressUpdate.fire({
processedInBatch: 0,
totalInBatch: totalFilesInBatch,
currentFile: undefined,
})
// Categorize events
const pathsToExplicitlyDelete: string[] = []
const filesToUpsertDetails: Array<{ path: string; uri: vscode.Uri; originalType: "create" | "change" }> = []
for (const event of eventsToProcess.values()) {
if (event.type === "delete") {
pathsToExplicitlyDelete.push(event.uri.fsPath)
} else {
filesToUpsertDetails.push({
path: event.uri.fsPath,
uri: event.uri,
originalType: event.type,
})
}
}
// Phase 1: Handle deletions
const { overallBatchError: deletionError, processedCount: deletionCount } = await this._handleBatchDeletions(
batchResults,
processedCountInBatch,
totalFilesInBatch,
pathsToExplicitlyDelete,
filesToUpsertDetails,
)
overallBatchError = deletionError
processedCountInBatch = deletionCount
// Phase 2: Process files and prepare upserts
const {
pointsForBatchUpsert,
successfullyProcessedForUpsert,
processedCount: upsertCount,
} = await this._processFilesAndPrepareUpserts(
filesToUpsertDetails,
batchResults,
processedCountInBatch,
totalFilesInBatch,
pathsToExplicitlyDelete,
)
processedCountInBatch = upsertCount
// Phase 3: Execute batch upsert
overallBatchError = await this._executeBatchUpsertOperations(
pointsForBatchUpsert,
successfullyProcessedForUpsert,
batchResults,
overallBatchError,
)
// Finalize
this._onDidFinishBatchProcessing.fire({
processedFiles: batchResults,
batchError: overallBatchError,
})
this._onBatchProgressUpdate.fire({
processedInBatch: totalFilesInBatch,
totalInBatch: totalFilesInBatch,
})
if (this.accumulatedEvents.size === 0) {
this._onBatchProgressUpdate.fire({
processedInBatch: 0,
totalInBatch: 0,
currentFile: undefined,
})
}
}
/**
* Processes a file
* @param filePath Path to the file to process
* @returns Promise resolving to processing result
*/
async processFile(filePath: string): Promise<FileProcessingResult> {
try {
// Get relative path for ignore checks
const relativeFilePath = generateRelativeFilePath(filePath, this.workspacePath)
// Check if file is in an ignored directory
// Use relative path to avoid matching parent directories outside the workspace
if (isPathInIgnoredDirectory(relativeFilePath)) {
return {
path: filePath,
status: "skipped" as const,
reason: "File is in an ignored directory",
}
}
// Check if file should be ignored
if (
!this.ignoreController.validateAccess(filePath) ||
(this.ignoreInstance && this.ignoreInstance.ignores(relativeFilePath))
) {
return {
path: filePath,
status: "skipped" as const,
reason: "File is ignored by .rooignore or .gitignore",
}
}
// Check file size
const fileStat = await vscode.workspace.fs.stat(vscode.Uri.file(filePath))
if (fileStat.size > MAX_FILE_SIZE_BYTES) {
return {
path: filePath,
status: "skipped" as const,
reason: "File is too large",
}
}
// Read file content
const fileContent = await vscode.workspace.fs.readFile(vscode.Uri.file(filePath))
const content = fileContent.toString()
// Calculate hash
const newHash = createHash("sha256").update(content).digest("hex")
// Check if file has changed
if (this.cacheManager.getHash(filePath) === newHash) {
return {
path: filePath,
status: "skipped" as const,
reason: "File has not changed",
}
}
// Parse file
const blocks = await codeParser.parseFile(filePath, { content, fileHash: newHash })
// Prepare points for batch processing
let pointsToUpsert: PointStruct[] = []
if (this.embedder && blocks.length > 0) {
const texts = blocks.map((block) => block.content)
const { embeddings } = await this.embedder.createEmbeddings(texts)
pointsToUpsert = blocks.map((block, index) => {
const normalizedAbsolutePath = generateNormalizedAbsolutePath(block.file_path, this.workspacePath)
const stableName = `${normalizedAbsolutePath}:${block.start_line}`
const pointId = uuidv5(stableName, QDRANT_CODE_BLOCK_NAMESPACE)
return {
id: pointId,
vector: embeddings[index],
payload: {
filePath: generateRelativeFilePath(normalizedAbsolutePath, this.workspacePath),
codeChunk: block.content,
startLine: block.start_line,
endLine: block.end_line,
},
}
})
}
return {
path: filePath,
status: "processed_for_batching" as const,
newHash,
pointsToUpsert,
}
} catch (error) {
return {
path: filePath,
status: "local_error" as const,
error: error as Error,
}
}
}
}