feat(prompts): implement Phase 5 - Dependency Tracking (FR-6)
Add directed dependency graph with cycle detection, topological sort, and query service for finding dependents/dependencies transitively. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
274
tests/integration/prompts/test_circular_detection.py
Normal file
274
tests/integration/prompts/test_circular_detection.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
Integration tests for circular dependency detection.
|
||||
|
||||
Tests cycle detection with real DB, pre-validation before
|
||||
persisting edges, and error detail reporting.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from markitect.prompts.models import Artifact, ArtifactType
|
||||
from markitect.prompts.repositories.sqlite import SQLiteArtifactRepository
|
||||
from markitect.prompts.dependencies.models import (
|
||||
DependencyEdge,
|
||||
EdgeType,
|
||||
CircularDependencyError,
|
||||
)
|
||||
from markitect.prompts.dependencies.repository import SQLiteDependencyRepository
|
||||
from markitect.prompts.dependencies.graph import GraphBuilder
|
||||
from markitect.prompts.dependencies.queries import DependencyQueryService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db():
|
||||
"""Create temporary database for testing."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
db_path = f.name
|
||||
yield db_path
|
||||
Path(db_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def artifact_repo(temp_db):
|
||||
"""Create artifact repository."""
|
||||
return SQLiteArtifactRepository(temp_db)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dep_repo(temp_db):
|
||||
"""Create dependency repository."""
|
||||
return SQLiteDependencyRepository(temp_db)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def builder(dep_repo):
|
||||
"""Create GraphBuilder."""
|
||||
return GraphBuilder(dep_repo)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def query_service(dep_repo):
|
||||
"""Create DependencyQueryService."""
|
||||
return DependencyQueryService(dep_repo)
|
||||
|
||||
|
||||
def _create_artifact(artifact_repo, space_id, name):
|
||||
"""Helper to create and persist an artifact."""
|
||||
artifact = Artifact.create(
|
||||
space_id=space_id,
|
||||
name=name,
|
||||
content=f"content for {name}",
|
||||
artifact_type=ArtifactType.CONTENT,
|
||||
)
|
||||
return artifact_repo.create(artifact)
|
||||
|
||||
|
||||
def _persist_edge(dep_repo, src_id, tgt_id, run_id="run-1"):
|
||||
"""Helper to persist a dependency edge."""
|
||||
edge = DependencyEdge.create(
|
||||
source_artifact_id=src_id,
|
||||
target_artifact_id=tgt_id,
|
||||
run_id=run_id,
|
||||
edge_type=EdgeType.REQUIRES,
|
||||
)
|
||||
return dep_repo.create(edge)
|
||||
|
||||
|
||||
class TestCycleDetectionWithDB:
|
||||
"""Tests for cycle detection using real database storage."""
|
||||
|
||||
def test_detect_simple_cycle(self, artifact_repo, dep_repo, query_service):
|
||||
"""Test detecting a simple 2-node cycle in persisted graph."""
|
||||
art_a = _create_artifact(artifact_repo, "space-1", "a")
|
||||
art_b = _create_artifact(artifact_repo, "space-1", "b")
|
||||
|
||||
_persist_edge(dep_repo, art_a.id, art_b.id)
|
||||
_persist_edge(dep_repo, art_b.id, art_a.id, run_id="run-2")
|
||||
|
||||
cycles = query_service.detect_circular_dependencies()
|
||||
assert len(cycles) > 0
|
||||
|
||||
def test_detect_three_node_cycle(self, artifact_repo, dep_repo, query_service):
|
||||
"""Test detecting a 3-node cycle A -> B -> C -> A."""
|
||||
art_a = _create_artifact(artifact_repo, "space-1", "a")
|
||||
art_b = _create_artifact(artifact_repo, "space-1", "b")
|
||||
art_c = _create_artifact(artifact_repo, "space-1", "c")
|
||||
|
||||
_persist_edge(dep_repo, art_a.id, art_b.id)
|
||||
_persist_edge(dep_repo, art_b.id, art_c.id)
|
||||
_persist_edge(dep_repo, art_c.id, art_a.id, run_id="run-2")
|
||||
|
||||
cycles = query_service.detect_circular_dependencies()
|
||||
assert len(cycles) > 0
|
||||
|
||||
# All three artifacts should be in the cycle
|
||||
cycle_nodes = set(cycles[0][:-1])
|
||||
assert art_a.id in cycle_nodes
|
||||
assert art_b.id in cycle_nodes
|
||||
assert art_c.id in cycle_nodes
|
||||
|
||||
def test_no_false_positives(self, artifact_repo, dep_repo, query_service):
|
||||
"""Test no false positive cycle detection in DAG."""
|
||||
art_a = _create_artifact(artifact_repo, "space-1", "a")
|
||||
art_b = _create_artifact(artifact_repo, "space-1", "b")
|
||||
art_c = _create_artifact(artifact_repo, "space-1", "c")
|
||||
art_d = _create_artifact(artifact_repo, "space-1", "d")
|
||||
|
||||
# Diamond: A -> B, A -> C, B -> D, C -> D
|
||||
_persist_edge(dep_repo, art_a.id, art_b.id)
|
||||
_persist_edge(dep_repo, art_a.id, art_c.id, run_id="run-2")
|
||||
_persist_edge(dep_repo, art_b.id, art_d.id, run_id="run-3")
|
||||
_persist_edge(dep_repo, art_c.id, art_d.id, run_id="run-4")
|
||||
|
||||
cycles = query_service.detect_circular_dependencies()
|
||||
assert cycles == []
|
||||
|
||||
|
||||
class TestPreValidation:
|
||||
"""Tests for pre-validation (would_create_cycle) before persisting edges."""
|
||||
|
||||
def test_validate_safe_edge(self, artifact_repo, dep_repo, query_service):
|
||||
"""Test pre-validation accepts safe edge."""
|
||||
art_a = _create_artifact(artifact_repo, "space-1", "a")
|
||||
art_b = _create_artifact(artifact_repo, "space-1", "b")
|
||||
art_c = _create_artifact(artifact_repo, "space-1", "c")
|
||||
|
||||
_persist_edge(dep_repo, art_a.id, art_b.id)
|
||||
|
||||
# B -> C would not create a cycle
|
||||
assert query_service.would_create_cycle(art_b.id, art_c.id) is False
|
||||
|
||||
def test_validate_cycle_creating_edge(
|
||||
self, artifact_repo, dep_repo, query_service
|
||||
):
|
||||
"""Test pre-validation rejects edge that would create a cycle."""
|
||||
art_a = _create_artifact(artifact_repo, "space-1", "a")
|
||||
art_b = _create_artifact(artifact_repo, "space-1", "b")
|
||||
art_c = _create_artifact(artifact_repo, "space-1", "c")
|
||||
|
||||
_persist_edge(dep_repo, art_a.id, art_b.id)
|
||||
_persist_edge(dep_repo, art_b.id, art_c.id, run_id="run-2")
|
||||
|
||||
# C -> A would create a cycle
|
||||
assert query_service.would_create_cycle(art_c.id, art_a.id) is True
|
||||
|
||||
def test_validate_before_persist_workflow(
|
||||
self, artifact_repo, dep_repo, query_service
|
||||
):
|
||||
"""Test the full validate-then-persist workflow."""
|
||||
art_a = _create_artifact(artifact_repo, "space-1", "a")
|
||||
art_b = _create_artifact(artifact_repo, "space-1", "b")
|
||||
art_c = _create_artifact(artifact_repo, "space-1", "c")
|
||||
|
||||
# Persist A -> B
|
||||
_persist_edge(dep_repo, art_a.id, art_b.id)
|
||||
|
||||
# Validate and persist B -> C (safe)
|
||||
assert query_service.would_create_cycle(art_b.id, art_c.id) is False
|
||||
_persist_edge(dep_repo, art_b.id, art_c.id, run_id="run-2")
|
||||
|
||||
# Validate C -> A (would create cycle, don't persist)
|
||||
assert query_service.would_create_cycle(art_c.id, art_a.id) is True
|
||||
# Don't persist - graph remains acyclic
|
||||
|
||||
# Verify graph is still cycle-free
|
||||
assert query_service.detect_circular_dependencies() == []
|
||||
|
||||
|
||||
class TestCycleErrorDetails:
|
||||
"""Tests for cycle error detail reporting."""
|
||||
|
||||
def test_topological_sort_error_contains_cycle(
|
||||
self, artifact_repo, dep_repo, builder
|
||||
):
|
||||
"""Test that topological sort error includes cycle details."""
|
||||
art_a = _create_artifact(artifact_repo, "space-1", "a")
|
||||
art_b = _create_artifact(artifact_repo, "space-1", "b")
|
||||
art_c = _create_artifact(artifact_repo, "space-1", "c")
|
||||
|
||||
_persist_edge(dep_repo, art_a.id, art_b.id)
|
||||
_persist_edge(dep_repo, art_b.id, art_c.id, run_id="run-2")
|
||||
_persist_edge(dep_repo, art_c.id, art_a.id, run_id="run-3")
|
||||
|
||||
graph = builder.build_graph()
|
||||
|
||||
with pytest.raises(CircularDependencyError) as exc_info:
|
||||
graph.topological_sort()
|
||||
|
||||
error = exc_info.value
|
||||
assert error.cycle is not None
|
||||
assert len(error.cycle) >= 3
|
||||
# The cycle should contain our artifact IDs
|
||||
cycle_set = set(error.cycle)
|
||||
assert art_a.id in cycle_set
|
||||
assert art_b.id in cycle_set
|
||||
assert art_c.id in cycle_set
|
||||
|
||||
def test_error_message_readable(self, artifact_repo, dep_repo, builder):
|
||||
"""Test that error message is human-readable."""
|
||||
art_a = _create_artifact(artifact_repo, "space-1", "a")
|
||||
art_b = _create_artifact(artifact_repo, "space-1", "b")
|
||||
|
||||
_persist_edge(dep_repo, art_a.id, art_b.id)
|
||||
_persist_edge(dep_repo, art_b.id, art_a.id, run_id="run-2")
|
||||
|
||||
graph = builder.build_graph()
|
||||
|
||||
with pytest.raises(CircularDependencyError) as exc_info:
|
||||
graph.topological_sort()
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "Circular dependency detected" in message
|
||||
assert "->" in message
|
||||
|
||||
|
||||
class TestCrossSpaceCycles:
|
||||
"""Tests for cycle detection across information spaces."""
|
||||
|
||||
def test_cross_space_cycle(self, artifact_repo, dep_repo, query_service):
|
||||
"""Test detecting cycles that span multiple spaces."""
|
||||
# space-a/X -> space-b/Y -> space-a/X (cycle across spaces)
|
||||
art_x = _create_artifact(artifact_repo, "space-a", "x")
|
||||
art_y = _create_artifact(artifact_repo, "space-b", "y")
|
||||
|
||||
_persist_edge(dep_repo, art_x.id, art_y.id)
|
||||
_persist_edge(dep_repo, art_y.id, art_x.id, run_id="run-2")
|
||||
|
||||
cycles = query_service.detect_circular_dependencies()
|
||||
assert len(cycles) > 0
|
||||
|
||||
def test_cross_space_transitive_cycle(
|
||||
self, artifact_repo, dep_repo, query_service
|
||||
):
|
||||
"""Test detecting transitive cycles across multiple spaces."""
|
||||
art_a = _create_artifact(artifact_repo, "space-a", "a")
|
||||
art_b = _create_artifact(artifact_repo, "space-b", "b")
|
||||
art_c = _create_artifact(artifact_repo, "space-c", "c")
|
||||
|
||||
_persist_edge(dep_repo, art_a.id, art_b.id)
|
||||
_persist_edge(dep_repo, art_b.id, art_c.id, run_id="run-2")
|
||||
_persist_edge(dep_repo, art_c.id, art_a.id, run_id="run-3")
|
||||
|
||||
cycles = query_service.detect_circular_dependencies()
|
||||
assert len(cycles) > 0
|
||||
|
||||
# Pre-validation should also catch it
|
||||
# If we had A -> B -> C already, adding C -> A would be caught
|
||||
assert query_service.would_create_cycle(art_c.id, art_a.id) is True
|
||||
|
||||
def test_cross_space_no_false_positive(
|
||||
self, artifact_repo, dep_repo, query_service
|
||||
):
|
||||
"""Test no false positives across spaces."""
|
||||
art_a = _create_artifact(artifact_repo, "space-a", "a")
|
||||
art_b = _create_artifact(artifact_repo, "space-b", "b")
|
||||
art_c = _create_artifact(artifact_repo, "space-c", "c")
|
||||
|
||||
# Linear: A -> B -> C (no cycle)
|
||||
_persist_edge(dep_repo, art_a.id, art_b.id)
|
||||
_persist_edge(dep_repo, art_b.id, art_c.id, run_id="run-2")
|
||||
|
||||
cycles = query_service.detect_circular_dependencies()
|
||||
assert cycles == []
|
||||
Reference in New Issue
Block a user