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