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
8use std::collections::{HashMap, HashSet};
9use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result};
12
13use crate::core::ResourceType;
14use crate::lockfile::lockfile_dependency_ref::LockfileDependencyRef;
15use crate::manifest::{DetailedDependency, ResourceDependency};
16use crate::metadata::MetadataExtractor;
17use crate::utils;
18
19use super::dependency_graph::{DependencyGraph, DependencyNode};
20use super::pattern_expander::generate_dependency_name;
21use super::types::{
22    DependencyKey, TransitiveContext, apply_manifest_override, compute_dependency_variant_hash,
23};
24use super::version_resolver::{PreparedSourceVersion, VersionResolutionService};
25use super::{PatternExpansionService, ResourceFetchingService, is_file_relative_path};
26
27/// Container for resolution services to reduce parameter count.
28pub struct ResolutionServices<'a> {
29    /// Service for version resolution and commit SHA lookup
30    pub version_service: &'a mut VersionResolutionService,
31    /// Service for pattern expansion (glob patterns)
32    pub pattern_service: &'a mut PatternExpansionService,
33}
34
35/// Process a single transitive dependency specification.
36#[allow(clippy::too_many_arguments)]
37async fn process_transitive_dependency_spec(
38    ctx: &TransitiveContext<'_>,
39    core: &super::ResolutionCore,
40    parent_dep: &ResourceDependency,
41    dep_resource_type: ResourceType,
42    parent_resource_type: ResourceType,
43    parent_name: &str,
44    dep_spec: &crate::manifest::DependencySpec,
45    version_service: &mut VersionResolutionService,
46    prepared_versions: &HashMap<String, PreparedSourceVersion>,
47) -> Result<(ResourceDependency, String)> {
48    // Get the canonical path to the parent resource file
49    let parent_file_path =
50        ResourceFetchingService::get_canonical_path(core, parent_dep, version_service)
51            .await
52            .with_context(|| {
53                format!(
54                    "Failed to get parent path for transitive dependencies of '{}'",
55                    parent_name
56                )
57            })?;
58
59    // Resolve the transitive dependency path
60    let trans_canonical = resolve_transitive_path(&parent_file_path, &dep_spec.path, parent_name)?;
61
62    // Create the transitive dependency
63    let trans_dep = create_transitive_dependency(
64        ctx,
65        parent_dep,
66        dep_resource_type,
67        parent_resource_type,
68        parent_name,
69        dep_spec,
70        &parent_file_path,
71        &trans_canonical,
72        prepared_versions,
73    )
74    .await?;
75
76    // Generate a name for the transitive dependency using source context
77    let trans_name = if trans_dep.get_source().is_none() {
78        // Local dependency - use manifest directory as source context
79        // Use trans_dep.get_path() which is already relative to manifest directory
80        // (computed in create_path_only_transitive_dep)
81        let manifest_dir = ctx
82            .base
83            .manifest
84            .manifest_dir
85            .as_ref()
86            .ok_or_else(|| anyhow::anyhow!("Manifest directory not available"))?;
87
88        let source_context = crate::resolver::source_context::SourceContext::local(manifest_dir);
89        generate_dependency_name(trans_dep.get_path(), &source_context)
90    } else {
91        // Git dependency - use remote source context
92        let source_name = trans_dep
93            .get_source()
94            .ok_or_else(|| anyhow::anyhow!("Git dependency missing source name"))?;
95        let source_context = crate::resolver::source_context::SourceContext::remote(source_name);
96        generate_dependency_name(trans_dep.get_path(), &source_context)
97    };
98
99    Ok((trans_dep, trans_name))
100}
101
102/// Resolve a transitive dependency path relative to its parent.
103fn resolve_transitive_path(
104    parent_file_path: &Path,
105    dep_path: &str,
106    parent_name: &str,
107) -> Result<PathBuf> {
108    // Check if this is a glob pattern
109    let is_pattern = dep_path.contains('*') || dep_path.contains('?') || dep_path.contains('[');
110
111    if is_pattern {
112        // For patterns, normalize (resolve .. and .) but don't canonicalize
113        let parent_dir = parent_file_path.parent().ok_or_else(|| {
114            anyhow::anyhow!(
115                "Failed to resolve transitive dependency '{}' for '{}': parent file has no directory",
116                dep_path,
117                parent_name
118            )
119        })?;
120        let resolved = parent_dir.join(dep_path);
121
122        // Preserve the root component when normalizing
123        let mut result = PathBuf::new();
124        for component in resolved.components() {
125            match component {
126                std::path::Component::RootDir => result.push(component),
127                std::path::Component::ParentDir => {
128                    result.pop();
129                }
130                std::path::Component::CurDir => {}
131                _ => result.push(component),
132            }
133        }
134        Ok(result)
135    } else if is_file_relative_path(dep_path) || !dep_path.contains('/') {
136        // File-relative path (starts with ./ or ../) or bare filename
137        // For bare filenames, treat as file-relative by resolving from parent directory
138        let parent_dir = parent_file_path.parent().ok_or_else(|| {
139            anyhow::anyhow!(
140                "Failed to resolve transitive dependency '{}' for '{}': parent file has no directory",
141                dep_path,
142                parent_name
143            )
144        })?;
145
146        let resolved = parent_dir.join(dep_path);
147        resolved.canonicalize().map_err(|e| {
148            // Create a FileOperationError for canonicalization failures
149            let file_error = crate::core::file_error::FileOperationError::new(
150                crate::core::file_error::FileOperationContext::new(
151                    crate::core::file_error::FileOperation::Canonicalize,
152                    &resolved,
153                    format!("resolving transitive dependency '{}' for '{}'", dep_path, parent_name),
154                    "transitive_resolver::resolve_transitive_path",
155                ),
156                e,
157            );
158            anyhow::Error::from(file_error)
159        })
160    } else {
161        // Repo-relative path
162        resolve_repo_relative_path(parent_file_path, dep_path, parent_name)
163    }
164}
165
166/// Resolve a repository-relative transitive dependency path.
167fn resolve_repo_relative_path(
168    parent_file_path: &Path,
169    dep_path: &str,
170    parent_name: &str,
171) -> Result<PathBuf> {
172    // For Git sources, find the worktree root; for local sources, find the source root
173    let repo_root = parent_file_path
174        .ancestors()
175        .find(|p| {
176            // Worktree directories have format: owner_repo_sha8
177            p.file_name().and_then(|n| n.to_str()).map(|s| s.contains('_')).unwrap_or(false)
178        })
179        .or_else(|| parent_file_path.ancestors().nth(2)) // Fallback for local sources
180        .ok_or_else(|| {
181            anyhow::anyhow!(
182                "Failed to find repository root for transitive dependency '{}'",
183                dep_path
184            )
185        })?;
186
187    let full_path = repo_root.join(dep_path);
188    full_path.canonicalize().with_context(|| {
189        format!(
190            "Failed to resolve repo-relative transitive dependency '{}' for '{}': {} (repo root: {})",
191            dep_path,
192            parent_name,
193            full_path.display(),
194            repo_root.display()
195        )
196    })
197}
198
199/// Create a ResourceDependency for a transitive dependency.
200#[allow(clippy::too_many_arguments)]
201async fn create_transitive_dependency(
202    ctx: &TransitiveContext<'_>,
203    parent_dep: &ResourceDependency,
204    dep_resource_type: ResourceType,
205    parent_resource_type: ResourceType,
206    parent_name: &str,
207    dep_spec: &crate::manifest::DependencySpec,
208    parent_file_path: &Path,
209    trans_canonical: &Path,
210    prepared_versions: &HashMap<String, PreparedSourceVersion>,
211) -> Result<ResourceDependency> {
212    use super::types::{OverrideKey, compute_dependency_variant_hash, normalize_lookup_path};
213
214    // Create the dependency as before
215    let mut dep = if parent_dep.get_source().is_none() {
216        create_path_only_transitive_dep(
217            ctx,
218            parent_dep,
219            dep_resource_type,
220            parent_resource_type,
221            dep_spec,
222            trans_canonical,
223        )?
224    } else {
225        create_git_backed_transitive_dep(
226            ctx,
227            parent_dep,
228            dep_resource_type,
229            parent_resource_type,
230            parent_name,
231            dep_spec,
232            parent_file_path,
233            trans_canonical,
234            prepared_versions,
235        )
236        .await?
237    };
238
239    // Check for manifest override
240    let normalized_path = normalize_lookup_path(dep.get_path());
241    let source = dep.get_source().map(std::string::ToString::to_string);
242
243    // Determine tool for the dependency
244    let tool = dep
245        .get_tool()
246        .map(str::to_string)
247        .unwrap_or_else(|| ctx.base.manifest.get_default_tool(dep_resource_type));
248
249    let variant_hash = compute_dependency_variant_hash(&dep);
250
251    let override_key = OverrideKey {
252        resource_type: dep_resource_type,
253        normalized_path: normalized_path.clone(),
254        source,
255        tool,
256        variant_hash,
257    };
258
259    // Apply manifest override if found
260    if let Some(override_info) = ctx.manifest_overrides.get(&override_key) {
261        apply_manifest_override(&mut dep, override_info, &normalized_path);
262    }
263
264    Ok(dep)
265}
266
267/// Create a path-only transitive dependency (parent is path-only).
268fn create_path_only_transitive_dep(
269    ctx: &TransitiveContext<'_>,
270    parent_dep: &ResourceDependency,
271    dep_resource_type: ResourceType,
272    parent_resource_type: ResourceType,
273    dep_spec: &crate::manifest::DependencySpec,
274    trans_canonical: &Path,
275) -> Result<ResourceDependency> {
276    let manifest_dir = ctx.base.manifest.manifest_dir.as_ref().ok_or_else(|| {
277        anyhow::anyhow!("Manifest directory not available for path-only transitive dep")
278    })?;
279
280    // Always compute relative path from manifest to target
281    let dep_path_str = match manifest_dir.canonicalize() {
282        Ok(canonical_manifest) => {
283            utils::compute_relative_path(&canonical_manifest, trans_canonical)
284        }
285        Err(e) => {
286            eprintln!(
287                "Warning: Could not canonicalize manifest directory {}: {}. Using non-canonical path.",
288                manifest_dir.display(),
289                e
290            );
291            utils::compute_relative_path(manifest_dir, trans_canonical)
292        }
293    };
294
295    // Determine tool for transitive dependency
296    let trans_tool = determine_transitive_tool(
297        ctx,
298        parent_dep,
299        dep_spec,
300        parent_resource_type,
301        dep_resource_type,
302    );
303
304    Ok(ResourceDependency::Detailed(Box::new(DetailedDependency {
305        source: None,
306        path: utils::normalize_path_for_storage(dep_path_str),
307        version: None,
308        branch: None,
309        rev: None,
310        command: None,
311        args: None,
312        target: None,
313        filename: None,
314        dependencies: None,
315        tool: trans_tool,
316        flatten: None,
317        install: dep_spec.install.or(Some(true)),
318        template_vars: Some(super::lockfile_builder::build_merged_variant_inputs(
319            ctx.base.manifest,
320            parent_dep,
321        )),
322    })))
323}
324
325/// Create a Git-backed transitive dependency (parent is Git-backed).
326#[allow(clippy::too_many_arguments)]
327async fn create_git_backed_transitive_dep(
328    ctx: &TransitiveContext<'_>,
329    parent_dep: &ResourceDependency,
330    dep_resource_type: ResourceType,
331    parent_resource_type: ResourceType,
332    _parent_name: &str,
333    dep_spec: &crate::manifest::DependencySpec,
334    parent_file_path: &Path,
335    trans_canonical: &Path,
336    _prepared_versions: &HashMap<String, PreparedSourceVersion>,
337) -> Result<ResourceDependency> {
338    let source_name = parent_dep
339        .get_source()
340        .ok_or_else(|| anyhow::anyhow!("Expected source for Git-backed dependency"))?;
341    let source_url = ctx
342        .base
343        .source_manager
344        .get_source_url(source_name)
345        .ok_or_else(|| anyhow::anyhow!("Source '{source_name}' not found"))?;
346
347    // Get repo-relative path by stripping the appropriate prefix
348    let repo_relative = if utils::is_local_path(&source_url) {
349        strip_local_source_prefix(&source_url, trans_canonical)?
350    } else {
351        // For remote Git sources, derive the worktree root from the parent file path
352        strip_git_worktree_prefix_from_parent(parent_file_path, trans_canonical)?
353    };
354
355    // Determine tool for transitive dependency
356    let trans_tool = determine_transitive_tool(
357        ctx,
358        parent_dep,
359        dep_spec,
360        parent_resource_type,
361        dep_resource_type,
362    );
363
364    Ok(ResourceDependency::Detailed(Box::new(DetailedDependency {
365        source: Some(source_name.to_string()),
366        path: utils::normalize_path_for_storage(repo_relative.to_string_lossy().to_string()),
367        version: dep_spec
368            .version
369            .clone()
370            .or_else(|| parent_dep.get_version().map(|v| v.to_string())),
371        branch: None,
372        rev: None,
373        command: None,
374        args: None,
375        target: None,
376        filename: None,
377        dependencies: None,
378        tool: trans_tool,
379        flatten: None,
380        install: dep_spec.install.or(Some(true)),
381        template_vars: Some(super::lockfile_builder::build_merged_variant_inputs(
382            ctx.base.manifest,
383            parent_dep,
384        )),
385    })))
386}
387
388/// Strip the local source prefix from a transitive dependency path.
389fn strip_local_source_prefix(source_url: &str, trans_canonical: &Path) -> Result<PathBuf> {
390    let source_path = PathBuf::from(source_url).canonicalize()?;
391
392    // Check if this is a pattern path (contains glob characters)
393    let trans_str = trans_canonical.to_string_lossy();
394    let is_pattern = trans_str.contains('*') || trans_str.contains('?') || trans_str.contains('[');
395
396    if is_pattern {
397        // For patterns, canonicalize the directory part while keeping the pattern filename intact
398        let parent_dir = trans_canonical.parent().ok_or_else(|| {
399            anyhow::anyhow!("Pattern path has no parent directory: {}", trans_canonical.display())
400        })?;
401        let filename = trans_canonical.file_name().ok_or_else(|| {
402            anyhow::anyhow!("Pattern path has no filename: {}", trans_canonical.display())
403        })?;
404
405        // Canonicalize the directory part
406        let canonical_dir = parent_dir.canonicalize().with_context(|| {
407            format!("Failed to canonicalize pattern directory: {}", parent_dir.display())
408        })?;
409
410        // Reconstruct the full path with canonical directory and pattern filename
411        let canonical_pattern = canonical_dir.join(filename);
412
413        // Now strip the source prefix
414        canonical_pattern
415            .strip_prefix(&source_path)
416            .with_context(|| {
417                format!(
418                    "Transitive pattern dep outside parent's source: {} not under {}",
419                    canonical_pattern.display(),
420                    source_path.display()
421                )
422            })
423            .map(|p| p.to_path_buf())
424    } else {
425        trans_canonical
426            .strip_prefix(&source_path)
427            .with_context(|| {
428                format!(
429                    "Transitive dep resolved outside parent's source directory: {} not under {}",
430                    trans_canonical.display(),
431                    source_path.display()
432                )
433            })
434            .map(|p| p.to_path_buf())
435    }
436}
437
438/// Strip the Git worktree prefix from a transitive dependency path by deriving
439/// the worktree root from the parent file path.
440fn strip_git_worktree_prefix_from_parent(
441    parent_file_path: &Path,
442    trans_canonical: &Path,
443) -> Result<PathBuf> {
444    // Find the worktree root by looking for a directory with the pattern: owner_repo_sha8
445    // Start from the parent file and walk up the directory tree
446    let worktree_root = parent_file_path
447        .ancestors()
448        .find(|p| {
449            p.file_name()
450                .and_then(|n| n.to_str())
451                .map(|s| {
452                    // Worktree directories have format: owner_repo_sha8 (contains underscores)
453                    s.contains('_')
454                })
455                .unwrap_or(false)
456        })
457        .ok_or_else(|| {
458            anyhow::anyhow!(
459                "Failed to find worktree root from parent file: {}",
460                parent_file_path.display()
461            )
462        })?;
463
464    // Canonicalize worktree root to handle symlinks
465    let canonical_worktree = worktree_root.canonicalize().with_context(|| {
466        format!("Failed to canonicalize worktree root: {}", worktree_root.display())
467    })?;
468
469    // Check if this is a pattern path (contains glob characters)
470    let trans_str = trans_canonical.to_string_lossy();
471    let is_pattern = trans_str.contains('*') || trans_str.contains('?') || trans_str.contains('[');
472
473    if is_pattern {
474        // For patterns, canonicalize the directory part while keeping the pattern filename intact
475        let parent_dir = trans_canonical.parent().ok_or_else(|| {
476            anyhow::anyhow!("Pattern path has no parent directory: {}", trans_canonical.display())
477        })?;
478        let filename = trans_canonical.file_name().ok_or_else(|| {
479            anyhow::anyhow!("Pattern path has no filename: {}", trans_canonical.display())
480        })?;
481
482        // Canonicalize the directory part
483        let canonical_dir = parent_dir.canonicalize().with_context(|| {
484            format!("Failed to canonicalize pattern directory: {}", parent_dir.display())
485        })?;
486
487        // Reconstruct the full path with canonical directory and pattern filename
488        let canonical_pattern = canonical_dir.join(filename);
489
490        // Now strip the worktree prefix
491        canonical_pattern
492            .strip_prefix(&canonical_worktree)
493            .with_context(|| {
494                format!(
495                    "Transitive pattern dep outside parent's worktree: {} not under {}",
496                    canonical_pattern.display(),
497                    canonical_worktree.display()
498                )
499            })
500            .map(|p| p.to_path_buf())
501    } else {
502        trans_canonical
503            .strip_prefix(&canonical_worktree)
504            .with_context(|| {
505                format!(
506                    "Transitive dep outside parent's worktree: {} not under {}",
507                    trans_canonical.display(),
508                    canonical_worktree.display()
509                )
510            })
511            .map(|p| p.to_path_buf())
512    }
513}
514
515/// Determine the tool for a transitive dependency.
516fn determine_transitive_tool(
517    ctx: &TransitiveContext<'_>,
518    parent_dep: &ResourceDependency,
519    dep_spec: &crate::manifest::DependencySpec,
520    parent_resource_type: ResourceType,
521    dep_resource_type: ResourceType,
522) -> Option<String> {
523    if let Some(explicit_tool) = &dep_spec.tool {
524        Some(explicit_tool.clone())
525    } else {
526        let parent_tool = parent_dep
527            .get_tool()
528            .map(str::to_string)
529            .unwrap_or_else(|| ctx.base.manifest.get_default_tool(parent_resource_type));
530        if ctx.base.manifest.is_resource_supported(&parent_tool, dep_resource_type) {
531            Some(parent_tool)
532        } else {
533            Some(ctx.base.manifest.get_default_tool(dep_resource_type))
534        }
535    }
536}
537
538/// Add a dependency to the conflict detector.
539fn add_to_conflict_detector(
540    ctx: &mut TransitiveContext<'_>,
541    name: &str,
542    dep: &ResourceDependency,
543    requester: &str,
544) {
545    if let Some(version) = dep.get_version() {
546        ctx.conflict_detector.add_requirement(name, requester, version);
547    }
548}
549
550/// Build the final ordered result from the dependency graph.
551fn build_ordered_result(
552    all_deps: HashMap<DependencyKey, ResourceDependency>,
553    ordered_nodes: Vec<DependencyNode>,
554) -> Result<Vec<(String, ResourceDependency, ResourceType)>> {
555    let mut result = Vec::new();
556    let mut added_keys = HashSet::new();
557
558    tracing::debug!(
559        "Transitive resolution - topological order has {} nodes, all_deps has {} entries",
560        ordered_nodes.len(),
561        all_deps.len()
562    );
563
564    for node in ordered_nodes {
565        tracing::debug!(
566            "Processing ordered node: {}/{} (source: {:?})",
567            node.resource_type,
568            node.name,
569            node.source
570        );
571
572        // Find matching dependency
573        for (key, dep) in &all_deps {
574            if key.0 == node.resource_type && key.1 == node.name && key.2 == node.source {
575                tracing::debug!(
576                    "  -> Found match in all_deps, adding to result with type {:?}",
577                    node.resource_type
578                );
579                result.push((node.name.clone(), dep.clone(), node.resource_type));
580                added_keys.insert(key.clone());
581                break;
582            }
583        }
584    }
585
586    // Add remaining dependencies that weren't in the graph (no transitive deps)
587    for (key, dep) in all_deps {
588        if !added_keys.contains(&key) && !dep.is_pattern() {
589            tracing::debug!(
590                "Adding non-graph dependency: {}/{} (source: {:?}) with type {:?}",
591                key.0,
592                key.1,
593                key.2,
594                key.0
595            );
596            result.push((key.1.clone(), dep.clone(), key.0));
597        }
598    }
599
600    tracing::debug!("Transitive resolution returning {} dependencies", result.len());
601
602    Ok(result)
603}
604
605/// Generate unique key for grouping dependencies by source and version.
606pub fn group_key(source: &str, version: &str) -> String {
607    format!("{source}::{version}")
608}
609
610/// Service-based wrapper for transitive dependency resolution.
611///
612/// This provides a simpler API for internal use that takes service references
613/// directly instead of requiring closure-based dependency injection.
614pub async fn resolve_with_services(
615    ctx: &mut TransitiveContext<'_>,
616    core: &super::ResolutionCore,
617    base_deps: &[(String, ResourceDependency, ResourceType)],
618    enable_transitive: bool,
619    prepared_versions: &HashMap<String, PreparedSourceVersion>,
620    pattern_alias_map: &mut HashMap<(ResourceType, String), String>,
621    services: &mut ResolutionServices<'_>,
622) -> Result<Vec<(String, ResourceDependency, ResourceType)>> {
623    // Clear state from any previous resolution
624    ctx.dependency_map.clear();
625
626    if !enable_transitive {
627        return Ok(base_deps.to_vec());
628    }
629
630    let mut graph = DependencyGraph::new();
631    let mut all_deps: HashMap<DependencyKey, ResourceDependency> = HashMap::new();
632    let mut processed: HashSet<DependencyKey> = HashSet::new();
633    let mut queue: Vec<(String, ResourceDependency, Option<ResourceType>, String)> = Vec::new();
634
635    // Add initial dependencies to queue with their threaded types
636    for (name, dep, resource_type) in base_deps {
637        let source = dep.get_source().map(std::string::ToString::to_string);
638        let tool = dep.get_tool().map(std::string::ToString::to_string);
639
640        // Compute variant_hash from MERGED variant_inputs (dep + global config)
641        // This ensures consistency with how LockedResource computes its hash
642        let merged_variant_inputs =
643            super::lockfile_builder::build_merged_variant_inputs(ctx.base.manifest, dep);
644        let variant_hash = crate::utils::compute_variant_inputs_hash(&merged_variant_inputs)
645            .unwrap_or_else(|_| crate::utils::EMPTY_VARIANT_INPUTS_HASH.to_string());
646
647        tracing::debug!(
648            "[DEBUG] Adding base dep to queue: '{}' (type: {:?}, source: {:?}, tool: {:?}, is_local: {})",
649            name,
650            resource_type,
651            source,
652            tool,
653            dep.is_local()
654        );
655        // Store pre-computed hash in queue to avoid duplicate computation
656        queue.push((name.clone(), dep.clone(), Some(*resource_type), variant_hash.clone()));
657        all_deps.insert((*resource_type, name.clone(), source, tool, variant_hash), dep.clone());
658    }
659
660    // Process queue to discover transitive dependencies
661    while let Some((name, dep, resource_type, variant_hash)) = queue.pop() {
662        let source = dep.get_source().map(std::string::ToString::to_string);
663        let tool = dep.get_tool().map(std::string::ToString::to_string);
664
665        let resource_type =
666            resource_type.expect("resource_type should always be threaded through queue");
667        let key = (resource_type, name.clone(), source.clone(), tool.clone(), variant_hash.clone());
668
669        tracing::debug!(
670            "[TRANSITIVE] Processing: '{}' (type: {:?}, source: {:?})",
671            name,
672            resource_type,
673            source
674        );
675
676        // Check if this queue entry is stale (superseded by conflict resolution)
677        if let Some(current_dep) = all_deps.get(&key) {
678            if current_dep.get_version() != dep.get_version() {
679                tracing::debug!("[TRANSITIVE] Skipped stale: '{}'", name);
680                continue;
681            }
682        }
683
684        if processed.contains(&key) {
685            tracing::debug!("[TRANSITIVE] Already processed: '{}'", name);
686            continue;
687        }
688
689        processed.insert(key.clone());
690
691        // Handle pattern dependencies by expanding them to concrete files
692        if dep.is_pattern() {
693            tracing::debug!("[TRANSITIVE] Expanding pattern: '{}'", name);
694            match services
695                .pattern_service
696                .expand_pattern(core, &dep, resource_type, services.version_service)
697                .await
698            {
699                Ok(concrete_deps) => {
700                    for (concrete_name, concrete_dep) in concrete_deps {
701                        pattern_alias_map
702                            .insert((resource_type, concrete_name.clone()), name.clone());
703
704                        let concrete_source =
705                            concrete_dep.get_source().map(std::string::ToString::to_string);
706                        let concrete_tool =
707                            concrete_dep.get_tool().map(std::string::ToString::to_string);
708                        let concrete_variant_hash = compute_dependency_variant_hash(&concrete_dep);
709                        let concrete_key = (
710                            resource_type,
711                            concrete_name.clone(),
712                            concrete_source,
713                            concrete_tool,
714                            concrete_variant_hash.clone(),
715                        );
716
717                        if let std::collections::hash_map::Entry::Vacant(e) =
718                            all_deps.entry(concrete_key)
719                        {
720                            e.insert(concrete_dep.clone());
721                            queue.push((
722                                concrete_name,
723                                concrete_dep,
724                                Some(resource_type),
725                                concrete_variant_hash,
726                            ));
727                        }
728                    }
729                }
730                Err(e) => {
731                    anyhow::bail!("Failed to expand pattern '{}': {}", dep.get_path(), e);
732                }
733            }
734            continue;
735        }
736
737        // Fetch resource content for metadata extraction
738        let content = ResourceFetchingService::fetch_content(core, &dep, services.version_service)
739            .await
740            .with_context(|| {
741                format!(
742                    "Failed to fetch resource '{}' ({}) for transitive deps",
743                    name,
744                    dep.get_path()
745                )
746            })?;
747
748        tracing::debug!("[TRANSITIVE] Fetched content for '{}' ({} bytes)", name, content.len());
749
750        // Build complete template_vars including global project config for metadata extraction
751        // This ensures transitive dependencies can use template variables like {{ agpm.project.language }}
752        let variant_inputs_value =
753            super::lockfile_builder::build_merged_variant_inputs(ctx.base.manifest, &dep);
754        let variant_inputs = Some(&variant_inputs_value);
755
756        // Extract metadata from the resource with complete variant_inputs
757        let path = PathBuf::from(dep.get_path());
758        let metadata = MetadataExtractor::extract(
759            &path,
760            &content,
761            variant_inputs,
762            ctx.base.operation_context.map(|arc| arc.as_ref()),
763        )?;
764
765        tracing::debug!(
766            "[DEBUG] Extracted metadata for '{}': has_deps={}",
767            name,
768            metadata.get_dependencies().is_some()
769        );
770
771        // Process transitive dependencies if present
772        if let Some(deps_map) = metadata.get_dependencies() {
773            tracing::debug!(
774                "[DEBUG] Found {} dependency type(s) for '{}': {:?}",
775                deps_map.len(),
776                name,
777                deps_map.keys().collect::<Vec<_>>()
778            );
779
780            for (dep_resource_type_str, dep_specs) in deps_map {
781                let dep_resource_type: ResourceType =
782                    dep_resource_type_str.parse().unwrap_or(ResourceType::Snippet);
783
784                for dep_spec in dep_specs {
785                    // Process each transitive dependency spec
786                    let (trans_dep, trans_name) = process_transitive_dependency_spec(
787                        ctx,
788                        core,
789                        &dep,
790                        dep_resource_type,
791                        resource_type,
792                        &name,
793                        dep_spec,
794                        services.version_service,
795                        prepared_versions,
796                    )
797                    .await?;
798
799                    let trans_source = trans_dep.get_source().map(std::string::ToString::to_string);
800                    let trans_tool = trans_dep.get_tool().map(std::string::ToString::to_string);
801                    let trans_variant_hash = compute_dependency_variant_hash(&trans_dep);
802
803                    // Store custom name if provided
804                    if let Some(custom_name) = &dep_spec.name {
805                        let trans_key = (
806                            dep_resource_type,
807                            trans_name.clone(),
808                            trans_source.clone(),
809                            trans_tool.clone(),
810                            trans_variant_hash.clone(),
811                        );
812                        ctx.transitive_custom_names.insert(trans_key, custom_name.clone());
813                        tracing::debug!(
814                            "Storing custom name '{}' for transitive dep '{}'",
815                            custom_name,
816                            trans_name
817                        );
818                    }
819
820                    // Add to dependency graph
821                    let from_node =
822                        DependencyNode::with_source(resource_type, &name, source.clone());
823                    let to_node = DependencyNode::with_source(
824                        dep_resource_type,
825                        &trans_name,
826                        trans_source.clone(),
827                    );
828                    graph.add_dependency(from_node, to_node);
829
830                    // Track in dependency map
831                    let from_key = (
832                        resource_type,
833                        name.clone(),
834                        source.clone(),
835                        tool.clone(),
836                        variant_hash.clone(),
837                    );
838                    let dep_ref =
839                        LockfileDependencyRef::local(dep_resource_type, trans_name.clone(), None)
840                            .to_string();
841                    tracing::debug!(
842                        "[DEBUG] Adding to dependency_map: parent='{}' (type={:?}, source={:?}, tool={:?}, hash={}), child='{}' (type={:?})",
843                        name,
844                        resource_type,
845                        source,
846                        tool,
847                        &variant_hash[..8],
848                        dep_ref,
849                        dep_resource_type
850                    );
851                    ctx.dependency_map.entry(from_key).or_default().push(dep_ref);
852
853                    // Add to conflict detector
854                    add_to_conflict_detector(ctx, &trans_name, &trans_dep, &name);
855
856                    // Check for version conflicts
857                    let trans_key = (
858                        dep_resource_type,
859                        trans_name.clone(),
860                        trans_source.clone(),
861                        trans_tool.clone(),
862                        trans_variant_hash.clone(),
863                    );
864
865                    tracing::debug!(
866                        "[TRANSITIVE] Found transitive dep '{}' (type: {:?}, tool: {:?}, parent: {})",
867                        trans_name,
868                        dep_resource_type,
869                        trans_tool,
870                        name
871                    );
872
873                    // Check if we already have this dependency
874                    if let std::collections::hash_map::Entry::Vacant(e) = all_deps.entry(trans_key)
875                    {
876                        // No conflict, add the dependency
877                        tracing::debug!(
878                            "Adding transitive dep '{}' (parent: {})",
879                            trans_name,
880                            name
881                        );
882                        e.insert(trans_dep.clone());
883                        queue.push((
884                            trans_name,
885                            trans_dep,
886                            Some(dep_resource_type),
887                            trans_variant_hash,
888                        ));
889                    } else {
890                        // Dependency already exists - conflict detector will handle version requirement conflicts
891                        tracing::debug!(
892                            "[TRANSITIVE] Skipping duplicate transitive dep '{}' (already processed)",
893                            trans_name
894                        );
895                    }
896                }
897            }
898        }
899    }
900
901    // Check for circular dependencies
902    graph.detect_cycles()?;
903
904    // Get topological order
905    let ordered_nodes = graph.topological_order()?;
906
907    // Build result with topologically ordered dependencies
908    build_ordered_result(all_deps, ordered_nodes)
909}