Files
markitect-main/tests/integration/prompts/test_circular_suppression.py
tegwick bd1d05ba79 feat(prompts): implement Phase 6 - Incremental Execution (FR-7, FR-8)
Add change detection, structural diff-based impact analysis,
configurable-depth incremental recomputation with circular suppression,
and impact debt tracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 13:18:27 +01:00

214 lines
7.4 KiB
Python

"""
Integration tests for circular dependency suppression.
Tests circular dependency handling with real DB, debt recording,
and various cycle topologies.
"""
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
from markitect.prompts.dependencies.repository import SQLiteDependencyRepository
from markitect.prompts.dependencies.queries import DependencyQueryService
from markitect.prompts.incremental.detector import ChangeDetector
from markitect.prompts.incremental.engine import IncrementalExecutionEngine
from markitect.prompts.incremental.models import RecomputeConfig, ChangeType
@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 query_service(dep_repo):
"""Create DependencyQueryService."""
return DependencyQueryService(dep_repo)
@pytest.fixture
def detector(temp_db):
"""Create ChangeDetector."""
return ChangeDetector(temp_db)
@pytest.fixture
def engine(temp_db, query_service):
"""Create IncrementalExecutionEngine."""
return IncrementalExecutionEngine(temp_db, query_service)
def _create_artifact(repo, space_id, name, content="content"):
"""Helper to create and persist an artifact."""
artifact = Artifact.create(
space_id=space_id,
name=name,
content=content,
artifact_type=ArtifactType.CONTENT,
)
return repo.create(artifact)
def _create_edge(repo, src, tgt, run_id="run-1"):
"""Helper to create and persist a dependency edge."""
edge = DependencyEdge.create(
source_artifact_id=src,
target_artifact_id=tgt,
run_id=run_id,
edge_type=EdgeType.REQUIRES,
)
return repo.create(edge)
class TestDirectCircularSuppression:
"""Tests for direct circular dependency (A <-> B) suppression."""
def test_mutual_dependency_suppressed(
self, artifact_repo, dep_repo, detector, engine
):
"""Test mutual dependency suppresses recompute and records debt."""
art_a = _create_artifact(artifact_repo, "space-1", "a", "content-a")
art_b = _create_artifact(artifact_repo, "space-1", "b", "content-b")
# A -> B and B -> A (circular)
_create_edge(dep_repo, art_a.id, art_b.id)
_create_edge(dep_repo, art_b.id, art_a.id)
# Detect change in B
change = detector.detect_change(art_b, "content-b-modified")
assert change is not None
detector.record_change(change)
# Recompute: A depends on B, but A -> B creates cycle
result = engine.recompute(
change,
config=RecomputeConfig(max_depth=1, suppress_circular=True),
old_content="content-b",
new_content="content-b-modified",
)
assert result.total_dependents == 1
assert result.suppressed_count == 1
assert result.recomputed_count == 0
assert result.suppressed[0].suppression_reason == "circular_dependency"
def test_debt_persisted_for_circular(
self, artifact_repo, dep_repo, detector, engine
):
"""Test that circular suppression debt is persisted in DB."""
art_a = _create_artifact(artifact_repo, "space-1", "a", "a-v1")
art_b = _create_artifact(artifact_repo, "space-1", "b", "b-v1")
_create_edge(dep_repo, art_a.id, art_b.id)
_create_edge(dep_repo, art_b.id, art_a.id)
change = detector.detect_change(art_b, "b-v2")
detector.record_change(change)
engine.recompute(
change,
config=RecomputeConfig(max_depth=1, suppress_circular=True),
old_content="b-v1",
new_content="b-v2",
)
# Verify debt persisted
debt = engine.get_debt_for_artifact(art_b.id)
assert len(debt) == 1
assert debt[0].suppression_reason == "circular_dependency"
assert debt[0].dependent_run_id == art_a.id
class TestThreeNodeCycleSuppression:
"""Tests for three-node circular dependency suppression."""
def test_three_node_cycle(
self, artifact_repo, dep_repo, detector, engine
):
"""Test 3-node cycle: A -> B -> C -> A."""
art_a = _create_artifact(artifact_repo, "space-1", "a", "a")
art_b = _create_artifact(artifact_repo, "space-1", "b", "b")
art_c = _create_artifact(artifact_repo, "space-1", "c", "c")
# A -> B -> C -> A
_create_edge(dep_repo, art_a.id, art_b.id)
_create_edge(dep_repo, art_b.id, art_c.id)
_create_edge(dep_repo, art_c.id, art_a.id)
# Change C, dependent at depth 1 is B
change = detector.detect_change(art_c, "c-modified")
detector.record_change(change)
result = engine.recompute(
change,
config=RecomputeConfig(max_depth=1, suppress_circular=True),
old_content="c",
new_content="c-modified",
)
# B depends on C. would_create_cycle(B, C) checks if C can reach B.
# C -> A -> B: yes, C can reach B. So B is suppressed.
assert result.total_dependents == 1
assert result.suppressed_count == 1
assert result.suppressed[0].suppression_reason == "circular_dependency"
class TestMixedCircularAndNormal:
"""Tests with a mix of circular and normal dependencies."""
def test_some_suppressed_some_recomputed(
self, artifact_repo, dep_repo, detector, engine
):
"""Test graph with both circular and normal dependents."""
art_a = _create_artifact(artifact_repo, "space-1", "a", "a")
art_b = _create_artifact(artifact_repo, "space-1", "b", "b")
art_c = _create_artifact(artifact_repo, "space-1", "c", "c")
# B -> A (normal), A -> B (creates cycle with B -> A)
# C -> A (normal, no cycle)
_create_edge(dep_repo, art_b.id, art_a.id)
_create_edge(dep_repo, art_a.id, art_b.id)
_create_edge(dep_repo, art_c.id, art_a.id)
# Change A: dependents are B and C
change = detector.detect_change(art_a, "a-modified")
detector.record_change(change)
result = engine.recompute(
change,
config=RecomputeConfig(max_depth=1, suppress_circular=True),
old_content="a",
new_content="a-modified",
)
assert result.total_dependents == 2
# B has circular dep with A → suppressed
# C has no circular dep with A → recomputed
circular_debts = [d for d in result.suppressed if d.suppression_reason == "circular_dependency"]
assert len(circular_debts) == 1
assert circular_debts[0].dependent_run_id == art_b.id
assert result.recomputed_count == 1
assert art_c.id in result.executed_run_ids