Private/Compliance/Get-GovernancePreflightData.ps1

function Get-GovernanceUnmappedSourceAreas {
    param(
        [Parameter(Mandatory)][System.Collections.IDictionary]$AreaPaths,
        [Parameter(Mandatory)][string]$SourceRoot
    )
    $rootless = $SourceRoot.TrimStart('\')
    foreach ($srcPath in @($AreaPaths.Keys | Sort-Object)) {
        $path = ([string]$srcPath).TrimStart('\')
        if ($path -eq $rootless -or $path -like "$rootless\*") { continue }
        [ordered]@{ source = '\' + $path; workItems = [int]$AreaPaths[$srcPath] }
    }
}

function Get-GovernancePreflightData {
    <#
        .SYNOPSIS
        The GATHER half of preflight (ADR-008): reads one incoming team's
        pre-migration state from its source organisation, and the target-org
        facts the analysis needs (authored UPN resolution), and returns them
        as a plain data document — facts only, no verdicts. The caller writes
        it to preflight-<code>.data.json; Resolve-GovernancePreflightFindings
        turns it into findings offline.

        Read-only against BOTH organisations. Throws on any read failure: a
        half-gathered source must never be analysed as if it were whole.

        Every area path in the document is rooted ('\Project\Node') to match
        the resolved model; source and projected target paths are paired so
        the analysis needs no knowledge of how the projection was made.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Code,
        [Parameter(Mandatory)][object]$Source,       # the sources.yaml entry for this code
        [Parameter(Mandatory)][object]$Slice,        # Select-GovernanceSubtree output
        [Parameter(Mandatory)][string]$Program,
        [Parameter(Mandatory)][string]$TargetOrgUrl,
        [Parameter(Mandatory)][string]$TargetProject,
        [object]$Scope = $null    # the migration query: @{ query; label } (ADR-010)
    )

    $srcOrgUrl   = ConvertTo-AdoOrgUrl -Org ([string]$Source.org)
    $srcProject  = [string]$Source.project
    $srcAreaRoot = '\' + [string]$Source.areaPath
    $srcTeams    = @($Source.teams | Where-Object { $_ })
    $repoInclude = if ($Source.repos) { @($Source.repos.include) } else { $null }

    # ── Source organisation ──────────────────────────────────────────────────
    $authState = Enter-AdoOrgAuth -OrgUrl $srcOrgUrl -AccessTokenRef ([string]$Source.accessToken)
    try {
        try {
            $srcAreas = Get-AdoAreaPathSubtree -OrgUrl $srcOrgUrl -Project $srcProject
        } catch {
            # The first read is where a wrong credential surfaces. Name the route
            # so the report can say WHY, not just "401".
            $route = switch ($authState.Route) {
                'entra'      { 'the signed-in Entra identity' }
                'source-pat' { "the sources.yaml accessToken PAT" }
                'target-pat' { 'the TARGET org PAT as a fallback (it is not valid for the source org unless created for all accessible organisations)' }
                default      { 'an unknown route' }
            }
            $why = if ($authState.Why) { " Entra was not used: $($authState.Why)." } else { '' }
            throw "source organisation $srcOrgUrl refused the read using $route`: $(([string]$_).TrimEnd('.')).$why"
        }
        if (-not $srcAreas.ContainsKey($srcAreaRoot)) {
            throw "source area path '$srcAreaRoot' does not exist in $($Source.org)/$srcProject"
        }
        $usage = Get-AdoWorkItemUsageUnderArea -OrgUrl $srcOrgUrl -Project $srcProject `
            -AreaPath ([string]$Source.areaPath) -Query ([string]$Scope.query)

        $repos = $null
        if ($null -ne $repoInclude) {
            $all   = Get-AdoRepoSet -OrgUrl $srcOrgUrl -Project $srcProject
            $repos = @($all.Keys | Where-Object { $n = $_; @($repoInclude | Where-Object { $n -like $_ }).Count -gt 0 } | Sort-Object)
        }

        $population = [ordered]@{}   # upn -> source team it was found in
        foreach ($srcTeam in $srcTeams) {
            foreach ($upn in @((Get-AdoTeamMemberUpnSet -OrgUrl $srcOrgUrl -Project $srcProject -Team $srcTeam).Keys | Sort-Object)) {
                $population[$upn] = $srcTeam
            }
        }
    } finally { Exit-AdoOrgAuth -State $authState }

    # ── Projection: source subtree -> target coordinates, with usage per path ─
    $areas = foreach ($srcPath in @($srcAreas.Keys | Where-Object { $_ -eq $srcAreaRoot -or $_ -like "$srcAreaRoot\*" } | Sort-Object)) {
        [ordered]@{
            source    = $srcPath
            target    = $Slice.TargetRoot + $srcPath.Substring($srcAreaRoot.Length)
            workItems = [int]$usage.AreaPaths[$srcPath.TrimStart('\')]
        }
    }
    # A full query can select work items outside the source root. Their area
    # paths cannot be projected by the root-relative mapping, so surface them
    # explicitly rather than silently omitting them from placement findings.
    $unmappedAreas = @(Get-GovernanceUnmappedSourceAreas -AreaPaths $usage.AreaPaths -SourceRoot $srcAreaRoot)

    # ── Target organisation: can every authored person be granted access? ────
    $upnCache   = @{}   # upn -> @{ resolved; suggestions }
    $lookup = {
        param($upn)
        if (-not $upnCache.ContainsKey($upn)) {
            $descriptor  = Find-AdoUserDescriptor -OrgUrl $TargetOrgUrl -Upn $upn
            $suggestions = if ($descriptor) { @() } else { @(Find-AdoUserSuggestion -OrgUrl $TargetOrgUrl -Upn $upn) }
            $upnCache[$upn] = @{ resolved = [bool]$descriptor; suggestions = $suggestions }
        }
        $upnCache[$upn]
    }

    $members    = [System.Collections.Generic.List[object]]::new()
    $teamAdmins = [System.Collections.Generic.List[object]]::new()
    $seenGroups = [System.Collections.Generic.HashSet[string]]::new()
    foreach ($team in $Slice.Teams) {
        foreach ($grp in @($team.securityGroups | Where-Object { $_ })) {
            if (-not $seenGroups.Add($grp.ado)) { continue }
            foreach ($e in @($grp.members | Where-Object { $_ })) {
                $upn = if ($e -is [string]) { if ($e -match '@') { $e } else { $null } }
                       elseif ($e.upn)      { [string]$e.upn }
                       else                 { $null }   # nested groups: audited post-apply, not here
                if (-not $upn) { continue }
                $r = & $lookup $upn
                $members.Add([ordered]@{ upn = $upn; group = [string]$grp.ado; resolved = $r.resolved; suggestions = @($r.suggestions) })
            }
        }
        foreach ($m in @($team.teamAdmins | Where-Object { $_ -and $_.upn })) {
            $upn = [string]$m.upn
            $r = & $lookup $upn
            $teamAdmins.Add([ordered]@{ upn = $upn; team = [string]$team.name; resolved = $r.resolved; suggestions = @($r.suggestions) })
        }
    }

    $tags = [ordered]@{}
    foreach ($k in ($usage.Tags.Keys | Sort-Object)) { $tags[$k] = [int]$usage.Tags[$k] }
    $iterations = [ordered]@{}
    foreach ($k in ($usage.IterationPaths.Keys | Sort-Object)) { $iterations[$k] = [int]$usage.IterationPaths[$k] }

    return [ordered]@{
        schema     = 'nkdagility.governance.preflight-data/1'
        gathered   = (Get-Date).ToUniversalTime().ToString('o')
        program    = $Program
        node       = $Code
        target     = [ordered]@{ org = $TargetOrgUrl; project = $TargetProject; root = [string]$Slice.TargetRoot }
        source     = [ordered]@{
            org         = $srcOrgUrl
            project     = $srcProject
            areaPath    = $srcAreaRoot
            teams       = @($srcTeams)
            repoInclude = $repoInclude
        }
        # The scope is recorded with the facts it produced: a data file gathered
        # under a different migration query is a different population, and
        # -SkipFresh compares this to decide whether reuse is honest.
        scope      = if ($Scope -and $Scope.query) {
            [ordered]@{ query = [string]$Scope.query; label = [string]$Scope.label }
        } else { $null }
        workItems  = [ordered]@{ count = [int]$usage.WorkItemCount }
        areas      = @($areas)
        unmappedAreas = @($unmappedAreas)
        tags       = $tags
        iterations = $iterations
        repos      = $repos
        population = $population
        authored   = [ordered]@{ members = @($members); teamAdmins = @($teamAdmins) }
    }
}