Modules/businessdev.ALbuild.RuntimePackages/Private/Get-BcRuntimeProductOrder.ps1

function Get-BcRuntimeProductOrder {
    <#
    .SYNOPSIS
        Orders catalogue products so every product comes after the products it depends on.
 
    .DESCRIPTION
        Inside one container the products are installed in sequence, and a product cannot be installed
        before the products it depends on. Several 365 business products depend on other products in the
        same catalogue (Extension License, 365 business API), so the order is a correctness requirement,
        not a preference.
 
        The sort is a depth-first topological sort that is STABLE: products with no ordering constraint
        between them keep their input order. That keeps the plan - and therefore the log, the per-app
        results and the run-to-run diff - reproducible instead of reshuffling on every run.
 
        A dependency cycle cannot be ordered. Rather than throwing (which would take down a whole
        catalogue run over one bad manifest) the products in the cycle are appended in input order and
        the caller is warned: the run then fails for those products only, at install time, with the real
        error from the service tier.
 
        Names in DependsOn that are not in the catalogue are ignored on purpose - a product may depend on
        a Microsoft app or on a third-party dependency resolved from a feed, neither of which this
        function has an opinion about.
 
    .PARAMETER Product
        Catalogue entries with Name and optional DependsOn (names of other entries).
 
    .OUTPUTS
        System.Object[] - the same objects, ordered.
    #>

    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [object[]] $Product
    )

    $byName = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($p in $Product) { $byName["$($p.Name)"] = $p }

    $ordered = [System.Collections.Generic.List[object]]::new()
    $state = @{}   # name -> 'visiting' | 'done'
    $cycles = [System.Collections.Generic.List[string]]::new()

    $visit = {
        param([string] $Name)

        if ($state[$Name] -eq 'done') { return }
        if ($state[$Name] -eq 'visiting') {
            if (-not $cycles.Contains($Name)) { $cycles.Add($Name) }
            return
        }
        $state[$Name] = 'visiting'

        $entry = $byName[$Name]
        $dependsOn = @(Get-BcRuntimeProperty -InputObject $entry -Name 'DependsOn' -Default @())
        foreach ($dep in $dependsOn) {
            $depName = "$dep"
            if ([string]::IsNullOrWhiteSpace($depName)) { continue }
            if (-not $byName.Contains($depName)) { continue }   # external dependency - not ours to order
            & $visit $depName
        }

        $state[$Name] = 'done'
        $ordered.Add($entry)
    }

    foreach ($p in $Product) { & $visit "$($p.Name)" }

    if ($cycles.Count -gt 0) {
        Write-ALbuildLog -Level Warning ("Dependency cycle between catalogue products: $($cycles -join ', '). " +
            'They are ordered as given; an install that genuinely needs the missing dependency will fail for those products only.')
    }

    return $ordered.ToArray()
}