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: object graph with drill-down into members,
                         callers and callees, impact, and the finding lists
 
        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 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,
        [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
    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 the library and the data, so it works offline, as a download and inside the tab.
    $resources = Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'Resources/CallGraph'
    $template = [System.IO.File]::ReadAllText((Join-Path $resources 'report.html'))
    $library = [System.IO.File]::ReadAllText((Join-Path $resources 'vendor/cytoscape.min.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('/*__CYTOSCAPE__*/', $library.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
    }
}