Add transitive dependency resolution and lock file support
Implements two major features for project dependency management: Transitive Dependency Resolution: - DependencyGraph/DependencyNode for structured dependency tracking - DependencyGraphBuilder with recursive resolution and cycle detection - Topological sorting for correct install order (dependencies first) - Version conflict detection with provenance tracking - Full semver support: ^, ~, >=, <=, >, <, = constraints - New `cmdforge deps tree` command to visualize dependency tree - Install flags: --dry-run, --force, --verbose Lock File Support (cmdforge.lock): - Lockfile/LockedPackage/LockfileMetadata dataclasses - Records exact versions of all dependencies (direct + transitive) - Integrity verification via SHA256 hashes - `cmdforge lock` to generate/update lock file - `cmdforge verify` to check installed tools match lock - Install flags: --frozen, --strict-frozen, --ignore-lock 62 new tests for version, dependency_graph, and lockfile modules. Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,547 @@
|
||||
"""Dependency graph for transitive dependency resolution.
|
||||
|
||||
Builds a complete dependency graph with:
|
||||
- Transitive closure (A -> B -> C)
|
||||
- Cycle detection
|
||||
- Version conflict detection with provenance
|
||||
- Topological sorting for install order
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Set, Optional, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
from .tool import TOOLS_DIR
|
||||
from .manifest import Dependency
|
||||
from .version import matches_constraint
|
||||
|
||||
|
||||
# Default owner for unqualified tool references
|
||||
DEFAULT_OWNER = "official"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DependencyNode:
|
||||
"""A node in the dependency graph."""
|
||||
owner: str # e.g., "official" (resolved), "" if unresolved
|
||||
name: str # e.g., "summarize"
|
||||
version_constraint: Optional[str] # e.g., "^1.0.0", "*", None
|
||||
resolved_version: Optional[str] # e.g., "1.2.3" (after resolution)
|
||||
source: Optional[str] # "local", "global", "registry", None (unresolved)
|
||||
path: Optional[Path] # Path to installed tool (if installed)
|
||||
children: List[str] = field(default_factory=list) # Qualified names of dependencies
|
||||
requested_by: str = "root" # Parent that introduced this dep
|
||||
|
||||
@property
|
||||
def qualified_name(self) -> str:
|
||||
"""Consistent key for graph lookups."""
|
||||
return f"{self.owner}/{self.name}" if self.owner else self.name
|
||||
|
||||
@property
|
||||
def is_resolved(self) -> bool:
|
||||
"""Check if this node has been resolved to a source."""
|
||||
return self.source is not None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DependencyGraph:
|
||||
"""Complete dependency graph with transitive closure."""
|
||||
root_dependencies: List[str] = field(default_factory=list) # Direct deps (qualified names)
|
||||
nodes: Dict[str, DependencyNode] = field(default_factory=dict) # qualified_name -> node
|
||||
resolution_order: List[str] = field(default_factory=list) # Topological sort (install order)
|
||||
conflicts: List[Tuple[str, str, str, str, str]] = field(default_factory=list) # (tool, c1, from1, c2, from2)
|
||||
cycles: List[List[str]] = field(default_factory=list) # Detected cycles
|
||||
|
||||
def add_node(self, node: DependencyNode, requested_by: str = "root") -> None:
|
||||
"""Add or update a node. Detects version conflicts with provenance."""
|
||||
key = node.qualified_name
|
||||
node.requested_by = requested_by
|
||||
|
||||
if key in self.nodes:
|
||||
existing = self.nodes[key]
|
||||
# Check for version conflict
|
||||
if (existing.version_constraint and node.version_constraint and
|
||||
existing.version_constraint != node.version_constraint):
|
||||
self.conflicts.append((
|
||||
key,
|
||||
existing.version_constraint,
|
||||
existing.requested_by,
|
||||
node.version_constraint,
|
||||
requested_by
|
||||
))
|
||||
self.nodes[key] = node
|
||||
|
||||
def get_node(self, qualified_name: str) -> Optional[DependencyNode]:
|
||||
"""Get a node by qualified name."""
|
||||
return self.nodes.get(qualified_name)
|
||||
|
||||
def get_install_order(self) -> List[DependencyNode]:
|
||||
"""Return nodes in installation order (dependencies first)."""
|
||||
return [self.nodes[name] for name in self.resolution_order if name in self.nodes]
|
||||
|
||||
def has_conflicts(self) -> bool:
|
||||
"""Check if any version conflicts were detected."""
|
||||
return len(self.conflicts) > 0
|
||||
|
||||
def has_cycles(self) -> bool:
|
||||
"""Check if any circular dependencies were detected."""
|
||||
return len(self.cycles) > 0
|
||||
|
||||
def get_unresolved(self) -> List[DependencyNode]:
|
||||
"""Get all unresolved nodes."""
|
||||
return [node for node in self.nodes.values() if not node.is_resolved]
|
||||
|
||||
def get_registry_nodes(self) -> List[DependencyNode]:
|
||||
"""Get nodes that need to be installed from registry."""
|
||||
return [
|
||||
node for node in self.nodes.values()
|
||||
if node.source == "registry" and node.path is None
|
||||
]
|
||||
|
||||
|
||||
class DependencyGraphBuilder:
|
||||
"""Builds a complete dependency graph with transitive resolution."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resolver: Optional["ToolResolver"] = None,
|
||||
client: Optional["RegistryClient"] = None,
|
||||
max_depth: int = 50,
|
||||
verbose: bool = False
|
||||
):
|
||||
"""
|
||||
Initialize the graph builder.
|
||||
|
||||
Args:
|
||||
resolver: ToolResolver for local/global lookups (created if not provided)
|
||||
client: RegistryClient for registry lookups (created if not provided)
|
||||
max_depth: Maximum dependency depth (prevents infinite recursion)
|
||||
verbose: Print debug info
|
||||
"""
|
||||
self.resolver = resolver
|
||||
self.client = client
|
||||
self.max_depth = max_depth
|
||||
self.verbose = verbose
|
||||
self._visited: Set[str] = set() # qualified names
|
||||
self._stack: List[str] = [] # For cycle detection
|
||||
self._registry_cache: Dict[str, "DownloadResult"] = {} # cache_key -> DownloadResult
|
||||
self._resolved_count = 0
|
||||
self.include_implicit = True
|
||||
|
||||
def _get_resolver(self) -> "ToolResolver":
|
||||
"""Get or create the tool resolver."""
|
||||
if self.resolver is None:
|
||||
from .resolver import ToolResolver
|
||||
self.resolver = ToolResolver(auto_fetch=False)
|
||||
return self.resolver
|
||||
|
||||
def _get_client(self) -> "RegistryClient":
|
||||
"""Get or create the registry client."""
|
||||
if self.client is None:
|
||||
from .registry_client import get_client
|
||||
self.client = get_client()
|
||||
return self.client
|
||||
|
||||
def build(
|
||||
self,
|
||||
dependencies: List[Dependency],
|
||||
include_implicit: bool = True
|
||||
) -> DependencyGraph:
|
||||
"""
|
||||
Build complete dependency graph.
|
||||
|
||||
Args:
|
||||
dependencies: Direct dependencies to resolve
|
||||
include_implicit: Whether to include ToolStep dependencies
|
||||
|
||||
Returns:
|
||||
DependencyGraph with all transitive dependencies
|
||||
"""
|
||||
graph = DependencyGraph()
|
||||
self.include_implicit = include_implicit
|
||||
|
||||
# Reset state for new build
|
||||
self._visited.clear()
|
||||
self._stack.clear()
|
||||
self._resolved_count = 0
|
||||
|
||||
# Track original names to resolve qualified names after
|
||||
original_names = []
|
||||
for dep in dependencies:
|
||||
original_names.append(dep.name)
|
||||
self._resolve_recursive(dep.name, dep.version, graph, depth=0)
|
||||
|
||||
# Update root_dependencies with qualified names from resolved nodes
|
||||
for orig_name in original_names:
|
||||
# Find the resolved node - check both qualified and unqualified
|
||||
node = graph.get_node(orig_name)
|
||||
if node:
|
||||
graph.root_dependencies.append(node.qualified_name)
|
||||
else:
|
||||
# Try to find by matching tool name across all nodes
|
||||
found = False
|
||||
for qname, n in graph.nodes.items():
|
||||
if n.name == orig_name or qname == orig_name:
|
||||
graph.root_dependencies.append(n.qualified_name)
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
# Unresolved - keep original name for error reporting
|
||||
graph.root_dependencies.append(orig_name)
|
||||
|
||||
graph.resolution_order = self._topological_sort(graph)
|
||||
return graph
|
||||
|
||||
def _qualify_name(self, name: str, resolved_owner: Optional[str] = None) -> str:
|
||||
"""Return qualified name after resolution (local-first)."""
|
||||
if "/" in name:
|
||||
return name
|
||||
owner = resolved_owner or DEFAULT_OWNER
|
||||
return f"{owner}/{name}"
|
||||
|
||||
def _parse_qualified(self, qualified: str) -> Tuple[Optional[str], str]:
|
||||
"""Split qualified name into owner and name (owner may be None)."""
|
||||
if "/" in qualified:
|
||||
parts = qualified.split("/", 1)
|
||||
return (parts[0], parts[1])
|
||||
return (None, qualified)
|
||||
|
||||
def _resolve_recursive(
|
||||
self,
|
||||
qualified_name: str,
|
||||
version_constraint: Optional[str],
|
||||
graph: DependencyGraph,
|
||||
depth: int
|
||||
) -> Optional[DependencyNode]:
|
||||
"""Recursively resolve a dependency and its children."""
|
||||
|
||||
# Cycle detection
|
||||
if qualified_name in self._stack:
|
||||
cycle = self._stack[self._stack.index(qualified_name):] + [qualified_name]
|
||||
graph.cycles.append(cycle)
|
||||
if self.verbose:
|
||||
print(f" Cycle detected: {' -> '.join(cycle)}")
|
||||
return None
|
||||
|
||||
# Already resolved - check for version conflicts
|
||||
if qualified_name in self._visited:
|
||||
existing = graph.get_node(qualified_name)
|
||||
if existing and version_constraint and existing.version_constraint != version_constraint:
|
||||
graph.conflicts.append((
|
||||
qualified_name,
|
||||
existing.version_constraint or "*",
|
||||
existing.requested_by or "unknown",
|
||||
version_constraint or "*",
|
||||
self._stack[-1] if self._stack else "unknown"
|
||||
))
|
||||
return existing
|
||||
|
||||
# Depth limit
|
||||
if depth > self.max_depth:
|
||||
raise RecursionError(f"Dependency depth exceeded {self.max_depth}")
|
||||
|
||||
self._visited.add(qualified_name)
|
||||
self._stack.append(qualified_name)
|
||||
|
||||
try:
|
||||
# Resolve the tool
|
||||
node = self._resolve_single(qualified_name, version_constraint)
|
||||
if node is None:
|
||||
return None
|
||||
|
||||
# Also track the resolved qualified name to prevent duplicates
|
||||
if node.qualified_name != qualified_name:
|
||||
self._visited.add(node.qualified_name)
|
||||
|
||||
graph.add_node(node, requested_by=self._stack[-2] if len(self._stack) > 1 else "root")
|
||||
self._resolved_count += 1
|
||||
if self.verbose:
|
||||
print(f" Resolved {self._resolved_count}: {node.qualified_name}")
|
||||
|
||||
# Get child dependencies (only if this node is resolved)
|
||||
if node.is_resolved:
|
||||
child_deps = self._get_tool_dependencies(node)
|
||||
else:
|
||||
child_deps = []
|
||||
|
||||
# Recurse into children and capture resolved keys
|
||||
node.children = []
|
||||
for child in child_deps:
|
||||
child_node = self._resolve_recursive(child.name, child.version, graph, depth + 1)
|
||||
if child_node and child_node.is_resolved:
|
||||
node.children.append(child_node.qualified_name)
|
||||
elif child_node:
|
||||
# Child exists but is unresolved - still track for error reporting
|
||||
node.children.append(child_node.qualified_name)
|
||||
if self.verbose:
|
||||
print(f" Warning: Unresolved dependency {child.name}")
|
||||
|
||||
return node
|
||||
|
||||
finally:
|
||||
self._stack.pop()
|
||||
|
||||
def _resolve_single(
|
||||
self,
|
||||
qualified_name: str,
|
||||
version_constraint: Optional[str]
|
||||
) -> Optional[DependencyNode]:
|
||||
"""Resolve a single dependency to a node."""
|
||||
owner, name = self._parse_qualified(qualified_name)
|
||||
owner_hint = None
|
||||
|
||||
# Try local/global first via resolver
|
||||
try:
|
||||
from .resolver import ToolNotFoundError
|
||||
resolver = self._get_resolver()
|
||||
resolved = resolver.resolve(qualified_name)
|
||||
owner_hint = resolved.owner
|
||||
if version_constraint and version_constraint not in ("", "*", "latest"):
|
||||
resolved_version = resolved.version or ""
|
||||
if not resolved_version or not matches_constraint(resolved_version, version_constraint):
|
||||
if self.verbose:
|
||||
print(
|
||||
f" Local/global {qualified_name}@{resolved_version or 'unknown'} "
|
||||
f"does not satisfy {version_constraint}, resolving from registry"
|
||||
)
|
||||
else:
|
||||
return DependencyNode(
|
||||
owner=resolved.owner or owner or "",
|
||||
name=resolved.tool.name,
|
||||
version_constraint=version_constraint,
|
||||
resolved_version=resolved.version,
|
||||
source=resolved.source,
|
||||
path=resolved.path,
|
||||
children=[]
|
||||
)
|
||||
else:
|
||||
return DependencyNode(
|
||||
owner=resolved.owner or owner or "",
|
||||
name=resolved.tool.name,
|
||||
version_constraint=version_constraint,
|
||||
resolved_version=resolved.version,
|
||||
source=resolved.source,
|
||||
path=resolved.path,
|
||||
children=[]
|
||||
)
|
||||
except ToolNotFoundError:
|
||||
pass
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
print(f" Warning: Resolver error for {qualified_name}: {e}")
|
||||
|
||||
# Try registry
|
||||
try:
|
||||
reg_owner = owner or owner_hint or DEFAULT_OWNER
|
||||
result = self._download_registry_config(
|
||||
reg_owner,
|
||||
name,
|
||||
version_constraint
|
||||
)
|
||||
return DependencyNode(
|
||||
owner=reg_owner,
|
||||
name=name,
|
||||
version_constraint=version_constraint,
|
||||
resolved_version=result.resolved_version or "",
|
||||
source="registry",
|
||||
path=None, # Not installed yet
|
||||
children=[]
|
||||
)
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
print(f" Could not resolve {qualified_name}: {e}")
|
||||
# Record unresolved node so graph reflects missing dependency
|
||||
return DependencyNode(
|
||||
owner=owner or owner_hint or "",
|
||||
name=name,
|
||||
version_constraint=version_constraint,
|
||||
resolved_version=None,
|
||||
source=None,
|
||||
path=None,
|
||||
children=[]
|
||||
)
|
||||
|
||||
def _get_tool_dependencies(self, node: DependencyNode) -> List[Dependency]:
|
||||
"""Get dependencies for a resolved tool node."""
|
||||
deps = []
|
||||
|
||||
if node.source in ("local", "global") and node.path:
|
||||
# Read from local config file
|
||||
deps.extend(self._read_deps_from_config(node.path))
|
||||
elif node.source == "registry":
|
||||
# Fetch config from registry without installing
|
||||
deps.extend(self._read_deps_from_registry(node.owner, node.name, node.resolved_version or node.version_constraint))
|
||||
|
||||
return deps
|
||||
|
||||
def _read_deps_from_config(self, tool_path: Path) -> List[Dependency]:
|
||||
"""Read dependencies from a local tool config."""
|
||||
deps = []
|
||||
config_path = tool_path / "config.yaml"
|
||||
if not config_path.exists():
|
||||
return deps
|
||||
|
||||
try:
|
||||
data = yaml.safe_load(config_path.read_text()) or {}
|
||||
|
||||
# Explicit dependencies
|
||||
for dep in data.get("dependencies", []):
|
||||
deps.append(Dependency.from_dict(dep))
|
||||
|
||||
# Implicit dependencies from ToolSteps
|
||||
if self.include_implicit:
|
||||
for step in data.get("steps", []):
|
||||
if step.get("type") == "tool":
|
||||
tool_ref = step.get("tool")
|
||||
if tool_ref:
|
||||
deps.append(Dependency.from_dict(tool_ref))
|
||||
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
print(f" Warning: Could not read deps from {config_path}: {e}")
|
||||
|
||||
return deps
|
||||
|
||||
def _read_deps_from_registry(self, owner: str, name: str, version: Optional[str]) -> List[Dependency]:
|
||||
"""Read dependencies from registry tool config."""
|
||||
deps = []
|
||||
|
||||
try:
|
||||
# Download config without installing (cache by qualified name + version/constraint)
|
||||
result = self._download_registry_config(owner, name, version)
|
||||
config_yaml = result.config_yaml
|
||||
|
||||
data = yaml.safe_load(config_yaml) or {}
|
||||
|
||||
# Explicit dependencies
|
||||
for dep in data.get("dependencies", []):
|
||||
deps.append(Dependency.from_dict(dep))
|
||||
|
||||
# Implicit dependencies from ToolSteps
|
||||
if self.include_implicit:
|
||||
for step in data.get("steps", []):
|
||||
if step.get("type") == "tool":
|
||||
tool_ref = step.get("tool")
|
||||
if tool_ref:
|
||||
deps.append(Dependency.from_dict(tool_ref))
|
||||
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
print(f" Warning: Could not read deps from registry {owner}/{name}: {e}")
|
||||
|
||||
return deps
|
||||
|
||||
def _download_registry_config(
|
||||
self,
|
||||
owner: str,
|
||||
name: str,
|
||||
version: Optional[str]
|
||||
) -> "DownloadResult":
|
||||
"""Download registry config with caching (version or constraint aware)."""
|
||||
cache_key = f"{owner}/{name}@{version or ''}"
|
||||
if cache_key in self._registry_cache:
|
||||
return self._registry_cache[cache_key]
|
||||
|
||||
client = self._get_client()
|
||||
result = client.download_tool(owner, name, version=version, install=False)
|
||||
self._registry_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
def _topological_sort(self, graph: DependencyGraph) -> List[str]:
|
||||
"""Return nodes in installation order (dependencies first)."""
|
||||
# Kahn's algorithm
|
||||
in_degree = {name: 0 for name in graph.nodes}
|
||||
|
||||
# Build in-degree counts
|
||||
for node in graph.nodes.values():
|
||||
for child in node.children:
|
||||
if child in in_degree:
|
||||
in_degree[child] += 1
|
||||
|
||||
# Start with nodes that have no dependencies
|
||||
queue = [name for name, deg in in_degree.items() if deg == 0]
|
||||
result = []
|
||||
|
||||
while queue:
|
||||
name = queue.pop(0)
|
||||
result.append(name)
|
||||
node = graph.nodes.get(name)
|
||||
if node:
|
||||
for child in node.children:
|
||||
if child in in_degree:
|
||||
in_degree[child] -= 1
|
||||
if in_degree[child] == 0:
|
||||
queue.append(child)
|
||||
|
||||
# In our graph, A.children contains B means "A depends on B".
|
||||
# Kahn's algorithm processes nodes with in_degree=0 first.
|
||||
# Nodes with in_degree=0 are roots (no one depends on them).
|
||||
# So the raw output is: dependents first, then dependencies.
|
||||
# We reverse to get: dependencies first, then dependents (correct install order).
|
||||
return list(reversed(result))
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolutionResult:
|
||||
"""Result of dependency resolution."""
|
||||
graph: DependencyGraph
|
||||
to_install: List[DependencyNode] # Tools needing installation
|
||||
already_installed: List[DependencyNode] # Already present
|
||||
failed: List[Tuple[str, str]] # (qualified_name, error_message)
|
||||
warnings: List[str] # Non-fatal issues
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
"""Check if resolution was successful."""
|
||||
return not self.graph.has_cycles() and not self.failed
|
||||
|
||||
|
||||
def resolve_dependencies(
|
||||
dependencies: List[Dependency],
|
||||
verbose: bool = False,
|
||||
include_implicit: bool = True
|
||||
) -> ResolutionResult:
|
||||
"""
|
||||
Convenience function to resolve dependencies and categorize results.
|
||||
|
||||
Args:
|
||||
dependencies: Direct dependencies to resolve
|
||||
verbose: Print debug info
|
||||
include_implicit: Include ToolStep dependencies
|
||||
|
||||
Returns:
|
||||
ResolutionResult with categorized nodes
|
||||
"""
|
||||
builder = DependencyGraphBuilder(verbose=verbose)
|
||||
graph = builder.build(dependencies, include_implicit=include_implicit)
|
||||
|
||||
to_install = []
|
||||
already_installed = []
|
||||
failed = []
|
||||
warnings = []
|
||||
|
||||
for node in graph.get_install_order():
|
||||
if not node.is_resolved:
|
||||
failed.append((node.qualified_name, "Tool not found"))
|
||||
elif node.source == "registry" and node.path is None:
|
||||
to_install.append(node)
|
||||
else:
|
||||
already_installed.append(node)
|
||||
|
||||
if graph.has_conflicts():
|
||||
for tool, v1, from1, v2, from2 in graph.conflicts:
|
||||
warnings.append(f"Version conflict for {tool}: {v1} (from {from1}) vs {v2} (from {from2})")
|
||||
|
||||
if graph.has_cycles():
|
||||
for cycle in graph.cycles:
|
||||
warnings.append(f"Circular dependency: {' -> '.join(cycle)}")
|
||||
|
||||
return ResolutionResult(
|
||||
graph=graph,
|
||||
to_install=to_install,
|
||||
already_installed=already_installed,
|
||||
failed=failed,
|
||||
warnings=warnings
|
||||
)
|
||||
Reference in New Issue
Block a user