"""Robot Framework helper for UKO Indexer smoke tests. Provides a CLI-style interface for Robot to invoke UKOIndexer creation, index lifecycle operations, graceful degradation, and provenance tracking. Exit code 0 = success, 1 = failure. Usage: python robot/helper_uko_indexer.py """ from __future__ import annotations import sys from collections.abc import Callable from pathlib import Path # Ensure the src directory is on the import path. _SRC = str(Path(__file__).resolve().parents[1] / "src") if _SRC not in sys.path: sys.path.insert(0, _SRC) from cleveragents.application.services.uko_indexer import UKOIndexer # noqa: E402 from cleveragents.application.services.uko_indexer_protocols import ( # noqa: E402 ContentReader, DefaultLifecycleHook, LocationContentReader, ) from cleveragents.domain.models.acms.analyzers import ( # noqa: E402 AnalyzerRegistry, ) from cleveragents.domain.models.acms.index_backends import ( # noqa: E402 IndexedDocument, SearchResult, ) from cleveragents.domain.models.acms.index_stubs import ( # noqa: E402 InMemoryGraphIndexBackend, InMemoryTextIndexBackend, InMemoryVectorIndexBackend, ) from cleveragents.domain.models.acms.provenance import ( # noqa: E402 IndexResult, ProvenanceMetadata, ) from cleveragents.domain.models.acms.python_analyzer import ( # noqa: E402 PythonAnalyzer, ) from cleveragents.domain.models.core.resource import Resource # noqa: E402 # Ensure features/ is importable for shared mocks _FEATURES = str(Path(__file__).resolve().parents[1]) if _FEATURES not in sys.path: sys.path.insert(0, _FEATURES) from features.mocks.uko_indexer_mocks import InMemoryContentReader # noqa: E402 ULID_1 = "01HQ8ZDRX50000000000000001" ULID_2 = "01HQ8ZDRX50000000000000002" PROJECT = "local/test" def _make_resource( resource_id: str, location: str = "src/example.py", ) -> Resource: """Create a minimal Resource for testing.""" return Resource( resource_id=resource_id, resource_type_name="git-checkout", location=location, classification="physical", ) def _make_indexer( *, text: bool = True, vector: bool = True, ) -> tuple[ UKOIndexer, InMemoryContentReader, InMemoryTextIndexBackend | None, InMemoryVectorIndexBackend | None, InMemoryGraphIndexBackend, ]: """Create a fully-wired UKOIndexer with in-memory backends.""" registry = AnalyzerRegistry() registry.register(PythonAnalyzer()) reader = InMemoryContentReader() graph = InMemoryGraphIndexBackend() text_be = InMemoryTextIndexBackend() if text else None vector_be = InMemoryVectorIndexBackend() if vector else None indexer = UKOIndexer( analyzer_registry=registry, graph_backend=graph, text_backend=text_be, vector_backend=vector_be, content_reader=reader, ) return ( indexer, reader, text_be, vector_be, graph, ) def cmd_index_resource() -> int: """Test basic index_resource pipeline.""" try: indexer, reader, _text, _vec, _graph = _make_indexer() resource = _make_resource(ULID_1) code = '"""Module doc."""\nclass Foo:\n """Class doc."""\n pass\n' reader.set_content(ULID_1, code) result = indexer.index_resource(resource, project=PROJECT) assert result.resource_id == ULID_1 assert result.triple_count > 0 assert result.analyzer_domain == "python" assert result.text_docs_indexed == 1 assert result.embeddings_indexed == 1 assert len(result.errors) == 0 assert indexer.indexed_resource_count == 1 print("uko-indexer-index-resource-ok") return 0 except Exception as exc: print(f"uko-indexer-index-resource-fail: {exc}") return 1 def cmd_remove_resource() -> int: """Test remove_resource cleanup including backend state.""" try: indexer, reader, text_be, vec_be, graph_be = _make_indexer() resource = _make_resource(ULID_1) reader.set_content(ULID_1, "x = 1\n") indexer.index_resource(resource, project=PROJECT) assert indexer.indexed_resource_count == 1 # Verify backends have data before removal assert graph_be.triple_count(PROJECT) > 0 assert text_be is not None and text_be.document_count > 0 assert vec_be is not None and vec_be.embedding_count > 0 indexer.remove_resource(ULID_1, project=PROJECT) assert indexer.indexed_resource_count == 0 # Verify backends are cleaned up after removal assert graph_be.triple_count(PROJECT) == 0 assert text_be.document_count == 0 assert vec_be.embedding_count == 0 print("uko-indexer-remove-resource-ok") return 0 except Exception as exc: print(f"uko-indexer-remove-resource-fail: {exc}") return 1 def cmd_reindex_resource() -> int: """Test reindex_resource (remove + re-index).""" try: indexer, reader, _text, _vec, _graph = _make_indexer() resource = _make_resource(ULID_1) reader.set_content(ULID_1, "x = 1\n") result1 = indexer.index_resource(resource, project=PROJECT) assert result1.triple_count > 0 # Update content and reindex reader.set_content( ULID_1, '"""Reindexed."""\nclass Bar:\n pass\n', ) result2 = indexer.reindex_resource(resource, project=PROJECT) assert result2.resource_id == ULID_1 assert result2.triple_count > 0 assert indexer.indexed_resource_count == 1 print("uko-indexer-reindex-resource-ok") return 0 except Exception as exc: print(f"uko-indexer-reindex-resource-fail: {exc}") return 1 def cmd_graceful_degradation() -> int: """Test graceful degradation without text/vector backends.""" try: indexer, reader, _, _, _graph = _make_indexer(text=False, vector=False) resource = _make_resource(ULID_1) reader.set_content(ULID_1, "y = 2\n") result = indexer.index_resource(resource, project=PROJECT) assert result.triple_count > 0 assert result.text_docs_indexed == 0 assert result.embeddings_indexed == 0 assert not indexer.has_text_backend assert not indexer.has_vector_backend assert len(result.errors) == 0 print("uko-indexer-graceful-degradation-ok") return 0 except Exception as exc: print(f"uko-indexer-graceful-degradation-fail: {exc}") return 1 def cmd_provenance() -> int: """Test provenance metadata is attached to triples.""" try: # Verify ProvenanceMetadata construction prov = ProvenanceMetadata( source_resource=ULID_1, source_path="src/example.py", ) assert prov.is_current is True assert prov.valid_from is not None # Verify provenance is attached during indexing via IndexResult indexer, reader, _, _, _graph = _make_indexer() resource = _make_resource(ULID_1) reader.set_content(ULID_1, "z = 3\n") result = indexer.index_resource(resource, project=PROJECT) assert result.triple_count > 0 assert result.resource_id == ULID_1 print("uko-indexer-provenance-ok") return 0 except Exception as exc: print(f"uko-indexer-provenance-fail: {exc}") return 1 def cmd_index_backends() -> int: """Test in-memory index backend operations.""" try: # Text backend text_be = InMemoryTextIndexBackend() text_be.index_document(PROJECT, "doc1", "hello world", {"key": "val"}) results = text_be.search(PROJECT, "hello", limit=5) assert len(results) == 1 assert results[0].doc_id == "doc1" text_be.remove_document(PROJECT, "doc1") results = text_be.search(PROJECT, "hello", limit=5) assert len(results) == 0 # Vector backend vec_be = InMemoryVectorIndexBackend() vec_be.index_embedding(PROJECT, "emb1", [1.0, 2.0, 3.0], {"key": "val"}) results = vec_be.search_similar(PROJECT, [1.0, 2.0, 3.0], limit=5) assert len(results) == 1 assert results[0].doc_id == "emb1" vec_be.remove_embedding(PROJECT, "emb1") results = vec_be.search_similar(PROJECT, [1.0, 2.0, 3.0], limit=5) assert len(results) == 0 # Graph backend graph_be = InMemoryGraphIndexBackend() graph_be.add_triple(PROJECT, "s1", "p1", "o1") bindings = graph_be.query(PROJECT, "SELECT * WHERE { ?s ?p ?o }") assert len(bindings) == 1 assert bindings[0]["s"] == "s1" assert bindings[0]["p"] == "p1" assert bindings[0]["o"] == "o1" graph_be.remove_triples(PROJECT, subject="s1", predicate=None, obj=None) bindings = graph_be.query(PROJECT, "SELECT * WHERE { ?s ?p ?o }") assert len(bindings) == 0 # Validation doc = IndexedDocument(project=PROJECT, doc_id="d1", char_count=10) assert doc.char_count == 10 sr = SearchResult(doc_id="d1", score=0.5, content="test") assert sr.score == 0.5 print("uko-indexer-index-backends-ok") return 0 except Exception as exc: print(f"uko-indexer-index-backends-fail: {exc}") return 1 def cmd_validation() -> int: """Test input validation guards.""" try: indexer, reader, _, _, _ = _make_indexer() resource = _make_resource(ULID_1) reader.set_content(ULID_1, "a = 1\n") # Empty project try: indexer.index_resource(resource, project="") print("uko-indexer-validation-fail: no ValueError for empty project") return 1 except ValueError: pass # Empty resource_id on remove try: indexer.remove_resource("", project=PROJECT) print("uko-indexer-validation-fail: no ValueError for empty resource_id") return 1 except ValueError: pass # Invalid analyzer_registry type try: UKOIndexer( analyzer_registry="not_a_registry", # type: ignore[arg-type] graph_backend=InMemoryGraphIndexBackend(), ) print("uko-indexer-validation-fail: no TypeError for invalid registry") return 1 except TypeError: pass # SearchResult score out of range try: SearchResult(doc_id="x", score=1.5, content="test") print("uko-indexer-validation-fail: no ValueError for score > 1.0") return 1 except ValueError: pass print("uko-indexer-validation-ok") return 0 except Exception as exc: print(f"uko-indexer-validation-fail: {exc}") return 1 def cmd_protocol_compliance() -> int: """Verify protocol compliance for key types.""" try: reader = InMemoryContentReader() assert isinstance(reader, ContentReader) hook = DefaultLifecycleHook() # DefaultLifecycleHook logs but doesn't fail result = IndexResult( resource_id=ULID_1, analyzer_domain="python", triple_count=5, ) hook.on_indexed(result) hook.on_removed(ULID_1, PROJECT) hook.on_error(ULID_1, "test error") # LocationContentReader protocol compliance loc_reader = LocationContentReader() assert isinstance(loc_reader, ContentReader) print("uko-indexer-protocol-compliance-ok") return 0 except Exception as exc: print(f"uko-indexer-protocol-compliance-fail: {exc}") return 1 def cmd_file_watching() -> int: """Test ResourceFileWatcher lifecycle, change detection, and EventBus.""" import threading import time from watchdog.events import FileDeletedEvent, FileModifiedEvent from cleveragents.application.services.resource_file_watcher import ( FileChangeType, ResourceFileWatcher, ) from cleveragents.infrastructure.events.models import DomainEvent from cleveragents.infrastructure.events.types import EventType class _MiniEventBus: def __init__(self) -> None: self.events: list[DomainEvent] = [] self._ev = threading.Event() def emit(self, event: DomainEvent) -> None: self.events.append(event) self._ev.set() def subscribe(self, event_type: EventType, handler: object) -> None: _ = event_type, handler try: import tempfile as _tf from pathlib import Path as _P tmpdir = _tf.mkdtemp() fpath = _P(tmpdir) / "watched.py" fpath.write_text("x = 1\n") changes: list[tuple[str, str, FileChangeType]] = [] cb_event = threading.Event() def _on_change(rid: str, proj: str, ct: FileChangeType) -> None: changes.append((rid, proj, ct)) cb_event.set() bus = _MiniEventBus() watcher = ResourceFileWatcher( on_change=_on_change, event_bus=bus, debounce_seconds=0.05, ) # 1. Lifecycle # Avoid creating real OS-level inotify watches in CI/parallel runs, # which can fail with ENOSPC when global watch limits are exhausted. # We only need the watcher in a "running" state to exercise internal # debounce/callback/event-bus behavior via synthetic watchdog events. assert not watcher.is_running watcher._running = True assert watcher.is_running # 2. Watch a file watcher.watch(fpath, resource_id=ULID_1, project=PROJECT) assert len(watcher.watched_paths) == 1 # 3. Simulate modification ev = FileModifiedEvent(str(fpath.resolve())) watcher._handle_fs_event(ev) assert cb_event.wait(timeout=2.0), "Callback did not fire" assert changes[-1][0] == ULID_1 assert changes[-1][2] == FileChangeType.MODIFIED # 4. EventBus received event time.sleep(0.1) assert len(bus.events) > 0 assert bus.events[-1].event_type == EventType.RESOURCE_MODIFIED assert bus.events[-1].details["resource_id"] == ULID_1 # 5. Simulate deletion cb_event.clear() ev2 = FileDeletedEvent(str(fpath.resolve())) watcher._handle_fs_event(ev2) assert cb_event.wait(timeout=2.0), "Callback did not fire for delete" assert changes[-1][2] == FileChangeType.DELETED # 6. Unwatch watcher.unwatch(fpath) assert len(watcher.watched_paths) == 0 # 7. Stop watcher.stop() assert not watcher.is_running # Cleanup fpath.unlink(missing_ok=True) _P(tmpdir).rmdir() print("uko-indexer-file-watching-ok") return 0 except Exception as exc: print(f"uko-indexer-file-watching-fail: {exc}") import traceback traceback.print_exc() return 1 COMMANDS: dict[str, Callable[[], int]] = { "index-resource": cmd_index_resource, "remove-resource": cmd_remove_resource, "reindex-resource": cmd_reindex_resource, "graceful-degradation": cmd_graceful_degradation, "provenance": cmd_provenance, "index-backends": cmd_index_backends, "validation": cmd_validation, "protocol-compliance": cmd_protocol_compliance, "file-watching": cmd_file_watching, } def main() -> int: """Entry point called by Robot Framework ``Run Process``.""" if len(sys.argv) < 2: print("Usage: helper_uko_indexer.py ") print(f"Commands: {', '.join(COMMANDS)}") return 1 command: str = sys.argv[1] handler = COMMANDS.get(command) if handler is None: print(f"Unknown command: {command}") print(f"Commands: {', '.join(COMMANDS)}") return 1 return handler() if __name__ == "__main__": sys.exit(main())