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