From 415c2b3d6e0d1fe4bb77027dae3b67aa53427f17 Mon Sep 17 00:00:00 2001 From: Paul Hernandez <60959+phernandez@users.noreply.github.com> Date: Mon, 11 May 2026 12:13:14 -0500 Subject: [PATCH] feat(cli): add orphan entity command (#816) Signed-off-by: phernandez --- .../api/v2/routers/knowledge_router.py | 33 ++++ src/basic_memory/cli/commands/__init__.py | 3 +- src/basic_memory/cli/commands/orphans.py | 93 +++++++++++ src/basic_memory/cli/main.py | 1 + src/basic_memory/mcp/clients/knowledge.py | 19 +++ .../repository/entity_repository.py | 18 ++- src/basic_memory/schemas/v2/__init__.py | 2 + src/basic_memory/schemas/v2/graph.py | 9 ++ tests/api/v2/test_orphan_router.py | 92 +++++++++++ tests/cli/test_orphans_command.py | 150 ++++++++++++++++++ tests/mcp/clients/test_clients.py | 33 ++++ tests/repository/test_entity_repository.py | 147 +++++++++++++++++ 12 files changed, 598 insertions(+), 2 deletions(-) create mode 100644 src/basic_memory/cli/commands/orphans.py create mode 100644 tests/api/v2/test_orphan_router.py create mode 100644 tests/cli/test_orphans_command.py diff --git a/src/basic_memory/api/v2/routers/knowledge_router.py b/src/basic_memory/api/v2/routers/knowledge_router.py index eef04bc7..a29fb976 100644 --- a/src/basic_memory/api/v2/routers/knowledge_router.py +++ b/src/basic_memory/api/v2/routers/knowledge_router.py @@ -38,6 +38,7 @@ from basic_memory.schemas.v2 import ( MoveEntityRequestV2, MoveDirectoryRequestV2, DeleteDirectoryRequestV2, + OrphanEntitiesResponse, ) from basic_memory.schemas.response import DirectoryMoveResult, DirectoryDeleteResult @@ -110,6 +111,38 @@ async def get_graph( return GraphResponse(nodes=nodes, edges=edges) +## Orphan entities endpoint + + +@router.get("/orphans", response_model=OrphanEntitiesResponse) +async def get_orphan_entities( + project_id: ProjectExternalIdPathDep, + entity_repository: EntityRepositoryV2ExternalDep, +) -> OrphanEntitiesResponse: + """Return entities that have no incoming or outgoing relations.""" + with logfire.span( + "api.request.knowledge.get_orphans", + entrypoint="api", + domain="knowledge", + action="get_orphans", + ): + logger.info("API v2 request: get_orphan_entities") + + entities = await entity_repository.find_without_relations() + nodes = [ + GraphNode( + external_id=entity.external_id, + title=entity.title, + note_type=entity.note_type, + file_path=entity.file_path, + ) + for entity in entities + ] + + logger.info(f"API v2 response: {len(nodes)} orphan entities") + return OrphanEntitiesResponse(entities=nodes, total=len(nodes)) + + ## Resolution endpoint diff --git a/src/basic_memory/cli/commands/__init__.py b/src/basic_memory/cli/commands/__init__.py index 8a749e22..5f80a149 100644 --- a/src/basic_memory/cli/commands/__init__.py +++ b/src/basic_memory/cli/commands/__init__.py @@ -1,6 +1,6 @@ """CLI commands for basic-memory.""" -from . import status, db, doctor, import_memory_json, mcp, import_claude_conversations +from . import status, db, doctor, import_memory_json, mcp, import_claude_conversations, orphans from . import ( import_claude_projects, import_chatgpt, @@ -18,6 +18,7 @@ __all__ = [ "import_memory_json", "mcp", "import_claude_conversations", + "orphans", "import_claude_projects", "import_chatgpt", "tool", diff --git a/src/basic_memory/cli/commands/orphans.py b/src/basic_memory/cli/commands/orphans.py new file mode 100644 index 00000000..6579588c --- /dev/null +++ b/src/basic_memory/cli/commands/orphans.py @@ -0,0 +1,93 @@ +"""Orphans command - show entities with no relations in the knowledge graph.""" + +import json +from typing import Annotated, Optional + +import typer +from loguru import logger +from mcp.server.fastmcp.exceptions import ToolError +from rich.console import Console +from rich.table import Table + +from basic_memory.cli.app import app +from basic_memory.cli.commands.routing import force_routing, validate_routing_flags +from basic_memory.config import ConfigManager +from basic_memory.mcp.async_client import get_client +from basic_memory.mcp.clients.knowledge import KnowledgeClient +from basic_memory.mcp.project_context import get_active_project +from basic_memory.schemas.v2.graph import GraphNode + +console = Console() + + +async def run_orphans(project: Optional[str] = None) -> tuple[str, list[GraphNode]]: + """Fetch entities that have no relations in the knowledge graph.""" + project = project or ConfigManager().default_project + + async with get_client(project_name=project) as client: + project_item = await get_active_project(client, project, None) + entities = await KnowledgeClient(client, project_item.external_id).get_orphans() + return project_item.name, entities + + +@app.command() +def orphans( + project: Annotated[ + Optional[str], + typer.Option(help="The project name."), + ] = None, + json_output: bool = typer.Option(False, "--json", help="Output in JSON format"), + local: bool = typer.Option( + False, "--local", help="Force local API routing (ignore cloud mode)" + ), + cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"), +): + """Show entities that have no relations in the knowledge graph. + + Orphan entities have no incoming or outgoing connections. These may indicate + newly created notes not yet linked to other entities, or notes that have had + their relations removed. + """ + from basic_memory.cli.commands.command_utils import run_with_cleanup + + try: + validate_routing_flags(local, cloud) + with force_routing(local=local, cloud=cloud): + project_name, entities = run_with_cleanup(run_orphans(project)) + + if json_output: + print(json.dumps([entity.model_dump(mode="json") for entity in entities], indent=2)) + return + + if not entities: + console.print(f"[green]No orphan entities in project '{project_name}'[/green]") + return + + table = Table(title=f"{project_name}: Entities Without Relations ({len(entities)} total)") + table.add_column("Title", style="cyan") + table.add_column("File Path", style="yellow") + table.add_column("Type", style="green") + + for entity in entities: + table.add_row( + entity.title, + entity.file_path, + entity.note_type or "", + ) + + console.print(table) + except (ValueError, ToolError) as exc: + if json_output: + print(json.dumps({"error": str(exc)}, indent=2)) + else: + console.print(f"[red]Error: {exc}[/red]") + raise typer.Exit(code=1) + except typer.Exit: + raise + except Exception as exc: + logger.error(f"Error fetching orphan entities: {exc}") + if json_output: + print(json.dumps({"error": str(exc)}, indent=2)) + else: + console.print(f"[red]Error: {exc}[/red]") + raise typer.Exit(code=1) # pragma: no cover diff --git a/src/basic_memory/cli/main.py b/src/basic_memory/cli/main.py index 9a427c7b..bd0d1cf9 100644 --- a/src/basic_memory/cli/main.py +++ b/src/basic_memory/cli/main.py @@ -24,6 +24,7 @@ if not _version_only_invocation(sys.argv[1:]): import_claude_projects, import_memory_json, mcp, + orphans, project, schema, status, diff --git a/src/basic_memory/mcp/clients/knowledge.py b/src/basic_memory/mcp/clients/knowledge.py index b0f5475b..50acb4ff 100644 --- a/src/basic_memory/mcp/clients/knowledge.py +++ b/src/basic_memory/mcp/clients/knowledge.py @@ -15,6 +15,7 @@ from basic_memory.schemas.response import ( DirectoryMoveResult, DirectoryDeleteResult, ) +from basic_memory.schemas.v2.graph import GraphNode, OrphanEntitiesResponse class KnowledgeClient: @@ -275,6 +276,24 @@ class KnowledgeClient: ) return DirectoryDeleteResult.model_validate(response.json()) + # --- Orphan detection --- + + async def get_orphans(self) -> list[GraphNode]: + """Get entities that have no incoming or outgoing relations.""" + with logfire.span( + "mcp.client.knowledge.get_orphans", + client_name="knowledge", + operation="get_orphans", + ): + response = await call_get( + self.http_client, + f"{self._base_path}/orphans", + client_name="knowledge", + operation="get_orphans", + path_template="/v2/projects/{project_id}/knowledge/orphans", + ) + return OrphanEntitiesResponse.model_validate(response.json()).entities + # --- Resolution --- async def resolve_entity(self, identifier: str, *, strict: bool = False) -> str: diff --git a/src/basic_memory/repository/entity_repository.py b/src/basic_memory/repository/entity_repository.py index 62adeddd..f3fab47c 100644 --- a/src/basic_memory/repository/entity_repository.py +++ b/src/basic_memory/repository/entity_repository.py @@ -5,7 +5,7 @@ from typing import List, Optional, Sequence, Union, Any from loguru import logger -from sqlalchemy import select, func +from sqlalchemy import exists, func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.orm import load_only, selectinload @@ -454,6 +454,22 @@ class EntityRepository(Repository[Entity]): result = await self.execute_query(query, use_query_options=False) return list(result.scalars().all()) + async def find_without_relations(self) -> Sequence[Entity]: + """Find entities that have no incoming or outgoing relations.""" + # Trigger: entity appears as a source in any relation. + # Why: even unresolved outgoing links mean the entity references another node. + # Outcome: entities with outgoing relations are excluded from the orphan list. + has_outgoing = exists().where(Relation.from_id == Entity.id) + + # Trigger: entity appears as the resolved target in any relation. + # Why: only resolved relation targets are graph nodes with an incoming edge. + # Outcome: entities referenced by resolved links are excluded from orphans. + has_incoming = exists().where(Relation.to_id == Entity.id) + + query = self.select().where(~has_outgoing).where(~has_incoming).order_by(Entity.file_path) + result = await self.execute_query(query, use_query_options=False) + return list(result.scalars().all()) + async def get_distinct_directories(self) -> List[str]: """Extract unique directory paths from file_path column. diff --git a/src/basic_memory/schemas/v2/__init__.py b/src/basic_memory/schemas/v2/__init__.py index 04b348ab..b4e0eab3 100644 --- a/src/basic_memory/schemas/v2/__init__.py +++ b/src/basic_memory/schemas/v2/__init__.py @@ -14,6 +14,7 @@ from basic_memory.schemas.v2.graph import ( GraphEdge, GraphNode, GraphResponse, + OrphanEntitiesResponse, ) from basic_memory.schemas.v2.resource import ( CreateResourceRequest, @@ -33,6 +34,7 @@ __all__ = [ "GraphEdge", "GraphNode", "GraphResponse", + "OrphanEntitiesResponse", "CreateResourceRequest", "UpdateResourceRequest", "ResourceResponse", diff --git a/src/basic_memory/schemas/v2/graph.py b/src/basic_memory/schemas/v2/graph.py index 8a02021c..a7d3e85f 100644 --- a/src/basic_memory/schemas/v2/graph.py +++ b/src/basic_memory/schemas/v2/graph.py @@ -29,3 +29,12 @@ class GraphResponse(BaseModel): edges: list[GraphEdge] = Field( default_factory=list, description="All resolved relations as edges" ) + + +class OrphanEntitiesResponse(BaseModel): + """Entities that have no incoming or outgoing relations in the knowledge graph.""" + + entities: list[GraphNode] = Field( + default_factory=list, description="Entities with no relations" + ) + total: int = Field(..., description="Total count of orphan entities") diff --git a/tests/api/v2/test_orphan_router.py b/tests/api/v2/test_orphan_router.py new file mode 100644 index 00000000..8a6c4e0a --- /dev/null +++ b/tests/api/v2/test_orphan_router.py @@ -0,0 +1,92 @@ +"""Tests for the /knowledge/orphans API endpoint.""" + +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_get_orphan_entities_empty_project(client: AsyncClient, v2_project_url): + """An empty project returns an empty orphans list.""" + response = await client.get(f"{v2_project_url}/knowledge/orphans") + + assert response.status_code == 200 + assert response.json() == {"entities": [], "total": 0} + + +@pytest.mark.asyncio +async def test_get_orphan_entities_returns_unlinked_entities(client: AsyncClient, v2_project_url): + """Entities with no relations appear in the orphans endpoint.""" + first = await client.post( + f"{v2_project_url}/knowledge/entities", + json={"title": "Orphan One", "directory": "orphan", "content": "No links here"}, + ) + second = await client.post( + f"{v2_project_url}/knowledge/entities", + json={"title": "Orphan Two", "directory": "orphan", "content": "Also no links"}, + ) + assert first.status_code == 200 + assert second.status_code == 200 + + response = await client.get(f"{v2_project_url}/knowledge/orphans") + + assert response.status_code == 200 + data = response.json() + titles = {entity["title"] for entity in data["entities"]} + assert titles == {"Orphan One", "Orphan Two"} + assert data["total"] == 2 + + +@pytest.mark.asyncio +async def test_get_orphan_entities_excludes_incoming_and_outgoing_relation_nodes( + client: AsyncClient, v2_project_url +): + """Entities with either side of a resolved relation are excluded from orphans.""" + target = await client.post( + f"{v2_project_url}/knowledge/entities", + json={ + "title": "Target Note", + "directory": "linked", + "content": "Referenced entity", + }, + ) + source = await client.post( + f"{v2_project_url}/knowledge/entities", + json={ + "title": "Source Note", + "directory": "linked", + "content": "- links_to [[Target Note]]", + }, + ) + standalone = await client.post( + f"{v2_project_url}/knowledge/entities", + json={"title": "Standalone Note", "directory": "linked", "content": "No links"}, + ) + assert source.status_code == 200 + assert target.status_code == 200 + assert standalone.status_code == 200 + + response = await client.get(f"{v2_project_url}/knowledge/orphans") + + assert response.status_code == 200 + titles = {entity["title"] for entity in response.json()["entities"]} + assert "Source Note" not in titles + assert "Target Note" not in titles + assert "Standalone Note" in titles + + +@pytest.mark.asyncio +async def test_get_orphan_entities_response_shape(client: AsyncClient, v2_project_url): + """Each orphan entity in the response has the expected graph-node fields.""" + created = await client.post( + f"{v2_project_url}/knowledge/entities", + json={"title": "Shape Test", "directory": "shape", "content": "Testing shape"}, + ) + assert created.status_code == 200 + + response = await client.get(f"{v2_project_url}/knowledge/orphans") + + assert response.status_code == 200 + data = response.json() + entity = next(entity for entity in data["entities"] if entity["title"] == "Shape Test") + assert set(entity) == {"external_id", "title", "note_type", "file_path"} + assert entity["file_path"].endswith(".md") diff --git a/tests/cli/test_orphans_command.py b/tests/cli/test_orphans_command.py new file mode 100644 index 00000000..d06c7b3f --- /dev/null +++ b/tests/cli/test_orphans_command.py @@ -0,0 +1,150 @@ +"""Tests for the 'basic-memory orphans' CLI command.""" + +import json +from contextlib import asynccontextmanager, nullcontext +from unittest.mock import AsyncMock, MagicMock, patch + +from mcp.server.fastmcp.exceptions import ToolError +from typer.testing import CliRunner + +from basic_memory.cli.main import app as cli_app +from basic_memory.schemas.v2.graph import GraphNode + +import basic_memory.cli.commands.orphans as orphans_cmd # noqa: F401 + +runner = CliRunner() + +_MOCK_PROJECT_ITEM = MagicMock() +_MOCK_PROJECT_ITEM.name = "test-project" +_MOCK_PROJECT_ITEM.external_id = "11111111-1111-1111-1111-111111111111" + +_ORPHAN_ENTITIES = [ + GraphNode( + external_id="aaaa-1111", + title="Isolated Note", + file_path="notes/isolated.md", + note_type="note", + ), + GraphNode( + external_id="bbbb-2222", + title="Dangling Spec", + file_path="specs/dangling.md", + note_type="spec", + ), +] + + +def _mock_config_manager(): + mock_config = MagicMock() + mock_config.default_project = "test-project" + return mock_config + + +@asynccontextmanager +async def _fake_get_client(project_name=None): + yield MagicMock() + + +@patch("basic_memory.cli.commands.orphans.run_orphans", new_callable=AsyncMock) +@patch("basic_memory.cli.commands.orphans.force_routing") +def test_orphans_preserves_project_routing_by_default(mock_force_routing, mock_run_orphans): + """Default invocation keeps routing implicit so project mode can choose local/cloud.""" + mock_force_routing.return_value = nullcontext() + mock_run_orphans.return_value = ("test-project", []) + + result = runner.invoke(cli_app, ["orphans"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + mock_force_routing.assert_called_once_with(local=False, cloud=False) + + +@patch("basic_memory.cli.commands.orphans.ConfigManager") +@patch("basic_memory.cli.commands.orphans.get_active_project", new_callable=AsyncMock) +@patch("basic_memory.cli.commands.orphans.get_client") +@patch("basic_memory.cli.commands.orphans.KnowledgeClient") +def test_orphans_json_output(mock_knowledge_cls, mock_get_client, mock_get_active, mock_config_cls): + """basic-memory orphans --json outputs a JSON array of orphan entity objects.""" + mock_config_cls.return_value = _mock_config_manager() + mock_get_active.return_value = _MOCK_PROJECT_ITEM + mock_get_client.side_effect = _fake_get_client + mock_knowledge = AsyncMock() + mock_knowledge.get_orphans.return_value = _ORPHAN_ENTITIES + mock_knowledge_cls.return_value = mock_knowledge + + result = runner.invoke(cli_app, ["orphans", "--json"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + json_start = result.output.rfind("[\n") + data = json.loads(result.output[json_start:]) + titles = {entity["title"] for entity in data} + assert titles == {"Isolated Note", "Dangling Spec"} + mock_get_client.assert_called_once_with(project_name="test-project") + + +@patch("basic_memory.cli.commands.orphans.ConfigManager") +@patch("basic_memory.cli.commands.orphans.get_active_project", new_callable=AsyncMock) +@patch("basic_memory.cli.commands.orphans.get_client") +@patch("basic_memory.cli.commands.orphans.KnowledgeClient") +def test_orphans_table_output( + mock_knowledge_cls, mock_get_client, mock_get_active, mock_config_cls +): + """basic-memory orphans renders a table with orphan titles and paths.""" + mock_config_cls.return_value = _mock_config_manager() + mock_get_active.return_value = _MOCK_PROJECT_ITEM + mock_get_client.side_effect = _fake_get_client + mock_knowledge = AsyncMock() + mock_knowledge.get_orphans.return_value = _ORPHAN_ENTITIES + mock_knowledge_cls.return_value = mock_knowledge + + result = runner.invoke(cli_app, ["orphans"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert "Isolated Note" in result.output + assert "Dangling Spec" in result.output + assert "notes/isolated.md" in result.output + + +@patch("basic_memory.cli.commands.orphans.ConfigManager") +@patch("basic_memory.cli.commands.orphans.get_active_project", new_callable=AsyncMock) +@patch("basic_memory.cli.commands.orphans.get_client") +@patch("basic_memory.cli.commands.orphans.KnowledgeClient") +def test_orphans_no_results(mock_knowledge_cls, mock_get_client, mock_get_active, mock_config_cls): + """basic-memory orphans prints a success message when no orphans are found.""" + mock_config_cls.return_value = _mock_config_manager() + mock_get_active.return_value = _MOCK_PROJECT_ITEM + mock_get_client.side_effect = _fake_get_client + mock_knowledge = AsyncMock() + mock_knowledge.get_orphans.return_value = [] + mock_knowledge_cls.return_value = mock_knowledge + + result = runner.invoke(cli_app, ["orphans"]) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + assert "No orphan entities" in result.output + + +@patch("basic_memory.cli.commands.orphans.run_orphans", new_callable=AsyncMock) +@patch("basic_memory.cli.commands.orphans.force_routing") +def test_orphans_value_error(mock_force_routing, mock_run_orphans): + """User-facing command errors are printed and exit with failure.""" + mock_force_routing.return_value = nullcontext() + mock_run_orphans.side_effect = ValueError("project not found") + + result = runner.invoke(cli_app, ["orphans"]) + + assert result.exit_code == 1 + assert "Error: project not found" in result.output + + +@patch("basic_memory.cli.commands.orphans.run_orphans", new_callable=AsyncMock) +@patch("basic_memory.cli.commands.orphans.force_routing") +def test_orphans_tool_error_json_output(mock_force_routing, mock_run_orphans): + """User-facing command errors are JSON formatted when requested.""" + mock_force_routing.return_value = nullcontext() + mock_run_orphans.side_effect = ToolError("cloud request failed") + + result = runner.invoke(cli_app, ["orphans", "--json"]) + + assert result.exit_code == 1 + json_start = result.output.rfind("{\n") + assert json.loads(result.output[json_start:]) == {"error": "cloud request failed"} diff --git a/tests/mcp/clients/test_clients.py b/tests/mcp/clients/test_clients.py index aa2e7514..62dd21ec 100644 --- a/tests/mcp/clients/test_clients.py +++ b/tests/mcp/clients/test_clients.py @@ -133,6 +133,39 @@ class TestKnowledgeClient: result = await client.resolve_entity("my-note") assert result == "entity-uuid-123" + @pytest.mark.asyncio + async def test_get_orphans_validates_response(self, monkeypatch): + """Orphan responses are validated into GraphNode objects.""" + from basic_memory.mcp.clients import knowledge as knowledge_mod + from basic_memory.schemas.v2.graph import GraphNode + + mock_response = MagicMock() + mock_response.json.return_value = { + "entities": [ + { + "external_id": "entity-uuid-123", + "title": "Orphan Note", + "file_path": "notes/orphan.md", + "note_type": "note", + } + ], + "total": 1, + } + + async def mock_call_get(client, url, **kwargs): + assert "/v2/projects/proj-123/knowledge/orphans" in url + return mock_response + + monkeypatch.setattr(knowledge_mod, "call_get", mock_call_get) + + mock_http = MagicMock() + client = KnowledgeClient(mock_http, "proj-123") + result = await client.get_orphans() + + assert len(result) == 1 + assert isinstance(result[0], GraphNode) + assert result[0].title == "Orphan Note" + class TestSearchClient: """Tests for SearchClient.""" diff --git a/tests/repository/test_entity_repository.py b/tests/repository/test_entity_repository.py index 1351e6c8..a74f9b34 100644 --- a/tests/repository/test_entity_repository.py +++ b/tests/repository/test_entity_repository.py @@ -1133,3 +1133,150 @@ async def test_get_file_path_to_permalink_map(entity_repository: EntityRepositor assert len(mapping) == 2 assert mapping["test/entity1.md"] == "test/entity1" assert mapping["test/entity2.md"] == "test/entity2" + + +@pytest.mark.asyncio +async def test_find_without_relations_returns_isolated_entities( + entity_repository: EntityRepository, session_maker, test_project: Project +): + """Entities with no incoming or outgoing relations are returned as orphans.""" + async with db.scoped_session(session_maker) as session: + orphan = Entity( + project_id=test_project.id, + title="Orphan", + note_type="test", + permalink="orphan/orphan", + file_path="orphan/orphan.md", + content_type="text/markdown", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + source = Entity( + project_id=test_project.id, + title="Source", + note_type="test", + permalink="source/source", + file_path="source/source.md", + content_type="text/markdown", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + target = Entity( + project_id=test_project.id, + title="Target", + note_type="test", + permalink="target/target", + file_path="target/target.md", + content_type="text/markdown", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + session.add_all([orphan, source, target]) + await session.flush() + + relation = Relation( + project_id=test_project.id, + from_id=source.id, + to_id=target.id, + to_name=target.title, + relation_type="links_to", + ) + session.add(relation) + + result = await entity_repository.find_without_relations() + titles = {entity.title for entity in result} + + assert "Orphan" in titles + assert "Source" not in titles + assert "Target" not in titles + + +@pytest.mark.asyncio +async def test_find_without_relations_excludes_unresolved_outgoing_links( + entity_repository: EntityRepository, session_maker, test_project: Project +): + """Entities with unresolved outgoing relations are still connected source nodes.""" + async with db.scoped_session(session_maker) as session: + source = Entity( + project_id=test_project.id, + title="Source", + note_type="test", + permalink="source/source", + file_path="source/source.md", + content_type="text/markdown", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + orphan = Entity( + project_id=test_project.id, + title="Orphan", + note_type="test", + permalink="orphan/orphan", + file_path="orphan/orphan.md", + content_type="text/markdown", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + session.add_all([source, orphan]) + await session.flush() + + unresolved_relation = Relation( + project_id=test_project.id, + from_id=source.id, + to_id=None, + to_name="Missing Target", + relation_type="links_to", + ) + session.add(unresolved_relation) + + result = await entity_repository.find_without_relations() + titles = {entity.title for entity in result} + + assert "Orphan" in titles + assert "Source" not in titles + + +@pytest.mark.asyncio +async def test_find_without_relations_respects_project_scope( + entity_repository: EntityRepository, session_maker, test_project: Project +): + """Orphan detection only returns isolated entities for the active project.""" + async with db.scoped_session(session_maker) as session: + active_orphan = Entity( + project_id=test_project.id, + title="Active Project Orphan", + note_type="test", + permalink="active/orphan", + file_path="active/orphan.md", + content_type="text/markdown", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + other_project = Project(name="other-project", path="/tmp/other") + session.add_all([active_orphan, other_project]) + await session.flush() + + other_orphan = Entity( + project_id=other_project.id, + title="Other Project Orphan", + note_type="test", + permalink="other/orphan", + file_path="other/orphan.md", + content_type="text/markdown", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + session.add(other_orphan) + + result = await entity_repository.find_without_relations() + titles = {entity.title for entity in result} + + assert "Active Project Orphan" in titles + assert "Other Project Orphan" not in titles + + +@pytest.mark.asyncio +async def test_find_without_relations_empty_project(entity_repository: EntityRepository): + """An empty project returns no orphans.""" + result = await entity_repository.find_without_relations() + assert result == []