diff --git a/.env.example b/.env.example index 2fba421..e3f6b1e 100644 --- a/.env.example +++ b/.env.example @@ -16,9 +16,9 @@ OLLAMA_PORT=11434 OLLAMA_MODEL=llama3.2:3b # Centralized Sync (optional, for multi-machine knowledge sharing) -ODIN_SYNC_ENABLED=false -# ODIN_URL=http://your-server:8080 -# ODIN_API_TOKEN=your-token +REMOTE_SYNC_ENABLED=false +# REMOTE_SYNC_URL=http://your-server:8080 +# REMOTE_SYNC_TOKEN=your-token # Cloud API Sync (optional) # PREFRONTAL_API_URL=http://your-api:8080 diff --git a/CLAUDE.md b/CLAUDE.md index 340e55b..fd8c661 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ vendor/bin/pest tests/Feature/Commands/KnowledgeSearchCommandTest.php | `QdrantService` | All vector DB operations (upsert, search, delete, collections) | | `EmbeddingService` | Text-to-vector conversion | | `KnowledgeCacheService` | Redis caching for sub-200ms queries | -| `OdinSyncService` | Background sync to centralized Odin server | +| `RemoteSyncService` | Background sync to centralized remote server | | `WriteGateService` | Filters knowledge quality before persistence | | `EntryMetadataService` | Staleness detection, confidence degradation | | `CorrectionService` | Multi-tier correction propagation | diff --git a/MISSION.md b/MISSION.md index bd97a56..8254f26 100644 --- a/MISSION.md +++ b/MISSION.md @@ -16,7 +16,7 @@ Knowledge is a command-line tool that captures technical decisions, learnings, a ### 2. Offline-First, Sync-Later - **Local Qdrant**: Full functionality without network -- **Background sync**: Queue writes, sync to Odin every N minutes +- **Background sync**: Queue writes, sync to remote server every N minutes - **Read-optimized**: Instant local reads, async writes to central DB - **Graceful degradation**: Always functional, network optional @@ -82,10 +82,10 @@ Knowledge is a command-line tool that captures technical decisions, learnings, a │ Background sync ▼ ┌─────────────────────────────────────────┐ -│ Odin (Centralized) │ +│ Remote Server (Centralized) │ │ - Same stack as local │ -│ - Team knowledge repository │ -│ - Exposed via Tailscale mesh │ +│ - Team/shared knowledge repository │ +│ - Exposed via VPN, Tailscale, or LAN │ └─────────────────────────────────────────┘ ``` diff --git a/README.md b/README.md index a8c8c92..0ecb124 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Knowledge CLI +[![Sentinel Gate](https://github.com/conduit-ui/knowledge/actions/workflows/gate.yml/badge.svg)](https://github.com/conduit-ui/knowledge/actions/workflows/gate.yml) + AI-powered knowledge base with semantic search, Qdrant vector storage, and Ollama intelligence. ## What It Does @@ -34,7 +36,7 @@ Embedding Server (sentence-transformers) ↓ Ollama (optional - async auto-tagging via background queue) ↓ -Odin Sync (background sync to centralized server) +Remote Sync (optional - background sync to centralized server) ``` No SQLite. No schema migrations. Pure vector storage. Per-project isolation via auto-detected git namespaces. @@ -96,7 +98,7 @@ All commands support `--project=` to target a specific project namespace a | Command | Description | |---------|-------------| | `sync` | Bidirectional sync (--push / --pull) | -| `sync:odin` | Background sync to Odin central server | +| `sync:remote` | Background sync to centralized remote server | | `sync:purge` | Purge sync queue | ### Code Intelligence @@ -162,9 +164,9 @@ REDIS_PORT=6379 OLLAMA_HOST=http://localhost:11434 ``` -### Odin (Production) +### Remote Server (Production) -Uses `docker-compose.odin.yml` with Tailscale networking for centralized knowledge sync. +Uses `docker-compose.remote.yml` to bind services to a specific network interface (e.g. Tailscale, VPN, LAN) for centralized knowledge sync across multiple machines. ## Development diff --git a/ROADMAP.md b/ROADMAP.md index 6819a2b..0ef0786 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,8 +8,8 @@ Replaced SQLite entirely with Qdrant-only architecture. No schema migrations, no ### Redis Caching Layer KnowledgeCacheService provides sub-200ms query responses through aggressive caching of embeddings, search results, and collection stats. -### Odin Background Sync -OdinSyncService syncs knowledge to centralized Odin server. Includes deletion propagation, sync purge, and bidirectional push/pull. +### Remote Background Sync +RemoteSyncService syncs knowledge to a centralized remote server. Includes deletion propagation, sync purge, and bidirectional push/pull. Useful for home labs and teams sharing a knowledge base. ### Entry Metadata & Staleness Detection EntryMetadataService tracks entry freshness with confidence degradation over time. Superseded marking instead of destructive overwrites. diff --git a/app/Commands/IndexCodeCommand.php b/app/Commands/IndexCodeCommand.php index a14d530..2021633 100644 --- a/app/Commands/IndexCodeCommand.php +++ b/app/Commands/IndexCodeCommand.php @@ -4,13 +4,12 @@ namespace App\Commands; -use App\Services\CodeIndexerService; +use App\Services\SymbolIndexService; use LaravelZero\Framework\Commands\Command; use function Laravel\Prompts\error; use function Laravel\Prompts\info; use function Laravel\Prompts\note; -use function Laravel\Prompts\progress; use function Laravel\Prompts\spin; use function Laravel\Prompts\table; use function Laravel\Prompts\warning; @@ -18,173 +17,109 @@ class IndexCodeCommand extends Command { protected $signature = 'index-code - {--path=* : Additional paths to index} - {--dry-run : Show what would be indexed without actually indexing} - {--stats : Show indexing statistics only}'; + {path? : Path to index (defaults to current directory)} + {--incremental : Only re-index changed files} + {--list : List all indexed repositories}'; - protected $description = 'Index code files for semantic search'; + protected $description = 'Index code files using tree-sitter AST parsing'; - /** @var array */ - private const DEFAULT_PATHS = []; - - public function handle(CodeIndexerService $indexer): int + public function handle(SymbolIndexService $indexer): int { - /** @var array $additionalPaths */ - $additionalPaths = (array) $this->option('path'); - $dryRun = (bool) $this->option('dry-run'); - $statsOnly = (bool) $this->option('stats'); - - $paths = array_unique(array_merge(self::DEFAULT_PATHS, $additionalPaths)); - - // Filter to existing paths - $validPaths = array_filter($paths, fn ($p): bool => is_dir($p)); - - if ($validPaths === []) { - error('No valid paths to index.'); - - return self::FAILURE; + if ((bool) $this->option('list')) { + return $this->listRepos($indexer); } - info('Code Indexer'); - note('Paths to index: '.implode(', ', array_map('basename', $validPaths))); - - // Ensure collection exists - if (! $dryRun) { - $created = spin( - fn (): bool => $indexer->ensureCollection(), - 'Ensuring code collection exists...' - ); + $pathArg = $this->argument('path'); + $path = is_string($pathArg) ? $pathArg : getcwd(); + $incremental = (bool) $this->option('incremental'); - if (! $created) { - error('Failed to create/verify code collection in Qdrant.'); + if ($path === false || ! is_dir($path)) { + error("Invalid path: {$path}"); - return self::FAILURE; - } - } - - // Collect all files first - $files = []; - foreach ($indexer->findFiles($validPaths) as $file) { - $files[] = $file; + return self::FAILURE; } - $totalFiles = count($files); - info("Found {$totalFiles} files to index."); + info('Symbol Indexer (tree-sitter AST)'); + note('Path: '.$path); - if ($statsOnly || $dryRun) { - $this->showStats($files); + /** @var array{success: bool, repo?: string, file_count?: int, symbol_count?: int, languages?: array, error?: string, incremental?: bool, changed?: int, new?: int, deleted?: int, warnings?: array} $result */ + $result = spin( + fn (): array => $indexer->indexFolder($path, $incremental), + $incremental ? 'Incremental indexing...' : 'Indexing with tree-sitter...' + ); - if ($dryRun) { - warning('Dry run - no files were indexed.'); - } + if (! $result['success']) { + error($result['error'] ?? 'Indexing failed.'); - return self::SUCCESS; + return self::FAILURE; } - if ($totalFiles === 0) { - warning('No files found to index.'); + if (isset($result['message'])) { + info($result['message']); return self::SUCCESS; } - // Index files with progress bar - $indexed = 0; - $failed = 0; - $totalChunks = 0; - $errors = []; - - $progress = progress( - label: 'Indexing files...', - steps: $totalFiles - ); - - $progress->start(); - - foreach ($files as $file) { - $result = $indexer->indexFile($file['path'], $file['repo']); + info('Indexing complete!'); - if ($result['success']) { - $indexed++; - $totalChunks += $result['chunks']; - } else { - $failed++; - if (isset($result['error'])) { - $errors[$file['path']] = $result['error']; - } - } + $rows = [ + ['Repository', $result['repo'] ?? 'unknown'], + ]; - $progress->advance(); + if (isset($result['incremental']) && $result['incremental']) { + $rows[] = ['Changed files', (string) ($result['changed'] ?? 0)]; + $rows[] = ['New files', (string) ($result['new'] ?? 0)]; + $rows[] = ['Deleted files', (string) ($result['deleted'] ?? 0)]; + } else { + $rows[] = ['Files indexed', (string) ($result['file_count'] ?? 0)]; } - $progress->finish(); + $rows[] = ['Symbols extracted', (string) ($result['symbol_count'] ?? 0)]; - // Show results - info('Indexing complete!'); + if (isset($result['languages'])) { + $langStr = implode(', ', array_map( + fn (string $lang, int $count): string => "{$lang}: {$count}", + array_keys($result['languages']), + array_values($result['languages']) + )); + $rows[] = ['Languages', $langStr]; + } - table( - ['Metric', 'Value'], - [ - ['Files indexed', (string) $indexed], - ['Files failed', (string) $failed], - ['Total chunks', (string) $totalChunks], - ] - ); + table(['Metric', 'Value'], $rows); - if ($errors !== [] && count($errors) <= 10) { - warning('Errors:'); - foreach ($errors as $path => $err) { - note(" {$path}: {$err}"); + if (isset($result['warnings']) && $result['warnings'] !== []) { + foreach (array_slice($result['warnings'], 0, 5) as $warn) { + warning($warn); } - } elseif ($errors !== []) { - warning(count($errors).' files had errors.'); } - return $failed > 0 && $indexed === 0 ? self::FAILURE : self::SUCCESS; + return self::SUCCESS; } - /** - * Show statistics about files to be indexed. - * - * @param array $files - */ - private function showStats(array $files): void + private function listRepos(SymbolIndexService $indexer): int { - $byRepo = []; - $byLang = []; + $repos = $indexer->listRepos(); - foreach ($files as $file) { - $repo = $file['repo']; - $ext = pathinfo($file['path'], PATHINFO_EXTENSION); - $lang = $this->extToLang($ext); + if ($repos === []) { + info('No indexed repositories found.'); - $byRepo[$repo] = ($byRepo[$repo] ?? 0) + 1; - $byLang[$lang] = ($byLang[$lang] ?? 0) + 1; + return self::SUCCESS; } - info('Files by repository:'); - $repoRows = []; - foreach ($byRepo as $repo => $count) { - $repoRows[] = [$repo, (string) $count]; + $rows = []; + foreach ($repos as $repo) { + $langs = implode(', ', array_keys($repo['languages'])); + $rows[] = [ + $repo['repo'], + (string) $repo['file_count'], + (string) $repo['symbol_count'], + $langs, + $repo['indexed_at'], + ]; } - table(['Repository', 'Files'], $repoRows); - info('Files by language:'); - $langRows = []; - foreach ($byLang as $lang => $count) { - $langRows[] = [$lang, (string) $count]; - } - table(['Language', 'Files'], $langRows); - } + table(['Repository', 'Files', 'Symbols', 'Languages', 'Indexed At'], $rows); - private function extToLang(string $ext): string - { - return match (strtolower($ext)) { - 'php' => 'PHP', - 'py' => 'Python', - 'js', 'jsx' => 'JavaScript', - 'ts', 'tsx' => 'TypeScript', - 'vue' => 'Vue', - default => 'Other', - }; + return self::SUCCESS; } } diff --git a/app/Commands/KnowledgeStatsCommand.php b/app/Commands/KnowledgeStatsCommand.php index 93ef1c0..c49243e 100644 --- a/app/Commands/KnowledgeStatsCommand.php +++ b/app/Commands/KnowledgeStatsCommand.php @@ -6,8 +6,8 @@ use App\Commands\Concerns\ResolvesProject; use App\Services\KnowledgeCacheService; -use App\Services\OdinSyncService; use App\Services\QdrantService; +use App\Services\RemoteSyncService; use Illuminate\Support\Collection; use LaravelZero\Framework\Commands\Command; @@ -25,7 +25,7 @@ class KnowledgeStatsCommand extends Command protected $description = 'Display analytics dashboard for knowledge entries'; - public function handle(QdrantService $qdrant, OdinSyncService $odinSync): int + public function handle(QdrantService $qdrant, RemoteSyncService $remoteSync): int { $project = $this->resolveProject(); @@ -44,7 +44,7 @@ public function handle(QdrantService $qdrant, OdinSyncService $odinSync): int $this->renderCacheMetrics($cacheService); } - $this->renderSyncStatus($odinSync); + $this->renderSyncStatus($remoteSync); return self::SUCCESS; } @@ -138,13 +138,13 @@ private function renderCacheMetrics(KnowledgeCacheService $cacheService): void table(['Cache', 'Hits', 'Misses', 'Hit Rate'], $rows); } - private function renderSyncStatus(OdinSyncService $odinSync): void + private function renderSyncStatus(RemoteSyncService $remoteSync): void { - if (! $odinSync->isEnabled()) { + if (! $remoteSync->isEnabled()) { return; } - $status = $odinSync->getStatus(); + $status = $remoteSync->getStatus(); $statusColor = match ($status['status']) { 'synced' => 'green', @@ -154,7 +154,7 @@ private function renderSyncStatus(OdinSyncService $odinSync): void }; $this->newLine(); - $this->line('Odin Sync'); + $this->line('Remote Sync'); table( ['Property', 'Value'], [ diff --git a/app/Commands/OdinSyncCommand.php b/app/Commands/RemoteSyncCommand.php similarity index 76% rename from app/Commands/OdinSyncCommand.php rename to app/Commands/RemoteSyncCommand.php index 011c7a9..f40438c 100644 --- a/app/Commands/OdinSyncCommand.php +++ b/app/Commands/RemoteSyncCommand.php @@ -4,19 +4,19 @@ namespace App\Commands; -use App\Services\OdinSyncService; use App\Services\QdrantService; +use App\Services\RemoteSyncService; use Illuminate\Support\Str; use LaravelZero\Framework\Commands\Command; -class OdinSyncCommand extends Command +class RemoteSyncCommand extends Command { /** * @var string */ - protected $signature = 'sync:odin - {--push : Push queued entries to Odin} - {--pull : Pull entries from Odin} + protected $signature = 'sync:remote + {--push : Push queued entries to remote server} + {--pull : Pull entries from remote server} {--status : Show sync status only} {--clear : Clear the sync queue} {--project=default : Project namespace to sync}'; @@ -24,26 +24,26 @@ class OdinSyncCommand extends Command /** * @var string */ - protected $description = 'Synchronize knowledge with Odin centralized server'; + protected $description = 'Synchronize knowledge with remote centralized server'; - public function handle(OdinSyncService $odinSync, QdrantService $qdrant): int + public function handle(RemoteSyncService $remoteSync, QdrantService $qdrant): int { - if (! $odinSync->isEnabled()) { - $this->error('Odin sync is disabled. Set ODIN_SYNC_ENABLED=true to enable.'); + if (! $remoteSync->isEnabled()) { + $this->error('Remote sync is disabled. Set REMOTE_SYNC_ENABLED=true to enable.'); return self::FAILURE; } // Status-only mode if ((bool) $this->option('status')) { - $this->displayStatus($odinSync); + $this->displayStatus($remoteSync); return self::SUCCESS; } // Clear queue if ((bool) $this->option('clear')) { - $odinSync->clearQueue(); + $remoteSync->clearQueue(); $this->info('Sync queue cleared.'); return self::SUCCESS; @@ -61,19 +61,19 @@ public function handle(OdinSyncService $odinSync, QdrantService $qdrant): int } // Check connectivity - $this->line('Checking Odin connectivity...'); - $available = $odinSync->isAvailable(); + $this->line('Checking remote server connectivity...'); + $available = $remoteSync->isAvailable(); if (! $available) { - $this->warn('Odin server is not reachable. Operations will remain queued for later sync.'); + $this->warn('Remote server is not reachable. Operations will remain queued for later sync.'); - $status = $odinSync->getStatus(); + $status = $remoteSync->getStatus(); $this->line("Pending operations: {$status['pending']}"); return self::SUCCESS; } - $this->info('Odin server connected.'); + $this->info('Remote server connected.'); $pushResult = ['synced' => 0, 'failed' => 0, 'remaining' => 0]; $pullCount = 0; @@ -81,14 +81,14 @@ public function handle(OdinSyncService $odinSync, QdrantService $qdrant): int // Push queued items if ($push) { $this->line('Processing sync queue...'); - $pushResult = $odinSync->processQueue(); + $pushResult = $remoteSync->processQueue(); } - // Pull from Odin + // Pull from remote server if ($pull) { - $this->line("Pulling entries from Odin for project '{$project}'..."); - $entries = $odinSync->pullFromOdin($project); - $pullCount = $this->mergeEntries($entries, $qdrant, $odinSync, $project); + $this->line("Pulling entries from remote server for project '{$project}'..."); + $entries = $remoteSync->pullFromRemote($project); + $pullCount = $this->mergeEntries($entries, $qdrant, $remoteSync, $project); } $this->displaySyncSummary($pushResult, $pullCount, $pull); @@ -104,7 +104,7 @@ public function handle(OdinSyncService $odinSync, QdrantService $qdrant): int private function mergeEntries( array $remoteEntries, QdrantService $qdrant, - OdinSyncService $odinSync, + RemoteSyncService $remoteSync, string $project, ): int { $merged = 0; @@ -121,14 +121,14 @@ private function mergeEntries( if ($local !== null) { // Conflict resolution: last-write-wins - $winner = $odinSync->resolveConflict($local, $remote); + $winner = $remoteSync->resolveConflict($local, $remote); if ($winner === $remote) { $entry = $this->buildEntryFromRemote($remote, $local['id']); $qdrant->upsert($entry, $project); $merged++; } } else { - // New entry from Odin + // New entry from remote server $entry = $this->buildEntryFromRemote($remote, Str::uuid()->toString()); $qdrant->upsert($entry, $project); $merged++; @@ -173,9 +173,9 @@ private function buildEntryFromRemote(array $remote, string|int $id): array return $entry; } - private function displayStatus(OdinSyncService $odinSync): void + private function displayStatus(RemoteSyncService $remoteSync): void { - $status = $odinSync->getStatus(); + $status = $remoteSync->getStatus(); $statusColor = match ($status['status']) { 'synced' => 'green', @@ -185,7 +185,7 @@ private function displayStatus(OdinSyncService $odinSync): void }; $this->newLine(); - $this->line('Odin Sync Status'); + $this->line('Remote Sync Status'); $this->table( ['Property', 'Value'], [ @@ -193,7 +193,7 @@ private function displayStatus(OdinSyncService $odinSync): void ['Pending Operations', (string) $status['pending']], ['Last Synced', $status['last_synced'] ?? 'Never'], ['Last Error', $status['last_error'] ?? 'None'], - ['Odin URL', (string) config('services.odin.url', 'Not configured')], + ['Remote URL', (string) config('services.remote.url', 'Not configured')], ] ); } @@ -204,7 +204,7 @@ private function displayStatus(OdinSyncService $odinSync): void private function displaySyncSummary(array $pushResult, int $pullCount, bool $pulled): void { $this->newLine(); - $this->info('=== Odin Sync Summary ==='); + $this->info('=== Remote Sync Summary ==='); $rows = [ ['Pushed (synced)', (string) $pushResult['synced']], diff --git a/app/Commands/SearchCodeCommand.php b/app/Commands/SearchCodeCommand.php index e511127..32b6f70 100644 --- a/app/Commands/SearchCodeCommand.php +++ b/app/Commands/SearchCodeCommand.php @@ -4,7 +4,7 @@ namespace App\Commands; -use App\Services\CodeIndexerService; +use App\Services\SymbolIndexService; use LaravelZero\Framework\Commands\Command; use function Laravel\Prompts\error; @@ -15,25 +15,30 @@ class SearchCodeCommand extends Command { protected $signature = 'search-code - {query : The semantic search query} + {query : The symbol search query} {--limit=10 : Maximum number of results} - {--repo= : Filter by repository name} - {--language= : Filter by language (php, python, javascript, typescript)} - {--show-content : Show code content in results}'; + {--repo=local/knowledge : Repository identifier} + {--kind= : Filter by symbol kind (class, method, function, type)} + {--file= : Filter by file pattern (e.g. */Services/*)} + {--show-source : Show symbol source code} + {--outline= : Show file outline instead of searching}'; - protected $description = 'Search code files semantically'; + protected $description = 'Search code symbols via tree-sitter index'; - public function handle(CodeIndexerService $indexer): int + public function handle(SymbolIndexService $indexer): int { + $outlineFile = $this->option('outline'); + if (is_string($outlineFile) && $outlineFile !== '') { + return $this->showOutline($indexer, $outlineFile); + } + $queryArg = $this->argument('query'); $query = is_string($queryArg) ? $queryArg : ''; - /** @var int $limit */ $limit = (int) $this->option('limit'); - /** @var string|null $repo */ - $repo = is_string($this->option('repo')) ? $this->option('repo') : null; - /** @var string|null $language */ - $language = is_string($this->option('language')) ? $this->option('language') : null; - $showContent = (bool) $this->option('show-content'); + $repo = is_string($this->option('repo')) ? $this->option('repo') : 'local/knowledge'; + $kind = is_string($this->option('kind')) ? $this->option('kind') : null; + $filePattern = is_string($this->option('file')) ? $this->option('file') : null; + $showSource = (bool) $this->option('show-source'); if (trim($query) === '') { error('Query cannot be empty.'); @@ -41,18 +46,10 @@ public function handle(CodeIndexerService $indexer): int return self::FAILURE; } - $filters = []; - if ($repo !== null) { - $filters['repo'] = $repo; - } - if ($language !== null) { - $filters['language'] = $language; - } - - /** @var array, start_line: int, end_line: int}> $results */ + /** @var array $results */ $results = spin( - fn (): array => $indexer->search($query, $limit, $filters), - 'Searching...' + fn (): array => $indexer->searchSymbols($query, $repo, $kind, $filePattern, $limit), + 'Searching symbols...' ); if ($results === []) { @@ -65,29 +62,30 @@ public function handle(CodeIndexerService $indexer): int foreach ($results as $i => $result) { $num = $i + 1; - $score = round($result['score'] * 100, 1); - $lines = $result['start_line'].'-'.$result['end_line']; - note("[{$num}] {$result['filepath']}"); - note(" Repo: {$result['repo']} | Lang: {$result['language']} | Score: {$score}% | Lines: {$lines}"); + note("[{$num}] {$result['name']} ({$result['kind']})"); + note(" {$result['file']}:{$result['line']}"); + note(" {$result['signature']}"); - if ($result['functions'] !== []) { - $funcs = implode(', ', array_slice($result['functions'], 0, 5)); - note(" Functions: {$funcs}"); + if ($result['summary'] !== '' && $result['summary'] !== $result['signature']) { + note(" {$result['summary']}"); } - if ($showContent) { - $this->line(''); - $this->line(' '.str_repeat('-', 60)); - $contentLines = explode("\n", $result['content']); - $preview = array_slice($contentLines, 0, 15); - foreach ($preview as $line) { - $this->line(' '.$line); - } - if (count($contentLines) > 15) { - $this->line(' ... ('.(count($contentLines) - 15).' more lines)'); + if ($showSource) { + $source = $indexer->getSymbolSource($result['id'], $repo); + if ($source !== null) { + $this->line(''); + $this->line(' '.str_repeat('-', 60)); + $sourceLines = explode("\n", $source); + $preview = array_slice($sourceLines, 0, 20); + foreach ($preview as $line) { + $this->line(' '.$line); + } + if (count($sourceLines) > 20) { + $this->line(' ... ('.(count($sourceLines) - 20).' more lines)'); + } + $this->line(' '.str_repeat('-', 60)); } - $this->line(' '.str_repeat('-', 60)); } $this->line(''); @@ -95,4 +93,40 @@ public function handle(CodeIndexerService $indexer): int return self::SUCCESS; } + + private function showOutline(SymbolIndexService $indexer, string $filePath): int + { + $repo = is_string($this->option('repo')) ? $this->option('repo') : 'local/knowledge'; + + $outline = $indexer->getFileOutline($filePath, $repo); + + if ($outline === []) { + info('No symbols found in file.'); + + return self::SUCCESS; + } + + info("Outline: {$filePath}"); + $this->renderOutline($outline); + + return self::SUCCESS; + } + + /** + * @param array> $nodes + */ + private function renderOutline(array $nodes, int $depth = 0): void + { + $indent = str_repeat(' ', $depth); + foreach ($nodes as $node) { + $kind = $node['kind'] ?? ''; + $name = $node['name'] ?? ''; + $line = $node['line'] ?? 0; + note("{$indent}{$kind} {$name} (line {$line})"); + + if (isset($node['children']) && $node['children'] !== []) { + $this->renderOutline($node['children'], $depth + 1); + } + } + } } diff --git a/app/Commands/Service/DownCommand.php b/app/Commands/Service/DownCommand.php index 706afca..9728cb0 100644 --- a/app/Commands/Service/DownCommand.php +++ b/app/Commands/Service/DownCommand.php @@ -14,18 +14,18 @@ class DownCommand extends Command { protected $signature = 'service:down {--volumes : Remove volumes} - {--odin : Use Odin (remote) configuration} + {--remote : Use remote configuration} {--force : Skip confirmation prompts}'; protected $description = 'Stop knowledge services'; public function handle(): int { - $composeFile = $this->option('odin') === true - ? 'docker-compose.odin.yml' + $composeFile = $this->option('remote') === true + ? 'docker-compose.remote.yml' : 'docker-compose.yml'; - $environment = $this->option('odin') === true ? 'Odin (Remote)' : 'Local'; + $environment = $this->option('remote') === true ? 'Remote' : 'Local'; if (! file_exists(base_path($composeFile))) { render(<<option('odin') === true - ? 'docker-compose.odin.yml' + $composeFile = $this->option('remote') === true + ? 'docker-compose.remote.yml' : 'docker-compose.yml'; - $environment = $this->option('odin') === true ? 'Odin (Remote)' : 'Local'; + $environment = $this->option('remote') === true ? 'Remote' : 'Local'; if (! file_exists(base_path($composeFile))) { render(<<option('odin') === true - ? 'docker-compose.odin.yml' + $composeFile = $this->option('remote') === true + ? 'docker-compose.remote.yml' : 'docker-compose.yml'; - $environment = $this->option('odin') === true ? 'Odin (Remote)' : 'Local'; + $environment = $this->option('remote') === true ? 'Remote' : 'Local'; // Perform health checks with spinner $healthData = spin( diff --git a/app/Commands/Service/UpCommand.php b/app/Commands/Service/UpCommand.php index 61ccf5e..2b0004b 100644 --- a/app/Commands/Service/UpCommand.php +++ b/app/Commands/Service/UpCommand.php @@ -13,17 +13,17 @@ class UpCommand extends Command { protected $signature = 'service:up {--d|detach : Run in detached mode} - {--odin : Use Odin (remote) configuration}'; + {--remote : Use remote configuration}'; protected $description = 'Start knowledge services (Qdrant, Redis, Embeddings)'; public function handle(): int { - $composeFile = $this->option('odin') === true - ? 'docker-compose.odin.yml' + $composeFile = $this->option('remote') === true + ? 'docker-compose.remote.yml' : 'docker-compose.yml'; - $environment = $this->option('odin') === true ? 'Odin (Remote)' : 'Local'; + $environment = $this->option('remote') === true ? 'Remote' : 'Local'; if (! file_exists(base_path($composeFile))) { render(<<make(EntryMetadataService::class), )); - // Odin sync service - $this->app->singleton(OdinSyncService::class, fn ($app): \App\Services\OdinSyncService => new OdinSyncService( + // Remote sync service + $this->app->singleton(RemoteSyncService::class, fn ($app): \App\Services\RemoteSyncService => new RemoteSyncService( $app->make(KnowledgePathService::class) )); diff --git a/app/Services/OdinSyncService.php b/app/Services/RemoteSyncService.php similarity index 94% rename from app/Services/OdinSyncService.php rename to app/Services/RemoteSyncService.php index a318dfb..80c3877 100644 --- a/app/Services/OdinSyncService.php +++ b/app/Services/RemoteSyncService.php @@ -7,7 +7,7 @@ use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; -class OdinSyncService +class RemoteSyncService { private readonly string $queuePath; @@ -24,15 +24,15 @@ public function __construct( } /** - * Check if Odin sync is enabled in configuration. + * Check if remote sync is enabled. */ public function isEnabled(): bool { - return (bool) config('services.odin.enabled', true); + return (bool) config('services.remote.enabled', true); } /** - * Check connectivity to Odin server. + * Check connectivity to remote server. */ public function isAvailable(): bool { @@ -60,7 +60,7 @@ public function isAvailable(): bool } /** - * Queue an entry for sync to Odin. + * Queue an entry for sync to remote server. * * @param array $entry */ @@ -83,7 +83,7 @@ public function queueForSync(array $entry, string $operation = 'upsert', string } /** - * Process the sync queue, pushing pending items to Odin. + * Process the sync queue, pushing pending items to the remote server. * * @return array{synced: int, failed: int, remaining: int} */ @@ -105,7 +105,7 @@ public function processQueue(): array } $remaining = []; - $batchSize = max(1, (int) config('services.odin.batch_size', 50)); + $batchSize = max(1, (int) config('services.remote.batch_size', 50)); $batches = array_chunk($queue, $batchSize); foreach ($batches as $batch) { @@ -146,11 +146,11 @@ public function processQueue(): array } /** - * Pull fresh entries from Odin for a project. + * Pull fresh entries from remote server. * * @return array> */ - public function pullFromOdin(string $project = 'default'): array + public function pullFromRemote(string $project = 'default'): array { $token = $this->getToken(); if ($token === '') { @@ -183,7 +183,7 @@ public function pullFromOdin(string $project = 'default'): array } /** - * List all projects that have been synced to Odin. + * List all projects that have been synced to remote server. * * @return array */ @@ -313,7 +313,7 @@ public function clearQueue(): void } /** - * Push a batch of entries to Odin. + * Push a batch of entries to remote server. * * @param array> $items * @return array{synced: int, failed: int, failedItems: array>} @@ -376,7 +376,7 @@ private function pushBatch(string $token, array $items): array } /** - * Delete a batch of entries from Odin. + * Delete a batch of entries from remote server. * * @param array> $items * @return array{synced: int, failed: int, failedItems: array>} @@ -483,21 +483,21 @@ private function updateStatus(string $status, int $pending, ?string $lastSynced } /** - * Get the Odin API base URL. + * Get the remote API base URL. */ private function getBaseUrl(): string { - $url = config('services.odin.url', ''); + $url = config('services.remote.url', ''); return is_string($url) ? $url : ''; } /** - * Get the Odin API token. + * Get the remote API token. */ private function getToken(): string { - $token = config('services.odin.token', ''); + $token = config('services.remote.token', ''); return is_string($token) ? $token : ''; } @@ -525,7 +525,7 @@ protected function createClient(): Client { return new Client([ 'base_uri' => $this->getBaseUrl(), - 'timeout' => (int) config('services.odin.timeout', 10), + 'timeout' => (int) config('services.remote.timeout', 10), 'headers' => [ 'Accept' => 'application/json', 'Content-Type' => 'application/json', diff --git a/app/Services/SymbolIndexService.php b/app/Services/SymbolIndexService.php new file mode 100644 index 0000000..5c38b22 --- /dev/null +++ b/app/Services/SymbolIndexService.php @@ -0,0 +1,521 @@ +, error?: string} + */ + public function indexFolder(string $path, bool $incremental = false): array + { + $resolvedPath = realpath($path); + if ($resolvedPath === false || ! is_dir($resolvedPath)) { + return ['success' => false, 'error' => "Invalid path: {$path}"]; + } + + // @codeCoverageIgnoreStart + // Subprocess call to jcodemunch — tested via integration/CLI, not unit tests + $storagePath = $this->resolveStoragePath(); + $incrementalFlag = $incremental ? 'True' : 'False'; + + $script = <<run(['python3', '-c', $script]); + + if (! $result->successful()) { + return ['success' => false, 'error' => $result->errorOutput()]; + } + + /** @var array{success: bool, repo?: string, file_count?: int, symbol_count?: int, languages?: array, error?: string} $decoded */ + $decoded = json_decode($result->output(), true); + + return is_array($decoded) ? $decoded : ['success' => false, 'error' => 'Invalid response from indexer']; + // @codeCoverageIgnoreEnd + } + + /** + * Search symbols using weighted keyword scoring. + * + * @return array + */ + public function searchSymbols( + string $query, + string $repo = 'local/knowledge', + ?string $kind = null, + ?string $filePattern = null, + int $maxResults = 10, + ): array { + $index = $this->loadIndex($repo); + if ($index === null) { + return []; + } + + $queryLower = strtolower($query); + $queryWords = array_filter(explode(' ', $queryLower)); + + $scored = []; + foreach ($index['symbols'] as $symbol) { + if ($kind !== null && ($symbol['kind'] ?? '') !== $kind) { + continue; + } + if ($filePattern !== null && ! $this->matchPattern($symbol['file'] ?? '', $filePattern)) { + continue; + } + + $score = $this->scoreSymbol($symbol, $queryLower, $queryWords); + if ($score > 0) { + $scored[] = [ + 'id' => $symbol['id'], + 'kind' => $symbol['kind'], + 'name' => $symbol['name'], + 'file' => $symbol['file'], + 'line' => $symbol['line'], + 'signature' => $symbol['signature'] ?? '', + 'summary' => $symbol['summary'] ?? '', + 'score' => $score, + ]; + } + } + + usort($scored, fn (array $a, array $b): int => $b['score'] <=> $a['score']); + + return array_slice($scored, 0, $maxResults); + } + + /** + * Get symbol source code via byte-offset seek+read. O(1). + */ + public function getSymbolSource(string $symbolId, string $repo = 'local/knowledge'): ?string + { + $index = $this->loadIndex($repo); + if ($index === null) { + return null; + } + + $symbol = $this->findSymbol($index, $symbolId); + if ($symbol === null) { + return null; + } + + $contentPath = $this->contentFilePath($repo, $symbol['file']); + if ($contentPath === null || ! file_exists($contentPath)) { + return null; + } + + $handle = fopen($contentPath, 'rb'); + // @codeCoverageIgnoreStart + // Defensive: fopen only fails if file disappears between exists() and fopen() + if ($handle === false) { + return null; + } + // @codeCoverageIgnoreEnd + + fseek($handle, $symbol['byte_offset']); + $source = fread($handle, $symbol['byte_length']); + fclose($handle); + + return $source !== false ? $source : null; + } + + /** + * Get symbol metadata by ID. + * + * @return array|null + */ + public function getSymbol(string $symbolId, string $repo = 'local/knowledge'): ?array + { + $index = $this->loadIndex($repo); + if ($index === null) { + return null; + } + + return $this->findSymbol($index, $symbolId); + } + + /** + * Get file outline — symbols in a specific file with hierarchy. + * + * @return array> + */ + public function getFileOutline(string $filePath, string $repo = 'local/knowledge'): array + { + $index = $this->loadIndex($repo); + if ($index === null) { + return []; + } + + $fileSymbols = array_filter( + $index['symbols'], + fn (array $s): bool => ($s['file'] ?? '') === $filePath + ); + + if ($fileSymbols === []) { + return []; + } + + return $this->buildSymbolTree($fileSymbols); + } + + /** + * Detect changed files by comparing SHA-256 hashes. + * + * @param array $currentFiles file_path => content + * @return array{changed: array, new: array, deleted: array} + */ + public function detectChanges(array $currentFiles, string $repo = 'local/knowledge'): array + { + $index = $this->loadIndex($repo); + $oldHashes = $index['file_hashes'] ?? []; + + $currentHashes = []; + foreach ($currentFiles as $path => $content) { + $currentHashes[$path] = hash('sha256', $content); + } + + $oldSet = array_keys($oldHashes); + $newSet = array_keys($currentHashes); + + $newFiles = array_diff($newSet, $oldSet); + $deletedFiles = array_diff($oldSet, $newSet); + $common = array_intersect($oldSet, $newSet); + + $changedFiles = []; + foreach ($common as $path) { + if ($oldHashes[$path] !== $currentHashes[$path]) { + $changedFiles[] = $path; + } + } + + return [ + 'changed' => $changedFiles, + 'new' => array_values($newFiles), + 'deleted' => array_values($deletedFiles), + ]; + } + + /** + * List all indexed repositories. + * + * @return array}> + */ + public function listRepos(): array + { + $storagePath = $this->resolveStoragePath(); + if (! is_dir($storagePath)) { + return []; + } + + $repos = []; + $jsonFiles = glob($storagePath.'/*.json'); + // @codeCoverageIgnoreStart + // Defensive: glob only returns false on pattern error, not on empty results + if ($jsonFiles === false) { + return []; + } + // @codeCoverageIgnoreEnd + foreach ($jsonFiles as $indexFile) { + $content = file_get_contents($indexFile); + // @codeCoverageIgnoreStart + // Defensive: file_get_contents only fails on read errors after glob found the file + if ($content === false) { + continue; + } + // @codeCoverageIgnoreEnd + $data = json_decode($content, true); + if (! is_array($data)) { + continue; + } + $repos[] = [ + 'repo' => $data['repo'] ?? basename($indexFile, '.json'), + 'indexed_at' => $data['indexed_at'] ?? '', + 'symbol_count' => count($data['symbols'] ?? []), + 'file_count' => count($data['source_files'] ?? []), + 'languages' => $data['languages'] ?? [], + ]; + } + + return $repos; + } + + /** + * Load a repository's index from disk. + * + * @return array{repo: string, owner: string, name: string, symbols: array>, file_hashes: array, source_files: array, languages: array, indexed_at: string}|null + */ + private function loadIndex(string $repo): ?array + { + [$owner, $name] = $this->parseRepo($repo); + $indexPath = $this->indexPath($owner, $name); + + if (! file_exists($indexPath)) { + return null; + } + + $content = file_get_contents($indexPath); + // @codeCoverageIgnoreStart + // Defensive: file_get_contents only fails on read errors after file_exists passed + if ($content === false) { + return null; + } + // @codeCoverageIgnoreEnd + + $data = json_decode($content, true); + if (! is_array($data)) { + return null; + } + + /** @var int $storedVersion */ + $storedVersion = $data['index_version'] ?? 1; + if ($storedVersion > self::INDEX_VERSION) { + return null; + } + + /** @var array{repo: string, owner: string, name: string, symbols: array>, file_hashes: array, source_files: array, languages: array, indexed_at: string} $data */ + return $data; + } + + /** + * Find a symbol by ID in an index. + * + * @param array $index + * @return array|null + */ + private function findSymbol(array $index, string $symbolId): ?array + { + foreach ($index['symbols'] as $symbol) { + if (($symbol['id'] ?? '') === $symbolId) { + return $symbol; + } + } + + return null; + } + + /** + * Calculate weighted search score for a symbol. + * + * @param array $symbol + * @param array $queryWords + */ + private function scoreSymbol(array $symbol, string $queryLower, array $queryWords): int + { + $score = 0; + $nameLower = strtolower($symbol['name'] ?? ''); + + // 1. Exact name match (highest weight) + if ($queryLower === $nameLower) { + $score += 20; + } elseif (str_contains($nameLower, $queryLower)) { + $score += 10; + } + + // 2. Name word overlap + foreach ($queryWords as $word) { + if (str_contains($nameLower, $word)) { + $score += 5; + } + } + + // 3. Signature match + $sigLower = strtolower($symbol['signature'] ?? ''); + if (str_contains($sigLower, $queryLower)) { + $score += 8; + } + foreach ($queryWords as $word) { + if (str_contains($sigLower, $word)) { + $score += 2; + } + } + + // 4. Summary match + $summaryLower = strtolower($symbol['summary'] ?? ''); + if (str_contains($summaryLower, $queryLower)) { + $score += 5; + } + foreach ($queryWords as $word) { + if (str_contains($summaryLower, $word)) { + $score += 1; + } + } + + // 5. Keyword match + $keywords = array_map('strtolower', $symbol['keywords'] ?? []); + foreach ($queryWords as $word) { + if (in_array($word, $keywords, true)) { + $score += 3; + } + } + + // 6. Docstring match + $docLower = strtolower($symbol['docstring'] ?? ''); + foreach ($queryWords as $word) { + if (str_contains($docLower, $word)) { + $score += 1; + } + } + + return $score; + } + + /** + * Match file path against a glob-like pattern. + */ + private function matchPattern(string $filePath, string $pattern): bool + { + return fnmatch($pattern, $filePath) || fnmatch("*/{$pattern}", $filePath); + } + + /** + * Build hierarchical symbol tree from flat symbol list. + * + * @param array> $symbols + * @return array> + */ + private function buildSymbolTree(array $symbols): array + { + $nodeMap = []; + foreach ($symbols as $symbol) { + $nodeMap[$symbol['id']] = [ + 'id' => $symbol['id'], + 'kind' => $symbol['kind'], + 'name' => $symbol['name'], + 'signature' => $symbol['signature'] ?? '', + 'summary' => $symbol['summary'] ?? '', + 'line' => $symbol['line'], + 'children' => [], + ]; + } + + $roots = []; + foreach ($symbols as $symbol) { + $parentId = $symbol['parent'] ?? null; + if ($parentId !== null && isset($nodeMap[$parentId])) { + $nodeMap[$parentId]['children'][] = &$nodeMap[$symbol['id']]; + } else { + $roots[] = &$nodeMap[$symbol['id']]; + } + } + + return $this->formatTree($roots); + } + + /** + * Clean up tree output — remove empty children arrays. + * + * @param array> $nodes + * @return array> + */ + private function formatTree(array $nodes): array + { + $result = []; + foreach ($nodes as $node) { + $formatted = [ + 'id' => $node['id'], + 'kind' => $node['kind'], + 'name' => $node['name'], + 'signature' => $node['signature'], + 'summary' => $node['summary'], + 'line' => $node['line'], + ]; + /** @var array> $children */ + $children = $node['children'] ?? []; + if ($children !== []) { + $formatted['children'] = $this->formatTree($children); + } + $result[] = $formatted; + } + + return $result; + } + + /** + * Parse repo identifier into [owner, name]. + * + * @return array{0: string, 1: string} + */ + private function parseRepo(string $repo): array + { + $parts = explode('/', $repo); + if (count($parts) === 2) { + return [$parts[0], $parts[1]]; + } + + return ['local', $repo]; + } + + /** + * Get path to index JSON file. + */ + private function indexPath(string $owner, string $name): string + { + return $this->resolveStoragePath()."/{$owner}-{$name}.json"; + } + + /** + * Get path to a raw content file. + */ + private function contentFilePath(string $repo, string $relativePath): ?string + { + [$owner, $name] = $this->parseRepo($repo); + $contentDir = $this->resolveStoragePath()."/{$owner}-{$name}"; + $fullPath = realpath($contentDir.'/'.$relativePath); + + // Path traversal protection + if ($fullPath === false) { + return null; + } + $realContentDir = realpath($contentDir); + // @codeCoverageIgnoreStart + // Defensive: realpath of content dir only fails if dir was deleted between calls + if ($realContentDir === false || ! str_starts_with($fullPath, $realContentDir)) { + return null; + } + // @codeCoverageIgnoreEnd + + return $fullPath; + } + + /** + * Resolve storage path, expanding ~ to home directory. + */ + private function resolveStoragePath(): string + { + $path = $this->storagePath; + // @codeCoverageIgnoreStart + // Environment-dependent: only triggered when service constructed with ~/... path + if (str_starts_with($path, '~/')) { + $home = getenv('HOME') !== false ? getenv('HOME') : '/tmp'; + $path = $home.'/'.substr($path, 2); + } + // @codeCoverageIgnoreEnd + + return $path; + } +} diff --git a/app/Services/ThemeClassifierService.php b/app/Services/ThemeClassifierService.php index 076dfef..d52c3bc 100644 --- a/app/Services/ThemeClassifierService.php +++ b/app/Services/ThemeClassifierService.php @@ -51,7 +51,7 @@ class ThemeClassifierService ], 'integrated-infrastructure' => [ 'keywords' => [ - 'infrastructure', 'server', 'homelab', 'odin', 'docker', + 'infrastructure', 'server', 'homelab', 'docker', 'podman', 'container', 'deploy', 'deployment', 'hosting', 'property', 'physical', 'hardware', 'network', 'tailscale', 'nginx', 'redis', 'database', 'postgres', 'mysql', diff --git a/config/services.php b/config/services.php index 5ddf9b9..b870e8b 100644 --- a/config/services.php +++ b/config/services.php @@ -19,21 +19,21 @@ /* |-------------------------------------------------------------------------- - | Odin Sync Configuration + | Remote Sync Configuration |-------------------------------------------------------------------------- | - | Configuration for background sync with the Odin centralized Qdrant - | server. Operations are queued locally and synced when Odin is available. + | Configuration for background sync with a centralized Qdrant + | server. Operations are queued locally and synced when the remote server is available. | Last-write-wins conflict resolution based on updated_at timestamps. | */ - 'odin' => [ - 'enabled' => env('ODIN_SYNC_ENABLED', false), - 'url' => env('ODIN_URL'), - 'token' => env('ODIN_API_TOKEN', env('PREFRONTAL_API_TOKEN')), - 'timeout' => env('ODIN_TIMEOUT', 10), - 'batch_size' => env('ODIN_BATCH_SIZE', 50), + 'remote' => [ + 'enabled' => env('REMOTE_SYNC_ENABLED', false), + 'url' => env('REMOTE_SYNC_URL'), + 'token' => env('REMOTE_SYNC_TOKEN', env('PREFRONTAL_API_TOKEN')), + 'timeout' => env('REMOTE_SYNC_TIMEOUT', 10), + 'batch_size' => env('REMOTE_SYNC_BATCH_SIZE', 50), ], ]; diff --git a/docker-compose.odin.yml b/docker-compose.remote.yml similarity index 95% rename from docker-compose.odin.yml rename to docker-compose.remote.yml index 71d72bf..217f489 100644 --- a/docker-compose.odin.yml +++ b/docker-compose.remote.yml @@ -2,7 +2,7 @@ # Binds to a specific network interface (e.g. Tailscale, VPN, LAN) # # Usage: -# BIND_ADDR=100.68.122.24 docker compose -f docker-compose.odin.yml up -d +# BIND_ADDR=192.168.1.100 docker compose -f docker-compose.remote.yml up -d # # Set BIND_ADDR to your server's interface IP (Tailscale, LAN, etc.) # Defaults to 127.0.0.1 (localhost only) if not set. diff --git a/resources/views/site/categories.blade.php b/resources/views/site/categories.blade.php deleted file mode 100644 index e45b4b2..0000000 --- a/resources/views/site/categories.blade.php +++ /dev/null @@ -1,30 +0,0 @@ -@extends('site.layout') - -@section('title', 'Categories - Knowledge Base') - -@section('content') -
-

Categories

- - @if(count($categories) === 0) -

No categories found.

- @else - @foreach($categories as $category) -
-

{{ $category->name ?? 'Uncategorized' }}

-

{{ $category->count }} entries

- - -
- @endforeach - @endif -
-@endsection diff --git a/resources/views/site/entry.blade.php b/resources/views/site/entry.blade.php deleted file mode 100644 index 66c7406..0000000 --- a/resources/views/site/entry.blade.php +++ /dev/null @@ -1,82 +0,0 @@ -@extends('site.layout') - -@section('title', $entry->title . ' - Knowledge Base') - -@section('content') -
-

{{ $entry->title }}

- -
- @if($entry->category) - {{ $entry->category }} - @endif - @if($entry->module) - {{ $entry->module }} - @endif - {{ $entry->priority }} - {{ $entry->confidence }}% - {{ $entry->status }} -
- - @if($entry->tags && count($entry->tags) > 0) -
- Tags: - @foreach($entry->tags as $tag) - {{ $tag }} - @endforeach -
- @endif - -
{{ $entry->content }}
- -
-

Metadata

- - @if($entry->source) -

Source: {{ $entry->source }}

- @endif - - @if($entry->ticket) -

Ticket: {{ $entry->ticket }}

- @endif - - @if($entry->author) -

Author: {{ $entry->author }}

- @endif - - @if($entry->files && count($entry->files) > 0) -

Files:

-
    - @foreach($entry->files as $file) -
  • {{ $file }}
  • - @endforeach -
- @endif - - @if($entry->repo) -

Repository: {{ $entry->repo }}

- @endif - - @if($entry->branch) -

Branch: {{ $entry->branch }}

- @endif - - @if($entry->commit) -

Commit: {{ $entry->commit }}

- @endif - -

Usage Count: {{ $entry->usage_count }}

- - @if($entry->last_used) -

Last Used: {{ $entry->last_used->format('Y-m-d H:i:s') }}

- @endif - -

Created: {{ $entry->created_at->format('Y-m-d H:i:s') }}

-

Updated: {{ $entry->updated_at->format('Y-m-d H:i:s') }}

-
- - -
-@endsection diff --git a/resources/views/site/index.blade.php b/resources/views/site/index.blade.php deleted file mode 100644 index d6b0088..0000000 --- a/resources/views/site/index.blade.php +++ /dev/null @@ -1,72 +0,0 @@ -@extends('site.layout') - -@section('title', 'Knowledge Base - Home') - -@section('content') - - -
-

Knowledge Entries

- - @if(count($entries) === 0) -

No entries found.

- @else -
- @foreach($entries as $entry) -
-

- - {{ $entry->title }} - -

- -
- @if($entry->category) - {{ $entry->category }} - @endif - {{ $entry->priority }} - {{ $entry->confidence }}% -
- - @if($entry->tags && count($entry->tags) > 0) -
- @foreach($entry->tags as $tag) - {{ $tag }} - @endforeach -
- @endif - -

- {{ \Illuminate\Support\Str::limit($entry->content, 200) }} -

-
- @endforeach -
- @endif -
-@endsection - -@section('scripts') - -@endsection diff --git a/resources/views/site/layout.blade.php b/resources/views/site/layout.blade.php deleted file mode 100644 index 6899ec6..0000000 --- a/resources/views/site/layout.blade.php +++ /dev/null @@ -1,166 +0,0 @@ - - - - - - @yield('title', 'Knowledge Base') - - @yield('styles') - - -
-
-

Knowledge Base

- -
-
- -
- @yield('content') -
- -
-
-

Generated by Knowledge CLI - {{ date('Y-m-d H:i:s') }}

-
-
- - @yield('scripts') - - diff --git a/resources/views/site/tags.blade.php b/resources/views/site/tags.blade.php deleted file mode 100644 index 9f7acbe..0000000 --- a/resources/views/site/tags.blade.php +++ /dev/null @@ -1,40 +0,0 @@ -@extends('site.layout') - -@section('title', 'Tags - Knowledge Base') - -@section('content') -
-

Tags

- - @if(count($tags) === 0) -

No tags found.

- @else -
- @foreach($tags as $tag) - - - {{ $tag->name }} ({{ $tag->count }}) - - - @endforeach -
- - @foreach($tags as $tag) -
-

{{ $tag->name }}

-

{{ $tag->count }} entries

- - -
- @endforeach - @endif -
-@endsection diff --git a/storage/framework/views/0f479c632229ee0a8ced4fa17a2fd1cc.php b/storage/framework/views/0f479c632229ee0a8ced4fa17a2fd1cc.php deleted file mode 100644 index bb0b89e..0000000 --- a/storage/framework/views/0f479c632229ee0a8ced4fa17a2fd1cc.php +++ /dev/null @@ -1,41 +0,0 @@ -startSection('title', 'Tags - Knowledge Base'); ?> - -startSection('content'); ?> -
-

Tags

- - -

No tags found.

- -
- addLoop($__currentLoopData); foreach($__currentLoopData as $tag): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> - - - name); ?> (count); ?>) - - - popLoop(); $loop = $__env->getLastLoop(); ?> -
- - addLoop($__currentLoopData); foreach($__currentLoopData as $tag): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> -
-

name); ?>

-

count); ?> entries

- -
    - entries; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $entry): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> -
  • - - title); ?> - - -
  • - popLoop(); $loop = $__env->getLastLoop(); ?> -
-
- popLoop(); $loop = $__env->getLastLoop(); ?> - -
-stopSection(); ?> - -make('site.layout', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> \ No newline at end of file diff --git a/storage/framework/views/26dd2becec94e79077a33621a63a7b11.php b/storage/framework/views/26dd2becec94e79077a33621a63a7b11.php deleted file mode 100644 index 91e252a..0000000 --- a/storage/framework/views/26dd2becec94e79077a33621a63a7b11.php +++ /dev/null @@ -1,31 +0,0 @@ -startSection('title', 'Categories - Knowledge Base'); ?> - -startSection('content'); ?> -
-

Categories

- - -

No categories found.

- - addLoop($__currentLoopData); foreach($__currentLoopData as $category): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> -
-

name ?? 'Uncategorized'); ?>

-

count); ?> entries

- -
    - entries; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $entry): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> -
  • - - title); ?> - - -
  • - popLoop(); $loop = $__env->getLastLoop(); ?> -
-
- popLoop(); $loop = $__env->getLastLoop(); ?> - -
-stopSection(); ?> - -make('site.layout', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> \ No newline at end of file diff --git a/storage/framework/views/bb5d12f3b3a26511ea3fac7651fa7849.php b/storage/framework/views/bb5d12f3b3a26511ea3fac7651fa7849.php deleted file mode 100644 index 44309e2..0000000 --- a/storage/framework/views/bb5d12f3b3a26511ea3fac7651fa7849.php +++ /dev/null @@ -1,167 +0,0 @@ - - - - - - <?php echo $__env->yieldContent('title', 'Knowledge Base'); ?> - - yieldContent('styles'); ?> - - -
-
-

Knowledge Base

- -
-
- -
- yieldContent('content'); ?> -
- -
-
-

Generated by Knowledge CLI -

-
-
- - yieldContent('scripts'); ?> - - - \ No newline at end of file diff --git a/storage/framework/views/e5aa6538698ba9e145c6fdac9ec1eb3d.php b/storage/framework/views/e5aa6538698ba9e145c6fdac9ec1eb3d.php deleted file mode 100644 index 2322d71..0000000 --- a/storage/framework/views/e5aa6538698ba9e145c6fdac9ec1eb3d.php +++ /dev/null @@ -1,74 +0,0 @@ -startSection('title', 'Knowledge Base - Home'); ?> - -startSection('content'); ?> - - -
-

Knowledge Entries

- - -

No entries found.

- -
- addLoop($__currentLoopData); foreach($__currentLoopData as $entry): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> -
-

- - title); ?> - - -

- -
- category): ?> - category); ?> - - priority); ?> - confidence); ?>% -
- - tags && count($entry->tags) > 0): ?> -
- tags; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $tag): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> - - popLoop(); $loop = $__env->getLastLoop(); ?> -
- - -

- content, 200)); ?> - -

-
- popLoop(); $loop = $__env->getLastLoop(); ?> -
- -
-stopSection(); ?> - -startSection('scripts'); ?> - -stopSection(); ?> - -make('site.layout', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> \ No newline at end of file diff --git a/storage/framework/views/e6d56113987c2dd4afd52a2de9a8da50.php b/storage/framework/views/e6d56113987c2dd4afd52a2de9a8da50.php deleted file mode 100644 index 6bb0369..0000000 --- a/storage/framework/views/e6d56113987c2dd4afd52a2de9a8da50.php +++ /dev/null @@ -1,82 +0,0 @@ -startSection('title', $entry->title . ' - Knowledge Base'); ?> - -startSection('content'); ?> -
-

title); ?>

- -
- category): ?> - category); ?> - - module): ?> - module); ?> - - priority); ?> - confidence); ?>% - status); ?> -
- - tags && count($entry->tags) > 0): ?> -
- Tags: - tags; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $tag): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> - - popLoop(); $loop = $__env->getLastLoop(); ?> -
- - -
content); ?>
- -
-

Metadata

- - source): ?> -

Source: source); ?>

- - - ticket): ?> -

Ticket: ticket); ?>

- - - author): ?> -

Author: author); ?>

- - - files && count($entry->files) > 0): ?> -

Files:

-
    - files; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $file): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> -
  • - popLoop(); $loop = $__env->getLastLoop(); ?> -
- - - repo): ?> -

Repository: repo); ?>

- - - branch): ?> -

Branch: branch); ?>

- - - commit): ?> -

Commit: commit); ?>

- - -

Usage Count: usage_count); ?>

- - last_used): ?> -

Last Used: last_used->format('Y-m-d H:i:s')); ?>

- - -

Created: created_at->format('Y-m-d H:i:s')); ?>

-

Updated: updated_at->format('Y-m-d H:i:s')); ?>

-
- - -
-stopSection(); ?> - -make('site.layout', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> \ No newline at end of file diff --git a/tests/Feature/Commands/IndexCodeCommandTest.php b/tests/Feature/Commands/IndexCodeCommandTest.php index a66711f..076dda6 100644 --- a/tests/Feature/Commands/IndexCodeCommandTest.php +++ b/tests/Feature/Commands/IndexCodeCommandTest.php @@ -2,424 +2,167 @@ declare(strict_types=1); -namespace App\Commands; - -/** - * Override is_dir in the App\Commands namespace for testing. - * This allows us to test the "no valid paths" branch. - */ -function is_dir(string $path): bool -{ - // If a test sets this global, use the mock behavior - if (isset($GLOBALS['__mock_is_dir']) && $GLOBALS['__mock_is_dir'] === true) { - return false; - } - - return \is_dir($path); -} - -namespace Tests\Feature\Commands; - -use App\Services\CodeIndexerService; -use Generator; +use App\Services\SymbolIndexService; beforeEach(function (): void { - $this->indexerMock = \Mockery::mock(CodeIndexerService::class); - $this->app->instance(CodeIndexerService::class, $this->indexerMock); - // Reset mock state - unset($GLOBALS['__mock_is_dir']); + $this->indexerMock = Mockery::mock(SymbolIndexService::class); + $this->app->instance(SymbolIndexService::class, $this->indexerMock); }); afterEach(function (): void { - \Mockery::close(); - unset($GLOBALS['__mock_is_dir']); + Mockery::close(); }); -/** - * Helper to create a Generator from an array. - * - * @param array $files - * @return Generator - */ -function filesGenerator(array $files): Generator -{ - foreach ($files as $file) { - yield $file; - } -} - describe('index-code command', function (): void { - it('fails when no valid paths exist', function (): void { - // Enable mock to make is_dir return false for all paths - $GLOBALS['__mock_is_dir'] = true; - - $this->indexerMock->shouldNotReceive('ensureCollection'); - $this->indexerMock->shouldNotReceive('findFiles'); - - $this->artisan('index-code') - ->assertFailed(); - }); - - it('indexes files from valid paths', function (): void { - $tempDir = sys_get_temp_dir().'/knowledge-test-'.uniqid(); - mkdir($tempDir, 0755, true); - - $this->indexerMock->shouldReceive('ensureCollection') - ->once() - ->andReturn(true); - - $this->indexerMock->shouldReceive('findFiles') - ->once() - ->andReturnUsing(fn () => filesGenerator([ - ['path' => '/path/to/file1.php', 'repo' => 'test-repo'], - ['path' => '/path/to/file2.php', 'repo' => 'test-repo'], - ])); - - $this->indexerMock->shouldReceive('indexFile') - ->with('/path/to/file1.php', 'test-repo') - ->once() - ->andReturn(['success' => true, 'chunks' => 3]); - - $this->indexerMock->shouldReceive('indexFile') - ->with('/path/to/file2.php', 'test-repo') - ->once() - ->andReturn(['success' => true, 'chunks' => 2]); - - $this->artisan('index-code', ['--path' => [$tempDir]]) - ->assertSuccessful(); - - rmdir($tempDir); - }); - - it('fails when collection creation fails', function (): void { - $tempDir = sys_get_temp_dir().'/knowledge-test-'.uniqid(); - mkdir($tempDir, 0755, true); - - $this->indexerMock->shouldReceive('ensureCollection') - ->once() - ->andReturn(false); - - $this->artisan('index-code', ['--path' => [$tempDir]]) - ->assertFailed(); - - rmdir($tempDir); - }); - - it('handles dry-run option', function (): void { - $tempDir = sys_get_temp_dir().'/knowledge-test-'.uniqid(); - mkdir($tempDir, 0755, true); - - $this->indexerMock->shouldNotReceive('ensureCollection'); - - $this->indexerMock->shouldReceive('findFiles') - ->once() - ->andReturnUsing(fn () => filesGenerator([ - ['path' => '/path/to/file.php', 'repo' => 'test-repo'], - ])); - - $this->indexerMock->shouldNotReceive('indexFile'); - - $this->artisan('index-code', ['--path' => [$tempDir], '--dry-run' => true]) - ->assertSuccessful(); - - rmdir($tempDir); - }); - - it('handles stats option', function (): void { + it('indexes a folder successfully', function (): void { $tempDir = sys_get_temp_dir().'/knowledge-test-'.uniqid(); mkdir($tempDir, 0755, true); - $this->indexerMock->shouldReceive('ensureCollection') - ->once() - ->andReturn(true); - - $this->indexerMock->shouldReceive('findFiles') + $this->indexerMock->shouldReceive('indexFolder') ->once() - ->andReturnUsing(fn () => filesGenerator([ - ['path' => '/path/to/file.php', 'repo' => 'test-repo'], - ['path' => '/path/to/app.js', 'repo' => 'other-repo'], - ])); + ->with($tempDir, false) + ->andReturn([ + 'success' => true, + 'repo' => 'local/test', + 'file_count' => 10, + 'symbol_count' => 50, + 'languages' => ['php' => 10], + ]); - $this->indexerMock->shouldNotReceive('indexFile'); - - $this->artisan('index-code', ['--path' => [$tempDir], '--stats' => true]) + $this->artisan('index-code', ['path' => $tempDir]) ->assertSuccessful(); rmdir($tempDir); }); - it('returns success when no files found', function (): void { + it('supports incremental indexing', function (): void { $tempDir = sys_get_temp_dir().'/knowledge-test-'.uniqid(); mkdir($tempDir, 0755, true); - $this->indexerMock->shouldReceive('ensureCollection') + $this->indexerMock->shouldReceive('indexFolder') ->once() - ->andReturn(true); + ->with($tempDir, true) + ->andReturn([ + 'success' => true, + 'repo' => 'local/test', + 'incremental' => true, + 'changed' => 2, + 'new' => 1, + 'deleted' => 0, + 'symbol_count' => 15, + ]); - $this->indexerMock->shouldReceive('findFiles') - ->once() - ->andReturnUsing(fn () => filesGenerator([])); - - $this->artisan('index-code', ['--path' => [$tempDir]]) + $this->artisan('index-code', ['path' => $tempDir, '--incremental' => true]) ->assertSuccessful(); rmdir($tempDir); }); - it('tracks failed indexing and shows errors', function (): void { + it('handles no changes in incremental mode', function (): void { $tempDir = sys_get_temp_dir().'/knowledge-test-'.uniqid(); mkdir($tempDir, 0755, true); - $this->indexerMock->shouldReceive('ensureCollection') - ->once() - ->andReturn(true); - - $this->indexerMock->shouldReceive('findFiles') - ->once() - ->andReturnUsing(fn () => filesGenerator([ - ['path' => '/path/to/file1.php', 'repo' => 'test-repo'], - ['path' => '/path/to/file2.php', 'repo' => 'test-repo'], - ])); - - $this->indexerMock->shouldReceive('indexFile') - ->with('/path/to/file1.php', 'test-repo') - ->once() - ->andReturn(['success' => true, 'chunks' => 2]); - - $this->indexerMock->shouldReceive('indexFile') - ->with('/path/to/file2.php', 'test-repo') + $this->indexerMock->shouldReceive('indexFolder') ->once() - ->andReturn(['success' => false, 'chunks' => 0, 'error' => 'Could not read file']); + ->andReturn([ + 'success' => true, + 'message' => 'No changes detected', + ]); - $this->artisan('index-code', ['--path' => [$tempDir]]) + $this->artisan('index-code', ['path' => $tempDir, '--incremental' => true]) ->assertSuccessful(); rmdir($tempDir); }); - it('fails when all indexing fails', function (): void { + it('fails when indexing fails', function (): void { $tempDir = sys_get_temp_dir().'/knowledge-test-'.uniqid(); mkdir($tempDir, 0755, true); - $this->indexerMock->shouldReceive('ensureCollection') - ->once() - ->andReturn(true); - - $this->indexerMock->shouldReceive('findFiles') + $this->indexerMock->shouldReceive('indexFolder') ->once() - ->andReturnUsing(fn () => filesGenerator([ - ['path' => '/path/to/file.php', 'repo' => 'test-repo'], - ])); + ->andReturn([ + 'success' => false, + 'error' => 'No source files found', + ]); - $this->indexerMock->shouldReceive('indexFile') - ->once() - ->andReturn(['success' => false, 'chunks' => 0, 'error' => 'Embedding failed']); - - $this->artisan('index-code', ['--path' => [$tempDir]]) + $this->artisan('index-code', ['path' => $tempDir]) ->assertFailed(); rmdir($tempDir); }); - it('shows warning when more than 10 errors occur', function (): void { - $tempDir = sys_get_temp_dir().'/knowledge-test-'.uniqid(); - mkdir($tempDir, 0755, true); - - $files = []; - for ($i = 1; $i <= 12; $i++) { - $files[] = ['path' => "/path/to/file{$i}.php", 'repo' => 'test-repo']; - } + it('fails with invalid path', function (): void { + $this->indexerMock->shouldNotReceive('indexFolder'); - $this->indexerMock->shouldReceive('ensureCollection') - ->once() - ->andReturn(true); - - $this->indexerMock->shouldReceive('findFiles') - ->once() - ->andReturnUsing(fn () => filesGenerator($files)); - - $this->indexerMock->shouldReceive('indexFile') - ->times(12) - ->andReturn(['success' => false, 'chunks' => 0, 'error' => 'Failed']); - - $this->artisan('index-code', ['--path' => [$tempDir]]) + $this->artisan('index-code', ['path' => '/nonexistent/path']) ->assertFailed(); - - rmdir($tempDir); }); - it('handles failure without error message', function (): void { - $tempDir = sys_get_temp_dir().'/knowledge-test-'.uniqid(); - mkdir($tempDir, 0755, true); - - $this->indexerMock->shouldReceive('ensureCollection') - ->once() - ->andReturn(true); - - $this->indexerMock->shouldReceive('findFiles') - ->once() - ->andReturnUsing(fn () => filesGenerator([ - ['path' => '/path/to/file1.php', 'repo' => 'test-repo'], - ['path' => '/path/to/file2.php', 'repo' => 'test-repo'], - ])); - - $this->indexerMock->shouldReceive('indexFile') - ->with('/path/to/file1.php', 'test-repo') + it('defaults to current directory when no path given', function (): void { + $this->indexerMock->shouldReceive('indexFolder') ->once() - ->andReturn(['success' => true, 'chunks' => 2]); + ->withArgs(function (string $path, bool $incremental): bool { + return is_dir($path) && ! $incremental; + }) + ->andReturn([ + 'success' => true, + 'repo' => 'local/knowledge', + 'file_count' => 5, + 'symbol_count' => 25, + 'languages' => ['php' => 5], + ]); - // Failure without error key - $this->indexerMock->shouldReceive('indexFile') - ->with('/path/to/file2.php', 'test-repo') - ->once() - ->andReturn(['success' => false, 'chunks' => 0]); - - $this->artisan('index-code', ['--path' => [$tempDir]]) + $this->artisan('index-code') ->assertSuccessful(); - - rmdir($tempDir); }); - it('deduplicates paths', function (): void { + it('shows warnings when present', function (): void { $tempDir = sys_get_temp_dir().'/knowledge-test-'.uniqid(); mkdir($tempDir, 0755, true); - $this->indexerMock->shouldReceive('ensureCollection') + $this->indexerMock->shouldReceive('indexFolder') ->once() - ->andReturn(true); + ->andReturn([ + 'success' => true, + 'repo' => 'local/test', + 'file_count' => 8, + 'symbol_count' => 40, + 'languages' => ['php' => 8], + 'warnings' => ['Skipped secret file: .env'], + ]); - // findFiles should be called with deduplicated paths - $this->indexerMock->shouldReceive('findFiles') - ->once() - ->andReturnUsing(fn () => filesGenerator([])); - - $this->artisan('index-code', ['--path' => [$tempDir, $tempDir]]) + $this->artisan('index-code', ['path' => $tempDir]) ->assertSuccessful(); rmdir($tempDir); }); }); -describe('language detection in stats', function (): void { - it('detects PHP files', function (): void { - $tempDir = sys_get_temp_dir().'/knowledge-test-'.uniqid(); - mkdir($tempDir, 0755, true); - - $this->indexerMock->shouldReceive('ensureCollection') - ->once() - ->andReturn(true); - - $this->indexerMock->shouldReceive('findFiles') - ->once() - ->andReturnUsing(fn () => filesGenerator([ - ['path' => '/path/to/file.php', 'repo' => 'test'], - ])); - - $this->artisan('index-code', ['--path' => [$tempDir], '--stats' => true]) - ->assertSuccessful(); - - rmdir($tempDir); - }); - - it('detects Python files', function (): void { - $tempDir = sys_get_temp_dir().'/knowledge-test-'.uniqid(); - mkdir($tempDir, 0755, true); - - $this->indexerMock->shouldReceive('ensureCollection') - ->once() - ->andReturn(true); - - $this->indexerMock->shouldReceive('findFiles') - ->once() - ->andReturnUsing(fn () => filesGenerator([ - ['path' => '/path/to/script.py', 'repo' => 'test'], - ])); - - $this->artisan('index-code', ['--path' => [$tempDir], '--stats' => true]) - ->assertSuccessful(); - - rmdir($tempDir); - }); - - it('detects JavaScript files', function (): void { - $tempDir = sys_get_temp_dir().'/knowledge-test-'.uniqid(); - mkdir($tempDir, 0755, true); - - $this->indexerMock->shouldReceive('ensureCollection') - ->once() - ->andReturn(true); - - $this->indexerMock->shouldReceive('findFiles') - ->once() - ->andReturnUsing(fn () => filesGenerator([ - ['path' => '/path/to/app.js', 'repo' => 'test'], - ['path' => '/path/to/component.jsx', 'repo' => 'test'], - ])); - - $this->artisan('index-code', ['--path' => [$tempDir], '--stats' => true]) - ->assertSuccessful(); - - rmdir($tempDir); - }); - - it('detects TypeScript files', function (): void { - $tempDir = sys_get_temp_dir().'/knowledge-test-'.uniqid(); - mkdir($tempDir, 0755, true); - - $this->indexerMock->shouldReceive('ensureCollection') - ->once() - ->andReturn(true); - - $this->indexerMock->shouldReceive('findFiles') - ->once() - ->andReturnUsing(fn () => filesGenerator([ - ['path' => '/path/to/types.ts', 'repo' => 'test'], - ['path' => '/path/to/component.tsx', 'repo' => 'test'], - ])); - - $this->artisan('index-code', ['--path' => [$tempDir], '--stats' => true]) - ->assertSuccessful(); - - rmdir($tempDir); - }); - - it('detects Vue files', function (): void { - $tempDir = sys_get_temp_dir().'/knowledge-test-'.uniqid(); - mkdir($tempDir, 0755, true); - - $this->indexerMock->shouldReceive('ensureCollection') - ->once() - ->andReturn(true); - - $this->indexerMock->shouldReceive('findFiles') - ->once() - ->andReturnUsing(fn () => filesGenerator([ - ['path' => '/path/to/App.vue', 'repo' => 'test'], - ])); - - $this->artisan('index-code', ['--path' => [$tempDir], '--stats' => true]) +describe('--list option', function (): void { + it('lists indexed repositories', function (): void { + $this->indexerMock->shouldReceive('listRepos') + ->once() + ->andReturn([ + [ + 'repo' => 'local/knowledge', + 'file_count' => 96, + 'symbol_count' => 508, + 'languages' => ['php' => 95, 'python' => 1], + 'indexed_at' => '2026-03-05T12:00:00', + ], + ]); + + $this->artisan('index-code', ['--list' => true]) ->assertSuccessful(); - - rmdir($tempDir); }); - it('categorizes unknown extensions as Other', function (): void { - $tempDir = sys_get_temp_dir().'/knowledge-test-'.uniqid(); - mkdir($tempDir, 0755, true); - - $this->indexerMock->shouldReceive('ensureCollection') + it('shows message when no repos indexed', function (): void { + $this->indexerMock->shouldReceive('listRepos') ->once() - ->andReturn(true); + ->andReturn([]); - $this->indexerMock->shouldReceive('findFiles') - ->once() - ->andReturnUsing(fn () => filesGenerator([ - ['path' => '/path/to/file.unknown', 'repo' => 'test'], - ])); - - $this->artisan('index-code', ['--path' => [$tempDir], '--stats' => true]) + $this->artisan('index-code', ['--list' => true]) ->assertSuccessful(); - - rmdir($tempDir); }); }); diff --git a/tests/Feature/Commands/OdinSyncCommandTest.php b/tests/Feature/Commands/RemoteSyncCommandTest.php similarity index 59% rename from tests/Feature/Commands/OdinSyncCommandTest.php rename to tests/Feature/Commands/RemoteSyncCommandTest.php index 88deded..7a96ccf 100644 --- a/tests/Feature/Commands/OdinSyncCommandTest.php +++ b/tests/Feature/Commands/RemoteSyncCommandTest.php @@ -2,90 +2,90 @@ declare(strict_types=1); -use App\Services\OdinSyncService; use App\Services\QdrantService; +use App\Services\RemoteSyncService; beforeEach(function (): void { - $this->odinMock = Mockery::mock(OdinSyncService::class); + $this->remoteMock = Mockery::mock(RemoteSyncService::class); $this->qdrantMock = Mockery::mock(QdrantService::class); - $this->app->instance(OdinSyncService::class, $this->odinMock); + $this->app->instance(RemoteSyncService::class, $this->remoteMock); $this->app->instance(QdrantService::class, $this->qdrantMock); }); -describe('OdinSyncCommand basic', function (): void { - it('fails when odin sync is disabled', function (): void { - $this->odinMock->shouldReceive('isEnabled')->once()->andReturn(false); +describe('RemoteSyncCommand basic', function (): void { + it('fails when remote sync is disabled', function (): void { + $this->remoteMock->shouldReceive('isEnabled')->once()->andReturn(false); - $this->artisan('sync:odin') + $this->artisan('sync:remote') ->assertFailed() - ->expectsOutput('Odin sync is disabled. Set ODIN_SYNC_ENABLED=true to enable.'); + ->expectsOutput('Remote sync is disabled. Set REMOTE_SYNC_ENABLED=true to enable.'); }); it('shows status only with --status flag', function (): void { - $this->odinMock->shouldReceive('isEnabled')->once()->andReturn(true); - $this->odinMock->shouldReceive('getStatus')->once()->andReturn([ + $this->remoteMock->shouldReceive('isEnabled')->once()->andReturn(true); + $this->remoteMock->shouldReceive('getStatus')->once()->andReturn([ 'status' => 'synced', 'pending' => 0, 'last_synced' => '2025-06-01T12:00:00+00:00', 'last_error' => null, ]); - config(['services.odin.url' => 'http://odin.local']); + config(['services.remote.url' => 'http://remote.local']); - $this->artisan('sync:odin', ['--status' => true]) + $this->artisan('sync:remote', ['--status' => true]) ->assertSuccessful() - ->expectsOutputToContain('Odin Sync Status'); + ->expectsOutputToContain('Remote Sync Status'); }); it('clears queue with --clear flag', function (): void { - $this->odinMock->shouldReceive('isEnabled')->once()->andReturn(true); - $this->odinMock->shouldReceive('clearQueue')->once(); + $this->remoteMock->shouldReceive('isEnabled')->once()->andReturn(true); + $this->remoteMock->shouldReceive('clearQueue')->once(); - $this->artisan('sync:odin', ['--clear' => true]) + $this->artisan('sync:remote', ['--clear' => true]) ->assertSuccessful() ->expectsOutput('Sync queue cleared.'); }); }); -describe('OdinSyncCommand connectivity', function (): void { - it('warns when Odin is unreachable', function (): void { - $this->odinMock->shouldReceive('isEnabled')->once()->andReturn(true); - $this->odinMock->shouldReceive('isAvailable')->once()->andReturn(false); - $this->odinMock->shouldReceive('getStatus')->once()->andReturn([ +describe('RemoteSyncCommand connectivity', function (): void { + it('warns when remote is unreachable', function (): void { + $this->remoteMock->shouldReceive('isEnabled')->once()->andReturn(true); + $this->remoteMock->shouldReceive('isAvailable')->once()->andReturn(false); + $this->remoteMock->shouldReceive('getStatus')->once()->andReturn([ 'status' => 'pending', 'pending' => 3, 'last_synced' => null, 'last_error' => null, ]); - $this->artisan('sync:odin') + $this->artisan('sync:remote') ->assertSuccessful() - ->expectsOutputToContain('Odin server is not reachable') + ->expectsOutputToContain('Remote server is not reachable') ->expectsOutputToContain('Pending operations: 3'); }); }); -describe('OdinSyncCommand push', function (): void { +describe('RemoteSyncCommand push', function (): void { it('pushes queued items when --push is specified', function (): void { - $this->odinMock->shouldReceive('isEnabled')->once()->andReturn(true); - $this->odinMock->shouldReceive('isAvailable')->once()->andReturn(true); - $this->odinMock->shouldReceive('processQueue')->once()->andReturn([ + $this->remoteMock->shouldReceive('isEnabled')->once()->andReturn(true); + $this->remoteMock->shouldReceive('isAvailable')->once()->andReturn(true); + $this->remoteMock->shouldReceive('processQueue')->once()->andReturn([ 'synced' => 5, 'failed' => 0, 'remaining' => 0, ]); - $this->artisan('sync:odin', ['--push' => true]) + $this->artisan('sync:remote', ['--push' => true]) ->assertSuccessful() - ->expectsOutputToContain('Odin Sync Summary'); + ->expectsOutputToContain('Remote Sync Summary'); }); }); -describe('OdinSyncCommand pull', function (): void { +describe('RemoteSyncCommand pull', function (): void { it('pulls and merges entries when --pull is specified', function (): void { - $this->odinMock->shouldReceive('isEnabled')->once()->andReturn(true); - $this->odinMock->shouldReceive('isAvailable')->once()->andReturn(true); - $this->odinMock->shouldReceive('pullFromOdin') + $this->remoteMock->shouldReceive('isEnabled')->once()->andReturn(true); + $this->remoteMock->shouldReceive('isAvailable')->once()->andReturn(true); + $this->remoteMock->shouldReceive('pullFromRemote') ->once() ->with('default') ->andReturn([ @@ -114,11 +114,11 @@ ->once() ->andReturn(true); - $this->odinMock->shouldNotReceive('resolveConflict'); + $this->remoteMock->shouldNotReceive('resolveConflict'); - $this->artisan('sync:odin', ['--pull' => true]) + $this->artisan('sync:remote', ['--pull' => true]) ->assertSuccessful() - ->expectsOutputToContain('Odin Sync Summary'); + ->expectsOutputToContain('Remote Sync Summary'); }); it('resolves conflicts with last-write-wins on pull', function (): void { @@ -142,9 +142,9 @@ 'usage_count' => 0, ]; - $this->odinMock->shouldReceive('isEnabled')->once()->andReturn(true); - $this->odinMock->shouldReceive('isAvailable')->once()->andReturn(true); - $this->odinMock->shouldReceive('pullFromOdin') + $this->remoteMock->shouldReceive('isEnabled')->once()->andReturn(true); + $this->remoteMock->shouldReceive('isAvailable')->once()->andReturn(true); + $this->remoteMock->shouldReceive('pullFromRemote') ->once() ->with('default') ->andReturn([$remoteEntry]); @@ -155,7 +155,7 @@ ->andReturn(collect([$localEntry])); // Remote is newer, should win - $this->odinMock->shouldReceive('resolveConflict') + $this->remoteMock->shouldReceive('resolveConflict') ->once() ->with($localEntry, $remoteEntry) ->andReturn($remoteEntry); @@ -164,14 +164,14 @@ ->once() ->andReturn(true); - $this->artisan('sync:odin', ['--pull' => true]) + $this->artisan('sync:remote', ['--pull' => true]) ->assertSuccessful(); }); it('skips entries with empty title on pull', function (): void { - $this->odinMock->shouldReceive('isEnabled')->once()->andReturn(true); - $this->odinMock->shouldReceive('isAvailable')->once()->andReturn(true); - $this->odinMock->shouldReceive('pullFromOdin') + $this->remoteMock->shouldReceive('isEnabled')->once()->andReturn(true); + $this->remoteMock->shouldReceive('isAvailable')->once()->andReturn(true); + $this->remoteMock->shouldReceive('pullFromRemote') ->once() ->andReturn([ ['title' => '', 'content' => 'No title'], @@ -180,7 +180,7 @@ $this->qdrantMock->shouldNotReceive('search'); $this->qdrantMock->shouldNotReceive('upsert'); - $this->artisan('sync:odin', ['--pull' => true]) + $this->artisan('sync:remote', ['--pull' => true]) ->assertSuccessful(); }); @@ -198,9 +198,9 @@ 'updated_at' => '2025-05-01T12:00:00+00:00', ]; - $this->odinMock->shouldReceive('isEnabled')->once()->andReturn(true); - $this->odinMock->shouldReceive('isAvailable')->once()->andReturn(true); - $this->odinMock->shouldReceive('pullFromOdin') + $this->remoteMock->shouldReceive('isEnabled')->once()->andReturn(true); + $this->remoteMock->shouldReceive('isAvailable')->once()->andReturn(true); + $this->remoteMock->shouldReceive('pullFromRemote') ->once() ->andReturn([$remoteEntry]); @@ -209,23 +209,23 @@ ->andReturn(collect([$localEntry])); // Local wins - $this->odinMock->shouldReceive('resolveConflict') + $this->remoteMock->shouldReceive('resolveConflict') ->once() ->andReturn($localEntry); // Should NOT upsert since local wins $this->qdrantMock->shouldNotReceive('upsert'); - $this->artisan('sync:odin', ['--pull' => true]) + $this->artisan('sync:remote', ['--pull' => true]) ->assertSuccessful(); }); }); -describe('OdinSyncCommand pull with optional fields', function (): void { +describe('RemoteSyncCommand pull with optional fields', function (): void { it('includes category and module from remote entry when set', function (): void { - $this->odinMock->shouldReceive('isEnabled')->once()->andReturn(true); - $this->odinMock->shouldReceive('isAvailable')->once()->andReturn(true); - $this->odinMock->shouldReceive('pullFromOdin') + $this->remoteMock->shouldReceive('isEnabled')->once()->andReturn(true); + $this->remoteMock->shouldReceive('isAvailable')->once()->andReturn(true); + $this->remoteMock->shouldReceive('pullFromRemote') ->once() ->with('default') ->andReturn([ @@ -256,14 +256,14 @@ && ($data['module'] ?? null) === 'core-api'), Mockery::any()) ->andReturn(true); - $this->artisan('sync:odin', ['--pull' => true]) + $this->artisan('sync:remote', ['--pull' => true]) ->assertSuccessful(); }); it('omits category and module when not set in remote entry', function (): void { - $this->odinMock->shouldReceive('isEnabled')->once()->andReturn(true); - $this->odinMock->shouldReceive('isAvailable')->once()->andReturn(true); - $this->odinMock->shouldReceive('pullFromOdin') + $this->remoteMock->shouldReceive('isEnabled')->once()->andReturn(true); + $this->remoteMock->shouldReceive('isAvailable')->once()->andReturn(true); + $this->remoteMock->shouldReceive('pullFromRemote') ->once() ->with('default') ->andReturn([ @@ -290,94 +290,94 @@ && ! array_key_exists('module', $data)), Mockery::any()) ->andReturn(true); - $this->artisan('sync:odin', ['--pull' => true]) + $this->artisan('sync:remote', ['--pull' => true]) ->assertSuccessful(); }); }); -describe('OdinSyncCommand status display', function (): void { +describe('RemoteSyncCommand status display', function (): void { it('uses red color for error sync status', function (): void { - $this->odinMock->shouldReceive('isEnabled')->once()->andReturn(true); - $this->odinMock->shouldReceive('getStatus')->once()->andReturn([ + $this->remoteMock->shouldReceive('isEnabled')->once()->andReturn(true); + $this->remoteMock->shouldReceive('getStatus')->once()->andReturn([ 'status' => 'error', 'pending' => 0, 'last_synced' => null, 'last_error' => 'Connection refused', ]); - config(['services.odin.url' => 'http://odin.local']); + config(['services.remote.url' => 'http://remote.local']); - $this->artisan('sync:odin', ['--status' => true]) + $this->artisan('sync:remote', ['--status' => true]) ->assertSuccessful() - ->expectsOutputToContain('Odin Sync Status'); + ->expectsOutputToContain('Remote Sync Status'); }); it('uses yellow color for pending sync status', function (): void { - $this->odinMock->shouldReceive('isEnabled')->once()->andReturn(true); - $this->odinMock->shouldReceive('getStatus')->once()->andReturn([ + $this->remoteMock->shouldReceive('isEnabled')->once()->andReturn(true); + $this->remoteMock->shouldReceive('getStatus')->once()->andReturn([ 'status' => 'pending', 'pending' => 3, 'last_synced' => null, 'last_error' => null, ]); - config(['services.odin.url' => 'http://odin.local']); + config(['services.remote.url' => 'http://remote.local']); - $this->artisan('sync:odin', ['--status' => true]) + $this->artisan('sync:remote', ['--status' => true]) ->assertSuccessful() - ->expectsOutputToContain('Odin Sync Status'); + ->expectsOutputToContain('Remote Sync Status'); }); it('uses gray color for unknown sync status', function (): void { - $this->odinMock->shouldReceive('isEnabled')->once()->andReturn(true); - $this->odinMock->shouldReceive('getStatus')->once()->andReturn([ + $this->remoteMock->shouldReceive('isEnabled')->once()->andReturn(true); + $this->remoteMock->shouldReceive('getStatus')->once()->andReturn([ 'status' => 'unknown-status', 'pending' => 0, 'last_synced' => null, 'last_error' => null, ]); - config(['services.odin.url' => 'http://odin.local']); + config(['services.remote.url' => 'http://remote.local']); - $this->artisan('sync:odin', ['--status' => true]) + $this->artisan('sync:remote', ['--status' => true]) ->assertSuccessful() - ->expectsOutputToContain('Odin Sync Status'); + ->expectsOutputToContain('Remote Sync Status'); }); }); -describe('OdinSyncCommand default two-way sync', function (): void { +describe('RemoteSyncCommand default two-way sync', function (): void { it('performs push then pull with no flags', function (): void { - $this->odinMock->shouldReceive('isEnabled')->once()->andReturn(true); - $this->odinMock->shouldReceive('isAvailable')->once()->andReturn(true); - $this->odinMock->shouldReceive('processQueue')->once()->andReturn([ + $this->remoteMock->shouldReceive('isEnabled')->once()->andReturn(true); + $this->remoteMock->shouldReceive('isAvailable')->once()->andReturn(true); + $this->remoteMock->shouldReceive('processQueue')->once()->andReturn([ 'synced' => 2, 'failed' => 0, 'remaining' => 0, ]); - $this->odinMock->shouldReceive('pullFromOdin') + $this->remoteMock->shouldReceive('pullFromRemote') ->once() ->with('default') ->andReturn([]); - $this->artisan('sync:odin') + $this->artisan('sync:remote') ->assertSuccessful() - ->expectsOutputToContain('Odin Sync Summary'); + ->expectsOutputToContain('Remote Sync Summary'); }); it('accepts custom project option', function (): void { - $this->odinMock->shouldReceive('isEnabled')->once()->andReturn(true); - $this->odinMock->shouldReceive('isAvailable')->once()->andReturn(true); - $this->odinMock->shouldReceive('processQueue')->once()->andReturn([ + $this->remoteMock->shouldReceive('isEnabled')->once()->andReturn(true); + $this->remoteMock->shouldReceive('isAvailable')->once()->andReturn(true); + $this->remoteMock->shouldReceive('processQueue')->once()->andReturn([ 'synced' => 0, 'failed' => 0, 'remaining' => 0, ]); - $this->odinMock->shouldReceive('pullFromOdin') + $this->remoteMock->shouldReceive('pullFromRemote') ->once() ->with('custom-project') ->andReturn([]); - $this->artisan('sync:odin', ['--project' => 'custom-project']) + $this->artisan('sync:remote', ['--project' => 'custom-project']) ->assertSuccessful(); }); }); diff --git a/tests/Feature/Commands/SearchCodeCommandTest.php b/tests/Feature/Commands/SearchCodeCommandTest.php index 37540c7..0343e38 100644 --- a/tests/Feature/Commands/SearchCodeCommandTest.php +++ b/tests/Feature/Commands/SearchCodeCommandTest.php @@ -2,110 +2,123 @@ declare(strict_types=1); -use App\Services\CodeIndexerService; +use App\Services\SymbolIndexService; beforeEach(function (): void { - $this->indexerMock = Mockery::mock(CodeIndexerService::class); - $this->app->instance(CodeIndexerService::class, $this->indexerMock); + $this->indexerMock = Mockery::mock(SymbolIndexService::class); + $this->app->instance(SymbolIndexService::class, $this->indexerMock); }); afterEach(function (): void { Mockery::close(); }); +function createSymbolResult( + string $name = 'authenticate', + string $kind = 'method', + string $file = 'app/Services/AuthService.php', + int $line = 15, + int $score = 35, +): array { + return [ + 'id' => "{$file}::{$name}#{$kind}", + 'kind' => $kind, + 'name' => $name, + 'file' => $file, + 'line' => $line, + 'signature' => "public function {$name}(): bool", + 'summary' => "Method {$name}", + 'score' => $score, + ]; +} + describe('search-code command', function (): void { - it('searches code semantically', function (): void { - $this->indexerMock->shouldReceive('search') + it('searches code symbols', function (): void { + $this->indexerMock->shouldReceive('searchSymbols') ->once() - ->with('authentication logic', 10, []) - ->andReturn([ - createCodeResult('/path/to/auth.php', 'my-repo', 'php', 0.95), - ]); + ->with('authenticate', 'local/knowledge', null, null, 10) + ->andReturn([createSymbolResult()]); - $this->artisan('search-code', ['query' => 'authentication logic']) + $this->artisan('search-code', ['query' => 'authenticate']) ->assertSuccessful() ->expectsOutputToContain('results found') - ->expectsOutputToContain('auth.php'); + ->expectsOutputToContain('authenticate'); }); it('fails with empty query', function (): void { - $this->indexerMock->shouldNotReceive('search'); + $this->indexerMock->shouldNotReceive('searchSymbols'); $this->artisan('search-code', ['query' => '']) ->assertFailed(); }); it('fails with whitespace-only query', function (): void { - $this->indexerMock->shouldNotReceive('search'); + $this->indexerMock->shouldNotReceive('searchSymbols'); $this->artisan('search-code', ['query' => ' ']) ->assertFailed(); }); it('shows no results message when no matches found', function (): void { - $this->indexerMock->shouldReceive('search') + $this->indexerMock->shouldReceive('searchSymbols') ->once() - ->with('nonexistent code', 10, []) ->andReturn([]); - $this->artisan('search-code', ['query' => 'nonexistent code']) + $this->artisan('search-code', ['query' => 'nonexistent']) ->assertSuccessful() ->expectsOutputToContain('No results found'); }); it('respects limit option', function (): void { - $this->indexerMock->shouldReceive('search') + $this->indexerMock->shouldReceive('searchSymbols') ->once() - ->with('test', 5, []) + ->with('test', 'local/knowledge', null, null, 5) ->andReturn([]); $this->artisan('search-code', ['query' => 'test', '--limit' => '5']) ->assertSuccessful(); }); - it('filters by repository', function (): void { - $this->indexerMock->shouldReceive('search') + it('filters by symbol kind', function (): void { + $this->indexerMock->shouldReceive('searchSymbols') ->once() - ->with('test', 10, ['repo' => 'my-project']) - ->andReturn([]); + ->with('user', 'local/knowledge', 'class', null, 10) + ->andReturn([createSymbolResult('User', 'class')]); - $this->artisan('search-code', ['query' => 'test', '--repo' => 'my-project']) + $this->artisan('search-code', ['query' => 'user', '--kind' => 'class']) ->assertSuccessful(); }); - it('filters by language', function (): void { - $this->indexerMock->shouldReceive('search') + it('filters by file pattern', function (): void { + $this->indexerMock->shouldReceive('searchSymbols') ->once() - ->with('test', 10, ['language' => 'php']) + ->with('test', 'local/knowledge', null, '*/Services/*', 10) ->andReturn([]); - $this->artisan('search-code', ['query' => 'test', '--language' => 'php']) + $this->artisan('search-code', ['query' => 'test', '--file' => '*/Services/*']) ->assertSuccessful(); }); - it('combines repo and language filters', function (): void { - $this->indexerMock->shouldReceive('search') + it('uses custom repo', function (): void { + $this->indexerMock->shouldReceive('searchSymbols') ->once() - ->with('test', 10, ['repo' => 'my-project', 'language' => 'typescript']) + ->with('test', 'local/myproject', null, null, 10) ->andReturn([]); - $this->artisan('search-code', [ - 'query' => 'test', - '--repo' => 'my-project', - '--language' => 'typescript', - ])->assertSuccessful(); + $this->artisan('search-code', ['query' => 'test', '--repo' => 'local/myproject']) + ->assertSuccessful(); }); it('displays multiple results with numbering', function (): void { - $this->indexerMock->shouldReceive('search') + $this->indexerMock->shouldReceive('searchSymbols') ->once() ->andReturn([ - createCodeResult('/path/to/file1.php', 'repo1', 'php', 0.95), - createCodeResult('/path/to/file2.js', 'repo2', 'javascript', 0.85), - createCodeResult('/path/to/file3.py', 'repo3', 'python', 0.75), + createSymbolResult('login', 'method', 'app/Auth.php', 10, 35), + createSymbolResult('User', 'class', 'app/User.php', 5, 20), + createSymbolResult('register', 'method', 'app/Auth.php', 50, 15), ]); - $this->artisan('search-code', ['query' => 'test']) + $this->artisan('search-code', ['query' => 'auth']) ->assertSuccessful() ->expectsOutputToContain('3 results found') ->expectsOutputToContain('[1]') @@ -113,139 +126,107 @@ ->expectsOutputToContain('[3]'); }); - it('displays functions when available', function (): void { - $result = createCodeResult('/path/to/auth.php', 'my-repo', 'php', 0.95); - $result['functions'] = ['authenticate', 'login', 'logout']; - - $this->indexerMock->shouldReceive('search') - ->once() - ->andReturn([$result]); - - $this->artisan('search-code', ['query' => 'auth']) - ->assertSuccessful() - ->expectsOutputToContain('Functions: authenticate, login, logout'); - }); - - it('truncates functions list to 5 items', function (): void { - $result = createCodeResult('/path/to/file.php', 'repo', 'php', 0.9); - $result['functions'] = ['func1', 'func2', 'func3', 'func4', 'func5', 'func6', 'func7']; - - $this->indexerMock->shouldReceive('search') + it('displays signature', function (): void { + $this->indexerMock->shouldReceive('searchSymbols') ->once() - ->andReturn([$result]); + ->andReturn([createSymbolResult()]); - $this->artisan('search-code', ['query' => 'test']) + $this->artisan('search-code', ['query' => 'authenticate']) ->assertSuccessful() - ->expectsOutputToContain('Functions: func1, func2, func3, func4, func5'); - }); - - it('does not display functions line when empty', function (): void { - $result = createCodeResult('/path/to/file.php', 'repo', 'php', 0.9); - $result['functions'] = []; - - $this->indexerMock->shouldReceive('search') - ->once() - ->andReturn([$result]); - - $output = $this->artisan('search-code', ['query' => 'test']); - $output->assertSuccessful(); - // Functions line should not appear when empty + ->expectsOutputToContain('public function authenticate'); }); - it('displays line range', function (): void { - $result = createCodeResult('/path/to/file.php', 'repo', 'php', 0.9); - $result['start_line'] = 10; - $result['end_line'] = 50; - - $this->indexerMock->shouldReceive('search') + it('displays file and line number', function (): void { + $this->indexerMock->shouldReceive('searchSymbols') ->once() - ->andReturn([$result]); + ->andReturn([createSymbolResult('test', 'function', 'src/utils.php', 42)]); $this->artisan('search-code', ['query' => 'test']) ->assertSuccessful() - ->expectsOutputToContain('Lines: 10-50'); + ->expectsOutputToContain('src/utils.php:42'); }); }); -describe('--show-content flag', function (): void { - it('displays code content when flag is set', function (): void { - $result = createCodeResult('/path/to/file.php', 'repo', 'php', 0.9); - $result['content'] = "indexerMock->shouldReceive('searchSymbols') + ->once() + ->andReturn([createSymbolResult()]); - $this->indexerMock->shouldReceive('search') + $this->indexerMock->shouldReceive('getSymbolSource') ->once() - ->andReturn([$result]); + ->andReturn("public function authenticate(): bool {\n return true;\n}"); - $this->artisan('search-code', ['query' => 'test', '--show-content' => true]) + $this->artisan('search-code', ['query' => 'authenticate', '--show-source' => true]) ->assertSuccessful() - ->expectsOutputToContain('function test()'); + ->expectsOutputToContain('return true'); }); - it('truncates content to 15 lines and shows remaining count', function (): void { + it('truncates source to 20 lines', function (): void { $lines = []; - for ($i = 1; $i <= 20; $i++) { - $lines[] = "Line {$i} of code"; + for ($i = 1; $i <= 25; $i++) { + $lines[] = " line {$i}"; } - $result = createCodeResult('/path/to/file.php', 'repo', 'php', 0.9); - $result['content'] = implode("\n", $lines); - $this->indexerMock->shouldReceive('search') + $this->indexerMock->shouldReceive('searchSymbols') + ->once() + ->andReturn([createSymbolResult()]); + + $this->indexerMock->shouldReceive('getSymbolSource') ->once() - ->andReturn([$result]); + ->andReturn(implode("\n", $lines)); - $this->artisan('search-code', ['query' => 'test', '--show-content' => true]) + $this->artisan('search-code', ['query' => 'test', '--show-source' => true]) ->assertSuccessful() - ->expectsOutputToContain('Line 1 of code') - ->expectsOutputToContain('Line 15 of code') ->expectsOutputToContain('... (5 more lines)'); }); +}); - it('does not show more lines indicator when content is 15 lines or less', function (): void { - $lines = []; - for ($i = 1; $i <= 10; $i++) { - $lines[] = "Line {$i}"; - } - $result = createCodeResult('/path/to/file.php', 'repo', 'php', 0.9); - $result['content'] = implode("\n", $lines); - - $this->indexerMock->shouldReceive('search') +describe('--outline flag', function (): void { + it('shows file outline', function (): void { + $this->indexerMock->shouldReceive('getFileOutline') ->once() - ->andReturn([$result]); + ->with('app/Services/QdrantService.php', 'local/knowledge') + ->andReturn([ + [ + 'id' => 'app/Services/QdrantService.php::QdrantService#class', + 'kind' => 'class', + 'name' => 'QdrantService', + 'signature' => 'class QdrantService', + 'summary' => 'Class QdrantService', + 'line' => 28, + 'children' => [ + [ + 'id' => 'app/Services/QdrantService.php::QdrantService.search#method', + 'kind' => 'method', + 'name' => 'search', + 'signature' => 'public function search()', + 'summary' => 'Search entries.', + 'line' => 100, + ], + ], + ], + ]); - $output = $this->artisan('search-code', ['query' => 'test', '--show-content' => true]); - $output->assertSuccessful(); - // Should not contain "more lines" indicator + $this->artisan('search-code', [ + 'query' => 'ignored', + '--outline' => 'app/Services/QdrantService.php', + ]) + ->assertSuccessful() + ->expectsOutputToContain('QdrantService') + ->expectsOutputToContain('search'); }); - it('shows separator lines around content', function (): void { - $result = createCodeResult('/path/to/file.php', 'repo', 'php', 0.9); - $result['content'] = "indexerMock->shouldReceive('search') + it('shows message when no symbols in file', function (): void { + $this->indexerMock->shouldReceive('getFileOutline') ->once() - ->andReturn([$result]); + ->andReturn([]); - $this->artisan('search-code', ['query' => 'test', '--show-content' => true]) + $this->artisan('search-code', [ + 'query' => 'ignored', + '--outline' => 'empty.php', + ]) ->assertSuccessful() - ->expectsOutputToContain('----'); + ->expectsOutputToContain('No symbols found'); }); }); - -// Helper function -function createCodeResult( - string $filepath, - string $repo, - string $language, - float $score, -): array { - return [ - 'filepath' => $filepath, - 'repo' => $repo, - 'language' => $language, - 'content' => "Sample code content for {$filepath}", - 'score' => $score, - 'functions' => [], - 'start_line' => 1, - 'end_line' => 100, - ]; -} diff --git a/tests/Feature/Commands/Service/DownCommandTest.php b/tests/Feature/Commands/Service/DownCommandTest.php index a4dc372..1b196e1 100644 --- a/tests/Feature/Commands/Service/DownCommandTest.php +++ b/tests/Feature/Commands/Service/DownCommandTest.php @@ -26,13 +26,13 @@ } }); - it('fails when odin docker-compose file does not exist', function () { + it('fails when remote docker-compose file does not exist', function () { $tempDir = sys_get_temp_dir().'/know-test-'.uniqid(); mkdir($tempDir, 0755, true); $this->app->setBasePath($tempDir); try { - $this->artisan('service:down', ['--odin' => true]) + $this->artisan('service:down', ['--remote' => true]) ->assertFailed(); } finally { rmdir($tempDir); @@ -57,11 +57,11 @@ && in_array('-v', $process->command)); }); - it('uses odin compose file when odin flag is set', function () { - $this->artisan('service:down', ['--odin' => true]) + it('uses remote compose file when remote flag is set', function () { + $this->artisan('service:down', ['--remote' => true]) ->assertSuccessful(); - Process::assertRan(fn ($process) => in_array('docker-compose.odin.yml', $process->command)); + Process::assertRan(fn ($process) => in_array('docker-compose.remote.yml', $process->command)); }); it('returns failure when docker compose fails', function () { @@ -76,11 +76,11 @@ ->assertFailed(); }); - it('combines odin and volumes flags correctly when forced', function () { - $this->artisan('service:down', ['--odin' => true, '--volumes' => true, '--force' => true]) + it('combines remote and volumes flags correctly when forced', function () { + $this->artisan('service:down', ['--remote' => true, '--volumes' => true, '--force' => true]) ->assertSuccessful(); - Process::assertRan(fn ($process) => in_array('docker-compose.odin.yml', $process->command) + Process::assertRan(fn ($process) => in_array('docker-compose.remote.yml', $process->command) && in_array('-v', $process->command)); }); }); @@ -95,7 +95,7 @@ expect($signature)->toContain('service:down'); expect($signature)->toContain('--volumes'); - expect($signature)->toContain('--odin'); + expect($signature)->toContain('--remote'); expect($signature)->toContain('--force'); }); diff --git a/tests/Feature/Commands/Service/LogsCommandTest.php b/tests/Feature/Commands/Service/LogsCommandTest.php index 43d8217..bbc56e1 100644 --- a/tests/Feature/Commands/Service/LogsCommandTest.php +++ b/tests/Feature/Commands/Service/LogsCommandTest.php @@ -26,7 +26,7 @@ } }); - it('fails when odin docker-compose file does not exist', function () { + it('fails when remote docker-compose file does not exist', function () { $tempDir = sys_get_temp_dir().'/know-test-'.uniqid(); mkdir($tempDir, 0755, true); $this->app->setBasePath($tempDir); @@ -34,7 +34,7 @@ try { $this->artisan('service:logs', [ 'service' => 'qdrant', - '--odin' => true, + '--remote' => true, ]) ->assertFailed(); } finally { @@ -69,11 +69,11 @@ Process::assertRan(fn ($process) => in_array('--tail=100', $process->command)); }); - it('uses odin compose file when odin flag is set', function () { - $this->artisan('service:logs', ['service' => 'qdrant', '--odin' => true]) + it('uses remote compose file when remote flag is set', function () { + $this->artisan('service:logs', ['service' => 'qdrant', '--remote' => true]) ->assertSuccessful(); - Process::assertRan(fn ($process) => in_array('docker-compose.odin.yml', $process->command)); + Process::assertRan(fn ($process) => in_array('docker-compose.remote.yml', $process->command)); }); it('returns exit code from docker compose', function () { @@ -122,7 +122,7 @@ expect($signature)->toContain('{service?'); expect($signature)->toContain('--f|follow'); expect($signature)->toContain('--tail=50'); - expect($signature)->toContain('--odin'); + expect($signature)->toContain('--remote'); }); it('has correct description', function () { diff --git a/tests/Feature/Commands/Service/StatusCommandTest.php b/tests/Feature/Commands/Service/StatusCommandTest.php index 87914ec..e64345a 100644 --- a/tests/Feature/Commands/Service/StatusCommandTest.php +++ b/tests/Feature/Commands/Service/StatusCommandTest.php @@ -57,11 +57,11 @@ ->assertSuccessful(); }); - it('shows odin environment with --odin flag', function () { - $this->artisan('service:status', ['--odin' => true]) + it('shows remote environment with --remote flag', function () { + $this->artisan('service:status', ['--remote' => true]) ->assertSuccessful(); - Process::assertRan(fn ($process) => in_array('docker-compose.odin.yml', $process->command)); + Process::assertRan(fn ($process) => in_array('docker-compose.remote.yml', $process->command)); }); }); @@ -143,11 +143,11 @@ && in_array('ps', $process->command)); }); - it('uses odin compose file with --odin flag', function () { - $this->artisan('service:status', ['--odin' => true]) + it('uses remote compose file with --remote flag', function () { + $this->artisan('service:status', ['--remote' => true]) ->assertSuccessful(); - Process::assertRan(fn ($process) => in_array('docker-compose.odin.yml', $process->command)); + Process::assertRan(fn ($process) => in_array('docker-compose.remote.yml', $process->command)); }); it('handles docker compose failure gracefully', function () { @@ -196,7 +196,7 @@ $signature = $signatureProperty->getValue($command); expect($signature)->toContain('service:status'); - expect($signature)->toContain('--odin'); + expect($signature)->toContain('--remote'); }); it('has correct description', function () { diff --git a/tests/Feature/Commands/Service/UpCommandTest.php b/tests/Feature/Commands/Service/UpCommandTest.php index 1751d11..e853657 100644 --- a/tests/Feature/Commands/Service/UpCommandTest.php +++ b/tests/Feature/Commands/Service/UpCommandTest.php @@ -26,13 +26,13 @@ } }); - it('fails when odin docker-compose file does not exist', function () { + it('fails when remote docker-compose file does not exist', function () { $tempDir = sys_get_temp_dir().'/know-test-'.uniqid(); mkdir($tempDir, 0755, true); $this->app->setBasePath($tempDir); try { - $this->artisan('service:up', ['--odin' => true]) + $this->artisan('service:up', ['--remote' => true]) ->assertFailed(); } finally { rmdir($tempDir); @@ -57,11 +57,11 @@ && in_array('-d', $process->command)); }); - it('uses odin compose file when odin flag is set', function () { - $this->artisan('service:up', ['--odin' => true]) + it('uses remote compose file when remote flag is set', function () { + $this->artisan('service:up', ['--remote' => true]) ->assertSuccessful(); - Process::assertRan(fn ($process) => in_array('docker-compose.odin.yml', $process->command)); + Process::assertRan(fn ($process) => in_array('docker-compose.remote.yml', $process->command)); }); it('returns failure when docker compose fails', function () { @@ -76,11 +76,11 @@ ->assertFailed(); }); - it('combines odin and detach flags correctly', function () { - $this->artisan('service:up', ['--odin' => true, '--detach' => true]) + it('combines remote and detach flags correctly', function () { + $this->artisan('service:up', ['--remote' => true, '--detach' => true]) ->assertSuccessful(); - Process::assertRan(fn ($process) => in_array('docker-compose.odin.yml', $process->command) + Process::assertRan(fn ($process) => in_array('docker-compose.remote.yml', $process->command) && in_array('-d', $process->command)); }); }); @@ -95,7 +95,7 @@ expect($signature)->toContain('service:up'); expect($signature)->toContain('--d|detach'); - expect($signature)->toContain('--odin'); + expect($signature)->toContain('--remote'); }); it('has correct description', function () { diff --git a/tests/Feature/KnowledgeStatsCommandTest.php b/tests/Feature/KnowledgeStatsCommandTest.php index caea984..af5fcaf 100644 --- a/tests/Feature/KnowledgeStatsCommandTest.php +++ b/tests/Feature/KnowledgeStatsCommandTest.php @@ -3,15 +3,15 @@ declare(strict_types=1); use App\Services\KnowledgeCacheService; -use App\Services\OdinSyncService; use App\Services\QdrantService; +use App\Services\RemoteSyncService; describe('KnowledgeStatsCommand', function (): void { it('displays comprehensive analytics dashboard covering all code paths', function (): void { $qdrant = mock(QdrantService::class); - $odinSync = mock(OdinSyncService::class); + $remoteSync = mock(RemoteSyncService::class); app()->instance(QdrantService::class, $qdrant); - app()->instance(OdinSyncService::class, $odinSync); + app()->instance(RemoteSyncService::class, $remoteSync); mockProjectDetector(); // Mixed entries testing all display logic in a single test due to static caching @@ -66,7 +66,7 @@ ->once() ->andReturnNull(); - $odinSync->shouldReceive('isEnabled') + $remoteSync->shouldReceive('isEnabled') ->once() ->andReturn(false); @@ -77,9 +77,9 @@ it('displays cache metrics when cache service is available', function (): void { $qdrant = mock(QdrantService::class); $cacheService = mock(KnowledgeCacheService::class); - $odinSync = mock(OdinSyncService::class); + $remoteSync = mock(RemoteSyncService::class); app()->instance(QdrantService::class, $qdrant); - app()->instance(OdinSyncService::class, $odinSync); + app()->instance(RemoteSyncService::class, $remoteSync); mockProjectDetector(); $entries = collect([ @@ -120,7 +120,7 @@ 'stats' => ['hits' => 8, 'misses' => 2], ]); - $odinSync->shouldReceive('isEnabled') + $remoteSync->shouldReceive('isEnabled') ->once() ->andReturn(false); @@ -128,11 +128,11 @@ ->assertSuccessful(); }); - it('displays odin sync status with unknown status using gray color', function (): void { + it('displays remote sync status with unknown status using gray color', function (): void { $qdrant = mock(QdrantService::class); - $odinSync = mock(OdinSyncService::class); + $remoteSync = mock(RemoteSyncService::class); app()->instance(QdrantService::class, $qdrant); - app()->instance(OdinSyncService::class, $odinSync); + app()->instance(RemoteSyncService::class, $remoteSync); mockProjectDetector(); $entries = collect([ @@ -165,11 +165,11 @@ ->once() ->andReturnNull(); - $odinSync->shouldReceive('isEnabled') + $remoteSync->shouldReceive('isEnabled') ->once() ->andReturn(true); - $odinSync->shouldReceive('getStatus') + $remoteSync->shouldReceive('getStatus') ->once() ->andReturn([ 'status' => 'unknown-status', @@ -182,19 +182,19 @@ ->assertSuccessful(); }); - it('displays odin sync error status in red', function (): void { + it('displays remote sync error status in red', function (): void { $qdrant = mock(QdrantService::class); - $odinSync = mock(OdinSyncService::class); + $remoteSync = mock(RemoteSyncService::class); app()->instance(QdrantService::class, $qdrant); - app()->instance(OdinSyncService::class, $odinSync); + app()->instance(RemoteSyncService::class, $remoteSync); mockProjectDetector(); $qdrant->shouldReceive('count')->once()->with('default')->andReturn(0); $qdrant->shouldReceive('scroll')->once()->with([], 0, 'default')->andReturn(collect([])); $qdrant->shouldReceive('getCollectionName')->with('default')->andReturn('knowledge_default'); $qdrant->shouldReceive('getCacheService')->once()->andReturnNull(); - $odinSync->shouldReceive('isEnabled')->once()->andReturn(true); - $odinSync->shouldReceive('getStatus')->once()->andReturn([ + $remoteSync->shouldReceive('isEnabled')->once()->andReturn(true); + $remoteSync->shouldReceive('getStatus')->once()->andReturn([ 'status' => 'error', 'pending' => 0, 'last_synced' => null, @@ -204,19 +204,19 @@ $this->artisan('stats')->assertSuccessful(); }); - it('displays odin sync pending status in yellow', function (): void { + it('displays remote sync pending status in yellow', function (): void { $qdrant = mock(QdrantService::class); - $odinSync = mock(OdinSyncService::class); + $remoteSync = mock(RemoteSyncService::class); app()->instance(QdrantService::class, $qdrant); - app()->instance(OdinSyncService::class, $odinSync); + app()->instance(RemoteSyncService::class, $remoteSync); mockProjectDetector(); $qdrant->shouldReceive('count')->once()->with('default')->andReturn(0); $qdrant->shouldReceive('scroll')->once()->with([], 0, 'default')->andReturn(collect([])); $qdrant->shouldReceive('getCollectionName')->with('default')->andReturn('knowledge_default'); $qdrant->shouldReceive('getCacheService')->once()->andReturnNull(); - $odinSync->shouldReceive('isEnabled')->once()->andReturn(true); - $odinSync->shouldReceive('getStatus')->once()->andReturn([ + $remoteSync->shouldReceive('isEnabled')->once()->andReturn(true); + $remoteSync->shouldReceive('getStatus')->once()->andReturn([ 'status' => 'pending', 'pending' => 5, 'last_synced' => null, @@ -226,11 +226,11 @@ $this->artisan('stats')->assertSuccessful(); }); - it('displays odin sync status when enabled', function (): void { + it('displays remote sync status when enabled', function (): void { $qdrant = mock(QdrantService::class); - $odinSync = mock(OdinSyncService::class); + $remoteSync = mock(RemoteSyncService::class); app()->instance(QdrantService::class, $qdrant); - app()->instance(OdinSyncService::class, $odinSync); + app()->instance(RemoteSyncService::class, $remoteSync); mockProjectDetector(); $entries = collect([ @@ -263,11 +263,11 @@ ->once() ->andReturnNull(); - $odinSync->shouldReceive('isEnabled') + $remoteSync->shouldReceive('isEnabled') ->once() ->andReturn(true); - $odinSync->shouldReceive('getStatus') + $remoteSync->shouldReceive('getStatus') ->once() ->andReturn([ 'status' => 'synced', diff --git a/tests/Unit/Services/OdinSyncServiceTest.php b/tests/Unit/Services/RemoteSyncServiceTest.php similarity index 79% rename from tests/Unit/Services/OdinSyncServiceTest.php rename to tests/Unit/Services/RemoteSyncServiceTest.php index 0b59c28..f5066af 100644 --- a/tests/Unit/Services/OdinSyncServiceTest.php +++ b/tests/Unit/Services/RemoteSyncServiceTest.php @@ -3,49 +3,49 @@ declare(strict_types=1); use App\Services\KnowledgePathService; -use App\Services\OdinSyncService; +use App\Services\RemoteSyncService; use GuzzleHttp\Client; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; beforeEach(function (): void { - $this->tempDir = sys_get_temp_dir().'/odin_sync_test_'.uniqid(); + $this->tempDir = sys_get_temp_dir().'/remote_sync_test_'.uniqid(); mkdir($this->tempDir, 0755, true); $this->pathService = Mockery::mock(KnowledgePathService::class); $this->pathService->shouldReceive('getKnowledgeDirectory') ->andReturn($this->tempDir); - config(['services.odin.enabled' => true]); - config(['services.odin.url' => 'http://test-odin.local']); - config(['services.odin.token' => 'test-token']); - config(['services.odin.timeout' => 5]); - config(['services.odin.batch_size' => 50]); + config(['services.remote.enabled' => true]); + config(['services.remote.url' => 'http://test-remote.local']); + config(['services.remote.token' => 'test-token']); + config(['services.remote.timeout' => 5]); + config(['services.remote.batch_size' => 50]); }); afterEach(function (): void { removeDirectory($this->tempDir); }); -describe('OdinSyncService configuration', function (): void { +describe('RemoteSyncService configuration', function (): void { it('reports enabled when config is true', function (): void { - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); expect($service->isEnabled())->toBeTrue(); }); it('reports disabled when config is false', function (): void { - config(['services.odin.enabled' => false]); - $service = new OdinSyncService($this->pathService); + config(['services.remote.enabled' => false]); + $service = new RemoteSyncService($this->pathService); expect($service->isEnabled())->toBeFalse(); }); }); -describe('OdinSyncService queue operations', function (): void { +describe('RemoteSyncService queue operations', function (): void { it('queues an entry for sync', function (): void { - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); $entry = [ 'id' => 'test-1', @@ -59,8 +59,8 @@ }); it('does not queue when disabled', function (): void { - config(['services.odin.enabled' => false]); - $service = new OdinSyncService($this->pathService); + config(['services.remote.enabled' => false]); + $service = new RemoteSyncService($this->pathService); $service->queueForSync(['id' => 'test-1', 'title' => 'Test', 'content' => 'Content']); @@ -68,7 +68,7 @@ }); it('queues multiple entries', function (): void { - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); $service->queueForSync(['id' => '1', 'title' => 'First', 'content' => 'Content 1']); $service->queueForSync(['id' => '2', 'title' => 'Second', 'content' => 'Content 2']); @@ -78,7 +78,7 @@ }); it('clears the queue', function (): void { - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); $service->queueForSync(['id' => '1', 'title' => 'Test', 'content' => 'Content']); expect($service->getPendingCount())->toBe(1); @@ -88,22 +88,22 @@ }); it('handles empty queue file gracefully', function (): void { - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); expect($service->getPendingCount())->toBe(0); }); it('handles corrupted queue file gracefully', function (): void { file_put_contents($this->tempDir.'/sync_queue.json', 'not-valid-json'); - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); expect($service->getPendingCount())->toBe(0); }); }); -describe('OdinSyncService processQueue', function (): void { +describe('RemoteSyncService processQueue', function (): void { it('processes empty queue', function (): void { - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); $result = $service->processQueue(); @@ -111,8 +111,8 @@ }); it('returns remaining when no token is set', function (): void { - config(['services.odin.token' => '']); - $service = new OdinSyncService($this->pathService); + config(['services.remote.token' => '']); + $service = new RemoteSyncService($this->pathService); $service->queueForSync(['id' => '1', 'title' => 'Test', 'content' => 'Content']); $result = $service->processQueue(); @@ -129,7 +129,7 @@ $mockClient = new Client(['handler' => $handlerStack]); app()->instance(Client::class, $mockClient); - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); $service->queueForSync(['id' => '1', 'title' => 'Test Entry', 'content' => 'Content']); $result = $service->processQueue(); @@ -149,7 +149,7 @@ $mockClient = new Client(['handler' => $handlerStack]); app()->instance(Client::class, $mockClient); - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); $service->queueForSync(['id' => '1', 'title' => 'Test Entry', 'content' => 'Content']); $result = $service->processQueue(); @@ -169,7 +169,7 @@ $mockClient = new Client(['handler' => $handlerStack]); app()->instance(Client::class, $mockClient); - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); $service->queueForSync(['id' => '1', 'title' => 'Test Entry', 'content' => 'Content'], 'delete'); $result = $service->processQueue(); @@ -189,7 +189,7 @@ $mockClient = new Client(['handler' => $handlerStack]); app()->instance(Client::class, $mockClient); - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); $service->queueForSync(['id' => '1', 'title' => 'Test', 'content' => 'Content'], 'delete'); $result = $service->processQueue(); @@ -202,9 +202,9 @@ }); }); -describe('OdinSyncService conflict resolution', function (): void { +describe('RemoteSyncService conflict resolution', function (): void { it('picks local when local is newer', function (): void { - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); $local = ['title' => 'Local', 'updated_at' => '2025-06-01T12:00:00+00:00']; $remote = ['title' => 'Remote', 'updated_at' => '2025-05-01T12:00:00+00:00']; @@ -215,7 +215,7 @@ }); it('picks remote when remote is newer', function (): void { - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); $local = ['title' => 'Local', 'updated_at' => '2025-05-01T12:00:00+00:00']; $remote = ['title' => 'Remote', 'updated_at' => '2025-06-01T12:00:00+00:00']; @@ -226,7 +226,7 @@ }); it('picks local when timestamps are equal', function (): void { - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); $local = ['title' => 'Local', 'updated_at' => '2025-06-01T12:00:00+00:00']; $remote = ['title' => 'Remote', 'updated_at' => '2025-06-01T12:00:00+00:00']; @@ -237,7 +237,7 @@ }); it('picks local when both timestamps are empty', function (): void { - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); $local = ['title' => 'Local']; $remote = ['title' => 'Remote']; @@ -248,7 +248,7 @@ }); it('picks remote when local timestamp is empty', function (): void { - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); $local = ['title' => 'Local']; $remote = ['title' => 'Remote', 'updated_at' => '2025-06-01T12:00:00+00:00']; @@ -259,7 +259,7 @@ }); it('picks local when remote timestamp is empty', function (): void { - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); $local = ['title' => 'Local', 'updated_at' => '2025-06-01T12:00:00+00:00']; $remote = ['title' => 'Remote']; @@ -270,9 +270,9 @@ }); }); -describe('OdinSyncService status', function (): void { +describe('RemoteSyncService status', function (): void { it('returns default status when no status file exists', function (): void { - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); $status = $service->getStatus(); @@ -283,7 +283,7 @@ }); it('returns pending status when queue has items', function (): void { - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); $service->queueForSync(['id' => '1', 'title' => 'Test', 'content' => 'Content']); $status = $service->getStatus(); @@ -301,7 +301,7 @@ 'last_error' => null, ])); - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); // Add items to queue - this makes the queue non-empty $service->queueForSync(['id' => '1', 'title' => 'Test', 'content' => 'Content']); @@ -315,7 +315,7 @@ it('handles corrupted status file gracefully', function (): void { file_put_contents($this->tempDir.'/sync_status.json', 'not-valid-json'); - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); $status = $service->getStatus(); @@ -330,7 +330,7 @@ $mockClient = new Client(['handler' => $handlerStack]); app()->instance(Client::class, $mockClient); - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); $service->queueForSync(['id' => '1', 'title' => 'Test', 'content' => 'Content']); $service->processQueue(); @@ -342,17 +342,17 @@ }); }); -describe('OdinSyncService connectivity', function (): void { +describe('RemoteSyncService connectivity', function (): void { it('reports unavailable when URL is empty', function (): void { - config(['services.odin.url' => '']); - $service = new OdinSyncService($this->pathService); + config(['services.remote.url' => '']); + $service = new RemoteSyncService($this->pathService); expect($service->isAvailable())->toBeFalse(); }); it('reports unavailable when token is empty', function (): void { - config(['services.odin.token' => '']); - $service = new OdinSyncService($this->pathService); + config(['services.remote.token' => '']); + $service = new RemoteSyncService($this->pathService); expect($service->isAvailable())->toBeFalse(); }); @@ -365,7 +365,7 @@ $mockClient = new Client(['handler' => $handlerStack]); app()->instance(Client::class, $mockClient); - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); expect($service->isAvailable())->toBeTrue(); @@ -380,7 +380,7 @@ $mockClient = new Client(['handler' => $handlerStack]); app()->instance(Client::class, $mockClient); - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); expect($service->isAvailable())->toBeFalse(); @@ -388,15 +388,15 @@ }); }); -describe('OdinSyncService pull', function (): void { +describe('RemoteSyncService pull', function (): void { it('returns empty when token is missing', function (): void { - config(['services.odin.token' => '']); - $service = new OdinSyncService($this->pathService); + config(['services.remote.token' => '']); + $service = new RemoteSyncService($this->pathService); - expect($service->pullFromOdin())->toBe([]); + expect($service->pullFromRemote())->toBe([]); }); - it('pulls entries from Odin', function (): void { + it('pulls entries from remote', function (): void { $entries = [ 'data' => [ ['title' => 'Entry 1', 'content' => 'Content 1'], @@ -411,8 +411,8 @@ $mockClient = new Client(['handler' => $handlerStack]); app()->instance(Client::class, $mockClient); - $service = new OdinSyncService($this->pathService); - $result = $service->pullFromOdin('myproject'); + $service = new RemoteSyncService($this->pathService); + $result = $service->pullFromRemote('myproject'); expect($result)->toHaveCount(2); expect($result[0]['title'])->toBe('Entry 1'); @@ -428,8 +428,8 @@ $mockClient = new Client(['handler' => $handlerStack]); app()->instance(Client::class, $mockClient); - $service = new OdinSyncService($this->pathService); - $result = $service->pullFromOdin(); + $service = new RemoteSyncService($this->pathService); + $result = $service->pullFromRemote(); expect($result)->toBe([]); @@ -444,8 +444,8 @@ $mockClient = new Client(['handler' => $handlerStack]); app()->instance(Client::class, $mockClient); - $service = new OdinSyncService($this->pathService); - $result = $service->pullFromOdin(); + $service = new RemoteSyncService($this->pathService); + $result = $service->pullFromRemote(); expect($result)->toBe([]); @@ -453,15 +453,15 @@ }); }); -describe('OdinSyncService listProjects', function (): void { +describe('RemoteSyncService listProjects', function (): void { it('returns empty when token is missing', function (): void { - config(['services.odin.token' => '']); - $service = new OdinSyncService($this->pathService); + config(['services.remote.token' => '']); + $service = new RemoteSyncService($this->pathService); expect($service->listProjects())->toBe([]); }); - it('returns projects list from Odin', function (): void { + it('returns projects list from remote', function (): void { $projects = [ 'data' => [ ['name' => 'project-a', 'entry_count' => 10, 'last_synced' => '2025-06-01'], @@ -476,7 +476,7 @@ $mockClient = new Client(['handler' => $handlerStack]); app()->instance(Client::class, $mockClient); - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); $result = $service->listProjects(); expect($result)->toHaveCount(2); @@ -493,7 +493,7 @@ $mockClient = new Client(['handler' => $handlerStack]); app()->instance(Client::class, $mockClient); - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); $result = $service->listProjects(); expect($result)->toBe([]); @@ -509,7 +509,7 @@ $mockClient = new Client(['handler' => $handlerStack]); app()->instance(Client::class, $mockClient); - $service = new OdinSyncService($this->pathService); + $service = new RemoteSyncService($this->pathService); $result = $service->listProjects(); expect($result)->toBe([]); diff --git a/tests/Unit/Services/SymbolIndexServiceTest.php b/tests/Unit/Services/SymbolIndexServiceTest.php new file mode 100644 index 0000000..fc854ef --- /dev/null +++ b/tests/Unit/Services/SymbolIndexServiceTest.php @@ -0,0 +1,557 @@ +group('symbol-index'); + +function createTestIndex(string $dir): string +{ + $indexDir = $dir.'/code-index'; + mkdir($indexDir, 0755, true); + + // Create index JSON matching jcodemunch format + $index = [ + 'repo' => 'local/test-repo', + 'owner' => 'local', + 'name' => 'test-repo', + 'indexed_at' => '2026-03-05T12:00:00', + 'index_version' => 2, + 'source_files' => ['app/Services/UserService.php', 'app/Models/User.php'], + 'languages' => ['php' => 2], + 'file_hashes' => [ + 'app/Services/UserService.php' => hash('sha256', 'user-service-content'), + 'app/Models/User.php' => hash('sha256', 'user-model-content'), + ], + 'symbols' => [ + [ + 'id' => 'app/Services/UserService.php::UserService#class', + 'file' => 'app/Services/UserService.php', + 'name' => 'UserService', + 'qualified_name' => 'UserService', + 'kind' => 'class', + 'language' => 'php', + 'signature' => 'class UserService', + 'docstring' => 'Handles user operations and authentication.', + 'summary' => 'Class UserService', + 'decorators' => [], + 'keywords' => ['user', 'auth'], + 'parent' => null, + 'line' => 10, + 'end_line' => 50, + 'byte_offset' => 100, + 'byte_length' => 800, + 'content_hash' => 'abc123', + ], + [ + 'id' => 'app/Services/UserService.php::UserService.authenticate#method', + 'file' => 'app/Services/UserService.php', + 'name' => 'authenticate', + 'qualified_name' => 'UserService.authenticate', + 'kind' => 'method', + 'language' => 'php', + 'signature' => 'public function authenticate(string $email, string $password): bool', + 'docstring' => 'Authenticate a user by email and password.', + 'summary' => 'Authenticate a user.', + 'decorators' => [], + 'keywords' => ['auth', 'login'], + 'parent' => 'app/Services/UserService.php::UserService#class', + 'line' => 15, + 'end_line' => 30, + 'byte_offset' => 200, + 'byte_length' => 400, + 'content_hash' => 'def456', + ], + [ + 'id' => 'app/Models/User.php::User#class', + 'file' => 'app/Models/User.php', + 'name' => 'User', + 'qualified_name' => 'User', + 'kind' => 'class', + 'language' => 'php', + 'signature' => 'class User extends Model', + 'docstring' => '', + 'summary' => 'Class User', + 'decorators' => [], + 'keywords' => [], + 'parent' => null, + 'line' => 8, + 'end_line' => 40, + 'byte_offset' => 50, + 'byte_length' => 600, + 'content_hash' => 'ghi789', + ], + ], + ]; + + file_put_contents($indexDir.'/local-test-repo.json', json_encode($index, JSON_PRETTY_PRINT)); + + // Create raw content directory with source files + $contentDir = $indexDir.'/local-test-repo'; + mkdir($contentDir.'/app/Services', 0755, true); + mkdir($contentDir.'/app/Models', 0755, true); + + // Write source files with content at correct byte offsets + $serviceContent = str_repeat(' ', 200) + .'public function authenticate(string $email, string $password): bool { return true; }' + .str_repeat(' ', 500); + file_put_contents($contentDir.'/app/Services/UserService.php', $serviceContent); + file_put_contents($contentDir.'/app/Models/User.php', str_repeat(' ', 700)); + + return $indexDir; +} + +beforeEach(function (): void { + $this->tempDir = sys_get_temp_dir().'/symbol-index-test-'.uniqid(); + mkdir($this->tempDir, 0755, true); + $this->indexDir = createTestIndex($this->tempDir); + $this->service = new SymbolIndexService($this->indexDir); +}); + +afterEach(function (): void { + // Recursive delete + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->tempDir, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iterator as $file) { + if ($file->isDir()) { + rmdir($file->getPathname()); + } else { + unlink($file->getPathname()); + } + } + rmdir($this->tempDir); +}); + +describe('searchSymbols', function (): void { + it('finds symbols by exact name match', function (): void { + $results = $this->service->searchSymbols('authenticate', 'local/test-repo'); + + expect($results)->not->toBeEmpty(); + expect($results[0]['name'])->toBe('authenticate'); + expect($results[0]['score'])->toBeGreaterThanOrEqual(20); + }); + + it('finds symbols by partial name match', function (): void { + $results = $this->service->searchSymbols('auth', 'local/test-repo'); + + expect($results)->not->toBeEmpty(); + // Should find both UserService (via keywords) and authenticate (via name) + $names = array_column($results, 'name'); + expect($names)->toContain('authenticate'); + }); + + it('ranks exact matches above partial matches', function (): void { + $results = $this->service->searchSymbols('User', 'local/test-repo'); + + expect($results)->toHaveCount(3); + expect($results[0]['name'])->toBe('User'); + }); + + it('filters by symbol kind', function (): void { + $results = $this->service->searchSymbols('user', 'local/test-repo', kind: 'class'); + + foreach ($results as $result) { + expect($result['kind'])->toBe('class'); + } + }); + + it('filters by file pattern', function (): void { + $results = $this->service->searchSymbols('user', 'local/test-repo', filePattern: '*/Models/*'); + + foreach ($results as $result) { + expect($result['file'])->toContain('Models'); + } + }); + + it('respects max results limit', function (): void { + $results = $this->service->searchSymbols('user', 'local/test-repo', maxResults: 1); + + expect($results)->toHaveCount(1); + }); + + it('returns empty array for non-matching query', function (): void { + $results = $this->service->searchSymbols('zzzznonexistent', 'local/test-repo'); + + expect($results)->toBeEmpty(); + }); + + it('returns empty array for non-existent repo', function (): void { + $results = $this->service->searchSymbols('test', 'local/nonexistent'); + + expect($results)->toBeEmpty(); + }); + + it('scores signature matches', function (): void { + $results = $this->service->searchSymbols('string email', 'local/test-repo'); + + expect($results)->not->toBeEmpty(); + // authenticate has 'string $email' in signature + $names = array_column($results, 'name'); + expect($names)->toContain('authenticate'); + }); + + it('scores docstring matches', function (): void { + $results = $this->service->searchSymbols('password', 'local/test-repo'); + + expect($results)->not->toBeEmpty(); + $names = array_column($results, 'name'); + expect($names)->toContain('authenticate'); + }); + + it('scores keyword matches', function (): void { + $results = $this->service->searchSymbols('login', 'local/test-repo'); + + expect($results)->not->toBeEmpty(); + $names = array_column($results, 'name'); + expect($names)->toContain('authenticate'); + }); +}); + +describe('getSymbolSource', function (): void { + it('retrieves source code via byte-offset seek', function (): void { + $source = $this->service->getSymbolSource( + 'app/Services/UserService.php::UserService.authenticate#method', + 'local/test-repo' + ); + + expect($source)->not->toBeNull(); + expect($source)->toContain('function authenticate'); + }); + + it('returns null for non-existent symbol', function (): void { + $source = $this->service->getSymbolSource('nonexistent#method', 'local/test-repo'); + + expect($source)->toBeNull(); + }); + + it('returns null for non-existent repo', function (): void { + $source = $this->service->getSymbolSource('any#method', 'local/nonexistent'); + + expect($source)->toBeNull(); + }); +}); + +describe('getSymbol', function (): void { + it('returns symbol metadata by ID', function (): void { + $symbol = $this->service->getSymbol( + 'app/Services/UserService.php::UserService#class', + 'local/test-repo' + ); + + expect($symbol)->not->toBeNull(); + expect($symbol['name'])->toBe('UserService'); + expect($symbol['kind'])->toBe('class'); + expect($symbol['line'])->toBe(10); + }); + + it('returns null for non-existent symbol', function (): void { + $symbol = $this->service->getSymbol('nonexistent#class', 'local/test-repo'); + + expect($symbol)->toBeNull(); + }); +}); + +describe('getFileOutline', function (): void { + it('returns hierarchical symbol tree for a file', function (): void { + $outline = $this->service->getFileOutline( + 'app/Services/UserService.php', + 'local/test-repo' + ); + + expect($outline)->toHaveCount(1); + expect($outline[0]['name'])->toBe('UserService'); + expect($outline[0]['kind'])->toBe('class'); + expect($outline[0])->toHaveKey('children'); + expect($outline[0]['children'])->toHaveCount(1); + expect($outline[0]['children'][0]['name'])->toBe('authenticate'); + }); + + it('returns empty array for non-existent file', function (): void { + $outline = $this->service->getFileOutline('nonexistent.php', 'local/test-repo'); + + expect($outline)->toBeEmpty(); + }); + + it('returns flat list for files without hierarchy', function (): void { + $outline = $this->service->getFileOutline( + 'app/Models/User.php', + 'local/test-repo' + ); + + expect($outline)->toHaveCount(1); + expect($outline[0]['name'])->toBe('User'); + expect($outline[0])->not->toHaveKey('children'); + }); +}); + +describe('detectChanges', function (): void { + it('detects changed files by hash comparison', function (): void { + $changes = $this->service->detectChanges([ + 'app/Services/UserService.php' => 'modified-content', + 'app/Models/User.php' => 'user-model-content', + ], 'local/test-repo'); + + expect($changes['changed'])->toContain('app/Services/UserService.php'); + expect($changes['new'])->toBeEmpty(); + expect($changes['deleted'])->toBeEmpty(); + }); + + it('detects new files', function (): void { + $changes = $this->service->detectChanges([ + 'app/Services/UserService.php' => 'user-service-content', + 'app/Models/User.php' => 'user-model-content', + 'app/Services/NewService.php' => 'new-content', + ], 'local/test-repo'); + + expect($changes['new'])->toContain('app/Services/NewService.php'); + expect($changes['changed'])->toBeEmpty(); + }); + + it('detects deleted files', function (): void { + $changes = $this->service->detectChanges([ + 'app/Services/UserService.php' => 'user-service-content', + ], 'local/test-repo'); + + expect($changes['deleted'])->toContain('app/Models/User.php'); + }); + + it('detects no changes when files unchanged', function (): void { + $changes = $this->service->detectChanges([ + 'app/Services/UserService.php' => 'user-service-content', + 'app/Models/User.php' => 'user-model-content', + ], 'local/test-repo'); + + expect($changes['changed'])->toBeEmpty(); + expect($changes['new'])->toBeEmpty(); + expect($changes['deleted'])->toBeEmpty(); + }); +}); + +describe('listRepos', function (): void { + it('lists indexed repositories', function (): void { + $repos = $this->service->listRepos(); + + expect($repos)->toHaveCount(1); + expect($repos[0]['repo'])->toBe('local/test-repo'); + expect($repos[0]['symbol_count'])->toBe(3); + expect($repos[0]['file_count'])->toBe(2); + }); + + it('returns empty array when no indexes exist', function (): void { + $service = new SymbolIndexService($this->tempDir.'/empty'); + + $repos = $service->listRepos(); + + expect($repos)->toBeEmpty(); + }); +}); + +describe('getSymbolSource edge cases', function (): void { + it('returns null when content file does not exist', function (): void { + // Delete the raw content file + unlink($this->indexDir.'/local-test-repo/app/Services/UserService.php'); + + $source = $this->service->getSymbolSource( + 'app/Services/UserService.php::UserService.authenticate#method', + 'local/test-repo' + ); + + expect($source)->toBeNull(); + }); +}); + +describe('getSymbol edge cases', function (): void { + it('returns null for non-existent repo', function (): void { + $symbol = $this->service->getSymbol('any#method', 'local/nonexistent'); + + expect($symbol)->toBeNull(); + }); +}); + +describe('getFileOutline edge cases', function (): void { + it('returns empty for non-existent repo', function (): void { + $outline = $this->service->getFileOutline('any.php', 'local/nonexistent'); + + expect($outline)->toBeEmpty(); + }); +}); + +describe('indexFolder', function (): void { + it('returns error for invalid path', function (): void { + $result = $this->service->indexFolder('/nonexistent/path/to/folder'); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Invalid path'); + }); + + it('returns error for file path instead of directory', function (): void { + $file = $this->tempDir.'/not-a-dir.txt'; + file_put_contents($file, 'content'); + + $result = $this->service->indexFolder($file); + + expect($result['success'])->toBeFalse(); + expect($result['error'])->toContain('Invalid path'); + }); +}); + +describe('path traversal protection', function (): void { + it('returns null for path traversal attempts', function (): void { + // Create a symbol with a path traversal attempt in the index + $index = json_decode( + file_get_contents($this->indexDir.'/local-test-repo.json'), + true + ); + $index['symbols'][] = [ + 'id' => '../../etc/passwd::evil#function', + 'file' => '../../etc/passwd', + 'name' => 'evil', + 'qualified_name' => 'evil', + 'kind' => 'function', + 'language' => 'php', + 'signature' => 'function evil()', + 'docstring' => '', + 'summary' => '', + 'decorators' => [], + 'keywords' => [], + 'parent' => null, + 'line' => 1, + 'end_line' => 5, + 'byte_offset' => 0, + 'byte_length' => 100, + 'content_hash' => 'abc', + ]; + file_put_contents( + $this->indexDir.'/local-test-repo.json', + json_encode($index) + ); + + $source = $this->service->getSymbolSource( + '../../etc/passwd::evil#function', + 'local/test-repo' + ); + + expect($source)->toBeNull(); + }); +}); + +describe('repo parsing', function (): void { + it('handles single-segment repo names', function (): void { + // Create an index for a single-segment repo name + $index = [ + 'repo' => 'local/myrepo', + 'owner' => 'local', + 'name' => 'myrepo', + 'indexed_at' => '2026-01-01', + 'index_version' => 2, + 'source_files' => [], + 'languages' => [], + 'symbols' => [ + [ + 'id' => 'test.php::hello#function', + 'file' => 'test.php', + 'name' => 'hello', + 'qualified_name' => 'hello', + 'kind' => 'function', + 'language' => 'php', + 'signature' => 'function hello()', + 'docstring' => '', + 'summary' => '', + 'decorators' => [], + 'keywords' => [], + 'parent' => null, + 'line' => 1, + 'end_line' => 3, + 'byte_offset' => 0, + 'byte_length' => 50, + 'content_hash' => 'xxx', + ], + ], + 'file_hashes' => [], + ]; + file_put_contents( + $this->indexDir.'/local-myrepo.json', + json_encode($index) + ); + + // Search using single-segment name (triggers parseRepo fallback) + $results = $this->service->searchSymbols('hello', 'myrepo'); + + expect($results)->not->toBeEmpty(); + expect($results[0]['name'])->toBe('hello'); + }); +}); + +describe('corrupt index handling', function (): void { + it('returns empty results for corrupt JSON index', function (): void { + file_put_contents( + $this->indexDir.'/local-corrupt.json', + '"just a string, not an object"' + ); + + $results = $this->service->searchSymbols('test', 'local/corrupt'); + + expect($results)->toBeEmpty(); + }); +}); + +describe('index version handling', function (): void { + it('rejects indexes with future version numbers', function (): void { + $futureIndex = [ + 'repo' => 'local/future', + 'owner' => 'local', + 'name' => 'future', + 'indexed_at' => '2026-01-01', + 'index_version' => 99, + 'source_files' => [], + 'languages' => [], + 'symbols' => [], + 'file_hashes' => [], + ]; + file_put_contents( + $this->indexDir.'/local-future.json', + json_encode($futureIndex) + ); + + $results = $this->service->searchSymbols('test', 'local/future'); + + expect($results)->toBeEmpty(); + }); +}); + +describe('listRepos edge cases', function (): void { + it('skips invalid JSON files in index dir', function (): void { + file_put_contents($this->indexDir.'/broken.json', 'not-valid-json{{{'); + + $repos = $this->service->listRepos(); + + // Should still return the valid repo, skipping the broken file + $repoNames = array_column($repos, 'repo'); + expect($repoNames)->toContain('local/test-repo'); + }); + + it('skips unreadable index files', function (): void { + file_put_contents($this->indexDir.'/empty-array.json', '[]'); + + $repos = $this->service->listRepos(); + + $repoNames = array_column($repos, 'repo'); + expect($repoNames)->toContain('local/test-repo'); + }); +}); + +describe('detectChanges edge cases', function (): void { + it('treats all files as new when no prior index exists', function (): void { + $changes = $this->service->detectChanges([ + 'new_file.php' => 'content', + ], 'local/nonexistent'); + + // No prior index means file_hashes is empty, so everything is new + expect($changes['new'])->toContain('new_file.php'); + expect($changes['changed'])->toBeEmpty(); + expect($changes['deleted'])->toBeEmpty(); + }); +}); diff --git a/tests/Unit/Services/ThemeClassifierServiceTest.php b/tests/Unit/Services/ThemeClassifierServiceTest.php index 034504b..aa9d07f 100644 --- a/tests/Unit/Services/ThemeClassifierServiceTest.php +++ b/tests/Unit/Services/ThemeClassifierServiceTest.php @@ -50,7 +50,7 @@ $entry = [ 'title' => 'Docker Deployment Configuration', 'content' => 'Configure Podman containers for homelab server with Tailscale networking', - 'tags' => ['docker', 'infrastructure', 'odin'], + 'tags' => ['docker', 'infrastructure', 'server'], ]; $result = $this->classifier->classify($entry);