Modules/businessdev.ALbuild.Marketplace/Private/Resolve-BcMarketplaceAppSet.ps1

function Resolve-BcMarketplaceAppSet {
    <#
    .SYNOPSIS
        Resolves the set of .app files to submit to Marketplace from a path, excluding test apps.
 
    .DESCRIPTION
        Marketplace/AppSource submissions take a MAIN app plus its dependency (library) apps, and a TEST
        app must NEVER be published. Release artifacts, however, contain a folder of built .app files
        whose names carry the version - and often the test app too. This turns a path (a folder or a
        single .app) into { MainApp; LibraryApps; Excluded } so the caller need only point at the output
        folder:
          * reads each .app's manifest on the host (Expand-BcAppFile - no container needed);
          * EXCLUDES test apps (manifest Target='Test', or a dependency on a Microsoft test/assert
            library - the same signal AL uses);
          * picks the MAIN app as the one no other included app depends on (the top of the dependency
            graph); the remaining non-test apps are its libraries.
 
    .PARAMETER Path
        A folder containing built .app files, or a single .app file. When -LibraryPath is NOT given this
        must hold the main app AND its libraries; when -LibraryPath IS given it holds only the main app
        (the top of the graph if several non-test apps are present).
 
    .PARAMETER LibraryPath
        Optional additional folder(s) or .app file(s) whose (non-test) apps are ALL treated as library
        apps - for offers whose main app and library app are built by different pipelines/repositories
        and therefore arrive as separate release artifacts (e.g. Address Validation + Extension License).
        When given, libraries come exclusively from here, not from -Path.
 
    .OUTPUTS
        PSCustomObject: MainApp / LibraryApps / Excluded, each element having File, Id, Name, Publisher.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Path,
        [string[]] $LibraryPath = @()
    )

    # Read a folder-or-.app path into app descriptor objects (with IsTest classification). Returns @().
    $readApps = {
        param([string] $p)
        if (-not (Test-Path -LiteralPath $p)) { throw "App path '$p' does not exist." }
        $item = Get-Item -LiteralPath $p
        $found = @()
        if ($item.PSIsContainer) {
            $found = @(Get-ChildItem -LiteralPath $item.FullName -Filter '*.app' -File -Recurse -ErrorAction SilentlyContinue | Sort-Object FullName)
        }
        elseif ($item.Extension -ieq '.app') { $found = @($item) }
        else { throw "App path '$p' is neither a folder nor a .app file." }
        if (@($found).Count -eq 0) { throw "No .app files were found under '$p'." }

        $result = foreach ($f in $found) {
            try { $info = Expand-BcAppFile -Path $f.FullName }
            catch { Write-ALbuildLog -Level Warning "Skipping '$($f.Name)': could not read its manifest ($($_.Exception.Message))."; continue }

            # Test app: manifest Target='Test', or a dependency on a Microsoft test/assert library.
            $target = ''
            try { $appNode = $info.Manifest.SelectSingleNode("//*[local-name()='App']"); if ($appNode) { $target = "$($appNode.GetAttribute('Target'))" } } catch { Write-Verbose "Could not read the App Target attribute from '$($f.Name)': $($_.Exception.Message)" }
            $isTest = ($target -ieq 'Test')
            if (-not $isTest) {
                foreach ($dep in @($info.Dependencies)) {
                    if ("$($dep.Publisher)" -eq 'Microsoft' -and ("$($dep.Name)" -like '*Test*' -or "$($dep.Name)" -like '*Assert*')) { $isTest = $true; break }
                }
            }
            [PSCustomObject]@{ File = $f.FullName; Id = "$($info.Id)"; Name = "$($info.Name)"; Publisher = "$($info.Publisher)"; DependencyIds = @($info.Dependencies | ForEach-Object { "$($_.Id)".ToLowerInvariant() }); IsTest = $isTest }
        }
        return @($result)
    }

    $excluded = @()

    # --- Main app (and, without -LibraryPath, its libraries) come from -Path ---------------------
    $mainApps = @(& $readApps $Path)
    $excluded += @($mainApps | Where-Object { $_.IsTest })
    $mainCandidates = @($mainApps | Where-Object { -not $_.IsTest })
    if ($mainCandidates.Count -eq 0) { throw "All .app files under '$Path' are test apps; there is nothing to publish to Marketplace." }

    # Main app = the one no OTHER candidate depends on (top of the dependency graph).
    $dependedOn = @{}
    foreach ($c in $mainCandidates) { foreach ($d in $c.DependencyIds) { if ($d) { $dependedOn[$d] = $true } } }
    $roots = @($mainCandidates | Where-Object { -not $dependedOn.ContainsKey($_.Id.ToLowerInvariant()) })
    if ($roots.Count -eq 0) { $roots = $mainCandidates }   # cyclic/edge case: fall back to all
    if ($roots.Count -gt 1) {
        Write-ALbuildLog -Level Warning "Found $($roots.Count) independent apps with no dependant ($(($roots | ForEach-Object { $_.Name }) -join ', ')); submitting '$($roots[0].Name)' as the main app. Point the task at a single product's output, set -LibraryPath, or set the product name explicitly, if that is wrong."
    }
    $main = $roots[0]

    # --- Library apps ----------------------------------------------------------------------------
    if (@($LibraryPath).Count -gt 0) {
        # Libraries arrive as separate artifacts (different repo/pipeline): take every non-test app from
        # the library path(s), and drop the main app itself if a library folder happens to include it.
        $libApps = @()
        foreach ($lp in @($LibraryPath)) { $libApps += @(& $readApps $lp) }
        $excluded += @($libApps | Where-Object { $_.IsTest })
        $libraries = @($libApps | Where-Object { -not $_.IsTest -and $_.File -ne $main.File -and $_.Id -ne $main.Id })
    }
    else {
        # Single-folder case: everything else under -Path is a library.
        $libraries = @($mainCandidates | Where-Object { $_.File -ne $main.File })
    }

    foreach ($x in $excluded) { Write-ALbuildLog "Excluding test app '$($x.Name)' ($([System.IO.Path]::GetFileName($x.File))) - test apps are never published to Marketplace." }
    Write-ALbuildLog "Marketplace app set: main '$($main.Name)' ($([System.IO.Path]::GetFileName($main.File)))$(if ($libraries.Count) { "; $($libraries.Count) library app(s): $(($libraries | ForEach-Object { $_.Name }) -join ', ')" })."
    [PSCustomObject]@{ MainApp = $main; LibraryApps = $libraries; Excluded = $excluded }
}