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>
233 lines
6.9 KiB
Python
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"]),
|
|
)
|