Modules/businessdev.ALbuild.RuntimePackages/Private/Invoke-BcSourceEmit.ps1

function Invoke-BcSourceEmit {
    <#
    .SYNOPSIS
        Drives Microsoft's AL compiler: manifest, symbol references, compilation, emit.
 
    .DESCRIPTION
        Late-bound throughout. The BC assemblies are version-specific and live wherever the artifact
        cache put them, so nothing here may be resolved at parse time.
 
        Arguments are bound BY PARAMETER NAME, not by position. Compilation.Create takes twelve
        parameters and CompilationOptions twenty-one, and BC majors have added to both. Binding by name
        means an unknown parameter falls back to its default instead of shifting everything after it.
 
    .PARAMETER Compiler
        An Import-BcAlCompiler result.
 
    .OUTPUTS
        PSCustomObject with Path, Bytes and Objects.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [PSCustomObject] $Compiler,
        [Parameter(Mandatory)] [string] $ProjectFolder,
        [Parameter(Mandatory)] [string] $OutputFolder,
        [string[]] $SymbolFolder = @(),
        [ValidateSet('Runtime', 'App')] [string] $Kind = 'Runtime',
        [switch] $IncludeAlCode,
        [string] $FileName
    )

    # Every emit that needs a single symbol directory leaves an 'albsym-*' folder in TEMP. A crashed
    # emit cannot remove its own, and this runs once per platform version - on run 27693 the agents
    # filled C: and the build died with 'not enough space on the disk'. Sweep what is demonstrably
    # finished (older than two hours) before adding another one.
    try {
        $tempRoot = Get-ALbuildScratchFolder
        $cutoff = (Get-Date).AddHours(-2)
        foreach ($stale in @(Get-ChildItem -LiteralPath $tempRoot -Directory -Filter 'albsym-*' -ErrorAction SilentlyContinue |
                    Where-Object { $_.LastWriteTime -lt $cutoff })) {
            Remove-Item -LiteralPath $stale.FullName -Recurse -Force -ErrorAction SilentlyContinue
        }
    }
    catch { }

    $assembly = $Compiler.Assembly
    $types = @()
    try { $types = $assembly.GetTypes() } catch { $types = @($_.Exception.Types | Where-Object { $_ }) }

    function Get-CompilerType([string] $name) {
        $t = $types | Where-Object { $_ -and $_.Name -eq $name } | Select-Object -First 1
        if (-not $t) { throw "This BC version's AL compiler does not expose '$name'. Report the BC version; the binding needs an additional shape." }
        return $t
    }

    # PowerShell unrolls an empty collection on return, which turns a fresh List into $null. The
    # leading comma is what stops it.
    function New-TypedList([Type] $elementType) {
        return , [Activator]::CreateInstance([System.Collections.Generic.List`1].MakeGenericType(@($elementType)))
    }

    function Get-DefaultFor([Type] $t) {
        if ($t.IsValueType) { return [Activator]::CreateInstance($t) }
        return $null
    }


    # Builds a reflection argument list by PARAMETER NAME from a name->value map.
    #
    # Deliberately not a `switch` expression: switch writes its result to the pipeline, and PowerShell
    # UNROLLS collections on the way. One of these values is an ImmutableArray<Guid> that is often in
    # its default state, and enumerating that throws "This operation cannot be performed on a default
    # instance of ImmutableArray<T>" - from inside the argument list, naming nothing that led there.
    function Build-Arguments([System.Reflection.MethodBase] $method, [hashtable] $map) {
        $result = [object[]]::new($method.GetParameters().Count)
        $i = 0
        foreach ($p in $method.GetParameters()) {
            if ($map.ContainsKey($p.Name)) { $result[$i] = $map[$p.Name] }
            else { $result[$i] = Get-DefaultFor $p.ParameterType }
            $i++
        }
        return , $result
    }

    $projectFull = (Resolve-Path -LiteralPath $ProjectFolder).Path
    $manifestPath = Join-Path $projectFull 'app.json'

    # --- manifest, read by the compiler's own reader ------------------------------------------------
    # Hardcoding these was tried and was wrong twice over: the ID ranges came out as the generic
    # extension range so every object of a real project was reported out of range, and the compilation
    # target ignored what app.json actually says.
    $projectManifestType = Get-CompilerType 'ProjectManifest'
    $readFromString = $projectManifestType.GetMethods('Public,Static') |
        Where-Object { $_.Name -eq 'ReadFromString' -and $_.GetParameters().Count -eq 3 } | Select-Object -First 1
    if (-not $readFromString) { throw "This BC version's ProjectManifest does not expose the expected ReadFromString." }

    $diagnosticType = $readFromString.GetParameters()[2].ParameterType.GetGenericArguments()[0]
    $manifestDiagnostics = New-TypedList $diagnosticType
    $projectManifest = $readFromString.Invoke($null, @(
            [string]$manifestPath, [string][System.IO.File]::ReadAllText($manifestPath), $manifestDiagnostics))
    $manifest = $projectManifestType.GetProperty('AppManifest').GetValue($projectManifest)
    if (-not $manifest) {
        throw "'$manifestPath' could not be read as an AL manifest. Check it against a project that builds - id, publisher, version and idRanges are all required."
    }

    # --- symbol references --------------------------------------------------------------------------
    $packageFolders = New-TypedList ([string])
    $defaultPackages = Join-Path $projectFull '.alpackages'
    if (Test-Path -LiteralPath $defaultPackages) { $packageFolders.Add($defaultPackages) }
    foreach ($folder in $SymbolFolder) {
        if ([string]::IsNullOrWhiteSpace($folder)) { continue }
        if (-not (Test-Path -LiteralPath $folder)) { continue }
        $full = (Resolve-Path -LiteralPath $folder).Path
        if (-not ($packageFolders -contains $full)) { $packageFolders.Add($full) }
    }
    Write-ALbuildLog -Level Verbose "Symbol folders: $($packageFolders -join '; ')"

    # RelativeFileSystem, not FileSystem: relative resource paths (control add-in JS/CSS) otherwise
    # resolve against the PROCESS working directory, which produced 93 phantom AL0572 errors.
    $fileSystem = (Get-CompilerType 'RelativeFileSystem').GetConstructors()[0].Invoke(@($projectFull))

    # The loader takes MANY directories from BC 18 onwards and exactly ONE before that:
    # BC17: FileSystemSymbolReferenceLoader(IFileSystem, String directory, IDocumentationProviderFactory)
    # BC28: FileSystemSymbolReferenceLoader(IFileSystem, IEnumerable directories, IDocumentationProviderFactory)
    # Handing a list to the older shape fails with 'List`1[System.String] cannot be converted to
    # System.String' from deep inside a reflection call, naming nothing that leads back to here.
    #
    # The older compilers have no chaining or composite loader either - MemoryCached wraps a single
    # next loader, nothing aggregates - so the one directory has to hold everything. Every symbol
    # package is therefore hard-linked into one folder: instant, and it costs no disk because the links
    # share the original file's data. A copy is the fallback when the folders sit on different volumes.
    # And BC 17.1 and older have no FileSystemSymbolReferenceLoader AT ALL - Microsoft introduced it
    # somewhere between 17.1 and 17.17. What those versions offer instead is
    # 'LocalCacheSymbolReferenceLoader(String cachePath, IDocumentationProviderFactory)': again exactly
    # one directory, and without the IFileSystem. Measured on onprem/17.1.18256.18474, where the run
    # otherwise stopped at "this BC version's AL compiler does not expose 'FileSystemSymbolReferenceLoader'"
    # and lost four packages the container route had produced.
    $symbolLoaderType = $types | Where-Object { $_ -and $_.Name -eq 'FileSystemSymbolReferenceLoader' } | Select-Object -First 1
    $usesLocalCacheLoader = $null -eq $symbolLoaderType
    if ($usesLocalCacheLoader) { $symbolLoaderType = Get-CompilerType 'LocalCacheSymbolReferenceLoader' }
    $symbolLoaderCtor = @($symbolLoaderType.GetConstructors())[0]
    # The directory parameter is the first one for the local-cache shape and the second where an
    # IFileSystem comes first.
    $directoryParameter = $symbolLoaderCtor.GetParameters()[$(if ($usesLocalCacheLoader) { 0 } else { 1 })]
    # Typed on purpose. Reflection does NOT unwrap a PSObject, and PowerShell wraps values freely -
    # '.GetType()' then still reports 'System.String', because PSObject is transparent to it. The
    # failure reads 'PSObject cannot be converted to System.String' while every diagnostic insists the
    # value IS a string. A typed variable settles it.
    [string] $singleFolder = ''
    $mergedFolder = $null
    if ($directoryParameter.ParameterType -eq [string]) {
        $mergedFolder = Get-ALbuildScratchFolder -Name ("albsym-" + [guid]::NewGuid().ToString('N').Substring(0, 10))
        New-Item -Path $mergedFolder -ItemType Directory -Force | Out-Null
        $linked = 0
        foreach ($folder in $packageFolders) {
            foreach ($package in @(Get-ChildItem -LiteralPath $folder -Filter '*.app' -File -ErrorAction SilentlyContinue)) {
                $target = Join-Path $mergedFolder $package.Name
                if (Test-Path -LiteralPath $target) { continue }
                try { New-Item -ItemType HardLink -Path $target -Value $package.FullName -ErrorAction Stop | Out-Null }
                catch { Copy-Item -LiteralPath $package.FullName -Destination $target -Force }
                $linked++
            }
        }
        Write-ALbuildLog -Level Verbose ("This compiler accepts a single symbol directory; $linked package(s) " +
            "gathered into '$mergedFolder'.")
        $singleFolder = $mergedFolder
    }

    # MemoryCached in front of the file-system loader: each symbol package is read once, not once per
    # reference that mentions it. Written as plain statements rather than a pipeline so the arguments
    # reach reflection as themselves.
    $symbolLoader = if ($usesLocalCacheLoader) {
        $symbolLoaderCtor.Invoke(@($singleFolder, $null))
    }
    elseif ($directoryParameter.ParameterType -eq [string]) {
        $symbolLoaderCtor.Invoke(@($fileSystem, $singleFolder, $null))
    }
    else {
        $symbolLoaderCtor.Invoke(@($fileSystem, $packageFolders, $null))
    }
    $memoryCachedCtor = @((Get-CompilerType 'MemoryCachedSymbolReferenceLoader').GetConstructors() |
            Where-Object { $_.GetParameters().Count -eq 1 })[0]
    $loader = $memoryCachedCtor.Invoke(@($symbolLoader))

    $specificationType = Get-CompilerType 'SymbolReferenceSpecification'
    $references = New-TypedList $specificationType
    $manifestType = $manifest.GetType()
    # Taking the references FROM the manifest rather than re-deriving them from app.json means
    # dependency shapes we have never seen still resolve.
    $declared = $manifestType.GetProperty('DependencyReferences').GetValue($manifest)
    if ($declared) { foreach ($d in $declared) { if ($d) { $references.Add($d) } } }
    foreach ($implied in 'AppReference', 'PlatformReference', 'TestReference') {
        $value = $manifestType.GetProperty($implied).GetValue($manifest)
        if ($value) { $references.Add($value) }
    }
    Write-ALbuildLog -Level Verbose "Symbol references: $($references.Count)"

    $resolverFactory = [Activator]::CreateInstance((Get-CompilerType 'NullDotNetResolverFactory'))
    # ReferenceManager is the one internal type in this chain; constructing it beats reimplementing
    # reference resolution.
    $referenceManager = (Get-CompilerType 'ReferenceManager').GetConstructors('NonPublic,Public,Instance')[0].Invoke(
        @($references, $loader, $resolverFactory, $true))

    # --- options ------------------------------------------------------------------------------------
    $optionsType = Get-CompilerType 'CompilationOptions'
    $optionsCtor = $optionsType.GetConstructors() | Sort-Object { $_.GetParameters().Count } -Descending | Select-Object -First 1
    $optionArgs = Build-Arguments $optionsCtor @{
        warningLevel          = 4
        concurrentBuild       = $true
        # Without Code the emit writes a package index naming code files it never wrote.
        generateOptions       = [Enum]::Parse((Get-CompilerType 'CompilationGenerationOptions'), 'All')
        codeGenerationOptions = [Enum]::Parse((Get-CompilerType 'CodeGenerationOption'), 'Text')
        idSpaces              = (Get-CompilerType 'IdSpaces').GetProperty('ExtensionDevelopmentIdSpace').GetValue($null)
    }
    $options = $optionsType.GetMethod('WithManifestOptions').Invoke($optionsCtor.Invoke($optionArgs), @($manifest))

    # --- syntax trees -------------------------------------------------------------------------------
    $syntaxTreeType = Get-CompilerType 'SyntaxTree'
    $parse = $syntaxTreeType.GetMethods('Public,Static') |
        Where-Object { $_.Name -eq 'ParseObjectText' -and $_.GetParameters().Count -eq 5 } | Select-Object -First 1
    if (-not $parse) { throw "This BC version's compiler does not expose the expected ParseObjectText overload." }

    # THE PREPROCESSOR SYMBOLS. Without them every '#if BC25' is parsed as if the symbol were undefined,
    # so version-guarded code is included or excluded exactly the wrong way round - and the result is not
    # an error but a package built from the wrong branch.
    #
    # Measured on run 27447: 49 of 60 packages failed that the container route had built from the same
    # source. Print Agent guards a block with '#if not BC25' (PrAPrinterAccessPerm.Codeunit.al:320); it
    # references the 'User Group Member' table Microsoft removed in BC25. With BC25 defined - as the
    # stamped manifest defines it - the block is skipped. With no symbols at all it was compiled, and
    # AL0433/AL0185/AL0171 followed. The 11 that did compile are no better off: they were built from
    # branches meant for a different platform, silently.
    #
    # 'includeRuntimeVersion' is passed so the parse also honours the manifest's runtime version; the
    # default is 17.0 regardless of the target. CompilationOptions already went through
    # WithManifestOptions - the parse side was simply missed.
    $parseOptionsType = Get-CompilerType 'ParseOptions'
    $parseOptions = $parseOptionsType.GetProperty('Default').GetValue($null)
    $withManifestParse = $parseOptionsType.GetMethod('WithManifestOptions')
    if (-not $withManifestParse) { throw "This BC version's ParseOptions does not expose WithManifestOptions." }
    $parseOptions = $withManifestParse.Invoke($parseOptions, @($manifest, $true))
    # Read from the MANIFEST, not from ParseOptions: ParseOptions exposes only RuntimeVersion and
    # DocumentationMode as properties - the symbols sit in a backing field. A first attempt looked for a
    # 'Preprocessor' property on the options and therefore reported '(none)' in every case, including
    # the ones that were working. A diagnostic that always says the same thing is worse than none.
    $symbolNames = @()
    $manifestSymbols = $manifestType.GetProperty('PreprocessorSymbols')
    if ($manifestSymbols) { $symbolNames = @($manifestSymbols.GetValue($manifest)) }
    Write-ALbuildLog "Preprocessor symbols: $(if ($symbolNames.Count) { $symbolNames -join ', ' } else { '(none in the manifest)' })"

    $ignored = @('.alpackages', '.altestrunner', '.snapshots', '.vscode', '.git', 'bin', 'obj')
    $trees = New-TypedList $syntaxTreeType
    $sourceCount = 0
    foreach ($file in (Get-ChildItem -LiteralPath $projectFull -Filter '*.al' -File -Recurse | Sort-Object FullName)) {
        $relative = $file.FullName.Substring($projectFull.Length).TrimStart([System.IO.Path]::DirectorySeparatorChar)
        $segments = $relative.Split([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
        # Only FOLDER names disqualify - a file may legitimately be called 'bin.al'.
        $inIgnored = $false
        for ($i = 0; $i -lt $segments.Count - 1; $i++) { if ($ignored -contains $segments[$i]) { $inIgnored = $true; break } }
        if ($inIgnored) { continue }
        $trees.Add($parse.Invoke($null, @(
                    [string][System.IO.File]::ReadAllText($file.FullName), [string]$file.FullName,
                    [System.Text.Encoding]::UTF8, $parseOptions, [System.Threading.CancellationToken]::None)))
        $sourceCount++
    }
    Write-ALbuildLog -Level Verbose "Source files: $sourceCount"

    # --- compilation --------------------------------------------------------------------------------
    # 'AppAlternateIds' exists from BC 18 onwards; on BC 17 the property is simply absent and reading it
    # straight cost a bare 'You cannot call a method on a null-valued expression', pointing at a
    # hashtable that spans a dozen lines.
    #
    # Read with a plain assignment, NOT through a helper function: a function returns through the
    # pipeline, and PowerShell unrolls collections on the way - this value is an ImmutableArray<Guid>
    # that is usually in its default state, and enumerating THAT throws 'This operation cannot be
    # performed on a default instance of ImmutableArray<T>'. The comment on Build-Arguments below warns
    # about exactly this; the first attempt at the BC 17 fix walked into it anyway and took six tests
    # of the modern path down with it.
    $alternateIdsProperty = $manifestType.GetProperty('AppAlternateIds')
    $alternateIds = $null
    if ($alternateIdsProperty) { $alternateIds = $alternateIdsProperty.GetValue($manifest) }

    # FROM THE MANIFEST, not an empty list. An empty one compiles and emits perfectly well, and then
    # nothing can see the app's internals: Banking's demo tool failed to publish against the runtime
    # package with 'AL0161: Codeunit "bdev.BNK API Client" is inaccessible due to its protection
    # level' for every internal object it legitimately uses. app.json's 'internalsVisibleTo' is
    # normally just the test app, which no release cares about - which is why this went unnoticed
    # until an app that ships alongside the product needed it.
    $internalsVisibleTo = New-TypedList $specificationType
    $declaredInternals = $manifestType.GetProperty('InternalsVisibleTo').GetValue($manifest)
    if ($declaredInternals) {
        foreach ($entry in $declaredInternals) {
            if (-not $entry) { continue }
            # Add one by one: the manifest exposes IEnumerable of the same specification type, but a
            # cast of the whole collection is not guaranteed across BC majors.
            $internalsVisibleTo.Add($entry)
        }
    }
    Write-ALbuildLog -Level Verbose "internalsVisibleTo: $($internalsVisibleTo.Count)"

    $compilationType = Get-CompilerType 'Compilation'
    $create = $compilationType.GetMethod('Create')
    $createArgs = Build-Arguments $create @{
        moduleName            = $manifestType.GetProperty('AppName').GetValue($manifest)
        publisher             = $manifestType.GetProperty('AppPublisher').GetValue($manifest)
        version               = $manifestType.GetProperty('AppVersion').GetValue($manifest)
        appId                 = $manifestType.GetProperty('AppId').GetValue($manifest)
        internalsVisibleTo    = $internalsVisibleTo
        syntaxTrees           = $trees
        options               = $options
        fileSystem            = $fileSystem
        dotNetResolverFactory = $resolverFactory
        referenceManager      = $referenceManager
        skipInvalidModules    = $true
        alternateIds          = $alternateIds
    }
    $compilation = $create.Invoke($null, $createArgs)

    $diagnostics = @($compilationType.GetMethod('GetDiagnostics').Invoke(
            $compilation, @([System.Threading.CancellationToken]::None)))
    $errors = @($diagnostics | Where-Object { "$($_.Severity)" -eq 'Error' })
    foreach ($warning in ($diagnostics | Where-Object { "$($_.Severity)" -eq 'Warning' })) {
        Write-ALbuildLog -Level Verbose " $($warning.Id): $($warning.GetMessage())"
    }
    if ($errors.Count -gt 0) {
        # The whole list, not a count: a caller reading the log must be able to fix it without rerunning.
        $detail = ($errors | Select-Object -First 20 | ForEach-Object {
                $span = $_.Location.GetLineSpan()
                " $($_.Id) $($span.Path):$($span.StartLinePosition.Line + 1) - $($_.GetMessage())"
            }) -join [Environment]::NewLine
        $more = if ($errors.Count -gt 20) { "$([Environment]::NewLine) ... and $($errors.Count - 20) more" } else { '' }
        throw "The project did not compile: $($errors.Count) error(s).$([Environment]::NewLine)$detail$more"
    }

    # --- emit ---------------------------------------------------------------------------------------
    $emitOptionsType = Get-CompilerType 'EmitOptions'
    $extensionValues = (Get-CompilerType 'ExtensionEmitValues').GetConstructors()[0].Invoke(@(
            $manifestType.GetProperty('AppId').GetValue($manifest),
            $manifestType.GetProperty('AppVersion').GetValue($manifest)))
    $emitCtor = $emitOptionsType.GetConstructors() | Sort-Object { $_.GetParameters().Count } -Descending | Select-Object -First 1
    $emitArgs = Build-Arguments $emitCtor @{
        runtimeMetadataVersion = $emitOptionsType.GetField('CurrentMetadataVersion', 'Public,Static').GetValue($null)
        extensionEmitValues    = $extensionValues
        # MUST stay false. With concurrent emit the package outputter switches to its FilePackageWriter
        # and writes the emitted code to a FOLDER BESIDE THE PROJECT instead of into the package. The
        # package is then silently incomplete and the server rejects it with "Specified part does not
        # exist in the package" - naming neither the cause nor the stray files left in the source tree.
        concurrentEmit         = $false
    }
    $emitOptions = $emitCtor.Invoke($emitArgs)

    if (-not (Test-Path -LiteralPath $OutputFolder)) { New-Item -Path $OutputFolder -ItemType Directory -Force | Out-Null }
    if (-not $FileName) {
        $invalid = [System.IO.Path]::GetInvalidFileNameChars()
        $clean = {
            param($value)
            (($value.ToCharArray() | ForEach-Object { if ($invalid -contains $_) { '_' } else { $_ } }) -join '').Trim()
        }
        $suffix = if ($Kind -eq 'Runtime') { '.runtime.app' } else { '.app' }
        # BC's own naming. Naming it after the project folder produces 'app.app' for the usual
        # app/ + test/ layout, and two projects emitted into one folder overwrite each other.
        $FileName = "$(& $clean $manifestType.GetProperty('AppPublisher').GetValue($manifest))_" +
        "$(& $clean $manifestType.GetProperty('AppName').GetValue($manifest))_" +
        "$(& $clean "$($manifestType.GetProperty('AppVersion').GetValue($manifest))")$suffix"
    }
    $outputPath = Join-Path $OutputFolder $FileName

    # One emit, wherever it goes. Kept as a script block because a RUNTIME package needs TWO of them.
    $emitInto = {
        param([System.IO.Stream] $Target, [string] $OutputterTypeName, [object[]] $CtorArgs,
            [switch] $AddResources)

        $type = Get-CompilerType $OutputterTypeName
        # Match on the argument count AND on there being exactly one Stream parameter: these
        # outputters also have overloads taking a package writer or an original package, and picking
        # one by position alone would bind silently to the wrong shape.
        $ctor = $type.GetConstructors() | Where-Object {
            $parameters = $_.GetParameters()
            $parameters.Count -eq $CtorArgs.Count -and
            @($parameters | Where-Object { $_.ParameterType.Name -eq 'Stream' }).Count -eq 1
        } | Select-Object -First 1
        if (-not $ctor) {
            throw "This BC version's $($type.Name) has no constructor taking a stream with $($CtorArgs.Count) arguments."
        }

        $outputter = $ctor.Invoke($CtorArgs)
        $emitResult = $compilationType.GetMethod('Emit').Invoke(
            $compilation, @($emitOptions, $outputter, [System.Threading.CancellationToken]::None))

        $emitErrors = @($emitResult.Diagnostics | Where-Object { "$($_.Severity)" -eq 'Error' })
        if (-not $emitResult.Success -or $emitErrors.Count -gt 0) {
            $detail = ($emitErrors | Select-Object -First 5 | ForEach-Object { " $($_.Id): $($_.GetMessage())" }) -join [Environment]::NewLine
            throw "The compiler could not produce the package.$([Environment]::NewLine)$detail"
        }

        # Resources are a SEPARATE step. Compilation.Emit writes code and the manifest; the files
        # named by app.json's 'resourceFolders' are added by the outputter's own AddPackagedResources,
        # which alc calls and which nothing here called - so ERiC's package carried no
        # 'Finanzamtsdaten.xlsx' and BC 28.4 refused to install it:
        # A resource matching 'Finanzamtsdaten.xlsx' could not be found in app '365 business ERiC'
        # Non-public, hence reflection, exactly as the ReferenceManager above. Before Dispose: the
        # package is finalised there, and anything added afterwards is lost.
        # ONLY when emitting from source. Handed an original package, Emit copies the resource
        # parts across itself, and adding them again fails with 'Cannot add part for the
        # specified URI because it is already in the package.'
        $resourceFlags = [System.Reflection.BindingFlags] 'NonPublic, Public, Instance'
        $resourceMethod = if ($AddResources) { $type.GetMethod('AddPackagedResources', $resourceFlags) } else { $null }
        if ($resourceMethod) {
            $bag = (Get-CompilerType 'DiagnosticBag').GetConstructor(@()).Invoke(@())
            # Assigned away: this overload is not void in every BC major, and a stray return value
            # joins the function's output - the caller then reads .Path off an array.
            $null = $resourceMethod.Invoke($outputter, @($bag))
            $resourceDiagnostics = @($bag.ToReadOnly())
            $resourceErrors = @($resourceDiagnostics | Where-Object { "$($_.Severity)" -eq 'Error' })
            if ($resourceErrors.Count -gt 0) {
                # Loud, not swallowed: a missing resource is a package that installs and then throws
                # in OnInstallAppPerCompany, which is far more expensive to diagnose than a red build.
                $detail = ($resourceErrors | Select-Object -First 10 | ForEach-Object { " $($_.Id): $($_.GetMessage())" }) -join [Environment]::NewLine
                throw "The resources declared in app.json could not be packaged: $($resourceErrors.Count) error(s).$([Environment]::NewLine)$detail"
            }
            foreach ($d in $resourceDiagnostics) { Write-ALbuildLog -Level Verbose " resource: $($d.Id) $($d.GetMessage())" }
        }

        # The outputter finishes the package on Dispose. Disposing it BEFORE the stream is not
        # stylistic: the package is written during teardown, and a closed stream loses it.
        if ($outputter -is [System.IDisposable]) { $outputter.Dispose() }
    }

    # Filtered, because @($null).Count is 1, not 0. An app.json without resourceFolders makes this
    # property return null, and the unfiltered version therefore looked like ONE declared folder whose
    # name was empty - which refused 20 Banking platform versions in run 27819 with
    # 'declares resourceFolders ()'.
    $resourceFolders = @()
    $resourceFolderProperty = $manifestType.GetProperty('AppResourceFolders')
    if ($resourceFolderProperty) {
        $resourceFolders = @($resourceFolderProperty.GetValue($manifest) | Where-Object { "$_".Trim() })
    }

    # Does this compiler support the two-step path at all? MEASURED, not assumed: BC 24 (compiler 13.1)
    # offers only the 6-argument constructors, BC 26 (15.0) and later add the NavAppPackageReader one.
    # Deciding this from the newest compiler on the developer's machine is what left BC 17-25 building
    # nothing at all in run 27798.
    $runtimeOutputterType = Get-CompilerType 'RuntimePackageModuleOutputter'
    $twoStepCtor = @($runtimeOutputterType.GetConstructors() | Where-Object {
            $parameters = $_.GetParameters()
            $parameters.Count -eq 7 -and
            $parameters[0].ParameterType.Name -eq 'NavAppPackageReader' -and
            $parameters[2].ParameterType.Name -eq 'Stream'
        }).Count -gt 0

    if ($Kind -eq 'Runtime' -and -not $twoStepCtor) {
        # No original-package overload on this compiler, so the package can only be emitted in one step -
        # and then it cannot carry resources. MEASURED on BC 24 (compiler 13.1): emitted one-step with
        # AddPackagedResources called, the resource is in the package under no path at all (PartExists
        # false for every candidate, ReadFilePaths unavailable), and the package is 3859 bytes against
        # 4125 for the two-step BC 29 package that does contain it.
        #
        # So an app that declares resourceFolders gets a refusal here rather than a package that
        # installs and then throws 'A resource matching ... could not be found' from
        # OnInstallAppPerCompany. Everything else emits exactly as it did before the two-step change.
        if ($resourceFolders.Count -gt 0) {
            # NOT a refusal. Refusing produced no package at all for BC 25 and early BC 26 (41 platform
            # versions of ERiC in run 27819), and a product would rather ship those platforms without the
            # resource and guard the code that reads it - '#if BC27' and up - than not ship them.
            #
            # Said out loud, though, every time. A package quietly missing a declared resource is exactly
            # the defect this whole change came from: BC 28.4 refused to install ERiC over a missing
            # Finanzamtsdaten.xlsx, thrown from OnInstallAppPerCompany after a green build. If the code
            # that reads it is NOT guarded away on this platform, this warning is the only notice anyone
            # gets before a customer sees it.
            Write-ALbuildLog -Level Warning ("This BC version's RuntimePackageModuleOutputter has no " +
                "original-package constructor, so the resources declared in app.json are NOT packaged: " +
                "$($resourceFolders -join ', '). The package is otherwise complete. Guard the code that " +
                'reads these resources so it does not exist on this platform, or build this platform ' +
                'version in a container.')
        }
        Write-ALbuildLog -Level Verbose 'This compiler has no original-package constructor; emitting the runtime package in one step.'
        $runtimeOptions = (Get-CompilerType 'RuntimeBuildOptions').GetConstructors()[0].Invoke(@([bool]$IncludeAlCode))
        $stream = [System.IO.File]::Create($outputPath)
        try {
            & $emitInto -Target $stream -OutputterTypeName 'RuntimePackageModuleOutputter' `
                -CtorArgs @($manifest, $stream, $compilation, $emitOptions, $runtimeOptions, $projectFull)
        }
        finally { $stream.Dispose() }
    }
    elseif ($Kind -eq 'Runtime') {
        # A RUNTIME package is built FROM a normal package, not straight from source.
        # RuntimePackageModuleOutputter overrides AddPackagedResources to copy resources out of an
        # ORIGINAL PACKAGE - it never reads resourceFolders from disk. Measured on the same project:
        # the normal package carried a 282-character resource manifest, the runtime package none, and
        # BC then refused to install ERiC because 'Finanzamtsdaten.xlsx' was not in the app. This is
        # also what alc does: compile to an .app, then repack that .app as the runtime package.
        #
        # The second emit REUSES the same Compilation, so this costs an emit (~1 s), not a compile.
        # Done for every app, not only the ones with resourceFolders: the same override is what
        # carries translations, control add-ins and report layouts across, and a runtime package that
        # silently lacks any of them is the defect this whole change is about.
        $intermediate = Get-ALbuildScratchFolder -Name ("albrt-" + [guid]::NewGuid().ToString('N').Substring(0, 10) + '.app')
        try {
            $stageStream = [System.IO.File]::Create($intermediate)
            try {
                & $emitInto -Target $stageStream -OutputterTypeName 'PackageModuleOutputter' `
                    -CtorArgs @($manifest, $stageStream, $compilation, $emitOptions, $projectFull) -AddResources
            }
            finally { $stageStream.Dispose() }
            Write-ALbuildLog -Level Verbose ("Intermediate package: $([Math]::Round((Get-Item -LiteralPath $intermediate).Length / 1KB, 1)) KB" +
                "$(if ($resourceFolders.Count -gt 0) { "; resourceFolders: $($resourceFolders -join ', ')" })")

            $readerType = Get-CompilerType 'NavAppPackageReader'
            $createReader = @($readerType.GetMethods('Public, Static') |
                    Where-Object { $_.Name -eq 'Create' -and $_.GetParameters().Count -eq 3 })[0]
            if (-not $createReader) { throw "This BC version's NavAppPackageReader has no Create(stream, leaveOpen, path)." }

            $readStream = [System.IO.File]::OpenRead($intermediate)
            try {
                $original = $createReader.Invoke($null, @($readStream, $false, [string]$intermediate))
                try {
                    $runtimeOptions = (Get-CompilerType 'RuntimeBuildOptions').GetConstructors()[0].Invoke(@([bool]$IncludeAlCode))
                    $stream = [System.IO.File]::Create($outputPath)
                    try {
                        & $emitInto -Target $stream -OutputterTypeName 'RuntimePackageModuleOutputter' `
                            -CtorArgs @($original, $manifest, $stream, $compilation, $emitOptions, $runtimeOptions, $projectFull)
                    }
                    finally { $stream.Dispose() }
                }
                finally { if ($original -is [System.IDisposable]) { $original.Dispose() } }
            }
            finally { $readStream.Dispose() }
        }
        finally {
            if (Test-Path -LiteralPath $intermediate) { Remove-Item -LiteralPath $intermediate -Force -ErrorAction SilentlyContinue }
        }
    }
    else {
        $stream = [System.IO.File]::Create($outputPath)
        try {
            & $emitInto -Target $stream -OutputterTypeName 'PackageModuleOutputter' `
                -CtorArgs @($manifest, $stream, $compilation, $emitOptions, $projectFull) -AddResources
        }
        finally { $stream.Dispose() }
    }

    $result = [PSCustomObject]@{
        Path    = $outputPath
        Bytes   = (Get-Item -LiteralPath $outputPath).Length
        Objects = $sourceCount
    }
    # The package is written and the loader is done with the folder, so the copies (a hard link is only
    # possible when TEMP and the symbol cache share a volume - otherwise this held full copies of the
    # first-party symbols) can go now rather than at the next call's sweep.
    if ($mergedFolder -and (Test-Path -LiteralPath $mergedFolder)) {
        Remove-Item -LiteralPath $mergedFolder -Recurse -Force -ErrorAction SilentlyContinue
    }
    $result
}