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:
2026-02-09 13:18:18 +01:00
parent c56c92c815
commit 9ce157400e
13 changed files with 3021 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Integration tests for prompt dependency tracking."""

View 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 == []

View File

@@ -0,0 +1,321 @@
"""
Integration tests for dependency graph full workflow.
Tests the complete workflow: artifacts + edges + graph + queries
across multiple information spaces.
"""
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,
DependencyGraph,
EdgeType,
)
from markitect.prompts.dependencies.repository import SQLiteDependencyRepository
from markitect.prompts.dependencies.graph import GraphBuilder
from markitect.prompts.dependencies.queries import DependencyQueryService
from markitect.prompts.execution.manifest import RunManifest
@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 (shares same DB)."""
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, content="test content"):
"""Helper to create and persist an artifact."""
artifact = Artifact.create(
space_id=space_id,
name=name,
content=content,
artifact_type=ArtifactType.CONTENT,
)
return artifact_repo.create(artifact)
class TestFullWorkflow:
"""Tests for the complete dependency tracking workflow."""
def test_manifest_to_graph_workflow(
self, artifact_repo, dep_repo, builder, query_service
):
"""Test full workflow: create artifacts, manifest, persist edges, query."""
# Step 1: Create artifacts in a space
art_a = _create_artifact(artifact_repo, "space-1", "artifact-a", "content-a")
art_b = _create_artifact(artifact_repo, "space-1", "artifact-b", "content-b")
art_c = _create_artifact(artifact_repo, "space-1", "artifact-c", "content-c")
# Step 2: Create a manifest with dependency edges
manifest = RunManifest.create(
run_id="run-1",
template_id=art_a.id,
template_name="artifact-a",
template_digest=art_a.content_digest,
)
manifest.add_dependency_edge(art_a.id, art_b.id, "requires")
manifest.add_dependency_edge(art_b.id, art_c.id, "requires")
# Step 3: Persist edges from manifest
persisted = builder.persist_edges(manifest)
assert len(persisted) == 2
# Step 4: Build graph
graph = builder.build_graph()
assert graph.has_edge(art_a.id, art_b.id)
assert graph.has_edge(art_b.id, art_c.id)
# Step 5: Query dependencies
deps = query_service.find_dependencies(art_a.id)
assert art_b.id in deps
transitive_deps = query_service.find_transitive_dependencies(art_a.id)
assert art_b.id in transitive_deps
assert art_c.id in transitive_deps
# Step 6: Query dependents
dependents = query_service.find_dependents(art_c.id)
assert art_b.id in dependents
transitive_dependents = query_service.find_transitive_dependents(art_c.id)
assert art_a.id in transitive_dependents
assert art_b.id in transitive_dependents
def test_build_order_workflow(
self, artifact_repo, dep_repo, builder, query_service
):
"""Test build order across a dependency graph."""
art_a = _create_artifact(artifact_repo, "space-1", "lib-core")
art_b = _create_artifact(artifact_repo, "space-1", "lib-utils")
art_c = _create_artifact(artifact_repo, "space-1", "app")
# app depends on lib-utils, lib-utils depends on lib-core
manifest = RunManifest.create(
run_id="run-build",
template_id=art_c.id,
template_name="app",
template_digest=art_c.content_digest,
)
manifest.add_dependency_edge(art_c.id, art_b.id, "requires")
manifest.add_dependency_edge(art_b.id, art_a.id, "requires")
builder.persist_edges(manifest)
# Build order should have core first, then utils, then app
order = query_service.get_build_order()
assert order.index(art_a.id) < order.index(art_b.id)
assert order.index(art_b.id) < order.index(art_c.id)
def test_dependency_chain_workflow(
self, artifact_repo, dep_repo, builder, query_service
):
"""Test finding dependency chains between artifacts."""
art_a = _create_artifact(artifact_repo, "space-1", "source")
art_b = _create_artifact(artifact_repo, "space-1", "intermediate")
art_c = _create_artifact(artifact_repo, "space-1", "target")
manifest = RunManifest.create(
run_id="run-chain",
template_id=art_a.id,
template_name="source",
template_digest=art_a.content_digest,
)
manifest.add_dependency_edge(art_a.id, art_b.id, "requires")
manifest.add_dependency_edge(art_b.id, art_c.id, "requires")
builder.persist_edges(manifest)
chain = query_service.get_dependency_chain(art_a.id, art_c.id)
assert chain is not None
assert chain[0] == art_a.id
assert chain[-1] == art_c.id
assert len(chain) == 3
class TestCrossSpaceGraph:
"""Tests for dependency tracking across information spaces (FR-6.2)."""
def test_cross_space_dependencies(
self, artifact_repo, dep_repo, builder, query_service
):
"""Test dependencies between artifacts in different spaces."""
# Create artifacts in different spaces
shared = _create_artifact(artifact_repo, "shared", "common-lib")
team_a = _create_artifact(artifact_repo, "team-a", "service-a")
team_b = _create_artifact(artifact_repo, "team-b", "service-b")
# Both services depend on shared lib
manifest = RunManifest.create(
run_id="run-cross-space",
template_id=team_a.id,
template_name="service-a",
template_digest=team_a.content_digest,
)
manifest.add_dependency_edge(team_a.id, shared.id, "requires")
manifest.add_dependency_edge(team_b.id, shared.id, "requires")
builder.persist_edges(manifest)
# Shared lib should have both services as dependents
dependents = query_service.find_dependents(shared.id)
assert team_a.id in dependents
assert team_b.id in dependents
# Each service should have shared as a dependency
assert shared.id in query_service.find_dependencies(team_a.id)
assert shared.id in query_service.find_dependencies(team_b.id)
def test_cross_space_transitive(
self, artifact_repo, dep_repo, builder, query_service
):
"""Test transitive dependencies across spaces."""
# shared/foundation -> shared/utils -> team-a/app
foundation = _create_artifact(artifact_repo, "shared", "foundation")
utils = _create_artifact(artifact_repo, "shared", "utils")
app = _create_artifact(artifact_repo, "team-a", "app")
manifest = RunManifest.create(
run_id="run-trans",
template_id=app.id,
template_name="app",
template_digest=app.content_digest,
)
manifest.add_dependency_edge(app.id, utils.id, "requires")
manifest.add_dependency_edge(utils.id, foundation.id, "requires")
builder.persist_edges(manifest)
# App should transitively depend on foundation
transitive = query_service.find_transitive_dependencies(app.id)
assert utils.id in transitive
assert foundation.id in transitive
def test_cross_space_build_order(
self, artifact_repo, dep_repo, builder, query_service
):
"""Test build order across spaces."""
core = _create_artifact(artifact_repo, "shared", "core")
api = _create_artifact(artifact_repo, "team-a", "api")
web = _create_artifact(artifact_repo, "team-b", "web")
manifest = RunManifest.create(
run_id="run-order",
template_id=web.id,
template_name="web",
template_digest=web.content_digest,
)
manifest.add_dependency_edge(api.id, core.id, "requires")
manifest.add_dependency_edge(web.id, api.id, "requires")
builder.persist_edges(manifest)
order = query_service.get_build_order()
assert order.index(core.id) < order.index(api.id)
assert order.index(api.id) < order.index(web.id)
class TestMultipleRuns:
"""Tests for dependency tracking across multiple runs."""
def test_edges_from_multiple_runs(
self, artifact_repo, dep_repo, builder, query_service
):
"""Test edges from multiple runs are combined in graph."""
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")
# Run 1: A -> B
manifest1 = RunManifest.create(
run_id="run-1",
template_id=art_a.id,
template_name="a",
template_digest=art_a.content_digest,
)
manifest1.add_dependency_edge(art_a.id, art_b.id, "requires")
builder.persist_edges(manifest1)
# Run 2: B -> C
manifest2 = RunManifest.create(
run_id="run-2",
template_id=art_b.id,
template_name="b",
template_digest=art_b.content_digest,
)
manifest2.add_dependency_edge(art_b.id, art_c.id, "requires")
builder.persist_edges(manifest2)
# Full graph should have A -> B -> C
graph = builder.build_graph()
assert graph.has_edge(art_a.id, art_b.id)
assert graph.has_edge(art_b.id, art_c.id)
# Transitive query should work across runs
transitive = query_service.find_transitive_dependencies(art_a.id)
assert art_b.id in transitive
assert art_c.id in transitive
def test_run_scoped_graph(self, artifact_repo, dep_repo, builder):
"""Test building graph scoped to a specific run."""
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 edges for different runs
edge1 = DependencyEdge.create(
source_artifact_id=art_a.id,
target_artifact_id=art_b.id,
run_id="run-1",
edge_type=EdgeType.REQUIRES,
)
dep_repo.create(edge1)
edge2 = DependencyEdge.create(
source_artifact_id=art_b.id,
target_artifact_id=art_c.id,
run_id="run-2",
edge_type=EdgeType.GENERATES,
)
dep_repo.create(edge2)
# Graph for run-1 only
graph1 = builder.build_graph_for_run("run-1")
assert graph1.has_edge(art_a.id, art_b.id)
assert not graph1.has_edge(art_b.id, art_c.id)
# Graph for run-2 only
graph2 = builder.build_graph_for_run("run-2")
assert not graph2.has_edge(art_a.id, art_b.id)
assert graph2.has_edge(art_b.id, art_c.id)