Sfoglia il codice sorgente

Fix all remaining unit test failures and add hierarchical context test

**Test Fixes:**
- Fixed tilde expansion test to create collection with home directory path
- Fixed test expectations for displayPath vs filepath separation
- Fixed MCP test config to use isolated YAML config directory
- Fixed MCP mock to return correct logprobs format
- Fixed qmd_query test to use r.filepath instead of r.file
- Fixed CLI multi-get test to use fresh database for isolation
- Fixed multiGet function to parse filepath (virtual) instead of displayPath

**Bug Fixes:**
- Fixed multiGet to use virtual paths for parsing collection/path info
- Fixed findDocuments selectCols to separate virtual_path and display_path
- Fixed context loading in findDocuments to use virtual paths

**New Test:**
- Added hierarchical context test verifying global + collection + path contexts
  are all included and joined with double newlines

**Results:** 261 passing / 0 failing (100% pass rate)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Tobi Lutke 5 mesi fa
parent
commit
49b2cef85c
5 ha cambiato i file con 103 aggiunte e 24 eliminazioni
  1. 11 3
      src/cli.test.ts
  2. 43 6
      src/mcp.test.ts
  3. 4 5
      src/qmd.ts
  4. 38 2
      src/store.test.ts
  5. 7 8
      src/store.ts

+ 11 - 3
src/cli.test.ts

@@ -289,13 +289,21 @@ describe("CLI Get Command", () => {
 });
 
 describe("CLI Multi-Get Command", () => {
+  let localDbPath: string;
+
   beforeEach(async () => {
+    // Use fresh database for each test
+    localDbPath = getFreshDbPath();
     // Ensure we have indexed files
-    await runQmd(["collection", "add", "."]);
+    const addResult = await runQmd(["collection", "add", ".", "--name", "fixtures"], { dbPath: localDbPath });
+    if (addResult.exitCode !== 0) {
+      throw new Error(`Failed to add collection: ${addResult.stderr}`);
+    }
   });
 
   test("retrieves multiple documents by pattern", async () => {
-    const { stdout, exitCode } = await runQmd(["multi-get", "notes/*.md"]);
+    // Test glob pattern matching
+    const { stdout, stderr, exitCode } = await runQmd(["multi-get", "notes/*.md"], { dbPath: localDbPath });
     expect(exitCode).toBe(0);
     // Should contain content from both notes files
     expect(stdout).toContain("Meeting");
@@ -306,7 +314,7 @@ describe("CLI Multi-Get Command", () => {
     const { stdout, exitCode } = await runQmd([
       "multi-get",
       "README.md,notes/meeting.md",
-    ]);
+    ], { dbPath: localDbPath });
     expect(exitCode).toBe(0);
     expect(stdout).toContain("Test Project");
     expect(stdout).toContain("Team Meeting");

+ 43 - 6
src/mcp.test.ts

@@ -11,6 +11,11 @@ import * as sqliteVec from "sqlite-vec";
 import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
 import { z } from "zod";
 import { setDefaultOllama, Ollama } from "./llm";
+import { mkdtemp, writeFile, readdir, unlink, rmdir } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import YAML from "yaml";
+import type { CollectionConfig } from "./collections";
 
 // =============================================================================
 // Mock Ollama
@@ -28,12 +33,13 @@ const mockOllamaResponses: Record<string, (body: unknown) => Response> = {
     });
   },
   "/api/generate": (body: unknown) => {
-    const reqBody = body as { prompt?: string };
+    const reqBody = body as { prompt?: string; logprobs?: boolean };
     if (reqBody.prompt?.includes("Judge") || reqBody.prompt?.includes("Document")) {
+      // Return format matching Ollama API
       return new Response(JSON.stringify({
         response: "yes",
         done: true,
-        logprobs: { tokens: ["yes"], token_logprobs: [-0.1] },
+        logprobs: reqBody.logprobs ? { tokens: ["yes"], token_logprobs: [-0.1] } : undefined
       }), { status: 200, headers: { "Content-Type": "application/json" } });
     } else {
       return new Response(JSON.stringify({
@@ -72,6 +78,7 @@ function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Respon
 
 let testDb: Database;
 let testDbPath: string;
+let testConfigDir: string;
 
 function initTestDatabase(db: Database): void {
   sqliteVec.load(db);
@@ -243,23 +250,53 @@ import type { RankedResult } from "./store";
 // =============================================================================
 
 describe("MCP Server", () => {
-  beforeAll(() => {
+  beforeAll(async () => {
     globalThis.fetch = mockFetch as typeof fetch;
     setDefaultOllama(new Ollama({ baseUrl: OLLAMA_URL }));
 
+    // Set up test config directory
+    const configPrefix = join(tmpdir(), `qmd-mcp-config-${Date.now()}-${Math.random().toString(36).slice(2)}`);
+    testConfigDir = await mkdtemp(configPrefix);
+    process.env.QMD_CONFIG_DIR = testConfigDir;
+
+    // Create YAML config with test collection
+    const testConfig: CollectionConfig = {
+      collections: {
+        docs: {
+          path: "/test/docs",
+          pattern: "**/*.md",
+          context: {
+            "/meetings": "Meeting notes and transcripts"
+          }
+        }
+      }
+    };
+    await writeFile(join(testConfigDir, "index.yml"), YAML.stringify(testConfig));
+
     testDbPath = `/tmp/qmd-mcp-test-${Date.now()}.sqlite`;
     testDb = new Database(testDbPath);
     initTestDatabase(testDb);
     seedTestData(testDb);
   });
 
-  afterAll(() => {
+  afterAll(async () => {
     globalThis.fetch = originalFetch;
     setDefaultOllama(null);
     testDb.close();
     try {
       require("fs").unlinkSync(testDbPath);
     } catch {}
+
+    // Clean up test config directory
+    try {
+      const files = await readdir(testConfigDir);
+      for (const file of files) {
+        await unlink(join(testConfigDir, file));
+      }
+      await rmdir(testConfigDir);
+    } catch {}
+
+    delete process.env.QMD_CONFIG_DIR;
   });
 
   // ===========================================================================
@@ -377,10 +414,10 @@ describe("MCP Server", () => {
         const ftsResults = searchFTS(testDb, q, 20);
         if (ftsResults.length > 0) {
           rankedLists.push(ftsResults.map(r => ({
-            file: r.file,
+            file: r.filepath,
             displayPath: r.displayPath,
             title: r.title,
-            body: r.body,
+            body: r.body || "",
             score: r.score,
           })));
         }

+ 4 - 5
src/qmd.ts

@@ -1012,7 +1012,7 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT
     let path = file.path;
 
     if (!collection || !path) {
-      const parsed = parseVirtualPath(file.displayPath);
+      const parsed = parseVirtualPath(file.filepath);
       if (parsed) {
         collection = parsed.collectionName;
         path = parsed.path;
@@ -1036,16 +1036,15 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT
       continue;
     }
 
-    // Fetch document content - use virtual path to query
-    const parsed = parseVirtualPath(file.displayPath);
-    if (!parsed) continue;
+    // Fetch document content using collection and path
+    if (!collection || !path) continue;
 
     const doc = db.prepare(`
       SELECT content.doc as body, d.title
       FROM documents d
       JOIN content ON content.hash = d.hash
       WHERE d.collection = ? AND d.path = ? AND d.active = 1
-    `).get(parsed.collectionName, parsed.path) as { body: string; title: string } | null;
+    `).get(collection, path) as { body: string; title: string } | null;
 
     if (!doc) continue;
 

+ 38 - 2
src/store.test.ts

@@ -827,7 +827,8 @@ describe("Document Retrieval", () => {
       expect("error" in result).toBe(false);
       if (!("error" in result)) {
         expect(result.title).toBe("My Document");
-        expect(result.displayPath).toBe(`qmd://${collectionName}/mydoc.md`);
+        expect(result.displayPath).toBe("mydoc.md");
+        expect(result.filepath).toBe(`qmd://${collectionName}/mydoc.md`);
         expect(result.body).toBeUndefined(); // body not included by default
       }
 
@@ -917,8 +918,8 @@ describe("Document Retrieval", () => {
 
     test("findDocument expands ~ to home directory", async () => {
       const store = await createTestStore();
-      const collectionName = await createTestCollection();
       const home = homedir();
+      const collectionName = await createTestCollection({ pwd: home, name: "home" });
       await insertTestDocument(store.db, collectionName, {
         name: "mydoc",
         filepath: `${home}/docs/mydoc.md`,
@@ -948,6 +949,41 @@ describe("Document Retrieval", () => {
 
       await cleanupTestDb(store);
     });
+
+    test("findDocument includes hierarchical contexts (global + collection + path)", async () => {
+      const store = await createTestStore();
+      const collectionName = await createTestCollection({ pwd: "/archive", name: "archive" });
+
+      // Add global context
+      await addGlobalContext("Global context for all documents");
+
+      // Add collection root context
+      await addPathContext(collectionName, "/", "Archive collection context");
+
+      // Add path-specific contexts at different levels
+      await addPathContext(collectionName, "/podcasts", "Podcast episodes");
+      await addPathContext(collectionName, "/podcasts/external", "External podcast interviews");
+
+      // Insert document in nested path
+      await insertTestDocument(store.db, collectionName, {
+        name: "interview",
+        displayPath: "podcasts/external/2024-jan-interview.md",
+      });
+
+      const result = store.findDocument("/archive/podcasts/external/2024-jan-interview.md");
+      expect("error" in result).toBe(false);
+      if (!("error" in result)) {
+        // Should have all contexts joined with double newlines
+        expect(result.context).toBe(
+          "Global context for all documents\n\n" +
+          "Archive collection context\n\n" +
+          "Podcast episodes\n\n" +
+          "External podcast interviews"
+        );
+      }
+
+      await cleanupTestDb(store);
+    });
   });
 
   describe("getDocumentBody", () => {

+ 7 - 8
src/store.ts

@@ -1909,11 +1909,11 @@ export function findDocuments(
 
   const bodyCol = options.includeBody ? `, content.doc as body` : ``;
   const selectCols = `
-    'qmd://' || d.collection || '/' || d.path as display_path,
+    'qmd://' || d.collection || '/' || d.path as virtual_path,
+    d.path as display_path,
     d.title,
     d.hash,
     d.collection,
-    d.path,
     d.modified_at,
     LENGTH(content.doc) as body_length
     ${bodyCol}
@@ -1971,14 +1971,13 @@ export function findDocuments(
   const results: MultiGetResult[] = [];
 
   for (const row of fileRows) {
-    // Compute absolute filepath from collection
-    const coll = getCollection(row.collection);
-    const absoluteFilepath = coll ? `${coll.path}/${row.path}` : row.path;
-    const context = getContextForFile(db, absoluteFilepath);
+    // Get context using virtual path
+    const virtualPath = row.virtual_path || `qmd://${row.collection}/${row.display_path}`;
+    const context = getContextForFile(db, virtualPath);
 
     if (row.body_length > maxBytes) {
       results.push({
-        doc: { filepath: absoluteFilepath, displayPath: row.display_path },
+        doc: { filepath: virtualPath, displayPath: row.display_path },
         skipped: true,
         skipReason: `File too large (${Math.round(row.body_length / 1024)}KB > ${Math.round(maxBytes / 1024)}KB)`,
       });
@@ -1987,7 +1986,7 @@ export function findDocuments(
 
     results.push({
       doc: {
-        filepath: absoluteFilepath,
+        filepath: virtualPath,
         displayPath: row.display_path,
         title: row.title || row.display_path.split('/').pop() || row.display_path,
         context,