Modules/businessdev.ALbuild.Apps/Public/Update-BcAppManifest.ps1

function Update-BcAppManifest {
    <#
    .SYNOPSIS
        Prepares an AL app manifest (app.json) for a target BC version: version features + preprocessor symbols.
 
    .DESCRIPTION
        Restores the pipeline's V1 "update manifest" behaviour for a compile against a specific BC version
        (a normal build against Latest, or a runtime package built for an older platform). Two adjustments,
        both keyed to -BcVersion:
 
          * Version features (port of Apply-BcVersionFeatures): set 'application' and 'platform' to
            '<major>.0.0.0' and 'runtime' to the target's AL runtime version. The runtime is
            '(major - 11).0' for BC >= 12 (17->6.0, 22->11.0, 28->17.0, ...), with the BC 18.1+ -> 7.1
            special case; this keeps working for future majors (V1 stopped at 26/27). Skipped with
            -SkipVersionFeatures (then only the preprocessor symbols are written).
 
          * Properties keyed to the AL runtime, in BOTH directions. A manifest is written for one BC
            version and compiled here against a dozen others, and AL rejects a property its runtime does
            not know: 'resourceFolders' against runtime 13.0 is "error AL0666: 'resourceFolders' is not
            available in runtime version '13.0'. The supported runtime versions are: '14.0' or greater",
            and the build stops. AL code guards itself with '#if BC25'; app.json cannot, so this does.
            Run 27383 lost 162 of 197 ERiC packages to exactly that line.
 
              UP 'applicationInsightsKey' -> 'applicationInsightsConnectionString' from runtime 7.2
                    'showMyCode' -> 'resourceExposurePolicy' from runtime 8.0
              DOWN the same two, back again, below those runtimes
              DROP 'resourceFolders' below runtime 14.0 (it has no older equivalent)
 
            The upward pair is the V1 pipeline's Apply-BcVersionFeatures behaviour, which the first port
            of this function left behind - which is why every compile still logged AL0667 deprecation
            warnings for both. The downward direction is new here: V1 only ever compiled an app against
            its own BC version, so it never needed it. Both are skipped when the modern (or legacy) form
            is already present, so stamping the same file twice changes nothing the second time.
 
          * 'extensionsPermissionSet.xml' from runtime 7.0 upwards: removed when the repository also has
            modern '*.permissionset.al' objects, warned about when it does not. Also V1 behaviour.
 
          * Preprocessor symbols: 'BC<min>'..'BC<target>', where 'min' is -MinMajor when given, otherwise
            the app's 'application' major read BEFORE the version-feature rewrite, plus any
            -PreprocessorSymbols. This lets version-conditional AL ('#if BC24 ... #endif') compile.
 
            Any existing 'preprocessorSymbols' array is REPLACED, never read and never extended. The
            committed array belongs to the repository and may hold internal symbols (DEBUG, ONPREM) that
            must not reach a published app, so it is not treated as input - not even its BC entries. That
            also means the function cannot recover the original floor from it after it has stamped once:
            where a manifest may be stamped repeatedly, the caller remembers the baseline and passes
            -MinMajor.
 
        Operates on a single app.json or every app.json under a folder (skipping .alpackages / output).
        Rewrites the file in place (build workspace); use before Invoke-BcCompiler.
 
    .PARAMETER Path
        An app.json file, or a folder searched recursively for app.json.
 
    .PARAMETER BcVersion
        The target BC version - full ('28.3.52162.52455') or major ('28'); its major drives everything.
 
    .PARAMETER PreprocessorSymbols
        Extra preprocessor symbols to add alongside the BC<n> range. The manifest's existing
        'preprocessorSymbols' array is REPLACED, never extended - a repository may commit internal symbols
        such as DEBUG or ONPREM, and those must never travel into a published app. Everything the build
        needs beyond the BC range is passed here, deliberately and visibly.
 
    .PARAMETER MinMajor
        The floor of the BC<n> range, overriding the value derived from 'application'.
 
        Needed because this function OVERWRITES 'application'. A second stamp of the same file would
        otherwise read the previous target back as the floor and collapse the range - BC17..BC29 becomes
        BC29 - which turns every '#if BC24' false and silently compiles the '#else' branch. The floor is not
        recovered from the manifest's own symbols either (see -PreprocessorSymbols); the caller remembers
        it. The DevOps CompileApp task keeps it in a per-project pipeline variable and passes it here.
 
    .PARAMETER SkipVersionFeatures
        Only inject preprocessor symbols; leave application / runtime / platform unchanged. The
        runtime-keyed adjustments still run, judged against the runtime the manifest already declares -
        that is the runtime the compile will use, so it is the one the properties have to be legal for.
 
    .PARAMETER ApplicationInsightsIngestionEndpoint
        Ingestion endpoint written into a generated connection string. The default is the region the
        company's Application Insights lives in, carried over from the V1 pipeline.
 
    .PARAMETER ApplicationInsightsLiveEndpoint
        Live-metrics endpoint for the same connection string.
 
    .EXAMPLE
        Update-BcAppManifest -Path .\app -BcVersion '28.3.52162.52455'
 
    .OUTPUTS
        PSCustomObject per app.json: AppJsonPath, MinMajor, TargetMajor, Runtime, Symbols.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [string] $Path,
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $BcVersion,
        [string[]] $PreprocessorSymbols = @(),
        [ValidateRange(1, 99)] [int] $MinMajor,
        [switch] $SkipVersionFeatures,
        [ValidateNotNullOrEmpty()] [string] $ApplicationInsightsIngestionEndpoint = 'https://westeurope-1.in.applicationinsights.azure.com/',
        [ValidateNotNullOrEmpty()] [string] $ApplicationInsightsLiveEndpoint = 'https://westeurope.livediagnostics.monitor.azure.com/'
    )

    if (-not (Test-Path -LiteralPath $Path)) { throw "Path '$Path' does not exist." }

    $target = ConvertTo-BcVersion $BcVersion
    $targetMajor = $target.Major
    # AL runtime for the target major: (major - 11).0 for BC >= 12 (17->6.0 ... 28->17.0); 18.1+ -> 7.1.
    $runtime = if ($targetMajor -eq 18 -and $target.Minor -ge 1) { '7.1' } else { "$($targetMajor - 11).0" }

    $item = Get-Item -LiteralPath $Path
    if ($item.PSIsContainer) {
        $files = @(Get-ChildItem -LiteralPath $item.FullName -Filter 'app.json' -File -Recurse -ErrorAction SilentlyContinue |
                Where-Object { $_.FullName -notmatch '[\\/](\.alpackages|\.altemplates|\.snapshots|\.output|output)[\\/]' })
    }
    elseif ($item.Name -eq 'app.json') {
        $files = @($item)
    }
    else {
        throw "Path '$Path' is neither a folder nor an app.json file."
    }
    if ($files.Count -eq 0) { throw "No app.json found under '$Path'." }

    $utf8NoBom = [System.Text.UTF8Encoding]::new($false)
    foreach ($file in $files) {
        $raw = [System.IO.File]::ReadAllText($file.FullName)
        $json = $raw | ConvertFrom-Json
        if (-not ($json.PSObject.Properties.Name -contains 'application')) {
            throw "'$($file.FullName)' has no top-level 'application' property."
        }
        # Named '$floor', not '$minMajor': PowerShell variable names are case-INSENSITIVE, so '$minMajor'
        # IS the '$MinMajor' parameter and this line would overwrite the caller's value before it could be
        # read. The tests caught exactly that.
        $floor = (ConvertTo-BcVersion ([string] $json.application)).Major

        # 'application' is ALSO what this function overwrites a few lines down, so reading it back on a
        # SECOND stamp of the same file yields the previous TARGET as the floor and the cumulative list
        # collapses (BC17..BC29 -> BC29). Every '#if BC24' is then FALSE and the '#else' branch compiles,
        # silently, because nothing about the manifest looks wrong afterwards.
        #
        # The floor is therefore NOT recovered from the manifest's own preprocessorSymbols. That array is
        # committed by the repository and may carry INTERNAL symbols - DEBUG, ONPREM - which must never
        # reach a published app, so it is never trusted and never extended: it is REPLACED wholesale, and
        # only -PreprocessorSymbols adds anything beyond the BC range.
        #
        # Where a manifest may be stamped more than once, the CALLER remembers the baseline (the DevOps task
        # keeps it in a pipeline variable per project) and passes it as -MinMajor.
        if ($PSBoundParameters.ContainsKey('MinMajor')) { $floor = $MinMajor }

        # BC<min>..BC<target> (either direction) + any extra symbols.
        $symbols = [System.Collections.Generic.List[string]]::new()
        $lo = [Math]::Min($floor, $targetMajor)
        $hi = [Math]::Max($floor, $targetMajor)
        for ($i = $lo; $i -le $hi; $i++) { $symbols.Add("BC$i") }
        foreach ($s in $PreprocessorSymbols) { if (-not [string]::IsNullOrWhiteSpace($s)) { $symbols.Add($s.Trim()) } }

        if (-not $SkipVersionFeatures) {
            $json | Add-Member -Name 'application' -Value "$targetMajor.0.0.0" -MemberType NoteProperty -Force
            $json | Add-Member -Name 'platform' -Value "$targetMajor.0.0.0" -MemberType NoteProperty -Force
            $json | Add-Member -Name 'runtime' -Value $runtime -MemberType NoteProperty -Force
        }
        # Properties that need a newer AL runtime than the one being compiled against. The effective
        # runtime is the one this call leaves in the file: ours, or the manifest's own when the version
        # features are skipped.
        #
        # Only 'resourceFolders' is handled. The V1 script this function ports (Apply-BcVersionFeatures)
        # also rewrote 'applicationInsightsKey' -> 'applicationInsightsConnectionString' and 'showMyCode'
        # -> 'resourceExposurePolicy'; those are REPLACEMENTS that change what a published app contains,
        # not removals, and porting them is a decision about the product, not about this build. They are
        # deprecation warnings today (AL0667), not errors.
        $effectiveRuntime = if ($SkipVersionFeatures) {
            # Read through PSObject: a manifest without 'runtime' is legal, and Set-StrictMode turns a
            # plain property access on a missing member into a terminating error.
            $declared = if ($json.PSObject.Properties['runtime']) { [string]$json.PSObject.Properties['runtime'].Value } else { '' }
            if ($declared) { $declared } else { $runtime }
        }
        else { $runtime }

        # Unparsable runtime: leave every one of these decisions alone rather than guess.
        $rt = try { [version]$effectiveRuntime } catch { $null }
        $dropped = [System.Collections.Generic.List[string]]::new()
        $adjusted = [System.Collections.Generic.List[string]]::new()
        $has = { param([string] $Name) $json.PSObject.Properties.Name -contains $Name }

        if ($rt) {
            # --- UP: the legacy form is not valid from this runtime on ---------------------------
            if ($rt -ge [version]'7.2' -and (& $has 'applicationInsightsKey') -and -not (& $has 'applicationInsightsConnectionString')) {
                $key = [string]$json.applicationInsightsKey
                $json.PSObject.Properties.Remove('applicationInsightsKey')
                $json | Add-Member -Name 'applicationInsightsConnectionString' -MemberType NoteProperty -Force `
                    -Value "InstrumentationKey=$key;IngestionEndpoint=$ApplicationInsightsIngestionEndpoint;LiveEndpoint=$ApplicationInsightsLiveEndpoint"
                $adjusted.Add("applicationInsightsKey -> applicationInsightsConnectionString")
            }
            if ($rt -ge [version]'8.0' -and (& $has 'showMyCode') -and -not (& $has 'resourceExposurePolicy')) {
                $show = [bool]$json.showMyCode
                $policy = [PSCustomObject]@{
                    allowDebugging            = $true
                    allowDownloadingSource    = $show
                    includeSourceInSymbolFile = $show
                }
                # 'applyToDevExtension' only exists from BC21; writing it for an older target is the same
                # class of error this whole block exists to prevent.
                if ($targetMajor -ge 21) { $policy | Add-Member -Name 'applyToDevExtension' -Value $false -MemberType NoteProperty }
                $json.PSObject.Properties.Remove('showMyCode')
                $json | Add-Member -Name 'resourceExposurePolicy' -Value $policy -MemberType NoteProperty -Force
                $adjusted.Add("showMyCode -> resourceExposurePolicy")
            }

            # --- DOWN: the modern form does not exist yet at this runtime ------------------------
            if ($rt -lt [version]'7.2' -and (& $has 'applicationInsightsConnectionString')) {
                # The key is recoverable from the connection string, so this is a conversion rather than
                # a loss.
                $conn = [string]$json.applicationInsightsConnectionString
                $key = if ($conn -match 'InstrumentationKey=([^;]+)') { $Matches[1] } else { '' }
                $json.PSObject.Properties.Remove('applicationInsightsConnectionString')
                if ($key) {
                    $json | Add-Member -Name 'applicationInsightsKey' -Value $key -MemberType NoteProperty -Force
                    $adjusted.Add("applicationInsightsConnectionString -> applicationInsightsKey")
                }
                else { $dropped.Add('applicationInsightsConnectionString') }
            }
            if ($rt -lt [version]'8.0' -and (& $has 'resourceExposurePolicy')) {
                $policy = $json.resourceExposurePolicy
                $show = $false
                if ($policy -and $policy.PSObject.Properties['allowDownloadingSource']) { $show = [bool]$policy.allowDownloadingSource }
                $json.PSObject.Properties.Remove('resourceExposurePolicy')
                $json | Add-Member -Name 'showMyCode' -Value $show -MemberType NoteProperty -Force
                $adjusted.Add("resourceExposurePolicy -> showMyCode")
            }

            # --- DROP: no older equivalent exists -----------------------------------------------
            if ($rt -lt [version]'14.0' -and (& $has 'resourceFolders')) {
                $json.PSObject.Properties.Remove('resourceFolders')
                $dropped.Add('resourceFolders')
            }
        }

        if ($adjusted.Count -gt 0) {
            Write-ALbuildLog "Manifest '$($file.Directory.Name)': $(($adjusted) -join ', ') (runtime $effectiveRuntime)."
        }
        if ($dropped.Count -gt 0) {
            Write-ALbuildLog -Level Warning ("Manifest '$($file.Directory.Name)': removed $(($dropped | ForEach-Object { "'$_'" }) -join ', ') - " +
                "not available in runtime $effectiveRuntime, which is what BC $targetMajor compiles with.")
        }

        # A permission set as XML stopped being the way from runtime 7.0. V1 removed the legacy file when
        # the repository had modern objects too, and warned when it did not - because removing the only
        # permission definition an app has would be worse than a deprecation warning.
        if ($rt -and $rt -ge [version]'7.0') {
            $legacy = @(Get-ChildItem -LiteralPath $file.Directory.FullName -Recurse -Filter 'extensionsPermissionSet.xml' -File -ErrorAction SilentlyContinue)
            if ($legacy.Count -gt 0) {
                $modern = @(Get-ChildItem -LiteralPath $file.Directory.FullName -Recurse -File -ErrorAction SilentlyContinue |
                        Where-Object { $_.Name -like '*.permissionset.al' })
                if ($modern.Count -gt 0) {
                    foreach ($x in $legacy) {
                        Remove-Item -LiteralPath $x.FullName -Force -ErrorAction SilentlyContinue
                        $adjusted.Add("removed $($x.Name)")
                    }
                    Write-ALbuildLog "Manifest '$($file.Directory.Name)': removed $($legacy.Count) legacy extensionsPermissionSet.xml file(s); the app carries PermissionSet objects."
                }
                else {
                    Write-ALbuildLog -Level Warning ("Manifest '$($file.Directory.Name)': extensionsPermissionSet.xml is still the only permission definition. " +
                        'From runtime 7.0 it should be a PermissionSet object; the file is kept, because removing it would leave the app with none.')
                }
            }
        }

        $json | Add-Member -Name 'preprocessorSymbols' -Value ([string[]] $symbols) -MemberType NoteProperty -Force

        if ($PSCmdlet.ShouldProcess($file.FullName, "Update manifest for BC $targetMajor")) {
            $out = $json | ConvertTo-Json -Depth 100
            # Windows PowerShell 5.1 escapes < > & ' to \uXXXX; restore them (valid JSON either way).
            $out = $out -replace '\\u0026', '&' -replace '\\u003c', '<' -replace '\\u003e', '>' -replace '\\u0027', "'"
            [System.IO.File]::WriteAllText($file.FullName, $out, $utf8NoBom)
            $vf = if ($SkipVersionFeatures) { '(symbols only)' } else { "application $targetMajor.0.0.0, runtime $runtime" }
            Write-ALbuildLog -Level Success "Manifest '$($file.Directory.Name)': $vf, symbols [$($symbols -join ', ')]."
        }

        [PSCustomObject]@{
            AppJsonPath = $file.FullName
            MinMajor    = $floor
            TargetMajor = $targetMajor
            Runtime     = $runtime
            Symbols     = @($symbols)
            # Reported, not only logged: a caller that stamps hundreds of manifests wants to know which
            # ones changed without grepping its own log.
            Removed     = @($dropped)
            Adjusted    = @($adjusted)
        }
    }
}