From 4f20eae6ea697ea48b2ad84f1d45bac0183376bf Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Mon, 28 Sep 2026 17:37:24 +0300 Subject: [PATCH 1/8] refactor(code-index): extract single-file preparation --- .../__tests__/file-preparation.spec.ts | 206 ++++++++++++++++++ .../processors/__tests__/file-watcher.spec.ts | 20 ++ .../code-index/processors/file-preparation.ts | 120 ++++++++++ .../code-index/processors/file-watcher.ts | 115 +--------- 4 files changed, 358 insertions(+), 103 deletions(-) create mode 100644 src/services/code-index/processors/__tests__/file-preparation.spec.ts create mode 100644 src/services/code-index/processors/file-preparation.ts diff --git a/src/services/code-index/processors/__tests__/file-preparation.spec.ts b/src/services/code-index/processors/__tests__/file-preparation.spec.ts new file mode 100644 index 0000000000..3184779c3d --- /dev/null +++ b/src/services/code-index/processors/__tests__/file-preparation.spec.ts @@ -0,0 +1,206 @@ +import { createHash } from "crypto" +import { v5 as uuidv5 } from "uuid" +import type { CodeBlock, ICodeParser, IEmbedder } from "../../interfaces" +import { MAX_FILE_SIZE_BYTES, QDRANT_CODE_BLOCK_NAMESPACE } from "../../constants" +import { prepareFile, type FilePreparationDependencies } from "../file-preparation" + +describe("prepareFile", () => { + const filePath = "/workspace/src/file.ts" + const content = "test content" + const hash = createHash("sha256").update(content).digest("hex") + const block: CodeBlock = { + file_path: filePath, + identifier: null, + type: "function", + start_line: 2, + end_line: 5, + content, + fileHash: hash, + segmentHash: "segment", + } + + function setup() { + return { + workspacePath: "/workspace", + validateAccess: vi.fn<(path: string) => boolean>().mockReturnValue(true), + ignoreInstance: { ignores: vi.fn<(path: string) => boolean>().mockReturnValue(false) }, + stat: vi.fn().mockResolvedValue({ size: 100 }), + readFile: vi.fn().mockResolvedValue(Buffer.from(content)), + getHash: vi.fn(), + parser: { parseFile: vi.fn().mockResolvedValue([{ ...block }]) }, + embedder: { + createEmbeddings: vi + .fn() + .mockResolvedValue({ embeddings: [[0.1, 0.2]] }), + }, + } satisfies FilePreparationDependencies + } + + it.each([".git/config", ".hidden/file.ts", "node_modules/pkg/file.ts", "dist/file.js"])( + "skips excluded directory %s before access or file reads", + async (relativePath) => { + const dependencies = setup() + const path = `/workspace/${relativePath}` + expect(await prepareFile(path, dependencies)).toEqual({ + path, + status: "skipped", + reason: "File is in an ignored directory", + }) + expect(dependencies.validateAccess).not.toHaveBeenCalled() + expect(dependencies.stat).not.toHaveBeenCalled() + }, + ) + + it.each(["access", "gitignore"])("skips %s exclusions before reading", async (source) => { + const dependencies = setup() + dependencies.validateAccess.mockReturnValue(source !== "access") + dependencies.ignoreInstance.ignores.mockReturnValue(source === "gitignore") + expect(await prepareFile(filePath, dependencies)).toEqual({ + path: filePath, + status: "skipped", + reason: "File is ignored by .rooignore or .gitignore", + }) + expect(dependencies.validateAccess).toHaveBeenCalledWith(filePath) + if (source === "access") { + expect(dependencies.ignoreInstance.ignores).not.toHaveBeenCalled() + } else { + expect(dependencies.ignoreInstance.ignores).toHaveBeenCalledWith("src/file.ts") + } + expect(dependencies.stat).not.toHaveBeenCalled() + }) + + it("skips oversized files without reading them", async () => { + const dependencies = setup() + dependencies.stat.mockResolvedValue({ size: MAX_FILE_SIZE_BYTES + 1 }) + expect(await prepareFile(filePath, dependencies)).toEqual({ + path: filePath, + status: "skipped", + reason: "File is too large", + }) + expect(dependencies.readFile).not.toHaveBeenCalled() + }) + + it("skips unchanged content before parsing or embedding", async () => { + const dependencies = setup() + dependencies.getHash.mockReturnValue(hash) + expect(await prepareFile(filePath, dependencies)).toEqual({ + path: filePath, + status: "skipped", + reason: "File has not changed", + }) + expect(dependencies.getHash).toHaveBeenCalledWith(filePath) + expect(dependencies.parser.parseFile).not.toHaveBeenCalled() + expect(dependencies.embedder.createEmbeddings).not.toHaveBeenCalled() + }) + + it("returns the hash and empty points when parsing produces no blocks", async () => { + const dependencies = setup() + dependencies.parser.parseFile.mockResolvedValue([]) + expect(await prepareFile(filePath, dependencies)).toEqual({ + path: filePath, + status: "processed_for_batching", + newHash: hash, + pointsToUpsert: [], + }) + expect(dependencies.embedder.createEmbeddings).not.toHaveBeenCalled() + }) + + it("still parses and returns the hash without an embedder or gitignore", async () => { + const dependencies = setup() + expect( + await prepareFile(filePath, { ...dependencies, embedder: undefined, ignoreInstance: undefined }), + ).toEqual({ + path: filePath, + status: "processed_for_batching", + newHash: hash, + pointsToUpsert: [], + }) + expect(dependencies.parser.parseFile).toHaveBeenCalledWith(filePath, { content, fileHash: hash }) + }) + + it("preserves normalized paths, stable IDs and embedding order at the size limit", async () => { + const dependencies = setup() + dependencies.stat.mockResolvedValue({ size: MAX_FILE_SIZE_BYTES }) + dependencies.parser.parseFile.mockResolvedValue([ + { ...block, file_path: "src/../src/file.ts" }, + { ...block, start_line: 8, end_line: 10, content: "second" }, + ]) + dependencies.embedder.createEmbeddings.mockResolvedValue({ + embeddings: [ + [0.1, 0.2], + [0.3, 0.4], + ], + }) + expect(await prepareFile(filePath, dependencies)).toEqual({ + path: filePath, + status: "processed_for_batching", + newHash: hash, + pointsToUpsert: [ + { + id: uuidv5(`${filePath}:2`, QDRANT_CODE_BLOCK_NAMESPACE), + vector: [0.1, 0.2], + payload: { filePath: "src/file.ts", codeChunk: content, startLine: 2, endLine: 5 }, + }, + { + id: uuidv5(`${filePath}:8`, QDRANT_CODE_BLOCK_NAMESPACE), + vector: [0.3, 0.4], + payload: { filePath: "src/file.ts", codeChunk: "second", startLine: 8, endLine: 10 }, + }, + ], + }) + expect(dependencies.stat).toHaveBeenCalledWith(filePath) + expect(dependencies.readFile).toHaveBeenCalledWith(filePath) + expect(dependencies.parser.parseFile).toHaveBeenCalledWith(filePath, { content, fileHash: hash }) + expect(dependencies.embedder.createEmbeddings).toHaveBeenCalledWith([content, "second"]) + }) + + it("does not treat a hidden workspace ancestor as an excluded directory", async () => { + const dependencies = setup() + dependencies.workspacePath = "/.hidden/workspace" + expect((await prepareFile("/.hidden/workspace/src/file.ts", dependencies)).status).toBe( + "processed_for_batching", + ) + expect(dependencies.ignoreInstance.ignores).toHaveBeenCalledWith("src/file.ts") + }) + + it("preserves Uint8Array toString content conversion", async () => { + const dependencies = setup() + dependencies.readFile.mockResolvedValue(new Uint8Array([65, 66])) + await prepareFile(filePath, dependencies) + expect(dependencies.parser.parseFile).toHaveBeenCalledWith(filePath, { + content: "65,66", + fileHash: createHash("sha256").update("65,66").digest("hex"), + }) + }) + + it.each(["access", "ignore", "stat", "read", "cache", "parse", "embed"])( + "returns the original %s error as a local error", + async (stage) => { + const dependencies = setup() + const error = new Error(`${stage} failed`) + const fail = () => { + throw error + } + if (stage === "access") dependencies.validateAccess.mockImplementation(fail) + if (stage === "ignore") dependencies.ignoreInstance.ignores.mockImplementation(fail) + if (stage === "stat") dependencies.stat.mockRejectedValue(error) + if (stage === "read") dependencies.readFile.mockRejectedValue(error) + if (stage === "cache") dependencies.getHash.mockImplementation(fail) + if (stage === "parse") dependencies.parser.parseFile.mockRejectedValue(error) + if (stage === "embed") dependencies.embedder.createEmbeddings.mockRejectedValue(error) + const result = await prepareFile(filePath, dependencies) + expect(result).toEqual({ path: filePath, status: "local_error", error }) + expect(result.error).toBe(error) + }, + ) + + it("does not wrap non-Error rejections", async () => { + const dependencies = setup() + dependencies.readFile.mockRejectedValue("read failed") + expect(await prepareFile(filePath, dependencies)).toEqual({ + path: filePath, + status: "local_error", + error: "read failed", + }) + }) +}) diff --git a/src/services/code-index/processors/__tests__/file-watcher.spec.ts b/src/services/code-index/processors/__tests__/file-watcher.spec.ts index fc61e687bd..7932b6137c 100644 --- a/src/services/code-index/processors/__tests__/file-watcher.spec.ts +++ b/src/services/code-index/processors/__tests__/file-watcher.spec.ts @@ -3,6 +3,7 @@ import * as vscode from "vscode" import { FileWatcher } from "../file-watcher" +import * as filePreparation from "../file-preparation" import { clearAllMocks } from "../../../../test-utils/reset" @@ -182,6 +183,25 @@ describe("FileWatcher", () => { vi.useRealTimers() }) + it("delegates public processFile to preparation without writing points or cache", async () => { + const prepare = vi.spyOn(filePreparation, "prepareFile") + try { + const path = "/mock/workspace/src/file.ts" + const result = await fileWatcher.processFile(path) + expect(prepare).toHaveBeenCalledWith(path, expect.objectContaining({ workspacePath: "/mock/workspace" })) + expect(result).toBe(await prepare.mock.results[0].value) + expect(result.status).toBe("processed_for_batching") + expect(result.pointsToUpsert).toHaveLength(1) + expect(vscode.workspace.fs.stat).toHaveBeenCalledWith(vscode.Uri.file(path)) + expect(vscode.workspace.fs.readFile).toHaveBeenCalledWith(vscode.Uri.file(path)) + expect(mockVectorStore.upsertPoints).not.toHaveBeenCalled() + expect(mockCacheManager.updateHash).not.toHaveBeenCalled() + expect(mockCacheManager.deleteHash).not.toHaveBeenCalled() + } finally { + prepare.mockRestore() + } + }) + describe("file filtering", () => { it("should ignore files in hidden directories on create events", async () => { // Initialize the file watcher diff --git a/src/services/code-index/processors/file-preparation.ts b/src/services/code-index/processors/file-preparation.ts new file mode 100644 index 0000000000..a0ea14412d --- /dev/null +++ b/src/services/code-index/processors/file-preparation.ts @@ -0,0 +1,120 @@ +import { createHash } from "crypto" +import { v5 as uuidv5 } from "uuid" +import type { Ignore } from "ignore" +import type { FileProcessingResult, ICodeParser, IEmbedder, PointStruct } from "../interfaces" +import { MAX_FILE_SIZE_BYTES, QDRANT_CODE_BLOCK_NAMESPACE } from "../constants" +import { generateNormalizedAbsolutePath, generateRelativeFilePath } from "../shared/get-relative-path" +import { isPathInIgnoredDirectory } from "../../glob/ignore-utils" + +export interface FilePreparationDependencies { + workspacePath: string + validateAccess: (filePath: string) => boolean + ignoreInstance?: Pick + stat: (filePath: string) => PromiseLike<{ size: number }> + readFile: (filePath: string) => PromiseLike + getHash: (filePath: string) => string | undefined + parser: ICodeParser + embedder?: Pick +} + +/** Prepares one file for batching without writing points or mutating the hash cache. */ +export async function prepareFile( + filePath: string, + dependencies: FilePreparationDependencies, +): Promise { + try { + // Get relative path for ignore checks + const relativeFilePath = generateRelativeFilePath(filePath, dependencies.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 ( + !dependencies.validateAccess(filePath) || + (dependencies.ignoreInstance && dependencies.ignoreInstance.ignores(relativeFilePath)) + ) { + return { + path: filePath, + status: "skipped" as const, + reason: "File is ignored by .rooignore or .gitignore", + } + } + + // Check file size + const fileStat = await dependencies.stat(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 dependencies.readFile(filePath) + const content = fileContent.toString() + + // Calculate hash + const newHash = createHash("sha256").update(content).digest("hex") + + // Check if file has changed + if (dependencies.getHash(filePath) === newHash) { + return { + path: filePath, + status: "skipped" as const, + reason: "File has not changed", + } + } + + // Parse file + const blocks = await dependencies.parser.parseFile(filePath, { content, fileHash: newHash }) + + // Prepare points for batch processing + let pointsToUpsert: PointStruct[] = [] + if (dependencies.embedder && blocks.length > 0) { + const texts = blocks.map((block) => block.content) + const { embeddings } = await dependencies.embedder.createEmbeddings(texts) + + pointsToUpsert = blocks.map((block, index) => { + const normalizedAbsolutePath = generateNormalizedAbsolutePath( + block.file_path, + dependencies.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, dependencies.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, + } + } +} diff --git a/src/services/code-index/processors/file-watcher.ts b/src/services/code-index/processors/file-watcher.ts index a6a3122c36..1fd4c74465 100644 --- a/src/services/code-index/processors/file-watcher.ts +++ b/src/services/code-index/processors/file-watcher.ts @@ -1,14 +1,6 @@ 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 { BATCH_SEGMENT_THRESHOLD, MAX_BATCH_RETRIES, INITIAL_RETRY_DELAY_MS } from "../constants" import { RooIgnoreController } from "../../../core/ignore/RooIgnoreController" -import { v5 as uuidv5 } from "uuid" import { Ignore } from "ignore" import { scannerExtensions } from "../shared/supported-extensions" import { @@ -20,9 +12,8 @@ import { BatchProcessingSummary, } from "../interfaces" import { codeParser } from "./parser" +import { prepareFile } from "./file-preparation" 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" @@ -507,97 +498,15 @@ export class FileWatcher implements IFileWatcher { * @returns Promise resolving to processing result */ async processFile(filePath: string): Promise { - 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, - } - } + return prepareFile(filePath, { + workspacePath: this.workspacePath, + validateAccess: (path) => this.ignoreController.validateAccess(path), + ignoreInstance: this.ignoreInstance, + stat: (path) => vscode.workspace.fs.stat(vscode.Uri.file(path)), + readFile: (path) => vscode.workspace.fs.readFile(vscode.Uri.file(path)), + getHash: (path) => this.cacheManager.getHash(path), + parser: codeParser, + embedder: this.embedder, + }) } } From 448d7670c80310b48877d56cb51b6279379bfab5 Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Mon, 28 Sep 2026 17:48:40 +0300 Subject: [PATCH 2/8] refactor(code-index): use constructor-injected file preparation class --- .../__tests__/file-preparation.spec.ts | 28 +-- .../processors/__tests__/file-watcher.spec.ts | 18 +- .../code-index/processors/file-preparation.ts | 166 +++++++++--------- .../code-index/processors/file-watcher.ts | 24 +-- 4 files changed, 126 insertions(+), 110 deletions(-) diff --git a/src/services/code-index/processors/__tests__/file-preparation.spec.ts b/src/services/code-index/processors/__tests__/file-preparation.spec.ts index 3184779c3d..a647579b12 100644 --- a/src/services/code-index/processors/__tests__/file-preparation.spec.ts +++ b/src/services/code-index/processors/__tests__/file-preparation.spec.ts @@ -2,9 +2,9 @@ import { createHash } from "crypto" import { v5 as uuidv5 } from "uuid" import type { CodeBlock, ICodeParser, IEmbedder } from "../../interfaces" import { MAX_FILE_SIZE_BYTES, QDRANT_CODE_BLOCK_NAMESPACE } from "../../constants" -import { prepareFile, type FilePreparationDependencies } from "../file-preparation" +import { FilePreparation, type FilePreparationDependencies } from "../file-preparation" -describe("prepareFile", () => { +describe("FilePreparation", () => { const filePath = "/workspace/src/file.ts" const content = "test content" const hash = createHash("sha256").update(content).digest("hex") @@ -41,7 +41,7 @@ describe("prepareFile", () => { async (relativePath) => { const dependencies = setup() const path = `/workspace/${relativePath}` - expect(await prepareFile(path, dependencies)).toEqual({ + expect(await new FilePreparation(dependencies).prepareFile(path)).toEqual({ path, status: "skipped", reason: "File is in an ignored directory", @@ -55,7 +55,7 @@ describe("prepareFile", () => { const dependencies = setup() dependencies.validateAccess.mockReturnValue(source !== "access") dependencies.ignoreInstance.ignores.mockReturnValue(source === "gitignore") - expect(await prepareFile(filePath, dependencies)).toEqual({ + expect(await new FilePreparation(dependencies).prepareFile(filePath)).toEqual({ path: filePath, status: "skipped", reason: "File is ignored by .rooignore or .gitignore", @@ -72,7 +72,7 @@ describe("prepareFile", () => { it("skips oversized files without reading them", async () => { const dependencies = setup() dependencies.stat.mockResolvedValue({ size: MAX_FILE_SIZE_BYTES + 1 }) - expect(await prepareFile(filePath, dependencies)).toEqual({ + expect(await new FilePreparation(dependencies).prepareFile(filePath)).toEqual({ path: filePath, status: "skipped", reason: "File is too large", @@ -83,7 +83,7 @@ describe("prepareFile", () => { it("skips unchanged content before parsing or embedding", async () => { const dependencies = setup() dependencies.getHash.mockReturnValue(hash) - expect(await prepareFile(filePath, dependencies)).toEqual({ + expect(await new FilePreparation(dependencies).prepareFile(filePath)).toEqual({ path: filePath, status: "skipped", reason: "File has not changed", @@ -96,7 +96,7 @@ describe("prepareFile", () => { it("returns the hash and empty points when parsing produces no blocks", async () => { const dependencies = setup() dependencies.parser.parseFile.mockResolvedValue([]) - expect(await prepareFile(filePath, dependencies)).toEqual({ + expect(await new FilePreparation(dependencies).prepareFile(filePath)).toEqual({ path: filePath, status: "processed_for_batching", newHash: hash, @@ -108,7 +108,9 @@ describe("prepareFile", () => { it("still parses and returns the hash without an embedder or gitignore", async () => { const dependencies = setup() expect( - await prepareFile(filePath, { ...dependencies, embedder: undefined, ignoreInstance: undefined }), + await new FilePreparation({ ...dependencies, embedder: undefined, ignoreInstance: undefined }).prepareFile( + filePath, + ), ).toEqual({ path: filePath, status: "processed_for_batching", @@ -131,7 +133,7 @@ describe("prepareFile", () => { [0.3, 0.4], ], }) - expect(await prepareFile(filePath, dependencies)).toEqual({ + expect(await new FilePreparation(dependencies).prepareFile(filePath)).toEqual({ path: filePath, status: "processed_for_batching", newHash: hash, @@ -157,7 +159,7 @@ describe("prepareFile", () => { it("does not treat a hidden workspace ancestor as an excluded directory", async () => { const dependencies = setup() dependencies.workspacePath = "/.hidden/workspace" - expect((await prepareFile("/.hidden/workspace/src/file.ts", dependencies)).status).toBe( + expect((await new FilePreparation(dependencies).prepareFile("/.hidden/workspace/src/file.ts")).status).toBe( "processed_for_batching", ) expect(dependencies.ignoreInstance.ignores).toHaveBeenCalledWith("src/file.ts") @@ -166,7 +168,7 @@ describe("prepareFile", () => { it("preserves Uint8Array toString content conversion", async () => { const dependencies = setup() dependencies.readFile.mockResolvedValue(new Uint8Array([65, 66])) - await prepareFile(filePath, dependencies) + await new FilePreparation(dependencies).prepareFile(filePath) expect(dependencies.parser.parseFile).toHaveBeenCalledWith(filePath, { content: "65,66", fileHash: createHash("sha256").update("65,66").digest("hex"), @@ -188,7 +190,7 @@ describe("prepareFile", () => { if (stage === "cache") dependencies.getHash.mockImplementation(fail) if (stage === "parse") dependencies.parser.parseFile.mockRejectedValue(error) if (stage === "embed") dependencies.embedder.createEmbeddings.mockRejectedValue(error) - const result = await prepareFile(filePath, dependencies) + const result = await new FilePreparation(dependencies).prepareFile(filePath) expect(result).toEqual({ path: filePath, status: "local_error", error }) expect(result.error).toBe(error) }, @@ -197,7 +199,7 @@ describe("prepareFile", () => { it("does not wrap non-Error rejections", async () => { const dependencies = setup() dependencies.readFile.mockRejectedValue("read failed") - expect(await prepareFile(filePath, dependencies)).toEqual({ + expect(await new FilePreparation(dependencies).prepareFile(filePath)).toEqual({ path: filePath, status: "local_error", error: "read failed", diff --git a/src/services/code-index/processors/__tests__/file-watcher.spec.ts b/src/services/code-index/processors/__tests__/file-watcher.spec.ts index 7932b6137c..e636b4764f 100644 --- a/src/services/code-index/processors/__tests__/file-watcher.spec.ts +++ b/src/services/code-index/processors/__tests__/file-watcher.spec.ts @@ -3,7 +3,7 @@ import * as vscode from "vscode" import { FileWatcher } from "../file-watcher" -import * as filePreparation from "../file-preparation" +import { FilePreparation } from "../file-preparation" import { clearAllMocks } from "../../../../test-utils/reset" @@ -183,17 +183,27 @@ describe("FileWatcher", () => { vi.useRealTimers() }) - it("delegates public processFile to preparation without writing points or cache", async () => { - const prepare = vi.spyOn(filePreparation, "prepareFile") + it("reuses constructor-created preparation for public processFile without writing points or cache", async () => { + const prepare = vi.spyOn(FilePreparation.prototype, "prepareFile") try { + const preparation = fileWatcher["filePreparation"] + expect(preparation).toBeInstanceOf(FilePreparation) const path = "/mock/workspace/src/file.ts" const result = await fileWatcher.processFile(path) - expect(prepare).toHaveBeenCalledWith(path, expect.objectContaining({ workspacePath: "/mock/workspace" })) + expect(prepare).toHaveBeenNthCalledWith(1, path) expect(result).toBe(await prepare.mock.results[0].value) expect(result.status).toBe("processed_for_batching") expect(result.pointsToUpsert).toHaveLength(1) expect(vscode.workspace.fs.stat).toHaveBeenCalledWith(vscode.Uri.file(path)) expect(vscode.workspace.fs.readFile).toHaveBeenCalledWith(vscode.Uri.file(path)) + const secondPath = "/mock/workspace/src/second.ts" + const secondResult = await fileWatcher.processFile(secondPath) + expect(prepare).toHaveBeenCalledTimes(2) + expect(prepare).toHaveBeenNthCalledWith(2, secondPath) + expect(prepare.mock.contexts[0]).toBe(preparation) + expect(prepare.mock.contexts[1]).toBe(preparation) + expect(secondResult).toBe(await prepare.mock.results[1].value) + expect(secondResult.status).toBe("processed_for_batching") expect(mockVectorStore.upsertPoints).not.toHaveBeenCalled() expect(mockCacheManager.updateHash).not.toHaveBeenCalled() expect(mockCacheManager.deleteHash).not.toHaveBeenCalled() diff --git a/src/services/code-index/processors/file-preparation.ts b/src/services/code-index/processors/file-preparation.ts index a0ea14412d..de061072a4 100644 --- a/src/services/code-index/processors/file-preparation.ts +++ b/src/services/code-index/processors/file-preparation.ts @@ -18,103 +18,105 @@ export interface FilePreparationDependencies { } /** Prepares one file for batching without writing points or mutating the hash cache. */ -export async function prepareFile( - filePath: string, - dependencies: FilePreparationDependencies, -): Promise { - try { - // Get relative path for ignore checks - const relativeFilePath = generateRelativeFilePath(filePath, dependencies.workspacePath) +export class FilePreparation { + constructor(private readonly dependencies: FilePreparationDependencies) {} - // 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", + public async prepareFile(filePath: string): Promise { + const dependencies = this.dependencies + try { + // Get relative path for ignore checks + const relativeFilePath = generateRelativeFilePath(filePath, dependencies.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 ( - !dependencies.validateAccess(filePath) || - (dependencies.ignoreInstance && dependencies.ignoreInstance.ignores(relativeFilePath)) - ) { - return { - path: filePath, - status: "skipped" as const, - reason: "File is ignored by .rooignore or .gitignore", + // Check if file should be ignored + if ( + !dependencies.validateAccess(filePath) || + (dependencies.ignoreInstance && dependencies.ignoreInstance.ignores(relativeFilePath)) + ) { + return { + path: filePath, + status: "skipped" as const, + reason: "File is ignored by .rooignore or .gitignore", + } } - } - // Check file size - const fileStat = await dependencies.stat(filePath) - if (fileStat.size > MAX_FILE_SIZE_BYTES) { - return { - path: filePath, - status: "skipped" as const, - reason: "File is too large", + // Check file size + const fileStat = await dependencies.stat(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 dependencies.readFile(filePath) - const content = fileContent.toString() + // Read file content + const fileContent = await dependencies.readFile(filePath) + const content = fileContent.toString() - // Calculate hash - const newHash = createHash("sha256").update(content).digest("hex") + // Calculate hash + const newHash = createHash("sha256").update(content).digest("hex") - // Check if file has changed - if (dependencies.getHash(filePath) === newHash) { - return { - path: filePath, - status: "skipped" as const, - reason: "File has not changed", + // Check if file has changed + if (dependencies.getHash(filePath) === newHash) { + return { + path: filePath, + status: "skipped" as const, + reason: "File has not changed", + } } - } - // Parse file - const blocks = await dependencies.parser.parseFile(filePath, { content, fileHash: newHash }) + // Parse file + const blocks = await dependencies.parser.parseFile(filePath, { content, fileHash: newHash }) - // Prepare points for batch processing - let pointsToUpsert: PointStruct[] = [] - if (dependencies.embedder && blocks.length > 0) { - const texts = blocks.map((block) => block.content) - const { embeddings } = await dependencies.embedder.createEmbeddings(texts) + // Prepare points for batch processing + let pointsToUpsert: PointStruct[] = [] + if (dependencies.embedder && blocks.length > 0) { + const texts = blocks.map((block) => block.content) + const { embeddings } = await dependencies.embedder.createEmbeddings(texts) - pointsToUpsert = blocks.map((block, index) => { - const normalizedAbsolutePath = generateNormalizedAbsolutePath( - block.file_path, - dependencies.workspacePath, - ) - const stableName = `${normalizedAbsolutePath}:${block.start_line}` - const pointId = uuidv5(stableName, QDRANT_CODE_BLOCK_NAMESPACE) + pointsToUpsert = blocks.map((block, index) => { + const normalizedAbsolutePath = generateNormalizedAbsolutePath( + block.file_path, + dependencies.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, dependencies.workspacePath), - codeChunk: block.content, - startLine: block.start_line, - endLine: block.end_line, - }, - } - }) - } + return { + id: pointId, + vector: embeddings[index], + payload: { + filePath: generateRelativeFilePath(normalizedAbsolutePath, dependencies.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, + 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, + } } } } diff --git a/src/services/code-index/processors/file-watcher.ts b/src/services/code-index/processors/file-watcher.ts index 1fd4c74465..44f9976053 100644 --- a/src/services/code-index/processors/file-watcher.ts +++ b/src/services/code-index/processors/file-watcher.ts @@ -12,7 +12,7 @@ import { BatchProcessingSummary, } from "../interfaces" import { codeParser } from "./parser" -import { prepareFile } from "./file-preparation" +import { FilePreparation } from "./file-preparation" import { CacheManager } from "../cache-manager" import { TelemetryService } from "@roo-code/telemetry" import { TelemetryEventName } from "@roo-code/types" @@ -23,6 +23,7 @@ import { Package } from "../../../shared/package" * Implementation of the file watcher interface */ export class FileWatcher implements IFileWatcher { + private readonly filePreparation: FilePreparation private ignoreInstance?: Ignore private fileWatcher?: vscode.FileSystemWatcher private ignoreController: RooIgnoreController @@ -77,6 +78,16 @@ export class FileWatcher implements IFileWatcher { if (ignoreInstance) { this.ignoreInstance = ignoreInstance } + this.filePreparation = new FilePreparation({ + workspacePath: this.workspacePath, + validateAccess: (path) => this.ignoreController.validateAccess(path), + ignoreInstance: this.ignoreInstance, + stat: (path) => vscode.workspace.fs.stat(vscode.Uri.file(path)), + readFile: (path) => vscode.workspace.fs.readFile(vscode.Uri.file(path)), + getHash: (path) => this.cacheManager.getHash(path), + parser: codeParser, + embedder: this.embedder, + }) // 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) { @@ -498,15 +509,6 @@ export class FileWatcher implements IFileWatcher { * @returns Promise resolving to processing result */ async processFile(filePath: string): Promise { - return prepareFile(filePath, { - workspacePath: this.workspacePath, - validateAccess: (path) => this.ignoreController.validateAccess(path), - ignoreInstance: this.ignoreInstance, - stat: (path) => vscode.workspace.fs.stat(vscode.Uri.file(path)), - readFile: (path) => vscode.workspace.fs.readFile(vscode.Uri.file(path)), - getHash: (path) => this.cacheManager.getHash(path), - parser: codeParser, - embedder: this.embedder, - }) + return this.filePreparation.prepareFile(filePath) } } From c2ec63820c87c574cb953d301669bbb8d1dd887e Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Mon, 28 Sep 2026 17:52:31 +0300 Subject: [PATCH 3/8] refactor(code-index): separate file preparation dependencies --- .../processors/__tests__/file-preparation.spec.ts | 3 ++- .../processors/file-preparation-dependencies.ts | 13 +++++++++++++ .../code-index/processors/file-preparation.ts | 15 ++------------- 3 files changed, 17 insertions(+), 14 deletions(-) create mode 100644 src/services/code-index/processors/file-preparation-dependencies.ts diff --git a/src/services/code-index/processors/__tests__/file-preparation.spec.ts b/src/services/code-index/processors/__tests__/file-preparation.spec.ts index a647579b12..3f953b9554 100644 --- a/src/services/code-index/processors/__tests__/file-preparation.spec.ts +++ b/src/services/code-index/processors/__tests__/file-preparation.spec.ts @@ -2,7 +2,8 @@ import { createHash } from "crypto" import { v5 as uuidv5 } from "uuid" import type { CodeBlock, ICodeParser, IEmbedder } from "../../interfaces" import { MAX_FILE_SIZE_BYTES, QDRANT_CODE_BLOCK_NAMESPACE } from "../../constants" -import { FilePreparation, type FilePreparationDependencies } from "../file-preparation" +import { FilePreparation } from "../file-preparation" +import type { FilePreparationDependencies } from "../file-preparation-dependencies" describe("FilePreparation", () => { const filePath = "/workspace/src/file.ts" diff --git a/src/services/code-index/processors/file-preparation-dependencies.ts b/src/services/code-index/processors/file-preparation-dependencies.ts new file mode 100644 index 0000000000..378306fc98 --- /dev/null +++ b/src/services/code-index/processors/file-preparation-dependencies.ts @@ -0,0 +1,13 @@ +import type { Ignore } from "ignore" +import type { ICodeParser, IEmbedder } from "../interfaces" + +export interface FilePreparationDependencies { + workspacePath: string + validateAccess: (filePath: string) => boolean + ignoreInstance?: Pick + stat: (filePath: string) => PromiseLike<{ size: number }> + readFile: (filePath: string) => PromiseLike + getHash: (filePath: string) => string | undefined + parser: ICodeParser + embedder?: Pick +} diff --git a/src/services/code-index/processors/file-preparation.ts b/src/services/code-index/processors/file-preparation.ts index de061072a4..a2d7cc4cd1 100644 --- a/src/services/code-index/processors/file-preparation.ts +++ b/src/services/code-index/processors/file-preparation.ts @@ -1,22 +1,11 @@ import { createHash } from "crypto" import { v5 as uuidv5 } from "uuid" -import type { Ignore } from "ignore" -import type { FileProcessingResult, ICodeParser, IEmbedder, PointStruct } from "../interfaces" +import type { FileProcessingResult, PointStruct } from "../interfaces" +import type { FilePreparationDependencies } from "./file-preparation-dependencies" import { MAX_FILE_SIZE_BYTES, QDRANT_CODE_BLOCK_NAMESPACE } from "../constants" import { generateNormalizedAbsolutePath, generateRelativeFilePath } from "../shared/get-relative-path" import { isPathInIgnoredDirectory } from "../../glob/ignore-utils" -export interface FilePreparationDependencies { - workspacePath: string - validateAccess: (filePath: string) => boolean - ignoreInstance?: Pick - stat: (filePath: string) => PromiseLike<{ size: number }> - readFile: (filePath: string) => PromiseLike - getHash: (filePath: string) => string | undefined - parser: ICodeParser - embedder?: Pick -} - /** Prepares one file for batching without writing points or mutating the hash cache. */ export class FilePreparation { constructor(private readonly dependencies: FilePreparationDependencies) {} From 3d1d6430d1e16917438e3bbbe9b3cd7d161348b5 Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Mon, 28 Sep 2026 18:09:22 +0300 Subject: [PATCH 4/8] test(code-index): cover platform-specific file preparation paths --- .../__tests__/file-preparation.spec.ts | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/services/code-index/processors/__tests__/file-preparation.spec.ts b/src/services/code-index/processors/__tests__/file-preparation.spec.ts index 3f953b9554..22716d4997 100644 --- a/src/services/code-index/processors/__tests__/file-preparation.spec.ts +++ b/src/services/code-index/processors/__tests__/file-preparation.spec.ts @@ -1,12 +1,29 @@ import { createHash } from "crypto" +import path from "path" import { v5 as uuidv5 } from "uuid" import type { CodeBlock, ICodeParser, IEmbedder } from "../../interfaces" import { MAX_FILE_SIZE_BYTES, QDRANT_CODE_BLOCK_NAMESPACE } from "../../constants" import { FilePreparation } from "../file-preparation" import type { FilePreparationDependencies } from "../file-preparation-dependencies" -describe("FilePreparation", () => { +// Keep path spies local to the modules under test, not Node or the test runner. +vi.mock("path", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, default: { ...actual } } +}) + +describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platform) => { + beforeEach(() => { + vi.spyOn(path, "resolve").mockImplementation(path[platform].resolve) + vi.spyOn(path, "relative").mockImplementation(path[platform].relative) + vi.spyOn(path, "normalize").mockImplementation(path[platform].normalize) + }) + + afterEach(() => vi.restoreAllMocks()) + const filePath = "/workspace/src/file.ts" + const relativeFilePath = path[platform].join("src", "file.ts") + const normalizedFilePath = path[platform].resolve(filePath) const content = "test content" const hash = createHash("sha256").update(content).digest("hex") const block: CodeBlock = { @@ -65,7 +82,7 @@ describe("FilePreparation", () => { if (source === "access") { expect(dependencies.ignoreInstance.ignores).not.toHaveBeenCalled() } else { - expect(dependencies.ignoreInstance.ignores).toHaveBeenCalledWith("src/file.ts") + expect(dependencies.ignoreInstance.ignores).toHaveBeenCalledWith(relativeFilePath) } expect(dependencies.stat).not.toHaveBeenCalled() }) @@ -140,14 +157,14 @@ describe("FilePreparation", () => { newHash: hash, pointsToUpsert: [ { - id: uuidv5(`${filePath}:2`, QDRANT_CODE_BLOCK_NAMESPACE), + id: uuidv5(`${normalizedFilePath}:2`, QDRANT_CODE_BLOCK_NAMESPACE), vector: [0.1, 0.2], - payload: { filePath: "src/file.ts", codeChunk: content, startLine: 2, endLine: 5 }, + payload: { filePath: relativeFilePath, codeChunk: content, startLine: 2, endLine: 5 }, }, { - id: uuidv5(`${filePath}:8`, QDRANT_CODE_BLOCK_NAMESPACE), + id: uuidv5(`${normalizedFilePath}:8`, QDRANT_CODE_BLOCK_NAMESPACE), vector: [0.3, 0.4], - payload: { filePath: "src/file.ts", codeChunk: "second", startLine: 8, endLine: 10 }, + payload: { filePath: relativeFilePath, codeChunk: "second", startLine: 8, endLine: 10 }, }, ], }) @@ -163,7 +180,7 @@ describe("FilePreparation", () => { expect((await new FilePreparation(dependencies).prepareFile("/.hidden/workspace/src/file.ts")).status).toBe( "processed_for_batching", ) - expect(dependencies.ignoreInstance.ignores).toHaveBeenCalledWith("src/file.ts") + expect(dependencies.ignoreInstance.ignores).toHaveBeenCalledWith(relativeFilePath) }) it("preserves Uint8Array toString content conversion", async () => { From a68d5187f7a3ac85d38c522ef2fffd762922aeaa Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Mon, 28 Sep 2026 22:40:57 +0300 Subject: [PATCH 5/8] refactor(code-index): inject preparation service objects --- .../__tests__/file-preparation.spec.ts | 74 +++++++++++++------ .../processors/__tests__/file-watcher.spec.ts | 3 + .../file-preparation-dependencies.ts | 10 ++- .../code-index/processors/file-preparation.ts | 9 ++- .../code-index/processors/file-watcher.ts | 7 +- 5 files changed, 69 insertions(+), 34 deletions(-) diff --git a/src/services/code-index/processors/__tests__/file-preparation.spec.ts b/src/services/code-index/processors/__tests__/file-preparation.spec.ts index 22716d4997..9d18e68bfd 100644 --- a/src/services/code-index/processors/__tests__/file-preparation.spec.ts +++ b/src/services/code-index/processors/__tests__/file-preparation.spec.ts @@ -1,5 +1,6 @@ import { createHash } from "crypto" import path from "path" +import { FileType, Uri } from "vscode" import { v5 as uuidv5 } from "uuid" import type { CodeBlock, ICodeParser, IEmbedder } from "../../interfaces" import { MAX_FILE_SIZE_BYTES, QDRANT_CODE_BLOCK_NAMESPACE } from "../../constants" @@ -40,11 +41,21 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo function setup() { return { workspacePath: "/workspace", - validateAccess: vi.fn<(path: string) => boolean>().mockReturnValue(true), + ignoreController: { + validateAccess: vi + .fn() + .mockReturnValue(true), + }, ignoreInstance: { ignores: vi.fn<(path: string) => boolean>().mockReturnValue(false) }, - stat: vi.fn().mockResolvedValue({ size: 100 }), - readFile: vi.fn().mockResolvedValue(Buffer.from(content)), - getHash: vi.fn(), + fileSystem: { + stat: vi + .fn() + .mockResolvedValue({ type: FileType.File, ctime: 0, mtime: 0, size: 100 }), + readFile: vi + .fn() + .mockResolvedValue(Buffer.from(content)), + }, + cacheManager: { getHash: vi.fn() }, parser: { parseFile: vi.fn().mockResolvedValue([{ ...block }]) }, embedder: { createEmbeddings: vi @@ -64,49 +75,54 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo status: "skipped", reason: "File is in an ignored directory", }) - expect(dependencies.validateAccess).not.toHaveBeenCalled() - expect(dependencies.stat).not.toHaveBeenCalled() + expect(dependencies.ignoreController.validateAccess).not.toHaveBeenCalled() + expect(dependencies.fileSystem.stat).not.toHaveBeenCalled() }, ) it.each(["access", "gitignore"])("skips %s exclusions before reading", async (source) => { const dependencies = setup() - dependencies.validateAccess.mockReturnValue(source !== "access") + dependencies.ignoreController.validateAccess.mockReturnValue(source !== "access") dependencies.ignoreInstance.ignores.mockReturnValue(source === "gitignore") expect(await new FilePreparation(dependencies).prepareFile(filePath)).toEqual({ path: filePath, status: "skipped", reason: "File is ignored by .rooignore or .gitignore", }) - expect(dependencies.validateAccess).toHaveBeenCalledWith(filePath) + expect(dependencies.ignoreController.validateAccess).toHaveBeenCalledWith(filePath) if (source === "access") { expect(dependencies.ignoreInstance.ignores).not.toHaveBeenCalled() } else { expect(dependencies.ignoreInstance.ignores).toHaveBeenCalledWith(relativeFilePath) } - expect(dependencies.stat).not.toHaveBeenCalled() + expect(dependencies.fileSystem.stat).not.toHaveBeenCalled() }) it("skips oversized files without reading them", async () => { const dependencies = setup() - dependencies.stat.mockResolvedValue({ size: MAX_FILE_SIZE_BYTES + 1 }) + dependencies.fileSystem.stat.mockResolvedValue({ + type: FileType.File, + ctime: 0, + mtime: 0, + size: MAX_FILE_SIZE_BYTES + 1, + }) expect(await new FilePreparation(dependencies).prepareFile(filePath)).toEqual({ path: filePath, status: "skipped", reason: "File is too large", }) - expect(dependencies.readFile).not.toHaveBeenCalled() + expect(dependencies.fileSystem.readFile).not.toHaveBeenCalled() }) it("skips unchanged content before parsing or embedding", async () => { const dependencies = setup() - dependencies.getHash.mockReturnValue(hash) + dependencies.cacheManager.getHash.mockReturnValue(hash) expect(await new FilePreparation(dependencies).prepareFile(filePath)).toEqual({ path: filePath, status: "skipped", reason: "File has not changed", }) - expect(dependencies.getHash).toHaveBeenCalledWith(filePath) + expect(dependencies.cacheManager.getHash).toHaveBeenCalledWith(filePath) expect(dependencies.parser.parseFile).not.toHaveBeenCalled() expect(dependencies.embedder.createEmbeddings).not.toHaveBeenCalled() }) @@ -140,7 +156,12 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo it("preserves normalized paths, stable IDs and embedding order at the size limit", async () => { const dependencies = setup() - dependencies.stat.mockResolvedValue({ size: MAX_FILE_SIZE_BYTES }) + dependencies.fileSystem.stat.mockResolvedValue({ + type: FileType.File, + ctime: 0, + mtime: 0, + size: MAX_FILE_SIZE_BYTES, + }) dependencies.parser.parseFile.mockResolvedValue([ { ...block, file_path: "src/../src/file.ts" }, { ...block, start_line: 8, end_line: 10, content: "second" }, @@ -168,12 +189,21 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo }, ], }) - expect(dependencies.stat).toHaveBeenCalledWith(filePath) - expect(dependencies.readFile).toHaveBeenCalledWith(filePath) + expect(dependencies.fileSystem.stat).toHaveBeenCalledWith(Uri.file(filePath)) + expect(dependencies.fileSystem.readFile).toHaveBeenCalledWith(Uri.file(filePath)) expect(dependencies.parser.parseFile).toHaveBeenCalledWith(filePath, { content, fileHash: hash }) expect(dependencies.embedder.createEmbeddings).toHaveBeenCalledWith([content, "second"]) }) + it("calls service methods with their original receivers", async () => { + const dependencies = setup() + expect((await new FilePreparation(dependencies).prepareFile(filePath)).status).toBe("processed_for_batching") + expect(dependencies.ignoreController.validateAccess.mock.contexts).toEqual([dependencies.ignoreController]) + expect(dependencies.fileSystem.stat.mock.contexts).toEqual([dependencies.fileSystem]) + expect(dependencies.fileSystem.readFile.mock.contexts).toEqual([dependencies.fileSystem]) + expect(dependencies.cacheManager.getHash.mock.contexts).toEqual([dependencies.cacheManager]) + }) + it("does not treat a hidden workspace ancestor as an excluded directory", async () => { const dependencies = setup() dependencies.workspacePath = "/.hidden/workspace" @@ -185,7 +215,7 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo it("preserves Uint8Array toString content conversion", async () => { const dependencies = setup() - dependencies.readFile.mockResolvedValue(new Uint8Array([65, 66])) + dependencies.fileSystem.readFile.mockResolvedValue(new Uint8Array([65, 66])) await new FilePreparation(dependencies).prepareFile(filePath) expect(dependencies.parser.parseFile).toHaveBeenCalledWith(filePath, { content: "65,66", @@ -201,11 +231,11 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo const fail = () => { throw error } - if (stage === "access") dependencies.validateAccess.mockImplementation(fail) + if (stage === "access") dependencies.ignoreController.validateAccess.mockImplementation(fail) if (stage === "ignore") dependencies.ignoreInstance.ignores.mockImplementation(fail) - if (stage === "stat") dependencies.stat.mockRejectedValue(error) - if (stage === "read") dependencies.readFile.mockRejectedValue(error) - if (stage === "cache") dependencies.getHash.mockImplementation(fail) + if (stage === "stat") dependencies.fileSystem.stat.mockRejectedValue(error) + if (stage === "read") dependencies.fileSystem.readFile.mockRejectedValue(error) + if (stage === "cache") dependencies.cacheManager.getHash.mockImplementation(fail) if (stage === "parse") dependencies.parser.parseFile.mockRejectedValue(error) if (stage === "embed") dependencies.embedder.createEmbeddings.mockRejectedValue(error) const result = await new FilePreparation(dependencies).prepareFile(filePath) @@ -216,7 +246,7 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo it("does not wrap non-Error rejections", async () => { const dependencies = setup() - dependencies.readFile.mockRejectedValue("read failed") + dependencies.fileSystem.readFile.mockRejectedValue("read failed") expect(await new FilePreparation(dependencies).prepareFile(filePath)).toEqual({ path: filePath, status: "local_error", diff --git a/src/services/code-index/processors/__tests__/file-watcher.spec.ts b/src/services/code-index/processors/__tests__/file-watcher.spec.ts index e636b4764f..736cca0806 100644 --- a/src/services/code-index/processors/__tests__/file-watcher.spec.ts +++ b/src/services/code-index/processors/__tests__/file-watcher.spec.ts @@ -188,6 +188,9 @@ describe("FileWatcher", () => { try { const preparation = fileWatcher["filePreparation"] expect(preparation).toBeInstanceOf(FilePreparation) + expect(preparation["dependencies"].fileSystem).toBe(vscode.workspace.fs) + expect(preparation["dependencies"].cacheManager).toBe(mockCacheManager) + expect(preparation["dependencies"].ignoreController).toBe(fileWatcher["ignoreController"]) const path = "/mock/workspace/src/file.ts" const result = await fileWatcher.processFile(path) expect(prepare).toHaveBeenNthCalledWith(1, path) diff --git a/src/services/code-index/processors/file-preparation-dependencies.ts b/src/services/code-index/processors/file-preparation-dependencies.ts index 378306fc98..001da428d7 100644 --- a/src/services/code-index/processors/file-preparation-dependencies.ts +++ b/src/services/code-index/processors/file-preparation-dependencies.ts @@ -1,13 +1,15 @@ import type { Ignore } from "ignore" +import type { FileSystem } from "vscode" +import type { RooIgnoreController } from "../../../core/ignore/RooIgnoreController" +import type { CacheManager } from "../cache-manager" import type { ICodeParser, IEmbedder } from "../interfaces" export interface FilePreparationDependencies { workspacePath: string - validateAccess: (filePath: string) => boolean + ignoreController: Pick ignoreInstance?: Pick - stat: (filePath: string) => PromiseLike<{ size: number }> - readFile: (filePath: string) => PromiseLike - getHash: (filePath: string) => string | undefined + fileSystem: Pick + cacheManager: Pick parser: ICodeParser embedder?: Pick } diff --git a/src/services/code-index/processors/file-preparation.ts b/src/services/code-index/processors/file-preparation.ts index a2d7cc4cd1..d30e755e91 100644 --- a/src/services/code-index/processors/file-preparation.ts +++ b/src/services/code-index/processors/file-preparation.ts @@ -1,4 +1,5 @@ import { createHash } from "crypto" +import { Uri } from "vscode" import { v5 as uuidv5 } from "uuid" import type { FileProcessingResult, PointStruct } from "../interfaces" import type { FilePreparationDependencies } from "./file-preparation-dependencies" @@ -28,7 +29,7 @@ export class FilePreparation { // Check if file should be ignored if ( - !dependencies.validateAccess(filePath) || + !dependencies.ignoreController.validateAccess(filePath) || (dependencies.ignoreInstance && dependencies.ignoreInstance.ignores(relativeFilePath)) ) { return { @@ -39,7 +40,7 @@ export class FilePreparation { } // Check file size - const fileStat = await dependencies.stat(filePath) + const fileStat = await dependencies.fileSystem.stat(Uri.file(filePath)) if (fileStat.size > MAX_FILE_SIZE_BYTES) { return { path: filePath, @@ -49,14 +50,14 @@ export class FilePreparation { } // Read file content - const fileContent = await dependencies.readFile(filePath) + const fileContent = await dependencies.fileSystem.readFile(Uri.file(filePath)) const content = fileContent.toString() // Calculate hash const newHash = createHash("sha256").update(content).digest("hex") // Check if file has changed - if (dependencies.getHash(filePath) === newHash) { + if (dependencies.cacheManager.getHash(filePath) === newHash) { return { path: filePath, status: "skipped" as const, diff --git a/src/services/code-index/processors/file-watcher.ts b/src/services/code-index/processors/file-watcher.ts index 44f9976053..933947bbe2 100644 --- a/src/services/code-index/processors/file-watcher.ts +++ b/src/services/code-index/processors/file-watcher.ts @@ -80,11 +80,10 @@ export class FileWatcher implements IFileWatcher { } this.filePreparation = new FilePreparation({ workspacePath: this.workspacePath, - validateAccess: (path) => this.ignoreController.validateAccess(path), + ignoreController: this.ignoreController, ignoreInstance: this.ignoreInstance, - stat: (path) => vscode.workspace.fs.stat(vscode.Uri.file(path)), - readFile: (path) => vscode.workspace.fs.readFile(vscode.Uri.file(path)), - getHash: (path) => this.cacheManager.getHash(path), + fileSystem: vscode.workspace.fs, + cacheManager: this.cacheManager, parser: codeParser, embedder: this.embedder, }) From f694a6ca18ac50031a968e8f4983a00f8be620d7 Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Mon, 28 Sep 2026 22:55:00 +0300 Subject: [PATCH 6/8] refactor(code-index): simplify file preparation stages --- .../__tests__/file-preparation.spec.ts | 15 ++ .../code-index/processors/file-preparation.ts | 134 ++++++++---------- 2 files changed, 76 insertions(+), 73 deletions(-) diff --git a/src/services/code-index/processors/__tests__/file-preparation.spec.ts b/src/services/code-index/processors/__tests__/file-preparation.spec.ts index 9d18e68bfd..879cab876a 100644 --- a/src/services/code-index/processors/__tests__/file-preparation.spec.ts +++ b/src/services/code-index/processors/__tests__/file-preparation.spec.ts @@ -76,7 +76,12 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo reason: "File is in an ignored directory", }) expect(dependencies.ignoreController.validateAccess).not.toHaveBeenCalled() + expect(dependencies.ignoreInstance.ignores).not.toHaveBeenCalled() expect(dependencies.fileSystem.stat).not.toHaveBeenCalled() + expect(dependencies.fileSystem.readFile).not.toHaveBeenCalled() + expect(dependencies.cacheManager.getHash).not.toHaveBeenCalled() + expect(dependencies.parser.parseFile).not.toHaveBeenCalled() + expect(dependencies.embedder.createEmbeddings).not.toHaveBeenCalled() }, ) @@ -96,6 +101,10 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo expect(dependencies.ignoreInstance.ignores).toHaveBeenCalledWith(relativeFilePath) } expect(dependencies.fileSystem.stat).not.toHaveBeenCalled() + expect(dependencies.fileSystem.readFile).not.toHaveBeenCalled() + expect(dependencies.cacheManager.getHash).not.toHaveBeenCalled() + expect(dependencies.parser.parseFile).not.toHaveBeenCalled() + expect(dependencies.embedder.createEmbeddings).not.toHaveBeenCalled() }) it("skips oversized files without reading them", async () => { @@ -112,6 +121,9 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo reason: "File is too large", }) expect(dependencies.fileSystem.readFile).not.toHaveBeenCalled() + expect(dependencies.cacheManager.getHash).not.toHaveBeenCalled() + expect(dependencies.parser.parseFile).not.toHaveBeenCalled() + expect(dependencies.embedder.createEmbeddings).not.toHaveBeenCalled() }) it("skips unchanged content before parsing or embedding", async () => { @@ -199,9 +211,12 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo const dependencies = setup() expect((await new FilePreparation(dependencies).prepareFile(filePath)).status).toBe("processed_for_batching") expect(dependencies.ignoreController.validateAccess.mock.contexts).toEqual([dependencies.ignoreController]) + expect(dependencies.ignoreInstance.ignores.mock.contexts).toEqual([dependencies.ignoreInstance]) expect(dependencies.fileSystem.stat.mock.contexts).toEqual([dependencies.fileSystem]) expect(dependencies.fileSystem.readFile.mock.contexts).toEqual([dependencies.fileSystem]) expect(dependencies.cacheManager.getHash.mock.contexts).toEqual([dependencies.cacheManager]) + expect(dependencies.parser.parseFile.mock.contexts).toEqual([dependencies.parser]) + expect(dependencies.embedder.createEmbeddings.mock.contexts).toEqual([dependencies.embedder]) }) it("does not treat a hidden workspace ancestor as an excluded directory", async () => { diff --git a/src/services/code-index/processors/file-preparation.ts b/src/services/code-index/processors/file-preparation.ts index d30e755e91..2876fca672 100644 --- a/src/services/code-index/processors/file-preparation.ts +++ b/src/services/code-index/processors/file-preparation.ts @@ -1,7 +1,7 @@ import { createHash } from "crypto" import { Uri } from "vscode" import { v5 as uuidv5 } from "uuid" -import type { FileProcessingResult, PointStruct } from "../interfaces" +import type { CodeBlock, FileProcessingResult, PointStruct } from "../interfaces" import type { FilePreparationDependencies } from "./file-preparation-dependencies" import { MAX_FILE_SIZE_BYTES, QDRANT_CODE_BLOCK_NAMESPACE } from "../constants" import { generateNormalizedAbsolutePath, generateRelativeFilePath } from "../shared/get-relative-path" @@ -14,99 +14,87 @@ export class FilePreparation { public async prepareFile(filePath: string): Promise { const dependencies = this.dependencies try { - // Get relative path for ignore checks - const relativeFilePath = generateRelativeFilePath(filePath, dependencies.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 ( - !dependencies.ignoreController.validateAccess(filePath) || - (dependencies.ignoreInstance && dependencies.ignoreInstance.ignores(relativeFilePath)) - ) { - return { - path: filePath, - status: "skipped" as const, - reason: "File is ignored by .rooignore or .gitignore", - } - } - - // Check file size - const fileStat = await dependencies.fileSystem.stat(Uri.file(filePath)) - if (fileStat.size > MAX_FILE_SIZE_BYTES) { - return { - path: filePath, - status: "skipped" as const, - reason: "File is too large", - } + const skipReason = await this.getSkipReason(filePath) + if (skipReason !== undefined) { + return this.skippedResult(filePath, skipReason) } - // Read file content const fileContent = await dependencies.fileSystem.readFile(Uri.file(filePath)) const content = fileContent.toString() - - // Calculate hash const newHash = createHash("sha256").update(content).digest("hex") - // Check if file has changed if (dependencies.cacheManager.getHash(filePath) === newHash) { - return { - path: filePath, - status: "skipped" as const, - reason: "File has not changed", - } + return this.skippedResult(filePath, "File has not changed") } - // Parse file const blocks = await dependencies.parser.parseFile(filePath, { content, fileHash: newHash }) - - // Prepare points for batch processing - let pointsToUpsert: PointStruct[] = [] - if (dependencies.embedder && blocks.length > 0) { - const texts = blocks.map((block) => block.content) - const { embeddings } = await dependencies.embedder.createEmbeddings(texts) - - pointsToUpsert = blocks.map((block, index) => { - const normalizedAbsolutePath = generateNormalizedAbsolutePath( - block.file_path, - dependencies.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, dependencies.workspacePath), - codeChunk: block.content, - startLine: block.start_line, - endLine: block.end_line, - }, - } - }) - } + const pointsToUpsert = await this.preparePoints(blocks) return { path: filePath, - status: "processed_for_batching" as const, + status: "processed_for_batching", newHash, pointsToUpsert, } } catch (error) { return { path: filePath, - status: "local_error" as const, + status: "local_error", error: error as Error, } } } + + private async getSkipReason(filePath: string): Promise { + const dependencies = this.dependencies + // Use relative paths so ignored directories outside the workspace do not exclude the file. + const relativeFilePath = generateRelativeFilePath(filePath, dependencies.workspacePath) + if (isPathInIgnoredDirectory(relativeFilePath)) { + return "File is in an ignored directory" + } + + if ( + !dependencies.ignoreController.validateAccess(filePath) || + dependencies.ignoreInstance?.ignores(relativeFilePath) + ) { + return "File is ignored by .rooignore or .gitignore" + } + + const fileStat = await dependencies.fileSystem.stat(Uri.file(filePath)) + if (fileStat.size > MAX_FILE_SIZE_BYTES) { + return "File is too large" + } + return undefined + } + + private skippedResult(filePath: string, reason: string): FileProcessingResult { + return { path: filePath, status: "skipped", reason } + } + + private async preparePoints(blocks: CodeBlock[]): Promise { + const dependencies = this.dependencies + if (!dependencies.embedder || blocks.length === 0) { + return [] + } + + const texts = blocks.map((block) => block.content) + const { embeddings } = await dependencies.embedder.createEmbeddings(texts) + + return blocks.map((block, index) => { + const normalizedAbsolutePath = generateNormalizedAbsolutePath(block.file_path, dependencies.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, dependencies.workspacePath), + codeChunk: block.content, + startLine: block.start_line, + endLine: block.end_line, + }, + } + }) + } } From 2debdc8043937bc57cfdacd6c33a7f9b43387d04 Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Mon, 28 Sep 2026 23:25:03 +0300 Subject: [PATCH 7/8] fix(code-index): decode prepared file bytes as UTF-8 --- .../__tests__/file-preparation.spec.ts | 63 ++++++++++++++++--- .../code-index/processors/file-preparation.ts | 2 +- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/src/services/code-index/processors/__tests__/file-preparation.spec.ts b/src/services/code-index/processors/__tests__/file-preparation.spec.ts index 879cab876a..d07a2a9a5a 100644 --- a/src/services/code-index/processors/__tests__/file-preparation.spec.ts +++ b/src/services/code-index/processors/__tests__/file-preparation.spec.ts @@ -228,13 +228,62 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo expect(dependencies.ignoreInstance.ignores).toHaveBeenCalledWith(relativeFilePath) }) - it("preserves Uint8Array toString content conversion", async () => { - const dependencies = setup() - dependencies.fileSystem.readFile.mockResolvedValue(new Uint8Array([65, 66])) - await new FilePreparation(dependencies).prepareFile(filePath) - expect(dependencies.parser.parseFile).toHaveBeenCalledWith(filePath, { - content: "65,66", - fileHash: createHash("sha256").update("65,66").digest("hex"), + describe.each(["Buffer", "Uint8Array"] as const)("UTF-8 decoding from %s", (representation) => { + it.each([ + { name: "ASCII", bytes: [65, 66], expectedContent: "AB" }, + { + name: "Cyrillic, Spanish, CJK and emoji", + bytes: [...Buffer.from("Привет, español 中文 😀", "utf-8")], + expectedContent: "Привет, español 中文 😀", + }, + { name: "preserved BOM", bytes: [0xef, 0xbb, 0xbf, 65, 66], expectedContent: "\uFEFFAB" }, + { name: "empty content", bytes: [], expectedContent: "" }, + { + name: "invalid and truncated UTF-8 sequences", + bytes: [65, 0xc3, 0x28, 0xff, 0xe2, 0x82], + expectedContent: "A\uFFFD(\uFFFD\uFFFD", + }, + { + name: "sliced view with nonzero byteOffset", + bytes: [0x58, 65, 0xc3, 0xb1, 0x59], + expectedContent: "Añ", + sliced: true, + }, + ])("decodes and hashes $name, then skips the cached content", async ({ bytes, expectedContent, sliced }) => { + const dependencies = setup() + const backing = representation === "Buffer" ? Buffer.from(bytes) : new Uint8Array(bytes) + const fileContent = sliced ? backing.subarray(1, backing.length - 1) : backing + if (sliced) { + expect(fileContent.byteOffset).toBeGreaterThan(0) + expect(fileContent.byteLength).toBeLessThan(backing.byteLength) + } + dependencies.fileSystem.readFile.mockResolvedValue(fileContent) + dependencies.parser.parseFile.mockResolvedValue([]) + // Hash the literal expected text, not the input bytes or the production decoder's output. + const expectedHash = createHash("sha256").update(expectedContent).digest("hex") + const preparation = new FilePreparation(dependencies) + + expect(await preparation.prepareFile(filePath)).toEqual({ + path: filePath, + status: "processed_for_batching", + newHash: expectedHash, + pointsToUpsert: [], + }) + expect(dependencies.parser.parseFile).toHaveBeenCalledWith(filePath, { + content: expectedContent, + fileHash: expectedHash, + }) + + dependencies.parser.parseFile.mockClear() + dependencies.cacheManager.getHash.mockReturnValue(expectedHash) + expect(await preparation.prepareFile(filePath)).toEqual({ + path: filePath, + status: "skipped", + reason: "File has not changed", + }) + expect(dependencies.cacheManager.getHash).toHaveBeenCalledWith(filePath) + expect(dependencies.parser.parseFile).not.toHaveBeenCalled() + expect(dependencies.embedder.createEmbeddings).not.toHaveBeenCalled() }) }) diff --git a/src/services/code-index/processors/file-preparation.ts b/src/services/code-index/processors/file-preparation.ts index 2876fca672..a99ff6b56a 100644 --- a/src/services/code-index/processors/file-preparation.ts +++ b/src/services/code-index/processors/file-preparation.ts @@ -20,7 +20,7 @@ export class FilePreparation { } const fileContent = await dependencies.fileSystem.readFile(Uri.file(filePath)) - const content = fileContent.toString() + const content = Buffer.from(fileContent).toString("utf-8") const newHash = createHash("sha256").update(content).digest("hex") if (dependencies.cacheManager.getHash(filePath) === newHash) { From 4f2d586e82a6bb9c3b0d4d9b25fb7471db90d322 Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Mon, 28 Sep 2026 23:29:10 +0300 Subject: [PATCH 8/8] test(code-index): assert preparation receiver identity --- .../__tests__/file-preparation.spec.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/services/code-index/processors/__tests__/file-preparation.spec.ts b/src/services/code-index/processors/__tests__/file-preparation.spec.ts index d07a2a9a5a..22020a9fdd 100644 --- a/src/services/code-index/processors/__tests__/file-preparation.spec.ts +++ b/src/services/code-index/processors/__tests__/file-preparation.spec.ts @@ -210,13 +210,20 @@ describe.each(["posix", "win32"] as const)("FilePreparation (%s paths)", (platfo it("calls service methods with their original receivers", async () => { const dependencies = setup() expect((await new FilePreparation(dependencies).prepareFile(filePath)).status).toBe("processed_for_batching") - expect(dependencies.ignoreController.validateAccess.mock.contexts).toEqual([dependencies.ignoreController]) - expect(dependencies.ignoreInstance.ignores.mock.contexts).toEqual([dependencies.ignoreInstance]) - expect(dependencies.fileSystem.stat.mock.contexts).toEqual([dependencies.fileSystem]) - expect(dependencies.fileSystem.readFile.mock.contexts).toEqual([dependencies.fileSystem]) - expect(dependencies.cacheManager.getHash.mock.contexts).toEqual([dependencies.cacheManager]) - expect(dependencies.parser.parseFile.mock.contexts).toEqual([dependencies.parser]) - expect(dependencies.embedder.createEmbeddings.mock.contexts).toEqual([dependencies.embedder]) + expect(dependencies.ignoreController.validateAccess).toHaveBeenCalledOnce() + expect(dependencies.ignoreController.validateAccess.mock.contexts[0]).toBe(dependencies.ignoreController) + expect(dependencies.ignoreInstance.ignores).toHaveBeenCalledOnce() + expect(dependencies.ignoreInstance.ignores.mock.contexts[0]).toBe(dependencies.ignoreInstance) + expect(dependencies.fileSystem.stat).toHaveBeenCalledOnce() + expect(dependencies.fileSystem.stat.mock.contexts[0]).toBe(dependencies.fileSystem) + expect(dependencies.fileSystem.readFile).toHaveBeenCalledOnce() + expect(dependencies.fileSystem.readFile.mock.contexts[0]).toBe(dependencies.fileSystem) + expect(dependencies.cacheManager.getHash).toHaveBeenCalledOnce() + expect(dependencies.cacheManager.getHash.mock.contexts[0]).toBe(dependencies.cacheManager) + expect(dependencies.parser.parseFile).toHaveBeenCalledOnce() + expect(dependencies.parser.parseFile.mock.contexts[0]).toBe(dependencies.parser) + expect(dependencies.embedder.createEmbeddings).toHaveBeenCalledOnce() + expect(dependencies.embedder.createEmbeddings.mock.contexts[0]).toBe(dependencies.embedder) }) it("does not treat a hidden workspace ancestor as an excluded directory", async () => {