Parcourir la source

fix: add missing context to search results markdown and XML formatters

searchResultsToMarkdown and searchResultsToXml in formatter.ts were
silently dropping the context field. Added formatter.test.ts covering
context visibility across all output formats.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tobi Lütke il y a 3 mois
Parent
commit
993628e768
2 fichiers modifiés avec 300 ajouts et 2 suppressions
  1. 296 0
      src/formatter.test.ts
  2. 4 2
      src/formatter.ts

+ 296 - 0
src/formatter.test.ts

@@ -0,0 +1,296 @@
+/**
+ * formatter.test.ts - Unit tests verifying context is shown in all output formats
+ *
+ * Run with: bun test formatter.test.ts
+ */
+
+import { describe, test, expect } from "bun:test";
+import {
+  // Search result formatters
+  searchResultsToJson,
+  searchResultsToCsv,
+  searchResultsToFiles,
+  searchResultsToMarkdown,
+  searchResultsToXml,
+  searchResultsToMcpCsv,
+  formatSearchResults,
+  // Document (multi-get) formatters
+  documentsToJson,
+  documentsToCsv,
+  documentsToFiles,
+  documentsToMarkdown,
+  documentsToXml,
+  formatDocuments,
+  // Single document formatters
+  documentToJson,
+  documentToMarkdown,
+  documentToXml,
+  formatDocument,
+  type MultiGetFile,
+} from "./formatter.js";
+import type { SearchResult, DocumentResult } from "./store.js";
+
+// =============================================================================
+// Test Fixtures
+// =============================================================================
+
+const TEST_CONTEXT = "Internal engineering keynotes from company summit events";
+
+function makeSearchResult(overrides: Partial<SearchResult> = {}): SearchResult {
+  return {
+    filepath: "qmd://archive/summit/keynote.md",
+    displayPath: "qmd://archive/summit/keynote.md",
+    title: "Summit Keynote",
+    context: TEST_CONTEXT,
+    hash: "dc5590abcdef",
+    docid: "dc5590",
+    collectionName: "archive",
+    modifiedAt: "2024-01-01T00:00:00Z",
+    bodyLength: 100,
+    body: "---\ntitle: Summit Keynote\n---\n\nThis is the keynote content.",
+    score: 0.84,
+    source: "fts",
+    ...overrides,
+  };
+}
+
+function makeDocumentResult(overrides: Partial<DocumentResult> = {}): DocumentResult {
+  return {
+    filepath: "qmd://archive/summit/keynote.md",
+    displayPath: "qmd://archive/summit/keynote.md",
+    title: "Summit Keynote",
+    context: TEST_CONTEXT,
+    hash: "dc5590abcdef",
+    docid: "dc5590",
+    collectionName: "archive",
+    modifiedAt: "2024-01-01T00:00:00Z",
+    bodyLength: 100,
+    body: "---\ntitle: Summit Keynote\n---\n\nThis is the keynote content.",
+    ...overrides,
+  };
+}
+
+function makeMultiGetFile(overrides: Partial<MultiGetFile & { skipped: false }> = {}): MultiGetFile {
+  return {
+    filepath: "qmd://archive/summit/keynote.md",
+    displayPath: "qmd://archive/summit/keynote.md",
+    title: "Summit Keynote",
+    context: TEST_CONTEXT,
+    body: "---\ntitle: Summit Keynote\n---\n\nThis is the keynote content.",
+    skipped: false,
+    ...overrides,
+  };
+}
+
+// =============================================================================
+// Search Results: Context in Every Format
+// =============================================================================
+
+describe("search results include context in all formats", () => {
+  const results = [makeSearchResult()];
+
+  test("JSON format includes context", () => {
+    const output = searchResultsToJson(results, { query: "keynote" });
+    const parsed = JSON.parse(output);
+    expect(parsed[0].context).toBe(TEST_CONTEXT);
+  });
+
+  test("CSV format includes context", () => {
+    const output = searchResultsToCsv(results, { query: "keynote" });
+    // Header should have context column
+    const lines = output.split("\n");
+    expect(lines[0]).toContain("context");
+    // Data row should contain the context text
+    expect(output).toContain(TEST_CONTEXT);
+  });
+
+  test("files format includes context", () => {
+    const output = searchResultsToFiles(results);
+    expect(output).toContain(TEST_CONTEXT);
+  });
+
+  test("Markdown format includes context", () => {
+    const output = searchResultsToMarkdown(results, { query: "keynote" });
+    expect(output).toContain(TEST_CONTEXT);
+  });
+
+  test("XML format includes context", () => {
+    const output = searchResultsToXml(results, { query: "keynote" });
+    expect(output).toContain(TEST_CONTEXT);
+  });
+
+  test("MCP CSV format includes context", () => {
+    const mcpResults = [{
+      docid: "dc5590",
+      file: "qmd://archive/summit/keynote.md",
+      title: "Summit Keynote",
+      score: 0.84,
+      context: TEST_CONTEXT,
+      snippet: "This is the keynote content.",
+    }];
+    const output = searchResultsToMcpCsv(mcpResults);
+    expect(output).toContain(TEST_CONTEXT);
+  });
+
+  test("formatSearchResults (JSON) includes context", () => {
+    const output = formatSearchResults(results, "json", { query: "keynote" });
+    const parsed = JSON.parse(output);
+    expect(parsed[0].context).toBe(TEST_CONTEXT);
+  });
+
+  test("formatSearchResults (CSV) includes context", () => {
+    const output = formatSearchResults(results, "csv", { query: "keynote" });
+    expect(output).toContain(TEST_CONTEXT);
+  });
+
+  test("formatSearchResults (files) includes context", () => {
+    const output = formatSearchResults(results, "files");
+    expect(output).toContain(TEST_CONTEXT);
+  });
+
+  test("formatSearchResults (md) includes context", () => {
+    const output = formatSearchResults(results, "md", { query: "keynote" });
+    expect(output).toContain(TEST_CONTEXT);
+  });
+
+  test("formatSearchResults (xml) includes context", () => {
+    const output = formatSearchResults(results, "xml", { query: "keynote" });
+    expect(output).toContain(TEST_CONTEXT);
+  });
+});
+
+// =============================================================================
+// Search Results: No Context When Absent
+// =============================================================================
+
+describe("search results omit context when null", () => {
+  const results = [makeSearchResult({ context: null })];
+
+  test("JSON format omits context field when null", () => {
+    const output = searchResultsToJson(results, { query: "keynote" });
+    const parsed = JSON.parse(output);
+    expect(parsed[0].context).toBeUndefined();
+  });
+
+  test("files format does not include trailing context when null", () => {
+    const output = searchResultsToFiles(results);
+    // Should just be docid,score,path - no trailing comma/context
+    expect(output).not.toContain(",\"");
+  });
+});
+
+// =============================================================================
+// Multi-Get Documents: Context in Every Format
+// =============================================================================
+
+describe("multi-get documents include context in all formats", () => {
+  const docs = [makeMultiGetFile()];
+
+  test("JSON format includes context", () => {
+    const output = documentsToJson(docs);
+    const parsed = JSON.parse(output);
+    expect(parsed[0].context).toBe(TEST_CONTEXT);
+  });
+
+  test("CSV format includes context", () => {
+    const output = documentsToCsv(docs);
+    const lines = output.split("\n");
+    expect(lines[0]).toContain("context");
+    expect(output).toContain(TEST_CONTEXT);
+  });
+
+  test("files format includes context", () => {
+    const output = documentsToFiles(docs);
+    expect(output).toContain(TEST_CONTEXT);
+  });
+
+  test("Markdown format includes context", () => {
+    const output = documentsToMarkdown(docs);
+    expect(output).toContain(TEST_CONTEXT);
+  });
+
+  test("XML format includes context", () => {
+    const output = documentsToXml(docs);
+    expect(output).toContain(TEST_CONTEXT);
+  });
+
+  test("formatDocuments (JSON) includes context", () => {
+    const output = formatDocuments(docs, "json");
+    const parsed = JSON.parse(output);
+    expect(parsed[0].context).toBe(TEST_CONTEXT);
+  });
+
+  test("formatDocuments (md) includes context", () => {
+    const output = formatDocuments(docs, "md");
+    expect(output).toContain(TEST_CONTEXT);
+  });
+
+  test("formatDocuments (xml) includes context", () => {
+    const output = formatDocuments(docs, "xml");
+    expect(output).toContain(TEST_CONTEXT);
+  });
+});
+
+// =============================================================================
+// Single Document: Context in Every Format
+// =============================================================================
+
+describe("single document includes context in all formats", () => {
+  const doc = makeDocumentResult();
+
+  test("JSON format includes context", () => {
+    const output = documentToJson(doc);
+    const parsed = JSON.parse(output);
+    expect(parsed.context).toBe(TEST_CONTEXT);
+  });
+
+  test("Markdown format includes context", () => {
+    const output = documentToMarkdown(doc);
+    expect(output).toContain(TEST_CONTEXT);
+  });
+
+  test("XML format includes context", () => {
+    const output = documentToXml(doc);
+    expect(output).toContain(TEST_CONTEXT);
+  });
+
+  test("formatDocument (JSON) includes context", () => {
+    const output = formatDocument(doc, "json");
+    const parsed = JSON.parse(output);
+    expect(parsed.context).toBe(TEST_CONTEXT);
+  });
+
+  test("formatDocument (md) includes context", () => {
+    const output = formatDocument(doc, "md");
+    expect(output).toContain(TEST_CONTEXT);
+  });
+
+  test("formatDocument (xml) includes context", () => {
+    const output = formatDocument(doc, "xml");
+    expect(output).toContain(TEST_CONTEXT);
+  });
+});
+
+// =============================================================================
+// Single Document: No Context When Absent
+// =============================================================================
+
+describe("single document omits context when null", () => {
+  const doc = makeDocumentResult({ context: null });
+
+  test("JSON format omits context field when null", () => {
+    const output = documentToJson(doc);
+    const parsed = JSON.parse(output);
+    expect(parsed.context).toBeUndefined();
+  });
+
+  test("Markdown format does not show Context line when null", () => {
+    const output = documentToMarkdown(doc);
+    expect(output).not.toContain("Context:");
+  });
+
+  test("XML format does not show context element when null", () => {
+    const output = documentToXml(doc);
+    expect(output).not.toContain("<context>");
+  });
+});

+ 4 - 2
src/formatter.ts

@@ -180,7 +180,8 @@ export function searchResultsToMarkdown(
     if (opts.lineNumbers) {
       content = addLineNumbers(content);
     }
-    return `---\n# ${heading}\n\n**docid:** \`#${row.docid}\`\n\n${content}\n`;
+    const contextLine = row.context ? `**context:** ${row.context}\n` : "";
+    return `---\n# ${heading}\n\n**docid:** \`#${row.docid}\`\n${contextLine}\n${content}\n`;
   }).join("\n");
 }
 
@@ -199,7 +200,8 @@ export function searchResultsToXml(
     if (opts.lineNumbers) {
       content = addLineNumbers(content);
     }
-    return `<file docid="#${row.docid}" name="${escapeXml(row.displayPath)}"${titleAttr}>\n${escapeXml(content)}\n</file>`;
+    const contextAttr = row.context ? ` context="${escapeXml(row.context)}"` : "";
+    return `<file docid="#${row.docid}" name="${escapeXml(row.displayPath)}"${titleAttr}${contextAttr}>\n${escapeXml(content)}\n</file>`;
   });
   return items.join("\n\n");
 }