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