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' }
    # A staging folder a build could not publish stays behind; a day old, it is no build's work in progress.
    if (Test-Path -LiteralPath $CacheFolder) {
        Get-ChildItem -LiteralPath $CacheFolder -Directory -Filter '.staging-*' -Force -ErrorAction SilentlyContinue |
            Where-Object { $_.LastWriteTimeUtc -lt [DateTime]::UtcNow.AddDays(-1) } |
            ForEach-Object { Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue }
    }

    # 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 {
                # Builds on one agent share the cache: one extracts, the others wait and then read it.
                $mutex = New-Object System.Threading.Mutex($false, (Get-ALbuildCacheLockName -Path $sourceFolder))
                $held = $false
                try {
                    try { $held = $mutex.WaitOne([TimeSpan]::FromMinutes(10)) }
                    catch [System.Threading.AbandonedMutexException] { $held = $true }
                    if (Test-Path -LiteralPath $marker) {
                        Write-ALbuildLog "Call graph: source of '$($best.Name)' $($best.Version) from the cache (unpacked by a parallel build)."
                    }
                    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 }
                        # A virus scanner holds freshly written files for a moment, and the rename then fails
                        # with "access denied" (build 29178). Thousands of files keep it busy longer than one
                        # artifact, hence the larger budget.
                        try { Move-ALbuildDirectory -Path $staging -Destination $sourceFolder -RetryCount 8 }
                        catch {
                            if (Test-Path -LiteralPath $marker) {
                                Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue
                            }
                            else {
                                # The staging folder is complete; build from it and leave the cache for next time.
                                Write-ALbuildLog -Level Warning "Call graph: could not cache the source of '$($best.Name)' ($($_.Exception.Message)); using it from '$staging' for this build."
                                $sourceFolder = $staging
                            }
                        }
                        Write-ALbuildLog "Call graph: unpacked $count source file(s) of '$($best.Name)' $($best.Version) in $([int]$sw.Elapsed.TotalSeconds) s."
                    }
                }
                finally {
                    if ($held) { $mutex.ReleaseMutex() }
                    $mutex.Dispose()
                }
            }
        }
        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()
}