Modules/businessdev.ALbuild.Apps/Public/New-BcCallGraph.ps1

function New-BcCallGraph {
    <#
    .SYNOPSIS
        Builds the call graph of a workspace's AL apps with the AL Tool's 'al graph'.
 
    .DESCRIPTION
        Compiles every AL project of the workspace together with the source of its dependencies
        ('al graph extract-whole'), so calls between the workspace apps and into the dependencies resolve.
        Microsoft's apps come from the BC artifact; other dependencies are used when their package ships
        source. A dependency without source is listed: calls into it - and calls that only pass one of its
        types - are missing from the graph.
 
        The whole-corpus graph is then pruned to the workspace apps and their boundary (every node of an own
        app, every edge touching one, and the dependency members those edges reach), which keeps it small
        enough to store per build. Writes to OutputFolder:
          graph.jsonl the pruned graph (al graph's JSON lines format)
          apps.json the own apps and the dependencies with their source status
 
        'al graph' exists from AL Tool 18.0.39 on. The AL Tool is chosen exactly as for compiling (from the
        BC version of the artifact); an older one fails with 'ALTOOL_NO_GRAPH:'.
 
    .PARAMETER ProjectFolder
        AL project folders (each with app.json). Default: every project under WorkspaceRoot.
 
    .PARAMETER WorkspaceRoot
        Repository root. Default: the current directory.
 
    .PARAMETER ArtifactUrl
        BC artifact whose Microsoft apps supply the dependency source and whose version selects the AL Tool.
 
    .PARAMETER PackageCachePath
        Further folders with dependency packages. Each project's .alpackages folder is always searched.
 
    .PARAMETER NoDependencySource
        Compile the workspace apps only (much faster; calls into dependencies stay unresolved).
 
    .PARAMETER AlToolPath
        Path of the 'al' executable to use instead of resolving one.
 
    .PARAMETER BcVersion
        BC version that selects the AL Tool when there is no ArtifactUrl.
 
    .PARAMETER AlToolVersion
        Exact AL Tool version to install and use.
 
    .PARAMETER OutputFolder
        Where graph.jsonl and apps.json are written. Default '<WorkspaceRoot>\.albuild\callgraph'.
 
    .PARAMETER KeepFullGraph
        Keep the unpruned whole-corpus graph as full-graph.jsonl (large: hundreds of MB with Microsoft's apps).
 
    .EXAMPLE
        New-BcCallGraph -WorkspaceRoot . -ArtifactUrl (Find-BcArtifactUrl -Type Sandbox -Country de)
 
    .OUTPUTS
        PSCustomObject: GraphPath, AppsPath, OutputFolder, Apps, Dependencies, DependenciesWithoutSource,
        Nodes, Edges, CorpusNodes, CorpusEdges, AlToolVersion, DurationSeconds.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([PSCustomObject])]
    param(
        [string[]] $ProjectFolder,
        [string] $WorkspaceRoot,
        [string] $ArtifactUrl,
        [string[]] $PackageCachePath = @(),
        [switch] $NoDependencySource,
        [string] $AlToolPath,
        [string] $BcVersion,
        [string] $AlToolVersion,
        [string] $OutputFolder,
        [switch] $KeepFullGraph
    )

    $watch = [System.Diagnostics.Stopwatch]::StartNew()
    Import-BcCallGraphEngine
    if (-not $WorkspaceRoot) { $WorkspaceRoot = (Get-Location).ProviderPath }
    $WorkspaceRoot = (Resolve-Path -LiteralPath $WorkspaceRoot).ProviderPath
    if (-not $OutputFolder) { $OutputFolder = Join-Path -Path (Join-Path -Path $WorkspaceRoot -ChildPath '.albuild') -ChildPath 'callgraph' }
    if (-not $PSCmdlet.ShouldProcess($OutputFolder, 'Build the call graph')) { return }
    New-Item -ItemType Directory -Force -Path $OutputFolder | Out-Null
    $OutputFolder = (Resolve-Path -LiteralPath $OutputFolder).ProviderPath

    # 1. The own apps.
    # @(if ...): an if statement unwraps a one-element array, and .Count of a string fails under StrictMode.
    $folders = @(if ($ProjectFolder) { $ProjectFolder | ForEach-Object { (Resolve-Path -LiteralPath $_).ProviderPath } }
        else { Get-BcProjectBuildOrder -Path $WorkspaceRoot | ForEach-Object { $_.Folder } })
    if ($folders.Count -eq 0) { throw "No AL project (app.json) found under '$WorkspaceRoot'." }
    $apps = @(foreach ($f in $folders) {
            $json = Get-Content -LiteralPath (Join-Path $f 'app.json') -Raw -Encoding UTF8 | ConvertFrom-Json
            # 'dependencies' and 'application' are optional in app.json (StrictMode would throw on a missing one).
            $field = { param($name) $p = $json.PSObject.Properties[$name]; if ($p) { $p.Value } else { $null } }
            [PSCustomObject]@{
                Id = "$(& $field 'id')".ToLowerInvariant(); Name = & $field 'name'; Publisher = & $field 'publisher'; Version = & $field 'version'
                Folder = $f; Dependencies = @(& $field 'dependencies' | Where-Object { $_ }); Application = & $field 'application'
            }
        })
    Write-ALbuildLog "Call graph: $($apps.Count) project(s): $(($apps | ForEach-Object { "$($_.Name) ($([System.IO.Path]::GetFileName($_.Folder)))" }) -join ', ')."

    # 2. The AL Tool, chosen as for compiling.
    if (-not $BcVersion -and $ArtifactUrl -and $ArtifactUrl -match '/(\d+\.\d+\.\d+\.\d+)/') { $BcVersion = $Matches[1] }
    $al = $AlToolPath
    $alVersion = $null
    if (-not $al) {
        $toolArgs = @{}
        if ($BcVersion) { $toolArgs['BcVersion'] = $BcVersion }
        if ($AlToolVersion) { $toolArgs['Version'] = $AlToolVersion }
        $tool = Install-BcAlTool @toolArgs
        $al = $tool.Path
        $alVersion = $tool.Version
    }
    if (-not $al) { throw 'No AL Tool (al) is available; pass -AlToolPath or install it with Install-BcAlTool.' }
    $help = Invoke-ALbuildProcess -FilePath $al -Arguments @('graph', '--help') -PassThru
    if (-not $help.Success -or "$($help.StdOut)" -notmatch 'extract-whole') {
        throw "ALTOOL_NO_GRAPH: The AL Tool$(if ($alVersion) { " $alVersion" }) at '$al' has no 'graph' command; it exists from AL Tool 18.0.39 (BC 29) on."
    }
    Write-ALbuildLog "Call graph: AL Tool $(if ($alVersion) { $alVersion } else { $al })."

    # 3. The dependency source.
    $dependencies = @()
    if (-not $NoDependencySource) {
        $search = New-Object System.Collections.Generic.List[string]
        foreach ($a in $apps) { $search.Add((Join-Path $a.Folder '.alpackages')) }
        foreach ($p in $PackageCachePath) { if ($p) { $search.Add($p) } }
        if ($ArtifactUrl) {
            $artifact = Get-BcArtifact -ArtifactUrl $ArtifactUrl -IncludePlatform $true
            if ($artifact.ApplicationPath) { $search.Add($artifact.ApplicationPath) }
            if ($artifact.PlatformPath) { $search.Add((Join-Path $artifact.PlatformPath 'Applications')) }
        }
        else {
            Write-ALbuildLog -Level Warning 'Call graph: no artifact given, so Microsoft source is only found in the package caches.'
        }
        $dependencies = @(Resolve-BcCallGraphDependency -Apps $apps -SearchFolder $search.ToArray())
    }
    $withoutSource = @($dependencies | Where-Object { -not $_.HasSource })

    # 4. One compilation of everything, then the boundary prune.
    $work = Get-ALbuildScratchFolder -Name ('callgraph-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
    try {
        $fullGraph = Join-Path $work 'full-graph.jsonl'
        $arguments = New-Object System.Collections.Generic.List[string]
        $arguments.AddRange([string[]]@('graph', 'extract-whole'))
        foreach ($f in $folders) { $arguments.Add('--corpus'); $arguments.Add($f) }
        foreach ($d in ($dependencies | Where-Object { $_.SourceFolder })) { $arguments.Add('--corpus'); $arguments.Add($d.SourceFolder) }
        $arguments.AddRange([string[]]@('--out', (Join-Path $work 'shards'), '--graph', $fullGraph))
        Write-ALbuildLog "Call graph: compiling $($folders.Count) project(s) and $(@($dependencies | Where-Object { $_.SourceFolder }).Count) dependency source(s) together..."
        $run = Invoke-ALbuildProcess -FilePath $al -Arguments $arguments.ToArray() -PassThru
        foreach ($line in ("$($run.StdOut)" -split "`r?`n" | Where-Object { $_.Trim() })) { Write-ALbuildLog " $line" }
        if (-not $run.Success -or -not (Test-Path -LiteralPath $fullGraph)) {
            $detail = if ("$($run.StdErr)".Trim()) { $run.StdErr } else { $run.StdOut }
            throw "al graph extract-whole failed (exit code $($run.ExitCode)): $("$detail".Trim())"
        }

        $graphPath = Join-Path $OutputFolder 'graph.jsonl'
        $prune = [ALbuild.CallGraph.Pruner]::Prune($fullGraph, $graphPath, [string[]]@($apps | ForEach-Object { $_.Id }))
        Write-ALbuildLog "Call graph: pruned $($prune.SourceNodes) node(s) / $($prune.SourceEdges) edge(s) to the workspace apps and their boundary: $($prune.Nodes) / $($prune.Edges)."
        if ($KeepFullGraph) { Copy-Item -LiteralPath $fullGraph -Destination (Join-Path $OutputFolder 'full-graph.jsonl') -Force }
    }
    finally {
        Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue
    }

    $appsPath = Join-Path $OutputFolder 'apps.json'
    $appRecords = @(
        $apps | ForEach-Object { [ordered]@{ id = $_.Id; name = $_.Name; publisher = $_.Publisher; version = $_.Version; role = 'own'; hasSource = $true; folder = $_.Folder; workspaceRoot = $WorkspaceRoot } }
        $dependencies | ForEach-Object { [ordered]@{ id = $_.Id; name = $_.Name; publisher = $_.Publisher; version = $_.Version; role = 'dependency'; hasSource = [bool]$_.HasSource } }
    )
    Set-Content -LiteralPath $appsPath -Value (ConvertTo-Json -InputObject $appRecords -Depth 4) -Encoding UTF8

    if ($withoutSource.Count -gt 0) {
        Write-ALbuildLog -Level Warning "Call graph: dependencies without source: $(($withoutSource | ForEach-Object { $_.Name }) -join ', '). Calls into them, and calls passing their types, are missing; dead-code and test-reach figures are less precise."
    }
    Write-ALbuildLog -Level Success "Call graph written to '$graphPath' in $([int]$watch.Elapsed.TotalSeconds) s."
    [PSCustomObject]@{
        GraphPath                 = $graphPath
        AppsPath                  = $appsPath
        OutputFolder              = $OutputFolder
        Apps                      = @($apps | ForEach-Object { $_.Name })
        Dependencies              = @($dependencies | ForEach-Object { [PSCustomObject]@{ Name = $_.Name; Publisher = $_.Publisher; Version = $_.Version; HasSource = $_.HasSource } })
        DependenciesWithoutSource = @($withoutSource | ForEach-Object { $_.Name })
        Nodes                     = $prune.Nodes
        Edges                     = $prune.Edges
        CorpusNodes               = $prune.SourceNodes
        CorpusEdges               = $prune.SourceEdges
        AlToolVersion             = $alVersion
        DurationSeconds           = [int]$watch.Elapsed.TotalSeconds
    }
}