Files
markitect-main/markitect/prompts/incremental/detector.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

233 lines
6.9 KiB
Python

"""
Change detector for artifact content changes.
Implements FR-7.1: Change detection with persistent records.
Detects changes by comparing content digests and persists
ArtifactChange records to the artifact_changes table.
"""
import sqlite3
from pathlib import Path
from typing import List, Optional
from markitect.prompts.models import Artifact, calculate_content_digest
from markitect.prompts.incremental.models import ArtifactChange, ChangeType
# SQL schema for artifact_changes table
CHANGES_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS artifact_changes (
id TEXT PRIMARY KEY,
artifact_id TEXT NOT NULL,
old_digest TEXT,
new_digest TEXT NOT NULL,
change_type TEXT NOT NULL,
detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_changes_artifact ON artifact_changes(artifact_id);
CREATE INDEX IF NOT EXISTS idx_changes_type ON artifact_changes(change_type);
"""
class ChangeDetector:
"""
Detects and records artifact content changes.
Uses digest comparison to detect changes and persists
ArtifactChange records to SQLite for auditing and
incremental recomputation triggering.
"""
def __init__(self, db_path: str):
"""
Initialize with database path and ensure tables exist.
Args:
db_path: Path to SQLite database file
"""
self.db_path = db_path
self._initialize_tables()
def _initialize_tables(self) -> None:
"""Initialize artifact_changes table if not exists."""
db_dir = Path(self.db_path).parent
if db_dir and not db_dir.exists():
db_dir.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(self.db_path)
try:
conn.executescript(CHANGES_TABLE_SQL)
conn.commit()
finally:
conn.close()
def _get_connection(self) -> sqlite3.Connection:
"""Get a database connection."""
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
def detect_change(
self,
artifact: Artifact,
new_content: str,
) -> Optional[ArtifactChange]:
"""
Detect if artifact content has changed.
Compares the artifact's stored digest against the new content digest.
If changed, creates and returns an ArtifactChange record (not yet persisted).
Args:
artifact: Existing artifact to check
new_content: New content to compare against
Returns:
ArtifactChange if content changed, None otherwise
"""
new_digest = calculate_content_digest(new_content)
if not artifact.has_changed(new_digest):
return None
return ArtifactChange.create(
artifact_id=artifact.id,
old_digest=artifact.content_digest,
new_digest=new_digest,
change_type=ChangeType.MODIFIED,
)
def detect_creation(
self,
artifact_id: str,
content: str,
) -> ArtifactChange:
"""
Record creation of a new artifact.
Args:
artifact_id: ID of newly created artifact
content: Content of new artifact
Returns:
ArtifactChange record for the creation
"""
new_digest = calculate_content_digest(content)
return ArtifactChange.create(
artifact_id=artifact_id,
old_digest=None,
new_digest=new_digest,
change_type=ChangeType.CREATED,
)
def detect_deletion(
self,
artifact: Artifact,
) -> ArtifactChange:
"""
Record deletion of an artifact.
Args:
artifact: Artifact being deleted
Returns:
ArtifactChange record for the deletion
"""
return ArtifactChange.create(
artifact_id=artifact.id,
old_digest=artifact.content_digest,
new_digest=artifact.content_digest,
change_type=ChangeType.DELETED,
)
def record_change(self, change: ArtifactChange) -> ArtifactChange:
"""
Persist an ArtifactChange record to the database.
Args:
change: Change record to persist
Returns:
Persisted change record
"""
conn = self._get_connection()
try:
conn.execute(
"""
INSERT INTO artifact_changes (
id, artifact_id, old_digest, new_digest,
change_type, detected_at
) VALUES (?, ?, ?, ?, ?, ?)
""",
(
change.id,
change.artifact_id,
change.old_digest,
change.new_digest,
change.change_type.value,
change.detected_at.isoformat(),
),
)
conn.commit()
return change
finally:
conn.close()
def get_changes_for_artifact(self, artifact_id: str) -> List[ArtifactChange]:
"""
Get all recorded changes for an artifact.
Args:
artifact_id: Artifact identifier
Returns:
List of change records, ordered by detection time
"""
conn = self._get_connection()
try:
cursor = conn.execute(
"""
SELECT * FROM artifact_changes
WHERE artifact_id = ?
ORDER BY detected_at
""",
(artifact_id,),
)
return [self._row_to_change(row) for row in cursor.fetchall()]
finally:
conn.close()
def get_changes_by_type(self, change_type: ChangeType) -> List[ArtifactChange]:
"""
Get all recorded changes of a specific type.
Args:
change_type: Type of change to filter by
Returns:
List of change records
"""
conn = self._get_connection()
try:
cursor = conn.execute(
"SELECT * FROM artifact_changes WHERE change_type = ?",
(change_type.value,),
)
return [self._row_to_change(row) for row in cursor.fetchall()]
finally:
conn.close()
def _row_to_change(self, row: sqlite3.Row) -> ArtifactChange:
"""Convert database row to ArtifactChange instance."""
from datetime import datetime
return ArtifactChange(
id=row["id"],
artifact_id=row["artifact_id"],
old_digest=row["old_digest"],
new_digest=row["new_digest"],
change_type=ChangeType(row["change_type"]),
detected_at=datetime.fromisoformat(row["detected_at"]),
)