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:
@@ -12,7 +12,7 @@ from .tool_commands import (
|
||||
from .provider_commands import cmd_providers
|
||||
from .registry_commands import cmd_registry
|
||||
from .collections_commands import cmd_collections
|
||||
from .project_commands import cmd_deps, cmd_install_deps, cmd_add, cmd_init
|
||||
from .project_commands import cmd_deps, cmd_deps_tree, cmd_install_deps, cmd_add, cmd_init, cmd_lock, cmd_verify
|
||||
from .config_commands import cmd_config
|
||||
|
||||
|
||||
@@ -284,14 +284,38 @@ def main():
|
||||
# Project Commands
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# 'deps' command
|
||||
# 'deps' command with subcommands
|
||||
p_deps = subparsers.add_parser("deps", help="Show project dependencies")
|
||||
deps_sub = p_deps.add_subparsers(dest="deps_cmd", help="Deps commands")
|
||||
|
||||
# deps tree
|
||||
p_deps_tree = deps_sub.add_parser("tree", help="Show dependency tree")
|
||||
p_deps_tree.add_argument("--verbose", "-v", action="store_true", help="Show detailed info")
|
||||
p_deps_tree.set_defaults(func=cmd_deps_tree)
|
||||
|
||||
# Default for deps with no subcommand shows basic deps list
|
||||
p_deps.set_defaults(func=cmd_deps)
|
||||
|
||||
# 'install' command (for dependencies)
|
||||
p_install = subparsers.add_parser("install", help="Install dependencies from cmdforge.yaml")
|
||||
p_install.add_argument("--dry-run", action="store_true", help="Show what would be installed without installing")
|
||||
p_install.add_argument("--force", action="store_true", help="Install despite version conflicts or stale lock")
|
||||
p_install.add_argument("--verbose", "-v", action="store_true", help="Show detailed resolution info")
|
||||
p_install.add_argument("--ignore-lock", action="store_true", help="Ignore lock file and resolve fresh from manifest")
|
||||
p_install.add_argument("--frozen", action="store_true", help="Fail if lock file is missing (for CI)")
|
||||
p_install.add_argument("--strict-frozen", action="store_true", help="Fail if lock is missing or stale (stricter CI mode)")
|
||||
p_install.set_defaults(func=cmd_install_deps)
|
||||
|
||||
# 'lock' command
|
||||
p_lock = subparsers.add_parser("lock", help="Generate or update cmdforge.lock")
|
||||
p_lock.add_argument("--force", "-f", action="store_true", help="Force regenerate even if up to date")
|
||||
p_lock.add_argument("--verbose", "-v", action="store_true", help="Show detailed output")
|
||||
p_lock.set_defaults(func=cmd_lock)
|
||||
|
||||
# 'verify' command
|
||||
p_verify = subparsers.add_parser("verify", help="Verify installed tools match lock file")
|
||||
p_verify.set_defaults(func=cmd_verify)
|
||||
|
||||
# 'add' command
|
||||
p_add = subparsers.add_parser("add", help="Add a tool to project dependencies")
|
||||
p_add.add_argument("tool", help="Tool to add (owner/name)")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,371 @@
|
||||
"""Lock file for reproducible dependency installation.
|
||||
|
||||
Provides cmdforge.lock file support for:
|
||||
- Recording exact versions of all dependencies (direct + transitive)
|
||||
- Reproducible installs across machines
|
||||
- Integrity verification via content hashes
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class LockedPackage:
|
||||
"""A locked dependency with exact version and integrity."""
|
||||
name: str # Qualified: owner/name
|
||||
version: str # Exact resolved version
|
||||
constraint: str # Original constraint
|
||||
integrity: str # sha256 hash (from registry config_hash)
|
||||
source: str # "registry", "local", "global"
|
||||
direct: bool # True if in manifest
|
||||
required_by: List[str] = field(default_factory=list) # Parent packages
|
||||
path: Optional[str] = None # Relative path for local tools
|
||||
|
||||
@property
|
||||
def owner(self) -> str:
|
||||
"""Extract owner from qualified name."""
|
||||
return self.name.split("/")[0] if "/" in self.name else ""
|
||||
|
||||
@property
|
||||
def tool_name(self) -> str:
|
||||
"""Extract tool name from qualified name."""
|
||||
return self.name.split("/")[1] if "/" in self.name else self.name
|
||||
|
||||
|
||||
@dataclass
|
||||
class LockfileMetadata:
|
||||
"""Metadata about when/how the lock file was generated."""
|
||||
generated_at: str
|
||||
cmdforge_version: str
|
||||
manifest_hash: str
|
||||
platform: str
|
||||
python_version: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Lockfile:
|
||||
"""Complete lock file representation."""
|
||||
lockfile_version: int = 1
|
||||
metadata: Optional[LockfileMetadata] = None
|
||||
packages: Dict[str, LockedPackage] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path = None) -> Optional["Lockfile"]:
|
||||
"""Load lock file from disk.
|
||||
|
||||
Args:
|
||||
path: Path to lock file (default: ./cmdforge.lock)
|
||||
|
||||
Returns:
|
||||
Lockfile object, or None if not found
|
||||
"""
|
||||
path = path or Path("cmdforge.lock")
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
return cls._from_dict(data)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load lock file: {e}")
|
||||
return None
|
||||
|
||||
def save(self, path: Path = None) -> None:
|
||||
"""Save lock file to disk.
|
||||
|
||||
Args:
|
||||
path: Path to save to (default: ./cmdforge.lock)
|
||||
"""
|
||||
path = path or Path("cmdforge.lock")
|
||||
with open(path, "w") as f:
|
||||
# Add header comment
|
||||
f.write("# cmdforge.lock\n")
|
||||
f.write("# Auto-generated by 'cmdforge lock' - do not edit manually\n")
|
||||
f.write("# To update: cmdforge lock --force\n\n")
|
||||
yaml.safe_dump(
|
||||
self._to_dict(),
|
||||
f,
|
||||
sort_keys=False,
|
||||
default_flow_style=False
|
||||
)
|
||||
|
||||
def get_package(self, name: str) -> Optional[LockedPackage]:
|
||||
"""Get a locked package by qualified name."""
|
||||
return self.packages.get(name)
|
||||
|
||||
def is_stale(self, manifest_path: Path = None) -> bool:
|
||||
"""Check if lock file is outdated relative to manifest.
|
||||
|
||||
Note: This only checks if the manifest hash changed.
|
||||
It does NOT detect if local tools were edited after locking.
|
||||
Use verify_lockfile() for full integrity checking.
|
||||
|
||||
Args:
|
||||
manifest_path: Path to manifest (default: ./cmdforge.yaml)
|
||||
|
||||
Returns:
|
||||
True if lock file is stale
|
||||
"""
|
||||
manifest_path = manifest_path or Path("cmdforge.yaml")
|
||||
if not manifest_path.exists():
|
||||
return True
|
||||
if not self.metadata:
|
||||
return True
|
||||
current_hash = compute_file_hash(manifest_path)
|
||||
return current_hash != self.metadata.manifest_hash
|
||||
|
||||
@classmethod
|
||||
def _from_dict(cls, data: dict) -> "Lockfile":
|
||||
"""Parse lock file from dict."""
|
||||
metadata = None
|
||||
if "metadata" in data:
|
||||
m = data["metadata"]
|
||||
metadata = LockfileMetadata(
|
||||
generated_at=m.get("generated_at", ""),
|
||||
cmdforge_version=m.get("cmdforge_version", ""),
|
||||
manifest_hash=m.get("manifest_hash", ""),
|
||||
platform=m.get("platform", ""),
|
||||
python_version=m.get("python_version", "")
|
||||
)
|
||||
|
||||
packages = {}
|
||||
for name, pkg_data in data.get("packages", {}).items():
|
||||
packages[name] = LockedPackage(
|
||||
name=name,
|
||||
version=pkg_data.get("version", ""),
|
||||
constraint=pkg_data.get("constraint", "*"),
|
||||
integrity=pkg_data.get("integrity", ""),
|
||||
source=pkg_data.get("source", "registry"),
|
||||
direct=pkg_data.get("direct", False),
|
||||
required_by=pkg_data.get("required_by", []),
|
||||
path=pkg_data.get("path")
|
||||
)
|
||||
|
||||
return cls(
|
||||
lockfile_version=data.get("lockfile_version", 1),
|
||||
metadata=metadata,
|
||||
packages=packages
|
||||
)
|
||||
|
||||
def _to_dict(self) -> dict:
|
||||
"""Convert to dict for YAML serialization."""
|
||||
d = {"lockfile_version": self.lockfile_version}
|
||||
|
||||
if self.metadata:
|
||||
d["metadata"] = {
|
||||
"generated_at": self.metadata.generated_at,
|
||||
"cmdforge_version": self.metadata.cmdforge_version,
|
||||
"manifest_hash": self.metadata.manifest_hash,
|
||||
"platform": self.metadata.platform,
|
||||
"python_version": self.metadata.python_version
|
||||
}
|
||||
|
||||
d["packages"] = {}
|
||||
for name, pkg in self.packages.items():
|
||||
pkg_dict = {
|
||||
"version": pkg.version,
|
||||
"constraint": pkg.constraint,
|
||||
"integrity": pkg.integrity,
|
||||
"source": pkg.source,
|
||||
"direct": pkg.direct
|
||||
}
|
||||
if pkg.required_by:
|
||||
pkg_dict["required_by"] = pkg.required_by
|
||||
if pkg.path:
|
||||
pkg_dict["path"] = pkg.path
|
||||
d["packages"][name] = pkg_dict
|
||||
|
||||
return d
|
||||
|
||||
|
||||
def compute_file_hash(path: Path) -> str:
|
||||
"""Compute SHA256 hash of raw file bytes.
|
||||
|
||||
Used ONLY for manifest hash (to detect any change, including formatting).
|
||||
For tool config integrity, use hash_utils.compute_yaml_hash() instead.
|
||||
|
||||
Args:
|
||||
path: Path to file
|
||||
|
||||
Returns:
|
||||
Hash string in format "sha256:<64-char-hex>"
|
||||
"""
|
||||
sha256 = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
sha256.update(chunk)
|
||||
return f"sha256:{sha256.hexdigest()}"
|
||||
|
||||
|
||||
def generate_lockfile(
|
||||
manifest: "Manifest",
|
||||
graph: "DependencyGraph",
|
||||
client: "RegistryClient"
|
||||
) -> Lockfile:
|
||||
"""
|
||||
Generate a lock file from resolved dependencies.
|
||||
|
||||
Args:
|
||||
manifest: Project manifest with constraints
|
||||
graph: Resolved dependency graph (from DependencyGraphBuilder)
|
||||
client: Registry client for fetching config_hash
|
||||
|
||||
Returns:
|
||||
Complete Lockfile ready to save
|
||||
"""
|
||||
import platform
|
||||
import sys
|
||||
from . import __version__
|
||||
|
||||
# Compute manifest hash
|
||||
manifest_path = Path("cmdforge.yaml")
|
||||
manifest_hash = compute_file_hash(manifest_path) if manifest_path.exists() else ""
|
||||
|
||||
metadata = LockfileMetadata(
|
||||
generated_at=datetime.now().astimezone().isoformat(),
|
||||
cmdforge_version=__version__,
|
||||
manifest_hash=manifest_hash,
|
||||
platform=platform.system().lower(),
|
||||
python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
||||
)
|
||||
|
||||
lock = Lockfile(metadata=metadata)
|
||||
|
||||
# Get direct dependency names for marking
|
||||
direct_names = {d.name for d in manifest.dependencies}
|
||||
|
||||
# Process all nodes in the graph
|
||||
for qualified_name, node in graph.nodes.items():
|
||||
if not node.is_resolved:
|
||||
continue
|
||||
integrity = _get_integrity_hash(node, client)
|
||||
|
||||
pkg = LockedPackage(
|
||||
name=qualified_name,
|
||||
version=node.resolved_version or "",
|
||||
constraint=node.version_constraint or "*",
|
||||
integrity=integrity,
|
||||
source=node.source or "registry",
|
||||
direct=qualified_name in direct_names or _is_direct(qualified_name, direct_names),
|
||||
required_by=_find_parents(qualified_name, graph),
|
||||
path=str(node.path) if node.source == "local" and node.path else None
|
||||
)
|
||||
lock.packages[qualified_name] = pkg
|
||||
|
||||
return lock
|
||||
|
||||
|
||||
def _get_integrity_hash(node: "DependencyNode", client: "RegistryClient") -> str:
|
||||
"""Get integrity hash for a dependency node."""
|
||||
if node.source in ("local", "global") and node.path:
|
||||
# For local/global tools, hash normalized config
|
||||
config_path = node.path / "config.yaml"
|
||||
if config_path.exists():
|
||||
from .hash_utils import compute_yaml_hash
|
||||
return compute_yaml_hash(config_path.read_text())
|
||||
return ""
|
||||
|
||||
elif node.source == "registry":
|
||||
# For registry tools, get the config_hash from registry
|
||||
# This is the hash BEFORE any local modifications
|
||||
try:
|
||||
result = client.download_tool(
|
||||
node.owner,
|
||||
node.name,
|
||||
version=node.resolved_version,
|
||||
install=False # Don't count as install, just get config
|
||||
)
|
||||
# Use the config_hash from registry response
|
||||
if hasattr(result, 'config_hash') and result.config_hash:
|
||||
# config_hash already includes "sha256:" prefix
|
||||
return result.config_hash
|
||||
# Fallback: hash the config_yaml content using normalized hashing
|
||||
if hasattr(result, 'config_yaml') and result.config_yaml:
|
||||
from .hash_utils import compute_yaml_hash
|
||||
return compute_yaml_hash(result.config_yaml)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _is_direct(qualified_name: str, direct_names: set) -> bool:
|
||||
"""Check if a qualified name matches any direct dependency."""
|
||||
# Handle case where manifest has unqualified names
|
||||
tool_name = qualified_name.split("/")[1] if "/" in qualified_name else qualified_name
|
||||
return qualified_name in direct_names or tool_name in direct_names
|
||||
|
||||
|
||||
def _find_parents(qualified_name: str, graph: "DependencyGraph") -> List[str]:
|
||||
"""Find packages that depend on this one."""
|
||||
parents = []
|
||||
for name, node in graph.nodes.items():
|
||||
if qualified_name in node.children:
|
||||
parents.append(name)
|
||||
return parents
|
||||
|
||||
|
||||
def verify_lockfile(
|
||||
lock: Lockfile,
|
||||
client: "RegistryClient"
|
||||
) -> List[str]:
|
||||
"""
|
||||
Verify installed tools match lock file.
|
||||
|
||||
Args:
|
||||
lock: Lock file to verify against
|
||||
client: Registry client for hash verification
|
||||
|
||||
Returns:
|
||||
List of verification errors (empty if all OK)
|
||||
"""
|
||||
from .resolver import ToolResolver, ToolNotFoundError
|
||||
|
||||
errors = []
|
||||
# Create resolver that doesn't use manifest (bypass version overrides)
|
||||
resolver = ToolResolver(auto_fetch=False)
|
||||
resolver.manifest = None
|
||||
|
||||
for name, locked in lock.packages.items():
|
||||
# Check tool exists
|
||||
try:
|
||||
resolved = resolver.resolve(name)
|
||||
except ToolNotFoundError:
|
||||
errors.append(f"{name}: not installed")
|
||||
continue
|
||||
except Exception:
|
||||
errors.append(f"{name}: not installed")
|
||||
continue
|
||||
|
||||
# Check version matches
|
||||
resolved_version = resolved.version or ""
|
||||
locked_version = locked.version or ""
|
||||
if resolved_version and locked_version and resolved_version != locked_version:
|
||||
errors.append(
|
||||
f"{name}: version mismatch "
|
||||
f"(installed: {resolved_version}, locked: {locked_version})"
|
||||
)
|
||||
|
||||
# Check integrity against installed tool config
|
||||
if locked.integrity and resolved.path:
|
||||
try:
|
||||
config_path = resolved.path / "config.yaml"
|
||||
if config_path.exists():
|
||||
from .hash_utils import compute_yaml_hash
|
||||
current_hash = compute_yaml_hash(config_path.read_text())
|
||||
if current_hash != locked.integrity:
|
||||
errors.append(
|
||||
f"{name}: integrity mismatch "
|
||||
f"(installed tool differs from lock)"
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append(f"{name}: could not verify integrity ({e})")
|
||||
|
||||
return errors
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Semantic version constraint matching.
|
||||
|
||||
Provides version parsing and constraint matching for dependency resolution.
|
||||
Follows semver 2.0.0 specification with support for common constraint operators.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Tuple, List
|
||||
|
||||
VERSION_PATTERN = re.compile(
|
||||
r'^(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)'
|
||||
r'(?:-(?P<prerelease>[0-9A-Za-z.-]+))?'
|
||||
r'(?:\+(?P<build>[0-9A-Za-z.-]+))?$'
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Version:
|
||||
"""Semantic version representation."""
|
||||
major: int
|
||||
minor: int
|
||||
patch: int
|
||||
prerelease: Optional[str] = None
|
||||
build: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def parse(cls, version_str: str) -> Optional["Version"]:
|
||||
"""Parse a version string into a Version object.
|
||||
|
||||
Args:
|
||||
version_str: Version string like "1.2.3" or "1.0.0-alpha.1+build.123"
|
||||
|
||||
Returns:
|
||||
Version object, or None if parsing fails
|
||||
"""
|
||||
if not version_str:
|
||||
return None
|
||||
match = VERSION_PATTERN.match(version_str.strip())
|
||||
if not match:
|
||||
return None
|
||||
return cls(
|
||||
major=int(match.group("major")),
|
||||
minor=int(match.group("minor")),
|
||||
patch=int(match.group("patch")),
|
||||
prerelease=match.group("prerelease"),
|
||||
build=match.group("build")
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
s = f"{self.major}.{self.minor}.{self.patch}"
|
||||
if self.prerelease:
|
||||
s += f"-{self.prerelease}"
|
||||
if self.build:
|
||||
s += f"+{self.build}"
|
||||
return s
|
||||
|
||||
@property
|
||||
def tuple(self) -> Tuple[int, int, int]:
|
||||
"""Get (major, minor, patch) tuple for comparison."""
|
||||
return (self.major, self.minor, self.patch)
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, Version):
|
||||
return False
|
||||
return self.tuple == other.tuple and self.prerelease == other.prerelease
|
||||
|
||||
def __lt__(self, other: "Version") -> bool:
|
||||
if self.tuple != other.tuple:
|
||||
return self.tuple < other.tuple
|
||||
# Prerelease versions are less than release versions
|
||||
# No prerelease = release version
|
||||
if self.prerelease is None and other.prerelease is not None:
|
||||
return False # 1.0.0 > 1.0.0-alpha
|
||||
if self.prerelease is not None and other.prerelease is None:
|
||||
return True # 1.0.0-alpha < 1.0.0
|
||||
# Both have prerelease, compare lexicographically
|
||||
return (self.prerelease or "") < (other.prerelease or "")
|
||||
|
||||
def __le__(self, other: "Version") -> bool:
|
||||
return self == other or self < other
|
||||
|
||||
def __gt__(self, other: "Version") -> bool:
|
||||
return not self <= other
|
||||
|
||||
def __ge__(self, other: "Version") -> bool:
|
||||
return not self < other
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((self.tuple, self.prerelease))
|
||||
|
||||
|
||||
def matches_constraint(version: str, constraint: str) -> bool:
|
||||
"""
|
||||
Check if a version satisfies a constraint.
|
||||
|
||||
Supported constraints:
|
||||
- "*" or "latest" or "": any version
|
||||
- "1.0.0": exact match
|
||||
- "^1.2.3": compatible (>=1.2.3 and <2.0.0)
|
||||
- "^0.2.3": for 0.x, means >=0.2.3 and <0.3.0 (semver special case)
|
||||
- "^0.0.3": for 0.0.x, means exactly 0.0.3 (semver special case)
|
||||
- "~1.2.3": approximately (>=1.2.3 and <1.3.0)
|
||||
- ">=1.0.0", "<=1.0.0", ">1.0.0", "<1.0.0": comparisons
|
||||
- "=1.0.0": explicit exact match
|
||||
|
||||
Args:
|
||||
version: Version string to check (e.g., "1.2.3")
|
||||
constraint: Constraint string (e.g., "^1.0.0")
|
||||
|
||||
Returns:
|
||||
True if version satisfies constraint
|
||||
"""
|
||||
if not constraint or constraint in ("*", "latest"):
|
||||
return True
|
||||
|
||||
v = Version.parse(version)
|
||||
if not v:
|
||||
return False
|
||||
|
||||
constraint = constraint.strip()
|
||||
|
||||
# Standard semver behavior: prerelease versions do NOT satisfy ranges
|
||||
# unless the constraint itself includes a prerelease.
|
||||
if v.prerelease and "-" not in constraint:
|
||||
return False
|
||||
|
||||
# Caret: ^1.2.3 means >=1.2.3 and <2.0.0
|
||||
# Special cases:
|
||||
# ^0.2.3 -> >=0.2.3 and <0.3.0
|
||||
# ^0.0.3 -> >=0.0.3 and <0.0.4 (exact match for 0.0.x series)
|
||||
if constraint.startswith("^"):
|
||||
c = Version.parse(constraint[1:])
|
||||
if not c:
|
||||
return False
|
||||
|
||||
# Must be >= constraint version
|
||||
if v < c:
|
||||
return False
|
||||
|
||||
# Upper bound depends on major version
|
||||
if c.major == 0:
|
||||
if c.minor == 0:
|
||||
# ^0.0.y allows ONLY 0.0.y (exact match for 0.0.x series)
|
||||
return v.major == 0 and v.minor == 0 and v.patch == c.patch
|
||||
# ^0.x.y allows >=0.x.y and <0.(x+1).0
|
||||
return v.major == 0 and v.minor == c.minor
|
||||
else:
|
||||
# ^x.y.z allows changes that don't modify major: <(x+1).0.0
|
||||
return v.major == c.major
|
||||
|
||||
# Tilde: ~1.2.3 means >=1.2.3 and <1.3.0
|
||||
if constraint.startswith("~"):
|
||||
c = Version.parse(constraint[1:])
|
||||
if not c:
|
||||
return False
|
||||
|
||||
# Must be >= constraint version
|
||||
if v < c:
|
||||
return False
|
||||
|
||||
# Must be same major.minor
|
||||
return v.major == c.major and v.minor == c.minor
|
||||
|
||||
# Comparison operators (check longest first)
|
||||
for op in (">=", "<=", ">", "<", "="):
|
||||
if constraint.startswith(op):
|
||||
c = Version.parse(constraint[len(op):].strip())
|
||||
if not c:
|
||||
return False
|
||||
if op == ">=":
|
||||
return v >= c
|
||||
elif op == "<=":
|
||||
return v <= c
|
||||
elif op == ">":
|
||||
return v > c
|
||||
elif op == "<":
|
||||
return v < c
|
||||
elif op == "=":
|
||||
return v == c
|
||||
|
||||
# Exact match (no operator)
|
||||
c = Version.parse(constraint)
|
||||
if c:
|
||||
return v == c
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def find_best_match(versions: List[str], constraint: str) -> Optional[str]:
|
||||
"""
|
||||
Find the best (highest) version that matches a constraint.
|
||||
|
||||
Args:
|
||||
versions: List of available versions
|
||||
constraint: Version constraint
|
||||
|
||||
Returns:
|
||||
Best matching version, or None if no match
|
||||
"""
|
||||
matching = [v for v in versions if matches_constraint(v, constraint)]
|
||||
if not matching:
|
||||
return None
|
||||
|
||||
# Sort by parsed version, return highest
|
||||
parsed = [(Version.parse(v), v) for v in matching]
|
||||
parsed = [(p, v) for p, v in parsed if p is not None]
|
||||
if not parsed:
|
||||
return None
|
||||
|
||||
parsed.sort(key=lambda x: x[0], reverse=True)
|
||||
return parsed[0][1]
|
||||
|
||||
|
||||
def is_valid_version(version: str) -> bool:
|
||||
"""Check if a string is a valid semver version.
|
||||
|
||||
Args:
|
||||
version: Version string to validate
|
||||
|
||||
Returns:
|
||||
True if valid semver format
|
||||
"""
|
||||
return Version.parse(version) is not None
|
||||
|
||||
|
||||
def compare_versions(v1: str, v2: str) -> int:
|
||||
"""Compare two version strings.
|
||||
|
||||
Args:
|
||||
v1: First version
|
||||
v2: Second version
|
||||
|
||||
Returns:
|
||||
-1 if v1 < v2, 0 if equal, 1 if v1 > v2
|
||||
|
||||
Raises:
|
||||
ValueError: If either version is invalid
|
||||
"""
|
||||
parsed_v1 = Version.parse(v1)
|
||||
parsed_v2 = Version.parse(v2)
|
||||
|
||||
if not parsed_v1 or not parsed_v2:
|
||||
raise ValueError(f"Invalid version: {v1 if not parsed_v1 else v2}")
|
||||
|
||||
if parsed_v1 < parsed_v2:
|
||||
return -1
|
||||
elif parsed_v1 > parsed_v2:
|
||||
return 1
|
||||
return 0
|
||||
Reference in New Issue
Block a user