Files
dss/dss/export_import/merger.py
Bruno Sarlo faa19beef3
Some checks failed
DSS Project Analysis / dss-context-update (push) Has been cancelled
Fix import paths and remove organ metaphors
- Update all `from storage.` imports to `from dss.storage.`
- Update `from config import config` to use `dss.settings`
- Update `from auth.` imports to `from dss.auth.`
- Update health check to use `dss.mcp.handler`
- Fix SmartMerger import (merger.py not smart_merger.py)
- Fix TranslationDictionary import path
- Fix test assertion for networkx edges/links
- Remove organ/body metaphors from:
  - API server health check
  - CLI status command and help text
  - Admin UI logger and error handler

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 13:05:00 -03:00

384 lines
13 KiB
Python

"""Smart merge strategy for .dss imports with conflict detection"""
import hashlib
from datetime import datetime
from typing import Dict, Any, List, Optional, Tuple, Literal
from dataclasses import dataclass
from enum import Enum
from .security import TimestampConflictResolver
from ..models.project import Project
from ..models.theme import DesignToken
from ..models.component import Component
from dss.storage.json_store import Projects, Components, Tokens
MergeStrategy = Literal["overwrite", "keep_local", "fork", "skip"]
class ConflictResolutionMode(str, Enum):
"""How to handle conflicts during merge"""
OVERWRITE = "overwrite" # Import wins
KEEP_LOCAL = "keep_local" # Local wins
FORK = "fork" # Create duplicate with new UUID
MANUAL = "manual" # Require user decision
@dataclass
class ConflictItem:
"""Detected conflict"""
uuid: str
entity_type: str # token, component, theme
entity_name: str
local_updated_at: datetime
imported_updated_at: datetime
local_hash: str
imported_hash: str
is_modified_both: bool # True if changed in both places
@property
def local_is_newer(self) -> bool:
"""Is local version newer?"""
return self.local_updated_at > self.imported_updated_at
@property
def imported_is_newer(self) -> bool:
"""Is imported version newer?"""
return self.imported_updated_at > self.local_updated_at
@property
def is_identical(self) -> bool:
"""Are both versions identical?"""
return self.local_hash == self.imported_hash
def get_safe_recommendation(self, allow_drift_detection: bool = True) -> Tuple[str, Optional[str]]:
"""Get safe conflict resolution recommendation with clock skew detection.
Uses TimestampConflictResolver to safely determine winner, accounting
for possible clock drift between systems.
Args:
allow_drift_detection: If True, warn about possible clock skew
Returns:
Tuple of (recommended_winner: 'local'|'imported'|'unknown', warning: str|None)
"""
resolver = TimestampConflictResolver()
return resolver.resolve_conflict(
self.local_updated_at,
self.imported_updated_at,
allow_drift_detection=allow_drift_detection,
)
@dataclass
class MergeAnalysis:
"""Analysis of merge operation"""
new_items: Dict[str, List[str]] # type -> [names]
updated_items: Dict[str, List[str]] # type -> [names]
conflicted_items: List[ConflictItem]
total_changes: int
@property
def has_conflicts(self) -> bool:
"""Are there conflicts?"""
return len(self.conflicted_items) > 0
class UUIDHashMap:
"""Maps UUIDs to content hashes for detecting changes"""
def __init__(self):
self.hashes: Dict[str, str] = {}
@staticmethod
def hash_token(token: DesignToken) -> str:
"""Generate stable hash of token content (excludes UUID, timestamps)"""
content = f"{token.name}:{token.value}:{token.type}:{token.category}:{token.description}:{token.source}:{token.deprecated}"
return hashlib.sha256(content.encode()).hexdigest()
@staticmethod
def hash_component(component: Component) -> str:
"""Generate stable hash of component content"""
import json
content = json.dumps(
{
"name": component.name,
"source": component.source,
"description": component.description,
"variants": component.variants,
"props": component.props,
"dependencies": sorted(component.dependencies),
},
sort_keys=True,
)
return hashlib.sha256(content.encode()).hexdigest()
def add_token(self, token: DesignToken):
"""Add token to hash map"""
self.hashes[token.uuid] = self.hash_token(token)
def add_component(self, component: Component):
"""Add component to hash map"""
self.hashes[component.uuid] = self.hash_component(component)
def get(self, uuid: str) -> Optional[str]:
"""Get hash for UUID"""
return self.hashes.get(uuid)
class SmartMerger:
"""Intelligent merge strategy for archives"""
def __init__(self, local_project: Project, imported_project: Project):
self.local_project = local_project
self.imported_project = imported_project
def analyze_merge(self) -> MergeAnalysis:
"""
Analyze what would happen in a merge without modifying anything
Returns:
MergeAnalysis with new, updated, and conflicted items
"""
new_items: Dict[str, List[str]] = {
"tokens": [],
"components": [],
"themes": [],
}
updated_items: Dict[str, List[str]] = {
"tokens": [],
"components": [],
"themes": [],
}
conflicts = []
# Build local UUID maps
local_token_uuids = {t.uuid: t for t in self.local_project.theme.tokens.values()}
local_component_uuids = {c.uuid: c for c in self.local_project.components}
# Check imported tokens
for token_name, imported_token in self.imported_project.theme.tokens.items():
if imported_token.uuid not in local_token_uuids:
new_items["tokens"].append(token_name)
else:
local_token = local_token_uuids[imported_token.uuid]
if local_token != imported_token:
# Detect conflict
conflict = self._detect_token_conflict(
imported_token.uuid,
local_token,
imported_token,
)
if conflict:
conflicts.append(conflict)
else:
updated_items["tokens"].append(token_name)
# Check imported components
for imported_comp in self.imported_project.components:
if imported_comp.uuid not in local_component_uuids:
new_items["components"].append(imported_comp.name)
else:
local_comp = local_component_uuids[imported_comp.uuid]
if local_comp != imported_comp:
conflict = self._detect_component_conflict(
imported_comp.uuid,
local_comp,
imported_comp,
)
if conflict:
conflicts.append(conflict)
else:
updated_items["components"].append(imported_comp.name)
total_changes = (
len(new_items["tokens"])
+ len(new_items["components"])
+ len(updated_items["tokens"])
+ len(updated_items["components"])
+ len(conflicts)
)
return MergeAnalysis(
new_items=new_items,
updated_items=updated_items,
conflicted_items=conflicts,
total_changes=total_changes,
)
def merge_with_strategy(
self,
conflict_handler: ConflictResolutionMode = ConflictResolutionMode.OVERWRITE,
) -> Project:
"""
Perform merge with specified conflict strategy
Args:
conflict_handler: How to handle conflicts
Returns:
Merged project
"""
analysis = self.analyze_merge()
# Create copy of local project
merged_project = self.local_project.model_copy(deep=True)
# Apply new tokens
for token_name in analysis.new_items["tokens"]:
if token_name in self.imported_project.theme.tokens:
imported_token = self.imported_project.theme.tokens[token_name]
merged_project.theme.tokens[token_name] = imported_token.model_copy()
# Apply updated tokens
for token_name in analysis.updated_items["tokens"]:
if token_name in self.imported_project.theme.tokens:
imported_token = self.imported_project.theme.tokens[token_name]
merged_project.theme.tokens[token_name] = imported_token.model_copy()
# Apply new components
for comp in self.imported_project.components:
if not any(c.uuid == comp.uuid for c in merged_project.components):
merged_project.components.append(comp.model_copy())
# Apply updated components
for comp in self.imported_project.components:
for i, local_comp in enumerate(merged_project.components):
if local_comp.uuid == comp.uuid:
merged_project.components[i] = comp.model_copy()
break
# Handle conflicts based on strategy
for conflict in analysis.conflicted_items:
self._resolve_conflict(
merged_project,
conflict,
conflict_handler,
)
return merged_project
def _detect_token_conflict(
self,
token_uuid: str,
local_token: DesignToken,
imported_token: DesignToken,
) -> Optional[ConflictItem]:
"""Check if token versions conflict"""
local_hash = UUIDHashMap.hash_token(local_token)
imported_hash = UUIDHashMap.hash_token(imported_token)
# No conflict if identical
if local_hash == imported_hash:
return None
# Conflict detected
return ConflictItem(
uuid=token_uuid,
entity_type="token",
entity_name=local_token.name,
local_updated_at=local_token.updated_at,
imported_updated_at=imported_token.updated_at,
local_hash=local_hash,
imported_hash=imported_hash,
is_modified_both=True,
)
def _detect_component_conflict(
self,
comp_uuid: str,
local_comp: Component,
imported_comp: Component,
) -> Optional[ConflictItem]:
"""Check if component versions conflict"""
local_hash = UUIDHashMap.hash_component(local_comp)
imported_hash = UUIDHashMap.hash_component(imported_comp)
# No conflict if identical
if local_hash == imported_hash:
return None
# Conflict detected
return ConflictItem(
uuid=comp_uuid,
entity_type="component",
entity_name=local_comp.name,
local_updated_at=local_comp.updated_at if hasattr(local_comp, 'updated_at') else datetime.utcnow(),
imported_updated_at=imported_comp.updated_at if hasattr(imported_comp, 'updated_at') else datetime.utcnow(),
local_hash=local_hash,
imported_hash=imported_hash,
is_modified_both=True,
)
def _resolve_conflict(
self,
project: Project,
conflict: ConflictItem,
strategy: ConflictResolutionMode,
):
"""Apply conflict resolution strategy"""
if strategy == ConflictResolutionMode.OVERWRITE:
# Import wins - already applied
pass
elif strategy == ConflictResolutionMode.KEEP_LOCAL:
# Undo the import
if conflict.entity_type == "token":
# Find and restore local token
local_token = next(
(t for t in self.local_project.theme.tokens.values() if t.uuid == conflict.uuid),
None,
)
if local_token:
project.theme.tokens[local_token.name] = local_token.model_copy()
elif conflict.entity_type == "component":
local_comp = next(
(c for c in self.local_project.components if c.uuid == conflict.uuid),
None,
)
if local_comp:
for i, comp in enumerate(project.components):
if comp.uuid == conflict.uuid:
project.components[i] = local_comp.model_copy()
break
elif strategy == ConflictResolutionMode.FORK:
# Create new item with new UUID
from uuid import uuid4
if conflict.entity_type == "token":
imported_token = next(
(t for t in self.imported_project.theme.tokens.values() if t.uuid == conflict.uuid),
None,
)
if imported_token:
forked = imported_token.model_copy()
forked.uuid = str(uuid4())
project.theme.tokens[f"{imported_token.name}_imported"] = forked
elif conflict.entity_type == "component":
imported_comp = next(
(c for c in self.imported_project.components if c.uuid == conflict.uuid),
None,
)
if imported_comp:
forked = imported_comp.model_copy()
forked.uuid = str(uuid4())
forked.name = f"{imported_comp.name}_imported"
project.components.append(forked)
# Export
__all__ = [
"SmartMerger",
"ConflictResolutionMode",
"ConflictItem",
"MergeAnalysis",
"UUIDHashMap",
]