agpm_cli/resolver/
transitive_resolver.rs

1//! Transitive dependency resolution for AGPM.
2//!
3//! This module handles the discovery and resolution of transitive dependencies,
4//! building dependency graphs, detecting cycles, and providing high-level
5//! orchestration for the entire transitive resolution process. It processes
6//! dependencies declared within resource files and resolves them in topological order.
7//!
8//! ## Parallel Processing Algorithm
9//!
10//! Transitive dependencies are resolved in parallel batches:
11//! 1. Calculate batch size: min(max(10, CPU cores × 2), remaining queue length)
12//! 2. Extract batch from queue (LIFO order to match serial behavior)
13//! 3. Process batch concurrently using join_all
14//! 4. Repeat until queue empty
15//!
16//! Concurrent safety is ensured via `Arc<DashMap>` for shared state.
17//! Each batch processes dependencies independently, with coordination
18//! happening through the shared DashMap-backed registries.
19
20use std::collections::HashSet;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23use std::sync::atomic::{AtomicUsize, Ordering};
24
25use anyhow::{Context, Result};
26use dashmap::DashMap;
27use futures::future::join_all;
28use tokio::sync::{Mutex, MutexGuard};
29
30use crate::core::ResourceType;
31use crate::lockfile::lockfile_dependency_ref::LockfileDependencyRef;
32use crate::manifest::{DetailedDependency, ResourceDependency};
33use crate::metadata::MetadataExtractor;
34use crate::utils;
35use crate::version::conflict::ConflictDetector;
36use crate::version::constraints::VersionConstraint;
37
38use super::dependency_graph::{DependencyGraph, DependencyNode};
39use super::pattern_expander::generate_dependency_name;
40use super::types::{DependencyKey, TransitiveContext, apply_manifest_override};
41use super::version_resolver::{PreparedSourceVersion, VersionResolutionService};
42use super::{PatternExpansionService, ResourceFetchingService, is_file_relative_path};
43
44use crate::constants::{batch_operation_timeout, default_lock_timeout};
45
46/// Acquire a tokio Mutex with timeout and diagnostic dump on failure.
47///
48/// This prevents deadlocks from hanging indefinitely by timing out and
49/// dumping lock state for debugging. Uses the test-mode-aware timeout
50/// from constants (8s in test mode, 30s in production).
51async fn acquire_mutex_with_timeout<'a, T>(
52    mutex: &'a Mutex<T>,
53    name: &str,
54) -> Result<MutexGuard<'a, T>> {
55    let timeout = default_lock_timeout();
56    match tokio::time::timeout(timeout, mutex.lock()).await {
57        Ok(guard) => Ok(guard),
58        Err(_) => {
59            eprintln!("[DEADLOCK] Timeout waiting for mutex '{}' after {:?}", name, timeout);
60            anyhow::bail!(
61                "Timeout waiting for Mutex '{}' after {:?} - possible deadlock",
62                name,
63                timeout
64            )
65        }
66    }
67}
68
69/// Check if a version string represents a semver constraint.
70///
71/// Returns `true` for semver types (Exact, Requirement) and `false` for GitRef.
72/// This is used to prefer semver versions over floating refs like "main" when
73/// resolving duplicate transitive dependencies.
74fn is_semver_version(version: Option<&str>) -> bool {
75    match version {
76        Some(v) => VersionConstraint::parse(v).is_ok_and(|c| c.is_semver()),
77        None => false,
78    }
79}
80
81/// Determine if a new dependency should replace an existing one based on version preference.
82///
83/// Semver versions (e.g., "v1.0.0", "^1.0.0") are preferred over git refs (e.g., "main").
84/// This ensures stable, reproducible builds by choosing explicit version constraints
85/// over floating branch refs.
86///
87/// Returns `true` if the new dependency should replace the existing one.
88fn should_replace_existing(existing: &ResourceDependency, new: &ResourceDependency) -> bool {
89    let existing_is_semver = is_semver_version(existing.get_version());
90    let new_is_semver = is_semver_version(new.get_version());
91
92    // Only replace if:
93    // 1. New is semver AND existing is NOT semver (semver wins over git refs)
94    // 2. This handles the case where A depends on C@main and B depends on C@v1.0.0
95    if new_is_semver && !existing_is_semver {
96        tracing::debug!(
97            "Preferring semver '{}' over git ref '{}'",
98            new.get_version().unwrap_or("none"),
99            existing.get_version().unwrap_or("none")
100        );
101        return true;
102    }
103
104    false
105}
106
107/// Container for resolution services to reduce parameter count.
108pub struct ResolutionServices<'a> {
109    /// Service for version resolution and commit SHA lookup
110    pub version_service: &'a VersionResolutionService,
111    /// Service for pattern expansion (glob patterns)
112    pub pattern_service: &'a PatternExpansionService,
113}
114
115/// Parameters for transitive resolution to reduce function argument count.
116pub struct TransitiveResolutionParams<'a> {
117    /// Core resolution context
118    pub ctx: &'a mut TransitiveContext<'a>,
119    /// Core resolution services
120    pub core: &'a super::ResolutionCore,
121    /// Base dependencies to resolve
122    pub base_deps: &'a [(String, ResourceDependency, ResourceType)],
123    /// Whether transitive resolution is enabled
124    pub enable_transitive: bool,
125    /// Pre-prepared source versions for resolution (concurrent)
126    pub prepared_versions: &'a Arc<DashMap<String, PreparedSourceVersion>>,
127    /// Map for pattern aliases (concurrent)
128    pub pattern_alias_map: &'a Arc<DashMap<(ResourceType, String), String>>,
129    /// Resolution services
130    pub services: &'a ResolutionServices<'a>,
131    /// Optional progress tracking
132    pub progress: Option<std::sync::Arc<crate::utils::MultiPhaseProgress>>,
133}
134
135/// Parameters for processing a transitive dependency specification.
136/// This struct reduces cognitive load by grouping related parameters
137/// and makes the function signature more maintainable.
138struct TransitiveDepProcessingParams<'a> {
139    /// The transitive resolution context
140    ctx: &'a TransitiveContext<'a>,
141    /// The core resolution services
142    core: &'a super::ResolutionCore,
143    /// The parent dependency
144    parent_dep: &'a ResourceDependency,
145    /// The resource type of the dependency
146    dep_resource_type: ResourceType,
147    /// The resource type of the parent
148    parent_resource_type: ResourceType,
149    /// The name of the parent resource
150    parent_name: &'a str,
151    /// The dependency specification
152    dep_spec: &'a crate::manifest::DependencySpec,
153    /// The version resolution service
154    version_service: &'a VersionResolutionService,
155    /// Pre-prepared source versions for resolution (concurrent)
156    prepared_versions: &'a Arc<DashMap<String, PreparedSourceVersion>>,
157}
158
159/// Context for processing a single transitive dependency.
160///
161/// Bundles shared state and context to reduce parameter count from 17 to 1,
162/// making the function more maintainable and easier to understand.
163/// This context groups related parameters into logical sections:
164/// - Input: The specific dependency being processed
165/// - Shared: Concurrent state shared across all parallel workers
166/// - Resolution: Core resolution services and context
167/// - Progress: Optional UI progress tracking
168struct TransitiveProcessingContext<'a> {
169    /// Input data for this specific dependency
170    input: TransitiveInput,
171
172    /// Shared concurrent state for processing
173    shared: TransitiveSharedState<'a>,
174
175    /// Resolution context and services
176    resolution: TransitiveResolutionContext<'a>,
177
178    /// Optional progress tracking
179    progress: Option<Arc<utils::MultiPhaseProgress>>,
180}
181
182/// Input data for processing a single dependency.
183///
184/// Contains the specific dependency information that varies for each
185/// function call: name, dependency spec, resource type, and variant hash.
186#[derive(Debug, Clone)]
187struct TransitiveInput {
188    name: String,
189    dep: ResourceDependency,
190    resource_type: ResourceType,
191    variant_hash: String,
192}
193
194/// Shared concurrent state used during processing.
195///
196/// Type alias for the queue entry tuple to reduce type complexity
197type QueueEntry = (String, ResourceDependency, Option<ResourceType>, String);
198
199/// Contains all the Arc-wrapped and shared state structures that need
200/// to be accessed concurrently by multiple workers processing dependencies in parallel.
201/// These are the data structures that were previously passed as individual parameters.
202///
203/// # Concurrency Design
204///
205/// Uses `tokio::sync::Mutex` for queue and graph to avoid blocking the async runtime.
206/// Uses `AtomicUsize` for queue_len to enable lock-free progress tracking.
207struct TransitiveSharedState<'a> {
208    graph: Arc<tokio::sync::Mutex<DependencyGraph>>,
209    all_deps: Arc<DashMap<DependencyKey, ResourceDependency>>,
210    processed: Arc<DashMap<DependencyKey, ()>>,
211    queue: Arc<tokio::sync::Mutex<Vec<QueueEntry>>>,
212    /// Atomic counter for queue length to avoid lock contention during progress updates.
213    /// Updated whenever items are added to/removed from the queue.
214    queue_len: Arc<AtomicUsize>,
215    pattern_alias_map: Arc<DashMap<(ResourceType, String), String>>,
216    completed_counter: Arc<AtomicUsize>,
217    dependency_map: &'a Arc<DashMap<DependencyKey, Vec<String>>>,
218    custom_names: &'a Arc<DashMap<DependencyKey, String>>,
219    prepared_versions: &'a Arc<DashMap<String, PreparedSourceVersion>>,
220}
221
222/// Resolution context and services.
223///
224/// Bundles the core resolution context, manifest overrides, core services,
225/// and resolution services that are needed for processing transitive dependencies.
226/// These are the context references that were previously passed as individual parameters.
227struct TransitiveResolutionContext<'a> {
228    ctx_base: &'a super::types::ResolutionContext<'a>,
229    manifest_overrides: &'a super::types::ManifestOverrideIndex,
230    core: &'a super::ResolutionCore,
231    services: &'a ResolutionServices<'a>,
232}
233
234/// Process a single transitive dependency specification.
235async fn process_transitive_dependency_spec(
236    params: TransitiveDepProcessingParams<'_>,
237) -> Result<(ResourceDependency, String)> {
238    // Get the canonical path to the parent resource file
239    let parent_file_path = ResourceFetchingService::get_canonical_path(
240        params.core,
241        params.parent_dep,
242        params.version_service,
243    )
244    .await
245    .with_context(|| {
246        format!("Failed to get parent path for transitive dependencies of '{}'", params.parent_name)
247    })?;
248
249    // Resolve the transitive dependency path
250    let trans_canonical =
251        resolve_transitive_path(&parent_file_path, &params.dep_spec.path, params.parent_name)?;
252
253    // Create the transitive dependency
254    let trans_dep = create_transitive_dependency(
255        params.ctx,
256        params.parent_dep,
257        params.dep_resource_type,
258        params.parent_resource_type,
259        params.parent_name,
260        params.dep_spec,
261        &parent_file_path,
262        &trans_canonical,
263        params.prepared_versions,
264    )
265    .await?;
266
267    // Generate a name for the transitive dependency using source context
268    let trans_name = if trans_dep.get_source().is_none() {
269        // Local dependency - use manifest directory as source context
270        // Use trans_dep.get_path() which is already relative to manifest directory
271        // (computed in create_path_only_transitive_dep)
272        let manifest_dir = params
273            .ctx
274            .base
275            .manifest
276            .manifest_dir
277            .as_ref()
278            .ok_or_else(|| anyhow::anyhow!("Manifest directory not available"))?;
279
280        let source_context = crate::resolver::source_context::SourceContext::local(manifest_dir);
281        generate_dependency_name(trans_dep.get_path(), &source_context)
282    } else {
283        // Git dependency - use remote source context
284        let source_name = trans_dep
285            .get_source()
286            .ok_or_else(|| anyhow::anyhow!("Git dependency missing source name"))?;
287        let source_context = crate::resolver::source_context::SourceContext::remote(source_name);
288        generate_dependency_name(trans_dep.get_path(), &source_context)
289    };
290
291    Ok((trans_dep, trans_name))
292}
293
294/// Resolve a transitive dependency path relative to its parent.
295fn resolve_transitive_path(
296    parent_file_path: &Path,
297    dep_path: &str,
298    parent_name: &str,
299) -> Result<PathBuf> {
300    // Check if this is a glob pattern
301    let is_pattern = dep_path.contains('*') || dep_path.contains('?') || dep_path.contains('[');
302
303    if is_pattern {
304        // For patterns, normalize (resolve .. and .) but don't canonicalize
305        let parent_dir = parent_file_path.parent().ok_or_else(|| {
306            anyhow::anyhow!(
307                "Failed to resolve transitive dependency '{}' for '{}': parent file has no directory",
308                dep_path,
309                parent_name
310            )
311        })?;
312        let resolved = parent_dir.join(dep_path);
313
314        // Preserve the root component when normalizing
315        let mut result = PathBuf::new();
316        for component in resolved.components() {
317            match component {
318                std::path::Component::RootDir => result.push(component),
319                std::path::Component::ParentDir => {
320                    result.pop();
321                }
322                std::path::Component::CurDir => {}
323                _ => result.push(component),
324            }
325        }
326        Ok(result)
327    } else if is_file_relative_path(dep_path) || !dep_path.contains('/') {
328        // File-relative path (starts with ./ or ../) or bare filename
329        // For bare filenames, treat as file-relative by resolving from parent directory
330        let parent_dir = parent_file_path.parent().ok_or_else(|| {
331            anyhow::anyhow!(
332                "Failed to resolve transitive dependency '{}' for '{}': parent file has no directory",
333                dep_path,
334                parent_name
335            )
336        })?;
337
338        let resolved = parent_dir.join(dep_path);
339        resolved.canonicalize().map_err(|e| {
340            // Create a FileOperationError for canonicalization failures
341            let file_error = crate::core::file_error::FileOperationError::new(
342                crate::core::file_error::FileOperationContext::new(
343                    crate::core::file_error::FileOperation::Canonicalize,
344                    &resolved,
345                    format!("resolving transitive dependency '{}' for '{}'", dep_path, parent_name),
346                    "transitive_resolver::resolve_transitive_path",
347                ),
348                e,
349            );
350            anyhow::Error::from(file_error)
351        })
352    } else {
353        // Repo-relative path
354        resolve_repo_relative_path(parent_file_path, dep_path, parent_name)
355    }
356}
357
358/// Resolve a repository-relative transitive dependency path.
359fn resolve_repo_relative_path(
360    parent_file_path: &Path,
361    dep_path: &str,
362    parent_name: &str,
363) -> Result<PathBuf> {
364    // For Git sources, find the worktree root; for local sources, find the source root
365    let repo_root = parent_file_path
366        .ancestors()
367        .find(|p| {
368            // Git worktrees have a .git file (not directory) pointing to the bare repo
369            // This is more robust than checking for underscores in the directory name
370            let git_path = p.join(".git");
371            git_path.is_file()
372        })
373        .or_else(|| parent_file_path.ancestors().nth(2)) // Fallback for local sources
374        .ok_or_else(|| {
375            anyhow::anyhow!(
376                "Failed to find repository root for transitive dependency '{}'",
377                dep_path
378            )
379        })?;
380
381    let full_path = repo_root.join(dep_path);
382    full_path.canonicalize().with_context(|| {
383        format!(
384            "Failed to resolve repo-relative transitive dependency '{}' for '{}': {} (repo root: {})",
385            dep_path,
386            parent_name,
387            full_path.display(),
388            repo_root.display()
389        )
390    })
391}
392
393/// Create a ResourceDependency for a transitive dependency.
394#[allow(clippy::too_many_arguments)]
395async fn create_transitive_dependency(
396    ctx: &TransitiveContext<'_>,
397    parent_dep: &ResourceDependency,
398    dep_resource_type: ResourceType,
399    parent_resource_type: ResourceType,
400    _parent_name: &str,
401    dep_spec: &crate::manifest::DependencySpec,
402    parent_file_path: &Path,
403    trans_canonical: &Path,
404    prepared_versions: &Arc<DashMap<String, PreparedSourceVersion>>,
405) -> Result<ResourceDependency> {
406    use super::types::{OverrideKey, normalize_lookup_path};
407
408    // Create the dependency as before
409    let mut dep = if parent_dep.get_source().is_none() {
410        create_path_only_transitive_dep(
411            ctx,
412            parent_dep,
413            dep_resource_type,
414            parent_resource_type,
415            dep_spec,
416            trans_canonical,
417        )?
418    } else {
419        create_git_backed_transitive_dep(
420            ctx,
421            parent_dep,
422            dep_resource_type,
423            parent_resource_type,
424            dep_spec,
425            parent_file_path,
426            trans_canonical,
427            prepared_versions,
428        )
429        .await?
430    };
431
432    // Check for manifest override
433    let normalized_path = normalize_lookup_path(dep.get_path());
434    let source = dep.get_source().map(std::string::ToString::to_string);
435
436    // Determine tool for the dependency
437    let tool = dep
438        .get_tool()
439        .map(str::to_string)
440        .unwrap_or_else(|| ctx.base.manifest.get_default_tool(dep_resource_type));
441
442    let variant_hash =
443        super::lockfile_builder::compute_merged_variant_hash(ctx.base.manifest, &dep);
444
445    let override_key = OverrideKey {
446        resource_type: dep_resource_type,
447        normalized_path: normalized_path.clone(),
448        source,
449        tool,
450        variant_hash,
451    };
452
453    // Apply manifest override if found
454    if let Some(override_info) = ctx.manifest_overrides.get(&override_key) {
455        apply_manifest_override(&mut dep, override_info, &normalized_path);
456    }
457
458    Ok(dep)
459}
460
461/// Create a path-only transitive dependency (parent is path-only).
462fn create_path_only_transitive_dep(
463    ctx: &TransitiveContext<'_>,
464    parent_dep: &ResourceDependency,
465    dep_resource_type: ResourceType,
466    parent_resource_type: ResourceType,
467    dep_spec: &crate::manifest::DependencySpec,
468    trans_canonical: &Path,
469) -> Result<ResourceDependency> {
470    let manifest_dir = ctx.base.manifest.manifest_dir.as_ref().ok_or_else(|| {
471        anyhow::anyhow!("Manifest directory not available for path-only transitive dep")
472    })?;
473
474    // Always compute relative path from manifest to target
475    let dep_path_str = match manifest_dir.canonicalize() {
476        Ok(canonical_manifest) => {
477            utils::compute_relative_path(&canonical_manifest, trans_canonical)
478        }
479        Err(e) => {
480            eprintln!(
481                "Warning: Could not canonicalize manifest directory {}: {}. Using non-canonical path.",
482                manifest_dir.display(),
483                e
484            );
485            utils::compute_relative_path(manifest_dir, trans_canonical)
486        }
487    };
488
489    // Determine tool for transitive dependency
490    let trans_tool = determine_transitive_tool(
491        ctx,
492        parent_dep,
493        dep_spec,
494        parent_resource_type,
495        dep_resource_type,
496    );
497
498    Ok(ResourceDependency::Detailed(Box::new(DetailedDependency {
499        source: None,
500        path: utils::normalize_path_for_storage(dep_path_str),
501        version: None,
502        branch: None,
503        rev: None,
504        command: None,
505        args: None,
506        target: None,
507        filename: None,
508        dependencies: None,
509        tool: trans_tool,
510        flatten: None,
511        install: dep_spec.install.or(Some(true)),
512        template_vars: Some(super::lockfile_builder::build_merged_variant_inputs(
513            ctx.base.manifest,
514            parent_dep,
515        )),
516    })))
517}
518
519/// Create a Git-backed transitive dependency (parent is Git-backed).
520#[allow(clippy::too_many_arguments)]
521async fn create_git_backed_transitive_dep(
522    ctx: &TransitiveContext<'_>,
523    parent_dep: &ResourceDependency,
524    dep_resource_type: ResourceType,
525    parent_resource_type: ResourceType,
526    dep_spec: &crate::manifest::DependencySpec,
527    parent_file_path: &Path,
528    trans_canonical: &Path,
529    _prepared_versions: &Arc<DashMap<String, PreparedSourceVersion>>,
530) -> Result<ResourceDependency> {
531    let source_name = parent_dep
532        .get_source()
533        .ok_or_else(|| anyhow::anyhow!("Expected source for Git-backed dependency"))?;
534    let source_url = ctx
535        .base
536        .source_manager
537        .get_source_url(source_name)
538        .ok_or_else(|| anyhow::anyhow!("Source '{source_name}' not found"))?;
539
540    // Get repo-relative path by stripping the appropriate prefix
541    let repo_relative = if utils::is_local_path(&source_url) {
542        strip_local_source_prefix(&source_url, trans_canonical)?
543    } else {
544        // For remote Git sources, derive the worktree root from the parent file path
545        strip_git_worktree_prefix_from_parent(parent_file_path, trans_canonical)?
546    };
547
548    // Determine tool for transitive dependency
549    let trans_tool = determine_transitive_tool(
550        ctx,
551        parent_dep,
552        dep_spec,
553        parent_resource_type,
554        dep_resource_type,
555    );
556
557    Ok(ResourceDependency::Detailed(Box::new(DetailedDependency {
558        source: Some(source_name.to_string()),
559        path: utils::normalize_path_for_storage(repo_relative.to_string_lossy().to_string()),
560        version: dep_spec
561            .version
562            .clone()
563            .or_else(|| parent_dep.get_version().map(|v| v.to_string())),
564        branch: None,
565        rev: None,
566        command: None,
567        args: None,
568        target: None,
569        filename: None,
570        dependencies: None,
571        tool: trans_tool,
572        flatten: None,
573        install: dep_spec.install.or(Some(true)),
574        template_vars: Some(super::lockfile_builder::build_merged_variant_inputs(
575            ctx.base.manifest,
576            parent_dep,
577        )),
578    })))
579}
580
581/// Strip the local source prefix from a transitive dependency path.
582fn strip_local_source_prefix(source_url: &str, trans_canonical: &Path) -> Result<PathBuf> {
583    let source_url_path = PathBuf::from(source_url);
584    let source_path = source_url_path.canonicalize().map_err(|e| {
585        let file_error = crate::core::file_error::FileOperationError::new(
586            crate::core::file_error::FileOperationContext::new(
587                crate::core::file_error::FileOperation::Canonicalize,
588                &source_url_path,
589                "canonicalizing local source path for transitive dependency".to_string(),
590                "transitive_resolver::strip_local_source_prefix",
591            ),
592            e,
593        );
594        anyhow::Error::from(file_error)
595    })?;
596
597    // Check if this is a pattern path (contains glob characters)
598    let trans_str = trans_canonical.to_string_lossy();
599    let is_pattern = trans_str.contains('*') || trans_str.contains('?') || trans_str.contains('[');
600
601    if is_pattern {
602        // For patterns, canonicalize the directory part while keeping the pattern filename intact
603        let parent_dir = trans_canonical.parent().ok_or_else(|| {
604            anyhow::anyhow!("Pattern path has no parent directory: {}", trans_canonical.display())
605        })?;
606        let filename = trans_canonical.file_name().ok_or_else(|| {
607            anyhow::anyhow!("Pattern path has no filename: {}", trans_canonical.display())
608        })?;
609
610        // Canonicalize the directory part
611        let canonical_dir = parent_dir.canonicalize().map_err(|e| {
612            let file_error = crate::core::file_error::FileOperationError::new(
613                crate::core::file_error::FileOperationContext::new(
614                    crate::core::file_error::FileOperation::Canonicalize,
615                    parent_dir,
616                    "canonicalizing pattern directory for local source".to_string(),
617                    "transitive_resolver::strip_local_source_prefix",
618                ),
619                e,
620            );
621            anyhow::Error::from(file_error)
622        })?;
623
624        // Reconstruct the full path with canonical directory and pattern filename
625        let canonical_pattern = canonical_dir.join(filename);
626
627        // Now strip the source prefix
628        canonical_pattern
629            .strip_prefix(&source_path)
630            .with_context(|| {
631                format!(
632                    "Transitive pattern dep outside parent's source: {} not under {}",
633                    canonical_pattern.display(),
634                    source_path.display()
635                )
636            })
637            .map(|p| p.to_path_buf())
638    } else {
639        trans_canonical
640            .strip_prefix(&source_path)
641            .with_context(|| {
642                format!(
643                    "Transitive dep resolved outside parent's source directory: {} not under {}",
644                    trans_canonical.display(),
645                    source_path.display()
646                )
647            })
648            .map(|p| p.to_path_buf())
649    }
650}
651
652/// Strip the Git worktree prefix from a transitive dependency path by deriving
653/// the worktree root from the parent file path.
654fn strip_git_worktree_prefix_from_parent(
655    parent_file_path: &Path,
656    trans_canonical: &Path,
657) -> Result<PathBuf> {
658    // Find the worktree root by looking for a directory with a .git file
659    // Git worktrees have a .git file (not directory) that points to the bare repo
660    // This is more robust than checking for underscores in the directory name
661    let worktree_root = parent_file_path
662        .ancestors()
663        .find(|p| {
664            let git_path = p.join(".git");
665            git_path.is_file()
666        })
667        .ok_or_else(|| {
668            anyhow::anyhow!(
669                "Failed to find worktree root from parent file: {}",
670                parent_file_path.display()
671            )
672        })?;
673
674    // Canonicalize worktree root to handle symlinks
675    let canonical_worktree = worktree_root.canonicalize().map_err(|e| {
676        let file_error = crate::core::file_error::FileOperationError::new(
677            crate::core::file_error::FileOperationContext::new(
678                crate::core::file_error::FileOperation::Canonicalize,
679                worktree_root,
680                "canonicalizing worktree root for transitive dependency".to_string(),
681                "transitive_resolver::strip_git_worktree_prefix_from_parent",
682            ),
683            e,
684        );
685        anyhow::Error::from(file_error)
686    })?;
687
688    // Check if this is a pattern path (contains glob characters)
689    let trans_str = trans_canonical.to_string_lossy();
690    let is_pattern = trans_str.contains('*') || trans_str.contains('?') || trans_str.contains('[');
691
692    if is_pattern {
693        // For patterns, canonicalize the directory part while keeping the pattern filename intact
694        let parent_dir = trans_canonical.parent().ok_or_else(|| {
695            anyhow::anyhow!("Pattern path has no parent directory: {}", trans_canonical.display())
696        })?;
697        let filename = trans_canonical.file_name().ok_or_else(|| {
698            anyhow::anyhow!("Pattern path has no filename: {}", trans_canonical.display())
699        })?;
700
701        // Canonicalize the directory part
702        let canonical_dir = parent_dir.canonicalize().map_err(|e| {
703            let file_error = crate::core::file_error::FileOperationError::new(
704                crate::core::file_error::FileOperationContext::new(
705                    crate::core::file_error::FileOperation::Canonicalize,
706                    parent_dir,
707                    "canonicalizing pattern directory for Git worktree".to_string(),
708                    "transitive_resolver::strip_git_worktree_prefix_from_parent",
709                ),
710                e,
711            );
712            anyhow::Error::from(file_error)
713        })?;
714
715        // Reconstruct the full path with canonical directory and pattern filename
716        let canonical_pattern = canonical_dir.join(filename);
717
718        // Now strip the worktree prefix
719        canonical_pattern
720            .strip_prefix(&canonical_worktree)
721            .with_context(|| {
722                format!(
723                    "Transitive pattern dep outside parent's worktree: {} not under {}",
724                    canonical_pattern.display(),
725                    canonical_worktree.display()
726                )
727            })
728            .map(|p| p.to_path_buf())
729    } else {
730        trans_canonical
731            .strip_prefix(&canonical_worktree)
732            .with_context(|| {
733                format!(
734                    "Transitive dep outside parent's worktree: {} not under {}",
735                    trans_canonical.display(),
736                    canonical_worktree.display()
737                )
738            })
739            .map(|p| p.to_path_buf())
740    }
741}
742
743/// Determine the tool for a transitive dependency.
744fn determine_transitive_tool(
745    ctx: &TransitiveContext<'_>,
746    parent_dep: &ResourceDependency,
747    dep_spec: &crate::manifest::DependencySpec,
748    parent_resource_type: ResourceType,
749    dep_resource_type: ResourceType,
750) -> Option<String> {
751    if let Some(explicit_tool) = &dep_spec.tool {
752        Some(explicit_tool.clone())
753    } else {
754        let parent_tool = parent_dep
755            .get_tool()
756            .map(str::to_string)
757            .unwrap_or_else(|| ctx.base.manifest.get_default_tool(parent_resource_type));
758        if ctx.base.manifest.is_resource_supported(&parent_tool, dep_resource_type) {
759            Some(parent_tool)
760        } else {
761            Some(ctx.base.manifest.get_default_tool(dep_resource_type))
762        }
763    }
764}
765
766/// Build the final ordered result from the dependency graph.
767fn build_ordered_result(
768    all_deps: Arc<DashMap<DependencyKey, ResourceDependency>>,
769    ordered_nodes: Vec<DependencyNode>,
770) -> Result<Vec<(String, ResourceDependency, ResourceType)>> {
771    let mut result = Vec::new();
772    let mut added_keys = HashSet::new();
773
774    tracing::debug!(
775        "Transitive resolution - topological order has {} nodes, all_deps has {} entries",
776        ordered_nodes.len(),
777        all_deps.len()
778    );
779
780    for node in ordered_nodes {
781        tracing::debug!(
782            "Processing ordered node: {}/{} (source: {:?})",
783            node.resource_type,
784            node.name,
785            node.source
786        );
787
788        // Find matching dependency
789        for entry in all_deps.iter() {
790            let (key, dep) = (entry.key(), entry.value());
791            if key.0 == node.resource_type && key.1 == node.name && key.2 == node.source {
792                tracing::debug!(
793                    "  -> Found match in all_deps, adding to result with type {:?}",
794                    node.resource_type
795                );
796                result.push((node.name.clone(), dep.clone(), node.resource_type));
797                added_keys.insert(key.clone());
798                break;
799            }
800        }
801    }
802
803    // Add remaining dependencies that weren't in the graph (no transitive deps)
804    for entry in all_deps.iter() {
805        let (key, dep) = (entry.key(), entry.value());
806        if !added_keys.contains(key) && !dep.is_pattern() {
807            tracing::debug!(
808                "Adding non-graph dependency: {}/{} (source: {:?}) with type {:?}",
809                key.0,
810                key.1,
811                key.2,
812                key.0
813            );
814            result.push((key.1.clone(), dep.clone(), key.0));
815        }
816    }
817
818    tracing::debug!("Transitive resolution returning {} dependencies", result.len());
819
820    Ok(result)
821}
822
823/// Generate unique key for grouping dependencies by source and version.
824pub fn group_key(source: &str, version: &str) -> String {
825    format!("{source}::{version}")
826}
827
828/// Process a single transitive dependency from the queue.
829///
830/// This function extracts the core loop body logic into a standalone async function
831/// that can be executed in parallel batches for improved performance.
832async fn process_single_transitive_dependency<'a>(
833    ctx: TransitiveProcessingContext<'a>,
834) -> Result<()> {
835    let source = ctx.input.dep.get_source().map(std::string::ToString::to_string);
836    // Use resolved tool (with manifest default) to match dependency_processing.rs lookup
837    // If dep has explicit tool, use it; otherwise use manifest's default tool for resource type
838    let tool =
839        Some(ctx.input.dep.get_tool().map(std::string::ToString::to_string).unwrap_or_else(|| {
840            ctx.resolution.ctx_base.manifest.get_default_tool(ctx.input.resource_type)
841        }));
842
843    let key = (
844        ctx.input.resource_type,
845        ctx.input.name.clone(),
846        source.clone(),
847        tool.clone(),
848        ctx.input.variant_hash.clone(),
849    );
850
851    // Build display name for progress tracking
852    let display_name = if source.is_some() {
853        if let Some(version) = ctx.input.dep.get_version() {
854            format!("{}@{}", ctx.input.name, version)
855        } else {
856            format!("{}@HEAD", ctx.input.name)
857        }
858    } else {
859        ctx.input.name.clone()
860    };
861    let progress_key = format!("{}:{}", ctx.input.resource_type, &display_name);
862
863    // Mark as active in progress window
864    if let Some(ref pm) = ctx.progress {
865        pm.mark_item_active(&display_name, &progress_key);
866    }
867
868    tracing::debug!(
869        "[TRANSITIVE] Processing: '{}' (type: {:?}, source: {:?})",
870        ctx.input.name,
871        ctx.input.resource_type,
872        source
873    );
874
875    // Check if this queue entry is stale (superseded by conflict resolution)
876    // CRITICAL: Extract version comparison result before releasing DashMap lock.
877    // We must not hold DashMap read locks while acquiring the queue Mutex,
878    // as this creates a potential AB-BA deadlock with other parallel tasks.
879    let is_stale = ctx
880        .shared
881        .all_deps
882        .get(&key)
883        .map(|current_dep| current_dep.get_version() != ctx.input.dep.get_version())
884        .unwrap_or(false);
885
886    if is_stale {
887        tracing::debug!("[TRANSITIVE] Skipped stale: '{}'", ctx.input.name);
888        // DashMap lock is released - progress update uses atomic counter (no lock needed)
889        if let Some(ref pm) = ctx.progress {
890            let completed = ctx.shared.completed_counter.fetch_add(1, Ordering::SeqCst) + 1;
891            let total = completed + ctx.shared.queue_len.load(Ordering::SeqCst);
892            pm.mark_item_complete(
893                &progress_key,
894                Some(&display_name),
895                completed,
896                total,
897                "Scanning dependencies",
898            );
899        }
900        return Ok(());
901    }
902
903    if ctx.shared.processed.contains_key(&key) {
904        tracing::debug!("[TRANSITIVE] Already processed: '{}'", ctx.input.name);
905        if let Some(ref pm) = ctx.progress {
906            let completed = ctx.shared.completed_counter.fetch_add(1, Ordering::SeqCst) + 1;
907            let total = completed + ctx.shared.queue_len.load(Ordering::SeqCst);
908            pm.mark_item_complete(
909                &progress_key,
910                Some(&display_name),
911                completed,
912                total,
913                "Scanning dependencies",
914            );
915        }
916        return Ok(());
917    }
918
919    ctx.shared.processed.insert(key.clone(), ());
920
921    // Handle pattern dependencies by expanding them to concrete files
922    if ctx.input.dep.is_pattern() {
923        tracing::debug!("[TRANSITIVE] Expanding pattern: '{}'", ctx.input.name);
924        match ctx
925            .resolution
926            .services
927            .pattern_service
928            .expand_pattern(
929                ctx.resolution.core,
930                &ctx.input.dep,
931                ctx.input.resource_type,
932                ctx.shared.prepared_versions.as_ref(),
933            )
934            .await
935        {
936            Ok(concrete_deps) => {
937                // CRITICAL: Collect items to add to queue BEFORE acquiring queue lock.
938                // We must not hold DashMap entry locks while acquiring the queue Mutex,
939                // as this creates a potential AB-BA deadlock with other parallel tasks.
940                let mut items_to_queue = Vec::new();
941
942                for (concrete_name, concrete_dep) in concrete_deps {
943                    ctx.shared.pattern_alias_map.insert(
944                        (ctx.input.resource_type, concrete_name.clone()),
945                        ctx.input.name.clone(),
946                    );
947
948                    let concrete_source =
949                        concrete_dep.get_source().map(std::string::ToString::to_string);
950                    let concrete_tool =
951                        concrete_dep.get_tool().map(std::string::ToString::to_string);
952                    let concrete_variant_hash =
953                        super::lockfile_builder::compute_merged_variant_hash(
954                            ctx.resolution.ctx_base.manifest,
955                            &concrete_dep,
956                        );
957                    let concrete_key = (
958                        ctx.input.resource_type,
959                        concrete_name.clone(),
960                        concrete_source,
961                        concrete_tool,
962                        concrete_variant_hash.clone(),
963                    );
964
965                    // Check and insert atomically, but DON'T hold entry lock while queuing
966                    match ctx.shared.all_deps.entry(concrete_key) {
967                        dashmap::mapref::entry::Entry::Vacant(e) => {
968                            e.insert(concrete_dep.clone());
969                            // Collect for later queue insertion (after DashMap entry is released)
970                            items_to_queue.push((
971                                concrete_name,
972                                concrete_dep,
973                                Some(ctx.input.resource_type),
974                                concrete_variant_hash,
975                            ));
976                        }
977                        dashmap::mapref::entry::Entry::Occupied(mut e) => {
978                            // Entry exists - check if we should replace with semver version
979                            let existing = e.get();
980                            if should_replace_existing(existing, &concrete_dep) {
981                                tracing::debug!(
982                                    "[PATTERN] Replacing existing dep '{}' with semver version",
983                                    concrete_name
984                                );
985                                e.insert(concrete_dep.clone());
986                                items_to_queue.push((
987                                    concrete_name,
988                                    concrete_dep,
989                                    Some(ctx.input.resource_type),
990                                    concrete_variant_hash,
991                                ));
992                            }
993                        }
994                    }
995                    // DashMap entry lock is released here at end of match scope
996                }
997
998                // Now safely acquire queue lock without holding any DashMap locks
999                if !items_to_queue.is_empty() {
1000                    let items_count = items_to_queue.len();
1001                    let mut queue =
1002                        acquire_mutex_with_timeout(&ctx.shared.queue, "transitive_queue").await?;
1003                    queue.extend(items_to_queue);
1004                    // Update atomic counter after extending queue
1005                    ctx.shared.queue_len.fetch_add(items_count, Ordering::SeqCst);
1006                }
1007            }
1008            Err(e) => {
1009                anyhow::bail!("Failed to expand pattern '{}': {}", ctx.input.dep.get_path(), e);
1010            }
1011        }
1012        // Pattern expansion complete - progress update uses atomic counter (no lock needed)
1013        if let Some(ref pm) = ctx.progress {
1014            let completed = ctx.shared.completed_counter.fetch_add(1, Ordering::SeqCst) + 1;
1015            let total = completed + ctx.shared.queue_len.load(Ordering::SeqCst);
1016            pm.mark_item_complete(
1017                &progress_key,
1018                Some(&display_name),
1019                completed,
1020                total,
1021                "Scanning dependencies",
1022            );
1023        }
1024        return Ok(());
1025    }
1026
1027    // Fetch resource content for metadata extraction
1028    // For skills, we need to read the SKILL.md file inside the directory
1029    let content = if ctx.input.resource_type == ResourceType::Skill {
1030        // Create a modified dependency that points to SKILL.md inside the skill directory
1031        let skill_md_dep = create_skill_md_dependency(&ctx.input.dep);
1032        ResourceFetchingService::fetch_content(
1033            ctx.resolution.core,
1034            &skill_md_dep,
1035            ctx.resolution.services.version_service,
1036        )
1037        .await
1038        .with_context(|| {
1039            format!(
1040                "Failed to fetch SKILL.md for skill '{}' ({})",
1041                ctx.input.name,
1042                ctx.input.dep.get_path()
1043            )
1044        })?
1045    } else {
1046        ResourceFetchingService::fetch_content(
1047            ctx.resolution.core,
1048            &ctx.input.dep,
1049            ctx.resolution.services.version_service,
1050        )
1051        .await
1052        .with_context(|| {
1053            format!(
1054                "Failed to fetch resource '{}' ({}) for transitive deps",
1055                ctx.input.name,
1056                ctx.input.dep.get_path()
1057            )
1058        })?
1059    };
1060
1061    // Note: With single-pass rendering, we no longer need to wrap non-templated
1062    // content in guards. Dependencies are rendered once with their own context
1063    // and embedded as-is.
1064
1065    tracing::debug!(
1066        "[TRANSITIVE] Fetched content for '{}' ({} bytes)",
1067        ctx.input.name,
1068        content.len()
1069    );
1070
1071    // Build complete template_vars including global project config for metadata extraction
1072    // This ensures transitive dependencies can use template variables like {{ agpm.project.language }}
1073    let variant_inputs_value = super::lockfile_builder::build_merged_variant_inputs(
1074        ctx.resolution.ctx_base.manifest,
1075        &ctx.input.dep,
1076    );
1077    let variant_inputs = Some(&variant_inputs_value);
1078
1079    // Extract metadata from the resource with complete variant_inputs
1080    // For skills, use SKILL.md path so extractor recognizes it as markdown
1081    let path = if ctx.input.resource_type == ResourceType::Skill {
1082        PathBuf::from(format!("{}/SKILL.md", ctx.input.dep.get_path().trim_end_matches('/')))
1083    } else {
1084        PathBuf::from(ctx.input.dep.get_path())
1085    };
1086    let metadata = MetadataExtractor::extract(
1087        &path,
1088        &content,
1089        variant_inputs,
1090        ctx.resolution.ctx_base.operation_context.map(|arc| arc.as_ref()),
1091    )?;
1092
1093    tracing::debug!(
1094        "[DEBUG] Extracted metadata for '{}': has_deps={}",
1095        ctx.input.name,
1096        metadata.get_dependencies().is_some()
1097    );
1098
1099    // Process transitive dependencies if present
1100    if let Some(deps_map) = metadata.get_dependencies() {
1101        tracing::debug!(
1102            "[DEBUG] Found {} dependency type(s) for '{}': {:?}",
1103            deps_map.len(),
1104            ctx.input.name,
1105            deps_map.keys().collect::<Vec<_>>()
1106        );
1107
1108        // CRITICAL: Collect items to queue BEFORE acquiring queue lock.
1109        // We must not hold DashMap entry locks while acquiring the queue Mutex,
1110        // as this creates a potential AB-BA deadlock with other parallel tasks.
1111        let mut items_to_queue = Vec::new();
1112
1113        // CRITICAL: Collect graph edges to batch-insert AFTER the loop.
1114        // Acquiring the graph mutex inside the loop creates high contention
1115        // and potential deadlocks with DashMap operations.
1116        let mut graph_edges: Vec<(DependencyNode, DependencyNode)> = Vec::new();
1117
1118        // Track declared dependencies for validation
1119        let declared_count = metadata.dependency_count();
1120        let declared_deps: Vec<(String, String)> = deps_map
1121            .iter()
1122            .flat_map(|(rtype, specs)| specs.iter().map(move |s| (rtype.clone(), s.path.clone())))
1123            .collect();
1124
1125        for (dep_resource_type_str, dep_specs) in deps_map {
1126            let dep_resource_type: ResourceType =
1127                dep_resource_type_str.parse().unwrap_or(ResourceType::Snippet);
1128
1129            for dep_spec in dep_specs {
1130                // Create a temporary TransitiveContext for this call
1131                // Note: conflict_detector is not used in parallel code (was removed in Phase 4)
1132                let mut dummy_conflict_detector = ConflictDetector::new();
1133                let temp_ctx = super::types::TransitiveContext {
1134                    base: *ctx.resolution.ctx_base,
1135                    dependency_map: ctx.shared.dependency_map,
1136                    transitive_custom_names: ctx.shared.custom_names,
1137                    conflict_detector: &mut dummy_conflict_detector,
1138                    manifest_overrides: ctx.resolution.manifest_overrides,
1139                };
1140
1141                // Process each transitive dependency spec
1142                let (trans_dep, trans_name) =
1143                    process_transitive_dependency_spec(TransitiveDepProcessingParams {
1144                        ctx: &temp_ctx,
1145                        core: ctx.resolution.core,
1146                        parent_dep: &ctx.input.dep,
1147                        dep_resource_type,
1148                        parent_resource_type: ctx.input.resource_type,
1149                        parent_name: &ctx.input.name,
1150                        dep_spec,
1151                        version_service: ctx.resolution.services.version_service,
1152                        prepared_versions: ctx.shared.prepared_versions,
1153                    })
1154                    .await?;
1155
1156                let trans_source = trans_dep.get_source().map(std::string::ToString::to_string);
1157                let trans_tool = trans_dep.get_tool().map(std::string::ToString::to_string);
1158                let trans_variant_hash = super::lockfile_builder::compute_merged_variant_hash(
1159                    ctx.resolution.ctx_base.manifest,
1160                    &trans_dep,
1161                );
1162
1163                // Store custom name if provided
1164                if let Some(custom_name) = &dep_spec.name {
1165                    let trans_key = (
1166                        dep_resource_type,
1167                        trans_name.clone(),
1168                        trans_source.clone(),
1169                        trans_tool.clone(),
1170                        trans_variant_hash.clone(),
1171                    );
1172                    ctx.shared.custom_names.insert(trans_key, custom_name.clone());
1173                    tracing::debug!(
1174                        "Storing custom name '{}' for transitive dep '{}'",
1175                        custom_name,
1176                        trans_name
1177                    );
1178                }
1179
1180                // Collect edge for dependency graph (batch-insert after loop)
1181                let from_node = DependencyNode::with_source(
1182                    ctx.input.resource_type,
1183                    &ctx.input.name,
1184                    source.clone(),
1185                );
1186                let to_node = DependencyNode::with_source(
1187                    dep_resource_type,
1188                    &trans_name,
1189                    trans_source.clone(),
1190                );
1191                graph_edges.push((from_node, to_node));
1192
1193                // Track in dependency map
1194                let from_key = (
1195                    ctx.input.resource_type,
1196                    ctx.input.name.clone(),
1197                    source.clone(),
1198                    tool.clone(),
1199                    ctx.input.variant_hash.clone(),
1200                );
1201                let dep_ref =
1202                    LockfileDependencyRef::local(dep_resource_type, trans_name.clone(), None)
1203                        .to_string();
1204                tracing::debug!(
1205                    "[DEBUG] Adding to dependency_map: parent='{}' (type={:?}, source={:?}, tool={:?}, hash={}), child='{}' (type={:?})",
1206                    ctx.input.name,
1207                    ctx.input.resource_type,
1208                    source,
1209                    tool,
1210                    &ctx.input.variant_hash[..8],
1211                    dep_ref,
1212                    dep_resource_type
1213                );
1214                ctx.shared.dependency_map.entry(from_key).or_default().push(dep_ref);
1215
1216                // DON'T add to conflict detector yet - we'll do it after SHA resolution
1217                // (Removed: add_to_conflict_detector call)
1218
1219                // Check for version conflicts
1220                let trans_key = (
1221                    dep_resource_type,
1222                    trans_name.clone(),
1223                    trans_source.clone(),
1224                    trans_tool.clone(),
1225                    trans_variant_hash.clone(),
1226                );
1227
1228                tracing::debug!(
1229                    "[TRANSITIVE] Found transitive dep '{}' (type: {:?}, tool: {:?}, parent: {})",
1230                    trans_name,
1231                    dep_resource_type,
1232                    trans_tool,
1233                    ctx.input.name
1234                );
1235
1236                // Check if we already have this dependency
1237                // Use entry API to atomically check and potentially update
1238                match ctx.shared.all_deps.entry(trans_key) {
1239                    dashmap::mapref::entry::Entry::Vacant(e) => {
1240                        // No existing entry, add the dependency
1241                        tracing::debug!(
1242                            "Adding transitive dep '{}' (parent: {})",
1243                            trans_name,
1244                            ctx.input.name
1245                        );
1246                        e.insert(trans_dep.clone());
1247                        // Collect for later queue insertion (after DashMap entry is released)
1248                        items_to_queue.push((
1249                            trans_name,
1250                            trans_dep,
1251                            Some(dep_resource_type),
1252                            trans_variant_hash,
1253                        ));
1254                    }
1255                    dashmap::mapref::entry::Entry::Occupied(mut e) => {
1256                        // Dependency already exists - check if we should replace it
1257                        // Prefer semver versions over git refs (e.g., v1.0.0 wins over main)
1258                        let existing = e.get();
1259                        if should_replace_existing(existing, &trans_dep) {
1260                            tracing::debug!(
1261                                "[TRANSITIVE] Replacing existing dep '{}' (version: {:?}) with semver version {:?}",
1262                                trans_name,
1263                                existing.get_version(),
1264                                trans_dep.get_version()
1265                            );
1266                            e.insert(trans_dep.clone());
1267                            // Re-queue to process with updated version
1268                            items_to_queue.push((
1269                                trans_name,
1270                                trans_dep,
1271                                Some(dep_resource_type),
1272                                trans_variant_hash,
1273                            ));
1274                        } else {
1275                            tracing::debug!(
1276                                "[TRANSITIVE] Keeping existing dep '{}' (version: {:?} vs new {:?})",
1277                                trans_name,
1278                                existing.get_version(),
1279                                trans_dep.get_version()
1280                            );
1281                        }
1282                    }
1283                }
1284                // DashMap entry lock is released here at end of match scope
1285            }
1286        }
1287
1288        // Capture resolved count before graph_edges is consumed
1289        let resolved_count = graph_edges.len();
1290
1291        // Batch-insert all graph edges after loops complete (single mutex acquisition)
1292        if !graph_edges.is_empty() {
1293            let mut graph =
1294                acquire_mutex_with_timeout(&ctx.shared.graph, "dependency_graph").await?;
1295            for (from_node, to_node) in graph_edges {
1296                graph.add_dependency(from_node, to_node);
1297            }
1298        }
1299
1300        // Now safely acquire queue lock without holding any DashMap locks
1301        if !items_to_queue.is_empty() {
1302            let items_count = items_to_queue.len();
1303            let mut queue =
1304                acquire_mutex_with_timeout(&ctx.shared.queue, "transitive_queue").await?;
1305            queue.extend(items_to_queue);
1306            // Update atomic counter after extending queue
1307            ctx.shared.queue_len.fetch_add(items_count, Ordering::SeqCst);
1308        }
1309
1310        // Validate all declared dependencies were processed
1311        if resolved_count < declared_count {
1312            return Err(crate::core::AgpmError::DependencyResolutionMismatch {
1313                resource: ctx.input.name.clone(),
1314                declared_count,
1315                resolved_count,
1316                declared_deps,
1317            }
1318            .into());
1319        }
1320    }
1321
1322    // Mark item as complete in progress window - uses atomic counter (no lock needed)
1323    if let Some(ref pm) = ctx.progress {
1324        let completed = ctx.shared.completed_counter.fetch_add(1, Ordering::SeqCst) + 1;
1325        let total = completed + ctx.shared.queue_len.load(Ordering::SeqCst);
1326        pm.mark_item_complete(
1327            &progress_key,
1328            Some(&display_name),
1329            completed,
1330            total,
1331            "Scanning dependencies",
1332        );
1333    }
1334
1335    Ok(())
1336}
1337
1338/// Service-based wrapper for transitive dependency resolution.
1339///
1340/// This provides a simpler API for internal use that takes service references
1341/// directly instead of requiring closure-based dependency injection.
1342pub async fn resolve_with_services(
1343    params: TransitiveResolutionParams<'_>,
1344) -> Result<Vec<(String, ResourceDependency, ResourceType)>> {
1345    let TransitiveResolutionParams {
1346        ctx,
1347        core,
1348        base_deps,
1349        enable_transitive,
1350        prepared_versions,
1351        pattern_alias_map,
1352        services,
1353        progress,
1354    } = params;
1355    // Clear state from any previous resolution
1356    ctx.dependency_map.clear();
1357
1358    if !enable_transitive {
1359        return Ok(base_deps.to_vec());
1360    }
1361
1362    let graph = Arc::new(tokio::sync::Mutex::new(DependencyGraph::new()));
1363    let all_deps: Arc<DashMap<DependencyKey, ResourceDependency>> = Arc::new(DashMap::new());
1364    let processed: Arc<DashMap<DependencyKey, ()>> = Arc::new(DashMap::new()); // Simulates HashSet
1365
1366    // Type alias to reduce complexity
1367    type QueueItem = (String, ResourceDependency, Option<ResourceType>, String);
1368    #[allow(clippy::type_complexity)]
1369    let queue: Arc<tokio::sync::Mutex<Vec<QueueItem>>> =
1370        Arc::new(tokio::sync::Mutex::new(Vec::new()));
1371    // Atomic counter for queue length - enables lock-free progress tracking
1372    let queue_len = Arc::new(AtomicUsize::new(0));
1373
1374    // Add initial dependencies to queue with their threaded types
1375    {
1376        let mut queue_guard = acquire_mutex_with_timeout(&queue, "transitive_queue").await?;
1377        for (name, dep, resource_type) in base_deps {
1378            let source = dep.get_source().map(std::string::ToString::to_string);
1379            // Use resolved tool (with manifest default) to match dependency_processing.rs lookup
1380            let tool = Some(
1381                dep.get_tool()
1382                    .map(std::string::ToString::to_string)
1383                    .unwrap_or_else(|| ctx.base.manifest.get_default_tool(*resource_type)),
1384            );
1385
1386            // Compute variant_hash from MERGED variant_inputs (dep + global config)
1387            // This ensures consistency with how LockedResource computes its hash
1388            let merged_variant_inputs =
1389                super::lockfile_builder::build_merged_variant_inputs(ctx.base.manifest, dep);
1390            let variant_hash = crate::utils::compute_variant_inputs_hash(&merged_variant_inputs)
1391                .unwrap_or_else(|_| crate::utils::EMPTY_VARIANT_INPUTS_HASH.to_string());
1392
1393            tracing::debug!(
1394                "[DEBUG] Adding base dep to queue: '{}' (type: {:?}, source: {:?}, tool: {:?}, is_local: {})",
1395                name,
1396                resource_type,
1397                source,
1398                tool,
1399                dep.is_local()
1400            );
1401            // Store pre-computed hash in queue to avoid duplicate computation
1402            queue_guard.push((
1403                name.clone(),
1404                dep.clone(),
1405                Some(*resource_type),
1406                variant_hash.clone(),
1407            ));
1408            all_deps
1409                .insert((*resource_type, name.clone(), source, tool, variant_hash), dep.clone());
1410        }
1411        // Update atomic queue length counter
1412        queue_len.store(queue_guard.len(), Ordering::SeqCst);
1413    }
1414
1415    // Track progress: total items to process = base_deps + discovered transitives
1416    let completed_counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1417
1418    // Calculate concurrency based on CPU cores
1419    let cores = std::thread::available_parallelism().map(std::num::NonZero::get).unwrap_or(4);
1420    let max_concurrent = std::cmp::max(10, cores * 2);
1421
1422    // Extract ctx references for parallel access (conflict_detector needs &mut, so we keep it outside)
1423    let ctx_dependency_map = ctx.dependency_map;
1424    let ctx_custom_names = ctx.transitive_custom_names;
1425    let ctx_base = &ctx.base;
1426    let ctx_manifest_overrides = ctx.manifest_overrides;
1427
1428    // Process queue in parallel batches to discover transitive dependencies
1429    loop {
1430        // Extract batch from queue (drain from end, same as serial pop order)
1431        let batch: Vec<QueueEntry> = {
1432            let mut q = acquire_mutex_with_timeout(&queue, "transitive_queue").await?;
1433            let current_queue_len = q.len();
1434            let batch_size = std::cmp::min(max_concurrent, current_queue_len);
1435            if batch_size == 0 {
1436                break; // Queue empty
1437            }
1438            // Drain from end and reverse to maintain LIFO ordering like serial version
1439            let mut batch_vec =
1440                q.drain(current_queue_len.saturating_sub(batch_size)..).collect::<Vec<_>>();
1441            batch_vec.reverse(); // Reverse to process in same order as serial (last added first)
1442            // Update atomic counter after draining from queue
1443            queue_len.fetch_sub(batch_vec.len(), Ordering::SeqCst);
1444            batch_vec
1445        };
1446
1447        // Process batch in parallel
1448        let batch_futures: Vec<_> = batch
1449            .into_iter()
1450            .map(|(name, dep, resource_type, variant_hash)| {
1451                // Clone Arc refs for concurrent access
1452                let graph_clone = Arc::clone(&graph);
1453                let all_deps_clone = Arc::clone(&all_deps);
1454                let processed_clone = Arc::clone(&processed);
1455                let queue_clone = Arc::clone(&queue);
1456                let queue_len_clone = Arc::clone(&queue_len);
1457                let pattern_alias_map_clone = Arc::clone(pattern_alias_map);
1458                let progress_clone = progress.clone();
1459                let counter_clone = Arc::clone(&completed_counter);
1460                let prepared_versions_clone = Arc::clone(prepared_versions);
1461                let dependency_map_clone = ctx_dependency_map;
1462                let custom_names_clone = ctx_custom_names;
1463                let manifest_overrides_clone = ctx_manifest_overrides;
1464
1465                async move {
1466                    let resource_type = resource_type
1467                        .expect("resource_type should always be threaded through queue");
1468
1469                    // Construct the processing context
1470                    let ctx = TransitiveProcessingContext {
1471                        input: TransitiveInput {
1472                            name,
1473                            dep,
1474                            resource_type,
1475                            variant_hash,
1476                        },
1477                        shared: TransitiveSharedState {
1478                            graph: graph_clone,
1479                            all_deps: all_deps_clone,
1480                            processed: processed_clone,
1481                            queue: queue_clone,
1482                            queue_len: queue_len_clone,
1483                            pattern_alias_map: pattern_alias_map_clone,
1484                            completed_counter: counter_clone,
1485                            dependency_map: dependency_map_clone,
1486                            custom_names: custom_names_clone,
1487                            prepared_versions: &prepared_versions_clone,
1488                        },
1489                        resolution: TransitiveResolutionContext {
1490                            ctx_base,
1491                            manifest_overrides: manifest_overrides_clone,
1492                            core,
1493                            services,
1494                        },
1495                        progress: progress_clone,
1496                    };
1497
1498                    process_single_transitive_dependency(ctx).await
1499                }
1500            })
1501            .collect();
1502
1503        // Execute batch concurrently with timeout to prevent indefinite blocking
1504        let timeout_duration = batch_operation_timeout();
1505        let results = tokio::time::timeout(timeout_duration, join_all(batch_futures))
1506            .await
1507            .with_context(|| {
1508                format!(
1509                    "Batch transitive resolution timed out after {:?} - possible deadlock",
1510                    timeout_duration
1511                )
1512            })?;
1513
1514        // Check for errors
1515        for result in results {
1516            result?;
1517        }
1518    }
1519
1520    // Check for circular dependencies
1521    acquire_mutex_with_timeout(&graph, "dependency_graph").await?.detect_cycles()?;
1522
1523    // Get topological order
1524    let ordered_nodes =
1525        acquire_mutex_with_timeout(&graph, "dependency_graph").await?.topological_order()?;
1526
1527    // Build result with topologically ordered dependencies
1528    build_ordered_result(all_deps, ordered_nodes)
1529}
1530
1531/// Create a modified dependency that points to SKILL.md inside a skill directory.
1532///
1533/// Skills are directory-based resources, but we need to read their SKILL.md file
1534/// for metadata extraction. This function creates a new dependency with the path
1535/// modified to point to the SKILL.md file.
1536fn create_skill_md_dependency(dep: &ResourceDependency) -> ResourceDependency {
1537    match dep {
1538        ResourceDependency::Simple(path) => {
1539            // For simple deps, append /SKILL.md to the path
1540            let skill_md_path = format!("{}/SKILL.md", path.trim_end_matches('/'));
1541            ResourceDependency::Simple(skill_md_path)
1542        }
1543        ResourceDependency::Detailed(detailed) => {
1544            // For detailed deps, create a new detailed dep with modified path
1545            let skill_md_path = format!("{}/SKILL.md", detailed.path.trim_end_matches('/'));
1546            ResourceDependency::Detailed(Box::new(DetailedDependency {
1547                path: skill_md_path,
1548                source: detailed.source.clone(),
1549                version: detailed.version.clone(),
1550                branch: detailed.branch.clone(),
1551                rev: detailed.rev.clone(),
1552                command: detailed.command.clone(),
1553                args: detailed.args.clone(),
1554                target: detailed.target.clone(),
1555                filename: detailed.filename.clone(),
1556                dependencies: detailed.dependencies.clone(),
1557                tool: detailed.tool.clone(),
1558                flatten: detailed.flatten,
1559                install: detailed.install,
1560                template_vars: detailed.template_vars.clone(),
1561            }))
1562        }
1563    }
1564}