Modules/businessdev.ALbuild.Containers/Public/Get-BcArtifactSymbolFolder.ps1

function Get-BcArtifactSymbolFolder {
    <#
    .SYNOPSIS
        Returns the symbol (package cache) folders a host AL compile needs from a downloaded BC artifact.
 
    .DESCRIPTION
        Collects every folder of a Get-BcArtifact result that provides compiled first-party symbols
        for a host (AL Tool) compile, in one place instead of each caller re-deriving the layout:
 
          * '<application>/Extensions' - first-party app symbols shipped with the country artifact.
          * '<application>/Applications.<COUNTRY>' - the compiled country apps INCLUDING the test toolkit
            (Tests-TestLibraries, Library Assert, Test Runner, ...). Localized artifacts only.
          * the platform folder holding 'System.app' - the AL system/runtime symbols.
 
        The W1 artifact ships no 'Applications.W1' folder - its compiled test toolkit lives scattered
        through the PLATFORM artifact instead ('Applications/TestFramework/**', 'Applications/BaseApp/Test',
        'Applications/System Application/Test', ...). Without it, compiling a test app against W1 fails
        with AL1022 ('Tests-TestLibraries ... could not be found'). So when the country artifact has no
        'Applications.*' folder, the platform's apps are staged once into '.albuild-toolkit-symbols'
        beside the platform artifact and that folder is returned too (keyed to the artifact version, so
        it is reused across builds). Staging is serialised on the artifact cache lock and published by
        an atomic rename, so a concurrent build never sees a half-copied symbol folder:
          * W1 sandbox (an 'Extensions' folder IS present): only the test toolkit is missing - stage
            just those apps (paths matching 'TestFramework', a 'Test' segment, or a 'Test Library' name).
          * On-premises (NO 'Extensions' and NO 'Applications.<country>'): the platform 'Applications'
            tree is the only source of first-party symbols, so stage them ALL (the business apps
            Application / Base Application / Business Foundation under '<App>/Source', plus the toolkit),
            or the host compile fails with AL1022 for 'Microsoft Application' / 'Base Application'.
 
    .PARAMETER Artifact
        The artifact object returned by Get-BcArtifact (ApplicationPath / PlatformPath).
 
    .PARAMETER Force
        Re-stage the W1 toolkit symbols even when the staging folder already exists.
 
    .EXAMPLE
        $artifact = Get-BcArtifact -ArtifactUrl $url
        $symbols = Get-BcArtifactSymbolFolder -Artifact $artifact
        Invoke-BcCompiler -ProjectFolder .\app -PackageCachePath (@('.alpackages') + $symbols)
 
    .OUTPUTS
        System.String[] - existing folders, ready for the compiler's package cache path.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)] [ValidateNotNull()] [object] $Artifact,
        [switch] $Force
    )

    $paths = [System.Collections.Generic.List[string]]::new()

    $extensions = Join-Path $Artifact.ApplicationPath 'Extensions'
    $hasExtensions = Test-Path -LiteralPath $extensions
    if ($hasExtensions) { $paths.Add($extensions) }

    # Compiled, country-specific first-party apps incl. the test toolkit (localized artifacts only).
    $countryApps = @(Get-ChildItem -LiteralPath $Artifact.ApplicationPath -Directory -ErrorAction SilentlyContinue |
            Where-Object { $_.Name -match '^Applications\..+' })
    foreach ($folder in $countryApps) { $paths.Add($folder.FullName) }

    if ($Artifact.PlatformPath) {
        # The platform 'System.app' (AL system/runtime symbols) sits in a version-specific folder.
        $systemApp = Get-ChildItem -LiteralPath $Artifact.PlatformPath -Recurse -Filter 'System.app' -File -ErrorAction SilentlyContinue |
            Select-Object -First 1
        if ($systemApp) { $paths.Add($systemApp.Directory.FullName) }

        # No 'Applications.<country>' folder: the compiled first-party apps the compiler needs live in an
        # 'Applications' tree instead - stage them once beside the platform artifact.
        # * W1 sandbox: the country artifact's 'Extensions' folder already provides the business apps
        # (Application, Base Application, ...); only the platform's test toolkit is missing, so stage
        # just that (TestFramework / a 'Test' segment / a 'Test Library' name).
        # * On-premises: there is no 'Extensions' (nor 'Applications.<country>') folder at all. The
        # *application* artifact carries an 'Applications' tree with the full country-specific
        # first-party set INCLUDING localization (e.g. the German 'Delivery Reminder' objects in the
        # DE Base Application) - the platform's W1 'Applications' tree does NOT. So prefer the
        # application artifact's tree and stage every app, or the compile fails with AL1022 for
        # 'Microsoft Application' / 'Base Application' or AL0247/AL0185 for localized objects.
        if ($countryApps.Count -eq 0) {
            $appApps = Join-Path $Artifact.ApplicationPath 'Applications'
            $platformApps = Join-Path $Artifact.PlatformPath 'Applications'
            # Choose the source by artifact kind, keyed on 'Extensions' (present only on sandbox):
            # * Sandbox (has 'Extensions'): the business apps come from 'Extensions'; only the test
            # toolkit is missing and it lives in the PLATFORM 'Applications' tree - never in the
            # application artifact. Use the platform tree (whether the country is W1 or localized).
            # * On-prem (no 'Extensions'): the full country first-party set INCLUDING localization is
            # in the APPLICATION artifact's 'Applications' tree; use it (fall back to the platform
            # tree only if the application artifact somehow has none).
            $sourceApps = if (-not $hasExtensions -and (Test-Path -LiteralPath $appApps)) { $appApps }
            elseif (Test-Path -LiteralPath $platformApps) { $platformApps }
            else { '' }
            if ($sourceApps) {
                $staging = Join-Path $Artifact.PlatformPath '.albuild-toolkit-symbols'
                # The staging folder is cached across builds, but its required CONTENT differs: the filter
                # ('toolkit' for a W1 sandbox vs 'full' for on-prem) AND the source tree (the country
                # 'app' artifact vs the 'platform' artifact). A plain "folder is not empty" check is not
                # enough - an older build (or a pre-fix module) may have staged the wrong set. Record both
                # in a marker file and re-stage when it is missing or does not match, so the cache self-heals.
                $sourceTag = if ($sourceApps -eq $appApps) { 'app' } else { 'platform' }
                $mode = "$(if ($hasExtensions) { 'toolkit' } else { 'full' })-$sourceTag"
                $markerFile = Join-Path $staging '.albuild-symbols-mode'
                $currentMode = if (Test-Path -LiteralPath $markerFile) { "$(Get-Content -LiteralPath $markerFile -Raw -ErrorAction SilentlyContinue)".Trim() } else { '' }
                # Wrapped in @(): an if that emits an empty array collapses to $null via the pipeline,
                # and .Count on $null then dies under Set-StrictMode.
                $staged = @(if (Test-Path -LiteralPath $staging) {
                        Get-ChildItem -LiteralPath $staging -Filter '*.app' -File -ErrorAction SilentlyContinue
                    })
                if ($Force -or $staged.Count -eq 0 -or $currentMode -ne $mode) {
                    # Stage under a lock, and publish ATOMICALLY. Copying straight into $staging and
                    # writing the marker afterwards leaves a window in which the folder exists and is
                    # non-empty but incomplete - and the check below only asks whether it has any .app in
                    # it. A concurrent build compiling against a half-copied symbol folder fails with a
                    # misleading AL1022, or worse, compiles against a first-party set that is silently
                    # missing apps. Same shape (and same lock naming) as Get-BcArtifact's extraction.
                    #
                    # Two builds on one host hit this whenever they share an artifact; the runtime factory
                    # makes it routine, because its workers are threads in a single process.
                    $mutex = New-Object System.Threading.Mutex($false, (Get-ALbuildCacheLockName -Path $staging))
                    $held = $false
                    try {
                        try { $held = $mutex.WaitOne([TimeSpan]::FromMinutes(15)) }
                        catch [System.Threading.AbandonedMutexException] { $held = $true }  # prior holder crashed; we own it

                        # Re-check under the lock: another build may have staged it while we waited.
                        $stagedNow = @(if (Test-Path -LiteralPath $staging) {
                                Get-ChildItem -LiteralPath $staging -Filter '*.app' -File -ErrorAction SilentlyContinue
                            })
                        $modeNow = if (Test-Path -LiteralPath $markerFile) { "$(Get-Content -LiteralPath $markerFile -Raw -ErrorAction SilentlyContinue)".Trim() } else { '' }
                        if ($Force -or $stagedNow.Count -eq 0 -or $modeNow -ne $mode) {
                            # Short temp name, deliberately: BC artifacts already nest close to the 260-char
                            # MAX_PATH limit, and a long ".staging-<guid>" sibling pushes the deepest file over it.
                            $temp = Join-Path (Split-Path -Parent $staging) ('.albsym-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
                            try {
                                New-Item -ItemType Directory -Force -Path $temp | Out-Null
                                $sourceAppFiles = @(Get-ChildItem -LiteralPath $sourceApps -Recurse -Filter '*.app' -File -ErrorAction SilentlyContinue)
                                if ($hasExtensions) {
                                    $sourceAppFiles = @($sourceAppFiles | Where-Object { $_.FullName -match '(?i)[\\/]TestFramework[\\/]|[\\/]Test[\\/]|Test Library' })
                                }
                                $sourceAppFiles | Copy-Item -Destination $temp -Force
                                # Marker goes in BEFORE the publish, so the folder is never visible without it.
                                Set-Content -LiteralPath (Join-Path $temp (Split-Path -Leaf $markerFile)) -Value $mode -Encoding UTF8 -NoNewline

                                if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue }
                                Move-ALbuildDirectory -Path $temp -Destination $staging
                                $what = if ($hasExtensions) { 'test toolkit' } else { 'first-party' }
                                Write-ALbuildLog -Level Information "Staged the $what symbols ($($sourceAppFiles.Count) app(s)) into '$staging'."
                            }
                            finally {
                                if (Test-Path -LiteralPath $temp) { Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue }
                            }
                        }
                    }
                    finally {
                        if ($held) { $mutex.ReleaseMutex() }
                        $mutex.Dispose()
                    }
                }
                if (@(Get-ChildItem -LiteralPath $staging -Filter '*.app' -File -ErrorAction SilentlyContinue).Count -gt 0) {
                    $paths.Add($staging)
                }
            }
        }
    }

    return $paths.ToArray()
}