Modules/businessdev.ALbuild.Apps/Public/Get-BcCallGraphReport.ps1

function Get-BcCallGraphReport {
    <#
    .SYNOPSIS
        Analyses a call graph and writes its metrics, findings, impact and interactive HTML report.
 
    .DESCRIPTION
        Reads a call graph built by New-BcCallGraph and writes next to it (or to OutputFolder):
          analysis.json metrics, findings, impact and per-member source hashes (the next build's baseline)
          summary.md a Markdown summary for the build summary page
          report.html a self-contained interactive report: a layered map of the parts, drilled down into
                         objects and members with callers and callees; the pages and what they reach; flows
                         and the start tour; impact, findings and AL checks
 
        The parts come from callgraph.architecture.json in the workspace root (see New-BcCallGraphArchitecture),
        otherwise from the folder structure, so the map is readable without one.
 
        Metrics: objects, methods, triggers, calls (by kind), calls into dependencies, event publishers and
        subscribers, interface implementations, test methods, product members not reached by any [Test]
        method, calls of obsolete code from product code, and dead methods (non-public, called neither in
        the graph nor anywhere in the source text, nor as event subscriber, interface implementation or by
        name). With a baseline - the folder of an earlier call graph - each metric gets its change, and the
        impact lists the added, modified and removed members, their transitive callers and the tests that
        reach the change.
 
        Reporting never throws for a finding. The optional gate (MaxNotReachedPercent, MaxDeadMethods,
        FailOnObsoleteCalls) is evaluated and returned in Gate; acting on it is up to the caller.
 
    .PARAMETER GraphPath
        The call graph folder (or its graph.jsonl).
 
    .PARAMETER BaselinePath
        The folder of the call graph to compare with.
 
    .PARAMETER BaselineLabel
        How the report names the baseline, e.g. 'build 20261031.4 (refs/heads/dev)'. Default: its folder.
 
    .PARAMETER OutputFolder
        Where the report files are written. Default: the graph folder.
 
    .PARAMETER WorkspaceRoot
        Root that source paths are shown relative to. Default: recorded by New-BcCallGraph.
 
    .PARAMETER ArchitecturePath
        callgraph.architecture.json to apply. Default: the file in the workspace root when there is one; without
        it the parts are derived from the folders.
 
    .PARAMETER MaxNotReachedPercent
        Gate: fail when more than this percentage of product methods and triggers is not reached by tests.
 
    .PARAMETER MaxDeadMethods
        Gate: fail when more dead methods than this are found.
 
    .PARAMETER FailOnObsoleteCalls
        Gate: fail when product code calls obsolete code.
 
    .PARAMETER PipelineTab
        Mention the build's Call graph tab in the summary (pipeline runs).
 
    .EXAMPLE
        Get-BcCallGraphReport -GraphPath .albuild/callgraph -BaselinePath ./baseline/callgraph -MaxDeadMethods 0
 
    .OUTPUTS
        PSCustomObject: Metrics, BaselineMetrics, Impact, Findings, Gate, AnalysisPath, SummaryPath, ReportPath.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [string] $GraphPath,
        [string] $BaselinePath,
        [string] $BaselineLabel,
        [string] $OutputFolder,
        [string] $WorkspaceRoot,
        [string] $ArchitecturePath,
        [Nullable[double]] $MaxNotReachedPercent,
        [Nullable[int]] $MaxDeadMethods,
        [switch] $FailOnObsoleteCalls,
        [switch] $PipelineTab
    )

    $graph = Resolve-BcCallGraphPath -Path $GraphPath -Require
    if (-not $OutputFolder) { $OutputFolder = $graph.Folder }
    New-Item -ItemType Directory -Force -Path $OutputFolder | Out-Null

    $analysis = Invoke-BcCallGraphAnalysis -GraphPath $graph.GraphPath -BaselinePath $BaselinePath -WorkspaceRoot $WorkspaceRoot -ArchitecturePath $ArchitecturePath
    if ($BaselineLabel -and $analysis['baseline']) { $analysis['baseline'] = $BaselineLabel }
    $m = $analysis['metrics']
    $f = $analysis['findings']

    # The gate: only what was asked for.
    $gate = $null
    if ($null -ne $MaxNotReachedPercent -or $null -ne $MaxDeadMethods -or $FailOnObsoleteCalls) {
        $failures = New-Object System.Collections.Generic.List[string]
        if ($null -ne $MaxNotReachedPercent) {
            if ($null -eq $m['notReachedPercent']) { Write-ALbuildLog -Level Warning 'Gate: no [Test] methods, so the test-reach limit cannot be checked.' }
            elseif ([double]$m['notReachedPercent'] -gt $MaxNotReachedPercent) { $failures.Add("$($m['notReachedPercent'])% of product methods are not reached by tests (limit $MaxNotReachedPercent%)") }
        }
        if ($null -ne $MaxDeadMethods -and [int]$m['deadMethods'] -gt $MaxDeadMethods) { $failures.Add("$($m['deadMethods']) dead methods (limit $MaxDeadMethods)") }
        if ($FailOnObsoleteCalls -and [int]$m['obsoleteCalls'] -gt 0) { $failures.Add("$($m['obsoleteCalls']) calls of obsolete code") }
        $gate = [PSCustomObject]@{ Passed = ($failures.Count -eq 0); Failures = $failures.ToArray() }
    }

    $analysisPath = Join-Path $OutputFolder 'analysis.json'
    $json = [ALbuild.CallGraph.Json]::Serialize($analysis)
    [System.IO.File]::WriteAllText($analysisPath, $json, (New-Object System.Text.UTF8Encoding($false)))

    $summaryPath = Join-Path $OutputFolder 'summary.md'
    $summary = Format-BcCallGraphSummary -Analysis $analysis -Gate $gate -PipelineTab:$PipelineTab
    [System.IO.File]::WriteAllText($summaryPath, $summary, (New-Object System.Text.UTF8Encoding($false)))

    # The report embeds its styles, the layout library and the data, so it works offline, as a download and inside the tab.
    $resourceRoot = Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'Resources'
    $resources = Join-Path -Path $resourceRoot -ChildPath 'CallGraph'
    $template = [System.IO.File]::ReadAllText((Join-Path $resources 'report.html'))
    $baseCss = [System.IO.File]::ReadAllText((Join-Path $resourceRoot 'Shared/albuild-report.css'))
    $elk = [System.IO.File]::ReadAllText((Join-Path $resources 'vendor/elk.bundled.js'))
    # The per-member hashes are for the next build's comparison, not for the page.
    $view = New-Object 'System.Collections.Generic.Dictionary[string,object]'
    foreach ($key in $analysis.Keys) { if ($key -ne 'methodHashes') { $view[$key] = $analysis[$key] } }
    $html = $template.Replace('/*__BASE_CSS__*/', $baseCss.Replace('</style', '<\/style')).Replace('/*__ELK__*/', $elk.Replace('</script', '<\/script')).Replace('/*__DATA__*/', [ALbuild.CallGraph.Json]::Serialize($view))
    $reportPath = Join-Path $OutputFolder 'report.html'
    [System.IO.File]::WriteAllText($reportPath, $html, (New-Object System.Text.UTF8Encoding($false)))

    $impact = $analysis['impact']
    Write-ALbuildLog ("Call graph: {0} objects, {1} methods, {2} calls; not reached by tests: {3}; obsolete calls: {4}; dead methods: {5}{6}." -f
        $m['objects'], $m['methods'], $m['edges'], $(if ($null -eq $m['notReachedPercent']) { 'n/a' } else { "$($m['notReachedByTests']) ($($m['notReachedPercent'])%)" }),
        $m['obsoleteCalls'], $m['deadMethods'],
        $(if ($impact) { "; impact: $($impact['addedTotal']) added, $($impact['modifiedTotal']) modified, $($impact['removedTotal']) removed, $($impact['affectedTotal']) affected, $($impact['testsTotal']) tests" } else { '' }))
    if ($gate -and -not $gate.Passed) { Write-ALbuildLog -Level Warning "Call graph gate failed: $($gate.Failures -join '; ')." }

    [PSCustomObject]@{
        Metrics         = ConvertTo-BcCallGraphObject $m
        BaselineMetrics = ConvertTo-BcCallGraphObject $analysis['baselineMetrics']
        Impact          = if ($impact) {
            [PSCustomObject]@{
                Added = $impact['addedTotal']; Modified = $impact['modifiedTotal']; Removed = $impact['removedTotal']
                Affected = $impact['affectedTotal']; AffectedObjects = $impact['affectedObjects']; Tests = $impact['testsTotal']
                Baseline = $analysis['baseline']
            }
        } else { $null }
        Findings        = [PSCustomObject]@{ NotReachedByTests = $f['untestedTotal']; ObsoleteCalls = $f['obsoleteCallsTotal']; DeadMethods = $f['deadTotal'] }
        Gate            = $gate
        AnalysisPath    = $analysisPath
        SummaryPath     = $summaryPath
        ReportPath      = $reportPath
    }
}