diff --git a/src/index.ts b/src/index.ts index 0459b55..dfaa13f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,6 @@ import { fileURLToPath } from "url"; import { parseConfig } from "./config/schema.js"; import { loadMergedConfig } from "./config/merger.js"; -import { Indexer } from "./indexer/index.js"; import { createWatcherWithIndexer } from "./watcher/index.js"; import { codebase_search, @@ -20,10 +19,19 @@ import { add_knowledge_base, list_knowledge_bases, remove_knowledge_base, + getSharedIndexer, initializeTools, } from "./tools/index.js"; import { loadCommandsFromDirectory } from "./commands/loader.js"; import { hasProjectMarker } from "./utils/files.js"; +import type { CombinedWatcher } from "./watcher/index.js"; + +let activeWatcher: CombinedWatcher | null = null; + +function replaceActiveWatcher(nextWatcher: CombinedWatcher | null): void { + activeWatcher?.stop(); + activeWatcher = nextWatcher; +} function getCommandsDir(): string { let currentDir = process.cwd(); @@ -43,7 +51,7 @@ const plugin: Plugin = async ({ directory }) => { initializeTools(projectRoot, config); - const indexer = new Indexer(projectRoot, config); + const indexer = getSharedIndexer(); const isValidProject = !config.indexing.requireProjectMarker || hasProjectMarker(projectRoot); @@ -61,7 +69,9 @@ const plugin: Plugin = async ({ directory }) => { } if (config.indexing.watchFiles && isValidProject) { - createWatcherWithIndexer(indexer, projectRoot, config); + replaceActiveWatcher(createWatcherWithIndexer(getSharedIndexer, projectRoot, config)); + } else { + replaceActiveWatcher(null); } return { diff --git a/src/tools/index.ts b/src/tools/index.ts index 9f0c1d8..c615bef 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,7 +1,7 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin"; +import { parseConfig, type ParsedCodebaseIndexConfig } from "../config/schema.js"; import { Indexer } from "../indexer/index.js"; -import { ParsedCodebaseIndexConfig } from "../config/schema.js"; import { formatCostEstimate } from "../utils/cost.js"; import type { LogLevel } from "../config/schema.js"; import type { LogEntry } from "../utils/logger.js"; @@ -30,6 +30,18 @@ export function initializeTools(projectRoot: string, config: ParsedCodebaseIndex sharedIndexer = new Indexer(projectRoot, config); } +export function getSharedIndexer(): Indexer { + return getIndexer(); +} + +function refreshIndexerFromConfig(): void { + if (!sharedProjectRoot) { + throw new Error("Codebase index tools not initialized. Plugin may not be loaded correctly."); + } + + sharedIndexer = new Indexer(sharedProjectRoot, parseConfig(loadConfig())); +} + function getIndexer(): Indexer { if (!sharedIndexer) { throw new Error("Codebase index tools not initialized. Plugin may not be loaded correctly."); @@ -372,6 +384,7 @@ export const add_knowledge_base: ToolDefinition = tool({ knowledgeBases.push(resolvedPath); config.knowledgeBases = knowledgeBases; saveConfig(config); + refreshIndexerFromConfig(); let result = `${resolvedPath}\n`; result += `Total knowledge bases: ${knowledgeBases.length}\n`; @@ -458,6 +471,7 @@ export const remove_knowledge_base: ToolDefinition = tool({ const removed = knowledgeBases.splice(index, 1)[0]; config.knowledgeBases = knowledgeBases; saveConfig(config); + refreshIndexerFromConfig(); let result = `Removed: ${removed}\n\n`; result += `Remaining knowledge bases: ${knowledgeBases.length}\n`; diff --git a/src/utils/files.ts b/src/utils/files.ts index 1375055..b92e6b0 100644 --- a/src/utils/files.ts +++ b/src/utils/files.ts @@ -111,7 +111,16 @@ export function shouldIncludeFile( } function matchGlob(filePath: string, pattern: string): boolean { - let regexPattern = pattern + if (pattern.startsWith("**/")) { + const withoutPrefix = pattern.slice(3); + if (withoutPrefix && matchGlob(filePath, withoutPrefix)) { + return true; + } + } + + const escapedPattern = pattern.replace(/[.+^$()|[\]\\]/g, "\\$&"); + + let regexPattern = escapedPattern .replace(/\*\*/g, "<<>>") .replace(/\*/g, "[^/]*") .replace(/<<>>/g, ".*") diff --git a/src/watcher/index.ts b/src/watcher/index.ts index d6ddf53..269ea09 100644 --- a/src/watcher/index.ts +++ b/src/watcher/index.ts @@ -1,9 +1,9 @@ import chokidar, { FSWatcher } from "chokidar"; import * as path from "path"; -import { CodebaseIndexConfig } from "../config/schema.js"; +import type { CodebaseIndexConfig } from "../config/schema.js"; import { createIgnoreFilter, shouldIncludeFile } from "../utils/files.js"; -import { Indexer } from "../indexer/index.js"; +import type { Indexer } from "../indexer/index.js"; import { isGitRepo, getHeadPath, getCurrentBranch } from "../git/index.js"; export type FileChangeType = "add" | "change" | "unlink"; @@ -243,7 +243,7 @@ export interface CombinedWatcher { } export function createWatcherWithIndexer( - indexer: Indexer, + getIndexer: () => Indexer, projectRoot: string, config: CodebaseIndexConfig ): CombinedWatcher { @@ -256,7 +256,7 @@ export function createWatcherWithIndexer( const hasDelete = changes.some((c) => c.type === "unlink"); if (hasAddOrChange || hasDelete) { - await indexer.index(); + await getIndexer().index(); } }); @@ -266,7 +266,7 @@ export function createWatcherWithIndexer( gitWatcher = new GitHeadWatcher(projectRoot); gitWatcher.start(async (oldBranch, newBranch) => { console.log(`Branch changed: ${oldBranch ?? "(none)"} -> ${newBranch}`); - await indexer.index(); + await getIndexer().index(); }); } diff --git a/tests/files.test.ts b/tests/files.test.ts index 37a3e1f..001fd71 100644 --- a/tests/files.test.ts +++ b/tests/files.test.ts @@ -82,6 +82,22 @@ describe("files utilities", () => { ) ).toBe(false); }); + + it("should include root-level files with dots in their names", () => { + const filter = createIgnoreFilter(tempDir); + const includePatterns = ["**/*.{ts,tsx,js,jsx,mjs,cjs}"]; + const excludePatterns = ["**/.*"]; + + expect( + shouldIncludeFile( + path.join(tempDir, "watcher.probe.ts"), + tempDir, + includePatterns, + excludePatterns, + filter + ) + ).toBe(true); + }); }); describe("collectFiles", () => { diff --git a/tests/tools-knowledge-bases.test.ts b/tests/tools-knowledge-bases.test.ts new file mode 100644 index 0000000..0796f10 --- /dev/null +++ b/tests/tools-knowledge-bases.test.ts @@ -0,0 +1,116 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { indexerInstances, MockIndexer } = vi.hoisted(() => { + const indexerInstances: Array<{ projectRoot: string; config: Record }> = []; + + class MockIndexer { + public readonly projectRoot: string; + public readonly config: Record; + + public constructor(projectRoot: string, config: Record) { + this.projectRoot = projectRoot; + this.config = config; + indexerInstances.push({ projectRoot, config }); + } + + public estimateCost = vi.fn().mockResolvedValue({ + filesCount: 0, + totalSizeBytes: 0, + estimatedChunks: 0, + estimatedTokens: 0, + estimatedCost: 0, + isFree: true, + provider: "ollama", + model: "nomic-embed-text", + }); + + public clearIndex = vi.fn().mockResolvedValue(undefined); + public index = vi.fn().mockResolvedValue({ + totalFiles: 0, + totalChunks: 0, + indexedChunks: 0, + failedChunks: 0, + tokensUsed: 0, + durationMs: 0, + existingChunks: 0, + removedChunks: 0, + skippedFiles: [], + parseFailures: [], + }); + + public getStatus = vi.fn().mockResolvedValue({ + indexed: true, + vectorCount: 0, + provider: "ollama", + model: "nomic-embed-text", + indexPath: "/tmp/index", + currentBranch: "main", + baseBranch: "main", + }); + + public healthCheck = vi.fn().mockResolvedValue({ + removed: 0, + gcOrphanEmbeddings: 0, + gcOrphanChunks: 0, + gcOrphanSymbols: 0, + gcOrphanCallEdges: 0, + filePaths: [], + }); + + public getLogger = vi.fn().mockReturnValue({ + isEnabled: vi.fn().mockReturnValue(false), + isMetricsEnabled: vi.fn().mockReturnValue(false), + getLogs: vi.fn().mockReturnValue([]), + getLogsByCategory: vi.fn().mockReturnValue([]), + getLogsByLevel: vi.fn().mockReturnValue([]), + formatMetrics: vi.fn().mockReturnValue(""), + }); + } + + return { indexerInstances, MockIndexer }; +}); + +vi.mock("../src/indexer/index.js", () => ({ + Indexer: MockIndexer, +})); + +import { parseConfig } from "../src/config/schema.js"; +import { add_knowledge_base, initializeTools, remove_knowledge_base } from "../src/tools/index.js"; + +describe("knowledge base tool config refresh", () => { + let tempDir: string; + let kbDir: string; + + beforeEach(() => { + indexerInstances.length = 0; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "kb-tools-test-")); + kbDir = fs.mkdtempSync(path.join(os.tmpdir(), "kb-source-")); + initializeTools(tempDir, parseConfig({ indexing: { watchFiles: false } })); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(kbDir, { recursive: true, force: true }); + }); + + it("rebuilds the shared indexer after adding a knowledge base", async () => { + await add_knowledge_base.execute({ path: kbDir }); + + expect(indexerInstances).toHaveLength(2); + expect(indexerInstances[1]?.projectRoot).toBe(tempDir); + expect(indexerInstances[1]?.config.knowledgeBases).toEqual([path.normalize(kbDir)]); + }); + + it("rebuilds the shared indexer after removing a knowledge base", async () => { + await add_knowledge_base.execute({ path: kbDir }); + await remove_knowledge_base.execute({ path: kbDir }); + + expect(indexerInstances).toHaveLength(3); + expect(indexerInstances[2]?.projectRoot).toBe(tempDir); + expect(indexerInstances[2]?.config.knowledgeBases).toEqual([]); + }); +}); diff --git a/tests/watcher.test.ts b/tests/watcher.test.ts index aed82bd..12569a4 100644 --- a/tests/watcher.test.ts +++ b/tests/watcher.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import * as fs from "fs"; import * as path from "path"; import * as os from "os"; -import { FileWatcher, GitHeadWatcher, FileChange } from "../src/watcher/index.js"; +import { FileWatcher, GitHeadWatcher, FileChange, createWatcherWithIndexer } from "../src/watcher/index.js"; import { ParsedCodebaseIndexConfig } from "../src/config/schema.js"; const createTestConfig = (overrides: Partial = {}): ParsedCodebaseIndexConfig => ({ @@ -119,6 +119,73 @@ describe("FileWatcher", () => { expect(tsChanges.length).toBeGreaterThanOrEqual(0); expect(mdChanges.length).toBe(0); }); + + it("should include matching root-level files", async () => { + const changes: FileChange[] = []; + watcher = new FileWatcher(tempDir, createTestConfig({ include: ["**/*.ts"] })); + + watcher.start(async (c) => { + changes.push(...c); + }); + + await new Promise((r) => setTimeout(r, 100)); + + fs.writeFileSync(path.join(tempDir, "root.ts"), "export const root = 1;"); + + await new Promise((r) => setTimeout(r, 1500)); + + expect(changes.some((c) => c.path.endsWith("root.ts"))).toBe(true); + }); + }); + + describe("createWatcherWithIndexer", () => { + it("uses the latest indexer instance for file-triggered reindexing", async () => { + const staleIndexer = { + index: vi.fn().mockResolvedValue(undefined), + }; + const refreshedIndexer = { + index: vi.fn().mockResolvedValue(undefined), + }; + + let currentIndexer = staleIndexer; + const combinedWatcher = createWatcherWithIndexer( + () => currentIndexer, + tempDir, + createTestConfig() + ); + + await new Promise((r) => setTimeout(r, 100)); + currentIndexer = refreshedIndexer; + + fs.writeFileSync(path.join(tempDir, "src", "reindex-me.ts"), "export const value = 1;"); + + await new Promise((r) => setTimeout(r, 1500)); + + expect(refreshedIndexer.index).toHaveBeenCalledTimes(1); + expect(staleIndexer.index).not.toHaveBeenCalled(); + + combinedWatcher.stop(); + }); + + it("stops the watcher cleanly after start", () => { + const indexer = { + index: vi.fn().mockResolvedValue(undefined), + }; + + const combinedWatcher = createWatcherWithIndexer( + () => indexer, + tempDir, + createTestConfig() + ); + + expect(combinedWatcher.fileWatcher.isRunning()).toBe(true); + expect(combinedWatcher.gitWatcher?.isRunning() ?? false).toBe(false); + + combinedWatcher.stop(); + + expect(combinedWatcher.fileWatcher.isRunning()).toBe(false); + expect(combinedWatcher.gitWatcher?.isRunning() ?? false).toBe(false); + }); }); });