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 } } |