Public/Invoke-GovernancePreflight.ps1

function Invoke-GovernancePreflight {
    <#
        .SYNOPSIS
        Pre-migration compliance report, per incoming team: if this team's
        current content moved into the governed target TODAY, which checks
        would fail? Two steps per node (ADR-008):

          gather Get-GovernancePreflightData reads the team's source location
                   (programs/<name>/sources.yaml) and the target org, and writes
                   the facts to preflight-<code>.data.json — no verdicts.
          analyse Resolve-GovernancePreflightFindings reads that document,
                   projects it against the node's slice of the resolved model
                   and runs the SAME evaluators audit uses — offline, pure —
                   so a team can fix its findings in place before migration and
                   the post-migration audit can never disagree with preflight.

        -Offline skips the gather and re-analyses the last data file: change a
        tag pattern, re-run, no org is touched. -SkipFresh does the same per
        node for any node whose data file already exists (optionally younger
        than -MaxAgeHours), so a run interrupted by a sign-in expiry resumes
        with only the missing teams — and touches no org at all when nothing
        is left to gather. A gather that fails records an ERROR finding and
        leaves the previous data file in place.

        Checked per node: area subtree shape (orphans-to-be, with work items
        per path), tag usage on work items under the source area (disallowed
        pattern families bundled, unsanctioned tags individually), repo naming
        (when sources.yaml names the team's repos), authored member UPNs
        resolvable in the TARGET org, and people working in the source team
        today who are not authored (the day-one lockout list).

        Read-only against BOTH organisations. Writes preflight-<code>.data.json,
        preflight-<code>.txt and preflight-<code>.json per node next to
        resolved.yaml, and exits non-zero when findings exist (same CI contract
        as audit). Findings are objects: class, check id, subject, counts,
        examples, plus any `labels:` the program attaches per check.

        Out of scope by design: work item type/state/field compatibility —
        that is the migration toolchain's validation, not governance.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$ProgramPath,
        [Parameter(Mandatory)][string]$ResolvedPath,
        [string]$Code,
        [string]$Org,
        [switch]$Offline,
        [switch]$SkipFresh,
        [int]$MaxAgeHours = 0   # with -SkipFresh: 0 = any existing data file counts as fresh
    )

    # Always build first — preflight must reflect the latest authored config.
    Invoke-GovernanceBuild -ProgramPath $ProgramPath -OutputPath $ResolvedPath | Out-Null

    $source  = Import-GovernanceSource -ProgramPath $ProgramPath
    if (-not $source.Sources) {
        throw "No sources.yaml in '$ProgramPath'. Preflight needs the pre-migration source location per node — see Test-GovernanceSources for the shape."
    }
    $resolved = ConvertFrom-Yaml (Get-Content $ResolvedPath -Raw)

    $issues = @(Test-GovernanceSources -Sources $source.Sources -Resolved $resolved -Labels $source.SourceLabels `
        -Reporting $source.SourceReporting -Scope $source.SourceScope)
    if ($issues.Count -gt 0) {
        $issues | ForEach-Object { Write-Error $_ }
        throw "sources.yaml validation failed with $($issues.Count) issue(s)."
    }

    $codes = if ($Code) {
        if (-not $source.Sources.Contains($Code)) {
            throw "No sources.yaml entry for '$Code'. Declared: $(@($source.Sources.Keys | Sort-Object) -join ', ')"
        }
        @($Code)
    } else { @($source.Sources.Keys | Sort-Object) }

    $manifest      = $source.Manifest
    $targetOrg     = if ($Org) { $Org } else { $manifest.org }
    $targetOrgUrl  = ConvertTo-AdoOrgUrl -Org $targetOrg
    $targetProject = if ($resolved.project -and $resolved.project.name) { $resolved.project.name } else { $resolved.program }
    # The migration query, per node: the node's own scope, else the program's.
    $scopeFor = { param($code)
        $s = $source.Sources[$code]
        if ($s.scope) { $s.scope } else { $source.SourceScope }
    }

    # Decide per node whether to gather or reuse, BEFORE authenticating: when
    # every node reuses, no organisation is contacted at all.
    $reuse = @{}
    foreach ($nodeCode in $codes) {
        $dataPath = (Get-GovernancePreflightPaths -ResolvedPath $ResolvedPath -Code $nodeCode -Program (Split-Path -Leaf $ProgramPath)).Data
        $fresh = (Test-Path -LiteralPath $dataPath) -and (
            $MaxAgeHours -le 0 -or ((Get-Date) - (Get-Item -LiteralPath $dataPath).LastWriteTime).TotalHours -lt $MaxAgeHours)
        # A data file gathered under a DIFFERENT migration query is a different
        # population, however recent it is. Reusing it would silently report on
        # work items the query no longer selects, so it is never fresh.
        if ($fresh -and -not $Offline) {
            $wantQuery = [string](& $scopeFor $nodeCode).query
            $hadQuery = try { [string]((Get-Content -LiteralPath $dataPath -Raw | ConvertFrom-Json -AsHashtable -Depth 20).scope.query) } catch { $null }
            if ($wantQuery -ne $hadQuery) {
                Write-Host " [info] $nodeCode — the migration query changed since this data was gathered; re-gathering" -ForegroundColor DarkCyan
                $fresh = $false
            }
        }
        $reuse[$nodeCode] = $Offline -or ($SkipFresh -and $fresh)
    }
    if (@($reuse.Values | Where-Object { -not $_ }).Count -gt 0) {
        Initialize-AdoAuth -Manifest $manifest -OrgUrl $targetOrgUrl | Out-Null
    }

    $totalFindings = 0

    # console vocabulary — same as the reconcile
    $print = {
        param($line)
        switch ($line.level) {
            'section' { Write-Host "`n--- $($line.text) ---" -ForegroundColor Cyan }
            'ok'      { Write-Host " [ok] $($line.text)" -ForegroundColor Green }
            'drift'   { Write-Host " [DRIFT] $($line.text)" -ForegroundColor Red }
            'orphan'  { Write-Host " [AUDIT EXCEPTION] $($line.text) (would not match config after migration)" -ForegroundColor Magenta }
            'error'   { Write-Host " [ERROR] $($line.text)" -ForegroundColor Red }
            'info'    { Write-Host " [info] $($line.text)" -ForegroundColor DarkCyan }
        }
    }

    foreach ($nodeCode in $codes) {
        $src      = $source.Sources[$nodeCode]
        $paths    = Get-GovernancePreflightPaths -ResolvedPath $ResolvedPath -Code $nodeCode -Program (Split-Path -Leaf $ProgramPath)
        $dataPath = $paths.Data
        $findings = @()
        $info     = @()
        New-Item -ItemType Directory -Path $paths.Dir -Force | Out-Null

        $reusing  = $reuse[$nodeCode]
        Write-Host "`nPreflighting '$nodeCode': $($src.org)/$($src.project) :: $($src.areaPath) -> $targetOrg/$targetProject$(if ($reusing) { ' [' + $(if ($Offline) { 'offline' } else { 'fresh' }) + ': re-analysing ' + (Split-Path $dataPath -Leaf) + ']' })" -ForegroundColor Cyan

        try {
            $slice = Select-GovernanceSubtree -Resolved $resolved -Code $nodeCode

            # ── gather (or reload) ────────────────────────────────────────────
            if ($reusing) {
                if (-not (Test-Path -LiteralPath $dataPath)) {
                    throw "no data file at '$dataPath' — run preflight without -Offline first to gather it"
                }
                $data = Get-Content -LiteralPath $dataPath -Raw | ConvertFrom-Json -AsHashtable -Depth 20
                if ([string]$data.schema -ne 'nkdagility.governance.preflight-data/1') {
                    throw "'$dataPath' is not a preflight data document this engine understands (schema '$($data.schema)')"
                }
                if ([string]$data.node -ne $nodeCode) {
                    throw "'$dataPath' was gathered for node '$($data.node)', not '$nodeCode'"
                }
                Write-Host " [info] data gathered $($data.gathered) from $($data.source.org)/$($data.source.project)" -ForegroundColor DarkCyan
            } else {
                $data = Get-GovernancePreflightData -Code $nodeCode -Source $src -Slice $slice `
                    -Program ([string]$resolved.program) -TargetOrgUrl $targetOrgUrl -TargetProject $targetProject `
                    -Scope (& $scopeFor $nodeCode)
                # Only a complete gather may replace the previous data file.
                $data | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $dataPath -Encoding utf8
                Write-Host " [info] data written to: $dataPath ($($data.workItems.count) work item(s), $(@($data.areas).Count) area path(s), $(@($data.tags.Keys).Count) tag(s))" -ForegroundColor DarkCyan
            }

            # ── analyse (pure) ────────────────────────────────────────────────
            $analysis = Resolve-GovernancePreflightFindings -Data $data -Slice $slice -Labels $source.SourceLabels
            foreach ($line in $analysis.Lines) { & $print $line }
            $findings = @($analysis.Findings)
            $info     = @($analysis.Info)
        } catch {
            $findings = @([ordered]@{
                class   = 'error'
                check   = 'preflight.error'
                subject = $nodeCode
                message = "ERROR preflighting '$nodeCode': $_"
            })
            & $print @{ level = 'error'; text = "preflight '$nodeCode': $_" }
        }

        Write-GovernanceReport -FindingObjects $findings -ProgramName $resolved.program `
            -Project $targetProject -OrgUrl $targetOrgUrl -Mode 'Preflight' -ReportPath $paths.Findings `
            -Title 'Governance preflight report' -InfoLines $info -ExtraHeader @(
                "Node : $nodeCode",
                "Source : $($src.org)/$($src.project) :: $($src.areaPath)",
                "Data : $(Split-Path $dataPath -Leaf)") | Out-Null

        $totalFindings += $findings.Count
    }

    if ($totalFindings -gt 0) {
        Write-Error "Governance preflight: $totalFindings finding(s) across $($codes.Count) node(s)."
    }
}