Modules/businessdev.ALbuild.Apps/Public/Test-BcAppBreakingChange.ps1

function Test-BcAppBreakingChange {
    <#
    .SYNOPSIS
        Checks an AL app for breaking changes against the baseline declared in its AppSourceCop.json.
 
    .DESCRIPTION
        AppSource breaking-change validation. If the project has an AppSourceCop.json with a "version"
        token, this:
          1. looks for the committed baseline app of that version in the repository
             (<publisher>_<name>_<version>.app);
          2. if the baseline is NOT committed, writes a warning and PROCEEDS without checking
             (Checked = $false) - you can't compare against a baseline you don't have;
          3. if it IS committed, compiles the app with the AppSourceCop analyzer against that baseline
             (the AL compiler emits AS-rule breaking-change diagnostics when the baseline app is present
             in the package cache and AppSourceCop.json carries the version) and reports them.
 
        Symbols for the compile come from -PackageCachePath (default <ProjectFolder>/.alpackages); the
        baseline app is added to a temporary copy of that cache so the real .alpackages is untouched.
 
    .PARAMETER ProjectFolder
        The AL app project folder (contains app.json and AppSourceCop.json).
 
    .PARAMETER Version
        Override the baseline version (default: the AppSourceCop.json "version" token).
 
    .PARAMETER SearchRoot
        Where to look for the committed baseline .app. Default: the git repo root, else ProjectFolder.
 
    .PARAMETER PackageCachePath
        Symbol folders for the compile. Default <ProjectFolder>/.alpackages.
 
    .PARAMETER Analyzer
        AppSourceCop analyzer reference. Default the compiler token '${AppSourceCop}'.
 
    .PARAMETER CompilerPath
        AL Tool CLI (default 'alc').
 
    .PARAMETER SkipCommittedCheck
        Treat a baseline .app present on disk but not git-tracked as committed (default: require tracked).
 
    .OUTPUTS
        PSCustomObject: Checked, Version, BaselineCommitted, BaselineFile, Diagnostics, BreakingChanges,
        Success, Message.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ProjectFolder,
        [string] $Version,
        [string] $SearchRoot,
        [string[]] $PackageCachePath,
        [string] $Analyzer = '${AppSourceCop}',
        [string] $CompilerPath,
        [switch] $SkipCommittedCheck
    )

    $copPath = Join-Path $ProjectFolder 'AppSourceCop.json'
    if (-not $Version) {
        if (-not (Test-Path -LiteralPath $copPath)) {
            return [PSCustomObject]@{ Checked = $false; Success = $true; Message = 'No AppSourceCop.json - breaking-change check not applicable.' }
        }
        $cop = Get-Content -LiteralPath $copPath -Raw | ConvertFrom-Json
        $Version = if ($cop.PSObject.Properties.Name -contains 'version') { "$($cop.version)" } else { '' }
        if (-not $Version) {
            return [PSCustomObject]@{ Checked = $false; Success = $true; Message = 'AppSourceCop.json has no "version" token - no breaking-change baseline configured.' }
        }
    }

    $appJson = Get-Content -LiteralPath (Join-Path $ProjectFolder 'app.json') -Raw | ConvertFrom-Json
    $expectedLeaf = "$($appJson.publisher)_$($appJson.name)_$Version.app"

    if (-not $SearchRoot) {
        $top = & git -C $ProjectFolder rev-parse --show-toplevel 2>$null
        $SearchRoot = if ($LASTEXITCODE -eq 0 -and $top) { "$top" } else { $ProjectFolder }
    }
    $root = (Resolve-Path -LiteralPath $SearchRoot).Path

    # Find the baseline .app for this version (prefer the exact publisher_name_version.app), excluding
    # build outputs and nested tooling folders (relative to the search root, like Get-BcProjectBuildOrder).
    $found = @(Get-ChildItem -LiteralPath $root -Filter "*_$Version.app" -File -Recurse -ErrorAction SilentlyContinue |
            Where-Object { $_.FullName.Substring($root.Length) -notmatch '[\\/](\.alpackages|\.output|output|\.git|\.claude|node_modules)[\\/]' })
    $baseline = $found | Where-Object { $_.Name -eq $expectedLeaf } | Select-Object -First 1
    if (-not $baseline) { $baseline = $found | Select-Object -First 1 }

    $committed = $false
    if ($baseline) {
        $committed = $true
        if (-not $SkipCommittedCheck) {
            & git -C $root ls-files --error-unmatch -- "$($baseline.FullName)" *> $null
            $committed = ($LASTEXITCODE -eq 0)
        }
    }

    if (-not $baseline -or -not $committed) {
        $msg = if (-not $baseline) {
            "Baseline app '$expectedLeaf' (AppSourceCop.json version $Version) is not committed; skipping the breaking-change check."
        } else {
            "Baseline app '$($baseline.Name)' is present but not git-tracked; skipping the breaking-change check."
        }
        Write-ALbuildLog -Level Warning $msg
        return [PSCustomObject]@{
            Checked = $false; Version = $Version; BaselineCommitted = $false
            BaselineFile = $(if ($baseline) { $baseline.FullName } else { $null }); Success = $true; Message = $msg
        }
    }

    Write-ALbuildLog "Checking breaking changes for '$($appJson.name)' against baseline $Version ($($baseline.Name))..."
    if (-not $PackageCachePath -or $PackageCachePath.Count -eq 0) { $PackageCachePath = @(Join-Path $ProjectFolder '.alpackages') }

    # Stage a temp cache = existing symbols + the baseline app (keep the real .alpackages clean).
    $cache = Join-Path ([System.IO.Path]::GetTempPath()) ('albuild-bc-' + [guid]::NewGuid().ToString('N'))
    New-Item -ItemType Directory -Force -Path $cache | Out-Null
    try {
        foreach ($p in $PackageCachePath) {
            if (Test-Path -LiteralPath $p) {
                Get-ChildItem -LiteralPath $p -Filter '*.app' -File -ErrorAction SilentlyContinue |
                    ForEach-Object { Copy-Item -LiteralPath $_.FullName -Destination $cache -Force }
            }
        }
        Copy-Item -LiteralPath $baseline.FullName -Destination $cache -Force

        # Resolve the AppSourceCop analyzer to its DLL. The compiler token '${AppSourceCop}' is only
        # understood by newer alc; the AL Tool here needs the assembly path. Search the AL Tool's store.
        $analyzerPath = $Analyzer
        if ($Analyzer -match 'AppSourceCop' -and $Analyzer -notmatch '\.dll$') {
            $alCmd = Get-Command 'al', 'alc', 'altool' -ErrorAction SilentlyContinue | Select-Object -First 1
            $searchDirs = @()
            if ($alCmd) { $searchDirs += (Split-Path -Parent $alCmd.Source) }
            $searchDirs += (Join-Path $env:USERPROFILE '.dotnet/tools')
            foreach ($d in ($searchDirs | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -Unique)) {
                $dll = Get-ChildItem -LiteralPath $d -Recurse -Filter '*AppSourceCop*.dll' -File -ErrorAction SilentlyContinue | Select-Object -First 1
                if ($dll) { $analyzerPath = $dll.FullName; break }
            }
            if ($analyzerPath -eq $Analyzer) {
                Write-ALbuildLog -Level Warning "Could not locate the AppSourceCop analyzer assembly; breaking-change rules may not run."
            }
        }

        $out = Join-Path $cache 'out'
        $ciArgs = @{ ProjectFolder = $ProjectFolder; OutputFolder = $out; Engine = 'AlTool'; PackageCachePath = @($cache); Analyzer = @($analyzerPath) }
        if ($CompilerPath) { $ciArgs['CompilerPath'] = $CompilerPath }
        $compile = Invoke-BcCompiler @ciArgs

        $diagnostics = @()
        foreach ($line in ("$($compile.Output)" -split "`r?`n")) {
            if ($line -match ':\s*(error|warning)\s+(AS\d{4})\b\s*:?\s*(.*)$') {
                $diagnostics += [PSCustomObject]@{ severity = $matches[1]; rule = $matches[2]; message = $line.Trim() }
            }
        }
        $breaking = @($diagnostics | Where-Object { $_.severity -eq 'error' })
        $success = ($breaking.Count -eq 0)
        if ($success) { Write-ALbuildLog -Level Success "No breaking changes vs baseline $Version." }
        else { Write-ALbuildLog -Level Error "$($breaking.Count) breaking-change diagnostic(s) vs baseline $Version." }

        return [PSCustomObject]@{
            Checked = $true; Version = $Version; BaselineCommitted = $true; BaselineFile = $baseline.FullName
            Diagnostics = @($diagnostics); BreakingChanges = $breaking; Success = $success
            Message = if ($success) { "No breaking changes vs baseline $Version." } else { "$($breaking.Count) breaking-change diagnostic(s)." }
        }
    }
    finally {
        Remove-Item -LiteralPath $cache -Recurse -Force -ErrorAction SilentlyContinue
    }
}