Files
hamza.khyari 1e606553d4
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / build (pull_request) Successful in 15s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 35s
CI / typecheck (pull_request) Successful in 38s
CI / unit_tests (pull_request) Successful in 2m32s
CI / integration_tests (pull_request) Successful in 3m18s
CI / docker (pull_request) Successful in 49s
CI / coverage (pull_request) Successful in 5m29s
CI / benchmark-regression (pull_request) Successful in 33m48s
feat(acms): implement Real-time Index Sync / UKOIndexer with pluggable analyzers
Implement the UKOIndexer service that produces UKO triples from resources
using pluggable domain-specific analyzers, wraps each triple with provenance
metadata, and simultaneously indexes into text, vector, and graph backends.

Key design decisions and components:

- UKOIndexer orchestrates the full index lifecycle: add_resource,
  update_resource (remove-then-add), remove_resource, and maintenance
  triggers. Each operation fires lifecycle hooks (on_indexed, on_removed,
  on_error) so callers can observe progress.

- Analyzer selection is pluggable via ContentAnalyzer protocol. The indexer
  accepts a registry mapping resource types to analyzers. PythonAnalyzer
  and MarkdownAnalyzer are provided as built-in implementations.

- LocationContentReader protocol abstracts file I/O with a base_dir
  parameter for path-traversal prevention (post-resolve validation rejects
  paths escaping the base directory and non-regular files).

- UKOTriple model includes a @model_validator ensuring at least one of
  object_uri or object_value is populated, preventing empty triples at
  construction time.

- Triple removal uses scoped deletion via uko:sourceResource predicate to
  avoid shared-subject collision — only triples originating from the
  specific resource are removed, not all triples for a shared subject.

- _resource_subjects.pop is deferred until after all backend removal
  operations succeed, preventing inconsistent state on partial failure.

- analyzer.analyze() is wrapped in try/except so that analyzer errors
  produce an IndexResult with error details rather than propagating
  exceptions to callers.

- All lifecycle hook calls are guarded via _fire_on_indexed,
  _fire_on_removed, and _fire_on_error helpers that catch and log hook
  exceptions without disrupting the indexing pipeline.

- max_triples parameter (default 50,000) bounds analyzer output size to
  prevent runaway resource consumption.

- ResourceFileWatcher monitors filesystem paths via watchdog and triggers
  re-indexing callbacks on file changes with configurable debouncing.
  Emits RESOURCE_MODIFIED domain events via EventBus when file changes
  are detected. Debounce timers coalesce rapid edits into a single
  callback invocation. Thread-safe design with daemon threads for clean
  shutdown.

- SearchResult.__post_init__ validates score is in [0.0, 1.0], correctly
  rejecting NaN values.

- Placeholder embedding uses [1.0] instead of [float(len(content))] to
  avoid leaking content size information.

- isinstance check on graph_backend ensures GraphIndexBackend protocol
  compliance at runtime.

- Test doubles extracted to features/mocks/uko_indexer_mocks.py for reuse
  across BDD steps and Robot helpers.

Spec reference: Architecture > ACMS > Real-time Index Synchronization
(specification.md lines ~43205-43300).

ISSUES CLOSED: #578
2026-03-11 00:40:07 +00:00

1084 lines
53 KiB
Gherkin

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