Files
markitect-main/tests/unit/prompts/test_traceability_service.py
tegwick 7b4bd461c9 feat(prompts): implement Phase 8 - Observability & Traceability (FR-11)
Complete implementation of Phase 8, the final phase of prompt dependency
resolution infrastructure, adding full observability and traceability.

## Features (FR-11)

### FR-11.1: Complete Artifact Provenance Tracing
- TraceabilityService: composition layer for full artifact lineage
- Trace any artifact to producing PromptTemplate, input artifacts,
  generator runs, and quality validation results
- ProvenanceTrace model with complete dependency chain reconstruction
- RunSummary and ArtifactLineage models for structured trace output

### FR-11.2: Recomputation Query Infrastructure
- PromptQueryService: cross-service complex queries
- Run history queries with template and status filters
- Stale artifact detection via impact debt analysis
- Dependency graph statistics (nodes, edges, cycles, roots, leaves)
- Content-based artifact lookups by digest

### Visualization Support
- GraphExporter: DOT (Graphviz) and Mermaid format export
- Supports all edge types (requires, generates, includes)
- Handles isolated nodes, linear chains, diamonds, and complex graphs

### CLI Commands (prompt group)
- `prompt trace <artifact_id>` - Full provenance trace as JSON
- `prompt graph <artifact_id>` - Dependency graph (DOT/Mermaid)
- `prompt runs` - List execution runs with filters
- `prompt debt` - Show impact debt and stale artifacts
- `prompt stats` - Dependency graph statistics

## Implementation

Source files (8):
- markitect/prompts/traceability/models.py - Trace data models
- markitect/prompts/traceability/service.py - TraceabilityService
- markitect/prompts/visualization/graph.py - Graph export
- markitect/prompts/queries/operations.py - PromptQueryService
- markitect/prompts/cli.py - Click CLI commands
- Package __init__.py files (3)

Tests (64 total, all passing):
- tests/unit/prompts/test_traceability_service.py (21 tests)
- tests/unit/prompts/test_visualization.py (14 tests)
- tests/unit/prompts/test_query_operations.py (12 tests)
- tests/integration/prompts/test_traceability_workflow.py (7 tests)
- tests/integration/prompts/test_prompt_cli.py (10 tests)

## Architecture

TraceabilityService is a composition layer that delegates to:
- DependencyQueryService (transitive dependency lookups)
- QualityValidator (validation history)
- IncrementalExecutionEngine (impact debt queries)
- Direct repository access (artifacts, edges)

No duplicate data storage - all data comes from existing Phase 1-7
infrastructure (artifact repo, dependency repo, validation DB, debt DB).

## Verification

All 2250 tests pass with 0 regressions.
Phase 8 completes the full 8-phase implementation roadmap.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-09 20:32:18 +01:00

357 lines
12 KiB
Python

"""
Unit tests for TraceabilityService.
Tests trace_artifact, get_producing_run, get_input_artifacts,
get_generator_runs, and get_validation_history.
"""
import pytest
import tempfile
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock
from markitect.prompts.dependencies.models import DependencyEdge, EdgeType
from markitect.prompts.dependencies.repository import SQLiteDependencyRepository
from markitect.prompts.execution.models import (
ExecutionStage,
PromptRun,
RunConfig,
RunStatus,
)
from markitect.prompts.models import Artifact, ArtifactType
from markitect.prompts.repositories.sqlite import SQLiteArtifactRepository
from markitect.prompts.traceability.models import (
ArtifactLineage,
ProvenanceTrace,
RunSummary,
)
from markitect.prompts.traceability.service import TraceabilityService
@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 service(artifact_repo, dep_repo, temp_db):
"""Create TraceabilityService."""
return TraceabilityService(artifact_repo, dep_repo, db_path=temp_db)
def _make_artifact(repo, space_id, name, content="content", atype=ArtifactType.CONTENT):
"""Helper to create and persist an artifact."""
artifact = Artifact.create(
space_id=space_id, name=name, content=content, artifact_type=atype
)
return repo.create(artifact)
def _make_run(template_id, status=RunStatus.SUCCESS, parent_run_id=None, depth=0):
"""Helper to create a PromptRun."""
run = PromptRun.create(
template_id=template_id,
input_bundle_hash="hash-abc",
parent_run_id=parent_run_id,
depth=depth,
)
if status == RunStatus.SUCCESS:
run.mark_complete()
return run
def _make_edge(repo, source, target, run_id, edge_type=EdgeType.REQUIRES):
"""Helper to create a dependency edge."""
edge = DependencyEdge.create(
source_artifact_id=source,
target_artifact_id=target,
run_id=run_id,
edge_type=edge_type,
)
return repo.create(edge)
class TestTraceArtifact:
"""Tests for trace_artifact."""
def test_trace_returns_provenance(self, service, artifact_repo, dep_repo):
"""Test trace_artifact returns a ProvenanceTrace."""
art = _make_artifact(artifact_repo, "space-1", "output-doc")
trace = service.trace_artifact(art.id)
assert isinstance(trace, ProvenanceTrace)
assert trace.artifact_id == art.id
def test_trace_with_producing_run(self, service, artifact_repo, dep_repo):
"""Test trace finds producing run via manifest metadata."""
template = _make_artifact(
artifact_repo, "space-1", "tmpl", atype=ArtifactType.TEMPLATE
)
output = _make_artifact(artifact_repo, "space-1", "output")
run = _make_run(template.id)
run.metadata["manifest"] = {
"output_artifacts": [{"artifact_id": output.id}],
"resolved_inputs": [],
}
service.register_run(run)
trace = service.trace_artifact(output.id)
assert trace.producing_run is not None
assert trace.producing_run.run_id == run.id
def test_trace_no_producing_run(self, service, artifact_repo):
"""Test trace with no producing run returns None."""
art = _make_artifact(artifact_repo, "space-1", "standalone")
trace = service.trace_artifact(art.id)
assert trace.producing_run is None
def test_trace_includes_dependency_chain(self, service, artifact_repo, dep_repo):
"""Test trace includes transitive dependency chain."""
a = _make_artifact(artifact_repo, "s", "a")
b = _make_artifact(artifact_repo, "s", "b")
c = _make_artifact(artifact_repo, "s", "c")
_make_edge(dep_repo, a.id, b.id, "r1")
_make_edge(dep_repo, b.id, c.id, "r1")
trace = service.trace_artifact(a.id)
assert b.id in trace.dependency_chain
assert c.id in trace.dependency_chain
def test_trace_to_dict(self, service, artifact_repo):
"""Test ProvenanceTrace serialization."""
art = _make_artifact(artifact_repo, "s", "x")
trace = service.trace_artifact(art.id)
d = trace.to_dict()
assert d["artifact_id"] == art.id
assert "producing_run" in d
assert "input_artifacts" in d
assert "dependency_chain" in d
class TestGetProducingRun:
"""Tests for get_producing_run."""
def test_finds_run_via_generates_edge(self, service, artifact_repo, dep_repo):
"""Test finding run via generates dependency edge."""
output = _make_artifact(artifact_repo, "s", "output")
run = _make_run("tmpl-1")
service.register_run(run)
# Create a "generates" edge from run.id -> output.id
_make_edge(dep_repo, run.id, output.id, run.id, EdgeType.GENERATES)
result = service.get_producing_run(output.id)
assert result is not None
assert result.run_id == run.id
def test_finds_run_via_manifest(self, service, artifact_repo):
"""Test finding run via manifest output_artifacts."""
output = _make_artifact(artifact_repo, "s", "output")
run = _make_run("tmpl-1")
run.metadata["manifest"] = {
"output_artifacts": [{"artifact_id": output.id}],
}
service.register_run(run)
result = service.get_producing_run(output.id)
assert result is not None
assert result.run_id == run.id
def test_returns_none_when_not_found(self, service):
"""Test returns None when no producing run exists."""
result = service.get_producing_run("nonexistent")
assert result is None
class TestGetInputArtifacts:
"""Tests for get_input_artifacts."""
def test_finds_inputs_via_edges(self, service, artifact_repo, dep_repo):
"""Test finding input artifacts via dependency edges."""
inp = _make_artifact(artifact_repo, "s", "input-data")
run = _make_run("tmpl-1")
service.register_run(run)
_make_edge(dep_repo, inp.id, run.id, run.id, EdgeType.REQUIRES)
inputs = service.get_input_artifacts(run.id)
assert len(inputs) == 1
assert inputs[0].artifact_id == inp.id
assert inputs[0].role == "input"
def test_finds_inputs_via_manifest(self, service, artifact_repo, dep_repo):
"""Test finding input artifacts via manifest resolved_inputs."""
inp = _make_artifact(artifact_repo, "s", "input-data")
run = _make_run("tmpl-1")
run.metadata["manifest"] = {
"resolved_inputs": [{"artifact_id": inp.id}],
"output_artifacts": [],
}
service.register_run(run)
inputs = service.get_input_artifacts(run.id)
assert len(inputs) == 1
assert inputs[0].artifact_id == inp.id
def test_no_duplicates(self, service, artifact_repo, dep_repo):
"""Test inputs are deduplicated across edges and manifest."""
inp = _make_artifact(artifact_repo, "s", "input-data")
run = _make_run("tmpl-1")
run.metadata["manifest"] = {
"resolved_inputs": [{"artifact_id": inp.id}],
"output_artifacts": [],
}
service.register_run(run)
_make_edge(dep_repo, inp.id, run.id, run.id, EdgeType.REQUIRES)
inputs = service.get_input_artifacts(run.id)
assert len(inputs) == 1
class TestGetOutputArtifacts:
"""Tests for get_output_artifacts."""
def test_finds_outputs_via_edges(self, service, artifact_repo, dep_repo):
"""Test finding output artifacts via generates edges."""
output = _make_artifact(artifact_repo, "s", "output")
run = _make_run("tmpl-1")
service.register_run(run)
_make_edge(dep_repo, run.id, output.id, run.id, EdgeType.GENERATES)
outputs = service.get_output_artifacts(run.id)
assert len(outputs) == 1
assert outputs[0].artifact_id == output.id
assert outputs[0].role == "output"
class TestGetGeneratorRuns:
"""Tests for get_generator_runs."""
def test_finds_child_runs(self, service):
"""Test finding nested generator runs."""
parent = _make_run("tmpl-1")
child1 = _make_run("tmpl-2", parent_run_id=parent.id, depth=1)
child2 = _make_run("tmpl-3", parent_run_id=parent.id, depth=1)
unrelated = _make_run("tmpl-4")
service.register_run(parent)
service.register_run(child1)
service.register_run(child2)
service.register_run(unrelated)
children = service.get_generator_runs(parent.id)
child_ids = {c.run_id for c in children}
assert child1.id in child_ids
assert child2.id in child_ids
assert unrelated.id not in child_ids
def test_no_children(self, service):
"""Test returns empty when no child runs exist."""
parent = _make_run("tmpl-1")
service.register_run(parent)
assert service.get_generator_runs(parent.id) == []
class TestGetValidationHistory:
"""Tests for get_validation_history."""
def test_returns_empty_without_db(self, artifact_repo, dep_repo):
"""Test returns empty list when no db_path."""
svc = TraceabilityService(artifact_repo, dep_repo, db_path=None)
assert svc.get_validation_history("art-1") == []
def test_returns_results_with_db(self, service):
"""Test returns results from validator when db_path is set."""
# Without actually writing validation data, should return empty
results = service.get_validation_history("art-1")
assert results == []
class TestGetImpactDebt:
"""Tests for get_impact_debt."""
def test_returns_empty_without_db(self, artifact_repo, dep_repo):
"""Test returns empty list when no db_path."""
svc = TraceabilityService(artifact_repo, dep_repo, db_path=None)
assert svc.get_impact_debt("art-1") == []
def test_returns_empty_no_debt(self, service):
"""Test returns empty when no debt exists."""
assert service.get_impact_debt("nonexistent") == []
class TestRunSummary:
"""Tests for RunSummary model."""
def test_create_and_to_dict(self):
"""Test RunSummary creation and serialization."""
now = datetime.utcnow()
summary = RunSummary.create(
run_id="r1",
template_id="t1",
status="success",
stage="complete",
input_bundle_hash="hash",
started_at=now,
completed_at=now,
)
d = summary.to_dict()
assert d["run_id"] == "r1"
assert d["status"] == "success"
assert d["completed_at"] is not None
def test_optional_fields(self):
"""Test RunSummary with optional fields as None."""
now = datetime.utcnow()
summary = RunSummary.create(
run_id="r1",
template_id="t1",
status="pending",
stage="pending",
input_bundle_hash="hash",
started_at=now,
)
d = summary.to_dict()
assert d["parent_run_id"] is None
assert d["completed_at"] is None
assert d["depth"] == 0
class TestArtifactLineage:
"""Tests for ArtifactLineage model."""
def test_to_dict(self):
"""Test ArtifactLineage serialization."""
lineage = ArtifactLineage(
artifact_id="a1",
name="test",
space_id="s1",
artifact_type="content",
content_digest="abc123",
role="input",
)
d = lineage.to_dict()
assert d["artifact_id"] == "a1"
assert d["role"] == "input"