diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 69a7257..cf4f586 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -3,6 +3,7 @@ import chalk from 'chalk'; import { Command } from 'commander'; import { cleanCommand } from './commands/clean.js'; +import { exploreCommand } from './commands/explore.js'; import { indexCommand } from './commands/index.js'; import { initCommand } from './commands/init.js'; import { searchCommand } from './commands/search.js'; @@ -20,6 +21,7 @@ program program.addCommand(initCommand); program.addCommand(indexCommand); program.addCommand(searchCommand); +program.addCommand(exploreCommand); program.addCommand(updateCommand); program.addCommand(statsCommand); program.addCommand(cleanCommand); diff --git a/packages/cli/src/commands/explore.ts b/packages/cli/src/commands/explore.ts new file mode 100644 index 0000000..1dbb7da --- /dev/null +++ b/packages/cli/src/commands/explore.ts @@ -0,0 +1,131 @@ +import { RepositoryIndexer } from '@lytics/dev-agent-core'; +import chalk from 'chalk'; +import { Command } from 'commander'; +import ora from 'ora'; +import { loadConfig } from '../utils/config.js'; +import { logger } from '../utils/logger.js'; + +const explore = new Command('explore').description('πŸ” Explore and analyze code patterns'); + +// Pattern search subcommand +explore + .command('pattern') + .description('Search for code patterns using semantic search') + .argument('', 'Pattern to search for') + .option('-l, --limit ', 'Number of results', '10') + .option('-t, --threshold ', 'Similarity threshold (0-1)', '0.7') + .action(async (query: string, options) => { + const spinner = ora('Searching for patterns...').start(); + + try { + const config = await loadConfig(); + if (!config) { + spinner.fail('No config found'); + logger.error('Run "dev init" first'); + process.exit(1); + return; + } + + const indexer = new RepositoryIndexer(config); + await indexer.initialize(); + + spinner.text = `Searching: "${query}"`; + const results = await indexer.search(query, { + limit: Number.parseInt(options.limit, 10), + scoreThreshold: Number.parseFloat(options.threshold), + }); + + spinner.succeed(`Found ${results.length} results`); + + if (results.length === 0) { + logger.warn('No patterns found'); + await indexer.close(); + return; + } + + console.log(chalk.cyan(`\nπŸ“Š Pattern Results for: "${query}"\n`)); + + for (const [i, result] of results.entries()) { + const meta = result.metadata as { + path: string; + name?: string; + type: string; + startLine?: number; + }; + + console.log(chalk.white(`${i + 1}. ${meta.name || meta.type}`)); + console.log(chalk.gray(` ${meta.path}${meta.startLine ? `:${meta.startLine}` : ''}`)); + console.log(chalk.green(` ${(result.score * 100).toFixed(1)}% match\n`)); + } + + await indexer.close(); + } catch (error) { + spinner.fail('Pattern search failed'); + logger.error((error as Error).message); + process.exit(1); + } + }); + +// Similar code subcommand +explore + .command('similar') + .description('Find code similar to a file') + .argument('', 'File path') + .option('-l, --limit ', 'Number of results', '5') + .action(async (file: string, options) => { + const spinner = ora('Finding similar code...').start(); + + try { + const config = await loadConfig(); + if (!config) { + spinner.fail('No config found'); + logger.error('Run "dev init" first'); + process.exit(1); + return; + } + + const indexer = new RepositoryIndexer(config); + await indexer.initialize(); + + const results = await indexer.search(file, { + limit: Number.parseInt(options.limit, 10) + 1, + scoreThreshold: 0.7, + }); + + // Filter out the file itself + const similar = results + .filter((r) => { + const meta = r.metadata as { path: string }; + return !meta.path.includes(file); + }) + .slice(0, Number.parseInt(options.limit, 10)); + + spinner.succeed(`Found ${similar.length} similar files`); + + if (similar.length === 0) { + logger.warn('No similar code found'); + await indexer.close(); + return; + } + + console.log(chalk.cyan(`\nπŸ”— Similar to: ${file}\n`)); + + for (const [i, result] of similar.entries()) { + const meta = result.metadata as { + path: string; + type: string; + }; + + console.log(chalk.white(`${i + 1}. ${meta.path}`)); + console.log(chalk.green(` ${(result.score * 100).toFixed(1)}% similar\n`)); + } + + await indexer.close(); + } catch (error) { + spinner.fail('Similar code search failed'); + logger.error((error as Error).message); + process.exit(1); + } + }); + +export { explore as exploreCommand }; diff --git a/packages/subagents/src/explorer/README.md b/packages/subagents/src/explorer/README.md new file mode 100644 index 0000000..c81daca --- /dev/null +++ b/packages/subagents/src/explorer/README.md @@ -0,0 +1,597 @@ +# Explorer Subagent - Visual Cortex + +**Code pattern discovery and analysis using semantic search** + +## Overview + +The Explorer Subagent is the "Visual Cortex" of dev-agent, specialized in discovering patterns, finding similar code, mapping relationships, and providing architectural insights. It leverages the Repository Indexer's semantic search capabilities to understand code by meaning, not just text matching. + +## Capabilities + +- **πŸ” Pattern Search** - Find code patterns using natural language queries +- **πŸ”— Similar Code** - Discover code similar to a reference file +- **πŸ•ΈοΈ Relationships** - Map component dependencies and usages +- **πŸ“Š Insights** - Get architectural overview and metrics + +## Quick Start + +### As a CLI Tool + +```bash +# Search for patterns +dev explore pattern "authentication logic" +dev explore pattern "error handling" --limit 5 + +# Find similar code +dev explore similar src/auth/login.ts +dev explore similar packages/core/index.ts --limit 10 + +# Get insights +dev explore insights +``` + +### As an Agent + +```typescript +import { ExplorerAgent, ContextManagerImpl } from '@lytics/dev-agent-subagents'; +import { RepositoryIndexer } from '@lytics/dev-agent-core'; +import { CoordinatorLogger } from '@lytics/dev-agent-subagents'; + +// Setup +const indexer = new RepositoryIndexer({ + repositoryPath: './my-repo', + vectorStorePath: './.dev-agent/vectors', +}); +await indexer.initialize(); + +const contextManager = new ContextManagerImpl(); +contextManager.setIndexer(indexer); + +const logger = new CoordinatorLogger('my-app', 'info'); + +// Initialize Explorer +const explorer = new ExplorerAgent(); +await explorer.initialize({ + agentName: 'explorer', + contextManager, + sendMessage: async (msg) => null, + broadcastMessage: async (msg) => [], + logger, +}); + +// Send exploration request +const response = await explorer.handleMessage({ + id: 'req-1', + type: 'request', + sender: 'user', + recipient: 'explorer', + payload: { + action: 'pattern', + query: 'database connection', + limit: 10, + threshold: 0.7, + }, + timestamp: Date.now(), +}); + +console.log(response?.payload); +``` + +## Pattern Search + +Find code patterns using semantic search - searches by meaning, not exact text. + +### Request Format + +```typescript +{ + action: 'pattern', + query: string, // Natural language query + limit?: number, // Max results (default: 10) + threshold?: number, // Similarity threshold 0-1 (default: 0.7) + fileTypes?: string[], // Filter by extensions (e.g., ['.ts', '.js']) +} +``` + +### Response Format + +```typescript +{ + action: 'pattern', + query: string, + results: Array<{ + id: string, + score: number, // Similarity score (0-1) + metadata: { + path: string, + type: string, // 'function', 'class', 'interface', etc. + name: string, + language: string, + startLine?: number, + endLine?: number, + } + }>, + totalFound: number, +} +``` + +### Examples + +**Find Authentication Code:** + +```bash +dev explore pattern "user authentication and login" +``` + +```typescript +const response = await explorer.handleMessage({ + id: 'auth-search', + type: 'request', + sender: 'user', + recipient: 'explorer', + payload: { + action: 'pattern', + query: 'user authentication and login', + limit: 5, + threshold: 0.75, + }, + timestamp: Date.now(), +}); +``` + +**Filter by File Type:** + +```typescript +payload: { + action: 'pattern', + query: 'API endpoint handlers', + fileTypes: ['.ts'], + limit: 10, +} +``` + +**Common Queries:** +- "error handling and logging" +- "database connection setup" +- "API endpoint handlers" +- "authentication middleware" +- "data validation logic" +- "unit test patterns" + +## Similar Code + +Find code files similar to a reference file based on semantic similarity. + +### Request Format + +```typescript +{ + action: 'similar', + filePath: string, // Reference file path + limit?: number, // Max results (default: 10) + threshold?: number, // Similarity threshold (default: 0.75) +} +``` + +### Response Format + +```typescript +{ + action: 'similar', + referenceFile: string, + similar: Array<{ + id: string, + score: number, + metadata: { + path: string, + type: string, + name: string, + language: string, + } + }>, + totalFound: number, +} +``` + +### Examples + +**Find Files Similar to auth.ts:** + +```bash +dev explore similar src/auth.ts +``` + +```typescript +const response = await explorer.handleMessage({ + id: 'similar-search', + type: 'request', + sender: 'user', + recipient: 'explorer', + payload: { + action: 'similar', + filePath: 'src/auth/login.ts', + limit: 5, + }, + timestamp: Date.now(), +}); +``` + +**Use Cases:** +- Find similar implementations for refactoring +- Discover duplicate or near-duplicate code +- Identify patterns across the codebase +- Learn from similar examples + +## Relationship Discovery + +Map component relationships - imports, exports, dependencies, and usages. + +### Request Format + +```typescript +{ + action: 'relationships', + component: string, // Component name to analyze + type?: 'imports' | 'exports' | 'dependencies' | 'usages' | 'all', + limit?: number, // Max results (default: 50) +} +``` + +### Response Format + +```typescript +{ + action: 'relationships', + component: string, + relationships: Array<{ + from: string, // Source file + to: string, // Target component + type: 'imports' | 'exports' | 'uses' | 'extends' | 'implements', + location: { + file: string, + line: number, + } + }>, + totalFound: number, +} +``` + +### Examples + +**Find All Relationships:** + +```typescript +payload: { + action: 'relationships', + component: 'AuthService', + type: 'all', +} +``` + +**Find Imports Only:** + +```typescript +payload: { + action: 'relationships', + component: 'UserRepository', + type: 'imports', + limit: 20, +} +``` + +**Find Usages:** + +```typescript +payload: { + action: 'relationships', + component: 'DatabaseConnection', + type: 'usages', +} +``` + +## Architectural Insights + +Get high-level overview of the codebase - common patterns, file counts, coverage. + +### Request Format + +```typescript +{ + action: 'insights', + type?: 'patterns' | 'complexity' | 'coverage' | 'all', +} +``` + +### Response Format + +```typescript +{ + action: 'insights', + insights: { + fileCount: number, + componentCount: number, + topPatterns: Array<{ + pattern: string, // e.g., 'class', 'async', 'export' + count: number, + files: string[], // Top 10 files + }>, + coverage?: { + indexed: number, + total: number, + percentage: number, + }, + } +} +``` + +### Examples + +**Get All Insights:** + +```bash +dev explore insights +``` + +```typescript +const response = await explorer.handleMessage({ + id: 'insights-request', + type: 'request', + sender: 'user', + recipient: 'explorer', + payload: { + action: 'insights', + type: 'all', + }, + timestamp: Date.now(), +}); +``` + +**Insights Include:** +- Total files and components indexed +- Most common code patterns (class, function, async, etc.) +- Files where patterns appear most +- Indexing coverage percentage + +## Integration with Coordinator + +The Explorer works seamlessly with the Subagent Coordinator: + +```typescript +import { SubagentCoordinator, ExplorerAgent } from '@lytics/dev-agent-subagents'; + +const coordinator = new SubagentCoordinator(); +await coordinator.initialize({ + repositoryPath: './my-repo', + vectorStorePath: './.dev-agent/vectors', +}); + +// Register Explorer +coordinator.registerAgent(new ExplorerAgent()); + +// Send exploration request via coordinator +const response = await coordinator.sendMessage({ + id: 'explore-1', + type: 'request', + sender: 'user', + recipient: 'explorer', + payload: { + action: 'pattern', + query: 'authentication', + }, + timestamp: Date.now(), +}); +``` + +## Error Handling + +The Explorer returns error responses for invalid requests: + +```typescript +// Unknown action +const response = await explorer.handleMessage({ + id: 'bad-request', + type: 'request', + sender: 'user', + recipient: 'explorer', + payload: { + action: 'unknown-action', + }, + timestamp: Date.now(), +}); + +// response.payload will contain: { action: 'pattern', error: 'Unknown action: unknown-action' } +``` + +## Health Check + +Check if the Explorer is healthy and has indexed data: + +```typescript +const healthy = await explorer.healthCheck(); + +if (!healthy) { + console.log('Explorer not ready - index the repository first'); +} +``` + +**Health Check Criteria:** +- Explorer is initialized +- Indexer is available +- Repository has indexed vectors (vectorsStored > 0) + +## Performance Tips + +### 1. Adjust Thresholds + +Lower thresholds find more results but with less relevance: + +```typescript +// Strict matching (fewer, more relevant results) +{ threshold: 0.8 } + +// Relaxed matching (more results, less relevant) +{ threshold: 0.6 } +``` + +### 2. Limit Results + +Use `limit` to control response size: + +```typescript +{ limit: 5 } // Quick exploration +{ limit: 20 } // Comprehensive search +``` + +### 3. Filter by File Type + +Narrow search scope for faster results: + +```typescript +{ + action: 'pattern', + query: 'API handlers', + fileTypes: ['.ts'], // Only TypeScript +} +``` + +### 4. Use Specific Queries + +More specific queries yield better results: + +``` +❌ "code" +βœ… "authentication middleware" + +❌ "function" +βœ… "database connection pooling" +``` + +## Testing + +The Explorer has comprehensive test coverage (20 tests): + +```bash +# Run Explorer tests +pnpm vitest run packages/subagents/src/explorer + +# Watch mode +cd packages/subagents && pnpm test:watch src/explorer +``` + +**Test Coverage:** +- Initialization and capabilities +- Pattern search with filters +- Similar code discovery +- Relationship mapping +- Insights gathering +- Error handling +- Health checks +- Shutdown procedures + +## Real-World Use Cases + +### 1. Code Review + +Find similar patterns before implementing: + +```bash +dev explore pattern "file upload handling" +dev explore similar src/uploads/handler.ts +``` + +### 2. Refactoring + +Identify code that should be consolidated: + +```bash +dev explore pattern "database query execution" +# Review results, identify duplicates +``` + +### 3. Learning Codebase + +Understand architecture quickly: + +```bash +dev explore insights +dev explore pattern "main entry point" +dev explore relationships "Application" +``` + +### 4. Impact Analysis + +See what depends on a component before changing it: + +```bash +dev explore relationships "UserService" --type usages +``` + +### 5. Finding Examples + +Learn by finding existing implementations: + +```bash +dev explore pattern "websocket connection handling" +dev explore similar tests/integration/websocket.test.ts +``` + +## Limitations + +1. **Requires Indexed Repository** - Run `dev index` first +2. **Semantic Search Quality** - Depends on embedding model quality +3. **No Real-Time Updates** - Reindex after significant changes +4. **Memory Usage** - Large repositories require more RAM for vectors +5. **Language Support** - Best for TypeScript/JavaScript, Markdown + +## Future Enhancements + +- Real-time code analysis without full reindex +- Support for more languages (Python, Rust, Go) +- Complexity metrics and code quality scores +- Visual relationship graphs +- Integration with IDE hover tooltips +- Code smell detection +- Refactoring suggestions + +## API Reference + +### ExplorerAgent + +```typescript +class ExplorerAgent implements Agent { + name: string = 'explorer'; + capabilities: string[]; + + async initialize(context: AgentContext): Promise + async handleMessage(message: Message): Promise + async healthCheck(): Promise + async shutdown(): Promise +} +``` + +### Exported Types + +```typescript +export type { + ExplorationAction, + ExplorationRequest, + ExplorationResult, + PatternSearchRequest, + PatternResult, + SimilarCodeRequest, + SimilarCodeResult, + RelationshipRequest, + RelationshipResult, + InsightsRequest, + InsightsResult, + CodeRelationship, + CodeInsights, + PatternFrequency, + ExplorationError, +}; +``` + +## License + +MIT Β© Lytics, Inc. + diff --git a/packages/subagents/src/explorer/index.test.ts b/packages/subagents/src/explorer/index.test.ts new file mode 100644 index 0000000..c7f3e70 --- /dev/null +++ b/packages/subagents/src/explorer/index.test.ts @@ -0,0 +1,720 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { RepositoryIndexer } from '@lytics/dev-agent-core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ContextManagerImpl } from '../coordinator/context-manager'; +import { CoordinatorLogger } from '../logger'; +import type { AgentContext, Message } from '../types'; +import { ExplorerAgent } from './index'; + +describe('ExplorerAgent', () => { + let explorer: ExplorerAgent; + let tempDir: string; + let indexer: RepositoryIndexer; + let contextManager: ContextManagerImpl; + let context: AgentContext; + + beforeEach(async () => { + // Create temp directory with test files + tempDir = await mkdtemp(join(tmpdir(), 'explorer-test-')); + + // Create test files + await writeFile( + join(tempDir, 'auth.ts'), + `export class AuthService { + async authenticate(user: string, password: string) { + // Authentication logic + return true; + } + }` + ); + + await writeFile( + join(tempDir, 'user.ts'), + `export class UserService { + async getUser(id: string) { + // User retrieval logic + return { id, name: 'Test' }; + } + }` + ); + + // Initialize indexer + indexer = new RepositoryIndexer({ + repositoryPath: tempDir, + vectorStorePath: join(tempDir, '.vectors'), + dimension: 384, + }); + + await indexer.initialize(); + await indexer.index(); + + // Create context manager + contextManager = new ContextManagerImpl(); + contextManager.setIndexer(indexer); + + // Create agent context + const logger = new CoordinatorLogger('test-explorer', 'error'); + context = { + agentName: 'explorer', + contextManager, + sendMessage: vi.fn(), + broadcastMessage: vi.fn(), + logger, + }; + + // Initialize explorer + explorer = new ExplorerAgent(); + await explorer.initialize(context); + }); + + afterEach(async () => { + await indexer.close(); + await rm(tempDir, { recursive: true, force: true }); + }); + + describe('initialization', () => { + it('should initialize successfully', async () => { + const agent = new ExplorerAgent(); + await expect(agent.initialize(context)).resolves.toBeUndefined(); + }); + + it('should have correct capabilities', () => { + expect(explorer.capabilities).toContain('explore'); + expect(explorer.capabilities).toContain('analyze-patterns'); + expect(explorer.capabilities).toContain('find-similar'); + }); + + it('should set agent name from context', () => { + expect(explorer.name).toBe('explorer'); + }); + }); + + describe('pattern search', () => { + it('should search for patterns', async () => { + const message: Message = { + id: 'msg-1', + type: 'request', + sender: 'test', + recipient: 'explorer', + payload: { + action: 'pattern', + query: 'authentication', + limit: 5, + }, + timestamp: Date.now(), + }; + + const response = await explorer.handleMessage(message); + + expect(response).toBeDefined(); + expect(response?.type).toBe('response'); + expect(response?.correlationId).toBe('msg-1'); + + const result = response?.payload as { action: string; results: unknown[] }; + expect(result.action).toBe('pattern'); + expect(Array.isArray(result.results)).toBe(true); + }); + + it('should filter results by file types', async () => { + const message: Message = { + id: 'msg-2', + type: 'request', + sender: 'test', + recipient: 'explorer', + payload: { + action: 'pattern', + query: 'class', + fileTypes: ['.ts'], + limit: 10, + }, + timestamp: Date.now(), + }; + + const response = await explorer.handleMessage(message); + + expect(response).toBeDefined(); + const result = response?.payload as { results: unknown[] }; + expect(Array.isArray(result.results)).toBe(true); + }); + + it('should respect limit parameter', async () => { + const message: Message = { + id: 'msg-3', + type: 'request', + sender: 'test', + recipient: 'explorer', + payload: { + action: 'pattern', + query: 'function', + limit: 2, + }, + timestamp: Date.now(), + }; + + const response = await explorer.handleMessage(message); + + const result = response?.payload as { results: unknown[]; totalFound: number }; + expect(result.results.length).toBeLessThanOrEqual(2); + }); + + it('should use custom threshold', async () => { + const message: Message = { + id: 'msg-threshold', + type: 'request', + sender: 'test', + recipient: 'explorer', + payload: { + action: 'pattern', + query: 'service', + threshold: 0.5, + }, + timestamp: Date.now(), + }; + + const response = await explorer.handleMessage(message); + expect(response).toBeDefined(); + expect(response?.type).toBe('response'); + }); + + it('should handle empty file types array', async () => { + const message: Message = { + id: 'msg-empty-types', + type: 'request', + sender: 'test', + recipient: 'explorer', + payload: { + action: 'pattern', + query: 'class', + fileTypes: [], + }, + timestamp: Date.now(), + }; + + const response = await explorer.handleMessage(message); + expect(response).toBeDefined(); + const result = response?.payload as { results: unknown[] }; + expect(Array.isArray(result.results)).toBe(true); + }); + }); + + describe('similar code search', () => { + it('should find similar code', async () => { + const message: Message = { + id: 'msg-4', + type: 'request', + sender: 'test', + recipient: 'explorer', + payload: { + action: 'similar', + filePath: 'auth.ts', + limit: 5, + }, + timestamp: Date.now(), + }; + + const response = await explorer.handleMessage(message); + + expect(response).toBeDefined(); + expect(response?.type).toBe('response'); + + const result = response?.payload as { action: string; similar: unknown[] }; + expect(result.action).toBe('similar'); + expect(Array.isArray(result.similar)).toBe(true); + }); + + it('should exclude the reference file itself', async () => { + const message: Message = { + id: 'msg-5', + type: 'request', + sender: 'test', + recipient: 'explorer', + payload: { + action: 'similar', + filePath: 'auth.ts', + }, + timestamp: Date.now(), + }; + + const response = await explorer.handleMessage(message); + + const result = response?.payload as { similar: Array<{ metadata: { path: string } }> }; + + // None of the similar results should be the reference file itself + const hasSelfReference = result.similar.some((r) => r.metadata.path === 'auth.ts'); + expect(hasSelfReference).toBe(false); + }); + + it('should use custom threshold for similarity', async () => { + const message: Message = { + id: 'msg-similar-threshold', + type: 'request', + sender: 'test', + recipient: 'explorer', + payload: { + action: 'similar', + filePath: 'user.ts', + threshold: 0.8, + }, + timestamp: Date.now(), + }; + + const response = await explorer.handleMessage(message); + expect(response).toBeDefined(); + const result = response?.payload as { similar: unknown[] }; + expect(Array.isArray(result.similar)).toBe(true); + }); + + it('should handle non-existent file gracefully', async () => { + const message: Message = { + id: 'msg-nonexistent', + type: 'request', + sender: 'test', + recipient: 'explorer', + payload: { + action: 'similar', + filePath: 'nonexistent.ts', + }, + timestamp: Date.now(), + }; + + const response = await explorer.handleMessage(message); + expect(response).toBeDefined(); + const result = response?.payload as { similar: unknown[] }; + expect(Array.isArray(result.similar)).toBe(true); + }); + }); + + describe('relationship discovery', () => { + it('should find component relationships', async () => { + const message: Message = { + id: 'msg-6', + type: 'request', + sender: 'test', + recipient: 'explorer', + payload: { + action: 'relationships', + component: 'AuthService', + type: 'all', + }, + timestamp: Date.now(), + }; + + const response = await explorer.handleMessage(message); + + expect(response).toBeDefined(); + + const result = response?.payload as { action: string; relationships: unknown[] }; + expect(result.action).toBe('relationships'); + expect(Array.isArray(result.relationships)).toBe(true); + }); + + it('should support different relationship types', async () => { + const types = ['imports', 'exports', 'usages', 'all'] as const; + + for (const type of types) { + const message: Message = { + id: `msg-rel-${type}`, + type: 'request', + sender: 'test', + recipient: 'explorer', + payload: { + action: 'relationships', + component: 'UserService', + type, + }, + timestamp: Date.now(), + }; + + const response = await explorer.handleMessage(message); + expect(response).toBeDefined(); + expect(response?.type).toBe('response'); + } + }); + + it('should handle dependencies relationship type', async () => { + const message: Message = { + id: 'msg-rel-deps', + type: 'request', + sender: 'test', + recipient: 'explorer', + payload: { + action: 'relationships', + component: 'AuthService', + type: 'dependencies', + }, + timestamp: Date.now(), + }; + + const response = await explorer.handleMessage(message); + expect(response).toBeDefined(); + const result = response?.payload as { relationships: unknown[] }; + expect(Array.isArray(result.relationships)).toBe(true); + }); + + it('should respect limit for relationships', async () => { + const message: Message = { + id: 'msg-rel-limit', + type: 'request', + sender: 'test', + recipient: 'explorer', + payload: { + action: 'relationships', + component: 'Service', + limit: 5, + }, + timestamp: Date.now(), + }; + + const response = await explorer.handleMessage(message); + const result = response?.payload as { relationships: unknown[] }; + expect(result.relationships.length).toBeLessThanOrEqual(5); + }); + + it('should handle no type specified (defaults to all)', async () => { + const message: Message = { + id: 'msg-rel-default', + type: 'request', + sender: 'test', + recipient: 'explorer', + payload: { + action: 'relationships', + component: 'UserService', + }, + timestamp: Date.now(), + }; + + const response = await explorer.handleMessage(message); + expect(response).toBeDefined(); + const result = response?.payload as { relationships: unknown[] }; + expect(Array.isArray(result.relationships)).toBe(true); + }); + }); + + describe('insights', () => { + it('should gather codebase insights', async () => { + const message: Message = { + id: 'msg-7', + type: 'request', + sender: 'test', + recipient: 'explorer', + payload: { + action: 'insights', + type: 'all', + }, + timestamp: Date.now(), + }; + + const response = await explorer.handleMessage(message); + + expect(response).toBeDefined(); + + const result = response?.payload as { + action: string; + insights: { + fileCount: number; + componentCount: number; + topPatterns: unknown[]; + }; + }; + + expect(result.action).toBe('insights'); + expect(result.insights.fileCount).toBeGreaterThanOrEqual(0); + expect(Array.isArray(result.insights.topPatterns)).toBe(true); + }); + + it('should include coverage information if data exists', async () => { + const message: Message = { + id: 'msg-8', + type: 'request', + sender: 'test', + recipient: 'explorer', + payload: { + action: 'insights', + }, + timestamp: Date.now(), + }; + + const response = await explorer.handleMessage(message); + + const result = response?.payload as { + insights: { + fileCount: number; + componentCount: number; + coverage?: { + indexed: number; + total: number; + percentage: number; + }; + }; + }; + + // Coverage is optional - depends on indexer state + expect(result.insights.fileCount).toBeGreaterThanOrEqual(0); + expect(result.insights.componentCount).toBeGreaterThanOrEqual(0); + + if (result.insights.coverage) { + expect(result.insights.coverage.indexed).toBeGreaterThanOrEqual(0); + expect(result.insights.coverage.percentage).toBeGreaterThanOrEqual(0); + } + }); + + it('should support different insight types', async () => { + const types = ['patterns', 'complexity', 'coverage', 'all'] as const; + + for (const type of types) { + const message: Message = { + id: `msg-insight-${type}`, + type: 'request', + sender: 'test', + recipient: 'explorer', + payload: { + action: 'insights', + type, + }, + timestamp: Date.now(), + }; + + const response = await explorer.handleMessage(message); + expect(response).toBeDefined(); + expect(response?.type).toBe('response'); + + const result = response?.payload as { insights: { fileCount: number } }; + expect(result.insights.fileCount).toBeGreaterThanOrEqual(0); + } + }); + + it('should handle insights with no type specified', async () => { + const message: Message = { + id: 'msg-insight-default', + type: 'request', + sender: 'test', + recipient: 'explorer', + payload: { + action: 'insights', + }, + timestamp: Date.now(), + }; + + const response = await explorer.handleMessage(message); + expect(response).toBeDefined(); + + const result = response?.payload as { insights: { topPatterns: unknown[] } }; + expect(Array.isArray(result.insights.topPatterns)).toBe(true); + }); + + it('should analyze pattern frequency in codebase', async () => { + // Add more files to ensure pattern analysis + await writeFile( + join(tempDir, 'helper.ts'), + `export class Helper { + async process() { + const data = await fetch(); + return data; + } + }` + ); + + // Reindex + await indexer.update(); + + const message: Message = { + id: 'msg-pattern-freq', + type: 'request', + sender: 'test', + recipient: 'explorer', + payload: { + action: 'insights', + type: 'patterns', + }, + timestamp: Date.now(), + }; + + const response = await explorer.handleMessage(message); + const result = response?.payload as { + insights: { + topPatterns: Array<{ pattern: string; count: number; files: string[] }>; + }; + }; + + expect(result.insights.topPatterns).toBeDefined(); + expect(Array.isArray(result.insights.topPatterns)).toBe(true); + }); + }); + + describe('error handling', () => { + it('should handle unknown actions', async () => { + const message: Message = { + id: 'msg-9', + type: 'request', + sender: 'test', + recipient: 'explorer', + payload: { + action: 'unknown-action', + }, + timestamp: Date.now(), + }; + + const response = await explorer.handleMessage(message); + + expect(response).toBeDefined(); + expect(response?.type).toBe('response'); + + const result = response?.payload as { error?: string }; + expect(result.error).toBeDefined(); + }); + + it('should return error response on failure', async () => { + // Create explorer without initialization + const uninitializedExplorer = new ExplorerAgent(); + + const message: Message = { + id: 'msg-10', + type: 'request', + sender: 'test', + recipient: 'explorer', + payload: { + action: 'pattern', + query: 'test', + }, + timestamp: Date.now(), + }; + + await expect(uninitializedExplorer.handleMessage(message)).rejects.toThrow(); + }); + + it('should ignore non-request messages', async () => { + const message: Message = { + id: 'msg-11', + type: 'event', + sender: 'test', + recipient: 'explorer', + payload: {}, + timestamp: Date.now(), + }; + + const response = await explorer.handleMessage(message); + expect(response).toBeNull(); + }); + + it('should handle errors during pattern search', async () => { + // Close the indexer to cause an error + await indexer.close(); + + const message: Message = { + id: 'msg-error-1', + type: 'request', + sender: 'test', + recipient: 'explorer', + payload: { + action: 'pattern', + query: 'test', + }, + timestamp: Date.now(), + }; + + const response = await explorer.handleMessage(message); + + expect(response).toBeDefined(); + expect(response?.type).toBe('error'); + expect(response?.payload).toHaveProperty('error'); + + // Reinitialize for other tests + await indexer.initialize(); + await indexer.index(); + }); + + it('should include priority in error responses', async () => { + const uninitializedExplorer = new ExplorerAgent(); + + const message: Message = { + id: 'msg-priority-error', + type: 'request', + sender: 'test', + recipient: 'explorer', + payload: { + action: 'pattern', + query: 'test', + }, + priority: 'high', + timestamp: Date.now(), + }; + + await expect(uninitializedExplorer.handleMessage(message)).rejects.toThrow( + 'Explorer not initialized' + ); + }); + }); + + describe('health check', () => { + it('should check health status', async () => { + const healthy = await explorer.healthCheck(); + // Health check depends on indexer state - just verify it returns boolean + expect(typeof healthy).toBe('boolean'); + }); + + it('should return false when not initialized', async () => { + const uninitializedExplorer = new ExplorerAgent(); + const healthy = await uninitializedExplorer.healthCheck(); + expect(healthy).toBe(false); + }); + + it('should return false when indexer has no data', async () => { + // Create empty indexer + const emptyDir = await mkdtemp(join(tmpdir(), 'empty-')); + const emptyIndexer = new RepositoryIndexer({ + repositoryPath: emptyDir, + vectorStorePath: join(emptyDir, '.vectors'), + }); + + await emptyIndexer.initialize(); + + const emptyContext = new ContextManagerImpl(); + emptyContext.setIndexer(emptyIndexer); + + const emptyExplorer = new ExplorerAgent(); + await emptyExplorer.initialize({ + ...context, + contextManager: emptyContext, + }); + + const healthy = await emptyExplorer.healthCheck(); + expect(healthy).toBe(false); + + await emptyIndexer.close(); + await rm(emptyDir, { recursive: true, force: true }); + }); + + it('should return false when indexer throws error', async () => { + // Create a context with a broken indexer + const brokenContext = new ContextManagerImpl(); + const brokenIndexer = new RepositoryIndexer({ + repositoryPath: tempDir, + vectorStorePath: join(tempDir, '.broken'), + }); + // Don't initialize - will cause errors + brokenContext.setIndexer(brokenIndexer); + + const testExplorer = new ExplorerAgent(); + await testExplorer.initialize({ + ...context, + contextManager: brokenContext, + }); + + const healthy = await testExplorer.healthCheck(); + expect(healthy).toBe(false); + }); + }); + + describe('shutdown', () => { + it('should shutdown gracefully', async () => { + await expect(explorer.shutdown()).resolves.toBeUndefined(); + }); + + it('should handle shutdown when not initialized', async () => { + const uninitializedExplorer = new ExplorerAgent(); + await expect(uninitializedExplorer.shutdown()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/subagents/src/explorer/index.ts b/packages/subagents/src/explorer/index.ts index e6ec380..5e942e2 100644 --- a/packages/subagents/src/explorer/index.ts +++ b/packages/subagents/src/explorer/index.ts @@ -1,20 +1,43 @@ /** * Explorer Subagent = Visual Cortex - * Explores and analyzes code patterns (future implementation) + * Explores and analyzes code patterns using semantic search */ import type { Agent, AgentContext, Message } from '../types'; +import type { + CodeInsights, + CodeRelationship, + ExplorationError, + ExplorationRequest, + ExplorationResult, + InsightsResult, + PatternResult, + RelationshipResult, + SimilarCodeResult, +} from './types'; +import { + calculateCoverage, + createRelationship, + extractFilePath, + getCommonPatterns, + isDuplicateRelationship, + isNotReferenceFile, + matchesFileType, + sortAndLimitPatterns, +} from './utils'; export class ExplorerAgent implements Agent { - name: string = 'explorer'; - capabilities: string[] = ['explore', 'analyze-patterns', 'find-similar']; + name = 'explorer'; + capabilities = ['explore', 'analyze-patterns', 'find-similar', 'relationships', 'insights']; private context?: AgentContext; async initialize(context: AgentContext): Promise { this.context = context; this.name = context.agentName; - context.logger.info('Explorer agent initialized'); + context.logger.info('Explorer agent initialized', { + capabilities: this.capabilities, + }); } async handleMessage(message: Message): Promise { @@ -22,31 +45,318 @@ export class ExplorerAgent implements Agent { throw new Error('Explorer not initialized'); } - // TODO: Implement actual exploration logic (ticket #9) - // For now, just acknowledge - this.context.logger.debug('Received message', { type: message.type }); + const { logger } = this.context; + + if (message.type !== 'request') { + logger.debug('Ignoring non-request message', { type: message.type }); + return null; + } + + try { + const request = message.payload as unknown as ExplorationRequest; + logger.debug('Processing exploration request', { action: request.action }); + + let result: ExplorationResult | ExplorationError; + + switch (request.action) { + case 'pattern': + result = await this.searchPattern(request); + break; + case 'similar': + result = await this.findSimilar(request); + break; + case 'relationships': + result = await this.findRelationships(request); + break; + case 'insights': + result = await this.getInsights(request); + break; + default: + result = { + action: 'pattern', + error: `Unknown action: ${(request as ExplorationRequest).action}`, + }; + } - if (message.type === 'request') { return { id: `${message.id}-response`, type: 'response', sender: this.name, recipient: message.sender, correlationId: message.id, + payload: result as unknown as Record, + priority: message.priority, + timestamp: Date.now(), + }; + } catch (error) { + logger.error('Exploration failed', error as Error, { + messageId: message.id, + }); + + return { + id: `${message.id}-error`, + type: 'error', + sender: this.name, + recipient: message.sender, + correlationId: message.id, payload: { - status: 'stub', - message: 'Explorer stub - implementation pending', + error: (error as Error).message, + stack: (error as Error).stack, }, priority: message.priority, timestamp: Date.now(), }; } + } + + /** + * Search for code patterns using semantic search + */ + private async searchPattern(request: { + action: 'pattern'; + query: string; + limit?: number; + threshold?: number; + fileTypes?: string[]; + }): Promise { + if (!this.context) { + throw new Error('Explorer not initialized'); + } + + const { logger, contextManager } = this.context; + const indexer = contextManager.getIndexer(); + + logger.info('Searching for pattern', { query: request.query }); + + const results = await indexer.search(request.query, { + limit: request.limit || 10, + scoreThreshold: request.threshold || 0.7, + }); + + // Filter by file types if specified + let filteredResults = results; + if (request.fileTypes && request.fileTypes.length > 0) { + const fileTypes = request.fileTypes; // Capture for type narrowing + filteredResults = results.filter((result) => matchesFileType(result, fileTypes)); + } + + logger.info('Pattern search complete', { + query: request.query, + found: filteredResults.length, + }); + + return { + action: 'pattern', + query: request.query, + results: filteredResults, + totalFound: filteredResults.length, + }; + } + + /** + * Find code similar to a given file + */ + private async findSimilar(request: { + action: 'similar'; + filePath: string; + limit?: number; + threshold?: number; + }): Promise { + if (!this.context) { + throw new Error('Explorer not initialized'); + } - return null; + const { logger, contextManager } = this.context; + const indexer = contextManager.getIndexer(); + + logger.info('Finding similar code', { filePath: request.filePath }); + + // Search using the file path to find similar code + // The indexer will use semantic similarity + const similarResults = await indexer.search(request.filePath, { + limit: (request.limit || 10) + 1, // +1 to potentially exclude reference itself + scoreThreshold: request.threshold || 0.75, + }); + + // Exclude the reference file itself if it appears + const similar = similarResults.filter((result) => isNotReferenceFile(result, request.filePath)); + + logger.info('Similar code found', { + filePath: request.filePath, + found: similar.length, + }); + + return { + action: 'similar', + referenceFile: request.filePath, + similar: similar.slice(0, request.limit || 10), + totalFound: similar.length, + }; + } + + /** + * Find component relationships (imports, dependencies, usages) + */ + private async findRelationships(request: { + action: 'relationships'; + component: string; + type?: 'imports' | 'exports' | 'dependencies' | 'usages' | 'all'; + limit?: number; + }): Promise { + if (!this.context) { + throw new Error('Explorer not initialized'); + } + + const { logger, contextManager } = this.context; + const indexer = contextManager.getIndexer(); + + logger.info('Finding relationships', { + component: request.component, + type: request.type || 'all', + }); + + const relationshipType = request.type || 'all'; + const relationships: CodeRelationship[] = []; + + // Search for imports + if (relationshipType === 'imports' || relationshipType === 'all') { + const importResults = await indexer.search(`import ${request.component} from`, { + limit: request.limit || 50, + }); + + for (const result of importResults) { + relationships.push(createRelationship(result, request.component, 'imports')); + } + } + + // Search for exports + if (relationshipType === 'exports' || relationshipType === 'all') { + const exportResults = await indexer.search(`export ${request.component}`, { + limit: request.limit || 50, + }); + + for (const result of exportResults) { + relationships.push(createRelationship(result, request.component, 'exports')); + } + } + + // Search for usages + if (relationshipType === 'usages' || relationshipType === 'all') { + const usageResults = await indexer.search(request.component, { + limit: request.limit || 50, + scoreThreshold: 0.8, + }); + + for (const result of usageResults) { + const relationship = createRelationship(result, request.component, 'uses'); + + // Avoid duplicates + if ( + !isDuplicateRelationship( + relationships, + relationship.location.file, + relationship.location.line + ) + ) { + relationships.push(relationship); + } + } + } + + logger.info('Relationships found', { + component: request.component, + found: relationships.length, + }); + + return { + action: 'relationships', + component: request.component, + relationships: relationships.slice(0, request.limit || 50), + totalFound: relationships.length, + }; + } + + /** + * Get architectural insights from the codebase + */ + private async getInsights(request: { + action: 'insights'; + type?: 'patterns' | 'complexity' | 'coverage' | 'all'; + }): Promise { + if (!this.context) { + throw new Error('Explorer not initialized'); + } + + const { logger, contextManager } = this.context; + const indexer = contextManager.getIndexer(); + + logger.info('Gathering insights', { type: request.type || 'all' }); + + const insights: CodeInsights = { + topPatterns: [], + fileCount: 0, + componentCount: 0, + }; + + // Get indexer stats + const stats = await indexer.getStats(); + if (stats) { + insights.fileCount = stats.filesScanned; + insights.componentCount = stats.documentsIndexed; + + if (stats.filesScanned > 0) { + insights.coverage = calculateCoverage(stats.vectorsStored, stats.filesScanned); + } + } + + // Analyze common patterns + if (!request.type || request.type === 'patterns' || request.type === 'all') { + const commonPatterns = getCommonPatterns(); + + for (const pattern of commonPatterns) { + const results = await indexer.search(pattern, { + limit: 100, + scoreThreshold: 0.6, + }); + + if (results.length > 0) { + const files = [...new Set(results.map(extractFilePath))]; + insights.topPatterns.push({ + pattern, + count: results.length, + files: files.slice(0, 10), // Top 10 files + }); + } + } + + // Sort by frequency and limit to top 10 + insights.topPatterns = sortAndLimitPatterns(insights.topPatterns, 10); + } + + logger.info('Insights gathered', { + patterns: insights.topPatterns.length, + files: insights.fileCount, + }); + + return { + action: 'insights', + insights, + }; } async healthCheck(): Promise { - return !!this.context; + if (!this.context) { + return false; + } + + // Check if indexer is available + try { + const indexer = this.context.contextManager.getIndexer(); + const stats = await indexer.getStats(); + return stats !== null && stats.vectorsStored > 0; + } catch { + return false; + } } async shutdown(): Promise { @@ -54,3 +364,20 @@ export class ExplorerAgent implements Agent { this.context = undefined; } } + +// Export types +export type * from './types'; + +// Export utilities +export { + calculateCoverage, + createRelationship, + extractFilePath, + extractMetadata, + getCommonPatterns, + isDuplicateRelationship, + isNotReferenceFile, + matchesFileType, + type ResultMetadata, + sortAndLimitPatterns, +} from './utils'; diff --git a/packages/subagents/src/explorer/types.ts b/packages/subagents/src/explorer/types.ts new file mode 100644 index 0000000..c3a8dae --- /dev/null +++ b/packages/subagents/src/explorer/types.ts @@ -0,0 +1,156 @@ +/** + * Explorer Types + * Pattern discovery and code exploration + */ + +import type { SearchResult } from '@lytics/dev-agent-core'; + +/** + * Exploration action types + */ +export type ExplorationAction = + | 'pattern' // Search for patterns/concepts + | 'similar' // Find similar code + | 'relationships' // Find component relationships + | 'insights'; // Get architectural insights + +/** + * Pattern search request + */ +export interface PatternSearchRequest { + action: 'pattern'; + query: string; // e.g., "authentication logic" + limit?: number; + threshold?: number; // Similarity threshold (0-1) + fileTypes?: string[]; // Filter by file extension +} + +/** + * Similar code request + */ +export interface SimilarCodeRequest { + action: 'similar'; + filePath: string; // Reference file + limit?: number; + threshold?: number; +} + +/** + * Relationship request + */ +export interface RelationshipRequest { + action: 'relationships'; + component: string; // Component/class/function name + type?: 'imports' | 'exports' | 'dependencies' | 'usages' | 'all'; + limit?: number; +} + +/** + * Insights request + */ +export interface InsightsRequest { + action: 'insights'; + type?: 'patterns' | 'complexity' | 'coverage' | 'all'; +} + +/** + * Union of all exploration requests + */ +export type ExplorationRequest = + | PatternSearchRequest + | SimilarCodeRequest + | RelationshipRequest + | InsightsRequest; + +/** + * Pattern search result + */ +export interface PatternResult { + action: 'pattern'; + query: string; + results: SearchResult[]; + totalFound: number; +} + +/** + * Similar code result + */ +export interface SimilarCodeResult { + action: 'similar'; + referenceFile: string; + similar: SearchResult[]; + totalFound: number; +} + +/** + * Code relationship + */ +export interface CodeRelationship { + from: string; + to: string; + type: 'imports' | 'exports' | 'uses' | 'extends' | 'implements'; + location: { + file: string; + line: number; + }; +} + +/** + * Relationship result + */ +export interface RelationshipResult { + action: 'relationships'; + component: string; + relationships: CodeRelationship[]; + totalFound: number; +} + +/** + * Pattern frequency + */ +export interface PatternFrequency { + pattern: string; + count: number; + files: string[]; +} + +/** + * Code insights + */ +export interface CodeInsights { + topPatterns: PatternFrequency[]; + fileCount: number; + componentCount: number; + averageComplexity?: number; + coverage?: { + indexed: number; + total: number; + percentage: number; + }; +} + +/** + * Insights result + */ +export interface InsightsResult { + action: 'insights'; + insights: CodeInsights; +} + +/** + * Union of all exploration results + */ +export type ExplorationResult = + | PatternResult + | SimilarCodeResult + | RelationshipResult + | InsightsResult; + +/** + * Exploration error + */ +export interface ExplorationError { + action: ExplorationAction; + error: string; + details?: string; +} diff --git a/packages/subagents/src/explorer/utils/analysis.test.ts b/packages/subagents/src/explorer/utils/analysis.test.ts new file mode 100644 index 0000000..55c0558 --- /dev/null +++ b/packages/subagents/src/explorer/utils/analysis.test.ts @@ -0,0 +1,204 @@ +/** + * Tests for code analysis utilities + */ + +import { describe, expect, it } from 'vitest'; +import { calculateCoverage, getCommonPatterns, sortAndLimitPatterns } from './analysis'; + +describe('Analysis Utilities', () => { + describe('getCommonPatterns', () => { + it('should return a predefined list of common patterns', () => { + const patterns = getCommonPatterns(); + expect(patterns).toEqual([ + 'class', + 'function', + 'interface', + 'type', + 'async', + 'export', + 'import', + 'const', + ]); + }); + + it('should return the same list on multiple calls', () => { + const patterns1 = getCommonPatterns(); + const patterns2 = getCommonPatterns(); + expect(patterns1).toEqual(patterns2); + }); + + it('should include TypeScript-specific patterns', () => { + const patterns = getCommonPatterns(); + expect(patterns).toContain('interface'); + expect(patterns).toContain('type'); + }); + + it('should include common JavaScript patterns', () => { + const patterns = getCommonPatterns(); + expect(patterns).toContain('class'); + expect(patterns).toContain('function'); + expect(patterns).toContain('const'); + }); + + it('should include module patterns', () => { + const patterns = getCommonPatterns(); + expect(patterns).toContain('import'); + expect(patterns).toContain('export'); + }); + + it('should include async pattern', () => { + const patterns = getCommonPatterns(); + expect(patterns).toContain('async'); + }); + }); + + describe('sortAndLimitPatterns', () => { + const mockPatterns = [ + { pattern: 'function', count: 50, files: ['/src/a.ts', '/src/b.ts'] }, + { pattern: 'class', count: 100, files: ['/src/c.ts'] }, + { pattern: 'const', count: 25, files: ['/src/d.ts', '/src/e.ts', '/src/f.ts'] }, + { pattern: 'interface', count: 75, files: ['/src/g.ts'] }, + { pattern: 'type', count: 30, files: ['/src/h.ts'] }, + ]; + + it('should sort patterns by count in descending order', () => { + const sorted = sortAndLimitPatterns(mockPatterns, 10); + expect(sorted[0].pattern).toBe('class'); + expect(sorted[1].pattern).toBe('interface'); + expect(sorted[2].pattern).toBe('function'); + expect(sorted[3].pattern).toBe('type'); + expect(sorted[4].pattern).toBe('const'); + }); + + it('should limit results to specified count', () => { + const limited = sortAndLimitPatterns(mockPatterns, 3); + expect(limited).toHaveLength(3); + expect(limited.map((p) => p.pattern)).toEqual(['class', 'interface', 'function']); + }); + + it('should handle limit larger than array size', () => { + const all = sortAndLimitPatterns(mockPatterns, 100); + expect(all).toHaveLength(5); + }); + + it('should handle limit of 0', () => { + const none = sortAndLimitPatterns(mockPatterns, 0); + expect(none).toHaveLength(0); + }); + + it('should handle limit of 1', () => { + const one = sortAndLimitPatterns(mockPatterns, 1); + expect(one).toHaveLength(1); + expect(one[0].pattern).toBe('class'); + }); + + it('should handle empty array', () => { + const empty = sortAndLimitPatterns([], 10); + expect(empty).toHaveLength(0); + }); + + it('should preserve pattern data', () => { + const sorted = sortAndLimitPatterns(mockPatterns, 3); + expect(sorted[0]).toEqual({ pattern: 'class', count: 100, files: ['/src/c.ts'] }); + }); + + it('should sort patterns with equal counts consistently', () => { + const equalCounts = [ + { pattern: 'a', count: 10, files: [] }, + { pattern: 'b', count: 10, files: [] }, + { pattern: 'c', count: 10, files: [] }, + ]; + + const sorted = sortAndLimitPatterns(equalCounts, 10); + expect(sorted).toHaveLength(3); + // All have same count, so order is maintained + for (const item of sorted) { + expect(item.count).toBe(10); + } + }); + + it('should handle patterns with zero count', () => { + const withZero = [ + { pattern: 'function', count: 10, files: [] }, + { pattern: 'class', count: 0, files: [] }, + { pattern: 'const', count: 5, files: [] }, + ]; + + const sorted = sortAndLimitPatterns(withZero, 10); + expect(sorted[0].pattern).toBe('function'); + expect(sorted[1].pattern).toBe('const'); + expect(sorted[2].pattern).toBe('class'); + }); + + it('should handle top 10 use case', () => { + const manyPatterns = Array.from({ length: 20 }, (_, i) => ({ + pattern: `pattern${i}`, + count: 100 - i * 5, + files: [], + })); + + const top10 = sortAndLimitPatterns(manyPatterns, 10); + expect(top10).toHaveLength(10); + expect(top10[0].count).toBe(100); + expect(top10[9].count).toBe(55); + }); + }); + + describe('calculateCoverage', () => { + it('should calculate coverage correctly for non-zero total', () => { + const coverage = calculateCoverage(50, 100); + expect(coverage).toEqual({ indexed: 50, total: 100, percentage: 50 }); + }); + + it('should handle zero total gracefully', () => { + const coverage = calculateCoverage(0, 0); + expect(coverage).toEqual({ indexed: 0, total: 0, percentage: 0 }); + }); + + it('should handle zero indexed items', () => { + const coverage = calculateCoverage(0, 100); + expect(coverage).toEqual({ indexed: 0, total: 100, percentage: 0 }); + }); + + it('should handle 100% coverage', () => { + const coverage = calculateCoverage(100, 100); + expect(coverage).toEqual({ indexed: 100, total: 100, percentage: 100 }); + }); + + it('should calculate decimal percentages correctly', () => { + const coverage = calculateCoverage(33, 100); + expect(coverage.percentage).toBe(33); + }); + + it('should handle partial coverage', () => { + const coverage = calculateCoverage(850, 1000); + expect(coverage).toEqual({ indexed: 850, total: 1000, percentage: 85 }); + }); + + it('should handle very small percentages', () => { + const coverage = calculateCoverage(1, 1000); + expect(coverage.percentage).toBe(0.1); + }); + + it('should handle large numbers', () => { + const coverage = calculateCoverage(9_500_000, 10_000_000); + expect(coverage).toEqual({ indexed: 9_500_000, total: 10_000_000, percentage: 95 }); + }); + + it('should handle edge case where indexed equals total', () => { + const coverage = calculateCoverage(42, 42); + expect(coverage.percentage).toBe(100); + }); + + it('should return precise floating point values', () => { + const coverage = calculateCoverage(1, 3); + expect(coverage.percentage).toBeCloseTo(33.333, 2); + }); + + it('should preserve indexed and total values', () => { + const coverage = calculateCoverage(75, 200); + expect(coverage.indexed).toBe(75); + expect(coverage.total).toBe(200); + }); + }); +}); diff --git a/packages/subagents/src/explorer/utils/analysis.ts b/packages/subagents/src/explorer/utils/analysis.ts new file mode 100644 index 0000000..718494c --- /dev/null +++ b/packages/subagents/src/explorer/utils/analysis.ts @@ -0,0 +1,63 @@ +/** + * Code Analysis Utilities + * Functions for pattern analysis and coverage calculation + */ + +/** + * Get list of common code patterns to analyze + * + * @returns Array of common pattern strings + * + * @example + * ```typescript + * const patterns = getCommonPatterns(); + * // ['class', 'function', 'interface', ...] + * ``` + */ +export function getCommonPatterns(): string[] { + return ['class', 'function', 'interface', 'type', 'async', 'export', 'import', 'const']; +} + +/** + * Sort patterns by frequency (descending) and limit to top N + * + * @param patterns - Array of pattern frequency objects + * @param limit - Maximum number of patterns to return + * @returns Sorted and limited array + * + * @example + * ```typescript + * const top = sortAndLimitPatterns(allPatterns, 10); + * // Returns top 10 most frequent patterns + * ``` + */ +export function sortAndLimitPatterns( + patterns: Array<{ pattern: string; count: number; files: string[] }>, + limit: number +): Array<{ pattern: string; count: number; files: string[] }> { + return patterns.sort((a, b) => b.count - a.count).slice(0, limit); +} + +/** + * Calculate coverage percentage + * + * @param indexed - Number of items indexed + * @param total - Total number of items + * @returns Coverage object with percentage + * + * @example + * ```typescript + * const coverage = calculateCoverage(850, 1000); + * // { indexed: 850, total: 1000, percentage: 85.0 } + * ``` + */ +export function calculateCoverage( + indexed: number, + total: number +): { indexed: number; total: number; percentage: number } { + return { + indexed, + total, + percentage: total > 0 ? (indexed / total) * 100 : 0, + }; +} diff --git a/packages/subagents/src/explorer/utils/filters.test.ts b/packages/subagents/src/explorer/utils/filters.test.ts new file mode 100644 index 0000000..d4e72fd --- /dev/null +++ b/packages/subagents/src/explorer/utils/filters.test.ts @@ -0,0 +1,237 @@ +/** + * Tests for result filtering utilities + */ + +import type { SearchResult } from '@lytics/dev-agent-core'; +import { describe, expect, it } from 'vitest'; +import { isNotReferenceFile, matchesFileType } from './filters'; + +describe('Filter Utilities', () => { + const mockSearchResult: SearchResult = { + id: 'doc1', + score: 0.9, + metadata: { + path: '/src/components/button.ts', + type: 'function', + language: 'typescript', + name: 'createButton', + startLine: 10, + endLine: 50, + }, + }; + + describe('matchesFileType', () => { + it('should return true for matching file types', () => { + expect(matchesFileType(mockSearchResult, ['.ts', '.js'])).toBe(true); + }); + + it('should return false for non-matching file types', () => { + expect(matchesFileType(mockSearchResult, ['.tsx', '.jsx'])).toBe(false); + }); + + it('should handle empty file types array', () => { + expect(matchesFileType(mockSearchResult, [])).toBe(false); + }); + + it('should match the first matching extension', () => { + expect(matchesFileType(mockSearchResult, ['.tsx', '.ts', '.js'])).toBe(true); + }); + + it('should be case-sensitive', () => { + expect(matchesFileType(mockSearchResult, ['.TS', '.JS'])).toBe(false); + }); + + it('should handle complex file extensions', () => { + const result: SearchResult = { + id: 'doc2', + score: 0.8, + metadata: { + path: '/src/data/config.test.ts', + type: 'test', + language: 'typescript', + name: 'config tests', + }, + }; + + expect(matchesFileType(result, ['.test.ts', '.spec.ts'])).toBe(true); + }); + + it('should filter TypeScript files from mixed results', () => { + const results: SearchResult[] = [ + { + id: 'doc1', + score: 0.9, + metadata: { + path: '/src/button.ts', + type: 'function', + language: 'typescript', + name: 'Button', + }, + }, + { + id: 'doc2', + score: 0.8, + metadata: { + path: '/src/auth.js', + type: 'function', + language: 'javascript', + name: 'Auth', + }, + }, + { + id: 'doc3', + score: 0.7, + metadata: { + path: '/src/app.tsx', + type: 'component', + language: 'typescript', + name: 'App', + }, + }, + ]; + + const tsFiles = results.filter((r) => matchesFileType(r, ['.ts', '.tsx'])); + expect(tsFiles).toHaveLength(2); + expect(tsFiles[0].metadata.path).toBe('/src/button.ts'); + expect(tsFiles[1].metadata.path).toBe('/src/app.tsx'); + }); + + it('should handle markdown and documentation files', () => { + const mdResult: SearchResult = { + id: 'doc-md', + score: 0.85, + metadata: { + path: '/docs/architecture.md', + type: 'document', + language: 'markdown', + name: 'Architecture', + }, + }; + + expect(matchesFileType(mdResult, ['.md', '.mdx'])).toBe(true); + expect(matchesFileType(mdResult, ['.ts', '.js'])).toBe(false); + }); + }); + + describe('isNotReferenceFile', () => { + it('should return true if the result path is different from the reference path', () => { + expect(isNotReferenceFile(mockSearchResult, '/src/another-file.ts')).toBe(true); + }); + + it('should return false if the result path is the same as the reference path', () => { + expect(isNotReferenceFile(mockSearchResult, '/src/components/button.ts')).toBe(false); + }); + + it('should be case-sensitive for file paths', () => { + expect(isNotReferenceFile(mockSearchResult, '/src/components/Button.ts')).toBe(true); + }); + + it('should handle paths with different separators', () => { + expect(isNotReferenceFile(mockSearchResult, '/src/components\\button.ts')).toBe(true); + }); + + it('should filter out reference file from similar results', () => { + const results: SearchResult[] = [ + { + id: 'doc1', + score: 1.0, + metadata: { path: '/src/auth.ts', type: 'class', language: 'typescript', name: 'Auth' }, + }, + { + id: 'doc2', + score: 0.9, + metadata: { + path: '/src/user-auth.ts', + type: 'class', + language: 'typescript', + name: 'UserAuth', + }, + }, + { + id: 'doc3', + score: 0.85, + metadata: { + path: '/src/api-auth.ts', + type: 'class', + language: 'typescript', + name: 'ApiAuth', + }, + }, + ]; + + const filtered = results.filter((r) => isNotReferenceFile(r, '/src/auth.ts')); + expect(filtered).toHaveLength(2); + expect(filtered.every((r) => r.metadata.path !== '/src/auth.ts')).toBe(true); + }); + + it('should handle absolute vs relative paths', () => { + const result: SearchResult = { + id: 'doc1', + score: 0.9, + metadata: { + path: '/absolute/path/file.ts', + type: 'module', + language: 'typescript', + name: 'file', + }, + }; + + expect(isNotReferenceFile(result, '/absolute/path/file.ts')).toBe(false); + expect(isNotReferenceFile(result, 'relative/path/file.ts')).toBe(true); + }); + }); + + describe('Combined filtering', () => { + it('should chain multiple filters', () => { + const results: SearchResult[] = [ + { + id: 'doc1', + score: 1.0, + metadata: { + path: '/src/button.ts', + type: 'component', + language: 'typescript', + name: 'Button', + }, + }, + { + id: 'doc2', + score: 0.95, + metadata: { + path: '/src/input.tsx', + type: 'component', + language: 'typescript', + name: 'Input', + }, + }, + { + id: 'doc3', + score: 0.9, + metadata: { + path: '/src/utils.js', + type: 'utility', + language: 'javascript', + name: 'utils', + }, + }, + { + id: 'doc4', + score: 0.85, + metadata: { + path: '/src/config.ts', + type: 'config', + language: 'typescript', + name: 'config', + }, + }, + ]; + + const filtered = results + .filter((r) => matchesFileType(r, ['.ts', '.tsx'])) + .filter((r) => isNotReferenceFile(r, '/src/button.ts')); + + expect(filtered).toHaveLength(2); + expect(filtered.map((r) => r.metadata.path)).toEqual(['/src/input.tsx', '/src/config.ts']); + }); + }); +}); diff --git a/packages/subagents/src/explorer/utils/filters.ts b/packages/subagents/src/explorer/utils/filters.ts new file mode 100644 index 0000000..3082f6b --- /dev/null +++ b/packages/subagents/src/explorer/utils/filters.ts @@ -0,0 +1,41 @@ +/** + * Result Filtering Utilities + * Functions for filtering and matching search results + */ + +import type { SearchResult } from '@lytics/dev-agent-core'; +import { extractMetadata } from './metadata'; + +/** + * Check if a search result matches a specific file type + * + * @param result - Search result to check + * @param fileTypes - Array of file extensions (e.g., ['.ts', '.tsx']) + * @returns True if result matches any of the file types + * + * @example + * ```typescript + * const isTypeScript = matchesFileType(result, ['.ts', '.tsx']); + * ``` + */ +export function matchesFileType(result: SearchResult, fileTypes: string[]): boolean { + const metadata = extractMetadata(result); + return fileTypes.some((ext) => metadata.path.endsWith(ext)); +} + +/** + * Check if a search result is not the reference file + * + * @param result - Search result to check + * @param referencePath - Reference file path to exclude + * @returns True if result is not the reference file + * + * @example + * ```typescript + * const similar = results.filter(r => isNotReferenceFile(r, 'auth.ts')); + * ``` + */ +export function isNotReferenceFile(result: SearchResult, referencePath: string): boolean { + const metadata = extractMetadata(result); + return metadata.path !== referencePath; +} diff --git a/packages/subagents/src/explorer/utils/index.ts b/packages/subagents/src/explorer/utils/index.ts new file mode 100644 index 0000000..835b4ad --- /dev/null +++ b/packages/subagents/src/explorer/utils/index.ts @@ -0,0 +1,17 @@ +/** + * Explorer Utilities + * + * Organized by domain for better maintainability and tree-shaking. + * + * @module explorer/utils + */ + +// Code analysis +export { calculateCoverage, getCommonPatterns, sortAndLimitPatterns } from './analysis'; + +// Result filtering +export { isNotReferenceFile, matchesFileType } from './filters'; +// Metadata extraction +export { extractFilePath, extractMetadata, type ResultMetadata } from './metadata'; +// Relationship management +export { createRelationship, isDuplicateRelationship } from './relationships'; diff --git a/packages/subagents/src/explorer/utils/metadata.test.ts b/packages/subagents/src/explorer/utils/metadata.test.ts new file mode 100644 index 0000000..ec3663c --- /dev/null +++ b/packages/subagents/src/explorer/utils/metadata.test.ts @@ -0,0 +1,146 @@ +/** + * Tests for metadata extraction utilities + */ + +import type { SearchResult } from '@lytics/dev-agent-core'; +import { describe, expect, it } from 'vitest'; +import { extractFilePath, extractMetadata, type ResultMetadata } from './metadata'; + +describe('Metadata Utilities', () => { + const mockSearchResult: SearchResult = { + id: 'doc1', + score: 0.9, + metadata: { + path: '/src/components/button.ts', + type: 'function', + language: 'typescript', + name: 'createButton', + startLine: 10, + endLine: 50, + }, + }; + + describe('extractMetadata', () => { + it('should correctly extract metadata from a search result', () => { + const metadata = extractMetadata(mockSearchResult); + expect(metadata).toEqual({ + path: '/src/components/button.ts', + type: 'function', + language: 'typescript', + name: 'createButton', + startLine: 10, + endLine: 50, + }); + }); + + it('should preserve all metadata fields', () => { + const metadata = extractMetadata(mockSearchResult); + expect(metadata.path).toBe('/src/components/button.ts'); + expect(metadata.type).toBe('function'); + expect(metadata.language).toBe('typescript'); + expect(metadata.name).toBe('createButton'); + expect(metadata.startLine).toBe(10); + expect(metadata.endLine).toBe(50); + }); + + it('should handle metadata without optional fields', () => { + const resultWithoutLines: SearchResult = { + id: 'doc2', + score: 0.8, + metadata: { + path: '/src/index.ts', + type: 'module', + language: 'typescript', + name: 'index', + }, + }; + + const metadata = extractMetadata(resultWithoutLines); + expect(metadata.path).toBe('/src/index.ts'); + expect(metadata.startLine).toBeUndefined(); + expect(metadata.endLine).toBeUndefined(); + }); + }); + + describe('extractFilePath', () => { + it('should correctly extract the file path', () => { + expect(extractFilePath(mockSearchResult)).toBe('/src/components/button.ts'); + }); + + it('should extract path from different result types', () => { + const results: SearchResult[] = [ + { + id: 'doc1', + score: 0.9, + metadata: { path: '/src/auth.ts', type: 'class', language: 'typescript', name: 'Auth' }, + }, + { + id: 'doc2', + score: 0.8, + metadata: { + path: '/src/utils.ts', + type: 'function', + language: 'typescript', + name: 'helper', + }, + }, + { + id: 'doc3', + score: 0.7, + metadata: { + path: '/docs/README.md', + type: 'document', + language: 'markdown', + name: 'README', + }, + }, + ]; + + const paths = results.map(extractFilePath); + expect(paths).toEqual(['/src/auth.ts', '/src/utils.ts', '/docs/README.md']); + }); + + it('should handle paths with special characters', () => { + const result: SearchResult = { + id: 'doc-special', + score: 0.9, + metadata: { + path: '/src/components/user-profile/[id].tsx', + type: 'component', + language: 'typescript', + name: 'UserProfile', + }, + }; + + expect(extractFilePath(result)).toBe('/src/components/user-profile/[id].tsx'); + }); + }); + + describe('ResultMetadata type', () => { + it('should accept valid metadata structures', () => { + const metadata: ResultMetadata = { + path: '/src/test.ts', + type: 'function', + language: 'typescript', + name: 'testFunc', + startLine: 1, + endLine: 10, + }; + + expect(metadata.path).toBe('/src/test.ts'); + }); + + it('should allow optional fields to be omitted', () => { + const metadata: ResultMetadata = { + path: '/src/test.ts', + type: 'module', + language: 'typescript', + name: 'test', + // startLine and endLine are optional + }; + + expect(metadata.startLine).toBeUndefined(); + expect(metadata.endLine).toBeUndefined(); + }); + }); +}); diff --git a/packages/subagents/src/explorer/utils/metadata.ts b/packages/subagents/src/explorer/utils/metadata.ts new file mode 100644 index 0000000..95dabf5 --- /dev/null +++ b/packages/subagents/src/explorer/utils/metadata.ts @@ -0,0 +1,53 @@ +/** + * Metadata Extraction Utilities + * Functions for extracting and typing search result metadata + */ + +import type { SearchResult } from '@lytics/dev-agent-core'; + +/** + * Search result metadata structure + */ +export interface ResultMetadata { + path: string; + type: string; + language: string; + name: string; + startLine?: number; + endLine?: number; +} + +/** + * Extract typed metadata from search result + * + * @param result - Raw search result from indexer + * @returns Typed metadata object + * + * @example + * ```typescript + * const result = await indexer.search('MyClass'); + * const metadata = extractMetadata(result[0]); + * console.log(metadata.path); // '/src/MyClass.ts' + * ``` + */ +export function extractMetadata(result: SearchResult): ResultMetadata { + return result.metadata as unknown as ResultMetadata; +} + +/** + * Extract file path from search result + * + * @param result - Search result + * @returns File path string + * + * @example + * ```typescript + * const results = await indexer.search('component'); + * const paths = results.map(extractFilePath); + * // ['/src/Button.tsx', '/src/Input.tsx', ...] + * ``` + */ +export function extractFilePath(result: SearchResult): string { + const metadata = extractMetadata(result); + return metadata.path; +} diff --git a/packages/subagents/src/explorer/utils/relationships.test.ts b/packages/subagents/src/explorer/utils/relationships.test.ts new file mode 100644 index 0000000..960dfe4 --- /dev/null +++ b/packages/subagents/src/explorer/utils/relationships.test.ts @@ -0,0 +1,232 @@ +/** + * Tests for relationship building utilities + */ + +import type { SearchResult } from '@lytics/dev-agent-core'; +import { describe, expect, it } from 'vitest'; +import type { CodeRelationship } from '../types'; +import { createRelationship, isDuplicateRelationship } from './relationships'; + +describe('Relationship Utilities', () => { + const mockSearchResult: SearchResult = { + id: 'doc1', + score: 0.9, + metadata: { + path: '/src/components/button.ts', + type: 'function', + language: 'typescript', + name: 'createButton', + startLine: 10, + endLine: 50, + }, + }; + + describe('createRelationship', () => { + it('should create a correct CodeRelationship object', () => { + const relationship = createRelationship(mockSearchResult, 'MyComponent', 'uses'); + expect(relationship).toEqual({ + from: '/src/components/button.ts', + to: 'MyComponent', + type: 'uses', + location: { file: '/src/components/button.ts', line: 10 }, + }); + }); + + it('should handle missing startLine', () => { + const resultWithoutLine: SearchResult = { + ...mockSearchResult, + metadata: { ...mockSearchResult.metadata, startLine: undefined }, + }; + const relationship = createRelationship(resultWithoutLine, 'MyComponent', 'uses'); + expect(relationship.location.line).toBe(0); + }); + + it('should create import relationships', () => { + const relationship = createRelationship(mockSearchResult, 'UserService', 'imports'); + expect(relationship.type).toBe('imports'); + expect(relationship.to).toBe('UserService'); + }); + + it('should create export relationships', () => { + const relationship = createRelationship(mockSearchResult, 'Button', 'exports'); + expect(relationship.type).toBe('exports'); + expect(relationship.to).toBe('Button'); + }); + + it('should create dependency relationships', () => { + const relationship = createRelationship(mockSearchResult, 'react', 'dependencies'); + expect(relationship.type).toBe('dependencies'); + expect(relationship.to).toBe('react'); + }); + + it('should preserve metadata in location', () => { + const result: SearchResult = { + id: 'doc-test', + score: 0.85, + metadata: { + path: '/src/services/auth.ts', + type: 'class', + language: 'typescript', + name: 'AuthService', + startLine: 42, + endLine: 100, + }, + }; + + const relationship = createRelationship(result, 'TokenService', 'uses'); + expect(relationship.location).toEqual({ + file: '/src/services/auth.ts', + line: 42, + }); + }); + + it('should handle different component name formats', () => { + const componentNames = ['UserService', 'user-service', 'user_service', 'UserServiceImpl']; + + const relationships = componentNames.map((name) => + createRelationship(mockSearchResult, name, 'uses') + ); + + for (const [index, rel] of relationships.entries()) { + expect(rel.to).toBe(componentNames[index]); + } + }); + }); + + describe('isDuplicateRelationship', () => { + const relationships: CodeRelationship[] = [ + { + from: '/src/app.ts', + to: 'Button', + type: 'imports', + location: { file: '/src/app.ts', line: 5 }, + }, + { + from: '/src/app.ts', + to: 'Input', + type: 'imports', + location: { file: '/src/app.ts', line: 10 }, + }, + ]; + + it('should return true for a duplicate relationship', () => { + expect(isDuplicateRelationship(relationships, '/src/app.ts', 5)).toBe(true); + }); + + it('should return false for a non-duplicate relationship (different file)', () => { + expect(isDuplicateRelationship(relationships, '/src/main.ts', 5)).toBe(false); + }); + + it('should return false for a non-duplicate relationship (different line)', () => { + expect(isDuplicateRelationship(relationships, '/src/app.ts', 15)).toBe(false); + }); + + it('should handle empty relationships array', () => { + expect(isDuplicateRelationship([], '/src/app.ts', 5)).toBe(false); + }); + + it('should check multiple relationships correctly', () => { + expect(isDuplicateRelationship(relationships, '/src/app.ts', 10)).toBe(true); + expect(isDuplicateRelationship(relationships, '/src/app.ts', 20)).toBe(false); + }); + + it('should prevent duplicate entries when building relationships', () => { + const newRels: CodeRelationship[] = []; + const testData = [ + { file: '/src/a.ts', line: 1 }, + { file: '/src/a.ts', line: 1 }, // Duplicate + { file: '/src/b.ts', line: 2 }, + { file: '/src/a.ts', line: 3 }, + ]; + + for (const data of testData) { + if (!isDuplicateRelationship(newRels, data.file, data.line)) { + newRels.push({ + from: data.file, + to: 'Component', + type: 'uses', + location: { file: data.file, line: data.line }, + }); + } + } + + expect(newRels).toHaveLength(3); + expect(newRels.filter((r) => r.location.file === '/src/a.ts')).toHaveLength(2); + }); + + it('should be case-sensitive for file paths', () => { + expect(isDuplicateRelationship(relationships, '/src/App.ts', 5)).toBe(false); + expect(isDuplicateRelationship(relationships, '/SRC/app.ts', 5)).toBe(false); + }); + + it('should handle line number 0', () => { + const relsWithZero: CodeRelationship[] = [ + { + from: '/src/index.ts', + to: 'Module', + type: 'exports', + location: { file: '/src/index.ts', line: 0 }, + }, + ]; + + expect(isDuplicateRelationship(relsWithZero, '/src/index.ts', 0)).toBe(true); + expect(isDuplicateRelationship(relsWithZero, '/src/index.ts', 1)).toBe(false); + }); + }); + + describe('Integration with createRelationship', () => { + it('should work together to build unique relationship lists', () => { + const results: SearchResult[] = [ + { + id: 'doc1', + score: 0.9, + metadata: { + path: '/src/auth.ts', + type: 'import', + language: 'typescript', + name: 'import', + startLine: 5, + }, + }, + { + id: 'doc2', + score: 0.85, + metadata: { + path: '/src/auth.ts', + type: 'usage', + language: 'typescript', + name: 'usage', + startLine: 5, // Same location as first + }, + }, + { + id: 'doc3', + score: 0.8, + metadata: { + path: '/src/user.ts', + type: 'usage', + language: 'typescript', + name: 'usage', + startLine: 20, + }, + }, + ]; + + const relationships: CodeRelationship[] = []; + + for (const result of results) { + const metadata = result.metadata as { + path: string; + startLine?: number; + }; + if (!isDuplicateRelationship(relationships, metadata.path, metadata.startLine || 0)) { + relationships.push(createRelationship(result, 'UserService', 'uses')); + } + } + + expect(relationships).toHaveLength(2); + expect(relationships[0].location).toEqual({ file: '/src/auth.ts', line: 5 }); + expect(relationships[1].location).toEqual({ file: '/src/user.ts', line: 20 }); + }); + }); +}); diff --git a/packages/subagents/src/explorer/utils/relationships.ts b/packages/subagents/src/explorer/utils/relationships.ts new file mode 100644 index 0000000..d40fa07 --- /dev/null +++ b/packages/subagents/src/explorer/utils/relationships.ts @@ -0,0 +1,62 @@ +/** + * Relationship Building Utilities + * Functions for creating and managing code relationships + */ + +import type { SearchResult } from '@lytics/dev-agent-core'; +import type { CodeRelationship } from '../types'; +import { extractMetadata } from './metadata'; + +/** + * Create a code relationship from a search result + * + * @param result - Search result from indexer + * @param component - Target component name + * @param type - Relationship type + * @returns Code relationship object + * + * @example + * ```typescript + * const rel = createRelationship(result, 'UserService', 'imports'); + * // { from: '/src/auth.ts', to: 'UserService', type: 'imports', ... } + * ``` + */ +export function createRelationship( + result: SearchResult, + component: string, + type: CodeRelationship['type'] +): CodeRelationship { + const metadata = extractMetadata(result); + return { + from: metadata.path, + to: component, + type, + location: { + file: metadata.path, + line: metadata.startLine || 0, + }, + }; +} + +/** + * Check if a relationship already exists in the array + * + * @param relationships - Array of existing relationships + * @param filePath - File path to check + * @param line - Line number to check + * @returns True if relationship exists + * + * @example + * ```typescript + * if (!isDuplicateRelationship(rels, '/src/file.ts', 42)) { + * relationships.push(newRelationship); + * } + * ``` + */ +export function isDuplicateRelationship( + relationships: CodeRelationship[], + filePath: string, + line: number +): boolean { + return relationships.some((r) => r.location.file === filePath && r.location.line === line); +}