Feature: UKO Indexer — Real-time Index Synchronization As a context pipeline consumer I need the UKOIndexer to analyze resources and index into backends So that context strategies can query pre-built indices Background: Given idx a clean analyzer registry And idx in-memory index backends # ================================================================= # Index backend protocols and stubs # ================================================================= Scenario: InMemoryTextIndexBackend indexes and searches documents When idx I index a text document "doc1" with content "hello world" in project "local/app" Then idx searching "hello" in project "local/app" should return 1 result And idx the search result doc_id should be "doc1" Scenario: InMemoryTextIndexBackend returns empty for non-matching query When idx I index a text document "doc1" with content "hello world" in project "local/app" Then idx searching "foobar" in project "local/app" should return 0 results Scenario: InMemoryTextIndexBackend scopes by project When idx I index a text document "doc1" with content "hello world" in project "local/app" Then idx searching "hello" in project "local/other" should return 0 results Scenario: InMemoryTextIndexBackend removes documents When idx I index a text document "doc1" with content "hello world" in project "local/app" And idx I remove text document "doc1" from project "local/app" Then idx searching "hello" in project "local/app" should return 0 results Scenario: InMemoryTextIndexBackend rebuilds index When idx I index a text document "doc1" with content "hello world" in project "local/app" And idx I rebuild text index for project "local/app" Then idx searching "hello" in project "local/app" should return 0 results Scenario: InMemoryTextIndexBackend rejects empty project Then idx indexing a text document with empty project should raise ValueError Scenario: InMemoryTextIndexBackend rejects empty content Then idx indexing a text document with empty content should raise ValueError Scenario: InMemoryTextIndexBackend rejects empty query Then idx searching with empty query should raise ValueError Scenario: InMemoryTextIndexBackend rejects non-positive limit Then idx searching with limit 0 should raise ValueError Scenario: InMemoryVectorIndexBackend indexes and searches embeddings When idx I index an embedding "emb1" with vector "1.0,2.0,3.0" in project "local/app" Then idx searching similar in project "local/app" should return 1 result Scenario: InMemoryVectorIndexBackend removes embeddings When idx I index an embedding "emb1" with vector "1.0,2.0,3.0" in project "local/app" And idx I remove embedding "emb1" from project "local/app" Then idx searching similar in project "local/app" should return 0 results Scenario: InMemoryVectorIndexBackend rejects out-of-range min_relevance When idx I index an embedding "emb1" with vector "1.0,2.0,3.0" in project "local/app" Then idx searching similar with min_relevance 1.1 should raise ValueError Scenario: InMemoryVectorIndexBackend rejects empty embedding Then idx indexing an embedding with empty vector should raise ValueError Scenario: InMemoryGraphIndexBackend adds and queries triples When idx I add a triple "uko://s" "uko:type" "uko://Class" in project "local/app" Then idx querying project "local/app" should return 1 binding And idx the binding should have subject "uko://s" Scenario: InMemoryGraphIndexBackend removes triples by subject When idx I add a triple "uko://s" "uko:type" "uko://Class" in project "local/app" And idx I add a triple "uko://s" "uko:name" "Foo" in project "local/app" And idx I remove triples with subject "uko://s" from project "local/app" Then idx querying project "local/app" should return 0 bindings Scenario: InMemoryGraphIndexBackend rejects all-None pattern Then idx removing triples with all-None pattern should raise ValueError Scenario: InMemoryGraphIndexBackend rejects empty project Then idx adding a triple with empty project should raise ValueError # ================================================================= # Provenance model # ================================================================= Scenario: ProvenanceMetadata has required fields When idx I create provenance for resource "01HQ8ZDRX50000000000000001" Then idx the provenance should have source_resource "01HQ8ZDRX50000000000000001" And idx the provenance should be current And idx the provenance should have a valid_from timestamp Scenario: ProvenanceMetadata rejects empty source_resource Then idx creating provenance with empty source_resource should raise ValidationError Scenario: ProvenancedTriple wraps triple with provenance When idx I create a provenanced triple for resource "01HQ8ZDRX50000000000000001" Then idx the provenanced triple should have the triple And idx the provenanced triple should have the provenance Scenario: ProvenanceMetadata is frozen When idx I create provenance for resource "01HQ8ZDRX50000000000000001" Then idx mutating the provenance should raise ValidationError Scenario: IndexResult tracks indexing summary When idx I create an IndexResult with 5 triples and 1 text doc Then idx the IndexResult should have triple_count 5 And idx the IndexResult should have text_docs_indexed 1 And idx the IndexResult should have analyzer_domain "python" Scenario: IndexResult rejects negative triple_count Then idx creating IndexResult with negative triple_count should raise ValidationError # ================================================================= # AnalyzerRegistry.get_for_resource # ================================================================= Scenario: AnalyzerRegistry.get_for_resource finds analyzer by location extension Given idx a PythonAnalyzer is registered When idx I look up analyzer for a resource with location "src/main.py" Then idx the analyzer domain should be "python" Scenario: AnalyzerRegistry.get_for_resource returns None for unknown extension Given idx a PythonAnalyzer is registered When idx I look up analyzer for a resource with location "data.csv" Then idx no analyzer should be found Scenario: AnalyzerRegistry.get_for_resource returns None for no location Given idx a PythonAnalyzer is registered When idx I look up analyzer for a resource with no location Then idx no analyzer should be found Scenario: AnalyzerRegistry.get_for_resource finds MarkdownAnalyzer Given idx a MarkdownAnalyzer is registered When idx I look up analyzer for a resource with location "README.md" Then idx the analyzer domain should be "markdown" Scenario: AnalyzerRegistry higher-priority analyzer supersedes lower-priority Given idx a clean analyzer registry And idx a PythonAnalyzer is registered with priority 0 And idx an alternative Python analyzer "python-advanced" is registered with priority 10 When idx I look up analyzer for a resource with location "main.py" Then idx the analyzer domain should be "python-advanced" Scenario: AnalyzerRegistry lower-priority analyzer does not supersede higher-priority Given idx a clean analyzer registry And idx an alternative Python analyzer "python-advanced" is registered with priority 10 And idx a PythonAnalyzer is registered with priority 0 When idx I look up analyzer for a resource with location "main.py" Then idx the analyzer domain should be "python-advanced" # ================================================================= # UKOIndexer core # ================================================================= Scenario: UKOIndexer indexes a Python resource into all backends Given idx a PythonAnalyzer is registered And idx a UKOIndexer with all backends When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n pass" Then idx the index result should have triple_count greater than 0 And idx the index result should have text_docs_indexed 1 And idx the index result should have embeddings_indexed 1 And idx the index result should have analyzer_domain "python" And idx the index result should have 0 errors And idx the graph backend should have triples for project "local/test" And idx the text backend should have 1 document And idx the vector backend should have 1 embedding And idx the indexer should track 1 indexed resource Scenario: UKOIndexer indexes a Markdown resource Given idx a MarkdownAnalyzer is registered And idx a UKOIndexer with all backends When idx I index a Markdown resource "01HQ8ZDRX50000000000000002" with content "# Title\nSome text" Then idx the index result should have triple_count greater than 0 And idx the index result should have analyzer_domain "markdown" Scenario: UKOIndexer returns none-domain when no analyzer matches And idx a UKOIndexer with all backends When idx I index a CSV resource "01HQ8ZDRX50000000000000003" with location "data.csv" Then idx the index result should have analyzer_domain "none" And idx the index result should have triple_count 0 Scenario: UKOIndexer degrades gracefully without text backend Given idx a PythonAnalyzer is registered And idx a UKOIndexer without text backend When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "x = 1" Then idx the index result should have text_docs_indexed 0 And idx the index result should have 0 errors Scenario: UKOIndexer degrades gracefully without vector backend Given idx a PythonAnalyzer is registered And idx a UKOIndexer without vector backend When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "x = 1" Then idx the index result should have embeddings_indexed 0 And idx the index result should have 0 errors Scenario: UKOIndexer degrades gracefully without text and vector backends Given idx a PythonAnalyzer is registered And idx a UKOIndexer with only graph backend When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "x = 1" Then idx the index result should have text_docs_indexed 0 And idx the index result should have embeddings_indexed 0 And idx the index result should have triple_count greater than 0 Scenario: UKOIndexer reports content read errors without side effects Given idx a PythonAnalyzer is registered And idx a UKOIndexer with a failing content reader When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "x = 1" Then idx the index result should have 1 errors And idx the index result error should mention "read" And idx the indexer should track 0 indexed resources And idx the text backend should have 0 documents And idx the vector backend should have 0 embeddings Scenario: UKOIndexer rejects empty project Given idx a UKOIndexer with all backends Then idx indexing with empty project should raise ValueError # ================================================================= # UKOIndexer remove and reindex # ================================================================= Scenario: UKOIndexer removes a resource from all backends Given idx a PythonAnalyzer is registered And idx a UKOIndexer with all backends When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n pass" And idx I remove resource "01HQ8ZDRX50000000000000001" from project "local/test" Then idx the graph backend should have 0 triples for project "local/test" And idx the text backend should have 0 documents And idx the vector backend should have 0 embeddings And idx the indexer should track 0 indexed resources Scenario: UKOIndexer reindexes a resource Given idx a PythonAnalyzer is registered And idx a UKOIndexer with all backends When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n pass" And idx I reindex resource "01HQ8ZDRX50000000000000001" with content "class Bar:\n pass" Then idx the index result should have triple_count greater than 0 And idx the indexer should track 1 indexed resource Scenario: UKOIndexer remove rejects empty resource_id Given idx a UKOIndexer with all backends Then idx removing with empty resource_id should raise ValueError Scenario: UKOIndexer remove rejects empty project Given idx a UKOIndexer with all backends Then idx removing with empty project should raise ValueError # ================================================================= # Lifecycle hooks # ================================================================= Scenario: DefaultLifecycleHook receives on_indexed event Given idx a UKOIndexer with a tracking lifecycle hook And idx a PythonAnalyzer is registered When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "x = 1" Then idx the lifecycle hook should have received 1 on_indexed event And idx the lifecycle hook should have received 0 on_error events Scenario: DefaultLifecycleHook receives on_removed event Given idx a UKOIndexer with a tracking lifecycle hook And idx a PythonAnalyzer is registered When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "x = 1" And idx I remove resource "01HQ8ZDRX50000000000000001" from project "local/test" Then idx the lifecycle hook should have received 1 on_removed event Scenario: Lifecycle hook receives on_error for read failure Given idx a UKOIndexer with a tracking lifecycle hook and failing reader And idx a PythonAnalyzer is registered When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "x = 1" Then idx the lifecycle hook should have received 1 on_error events # ================================================================= # UKOIndexer constructor validation # ================================================================= Scenario: UKOIndexer rejects invalid analyzer_registry type Then idx creating UKOIndexer with invalid registry should raise TypeError Scenario: UKOIndexer has_text_backend property Given idx a UKOIndexer with all backends Then idx the indexer should have text backend And idx the indexer should have vector backend Scenario: UKOIndexer without optional backends reports availability Given idx a UKOIndexer with only graph backend Then idx the indexer should not have text backend And idx the indexer should not have vector backend # ================================================================= # SearchResult and IndexedDocument models # ================================================================= Scenario: SearchResult validates score range Then idx creating SearchResult with score 1.5 should raise ValueError Scenario: SearchResult rejects empty doc_id Then idx creating SearchResult with empty doc_id should raise ValueError Scenario: IndexedDocument validates non-negative char_count Then idx creating IndexedDocument with negative char_count should raise ValueError Scenario: IndexedDocument rejects empty project Then idx creating IndexedDocument with empty project should raise ValueError # ================================================================= # Review-discovered edge cases # ================================================================= Scenario: UKOIndexer double-index is idempotent (no stale accumulation) Given idx a PythonAnalyzer is registered And idx a UKOIndexer with all backends When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n pass" And idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n pass" Then idx the indexer should track 1 indexed resource And idx the text backend should have 1 document And idx the vector backend should have 1 embedding Scenario: UKOIndexer reindex replaces old triples with new Given idx a PythonAnalyzer is registered And idx a UKOIndexer with all backends When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n pass" And idx I reindex resource "01HQ8ZDRX50000000000000001" with content "class Bar:\n pass" Then idx the indexer should track 1 indexed resource And idx the graph backend should contain triple with predicate "rdfs:label" and object "Bar" And idx the graph backend should not contain triple with object "Foo" Scenario: UKOIndexer remove of never-indexed resource is no-op Given idx a PythonAnalyzer is registered And idx a UKOIndexer with all backends When idx I remove resource "01HQ8ZDRX50000000000000099" from project "local/test" Then idx the indexer should track 0 indexed resources Scenario: ProvenancedTriple has correct source_resource value Given idx a PythonAnalyzer is registered And idx an AnalyzerRegistry with the PythonAnalyzer When idx I analyze content "x = 1" with resource URI "uko://resource/01HQ8ZDRX50000000000000001" And idx I wrap the first triple with provenance for resource "01HQ8ZDRX50000000000000001" at path "src/main.py" Then idx the provenance source_resource should be "01HQ8ZDRX50000000000000001" And idx the provenance source_path should be "src/main.py" # ================================================================= # Bug-fix verifications (deep review round 2) # ================================================================= Scenario: Idempotent re-index does NOT fire on_removed lifecycle event Given idx a UKOIndexer with a tracking lifecycle hook And idx a PythonAnalyzer is registered When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n pass" And idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Bar:\n pass" Then idx the lifecycle hook should have received 0 on_removed events And idx the lifecycle hook should have received 2 on_indexed events Scenario: Cross-project re-index cleans up old project data Given idx a PythonAnalyzer is registered And idx a UKOIndexer with all backends When idx I index Python resource "01HQ8ZDRX50000000000000001" under project "org/old" content "class Foo:\n pass" And idx I index Python resource "01HQ8ZDRX50000000000000001" under project "org/new" content "class Bar:\n pass" Then idx the graph backend should have 0 triples for project "org/old" And idx the graph backend should have triples for project "org/new" And idx the text backend should have 1 document And idx the indexer should track 1 indexed resource Scenario: Reindex with different project cleans old project Given idx a PythonAnalyzer is registered And idx a UKOIndexer with all backends When idx I index Python resource "01HQ8ZDRX50000000000000001" under project "org/old" content "class Foo:\n pass" And idx I reindex resource "01HQ8ZDRX50000000000000001" under project "org/new" content "class Bar:\n pass" Then idx the graph backend should have 0 triples for project "org/old" And idx the graph backend should have triples for project "org/new" And idx the indexer should track 1 indexed resource Scenario: Whitespace-only project is rejected by text backend Then idx indexing a text document with whitespace-only project should raise ValueError Scenario: Whitespace-only content is rejected by text backend Then idx indexing a text document with whitespace-only content should raise ValueError Scenario: Whitespace-only subject is rejected by graph backend Then idx adding a triple with whitespace-only subject should raise ValueError # ================================================================= # Backend error path scenarios (graceful degradation) # ================================================================= Scenario: UKOIndexer handles graph backend error gracefully Given idx a PythonAnalyzer is registered And idx a UKOIndexer with a failing graph backend When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n pass" Then idx the index result should have triple_count 0 And idx the index result errors should mention "Graph backend" Scenario: UKOIndexer handles text backend error gracefully Given idx a PythonAnalyzer is registered And idx a UKOIndexer with a failing text backend When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n pass" Then idx the index result should have text_docs_indexed 0 And idx the index result errors should mention "Text backend" And idx the index result should have triple_count greater than 0 Scenario: UKOIndexer handles vector backend error gracefully Given idx a PythonAnalyzer is registered And idx a UKOIndexer with a failing vector backend When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n pass" Then idx the index result should have embeddings_indexed 0 And idx the index result errors should mention "Vector backend" And idx the index result should have triple_count greater than 0 # ================================================================= # Strengthened content assertions # ================================================================= Scenario: Text backend stores correct doc_id and content after indexing Given idx a PythonAnalyzer is registered And idx a UKOIndexer with all backends When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n pass" Then idx the text backend should contain doc_id "uko://resource/01HQ8ZDRX50000000000000001" in project "local/test" Scenario: Vector backend stores correct doc_id after indexing Given idx a PythonAnalyzer is registered And idx a UKOIndexer with all backends When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n pass" Then idx the vector backend should contain doc_id "uko://resource/01HQ8ZDRX50000000000000001" in project "local/test" Scenario: Graph backend stores sourceResource provenance link Given idx a PythonAnalyzer is registered And idx a UKOIndexer with all backends When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n pass" Then idx the graph backend should contain triple with predicate "uko:sourceResource" and object "uko://resource/01HQ8ZDRX50000000000000001" # ================================================================= # Additional negative validation tests # ================================================================= Scenario: IndexResult rejects empty resource_id Then idx creating IndexResult with empty resource_id should raise ValidationError Scenario: SearchResult rejects negative score Then idx creating SearchResult with score -0.1 should raise ValueError Scenario: Reindex rejects empty project Given idx a UKOIndexer with all backends Then idx reindexing with empty project should raise ValueError Scenario: Reindex rejects whitespace-only project Given idx a UKOIndexer with all backends Then idx reindexing with whitespace-only project should raise ValueError # ================================================================= # LocationContentReader tests (P2-5) # ================================================================= Scenario: LocationContentReader rejects path with dotdot components Then idx LocationContentReader should reject path with dotdot component Scenario: LocationContentReader rejects resource with no location Then idx LocationContentReader should reject resource with no location Scenario: LocationContentReader rejects non-positive max_content_size Then idx LocationContentReader should reject max_content_size of 0 Scenario: LocationContentReader reads a real file Then idx LocationContentReader should read a real file successfully Scenario: LocationContentReader rejects non-existent file Then idx LocationContentReader should reject non-existent file Scenario: LocationContentReader rejects path escaping base_dir Then idx LocationContentReader should reject path escaping base_dir Scenario: SearchResult rejects NaN score Then idx creating SearchResult with NaN score should raise ValueError # ================================================================= # File-watching integration (P1-1: file-watching acceptance criterion) # ================================================================= @file-watching Scenario: ResourceFileWatcher starts and stops cleanly Given idx a ResourceFileWatcher with default settings When idx the file watcher is started Then idx the file watcher should be running When idx the file watcher is stopped Then idx the file watcher should not be running @file-watching Scenario: Watching a file path registers it for monitoring Given idx a ResourceFileWatcher with default settings And idx a temporary watched file "test.py" with content "x = 1" When idx the watcher registers the file for resource "res-001" project "local/test" Then idx the watcher should have 1 watched paths @file-watching Scenario: Unwatching a file path removes it from monitoring Given idx a ResourceFileWatcher with default settings And idx a temporary watched file "test.py" with content "x = 1" When idx the watcher registers the file for resource "res-001" project "local/test" And idx the watcher unregisters the watched file Then idx the watcher should have 0 watched paths @file-watching Scenario: File modification triggers on_change callback Given idx a ResourceFileWatcher with callback tracking and debounce 0.05 And idx a temporary watched file "test.py" with content "x = 1" And idx the file watcher is started And idx the watcher registers the file for resource "res-001" project "local/test" When idx a FileModifiedEvent is simulated for the watched file Then idx the on_change callback should fire within 2 seconds And idx the callback should receive resource "res-001" project "local/test" change "modified" @file-watching Scenario: File deletion triggers on_change callback Given idx a ResourceFileWatcher with callback tracking and debounce 0.05 And idx a temporary watched file "test.py" with content "x = 1" And idx the file watcher is started And idx the watcher registers the file for resource "res-001" project "local/test" When idx a FileDeletedEvent is simulated for the watched file Then idx the on_change callback should fire within 2 seconds And idx the callback should receive resource "res-001" project "local/test" change "deleted" @file-watching Scenario: Debouncing coalesces rapid changes into one callback Given idx a ResourceFileWatcher with callback tracking and debounce 0.1 And idx a temporary watched file "test.py" with content "x = 1" And idx the file watcher is started And idx the watcher registers the file for resource "res-001" project "local/test" When idx 5 FileModifiedEvents are simulated rapidly for the watched file Then idx the on_change callback should fire within 2 seconds And idx the on_change callback should have fired exactly 1 time @file-watching Scenario: EventBus receives RESOURCE_MODIFIED event on file change Given idx a ResourceFileWatcher with EventBus tracking and debounce 0.05 And idx a temporary watched file "test.py" with content "x = 1" And idx the file watcher is started And idx the watcher registers the file for resource "res-001" project "local/test" When idx a FileModifiedEvent is simulated for the watched file Then idx the EventBus should receive a RESOURCE_MODIFIED event within 2 seconds And idx the event details should contain resource_id "res-001" @file-watching Scenario: Callback errors do not crash the watcher Given idx a ResourceFileWatcher with a failing callback and debounce 0.05 And idx a temporary watched file "test.py" with content "x = 1" And idx the file watcher is started And idx the watcher registers the file for resource "res-001" project "local/test" When idx a FileModifiedEvent is simulated for the watched file And idx we wait 0.2 seconds for debounce Then idx the file watcher should be running @file-watching Scenario: Watching a non-existent path raises ValueError Given idx a ResourceFileWatcher with default settings Then idx watching path "/nonexistent/path.py" should raise ValueError containing "does not exist" @file-watching Scenario: Watching a directory path raises ValueError Given idx a ResourceFileWatcher with default settings And idx a temporary watched directory Then idx watching the temporary directory should raise ValueError containing "not a file" @file-watching Scenario: Negative debounce_seconds raises ValueError Then idx creating ResourceFileWatcher with debounce -1.0 should raise ValueError @file-watching Scenario: Unwatched file changes are ignored Given idx a ResourceFileWatcher with callback tracking and debounce 0.05 And idx the file watcher is started When idx a FileModifiedEvent is simulated for path "/tmp/unwatched.py" And idx we wait 0.2 seconds for debounce Then idx the on_change callback should have fired exactly 0 times @file-watching Scenario: Directory events are ignored by the watcher Given idx a ResourceFileWatcher with callback tracking and debounce 0.05 And idx a temporary watched file "test.py" with content "x = 1" And idx the file watcher is started And idx the watcher registers the file for resource "res-001" project "local/test" When idx a directory event is simulated for the watched path And idx we wait 0.2 seconds for debounce Then idx the on_change callback should have fired exactly 0 times # ================================================================= # Coverage — file-watcher validation and edge cases # ================================================================= @file-watching Scenario: Watch rejects empty resource_id Given idx a ResourceFileWatcher with default settings And idx a temporary watched file "test.py" with content "x = 1" Then idx watching with empty resource_id should raise ValueError @file-watching Scenario: Watch rejects empty project Given idx a ResourceFileWatcher with default settings And idx a temporary watched file "test.py" with content "x = 1" Then idx watching with empty project should raise ValueError @file-watching Scenario: Starting an already-running watcher raises RuntimeError Given idx a ResourceFileWatcher with default settings And idx the file watcher is started Then idx starting the watcher again should raise RuntimeError And idx I stop the file watcher @file-watching Scenario: Stopping a non-running watcher is a no-op Given idx a ResourceFileWatcher with default settings When idx I stop the file watcher Then idx the file watcher should not be running @file-watching Scenario: debounce_seconds property returns configured value Given idx a ResourceFileWatcher with callback tracking and debounce 0.25 Then idx the debounce_seconds should be 0.25 @file-watching Scenario: File creation triggers on_change with created type Given idx a ResourceFileWatcher with callback tracking and debounce 0.05 And idx a temporary watched file "test.py" with content "x = 1" And idx the file watcher is started And idx the watcher registers the file for resource "res-001" project "local/test" When idx a FileCreatedEvent is simulated for the watched file Then idx the on_change callback should fire within 2 seconds And idx the callback should receive resource "res-001" project "local/test" change "created" @file-watching Scenario: File move triggers on_change with moved type Given idx a ResourceFileWatcher with callback tracking and debounce 0.05 And idx a temporary watched file "test.py" with content "x = 1" And idx the file watcher is started And idx the watcher registers the file for resource "res-001" project "local/test" When idx a FileMovedEvent is simulated for the watched file Then idx the on_change callback should fire within 2 seconds And idx the callback should receive resource "res-001" project "local/test" change "moved" @file-watching Scenario: Unwatch cancels pending debounce timer Given idx a ResourceFileWatcher with callback tracking and debounce 5.0 And idx a temporary watched file "test.py" with content "x = 1" And idx the file watcher is started And idx the watcher registers the file for resource "res-001" project "local/test" When idx a FileModifiedEvent is simulated for the watched file And idx the watcher unregisters the watched file And idx we wait 0.2 seconds for debounce Then idx the on_change callback should have fired exactly 0 times # ================================================================= # Coverage — UKOIndexer validation and error paths # ================================================================= Scenario: UKOIndexer rejects invalid graph_backend type Then idx creating UKOIndexer with invalid graph_backend should raise TypeError Scenario: UKOIndexer analyzer_registry property returns the registry Given idx a content reader with resource "01HQ8ZDRX50000000000000001" content "x = 1" When idx I create an indexer Then idx the indexer analyzer_registry should be the same registry Scenario: Lifecycle hook error on on_indexed does not crash indexer Given idx a PythonAnalyzer is registered And idx a content reader with resource "01HQ8ZDRX50000000000000001" content "x = 1\n" And idx a lifecycle hook that raises on on_indexed When idx I create an indexer with the failing hook And idx I index resource "01HQ8ZDRX50000000000000001" in project "local/test" Then idx the index result should have 0 errors Scenario: Lifecycle hook error on on_removed does not crash indexer Given idx a content reader with resource "01HQ8ZDRX50000000000000001" content "x = 1\n" And idx a lifecycle hook that raises on on_removed When idx I create an indexer with the failing hook And idx I index resource "01HQ8ZDRX50000000000000001" in project "local/test" Then idx removing resource "01HQ8ZDRX50000000000000001" should not raise Scenario: Lifecycle hook error on on_error does not crash indexer Given idx a PythonAnalyzer is registered And idx a lifecycle hook that raises on on_error And idx a content reader that raises on read When idx I create an indexer with the failing hook And idx I index resource "01HQ8ZDRX50000000000000001" in project "local/test" Then idx the index result should have errors Scenario: Analyzer exception returns IndexResult with error Given idx a content reader with resource "01HQ8ZDRX50000000000000001" content "x = 1\n" And idx an analyzer that raises RuntimeError When idx I create an indexer And idx I index resource "01HQ8ZDRX50000000000000001" in project "local/test" Then idx the index result should have errors And idx the index result error should mention "Analyzer error" Scenario: max_triples caps analyzer output Given idx a content reader with resource "01HQ8ZDRX50000000000000001" content "x = 1\n" And idx an analyzer that produces 100 triples When idx I create an indexer with max_triples 5 And idx I index resource "01HQ8ZDRX50000000000000001" in project "local/test" Then idx the index result triple_count should be at most 5 # ================================================================= # Coverage — LocationContentReader edge cases # ================================================================= Scenario: LocationContentReader rejects non-regular file (e.g. named pipe) Then idx LocationContentReader should reject a non-regular file Scenario: LocationContentReader rejects content exceeding max size Then idx LocationContentReader should reject content exceeding max size # ================================================================= # Coverage — ResourceFileWatcher start with pre-registered paths # ================================================================= @file-watching Scenario: Starting watcher with pre-registered paths schedules them Given idx a ResourceFileWatcher with callback tracking and debounce 0.05 And idx a temporary watched file "test.py" with content "x = 1" And idx the watcher registers the file for resource "res-001" project "local/test" When idx the file watcher is started Then idx the file watcher should be running And idx the watcher should have 1 watched paths # ================================================================= # Coverage — IndexedDocument validation # ================================================================= Scenario: IndexedDocument rejects empty doc_id Then idx creating IndexedDocument with empty doc_id should raise ValueError # ================================================================= # Coverage — Protocol stub NotImplementedError # ================================================================= Scenario: ContentReader protocol raises NotImplementedError Then idx calling ContentReader.read_content directly should raise NotImplementedError Scenario: IndexLifecycleHook protocol raises NotImplementedError on on_indexed Then idx calling IndexLifecycleHook.on_indexed directly should raise NotImplementedError Scenario: IndexLifecycleHook protocol raises NotImplementedError on on_removed Then idx calling IndexLifecycleHook.on_removed directly should raise NotImplementedError Scenario: IndexLifecycleHook protocol raises NotImplementedError on on_error Then idx calling IndexLifecycleHook.on_error directly should raise NotImplementedError Scenario: AnalyzerProtocol raises NotImplementedError on supported_extensions Then idx calling AnalyzerProtocol.supported_extensions directly should raise NotImplementedError Scenario: AnalyzerProtocol raises NotImplementedError on domain Then idx calling AnalyzerProtocol.domain directly should raise NotImplementedError Scenario: AnalyzerProtocol raises NotImplementedError on analyze Then idx calling AnalyzerProtocol.analyze directly should raise NotImplementedError # ================================================================= # Coverage — UKOTriple validation edge cases # ================================================================= Scenario: UKOTriple rejects whitespace-only subject_uri Then idx creating UKOTriple with whitespace-only subject_uri should raise ValueError Scenario: UKOTriple rejects whitespace-only predicate Then idx creating UKOTriple with whitespace-only predicate should raise ValueError Scenario: UKOTriple rejects both object_uri and object_value empty Then idx creating UKOTriple with empty object_uri and empty object_value should raise ValueError # ================================================================= # Coverage — AnalyzerRegistry edge cases # ================================================================= Scenario: AnalyzerRegistry.get_for_resource returns None for extensionless location Given idx a PythonAnalyzer is registered When idx I look up analyzer for a resource with location "Makefile" Then idx no analyzer should be found # ================================================================= # Coverage — AnalyzerRegistry validation and listing # ================================================================= Scenario: AnalyzerRegistry.register rejects non-protocol object Then idx registering a non-protocol object should raise TypeError Scenario: AnalyzerRegistry.register rejects analyzer with empty extensions Then idx registering an analyzer with empty extensions should raise ValueError Scenario: AnalyzerRegistry.get_for_extension with empty string returns None Then idx looking up empty extension should return None Scenario: AnalyzerRegistry.list_extensions returns registered extensions Given idx a PythonAnalyzer is registered Then idx list_extensions should include ".py" Scenario: AnalyzerRegistry.list_analyzers returns registered analyzers Given idx a PythonAnalyzer is registered Then idx list_analyzers should return 1 analyzer # ================================================================= # Coverage — fire_on_removed direct call with raising hook # ================================================================= Scenario: fire_on_removed swallows hook exception Then idx calling fire_on_removed with a raising hook should not raise # ================================================================= # Coverage — ResourceFileWatcher additional paths # ================================================================= @file-watching Scenario: ResourceFileWatcher with auto_reindex disabled does not start observer Given idx a ResourceFileWatcher with auto_reindex disabled When idx the file watcher is started Then idx the file watcher should not be running @file-watching Scenario: ResourceFileWatcher auto_reindex property reflects construction Given idx a ResourceFileWatcher with auto_reindex disabled Then idx the auto_reindex property should be false @file-watching Scenario: _fire_change with failing callback logs error but does not crash Given idx a ResourceFileWatcher with a directly-failing callback and debounce 0.05 And idx a temporary watched file "test.py" with content "x = 1" And idx the file watcher is started And idx the watcher registers the file for resource "res-001" project "local/test" When idx _fire_change is called directly with failing callback Then idx the file watcher should be running @file-watching Scenario: _fire_change with failing event_bus logs error but does not crash Given idx a ResourceFileWatcher with a failing event bus and debounce 0.05 And idx a temporary watched file "test.py" with content "x = 1" And idx the file watcher is started And idx the watcher registers the file for resource "res-001" project "local/test" When idx _fire_change is called directly with failing event bus Then idx the file watcher should be running @file-watching Scenario: _fire_change after stop is a no-op Given idx a ResourceFileWatcher with callback tracking and debounce 0.05 And idx a temporary watched file "test.py" with content "x = 1" And idx the file watcher is started And idx the watcher registers the file for resource "res-001" project "local/test" When idx the file watcher is stopped And idx _fire_change is called directly after stop Then idx the on_change callback should have fired exactly 0 times @file-watching Scenario: _ResourceChangeHandler routes events to watcher Given idx a ResourceFileWatcher with callback tracking and debounce 0.05 And idx a temporary watched file "test.py" with content "x = 1" And idx the file watcher is started And idx the watcher registers the file for resource "res-001" project "local/test" When idx a _ResourceChangeHandler is used to route events Then idx the on_change callback should fire within 2 seconds # ================================================================= # Coverage — removal backend failure paths # ================================================================= @coverage Scenario: remove_resource tolerates graph backend failure Given idx a clean analyzer registry And idx in-memory index backends And idx a PythonAnalyzer is registered And idx a UKOIndexer with removal-failing graph backend When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n pass" And idx I remove resource "01HQ8ZDRX50000000000000001" from project "local/test" Then idx the indexer should track 0 indexed resources @coverage Scenario: remove_resource tolerates text backend failure Given idx a clean analyzer registry And idx in-memory index backends And idx a PythonAnalyzer is registered And idx a UKOIndexer with removal-failing text backend When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n pass" And idx I remove resource "01HQ8ZDRX50000000000000001" from project "local/test" Then idx the indexer should track 0 indexed resources @coverage Scenario: remove_resource tolerates vector backend failure Given idx a clean analyzer registry And idx in-memory index backends And idx a PythonAnalyzer is registered And idx a UKOIndexer with removal-failing vector backend When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n pass" And idx I remove resource "01HQ8ZDRX50000000000000001" from project "local/test" Then idx the indexer should track 0 indexed resources # ================================================================= # Coverage — max_triples validation # ================================================================= @coverage Scenario: max_triples of zero raises ValueError Then idx creating a UKOIndexer with max_triples 0 should raise ValueError # ================================================================= # Coverage — reindex_resource on a fresh (not previously indexed) resource # ================================================================= @coverage Scenario: reindex_resource on a never-indexed resource succeeds Given idx a clean analyzer registry And idx in-memory index backends And idx a PythonAnalyzer is registered And idx a content reader with resource "01HQ8ZDRX50000000000000001" content "x = 1\n" When idx I create an indexer And idx I reindex resource "01HQ8ZDRX50000000000000001" in project "local/test" Then idx the index result should have triple_count greater than 0 # ================================================================= # Coverage — index_graph edge cases (internals) # ================================================================= @coverage Scenario: index_graph skips triple with no object_uri and no object_value Then idx index_graph should skip triples with empty object @coverage Scenario: index_graph handles rdfs:label failure for dual-object triple Then idx index_graph should tolerate rdfs:label failure @coverage Scenario: index_graph handles provenance link failure Then idx index_graph should tolerate provenance link failure # ================================================================= # Coverage — index_stubs edge cases # ================================================================= @coverage Scenario: InMemoryTextIndexBackend search respects limit Then idx text search should stop at the limit @coverage Scenario: InMemoryVectorIndexBackend rejects empty query_embedding Then idx vector search_similar with empty embedding should raise ValueError @coverage Scenario: InMemoryVectorIndexBackend rejects zero limit Then idx vector search_similar with limit 0 should raise ValueError @coverage Scenario: InMemoryVectorIndexBackend search filters by project Then idx vector search_similar filters by project @coverage Scenario: InMemoryGraphIndexBackend remove_triples on missing project is no-op Then idx graph remove_triples on missing project should be no-op @coverage Scenario: InMemoryGraphIndexBackend triple_count across all projects Then idx graph triple_count without project should sum all projects # ================================================================= # Coverage — index_backends protocol base methods # ================================================================= @coverage Scenario: Protocol base method bodies are exercisable Then idx all index backend protocol base methods should be callable # ================================================================= # Coverage — min_relevance filtering (P3-3) # ================================================================= @coverage Scenario: InMemoryVectorIndexBackend min_relevance above 1.0 raises ValueError Then idx vector search_similar with min_relevance above 1.0 raises ValueError @coverage Scenario: InMemoryVectorIndexBackend min_relevance 1.0 includes exact matches Then idx vector search_similar with min_relevance 1.0 includes exact matches @coverage Scenario: InMemoryVectorIndexBackend min_relevance 0.0 includes all matches Then idx vector search_similar with min_relevance 0.0 includes all matches # ================================================================= # Coverage — stub size limits (P2-16) # ================================================================= @coverage Scenario: InMemoryTextIndexBackend enforces max_entries limit Then idx text stub should raise RuntimeError when max_entries exceeded @coverage Scenario: InMemoryVectorIndexBackend enforces max_entries limit Then idx vector stub should raise RuntimeError when max_entries exceeded @coverage Scenario: InMemoryGraphIndexBackend enforces max_entries limit Then idx graph stub should raise RuntimeError when max_entries exceeded # ================================================================= # Coverage — provenance metadata persistence (P2-22, P2-27) # ================================================================= @coverage Scenario: index_graph stores full provenance metadata and confidence Then idx index_graph should store provenance metadata triples # ================================================================= # Coverage — callback + event_bus combo (P3-#18) # ================================================================= @coverage Scenario: ResourceFileWatcher fires both callback and EventBus Then idx file watcher should fire both callback and event_bus on change # ================================================================= # Coverage — source_range validation (#28) # ================================================================= @coverage Scenario: ProvenanceMetadata rejects invalid source_range format Then idx creating ProvenanceMetadata with invalid source_range should raise @coverage Scenario: ProvenanceMetadata accepts empty source_range Then idx creating ProvenanceMetadata with empty source_range should succeed # ================================================================= # Coverage — move to new directory (#4) # ================================================================= @coverage Scenario: FileMovedEvent to new directory schedules observer for dest parent Then idx file watcher should schedule new dir on cross-directory move # ================================================================= # Coverage — concurrency (#58) # ================================================================= @coverage Scenario: Concurrent indexing of different resources is thread-safe Then idx concurrent indexing of different resources should be thread-safe # ================================================================= # Coverage — move then modify sequence (#59) # ================================================================= @coverage Scenario: FileMovedEvent followed by FileModifiedEvent on new path Then idx file watcher should handle move then modify sequence # ================================================================= # Coverage — empty-string filter validation in remove_triples # ================================================================= @coverage Scenario: Graph remove_triples rejects empty-string subject filter Given idx a clean analyzer registry And idx in-memory index backends Then idx removing triples with empty-string subject should raise ValueError @coverage Scenario: Graph remove_triples rejects empty-string predicate filter Given idx a clean analyzer registry And idx in-memory index backends Then idx removing triples with empty-string predicate should raise ValueError @coverage Scenario: Graph remove_triples rejects empty-string obj filter Given idx a clean analyzer registry And idx in-memory index backends Then idx removing triples with empty-string obj should raise ValueError # ================================================================= # Coverage — dest timer cancel and EventBus move dest_path # ================================================================= @coverage @file-watching Scenario: Rapid create-then-move cancels prior dest timer Given idx a clean analyzer registry And idx in-memory index backends Then idx file watcher should cancel prior dest timer on rapid sequence @coverage @file-watching Scenario: EventBus move event includes dest_path in details Given idx a clean analyzer registry And idx in-memory index backends Then idx file watcher EventBus move event should include dest_path