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>
214 lines
7.4 KiB
Python
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
|