Modules/businessdev.ALbuild.Apps/Private/Resolve-BcCallGraphDependency.ps1

function Resolve-BcCallGraphDependency {
    <#
    .SYNOPSIS
        Finds the dependency closure of the given apps and unpacks the source of every dependency that ships it.
 
    .DESCRIPTION
        al graph compiles source only: a call into an app whose source is not part of the corpus is not
        resolved, and a call that merely passes a type of such an app is not bound at all. So the source of
        the dependencies - Microsoft's from the BC artifact, others from the package caches - is unpacked
        into a cache folder and added to the corpus.
 
        Packages are matched by their file name ('<Publisher>_<Name>[_<Version>].app') and confirmed by the
        app id in their manifest; the one with source and the highest version wins. ReadyToRun packages,
        which wrap the real app as an inner .app, are read through. Unpacked sources are cached per app id and
        version under '<BaseFolder>\callgraph\src' and reused by later builds on the same agent.
 
    .PARAMETER Apps
        The own apps: objects with Id plus the app.json 'dependencies' and 'application' of each project.
 
    .PARAMETER SearchFolder
        Folders searched (recursively) for .app packages.
 
    .PARAMETER CacheFolder
        Where unpacked sources are kept. Default '<BaseFolder>\callgraph\src'.
 
    .OUTPUTS
        PSCustomObject per dependency: Id, Name, Publisher, Version, HasSource, SourceFolder, PackagePath.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [object[]] $Apps,
        [string[]] $SearchFolder = @(),
        [string] $CacheFolder
    )

    Import-BcCallGraphEngine
    if (-not $CacheFolder) { $CacheFolder = Join-Path -Path (Join-Path -Path (Get-ALbuildConfig -Name 'BaseFolder') -ChildPath 'callgraph') -ChildPath 'src' }

    # The 'Application' umbrella app stands for Base Application, System Application, Business Foundation...
    $applicationApp = 'c1335042-3002-4257-bf8a-75c898ccb1b8'
    $ownIds = @{}
    foreach ($a in $Apps) { $ownIds["$($a.Id)".ToLowerInvariant()] = $true }

    # File index: every .app below the search folders, by lower-case file name.
    $packages = New-Object System.Collections.Generic.List[System.IO.FileInfo]
    foreach ($folder in ($SearchFolder | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -Unique)) {
        foreach ($f in (Get-ChildItem -LiteralPath $folder -Filter '*.app' -File -Recurse -ErrorAction SilentlyContinue)) { $packages.Add($f) }
    }
    Write-ALbuildLog "Call graph: $($packages.Count) package(s) found in $(@($SearchFolder).Count) search folder(s)."

    $queue = New-Object System.Collections.Generic.Queue[object]
    foreach ($a in $Apps) {
        foreach ($d in @($a.Dependencies)) { if ($d -and $d.id) { $queue.Enqueue([PSCustomObject]@{ Id = "$($d.id)".ToLowerInvariant(); Name = "$($d.name)"; Publisher = "$($d.publisher)" }) } }
        if ($a.Application) { $queue.Enqueue([PSCustomObject]@{ Id = $applicationApp; Name = 'Application'; Publisher = 'Microsoft' }) }
    }

    $seen = @{}
    $results = New-Object System.Collections.Generic.List[object]
    while ($queue.Count -gt 0) {
        $dep = $queue.Dequeue()
        if (-not $dep.Id -or $seen.ContainsKey($dep.Id) -or $ownIds.ContainsKey($dep.Id)) { continue }
        $seen[$dep.Id] = $true

        $prefix = "$($dep.Publisher)_$($dep.Name)".ToLowerInvariant()
        $candidates = @($packages | Where-Object {
                $n = $_.Name.ToLowerInvariant()
                $n -eq "$prefix.app" -or $n.StartsWith("$($prefix)_")
            })
        $manifests = @(foreach ($c in $candidates) {
                try { $m = [ALbuild.CallGraph.AppPackage]::ReadManifest($c.FullName); if ($m -and $m.Id -eq $dep.Id) { $m } }
                catch { Write-ALbuildLog -Level Verbose "Call graph: could not read '$($c.FullName)': $($_.Exception.Message)" }
            })
        $best = $manifests | Sort-Object -Property @{ Expression = { $_.HasSource }; Descending = $true }, @{ Expression = { [version]$_.Version }; Descending = $true } | Select-Object -First 1

        if (-not $best) {
            Write-ALbuildLog -Level Warning "Call graph: no package of '$($dep.Name)' by '$($dep.Publisher)' found; calls into it stay unresolved."
            $results.Add([PSCustomObject]@{ Id = $dep.Id; Name = $dep.Name; Publisher = $dep.Publisher; Version = $null; HasSource = $false; SourceFolder = $null; PackagePath = $null })
            continue
        }
        foreach ($d in $best.Dependencies) { $queue.Enqueue([PSCustomObject]@{ Id = $d.Id; Name = $d.Name; Publisher = $d.Publisher }) }
        # The umbrella app has no code of its own; only its dependencies matter.
        if ($best.Id -eq $applicationApp -and -not $best.HasSource) { continue }

        $sourceFolder = $null
        if ($best.HasSource) {
            $sourceFolder = Join-Path -Path $CacheFolder -ChildPath "$($best.Id)_$($best.Version)"
            $marker = Join-Path -Path $sourceFolder -ChildPath '.albuild-complete'
            if (Test-Path -LiteralPath $marker) {
                Write-ALbuildLog "Call graph: source of '$($best.Name)' $($best.Version) from the cache."
            }
            else {
                # Unpack into a staging folder and rename it into place, so a cancelled build never leaves a
                # half-unpacked source that a later build would trust.
                $staging = Join-Path -Path $CacheFolder -ChildPath ('.staging-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
                $sw = [System.Diagnostics.Stopwatch]::StartNew()
                $count = [ALbuild.CallGraph.AppPackage]::ExtractSource($best.Path, $staging)
                Set-Content -LiteralPath (Join-Path $staging '.albuild-complete') -Value $best.Path -Encoding UTF8
                if (Test-Path -LiteralPath $sourceFolder) { Remove-Item -LiteralPath $sourceFolder -Recurse -Force -ErrorAction SilentlyContinue }
                try { [System.IO.Directory]::Move($staging, $sourceFolder) }
                catch {
                    # Another build on this agent got there first.
                    Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue
                    if (-not (Test-Path -LiteralPath $marker)) { throw }
                }
                Write-ALbuildLog "Call graph: unpacked $count source file(s) of '$($best.Name)' $($best.Version) in $([int]$sw.Elapsed.TotalSeconds) s."
            }
        }
        else {
            Write-ALbuildLog -Level Warning "Call graph: '$($best.Name)' $($best.Version) ships no source; calls into it - and calls passing its types - stay unresolved."
        }
        $results.Add([PSCustomObject]@{ Id = $best.Id; Name = $best.Name; Publisher = $best.Publisher; Version = $best.Version; HasSource = [bool]$best.HasSource; SourceFolder = $sourceFolder; PackagePath = $best.Path })
    }
    return $results.ToArray()
}