Implements MCP server for semantic search across GSD projects: MCP Tools: - gsd_memory_search: Search across all registered projects - gsd_memory_decisions: Find decisions from SUMMARY/PROJECT.md - gsd_memory_patterns: Find patterns from SUMMARY.md - gsd_memory_pitfalls: Find pitfalls from RESEARCH.md - gsd_memory_stack: Find tech stack entries - gsd_memory_register: Register project with memory - gsd_memory_index: Trigger indexing/update - gsd_memory_status: Show system status Architecture: - QMD wrapper with grep fallback when QMD unavailable - YAML frontmatter extractors for GSD document types - Project registry at ~/.gsd/projects.json - Auto-install via bin/install.js for Claude Code Workflow integrations: - new-project: Registers project with memory - plan-phase: Indexes after planning - execute-phase: Indexes after execution - complete-milestone: Indexes milestone completion - Researcher agents query memory before Context7 Tests: 117 passing (66 unit + 51 integration) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
266 lines
7.6 KiB
TypeScript
266 lines
7.6 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { qmd } from '../../src/qmd.js';
|
|
import { execSync } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
|
|
describe('QMD wrapper integration', () => {
|
|
let testDir: string;
|
|
let originalIsAvailable: typeof qmd.isAvailable;
|
|
|
|
beforeEach(() => {
|
|
// Create temp test directory with some markdown files
|
|
testDir = path.join(os.tmpdir(), `gsd-qmd-test-${Date.now()}`);
|
|
fs.mkdirSync(testDir, { recursive: true });
|
|
|
|
// Create test markdown files
|
|
fs.writeFileSync(
|
|
path.join(testDir, 'test1.md'),
|
|
`---
|
|
phase: 01
|
|
tags: [auth, jwt]
|
|
---
|
|
# Authentication Module
|
|
|
|
This module handles JWT authentication and session management.
|
|
We use jose for token signing and verification.
|
|
`
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(testDir, 'test2.md'),
|
|
`---
|
|
phase: 02
|
|
tags: [api, rest]
|
|
---
|
|
# API Layer
|
|
|
|
RESTful API endpoints for user management.
|
|
Authentication is required for all protected routes.
|
|
`
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(testDir, 'test3.md'),
|
|
`# Unrelated Content
|
|
|
|
This file contains unrelated content about database migrations.
|
|
`
|
|
);
|
|
|
|
originalIsAvailable = qmd.isAvailable.bind(qmd);
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Cleanup
|
|
if (fs.existsSync(testDir)) {
|
|
fs.rmSync(testDir, { recursive: true });
|
|
}
|
|
qmd.isAvailable = originalIsAvailable;
|
|
});
|
|
|
|
describe('isAvailable', () => {
|
|
it('returns boolean indicating QMD availability', async () => {
|
|
const available = await qmd.isAvailable();
|
|
expect(typeof available).toBe('boolean');
|
|
});
|
|
});
|
|
|
|
describe('findMarkdownFiles', () => {
|
|
it('finds all markdown files recursively', () => {
|
|
const files = qmd.findMarkdownFiles(testDir);
|
|
expect(files.length).toBe(3);
|
|
expect(files.some(f => f.endsWith('test1.md'))).toBe(true);
|
|
expect(files.some(f => f.endsWith('test2.md'))).toBe(true);
|
|
expect(files.some(f => f.endsWith('test3.md'))).toBe(true);
|
|
});
|
|
|
|
it('excludes node_modules and hidden directories', () => {
|
|
// Create node_modules with markdown
|
|
const nmDir = path.join(testDir, 'node_modules');
|
|
fs.mkdirSync(nmDir, { recursive: true });
|
|
fs.writeFileSync(path.join(nmDir, 'package.md'), '# Package');
|
|
|
|
// Create hidden directory with markdown
|
|
const hiddenDir = path.join(testDir, '.hidden');
|
|
fs.mkdirSync(hiddenDir, { recursive: true });
|
|
fs.writeFileSync(path.join(hiddenDir, 'secret.md'), '# Secret');
|
|
|
|
const files = qmd.findMarkdownFiles(testDir);
|
|
expect(files.some(f => f.includes('node_modules'))).toBe(false);
|
|
expect(files.some(f => f.includes('.hidden'))).toBe(false);
|
|
});
|
|
|
|
it('handles nested directories', () => {
|
|
const nestedDir = path.join(testDir, 'docs', 'api');
|
|
fs.mkdirSync(nestedDir, { recursive: true });
|
|
fs.writeFileSync(path.join(nestedDir, 'nested.md'), '# Nested');
|
|
|
|
const files = qmd.findMarkdownFiles(testDir);
|
|
expect(files.some(f => f.endsWith('nested.md'))).toBe(true);
|
|
});
|
|
|
|
it('returns empty array for non-existent directory', () => {
|
|
const files = qmd.findMarkdownFiles('/nonexistent/path');
|
|
expect(files).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('grepFallback', () => {
|
|
it('finds content matching query', async () => {
|
|
const results = await qmd.grepFallback({
|
|
query: 'authentication',
|
|
paths: [testDir],
|
|
limit: 10
|
|
});
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
expect(results.some(r => r.source.includes('test1.md'))).toBe(true);
|
|
expect(results.some(r => r.source.includes('test2.md'))).toBe(true);
|
|
});
|
|
|
|
it('returns results with relevance scores', async () => {
|
|
const results = await qmd.grepFallback({
|
|
query: 'jwt',
|
|
paths: [testDir]
|
|
});
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
for (const result of results) {
|
|
expect(result).toHaveProperty('relevance');
|
|
expect(typeof result.relevance).toBe('number');
|
|
expect(result.relevance).toBeGreaterThanOrEqual(0);
|
|
expect(result.relevance).toBeLessThanOrEqual(1);
|
|
}
|
|
});
|
|
|
|
it('returns content snippets around matches', async () => {
|
|
const results = await qmd.grepFallback({
|
|
query: 'jwt',
|
|
paths: [testDir]
|
|
});
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
for (const result of results) {
|
|
expect(result).toHaveProperty('content');
|
|
expect(result.content.toLowerCase()).toContain('jwt');
|
|
}
|
|
});
|
|
|
|
it('respects limit parameter', async () => {
|
|
const results = await qmd.grepFallback({
|
|
query: 'the', // Common word
|
|
paths: [testDir],
|
|
limit: 2
|
|
});
|
|
|
|
expect(results.length).toBeLessThanOrEqual(2);
|
|
});
|
|
|
|
it('sorts results by relevance', async () => {
|
|
const results = await qmd.grepFallback({
|
|
query: 'authentication',
|
|
paths: [testDir]
|
|
});
|
|
|
|
if (results.length > 1) {
|
|
for (let i = 1; i < results.length; i++) {
|
|
expect(results[i - 1].relevance).toBeGreaterThanOrEqual(results[i].relevance);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('case insensitive search', async () => {
|
|
const results = await qmd.grepFallback({
|
|
query: 'JWT', // Uppercase
|
|
paths: [testDir]
|
|
});
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
expect(results.some(r => r.source.includes('test1.md'))).toBe(true);
|
|
});
|
|
|
|
it('returns empty array for no matches', async () => {
|
|
const results = await qmd.grepFallback({
|
|
query: 'xyznonexistent123',
|
|
paths: [testDir]
|
|
});
|
|
|
|
expect(results).toEqual([]);
|
|
});
|
|
|
|
it('marks context as grep', async () => {
|
|
const results = await qmd.grepFallback({
|
|
query: 'authentication',
|
|
paths: [testDir]
|
|
});
|
|
|
|
for (const result of results) {
|
|
expect(result.context).toBe('grep');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('search (integration)', () => {
|
|
it('uses grep fallback when QMD unavailable', async () => {
|
|
// Force QMD unavailable
|
|
vi.spyOn(qmd, 'isAvailable').mockResolvedValue(false);
|
|
|
|
const results = await qmd.search({
|
|
query: 'authentication',
|
|
paths: [testDir]
|
|
});
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
expect(results[0].context).toBe('grep');
|
|
});
|
|
|
|
it('uses grep fallback when no collection specified', async () => {
|
|
const results = await qmd.search({
|
|
query: 'authentication',
|
|
paths: [testDir]
|
|
// No collection specified
|
|
});
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
expect(results[0].context).toBe('grep');
|
|
});
|
|
});
|
|
|
|
describe('createCollection', () => {
|
|
it('returns graceful error when QMD unavailable', async () => {
|
|
vi.spyOn(qmd, 'isAvailable').mockResolvedValue(false);
|
|
|
|
const result = await qmd.createCollection({
|
|
name: 'test-collection',
|
|
paths: [testDir]
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.reason).toBe('qmd_not_available');
|
|
});
|
|
});
|
|
|
|
describe('index', () => {
|
|
it('returns graceful error when QMD unavailable', async () => {
|
|
vi.spyOn(qmd, 'isAvailable').mockResolvedValue(false);
|
|
|
|
const result = await qmd.index('test-collection');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.reason).toBe('qmd_not_available');
|
|
});
|
|
});
|
|
|
|
describe('status', () => {
|
|
it('returns available: false when QMD unavailable', async () => {
|
|
vi.spyOn(qmd, 'isAvailable').mockResolvedValue(false);
|
|
|
|
const result = await qmd.status('test-collection');
|
|
|
|
expect(result.available).toBe(false);
|
|
});
|
|
});
|
|
});
|