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
+373 -28
View File
@@ -14,6 +14,10 @@ from ..resolver import (
def cmd_deps(args):
"""Show project dependencies from cmdforge.yaml."""
# If 'tree' subcommand, delegate to that
if hasattr(args, 'deps_cmd') and args.deps_cmd == 'tree':
return cmd_deps_tree(args)
manifest = load_manifest()
if manifest is None:
@@ -51,9 +55,99 @@ def cmd_deps(args):
return 0
def cmd_deps_tree(args):
"""Show dependency tree with transitive dependencies."""
from ..dependency_graph import DependencyGraphBuilder
from ..resolver import ToolResolver
manifest = load_manifest()
if manifest is None:
print("No cmdforge.yaml found in current project.")
print("Create one with: cmdforge init")
return 1
if not manifest.dependencies:
print("No dependencies in cmdforge.yaml")
return 0
verbose = getattr(args, 'verbose', False)
resolver = ToolResolver(auto_fetch=False)
builder = DependencyGraphBuilder(resolver=resolver, verbose=verbose)
print("Resolving dependencies...")
graph = builder.build(manifest.dependencies)
def print_tree(qualified_name: str, indent: int = 0, seen: set = None):
seen = seen or set()
prefix = " " * indent
node = graph.get_node(qualified_name)
if not node:
print(f"{prefix}- {qualified_name} (not found)")
return
# Status indicator
if node.source in ("local", "global"):
status = "installed"
elif node.source == "registry" and node.path:
status = "installed"
elif node.source == "registry":
status = "registry"
else:
status = "missing"
# Show resolved version if available, otherwise fall back to constraint
if node.resolved_version:
version = f"@{node.resolved_version}"
elif node.version_constraint:
version = f"@{node.version_constraint}"
else:
version = ""
circular = " (circular)" if qualified_name in seen else ""
print(f"{prefix}- {node.qualified_name}{version} [{status}]{circular}")
if qualified_name not in seen:
seen.add(qualified_name)
for child in node.children:
print_tree(child, indent + 1, seen.copy())
print()
print("Dependency tree:")
for root in graph.root_dependencies:
print_tree(root)
if graph.conflicts:
print()
print("Version conflicts:")
for tool, v1, from1, v2, from2 in graph.conflicts:
print(f" {tool}: {v1} (from {from1}) vs {v2} (from {from2})")
if graph.cycles:
print()
print("Circular dependencies:")
for cycle in graph.cycles:
print(f" {' -> '.join(cycle)}")
# Summary
print()
total = len(graph.nodes)
installed = sum(1 for n in graph.nodes.values() if n.source in ("local", "global") or (n.source == "registry" and n.path))
to_install = sum(1 for n in graph.nodes.values() if n.source == "registry" and n.path is None)
missing = sum(1 for n in graph.nodes.values() if not n.is_resolved)
print(f"Total: {total} tools ({installed} installed, {to_install} to install, {missing} missing)")
return 0
def cmd_install_deps(args):
"""Install dependencies from cmdforge.yaml."""
"""Install all dependencies from cmdforge.yaml with transitive resolution."""
from ..dependency_graph import DependencyGraphBuilder
from ..resolver import ToolResolver, install_from_registry, ToolNotFoundError
from ..registry_client import RegistryError
from ..lockfile import Lockfile
manifest = load_manifest()
@@ -66,46 +160,195 @@ def cmd_install_deps(args):
print("No dependencies to install.")
return 0
print(f"Installing dependencies for {manifest.name}...")
verbose = getattr(args, 'verbose', False)
dry_run = getattr(args, 'dry_run', False)
force = getattr(args, 'force', False)
frozen = getattr(args, 'frozen', False)
strict_frozen = getattr(args, 'strict_frozen', False)
ignore_lock = getattr(args, 'ignore_lock', False)
# Check for lock file
lock = Lockfile.load()
if frozen or strict_frozen:
# Frozen mode: require lock file
if lock is None:
print("Error: --frozen requires cmdforge.lock")
print("Run 'cmdforge lock' first.")
return 1
if strict_frozen and lock.is_stale():
print("Error: cmdforge.lock is stale (manifest changed)")
print("Run 'cmdforge lock' to update, or drop --strict-frozen")
return 1
if lock and not ignore_lock:
return _install_from_lock(lock, args)
# Fall through to manifest-based installation
# Build dependency graph
resolver = ToolResolver(auto_fetch=False)
builder = DependencyGraphBuilder(resolver=resolver, verbose=verbose)
print("Resolving dependencies...")
graph = builder.build(manifest.dependencies)
# Check for issues
if graph.has_cycles():
print()
print("Error: Circular dependencies detected:")
for cycle in graph.cycles:
print(f" {' -> '.join(cycle)}")
return 1
if graph.has_conflicts():
print()
print("Warning: Version conflicts detected:")
for tool, v1, from1, v2, from2 in graph.conflicts:
print(f" {tool}: {v1} (from {from1}) vs {v2} (from {from2})")
if not force:
print()
print("Use --force to install anyway (first constraint wins)")
return 1
unresolved = graph.get_unresolved()
if unresolved:
print()
print("Error: Missing dependencies:")
for node in unresolved:
constraint = f"@{node.version_constraint}" if node.version_constraint else ""
print(f" {node.qualified_name}{constraint}")
return 1
# Determine what needs installation (in correct order)
to_install = [
node for node in graph.get_install_order()
if node.source == "registry" and node.path is None
]
if not to_install:
print("All dependencies already installed.")
return 0
if dry_run:
print()
print(f"Would install {len(to_install)} tool(s):")
for node in to_install:
version = node.resolved_version or "latest"
print(f" {node.qualified_name}@{version}")
return 0
print()
print(f"Installing {len(to_install)} tool(s) in dependency order:")
for node in to_install:
version = node.resolved_version or "latest"
print(f" {node.qualified_name}@{version}")
# Install in topological order (dependencies first)
installed = 0
failed = []
installed = []
for i, dep in enumerate(manifest.dependencies, 1):
print(f"[{i}/{len(manifest.dependencies)}] {dep.name}@{dep.version}")
print()
for node in to_install:
try:
print(f"Installing {node.qualified_name}...", end=" ", flush=True)
spec = node.qualified_name
install_version = node.resolved_version or node.version_constraint
install_from_registry(spec, version=install_version)
print(f"v{node.resolved_version or 'latest'}")
installed += 1
except RegistryError as e:
print(f"FAILED: {e.message}")
failed.append((node.qualified_name, e.message))
except Exception as e:
print(f"FAILED: {e}")
failed.append((node.qualified_name, str(e)))
# Check if already installed
existing = find_tool(dep.name)
if existing:
print(f" Already installed: {existing.full_name}")
installed.append(dep.name)
# Summary
print()
print(f"Installed: {installed}/{len(to_install)}")
if failed:
print("Failed:")
for name, err in failed:
print(f" {name}: {err}")
return 1
return 0
def _install_from_lock(lock: "Lockfile", args) -> int:
"""Install exact versions from lock file."""
from ..resolver import ToolResolver, ToolNotFoundError, install_from_registry
from ..registry_client import RegistryError
verbose = getattr(args, 'verbose', False)
dry_run = getattr(args, 'dry_run', False)
force = getattr(args, 'force', False)
print("Installing from cmdforge.lock (reproducible)...")
if lock.is_stale() and not force:
print("Warning: Lock file may be outdated (manifest changed)")
print("Run 'cmdforge lock' to update, or use --force to continue")
return 1
# Check what needs installing
resolver = ToolResolver(auto_fetch=False)
resolver.manifest = None
to_install = []
for name, locked in lock.packages.items():
try:
resolved = resolver.resolve(name)
# Check if version matches
resolved_version = resolved.version or ""
locked_version = locked.version or ""
if resolved_version != locked_version:
to_install.append(locked)
except ToolNotFoundError:
to_install.append(locked)
except Exception:
to_install.append(locked)
if not to_install:
print("All locked packages already installed.")
return 0
print()
print(f"Installing {len(to_install)} package(s):")
for pkg in to_install:
print(f" {pkg.name}@{pkg.version}")
if dry_run:
return 0
# Install exact versions
installed = 0
failed = []
print()
for pkg in to_install:
if pkg.source == "local":
print(f"{pkg.name}: skipping (local)")
continue
try:
print(f" Downloading...")
resolved = install_from_registry(dep.name, dep.version)
print(f" Installed: {resolved.full_name}@{resolved.version}")
installed.append(dep.name)
print(f"Installing {pkg.name}@{pkg.version}...", end=" ", flush=True)
# Use correct API: install_from_registry(spec, version)
install_from_registry(pkg.name, version=pkg.version)
print("OK")
installed += 1
except RegistryError as e:
if e.code == "TOOL_NOT_FOUND":
print(f" Not found in registry")
elif e.code == "VERSION_NOT_FOUND" or e.code == "CONSTRAINT_UNSATISFIABLE":
print(f" Version {dep.version} not available")
elif e.code == "CONNECTION_ERROR":
print(f" Connection failed (check network)")
else:
print(f" Failed: {e.message}")
failed.append(dep.name)
print(f"FAILED: {e.message}")
failed.append((pkg.name, e.message))
except Exception as e:
print(f" Failed: {e}")
failed.append(dep.name)
print(f"FAILED: {e}")
failed.append((pkg.name, str(e)))
print()
print(f"Installed {len(installed)} tools")
print(f"Installed: {installed}/{len(to_install)}")
if failed:
print(f"Failed: {', '.join(failed)}")
print("Failed:")
for name, err in failed:
print(f" {name}: {err}")
return 1
return 0
@@ -200,3 +443,105 @@ def cmd_init(args):
print("Install them with: cmdforge install")
return 0
def cmd_lock(args):
"""Generate or update cmdforge.lock file."""
from ..lockfile import Lockfile, generate_lockfile
from ..dependency_graph import DependencyGraphBuilder
from ..resolver import ToolResolver
from ..registry_client import get_client
manifest = load_manifest()
if manifest is None:
print("No cmdforge.yaml found. Run 'cmdforge init' first.")
return 1
verbose = getattr(args, 'verbose', False)
force = getattr(args, 'force', False)
# Check for existing lock
existing_lock = Lockfile.load()
if existing_lock and not force:
if not existing_lock.is_stale():
print("Lock file is up to date. Use --force to regenerate.")
return 0
print("Lock file is stale (manifest changed), regenerating...")
# Resolve all dependencies
print("Resolving dependencies...")
resolver = ToolResolver(auto_fetch=True) # Allow fetching for resolution
builder = DependencyGraphBuilder(resolver=resolver, verbose=verbose)
try:
graph = builder.build(manifest.dependencies)
except Exception as e:
print(f"Error resolving dependencies: {e}")
return 1
if graph.has_cycles():
print("Error: Circular dependencies detected")
for cycle in graph.cycles:
print(f" {' -> '.join(cycle)}")
return 1
unresolved = graph.get_unresolved()
if unresolved and not force:
print("Error: Some dependencies could not be resolved:")
for node in unresolved:
constraint = f"@{node.version_constraint}" if node.version_constraint else ""
print(f" {node.qualified_name}{constraint}")
print("Fix missing deps or re-run with --force to lock what can be resolved.")
return 1
if unresolved and force:
print("Warning: Some dependencies could not be resolved and will be skipped:")
for node in unresolved:
constraint = f"@{node.version_constraint}" if node.version_constraint else ""
print(f" {node.qualified_name}{constraint}")
# Generate lock file
print("Generating lock file...")
client = get_client()
lock = generate_lockfile(manifest, graph, client)
lock.save()
# Summary
direct = sum(1 for p in lock.packages.values() if p.direct)
transitive = len(lock.packages) - direct
print()
print(f"Locked {len(lock.packages)} package(s):")
print(f" Direct: {direct}")
print(f" Transitive: {transitive}")
print()
print("Wrote cmdforge.lock")
return 0
def cmd_verify(args):
"""Verify installed tools match lock file."""
from ..lockfile import Lockfile, verify_lockfile
from ..registry_client import get_client
lock = Lockfile.load()
if lock is None:
print("No cmdforge.lock found. Run 'cmdforge lock' first.")
return 1
if lock.is_stale():
print("Warning: Lock file may be outdated (manifest changed)")
print()
print("Verifying lock file...")
client = get_client()
errors = verify_lockfile(lock, client)
if not errors:
print(f"All {len(lock.packages)} packages verified OK")
return 0
print()
print(f"{len(errors)} verification error(s):")
for error in errors:
print(f" {error}")
return 1