Public/Get-WingetDependencyGraph.ps1

function Get-WingetDependencyGraph {
    <#
    .SYNOPSIS
        Visualize package dependency relationships as a graph.
 
    .DESCRIPTION
        Queries winget package manifests for dependency information and renders
        the relationships as a directed graph. Supports multiple output formats:
        Mermaid (for GitHub/docs), DOT (for Graphviz), ASCII tree, and structured
        object output for pipeline use.
 
        Can visualize dependencies for a specific package, all installed packages,
        or a curated list. Also detects circular dependencies and orphan packages.
 
    .PARAMETER PackageId
        One or more package IDs to analyze dependencies for.
 
    .PARAMETER AllInstalled
        Build a graph of all installed packages and their relationships.
 
    .PARAMETER Format
        Output format: Mermaid (default), DOT, Tree, or Object.
 
    .PARAMETER Depth
        Maximum dependency depth to traverse. Default: 3.
 
    .PARAMETER IncludeExternal
        Include external dependencies (Windows Features, Store packages).
 
    .PARAMETER HighlightCircular
        Detect and highlight circular dependency chains.
 
    .PARAMETER OutputPath
        Save the graph output to a file instead of displaying it.
 
    .EXAMPLE
        Get-WingetDependencyGraph -PackageId "Python.Python.3.12"
        Shows the dependency tree for Python 3.12.
 
    .EXAMPLE
        Get-WingetDependencyGraph -PackageId "Microsoft.VisualStudioCode" -Format DOT
        Outputs Graphviz DOT format for rendering with dot/neato.
 
    .EXAMPLE
        Get-WingetDependencyGraph -AllInstalled -Format Mermaid -Depth 2
        Generates a Mermaid diagram of all installed package relationships.
 
    .EXAMPLE
        Get-WingetDependencyGraph -PackageId "Git.Git","Node.js" -Format Tree
        ASCII tree view of dependencies for Git and Node.js.
 
    .NOTES
        Author: Matthew Bubb
        Dependency data is sourced from winget-pkgs GitHub manifests and local COM API.
    #>

    [CmdletBinding(DefaultParameterSetName = 'ById')]
    param(
        [Parameter(ParameterSetName = 'ById', Mandatory, Position = 0, ValueFromPipeline)]
        [Alias('Id')]
        [string[]]$PackageId,

        [Parameter(ParameterSetName = 'AllInstalled', Mandatory)]
        [switch]$AllInstalled,

        [ValidateSet('Mermaid', 'DOT', 'Tree', 'Object')]
        [string]$Format = 'Mermaid',

        [ValidateRange(1, 10)]
        [int]$Depth = 3,

        [switch]$IncludeExternal,

        [switch]$HighlightCircular,

        [string]$OutputPath
    )

    begin {
        $graph = @{
            Nodes = [System.Collections.Generic.Dictionary[string, hashtable]]::new([System.StringComparer]::OrdinalIgnoreCase)
            Edges = [System.Collections.Generic.List[hashtable]]::new()
            Circular = [System.Collections.Generic.List[string]]::new()
        }
        $visited = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        $inStack = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

        function Add-GraphNode {
            param([string]$Id, [string]$Label, [string]$Type = 'package')
            if (-not $graph.Nodes.ContainsKey($Id)) {
                $graph.Nodes[$Id] = @{ Id = $Id; Label = $Label; Type = $Type; Dependencies = @(); Dependents = @() }
            }
        }

        function Add-GraphEdge {
            param([string]$From, [string]$To, [string]$Relation = 'depends-on')
            $graph.Edges.Add(@{ From = $From; To = $To; Relation = $Relation })
            if ($graph.Nodes.ContainsKey($From)) { $graph.Nodes[$From].Dependencies += $To }
            if ($graph.Nodes.ContainsKey($To)) { $graph.Nodes[$To].Dependents += $From }
        }

        function Get-PackageDependencies {
            param([string]$Id, [int]$CurrentDepth)

            if ($CurrentDepth -gt $Depth) { return }
            if ($visited.Contains($Id)) {
                # Circular dependency detection
                if ($inStack.Contains($Id) -and $HighlightCircular) {
                    $graph.Circular.Add($Id)
                }
                return
            }

            $visited.Add($Id) | Out-Null
            $inStack.Add($Id) | Out-Null

            # Try COM API first for installed package info
            $deps = @()
            try {
                $pkgResult = Microsoft.WinGet.Client\Get-WinGetPackage -Id $Id -ErrorAction SilentlyContinue
                if ($pkgResult) {
                    Add-GraphNode -Id $Id -Label "$($pkgResult.Name) ($Id)" -Type 'installed'
                }
            } catch { }

            # Fetch manifest from GitHub for dependency info
            $manifestDeps = Get-ManifestDependencies -PackageId $Id
            if ($manifestDeps) {
                Add-GraphNode -Id $Id -Label $Id -Type 'package'
                foreach ($dep in $manifestDeps) {
                    $depId = $dep.PackageIdentifier
                    $depType = $dep.Type ?? 'package'

                    if (-not $IncludeExternal -and $depType -in @('WindowsFeature', 'WindowsStore')) {
                        continue
                    }

                    Add-GraphNode -Id $depId -Label $depId -Type $depType
                    Add-GraphEdge -From $Id -To $depId -Relation $depType
                    Get-PackageDependencies -Id $depId -CurrentDepth ($CurrentDepth + 1)
                }
            }

            $inStack.Remove($Id) | Out-Null
        }

        function Get-ManifestDependencies {
            param([string]$PkgId)

            try {
                # winget-pkgs manifest path: manifests/p/Publisher/Package/Version/Publisher.Package.yaml
                $idParts = $PkgId.Split('.')
                if ($idParts.Count -lt 2) { return $null }

                $publisher = $idParts[0]
                $firstLetter = $publisher[0].ToString().ToLower()
                $packagePath = $idParts -join '/'

                # Try to get the latest version manifest
                $apiBase = "https://api.github.com/repos/microsoft/winget-pkgs/contents"
                $manifestDir = "$apiBase/manifests/$firstLetter/$packagePath"

                $headers = @{ 'User-Agent' = 'WingetBatch' }
                $token = Get-WingetBatchGitHubToken -ErrorAction SilentlyContinue
                if ($token) { $headers['Authorization'] = "Bearer $token" }

                $versions = Invoke-RestMethod -Uri $manifestDir -Headers $headers -ErrorAction SilentlyContinue
                if (-not $versions) { return $null }

                # Get latest version
                $latest = $versions | Sort-Object { [version]($_.name -replace '[^0-9.]', '') } -ErrorAction SilentlyContinue | Select-Object -Last 1
                if (-not $latest) { $latest = $versions | Select-Object -Last 1 }

                # Fetch the installer manifest (contains dependencies)
                $versionFiles = Invoke-RestMethod -Uri $latest.url -Headers $headers -ErrorAction SilentlyContinue
                $installerManifest = $versionFiles | Where-Object { $_.name -match '\.installer\.yaml$' } | Select-Object -First 1

                if ($installerManifest) {
                    $content = Invoke-RestMethod -Uri $installerManifest.url -Headers $headers -ErrorAction SilentlyContinue
                    # Parse YAML content for Dependencies section
                    $rawContent = $content.content
                    if ($content.encoding -eq 'base64') {
                        $rawContent = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($rawContent))
                    }

                    # Simple YAML parsing for dependencies
                    $deps = @()
                    $inDeps = $false
                    $currentDep = @{}
                    foreach ($line in $rawContent.Split("`n")) {
                        if ($line -match '^\s*Dependencies:') { $inDeps = $true; continue }
                        if ($inDeps) {
                            if ($line -match '^\s*-\s*PackageIdentifier:\s*(.+)') {
                                if ($currentDep.Count -gt 0) { $deps += $currentDep }
                                $currentDep = @{ PackageIdentifier = $Matches[1].Trim(); Type = 'package' }
                            }
                            elseif ($line -match '^\s*WindowsFeatures:') { $inDeps = $false }
                            elseif ($line -match '^\s*ExternalDependencies:') { $inDeps = $false }
                            elseif ($line -match '^\S' -and $line -notmatch '^\s') { $inDeps = $false }
                        }
                    }
                    if ($currentDep.Count -gt 0) { $deps += $currentDep }
                    return $deps
                }
            } catch {
                Write-Verbose "Could not fetch manifest for ${PkgId}: $_"
            }
            return $null
        }
    }

    process {
        if ($AllInstalled) {
            Write-Progress -Activity "Building dependency graph" -Status "Enumerating installed packages..." -PercentComplete 0
            $installed = Microsoft.WinGet.Client\Get-WinGetPackage -ErrorAction SilentlyContinue
            $total = $installed.Count
            $i = 0

            foreach ($pkg in $installed) {
                $i++
                Write-Progress -Activity "Building dependency graph" -Status "Analyzing $($pkg.Id)" -PercentComplete (($i / $total) * 100)
                Add-GraphNode -Id $pkg.Id -Label "$($pkg.Name)" -Type 'installed'
                Get-PackageDependencies -Id $pkg.Id -CurrentDepth 1
            }
            Write-Progress -Activity "Building dependency graph" -Completed
        }
        else {
            foreach ($id in $PackageId) {
                Add-GraphNode -Id $id -Label $id -Type 'root'
                Get-PackageDependencies -Id $id -CurrentDepth 1
            }
        }
    }

    end {
        # Generate output based on format
        $output = switch ($Format) {
            'Mermaid' { Format-MermaidGraph }
            'DOT' { Format-DOTGraph }
            'Tree' { Format-TreeGraph }
            'Object' {
                [PSCustomObject]@{
                    Nodes = $graph.Nodes.Values | ForEach-Object {
                        [PSCustomObject]@{ Id = $_.Id; Label = $_.Label; Type = $_.Type; Dependencies = $_.Dependencies; Dependents = $_.Dependents }
                    }
                    Edges = $graph.Edges | ForEach-Object { [PSCustomObject]$_ }
                    CircularDependencies = $graph.Circular
                    Stats = [PSCustomObject]@{
                        TotalNodes = $graph.Nodes.Count
                        TotalEdges = $graph.Edges.Count
                        CircularCount = $graph.Circular.Count
                        OrphanNodes = ($graph.Nodes.Values | Where-Object { $_.Dependencies.Count -eq 0 -and $_.Dependents.Count -eq 0 }).Count
                    }
                }
            }
        }

        if ($OutputPath) {
            $output | Set-Content -Path $OutputPath -Encoding UTF8
            Write-Host " Graph saved to: $OutputPath" -ForegroundColor Green
            Write-Host " Nodes: $($graph.Nodes.Count) | Edges: $($graph.Edges.Count)" -ForegroundColor DarkGray
        } else {
            $output
        }

        # Warn about circular dependencies
        if ($graph.Circular.Count -gt 0) {
            Write-Warning "Circular dependencies detected: $($graph.Circular -join ', ')"
        }
    }
}

function Format-MermaidGraph {
    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.AppendLine("graph TD")

    # Node definitions with shapes based on type
    foreach ($node in $graph.Nodes.Values) {
        $safeId = $node.Id -replace '[^a-zA-Z0-9]', '_'
        $label = $node.Label -replace '"', "'"
        switch ($node.Type) {
            'root' { [void]$sb.AppendLine(" $safeId[/$label/]") }  # Parallelogram for root
            'installed' { [void]$sb.AppendLine(" $safeId[$label]") }  # Rectangle
            'WindowsFeature' { [void]$sb.AppendLine(" $safeId(($label))") }  # Circle
            default { [void]$sb.AppendLine(" $safeId[$label]") }
        }
    }

    # Edges
    foreach ($edge in $graph.Edges) {
        $fromSafe = $edge.From -replace '[^a-zA-Z0-9]', '_'
        $toSafe = $edge.To -replace '[^a-zA-Z0-9]', '_'
        $style = if ($edge.Relation -eq 'WindowsFeature') { '-.->' } else { '-->' }
        [void]$sb.AppendLine(" $fromSafe $style $toSafe")
    }

    # Highlight circular
    if ($graph.Circular.Count -gt 0) {
        [void]$sb.AppendLine("")
        foreach ($circ in $graph.Circular) {
            $safeId = $circ -replace '[^a-zA-Z0-9]', '_'
            [void]$sb.AppendLine(" style $safeId stroke:#f00,stroke-width:3px")
        }
    }

    $sb.ToString()
}

function Format-DOTGraph {
    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.AppendLine("digraph WingetDependencies {")
    [void]$sb.AppendLine(" rankdir=LR;")
    [void]$sb.AppendLine(" node [shape=box, style=rounded, fontname=`"Segoe UI`"];")
    [void]$sb.AppendLine(" edge [fontname=`"Segoe UI`", fontsize=10];")
    [void]$sb.AppendLine("")

    foreach ($node in $graph.Nodes.Values) {
        $attrs = "label=`"$($node.Label)`""
        switch ($node.Type) {
            'root' { $attrs += ", style=`"rounded,bold`", color=`"#2196F3`"" }
            'installed' { $attrs += ", color=`"#4CAF50`"" }
            'WindowsFeature' { $attrs += ", shape=ellipse, color=`"#FF9800`"" }
        }
        $safeId = "`"$($node.Id)`""
        [void]$sb.AppendLine(" $safeId [$attrs];")
    }

    [void]$sb.AppendLine("")
    foreach ($edge in $graph.Edges) {
        $style = if ($edge.Relation -eq 'WindowsFeature') { " [style=dashed, label=`"feature`"]" } else { "" }
        [void]$sb.AppendLine(" `"$($edge.From)`" -> `"$($edge.To)`"$style;")
    }

    [void]$sb.AppendLine("}")
    $sb.ToString()
}

function Format-TreeGraph {
    $sb = [System.Text.StringBuilder]::new()
    $roots = $graph.Nodes.Values | Where-Object { $_.Type -eq 'root' -or $_.Dependents.Count -eq 0 }

    function Write-TreeNode {
        param([hashtable]$Node, [string]$Prefix = "", [bool]$IsLast = $true)

        $connector = if ($Prefix -eq "") { "" } elseif ($IsLast) { "└── " } else { "├── " }
        $typeIcon = switch ($Node.Type) {
            'root' { "◆" }
            'installed' { "●" }
            'WindowsFeature' { "○" }
            default { "○" }
        }

        [void]$sb.AppendLine("$Prefix$connector$typeIcon $($Node.Label)")

        $childPrefix = if ($Prefix -eq "") { "" } elseif ($IsLast) { "$Prefix " } else { "$Prefix│ " }
        $deps = $Node.Dependencies | Select-Object -Unique
        for ($i = 0; $i -lt $deps.Count; $i++) {
            $childId = $deps[$i]
            if ($graph.Nodes.ContainsKey($childId)) {
                Write-TreeNode -Node $graph.Nodes[$childId] -Prefix $childPrefix -IsLast ($i -eq $deps.Count - 1)
            }
        }
    }

    foreach ($root in $roots) {
        Write-TreeNode -Node $root
        [void]$sb.AppendLine("")
    }

    $sb.ToString()
}