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:
rob
2026-01-26 22:05:09 -04:00
co-authored by Claude Opus 4.5
parent 29869ef59f
commit 3e7eaeb504
10 changed files with 2536 additions and 31 deletions
+547
View File
@@ -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
)