Modules/businessdev.ALbuild.Core/Public/Get-BcProjectBuildOrder.ps1

function Get-BcProjectBuildOrder {
    <#
    .SYNOPSIS
        Discovers the AL projects under a repository root and orders them by inter-project dependency.
 
    .DESCRIPTION
        Finds every app.json under -Path (recursively, excluding symbol/package/output folders), reads
        each project's id/name/publisher/version and its declared dependency ids, drops any project
        whose folder leaf name is listed in -ExcludeProjects, then returns the projects topologically
        sorted so that a project always comes after the in-repo projects it depends on (e.g. 'app'
        before 'test'/'migration'). Dependencies on apps that are not part of the repository are
        ignored for ordering - they come from feeds or are already in the container. Throws on a
        dependency cycle.
 
        This is the single source of "which projects, in what order" for the multi-root build: the
        ResolveDependencies, CompileApp and PublishApp tasks all walk this list.
 
    .PARAMETER Path
        Repository root to search, a single app folder, or a single app.json. Default: current location.
 
    .PARAMETER ExcludeProjects
        Folder leaf names to exclude from the build (case-insensitive), e.g. @('migration').
 
    .EXAMPLE
        Get-BcProjectBuildOrder -Path . -ExcludeProjects 'migration'
 
    .OUTPUTS
        PSCustomObject per project, in build order: Folder, Name, Id, Publisher, Version, AppJsonPath,
        RepoDependencyIds.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Position = 0)] [string] $Path = (Get-Location).Path,
        [string[]] $ExcludeProjects = @()
    )

    if (-not (Test-Path -LiteralPath $Path)) { throw "Path '$Path' does not exist." }

    $item = Get-Item -LiteralPath $Path
    if ($item.PSIsContainer) {
        $appJsonFiles = @(Get-ChildItem -LiteralPath $item.FullName -Filter 'app.json' -File -Recurse -ErrorAction SilentlyContinue |
                Where-Object { $_.FullName -notmatch '[\\/](\.alpackages|\.altemplates|\.snapshots|\.output)[\\/]' })
    }
    elseif ($item.Name -eq 'app.json') {
        $appJsonFiles = @($item)
    }
    else {
        throw "Path '$Path' is neither a folder nor an app.json file."
    }

    $hasProp = { param($obj, $name) $null -ne $obj -and ($obj.PSObject.Properties.Name -contains $name) }

    # Read each project's manifest into a descriptor.
    $projects = foreach ($file in $appJsonFiles) {
        try {
            $json = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8 | ConvertFrom-Json
        }
        catch {
            Write-ALbuildLog -Level Warning "Ignoring invalid app.json '$($file.FullName)': $($_.Exception.Message)."
            continue
        }

        $depIds = @()
        if (& $hasProp $json 'dependencies') {
            foreach ($dep in @($json.dependencies)) {
                $id = if (& $hasProp $dep 'id') { "$($dep.id)" } elseif (& $hasProp $dep 'appId') { "$($dep.appId)" } else { '' }
                if ($id) { $depIds += $id.ToLowerInvariant() }
            }
        }

        [PSCustomObject]@{
            Folder            = $file.Directory.FullName
            Leaf              = $file.Directory.Name
            Name              = if (& $hasProp $json 'name') { "$($json.name)" } else { $file.Directory.Name }
            Id                = if (& $hasProp $json 'id') { "$($json.id)".ToLowerInvariant() } else { '' }
            Publisher         = if (& $hasProp $json 'publisher') { "$($json.publisher)" } else { '' }
            Version           = if (& $hasProp $json 'version') { "$($json.version)" } else { '' }
            AppJsonPath       = $file.FullName
            DependencyIds     = $depIds
            RepoDependencyIds = @()
        }
    }
    $projects = @($projects)

    # Apply exclusions by folder leaf name (case-insensitive; -contains is case-insensitive for strings).
    if ($ExcludeProjects -and $ExcludeProjects.Count -gt 0) {
        $projects = @($projects | Where-Object { $ExcludeProjects -notcontains $_.Leaf })
    }
    if ($projects.Count -eq 0) { return @() }

    # Restrict each project's dependencies to the ones that are themselves projects in this repo.
    $idToProject = @{}
    foreach ($p in $projects) { if ($p.Id) { $idToProject[$p.Id] = $p } }
    foreach ($p in $projects) {
        $p.RepoDependencyIds = @($p.DependencyIds | Where-Object { $idToProject.ContainsKey($_) -and $_ -ne $p.Id })
    }

    # Topological sort: emit a project once all of its in-repo dependencies have been emitted.
    $doneIds = [System.Collections.Generic.HashSet[string]]::new()
    $ordered = [System.Collections.Generic.List[object]]::new()
    $remaining = [System.Collections.Generic.List[object]]::new()
    foreach ($p in $projects) { [void]$remaining.Add($p) }

    while ($remaining.Count -gt 0) {
        $progressed = $false
        foreach ($p in @($remaining)) {
            $unmet = @($p.RepoDependencyIds | Where-Object { -not $doneIds.Contains($_) })
            if ($unmet.Count -eq 0) {
                [void]$ordered.Add($p)
                if ($p.Id) { [void]$doneIds.Add($p.Id) }
                [void]$remaining.Remove($p)
                $progressed = $true
            }
        }
        if (-not $progressed) {
            throw "Cyclic inter-project dependency among: $(@($remaining | ForEach-Object { $_.Leaf }) -join ', ')."
        }
    }

    return @($ordered | Select-Object Folder, Name, Id, Publisher, Version, AppJsonPath, RepoDependencyIds)
}