Skip to content

Commit 957aa17

Browse files
committed
feat(mcp): refactor dev_inspect and optimize pattern analysis
BREAKING CHANGE: dev_inspect no longer requires 'action' parameter Major Changes: - Refactored dev_inspect to single-purpose tool (finds similar files + pattern analysis) - Created PatternAnalysisService with 5 pattern extractors (imports, error handling, types, testing, size) - Optimized pattern analysis with batch scanning (5-10x faster) - Fixed semantic search to use document embeddings instead of path strings - Fixed --force flag to properly clear vector store - Removed outputSchema from all 9 MCP adapters to fix Cursor/Claude compatibility - Added extension filtering for relevant file comparisons Performance Improvements: - Batch scan all files in one pass (1 ts-morph initialization vs 6) - Added searchByDocumentId for embedding-based similarity - Pattern analysis now 500-1000ms (down from 2-3 seconds) Bug Fixes: - Fixed findSimilar to search by embeddings, not file paths - Fixed force re-index to actually clear old data - Fixed race condition in LanceDB table creation - Fixed all MCP protocol compliance issues New Features: - Test utilities in core/utils (reusable isTestFile, findTestFile) - Vector store clear() method - searchByDocumentId() for similarity search - Comprehensive pattern analysis (5 categories) Documentation: - Updated README.md with pattern categories - Updated CLAUDE.md with new dev_inspect description - Complete rewrite of dev-inspect.mdx website docs - Added migration guide from dev_explore Tests: - All 1100+ tests passing - Added 10 new test-utils tests - Pattern analysis service fully tested
1 parent ac04aac commit 957aa17

File tree

23 files changed

+505
-492
lines changed

23 files changed

+505
-492
lines changed

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ Once installed, AI tools gain access to:
168168
- **`dev_map`** - Codebase structure with component counts and change frequency
169169
- **`dev_history`** - Semantic search over git commits (who changed what and why)
170170
- **`dev_plan`** - Assemble context for GitHub issues (code + history + patterns)
171-
- **`dev_inspect`** - Inspect files (compare similar implementations, check patterns)
171+
- **`dev_inspect`** - Inspect files for pattern analysis (finds similar code, compares error handling, types, imports, testing)
172172
- **`dev_gh`** - Search GitHub issues/PRs semantically
173173
- **`dev_status`** - Repository indexing status
174174
- **`dev_health`** - Server health checks

README.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,13 +158,20 @@ Assemble context for issue #42
158158
**Note:** This tool no longer generates task breakdowns. It provides comprehensive context so the AI assistant can create better plans.
159159

160160
### `dev_inspect` - File Analysis
161-
Inspect specific files, compare implementations, validate patterns.
161+
Inspect files for pattern analysis. Finds similar code and compares patterns (error handling, type coverage, imports, testing).
162162

163163
```
164-
Compare src/auth/middleware.ts with similar implementations
165-
Validate pattern consistency in src/hooks/useAuth.ts
164+
Inspect src/auth/middleware.ts for patterns
165+
Check how src/hooks/useAuth.ts compares to similar hooks
166166
```
167167

168+
**Pattern Categories:**
169+
- Import style (ESM vs CJS)
170+
- Error handling (throw vs result types)
171+
- Type coverage (full, partial, none)
172+
- Test coverage (co-located test files)
173+
- File size relative to similar code
174+
168175
### `dev_status` - Repository Status
169176
View indexing status, component health, and repository information.
170177

packages/core/src/indexer/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ export class RepositoryIndexer {
9292
const _documentsIndexed = 0;
9393

9494
try {
95+
// Clear vector store if force re-index requested
96+
if (options.force) {
97+
options.logger?.info('Force re-index requested, clearing existing vectors');
98+
await this.vectorStorage.clear();
99+
this.state = null; // Reset state to force fresh scan
100+
}
101+
95102
// Phase 1: Scan repository
96103
const onProgress = options.onProgress;
97104
onProgress?.({
@@ -526,6 +533,14 @@ export class RepositoryIndexer {
526533
return this.vectorStorage.search(query, options);
527534
}
528535

536+
/**
537+
* Find similar documents to a given document by ID
538+
* More efficient than search() as it reuses the document's existing embedding
539+
*/
540+
async searchByDocumentId(documentId: string, options?: SearchOptions): Promise<SearchResult[]> {
541+
return this.vectorStorage.searchByDocumentId(documentId, options);
542+
}
543+
529544
/**
530545
* Get all indexed documents without semantic search (fast scan)
531546
* Use this when you need all documents and don't need relevance ranking

packages/core/src/services/pattern-analysis-service.ts

Lines changed: 67 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import * as fs from 'node:fs/promises';
99
import * as path from 'node:path';
1010
import { scanRepository } from '../scanner';
1111
import type { Document } from '../scanner/types';
12+
import { findTestFile, isTestFile } from '../utils/test-utils';
1213
import type {
1314
ErrorHandlingComparison,
1415
ErrorHandlingPattern,
@@ -65,35 +66,50 @@ export class PatternAnalysisService {
6566

6667
const documents = result.documents.filter((d) => d.metadata.file === filePath);
6768

68-
// Step 2: Get file stats and content
69-
const fullPath = path.join(this.config.repositoryPath, filePath);
70-
const [stat, content] = await Promise.all([fs.stat(fullPath), fs.readFile(fullPath, 'utf-8')]);
71-
72-
const lines = content.split('\n').length;
73-
74-
// Step 3: Extract all patterns
75-
return {
76-
fileSize: {
77-
lines,
78-
bytes: stat.size,
79-
},
80-
testing: await this.analyzeTesting(filePath),
81-
importStyle: await this.analyzeImportsFromFile(filePath, documents),
82-
errorHandling: this.analyzeErrorHandling(content),
83-
typeAnnotations: this.analyzeTypes(documents),
84-
};
69+
// Step 2: Use the optimized analysis method
70+
return this.analyzeFileWithDocs(filePath, documents);
8571
}
8672

8773
/**
8874
* Compare patterns between target file and similar files
8975
*
76+
* OPTIMIZED: Batch scans all files in one pass to avoid repeated ts-morph initialization
77+
*
9078
* @param targetFile - Target file to analyze
9179
* @param similarFiles - Array of similar file paths
9280
* @returns Pattern comparison results
9381
*/
9482
async comparePatterns(targetFile: string, similarFiles: string[]): Promise<PatternComparison> {
95-
const targetPatterns = await this.analyzeFile(targetFile);
96-
const similarPatterns = await Promise.all(similarFiles.map((f) => this.analyzeFile(f)));
83+
// OPTIMIZATION: Batch scan all files at once (5-10x faster than individual scans)
84+
const allFiles = [targetFile, ...similarFiles];
85+
const batchResult = await scanRepository({
86+
repoRoot: this.config.repositoryPath,
87+
include: allFiles,
88+
});
89+
90+
// Group documents by file for fast lookup
91+
const docsByFile = new Map<string, Document[]>();
92+
for (const doc of batchResult.documents) {
93+
const file = doc.metadata.file;
94+
if (!docsByFile.has(file)) {
95+
docsByFile.set(file, []);
96+
}
97+
const docs = docsByFile.get(file);
98+
if (docs) {
99+
docs.push(doc);
100+
}
101+
}
102+
103+
// Analyze target file with cached documents
104+
const targetPatterns = await this.analyzeFileWithDocs(
105+
targetFile,
106+
docsByFile.get(targetFile) || []
107+
);
108+
109+
// Analyze similar files in parallel with cached documents
110+
const similarPatterns = await Promise.all(
111+
similarFiles.map((f) => this.analyzeFileWithDocs(f, docsByFile.get(f) || []))
112+
);
97113

98114
return {
99115
fileSize: this.compareFileSize(
@@ -119,6 +135,36 @@ export class PatternAnalysisService {
119135
};
120136
}
121137

138+
/**
139+
* Analyze file patterns using pre-scanned documents (faster)
140+
*
141+
* @param filePath - Relative path from repository root
142+
* @param documents - Pre-scanned documents for this file
143+
* @returns Pattern analysis results
144+
*/
145+
private async analyzeFileWithDocs(
146+
filePath: string,
147+
documents: Document[]
148+
): Promise<FilePatterns> {
149+
// Step 1: Get file stats and content
150+
const fullPath = path.join(this.config.repositoryPath, filePath);
151+
const [stat, content] = await Promise.all([fs.stat(fullPath), fs.readFile(fullPath, 'utf-8')]);
152+
153+
const lines = content.split('\n').length;
154+
155+
// Step 2: Extract all patterns (using cached documents)
156+
return {
157+
fileSize: {
158+
lines,
159+
bytes: stat.size,
160+
},
161+
testing: await this.analyzeTesting(filePath),
162+
importStyle: await this.analyzeImportsFromFile(filePath, documents),
163+
errorHandling: this.analyzeErrorHandling(content),
164+
typeAnnotations: this.analyzeTypes(documents),
165+
};
166+
}
167+
122168
// ========================================================================
123169
// Pattern Extractors (MVP: 5 core patterns)
124170
// ========================================================================
@@ -130,11 +176,11 @@ export class PatternAnalysisService {
130176
*/
131177
private async analyzeTesting(filePath: string): Promise<TestingPattern> {
132178
// Skip if already a test file
133-
if (this.isTestFile(filePath)) {
179+
if (isTestFile(filePath)) {
134180
return { hasTest: false };
135181
}
136182

137-
const testFile = await this.findTestFile(filePath);
183+
const testFile = await findTestFile(filePath, this.config.repositoryPath);
138184
return {
139185
hasTest: testFile !== null,
140186
testPath: testFile || undefined,
@@ -440,35 +486,4 @@ export class PatternAnalysisService {
440486
// ========================================================================
441487
// Utility Methods
442488
// ========================================================================
443-
444-
/**
445-
* Check if a path is a test file
446-
*/
447-
private isTestFile(filePath: string): boolean {
448-
return filePath.includes('.test.') || filePath.includes('.spec.');
449-
}
450-
451-
/**
452-
* Find test file for a source file
453-
*
454-
* Checks for common patterns: *.test.*, *.spec.*
455-
*/
456-
private async findTestFile(sourcePath: string): Promise<string | null> {
457-
const ext = path.extname(sourcePath);
458-
const base = sourcePath.slice(0, -ext.length);
459-
460-
const patterns = [`${base}.test${ext}`, `${base}.spec${ext}`];
461-
462-
for (const testPath of patterns) {
463-
const fullPath = path.join(this.config.repositoryPath, testPath);
464-
try {
465-
await fs.access(fullPath);
466-
return testPath;
467-
} catch {
468-
// File doesn't exist, try next pattern
469-
}
470-
}
471-
472-
return null;
473-
}
474489
}

packages/core/src/services/search-service.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -124,19 +124,24 @@ export class SearchService {
124124
async findSimilar(filePath: string, options?: SimilarityOptions): Promise<SearchResult[]> {
125125
const indexer = await this.getIndexer();
126126
try {
127-
// Search for documents from the target file
128-
const fileResults = await indexer.search(filePath, { limit: 5 });
129-
if (fileResults.length === 0) {
127+
// Step 1: Get all documents from the target file
128+
const allDocs = await indexer.getAll({ limit: 10000 });
129+
const fileDocuments = allDocs.filter((doc) => doc.metadata.path === filePath);
130+
131+
if (fileDocuments.length === 0) {
132+
this.logger?.warn({ filePath }, 'No indexed documents found for file');
130133
return [];
131134
}
132135

133-
// Use the path as query to find similar code patterns
134-
const results = await indexer.search(filePath, {
135-
limit: (options?.limit ?? 10) + 1, // +1 to account for the file itself
136+
// Step 2: Use the first document's embedding to find similar documents
137+
// This is more accurate than searching by file path string
138+
const referenceDocId = fileDocuments[0].id;
139+
const results = await indexer.searchByDocumentId(referenceDocId, {
140+
limit: (options?.limit ?? 10) + fileDocuments.length, // +N to account for the file's own documents
136141
scoreThreshold: options?.threshold ?? 0.7,
137142
});
138143

139-
// Filter out the original file
144+
// Step 3: Filter out documents from the same file
140145
return results.filter((r) => r.metadata.path !== filePath);
141146
} finally {
142147
await indexer.close();
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import * as fs from 'node:fs/promises';
2+
import * as path from 'node:path';
3+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4+
import { findTestFile, isTestFile } from '../test-utils';
5+
6+
describe('test-utils', () => {
7+
describe('isTestFile', () => {
8+
it('should identify .test. files', () => {
9+
expect(isTestFile('src/utils/helper.test.ts')).toBe(true);
10+
expect(isTestFile('src/components/Button.test.tsx')).toBe(true);
11+
expect(isTestFile('lib/parser.test.js')).toBe(true);
12+
});
13+
14+
it('should identify .spec. files', () => {
15+
expect(isTestFile('src/utils/helper.spec.ts')).toBe(true);
16+
expect(isTestFile('src/components/Button.spec.tsx')).toBe(true);
17+
expect(isTestFile('lib/parser.spec.js')).toBe(true);
18+
});
19+
20+
it('should return false for non-test files', () => {
21+
expect(isTestFile('src/utils/helper.ts')).toBe(false);
22+
expect(isTestFile('src/components/Button.tsx')).toBe(false);
23+
expect(isTestFile('lib/parser.js')).toBe(false);
24+
expect(isTestFile('README.md')).toBe(false);
25+
});
26+
27+
it('should handle edge cases', () => {
28+
expect(isTestFile('')).toBe(false);
29+
expect(isTestFile('test.ts')).toBe(false); // Needs .test. or .spec.
30+
expect(isTestFile('spec.ts')).toBe(false);
31+
});
32+
});
33+
34+
describe('findTestFile', () => {
35+
const testDir = path.join(__dirname, '__temp_test_utils__');
36+
37+
beforeEach(async () => {
38+
await fs.mkdir(testDir, { recursive: true });
39+
});
40+
41+
afterEach(async () => {
42+
await fs.rm(testDir, { recursive: true, force: true });
43+
});
44+
45+
it('should find .test. file', async () => {
46+
// Create source and test file
47+
const sourceFile = 'helper.ts';
48+
const testFile = 'helper.test.ts';
49+
await fs.writeFile(path.join(testDir, sourceFile), '// source');
50+
await fs.writeFile(path.join(testDir, testFile), '// test');
51+
52+
const result = await findTestFile(sourceFile, testDir);
53+
expect(result).toBe(testFile);
54+
});
55+
56+
it('should find .spec. file', async () => {
57+
// Create source and spec file
58+
const sourceFile = 'parser.ts';
59+
const specFile = 'parser.spec.ts';
60+
await fs.writeFile(path.join(testDir, sourceFile), '// source');
61+
await fs.writeFile(path.join(testDir, specFile), '// spec');
62+
63+
const result = await findTestFile(sourceFile, testDir);
64+
expect(result).toBe(specFile);
65+
});
66+
67+
it('should prefer .test. over .spec.', async () => {
68+
// Create source and both test files
69+
const sourceFile = 'utils.ts';
70+
const testFile = 'utils.test.ts';
71+
const specFile = 'utils.spec.ts';
72+
await fs.writeFile(path.join(testDir, sourceFile), '// source');
73+
await fs.writeFile(path.join(testDir, testFile), '// test');
74+
await fs.writeFile(path.join(testDir, specFile), '// spec');
75+
76+
const result = await findTestFile(sourceFile, testDir);
77+
expect(result).toBe(testFile); // .test. is checked first
78+
});
79+
80+
it('should return null if no test file exists', async () => {
81+
// Create only source file
82+
const sourceFile = 'lonely.ts';
83+
await fs.writeFile(path.join(testDir, sourceFile), '// source');
84+
85+
const result = await findTestFile(sourceFile, testDir);
86+
expect(result).toBeNull();
87+
});
88+
89+
it('should handle different extensions', async () => {
90+
// Test with .tsx
91+
const sourceFile = 'Component.tsx';
92+
const testFile = 'Component.test.tsx';
93+
await fs.writeFile(path.join(testDir, sourceFile), '// component');
94+
await fs.writeFile(path.join(testDir, testFile), '// test');
95+
96+
const result = await findTestFile(sourceFile, testDir);
97+
expect(result).toBe(testFile);
98+
});
99+
100+
it('should handle nested paths', async () => {
101+
// Create nested directory structure
102+
const nestedDir = path.join(testDir, 'src', 'utils');
103+
await fs.mkdir(nestedDir, { recursive: true });
104+
105+
const sourceFile = 'src/utils/helper.ts';
106+
const testFile = 'src/utils/helper.test.ts';
107+
await fs.writeFile(path.join(testDir, sourceFile), '// source');
108+
await fs.writeFile(path.join(testDir, testFile), '// test');
109+
110+
const result = await findTestFile(sourceFile, testDir);
111+
expect(result).toBe(testFile);
112+
});
113+
});
114+
});

packages/core/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ export * from './concurrency';
66
export * from './file-validator';
77
export * from './icons';
88
export * from './retry';
9+
export * from './test-utils';
910
export * from './wasm-resolver';

0 commit comments

Comments
 (0)