From ac9621c151ff74d425262161d896c339ea1a05d5 Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Fri, 6 Mar 2026 11:58:50 -0700 Subject: [PATCH] feat: search-code source enrichment, file-outline/symbol-lookup MCP tools, stale symbol pruning (#138, #139, #140) - SearchCodeTool now returns actual source code via SymbolIndexService lookup - Add FileOutlineTool and SymbolLookupTool MCP tools for symbol navigation - Add pruneStaleSymbols() to CodeIndexerService, wired into vectorize-code and reindex:all - Add getSymbolSourceByNameAndFile() to SymbolIndexService - Fix ScrollPoints parameter order in pruneStaleSymbols - 1194 tests passing, PHPStan clean Closes #138, closes #139, closes #140 Co-Authored-By: Claude Opus 4.6 --- app/Commands/ReindexAllCommand.php | 5 + app/Commands/VectorizeCodeCommand.php | 5 + app/Mcp/Servers/KnowledgeServer.php | 4 + app/Mcp/Tools/FileOutlineTool.php | 55 +++++++ app/Mcp/Tools/SearchCodeTool.php | 35 +++-- app/Mcp/Tools/SymbolLookupTool.php | 72 +++++++++ app/Services/CodeIndexerService.php | 77 +++++++++- app/Services/SymbolIndexService.php | 51 +++++++ .../Commands/VectorizeCodeCommandTest.php | 12 ++ tests/Unit/Mcp/Tools/FileOutlineToolTest.php | 81 ++++++++++ tests/Unit/Mcp/Tools/SearchCodeToolTest.php | 42 +++++- tests/Unit/Mcp/Tools/SymbolLookupToolTest.php | 93 ++++++++++++ .../Unit/Services/CodeIndexerServiceTest.php | 139 ++++++++++++++++++ 13 files changed, 658 insertions(+), 13 deletions(-) create mode 100644 app/Mcp/Tools/FileOutlineTool.php create mode 100644 app/Mcp/Tools/SymbolLookupTool.php create mode 100644 tests/Unit/Mcp/Tools/FileOutlineToolTest.php create mode 100644 tests/Unit/Mcp/Tools/SymbolLookupToolTest.php diff --git a/app/Commands/ReindexAllCommand.php b/app/Commands/ReindexAllCommand.php index 8e5b4f0..6219f80 100644 --- a/app/Commands/ReindexAllCommand.php +++ b/app/Commands/ReindexAllCommand.php @@ -112,6 +112,11 @@ public function handle( $vectorized++; note(" Vectorized: {$vResult['success']}/{$vResult['total']} ({$vResult['failed']} failed)"); + + $pruneResult = $codeIndexer->pruneStaleSymbols($indexPath, $repo); + if ($pruneResult['deleted'] > 0) { + note(" Pruned {$pruneResult['deleted']} stale symbols"); + } } info("Done: {$indexed} indexed, {$vectorized} vectorized, {$errors} errors"); diff --git a/app/Commands/VectorizeCodeCommand.php b/app/Commands/VectorizeCodeCommand.php index a221591..4db1462 100644 --- a/app/Commands/VectorizeCodeCommand.php +++ b/app/Commands/VectorizeCodeCommand.php @@ -78,6 +78,11 @@ function (int $success, int $failed, int $total) use (&$lastReport): void { info("Done: {$result['success']}/{$result['total']} symbols vectorized, {$result['failed']} failed"); + $pruneResult = $codeIndexer->pruneStaleSymbols($indexPath, $repo); + if ($pruneResult['deleted'] > 0) { + note("Pruned {$pruneResult['deleted']} stale symbols ({$pruneResult['total_checked']} checked)"); + } + return self::SUCCESS; } } diff --git a/app/Mcp/Servers/KnowledgeServer.php b/app/Mcp/Servers/KnowledgeServer.php index 748bc7f..4a39d8a 100644 --- a/app/Mcp/Servers/KnowledgeServer.php +++ b/app/Mcp/Servers/KnowledgeServer.php @@ -6,10 +6,12 @@ use App\Mcp\Tools\ContextTool; use App\Mcp\Tools\CorrectTool; +use App\Mcp\Tools\FileOutlineTool; use App\Mcp\Tools\RecallTool; use App\Mcp\Tools\RememberTool; use App\Mcp\Tools\SearchCodeTool; use App\Mcp\Tools\StatsTool; +use App\Mcp\Tools\SymbolLookupTool; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Attributes\Instructions; use Laravel\Mcp\Server\Attributes\Name; @@ -27,6 +29,8 @@ class KnowledgeServer extends Server ContextTool::class, StatsTool::class, SearchCodeTool::class, + FileOutlineTool::class, + SymbolLookupTool::class, ]; protected array $resources = []; diff --git a/app/Mcp/Tools/FileOutlineTool.php b/app/Mcp/Tools/FileOutlineTool.php new file mode 100644 index 0000000..57549b0 --- /dev/null +++ b/app/Mcp/Tools/FileOutlineTool.php @@ -0,0 +1,55 @@ +get('file'); + + if (! is_string($file) || $file === '') { + return Response::error('A file path is required.'); + } + + $repo = is_string($request->get('repo')) ? $request->get('repo') : 'local/knowledge'; + + $outline = $this->symbolIndex->getFileOutline($file, $repo); + + return Response::text(json_encode([ + 'file' => $file, + 'repo' => $repo, + 'symbols' => $outline, + 'total' => count($outline), + ], JSON_THROW_ON_ERROR)); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'file' => $schema->string() + ->description('Relative file path within the repo (e.g., "app/Services/UserService.php")') + ->required(), + 'repo' => $schema->string() + ->description('Repository identifier (e.g., "local/pstrax-laravel"). Defaults to "local/knowledge".'), + ]; + } +} diff --git a/app/Mcp/Tools/SearchCodeTool.php b/app/Mcp/Tools/SearchCodeTool.php index e4ca98a..8200426 100644 --- a/app/Mcp/Tools/SearchCodeTool.php +++ b/app/Mcp/Tools/SearchCodeTool.php @@ -5,6 +5,7 @@ namespace App\Mcp\Tools; use App\Services\CodeIndexerService; +use App\Services\SymbolIndexService; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -20,6 +21,7 @@ class SearchCodeTool extends Tool { public function __construct( private readonly CodeIndexerService $codeIndexer, + private readonly SymbolIndexService $symbolIndex, ) {} public function handle(Request $request): Response @@ -47,16 +49,29 @@ public function handle(Request $request): Response ], JSON_THROW_ON_ERROR)); } - $formatted = array_map(fn (array $r): array => [ - 'filepath' => $r['filepath'], - 'repo' => $r['repo'], - 'language' => $r['language'], - 'symbol_name' => $r['symbol_name'] ?? null, - 'symbol_kind' => $r['symbol_kind'] ?? null, - 'line' => $r['start_line'], - 'score' => round($r['score'], 3), - 'content' => $r['content'], - ], $results); + $formatted = array_map(function (array $r): array { + $source = null; + $symbolName = $r['symbol_name'] ?? null; + if (is_string($symbolName) && $symbolName !== '') { + $source = $this->symbolIndex->getSymbolSourceByNameAndFile( + $symbolName, + $r['filepath'], + $r['repo'], + ); + } + + return [ + 'filepath' => $r['filepath'], + 'repo' => $r['repo'], + 'language' => $r['language'], + 'symbol_name' => $symbolName, + 'symbol_kind' => $r['symbol_kind'] ?? null, + 'line' => $r['start_line'], + 'score' => round($r['score'], 3), + 'content' => $r['content'], + 'source' => $source, + ]; + }, $results); return Response::text(json_encode([ 'results' => $formatted, diff --git a/app/Mcp/Tools/SymbolLookupTool.php b/app/Mcp/Tools/SymbolLookupTool.php new file mode 100644 index 0000000..46d5394 --- /dev/null +++ b/app/Mcp/Tools/SymbolLookupTool.php @@ -0,0 +1,72 @@ +get('symbol_id'); + + if (! is_string($symbolId) || $symbolId === '') { + return Response::error('A symbol_id is required.'); + } + + $repo = is_string($request->get('repo')) ? $request->get('repo') : 'local/knowledge'; + $includeSource = $request->get('include_source') !== false; + + $symbol = $this->symbolIndex->getSymbol($symbolId, $repo); + + if ($symbol === null) { + return Response::error("Symbol '{$symbolId}' not found in {$repo}."); + } + + $result = [ + 'id' => $symbol['id'] ?? $symbolId, + 'kind' => $symbol['kind'] ?? '', + 'name' => $symbol['name'] ?? '', + 'file' => $symbol['file'] ?? '', + 'line' => $symbol['line'] ?? 0, + 'signature' => $symbol['signature'] ?? '', + 'summary' => $symbol['summary'] ?? '', + 'docstring' => $symbol['docstring'] ?? '', + ]; + + if ($includeSource) { + $result['source'] = $this->symbolIndex->getSymbolSource($symbolId, $repo); + } + + return Response::text(json_encode($result, JSON_THROW_ON_ERROR)); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'symbol_id' => $schema->string() + ->description('The symbol ID from a search or outline result') + ->required(), + 'repo' => $schema->string() + ->description('Repository identifier (e.g., "local/pstrax-laravel"). Defaults to "local/knowledge".'), + 'include_source' => $schema->boolean() + ->description('Whether to include the full source code (default: true)'), + ]; + } +} diff --git a/app/Services/CodeIndexerService.php b/app/Services/CodeIndexerService.php index 3262090..384e4c4 100644 --- a/app/Services/CodeIndexerService.php +++ b/app/Services/CodeIndexerService.php @@ -7,7 +7,9 @@ use App\Contracts\EmbeddingServiceInterface; use App\Integrations\Qdrant\QdrantConnector; use App\Integrations\Qdrant\Requests\CreateCollection; +use App\Integrations\Qdrant\Requests\DeletePoints; use App\Integrations\Qdrant\Requests\GetCollectionInfo; +use App\Integrations\Qdrant\Requests\ScrollPoints; use App\Integrations\Qdrant\Requests\SearchPoints; use App\Integrations\Qdrant\Requests\UpsertPoints; use Symfony\Component\Finder\Finder; @@ -171,7 +173,7 @@ public function indexFile(string $filepath, string $repo): array * Search code semantically. * * @param array{repo?: string, language?: string} $filters - * @return array}> + * @return array, symbol_name: string|null, symbol_kind: string|null, signature: string|null, start_line: int, end_line: int}> */ public function search(string $query, int $limit = 10, array $filters = []): array { @@ -346,6 +348,79 @@ function (array $s) use ($allowedKinds, $language): bool { return ['success' => $success, 'failed' => $failed, 'total' => $total]; } + /** + * Remove vectorized symbols that no longer exist in the current index. + * + * @return array{deleted: int, total_checked: int} + */ + public function pruneStaleSymbols(string $indexPath, string $repo): array + { + $content = @file_get_contents($indexPath); + if ($content === false) { + return ['deleted' => 0, 'total_checked' => 0]; + } + + /** @var array{symbols: array>}|null $index */ + $index = json_decode($content, true); + if (! is_array($index) || ! isset($index['symbols'])) { + return ['deleted' => 0, 'total_checked' => 0]; + } + + // Build set of valid point IDs from the current index + $validIds = []; + foreach ($index['symbols'] as $symbol) { + $id = md5("{$repo}:{$symbol['file']}:{$symbol['name']}:{$symbol['line']}"); + $validIds[$id] = true; + } + + // Scroll through all points for this repo in Qdrant + $staleIds = []; + $totalChecked = 0; + $offset = null; + + do { + $filter = ['must' => [['key' => 'repo', 'match' => ['value' => $repo]]]]; + $response = $this->connector->send( + new ScrollPoints(self::COLLECTION_NAME, 100, $filter, $offset) + ); + + if (! $response->successful()) { + break; + } + + $data = $response->json(); + $points = $data['result']['points'] ?? []; + $offset = $data['result']['next_page_offset'] ?? null; + + foreach ($points as $point) { + // Only check symbol points (they have symbol_name in payload) + if (! isset($point['payload']['symbol_name'])) { + continue; + } + + $totalChecked++; + $pointId = $point['id']; + + if (! isset($validIds[$pointId])) { + $staleIds[] = $pointId; + } + } + } while ($offset !== null && $points !== []); + + // Delete stale points in batches + $deleted = 0; + foreach (array_chunk($staleIds, 100) as $batch) { + $response = $this->connector->send( + new DeletePoints(self::COLLECTION_NAME, $batch) + ); + if ($response->successful()) { + $deleted += count($batch); + } + } + + return ['deleted' => $deleted, 'total_checked' => $totalChecked]; + } + /** * Build searchable text from a tree-sitter symbol. * diff --git a/app/Services/SymbolIndexService.php b/app/Services/SymbolIndexService.php index 63e769f..6a07df1 100644 --- a/app/Services/SymbolIndexService.php +++ b/app/Services/SymbolIndexService.php @@ -302,6 +302,40 @@ private function loadIndex(string $repo): ?array return $data; } + /** + * Get symbol source code by name and file path. + */ + public function getSymbolSourceByNameAndFile(string $name, string $filePath, string $repo = 'local/knowledge'): ?string + { + $index = $this->loadIndex($repo); + if ($index === null) { + return null; + } + + $symbol = $this->findSymbolByNameAndFile($index, $name, $filePath); + if ($symbol === null) { + return null; + } + + $contentPath = $this->contentFilePath($repo, $symbol['file']); + if ($contentPath === null || ! file_exists($contentPath)) { + return null; + } + + $handle = fopen($contentPath, 'rb'); + // @codeCoverageIgnoreStart + if ($handle === false) { + return null; + } + // @codeCoverageIgnoreEnd + + fseek($handle, $symbol['byte_offset']); + $source = fread($handle, $symbol['byte_length']); + fclose($handle); + + return $source !== false ? $source : null; + } + /** * Find a symbol by ID in an index. * @@ -319,6 +353,23 @@ private function findSymbol(array $index, string $symbolId): ?array return null; } + /** + * Find a symbol by name and file path in an index. + * + * @param array $index + * @return array|null + */ + private function findSymbolByNameAndFile(array $index, string $name, string $filePath): ?array + { + foreach ($index['symbols'] as $symbol) { + if (($symbol['name'] ?? '') === $name && ($symbol['file'] ?? '') === $filePath) { + return $symbol; + } + } + + return null; + } + /** * Calculate weighted search score for a symbol. * diff --git a/tests/Feature/Commands/VectorizeCodeCommandTest.php b/tests/Feature/Commands/VectorizeCodeCommandTest.php index 845dadd..fead78d 100644 --- a/tests/Feature/Commands/VectorizeCodeCommandTest.php +++ b/tests/Feature/Commands/VectorizeCodeCommandTest.php @@ -57,6 +57,10 @@ ->once() ->andReturn(['success' => 5, 'failed' => 1, 'total' => 6]); + $this->codeIndexerMock->shouldReceive('pruneStaleSymbols') + ->once() + ->andReturn(['deleted' => 0, 'total_checked' => 0]); + $this->artisan('vectorize-code', ['repo' => 'local/test-vectorize']) ->assertSuccessful(); @@ -79,6 +83,10 @@ ->once() ->andReturn(['success' => 3, 'failed' => 0, 'total' => 3]); + $this->codeIndexerMock->shouldReceive('pruneStaleSymbols') + ->once() + ->andReturn(['deleted' => 0, 'total_checked' => 0]); + $this->artisan('vectorize-code', [ 'repo' => 'local/test-vectorize', '--kind' => ['class', 'method'], @@ -103,6 +111,10 @@ ->once() ->andReturn(['success' => 2, 'failed' => 0, 'total' => 2]); + $this->codeIndexerMock->shouldReceive('pruneStaleSymbols') + ->once() + ->andReturn(['deleted' => 0, 'total_checked' => 0]); + $this->artisan('vectorize-code', [ 'repo' => 'local/test-vectorize', '--language' => 'php', diff --git a/tests/Unit/Mcp/Tools/FileOutlineToolTest.php b/tests/Unit/Mcp/Tools/FileOutlineToolTest.php new file mode 100644 index 0000000..512dcce --- /dev/null +++ b/tests/Unit/Mcp/Tools/FileOutlineToolTest.php @@ -0,0 +1,81 @@ +group('mcp-tools'); + +beforeEach(function (): void { + $this->symbolIndex = Mockery::mock(SymbolIndexService::class); + $this->tool = new FileOutlineTool($this->symbolIndex); +}); + +describe('file outline tool', function (): void { + it('returns error when file is missing', function (): void { + $request = new Request([]); + $response = $this->tool->handle($request); + expect($response->isError())->toBeTrue(); + }); + + it('returns error when file is empty string', function (): void { + $request = new Request(['file' => '']); + $response = $this->tool->handle($request); + expect($response->isError())->toBeTrue(); + }); + + it('returns empty outline for unknown file', function (): void { + $this->symbolIndex->shouldReceive('getFileOutline') + ->with('app/Unknown.php', 'local/knowledge') + ->once() + ->andReturn([]); + + $request = new Request(['file' => 'app/Unknown.php']); + $response = $this->tool->handle($request); + + $data = json_decode((string) $response->content(), true); + expect($data['symbols'])->toBeEmpty() + ->and($data['total'])->toBe(0) + ->and($data['file'])->toBe('app/Unknown.php'); + }); + + it('returns symbol hierarchy', function (): void { + $this->symbolIndex->shouldReceive('getFileOutline') + ->with('app/Services/UserService.php', 'local/pstrax') + ->once() + ->andReturn([ + [ + 'id' => 'sym-1', + 'kind' => 'class', + 'name' => 'UserService', + 'signature' => 'class UserService', + 'summary' => '', + 'line' => 10, + 'children' => [ + ['id' => 'sym-2', 'kind' => 'method', 'name' => 'find', 'signature' => 'public function find(int $id)', 'summary' => '', 'line' => 15], + ], + ], + ]); + + $request = new Request(['file' => 'app/Services/UserService.php', 'repo' => 'local/pstrax']); + $response = $this->tool->handle($request); + + $data = json_decode((string) $response->content(), true); + expect($data['total'])->toBe(1) + ->and($data['symbols'][0]['name'])->toBe('UserService') + ->and($data['symbols'][0]['children'][0]['name'])->toBe('find') + ->and($data['repo'])->toBe('local/pstrax'); + }); + + it('defaults repo to local/knowledge', function (): void { + $this->symbolIndex->shouldReceive('getFileOutline') + ->withArgs(fn ($f, $r) => $r === 'local/knowledge') + ->once() + ->andReturn([]); + + $request = new Request(['file' => 'test.php']); + $this->tool->handle($request); + }); +}); diff --git a/tests/Unit/Mcp/Tools/SearchCodeToolTest.php b/tests/Unit/Mcp/Tools/SearchCodeToolTest.php index ecc7137..06d7fd2 100644 --- a/tests/Unit/Mcp/Tools/SearchCodeToolTest.php +++ b/tests/Unit/Mcp/Tools/SearchCodeToolTest.php @@ -4,13 +4,15 @@ use App\Mcp\Tools\SearchCodeTool; use App\Services\CodeIndexerService; +use App\Services\SymbolIndexService; use Laravel\Mcp\Request; uses()->group('mcp-tools'); beforeEach(function (): void { $this->codeIndexer = Mockery::mock(CodeIndexerService::class); - $this->tool = new SearchCodeTool($this->codeIndexer); + $this->symbolIndex = Mockery::mock(SymbolIndexService::class); + $this->tool = new SearchCodeTool($this->codeIndexer, $this->symbolIndex); }); describe('search code tool', function (): void { @@ -46,7 +48,7 @@ ->and($data['meta']['query'])->toBe('authentication middleware'); }); - it('returns formatted results', function (): void { + it('returns formatted results with source code', function (): void { $this->codeIndexer->shouldReceive('search') ->once() ->andReturn([ @@ -65,6 +67,11 @@ ], ]); + $this->symbolIndex->shouldReceive('getSymbolSourceByNameAndFile') + ->with('Auth', '/app/Http/Middleware/Auth.php', 'local/pstrax-laravel') + ->once() + ->andReturn('class Auth extends Middleware { public function handle() {} }'); + $request = new Request(['query' => 'authentication middleware']); $response = $this->tool->handle($request); @@ -75,9 +82,40 @@ ->and($data['results'][0]['symbol_kind'])->toBe('class') ->and($data['results'][0]['score'])->toBe(0.92) ->and($data['results'][0]['line'])->toBe(5) + ->and($data['results'][0]['source'])->toBe('class Auth extends Middleware { public function handle() {} }') ->and($data['meta']['total'])->toBe(1); }); + it('returns null source when symbol not found in index', function (): void { + $this->codeIndexer->shouldReceive('search') + ->once() + ->andReturn([ + [ + 'filepath' => '/app/Foo.php', + 'repo' => 'local/test', + 'language' => 'php', + 'content' => 'class Foo {}', + 'score' => 0.8, + 'functions' => [], + 'symbol_name' => 'Foo', + 'symbol_kind' => 'class', + 'signature' => 'class Foo', + 'start_line' => 1, + 'end_line' => 5, + ], + ]); + + $this->symbolIndex->shouldReceive('getSymbolSourceByNameAndFile') + ->once() + ->andReturnNull(); + + $request = new Request(['query' => 'test query']); + $response = $this->tool->handle($request); + + $data = json_decode((string) $response->content(), true); + expect($data['results'][0]['source'])->toBeNull(); + }); + it('passes repo filter to search', function (): void { $this->codeIndexer->shouldReceive('search') ->withArgs(function (string $query, int $limit, array $filters): bool { diff --git a/tests/Unit/Mcp/Tools/SymbolLookupToolTest.php b/tests/Unit/Mcp/Tools/SymbolLookupToolTest.php new file mode 100644 index 0000000..b58bc9e --- /dev/null +++ b/tests/Unit/Mcp/Tools/SymbolLookupToolTest.php @@ -0,0 +1,93 @@ +group('mcp-tools'); + +beforeEach(function (): void { + $this->symbolIndex = Mockery::mock(SymbolIndexService::class); + $this->tool = new SymbolLookupTool($this->symbolIndex); +}); + +describe('symbol lookup tool', function (): void { + it('returns error when symbol_id is missing', function (): void { + $request = new Request([]); + $response = $this->tool->handle($request); + expect($response->isError())->toBeTrue(); + }); + + it('returns error when symbol_id is empty', function (): void { + $request = new Request(['symbol_id' => '']); + $response = $this->tool->handle($request); + expect($response->isError())->toBeTrue(); + }); + + it('returns error when symbol not found', function (): void { + $this->symbolIndex->shouldReceive('getSymbol') + ->with('nonexistent', 'local/knowledge') + ->once() + ->andReturnNull(); + + $request = new Request(['symbol_id' => 'nonexistent']); + $response = $this->tool->handle($request); + expect($response->isError())->toBeTrue(); + }); + + it('returns symbol with source', function (): void { + $this->symbolIndex->shouldReceive('getSymbol') + ->with('sym-1', 'local/pstrax') + ->once() + ->andReturn([ + 'id' => 'sym-1', + 'kind' => 'class', + 'name' => 'UserService', + 'file' => 'app/Services/UserService.php', + 'line' => 10, + 'signature' => 'class UserService', + 'summary' => 'Handles users', + 'docstring' => '/** User service */', + ]); + + $this->symbolIndex->shouldReceive('getSymbolSource') + ->with('sym-1', 'local/pstrax') + ->once() + ->andReturn('class UserService { }'); + + $request = new Request(['symbol_id' => 'sym-1', 'repo' => 'local/pstrax']); + $response = $this->tool->handle($request); + + $data = json_decode((string) $response->content(), true); + expect($data['name'])->toBe('UserService') + ->and($data['source'])->toBe('class UserService { }') + ->and($data['kind'])->toBe('class'); + }); + + it('excludes source when include_source is false', function (): void { + $this->symbolIndex->shouldReceive('getSymbol') + ->with('sym-1', 'local/knowledge') + ->once() + ->andReturn([ + 'id' => 'sym-1', + 'kind' => 'method', + 'name' => 'find', + 'file' => 'app/Foo.php', + 'line' => 5, + 'signature' => 'public function find()', + 'summary' => '', + 'docstring' => '', + ]); + + $this->symbolIndex->shouldNotReceive('getSymbolSource'); + + $request = new Request(['symbol_id' => 'sym-1', 'include_source' => false]); + $response = $this->tool->handle($request); + + $data = json_decode((string) $response->content(), true); + expect($data['name'])->toBe('find') + ->and(array_key_exists('source', $data))->toBeFalse(); + }); +}); diff --git a/tests/Unit/Services/CodeIndexerServiceTest.php b/tests/Unit/Services/CodeIndexerServiceTest.php index f8c1f80..3ed0058 100644 --- a/tests/Unit/Services/CodeIndexerServiceTest.php +++ b/tests/Unit/Services/CodeIndexerServiceTest.php @@ -1238,3 +1238,142 @@ function (int $success, int $failed, int $total) use (&$progressCalled): void { expect($property->getValue($service))->toBe(768); }); }); + +describe('pruneStaleSymbols', function (): void { + it('returns zeros for non-existent file', function (): void { + $result = $this->service->pruneStaleSymbols('/nonexistent/file.json', 'local/test'); + + expect($result)->toMatchArray(['deleted' => 0, 'total_checked' => 0]); + }); + + it('returns zeros for invalid JSON', function (): void { + $tempFile = tempnam(sys_get_temp_dir(), 'idx_'); + file_put_contents($tempFile, 'not json'); + + $result = $this->service->pruneStaleSymbols($tempFile, 'local/test'); + + expect($result)->toMatchArray(['deleted' => 0, 'total_checked' => 0]); + unlink($tempFile); + }); + + it('deletes stale points', function (): void { + $indexData = [ + 'symbols' => [ + ['name' => 'Foo', 'file' => 'Foo.php', 'line' => 1, 'kind' => 'class'], + ], + ]; + $tempFile = tempnam(sys_get_temp_dir(), 'idx_'); + file_put_contents($tempFile, json_encode($indexData)); + + $validId = md5('local/test:Foo.php:Foo:1'); + $staleId = md5('local/test:Bar.php:Bar:1'); + + // Scroll returns one valid and one stale point + $scrollResponse = createCodeMockResponse(true, 200, [ + 'result' => [ + 'points' => [ + ['id' => $validId, 'payload' => ['symbol_name' => 'Foo', 'repo' => 'local/test']], + ['id' => $staleId, 'payload' => ['symbol_name' => 'Bar', 'repo' => 'local/test']], + ], + 'next_page_offset' => null, + ], + ]); + + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(\App\Integrations\Qdrant\Requests\ScrollPoints::class)) + ->once() + ->andReturn($scrollResponse); + + $deleteResponse = createCodeMockResponse(true); + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(\App\Integrations\Qdrant\Requests\DeletePoints::class)) + ->once() + ->andReturn($deleteResponse); + + $result = $this->service->pruneStaleSymbols($tempFile, 'local/test'); + + expect($result['deleted'])->toBe(1) + ->and($result['total_checked'])->toBe(2); + + unlink($tempFile); + }); + + it('skips non-symbol points (file chunks)', function (): void { + $indexData = ['symbols' => []]; + $tempFile = tempnam(sys_get_temp_dir(), 'idx_'); + file_put_contents($tempFile, json_encode($indexData)); + + $scrollResponse = createCodeMockResponse(true, 200, [ + 'result' => [ + 'points' => [ + ['id' => 'chunk-1', 'payload' => ['filepath' => 'Foo.php', 'repo' => 'local/test']], + ], + 'next_page_offset' => null, + ], + ]); + + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(\App\Integrations\Qdrant\Requests\ScrollPoints::class)) + ->once() + ->andReturn($scrollResponse); + + $result = $this->service->pruneStaleSymbols($tempFile, 'local/test'); + + expect($result['deleted'])->toBe(0) + ->and($result['total_checked'])->toBe(0); + + unlink($tempFile); + }); + + it('handles scroll failure gracefully', function (): void { + $indexData = ['symbols' => [['name' => 'Foo', 'file' => 'Foo.php', 'line' => 1, 'kind' => 'class']]]; + $tempFile = tempnam(sys_get_temp_dir(), 'idx_'); + file_put_contents($tempFile, json_encode($indexData)); + + $scrollResponse = createCodeMockResponse(false, 500); + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(\App\Integrations\Qdrant\Requests\ScrollPoints::class)) + ->once() + ->andReturn($scrollResponse); + + $result = $this->service->pruneStaleSymbols($tempFile, 'local/test'); + + expect($result['deleted'])->toBe(0) + ->and($result['total_checked'])->toBe(0); + + unlink($tempFile); + }); + + it('reports no deletions when all points are current', function (): void { + $indexData = [ + 'symbols' => [ + ['name' => 'Foo', 'file' => 'Foo.php', 'line' => 1, 'kind' => 'class'], + ], + ]; + $tempFile = tempnam(sys_get_temp_dir(), 'idx_'); + file_put_contents($tempFile, json_encode($indexData)); + + $validId = md5('local/test:Foo.php:Foo:1'); + + $scrollResponse = createCodeMockResponse(true, 200, [ + 'result' => [ + 'points' => [ + ['id' => $validId, 'payload' => ['symbol_name' => 'Foo', 'repo' => 'local/test']], + ], + 'next_page_offset' => null, + ], + ]); + + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(\App\Integrations\Qdrant\Requests\ScrollPoints::class)) + ->once() + ->andReturn($scrollResponse); + + $result = $this->service->pruneStaleSymbols($tempFile, 'local/test'); + + expect($result['deleted'])->toBe(0) + ->and($result['total_checked'])->toBe(1); + + unlink($tempFile); + }); +});