Skip to content

Commit 830051c

Browse files
committed
fix(explore): search file content instead of filename for similar code
Bug: explore similar searched filename as text → 0 results Fix: Read file content and search its embeddings Changes: - Extract file utilities (resolveFilePath, readFileContent, etc.) - Add 22 unit tests for utilities (100% coverage) - Refactor explore similar to use utilities - Add --threshold option (default: 0.5) - Improve error messages for missing/empty files Before: $ dev explore similar file.ts ⚠ No similar code found # searched "file.ts" as text After: $ dev explore similar packages/core/src/vector/store.ts 1. packages/core/src/vector/README.md (64.1% similar) 2. packages/core/src/vector/embedder.ts (61.7% similar) Tests: 22/22 passing ✅
1 parent 4f9df34 commit 830051c

File tree

3 files changed

+329
-4
lines changed

3 files changed

+329
-4
lines changed

packages/cli/src/commands/explore.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ explore
7272
.description('Find code similar to a file')
7373
.argument('<file>', 'File path')
7474
.option('-l, --limit <number>', 'Number of results', '5')
75+
.option('-t, --threshold <number>', 'Similarity threshold (0-1)', '0.5')
7576
.action(async (file: string, options) => {
7677
const spinner = ora('Finding similar code...').start();
7778

@@ -84,19 +85,34 @@ explore
8485
return;
8586
}
8687

88+
// Prepare file for search (read content, resolve paths)
89+
spinner.text = 'Reading file content...';
90+
const { prepareFileForSearch } = await import('../utils/file.js');
91+
92+
let fileInfo: Awaited<ReturnType<typeof prepareFileForSearch>>;
93+
try {
94+
fileInfo = await prepareFileForSearch(config.repositoryPath, file);
95+
} catch (error) {
96+
spinner.fail((error as Error).message);
97+
process.exit(1);
98+
return;
99+
}
100+
87101
const indexer = new RepositoryIndexer(config);
88102
await indexer.initialize();
89103

90-
const results = await indexer.search(file, {
104+
// Search using file content, not filename
105+
spinner.text = 'Searching for similar code...';
106+
const results = await indexer.search(fileInfo.content, {
91107
limit: Number.parseInt(options.limit, 10) + 1,
92-
scoreThreshold: 0.7,
108+
scoreThreshold: Number.parseFloat(options.threshold),
93109
});
94110

95-
// Filter out the file itself
111+
// Filter out the file itself (exact path match)
96112
const similar = results
97113
.filter((r) => {
98114
const meta = r.metadata as { path: string };
99-
return !meta.path.includes(file);
115+
return meta.path !== fileInfo.relativePath;
100116
})
101117
.slice(0, Number.parseInt(options.limit, 10));
102118

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
/**
2+
* Unit tests for file utilities
3+
* Target: 100% coverage for pure utility functions
4+
*/
5+
6+
import * as fs from 'node:fs/promises';
7+
import * as os from 'node:os';
8+
import * as path from 'node:path';
9+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
10+
import { normalizeFilePath, prepareFileForSearch, readFileContent, resolveFilePath } from './file';
11+
12+
describe('File Utilities', () => {
13+
describe('resolveFilePath', () => {
14+
it('should resolve relative path to absolute', () => {
15+
const repoPath = '/home/user/project';
16+
const filePath = 'src/index.ts';
17+
18+
const result = resolveFilePath(repoPath, filePath);
19+
20+
expect(result).toBe('/home/user/project/src/index.ts');
21+
});
22+
23+
it('should handle already absolute paths', () => {
24+
const repoPath = '/home/user/project';
25+
const filePath = '/home/user/project/src/index.ts';
26+
27+
const result = resolveFilePath(repoPath, filePath);
28+
29+
expect(result).toBe('/home/user/project/src/index.ts');
30+
});
31+
32+
it('should handle paths with ../', () => {
33+
const repoPath = '/home/user/project';
34+
const filePath = 'src/../lib/utils.ts';
35+
36+
const result = resolveFilePath(repoPath, filePath);
37+
38+
expect(result).toBe('/home/user/project/lib/utils.ts');
39+
});
40+
41+
it('should handle current directory', () => {
42+
const repoPath = '/home/user/project';
43+
const filePath = './src/index.ts';
44+
45+
const result = resolveFilePath(repoPath, filePath);
46+
47+
expect(result).toBe('/home/user/project/src/index.ts');
48+
});
49+
});
50+
51+
describe('normalizeFilePath', () => {
52+
it('should create relative path from absolute', () => {
53+
const repoPath = '/home/user/project';
54+
const absolutePath = '/home/user/project/src/index.ts';
55+
56+
const result = normalizeFilePath(repoPath, absolutePath);
57+
58+
expect(result).toBe('src/index.ts');
59+
});
60+
61+
it('should handle paths in subdirectories', () => {
62+
const repoPath = '/home/user/project';
63+
const absolutePath = '/home/user/project/packages/core/src/index.ts';
64+
65+
const result = normalizeFilePath(repoPath, absolutePath);
66+
67+
expect(result).toBe('packages/core/src/index.ts');
68+
});
69+
70+
it('should handle same path', () => {
71+
const repoPath = '/home/user/project';
72+
const absolutePath = '/home/user/project';
73+
74+
const result = normalizeFilePath(repoPath, absolutePath);
75+
76+
expect(result).toBe('');
77+
});
78+
79+
it('should handle paths outside repository', () => {
80+
const repoPath = '/home/user/project';
81+
const absolutePath = '/home/user/other/file.ts';
82+
83+
const result = normalizeFilePath(repoPath, absolutePath);
84+
85+
expect(result).toContain('..');
86+
});
87+
});
88+
89+
describe('readFileContent', () => {
90+
let tempDir: string;
91+
let testFile: string;
92+
93+
beforeEach(async () => {
94+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'file-test-'));
95+
testFile = path.join(tempDir, 'test.txt');
96+
});
97+
98+
afterEach(async () => {
99+
await fs.rm(tempDir, { recursive: true, force: true });
100+
});
101+
102+
it('should read file content', async () => {
103+
const content = 'Hello, World!';
104+
await fs.writeFile(testFile, content);
105+
106+
const result = await readFileContent(testFile);
107+
108+
expect(result).toBe(content);
109+
});
110+
111+
it('should read multiline content', async () => {
112+
const content = 'Line 1\nLine 2\nLine 3';
113+
await fs.writeFile(testFile, content);
114+
115+
const result = await readFileContent(testFile);
116+
117+
expect(result).toBe(content);
118+
});
119+
120+
it('should throw error for non-existent file', async () => {
121+
const nonExistent = path.join(tempDir, 'does-not-exist.txt');
122+
123+
await expect(readFileContent(nonExistent)).rejects.toThrow('File not found');
124+
});
125+
126+
it('should throw error for empty file', async () => {
127+
await fs.writeFile(testFile, '');
128+
129+
await expect(readFileContent(testFile)).rejects.toThrow('File is empty');
130+
});
131+
132+
it('should throw error for whitespace-only file', async () => {
133+
await fs.writeFile(testFile, ' \n \t \n ');
134+
135+
await expect(readFileContent(testFile)).rejects.toThrow('File is empty');
136+
});
137+
138+
it('should handle files with leading/trailing whitespace', async () => {
139+
const content = ' \n Content \n ';
140+
await fs.writeFile(testFile, content);
141+
142+
const result = await readFileContent(testFile);
143+
144+
expect(result).toBe(content);
145+
expect(result.trim()).toBe('Content');
146+
});
147+
148+
it('should handle large files', async () => {
149+
const content = 'x'.repeat(10000);
150+
await fs.writeFile(testFile, content);
151+
152+
const result = await readFileContent(testFile);
153+
154+
expect(result.length).toBe(10000);
155+
});
156+
157+
it('should handle files with special characters', async () => {
158+
const content = 'Hello 🚀 World\n中文\nΨ';
159+
await fs.writeFile(testFile, content, 'utf-8');
160+
161+
const result = await readFileContent(testFile);
162+
163+
expect(result).toBe(content);
164+
});
165+
});
166+
167+
describe('prepareFileForSearch', () => {
168+
let tempDir: string;
169+
let testFile: string;
170+
171+
beforeEach(async () => {
172+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'file-test-'));
173+
testFile = path.join(tempDir, 'test.txt');
174+
});
175+
176+
afterEach(async () => {
177+
await fs.rm(tempDir, { recursive: true, force: true });
178+
});
179+
180+
it('should prepare file for search', async () => {
181+
const content = 'Test content';
182+
await fs.writeFile(testFile, content);
183+
184+
const result = await prepareFileForSearch(tempDir, 'test.txt');
185+
186+
expect(result.content).toBe(content);
187+
expect(result.absolutePath).toBe(testFile);
188+
expect(result.relativePath).toBe('test.txt');
189+
});
190+
191+
it('should handle nested directories', async () => {
192+
const subDir = path.join(tempDir, 'src', 'utils');
193+
await fs.mkdir(subDir, { recursive: true });
194+
const nestedFile = path.join(subDir, 'helper.ts');
195+
await fs.writeFile(nestedFile, 'export function helper() {}');
196+
197+
const result = await prepareFileForSearch(tempDir, 'src/utils/helper.ts');
198+
199+
expect(result.content).toContain('helper');
200+
expect(result.relativePath).toBe('src/utils/helper.ts');
201+
});
202+
203+
it('should return correct FileContentResult structure', async () => {
204+
await fs.writeFile(testFile, 'content');
205+
206+
const result = await prepareFileForSearch(tempDir, 'test.txt');
207+
208+
expect(result).toHaveProperty('content');
209+
expect(result).toHaveProperty('absolutePath');
210+
expect(result).toHaveProperty('relativePath');
211+
expect(typeof result.content).toBe('string');
212+
expect(typeof result.absolutePath).toBe('string');
213+
expect(typeof result.relativePath).toBe('string');
214+
});
215+
216+
it('should throw error for non-existent file', async () => {
217+
await expect(prepareFileForSearch(tempDir, 'nonexistent.txt')).rejects.toThrow(
218+
'File not found'
219+
);
220+
});
221+
222+
it('should throw error for empty file', async () => {
223+
await fs.writeFile(testFile, '');
224+
225+
await expect(prepareFileForSearch(tempDir, 'test.txt')).rejects.toThrow('File is empty');
226+
});
227+
228+
it('should handle absolute path input', async () => {
229+
await fs.writeFile(testFile, 'content');
230+
231+
const result = await prepareFileForSearch(tempDir, testFile);
232+
233+
expect(result.content).toBe('content');
234+
expect(result.relativePath).toBe('test.txt');
235+
});
236+
});
237+
});

packages/cli/src/utils/file.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* File utility functions for CLI commands
3+
* Pure functions for file operations and validation
4+
*/
5+
6+
import * as fs from 'node:fs/promises';
7+
import * as path from 'node:path';
8+
9+
/**
10+
* Resolve a file path relative to the repository root
11+
*/
12+
export function resolveFilePath(repositoryPath: string, filePath: string): string {
13+
return path.resolve(repositoryPath, filePath);
14+
}
15+
16+
/**
17+
* Normalize a file path to be relative to repository root
18+
*/
19+
export function normalizeFilePath(repositoryPath: string, absolutePath: string): string {
20+
return path.relative(repositoryPath, absolutePath);
21+
}
22+
23+
/**
24+
* Read and validate file content
25+
* @throws Error if file doesn't exist or is empty
26+
*/
27+
export async function readFileContent(filePath: string): Promise<string> {
28+
// Check if file exists
29+
try {
30+
await fs.access(filePath);
31+
} catch {
32+
throw new Error(`File not found: ${filePath}`);
33+
}
34+
35+
// Read file content
36+
const content = await fs.readFile(filePath, 'utf-8');
37+
38+
// Validate content
39+
if (content.trim().length === 0) {
40+
throw new Error(`File is empty: ${filePath}`);
41+
}
42+
43+
return content;
44+
}
45+
46+
/**
47+
* Result of reading file for similarity search
48+
*/
49+
export interface FileContentResult {
50+
content: string;
51+
absolutePath: string;
52+
relativePath: string;
53+
}
54+
55+
/**
56+
* Prepare a file for similarity search
57+
* Resolves path, reads content, and normalizes paths
58+
*/
59+
export async function prepareFileForSearch(
60+
repositoryPath: string,
61+
filePath: string
62+
): Promise<FileContentResult> {
63+
const absolutePath = resolveFilePath(repositoryPath, filePath);
64+
const content = await readFileContent(absolutePath);
65+
const relativePath = normalizeFilePath(repositoryPath, absolutePath);
66+
67+
return {
68+
content,
69+
absolutePath,
70+
relativePath,
71+
};
72+
}

0 commit comments

Comments
 (0)