Public/ConvertTo-GovernancePreflightReport.ps1

function ConvertTo-GovernancePreflightReport {
    <#
        .SYNOPSIS
        Renders one team's preflight fix report as markdown from the two files
        preflight already wrote — the data document (facts) and the findings
        document (verdicts) — plus, when present, an observations fragment
        written separately (by a person, or by the preflight-report skill).

        This function OWNS the document (ADR-009). Every count, table and
        label in it is copied from the inputs; nothing is computed by anyone
        who might get it wrong. The fragment is inserted between two markers
        in one bounded section and can never reach a table. Idempotent: the
        same inputs produce the same bytes, and re-running after the fragment
        appears produces the same document plus that section.

        Engagement vocabulary (rule numbers, task ids, owner lanes) comes from
        the finding objects and the optional -Labels map; the name of the
        standard they refer to and the candidate-tag threshold come from
        -Reporting. The engine itself names no customer document.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$DataPath,
        [Parameter(Mandatory)][string]$FindingsPath,
        [string]$ObservationsPath = '',
        [string]$OutputPath = '',
        [object]$Labels = $null,      # check id -> @{ rule; task; lane; ... }
        [object]$Reporting = $null    # @{ standard; audience; candidateTagMinUses; iterationTop; title }
    )

    foreach ($p in $DataPath, $FindingsPath) {
        if (-not (Test-Path -LiteralPath $p)) { throw "ConvertTo-GovernancePreflightReport: input not found: $p" }
    }
    $data = Get-Content -LiteralPath $DataPath -Raw | ConvertFrom-Json -AsHashtable -Depth 20
    $fin  = Get-Content -LiteralPath $FindingsPath -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]$fin.mode -ne 'Preflight') {
        throw "'$FindingsPath' is a '$($fin.mode)' report, not a preflight findings document."
    }

    $code      = [string]$data.node
    $threshold = if ($Reporting -and $Reporting.candidateTagMinUses) { [int]$Reporting.candidateTagMinUses } else { 20 }
    $iterTop   = if ($Reporting -and $Reporting.iterationTop)        { [int]$Reporting.iterationTop }        else { 7 }
    $title     = if ($Reporting -and $Reporting.title)               { [string]$Reporting.title }            else { 'Pre-migration readiness check' }
    # Default beside the inputs, carrying the same self-describing stem the
    # data file has ('<program>-preflight-<CODE>-data.json' -> '…-report.md'),
    # so a report lifted out of the folder still says what it is.
    if (-not $OutputPath) {
        $stem = [System.IO.Path]::GetFileName($DataPath) -replace '[-.]?data\.json$', ''
        $name = if ($stem) { "$stem-report.md" } else { "preflight-$code-report.md" }
        $OutputPath = Join-Path ([System.IO.Path]::GetDirectoryName($DataPath)) $name
    }

    # ── helpers ────────────────────────────────────────────────────────────
    $inv = [System.Globalization.CultureInfo]::InvariantCulture
    $n   = { param($v) ([long]$v).ToString('N0', $inv) }
    $esc = { param($s) ([string]$s) -replace '\|', '\|' -replace "`r?`n", ' ' }
    $L   = [System.Collections.Generic.List[string]]::new()
    $table = {
        param([string[]]$header, [object[]]$rows)
        $L.Add('| ' + (($header | ForEach-Object { & $esc $_ }) -join ' | ') + ' |')
        $L.Add('|' + (($header | ForEach-Object { ' --- ' }) -join '|') + '|')
        foreach ($r in $rows) { $L.Add('| ' + ((@($r) | ForEach-Object { & $esc $_ }) -join ' | ') + ' |') }
        $L.Add('')
    }
    $findings = @($fin.findings)
    $byCheck  = @{}
    foreach ($f in $findings) {
        $k = [string]$f.check
        if (-not $byCheck.ContainsKey($k)) { $byCheck[$k] = [System.Collections.Generic.List[object]]::new() }
        $byCheck[$k].Add($f)
    }
    $count = { param($check) if ($byCheck.ContainsKey($check)) { $byCheck[$check].Count } else { 0 } }

    # Label columns: whatever the program attached, rule/task/lane first.
    $engineFields = @('class', 'check', 'subject', 'message', 'source', 'workItems', 'tags', 'examples', 'group', 'team', 'sourceTeam', 'suggestions', 'why')
    $labelKeys = [System.Collections.Generic.List[string]]::new()
    foreach ($pref in 'rule', 'task', 'lane') { $labelKeys.Add($pref) }
    if ($Labels) { foreach ($c in @($Labels.Keys)) { foreach ($k in @($Labels[$c].Keys)) { if ($k -notin $labelKeys) { $labelKeys.Add([string]$k) } } } }
    foreach ($f in $findings) { foreach ($k in @($f.Keys)) { if ($k -notin $engineFields -and $k -notin $labelKeys) { $labelKeys.Add([string]$k) } } }
    $labelsFor = {
        param($check)
        $out = [ordered]@{}
        if ($Labels -and $Labels.Contains($check)) { foreach ($k in @($Labels[$check].Keys)) { $out[[string]$k] = [string]$Labels[$check][$k] } }
        if ($byCheck.ContainsKey($check)) {
            $first = $byCheck[$check][0]
            foreach ($k in $labelKeys) { if (-not $out.Contains($k) -and $first.Contains($k)) { $out[$k] = [string]$first[$k] } }
        }
        $out
    }
    $anyLabels = $false
    foreach ($c in @($byCheck.Keys) + @(if ($Labels) { $Labels.Keys })) { if ((& $labelsFor $c).Count -gt 0) { $anyLabels = $true; break } }
    $usedLabelKeys = @($labelKeys | Where-Object { $k = $_; @(@($byCheck.Keys) + @(if ($Labels) { $Labels.Keys }) | Where-Object { (& $labelsFor $_).Contains($k) }).Count -gt 0 })
    $titleCase = { param($s) ([string]$s).Substring(0, 1).ToUpperInvariant() + ([string]$s).Substring(1) }

    # ── facts ─────────────────────────────────────────────────────────────
    $areas      = @($data.areas)
    $unmappedAreas = @($data.unmappedAreas | Where-Object { $_ })
    $srcRoot    = [string]$data.source.areaPath
    $subAreas   = @($areas | Where-Object { [string]$_.source -ne $srcRoot })
    $orphanSet  = @{}; foreach ($f in @(if ($byCheck.ContainsKey('area.orphan')) { $byCheck['area.orphan'] })) { $orphanSet[[string]$f.source] = $true }
    $families   = @(if ($byCheck.ContainsKey('tag.disallowed')) { $byCheck['tag.disallowed'] })
    $unsanct    = @(if ($byCheck.ContainsKey('tag.unsanctioned')) { $byCheck['tag.unsanctioned'] })
    $asColumn   = @(if ($byCheck.ContainsKey('tag.boardColumn')) { $byCheck['tag.boardColumn'] })
    $toRetire   = @(if ($byCheck.ContainsKey('tag.retire')) { $byCheck['tag.retire'] })
    $famTags    = ($families | ForEach-Object { [long]$_.tags }      | Measure-Object -Sum).Sum
    $famItems   = ($families | ForEach-Object { [long]$_.workItems } | Measure-Object -Sum).Sum
    $teamsDecl  = @($data.source.teams | Where-Object { $_ }).Count -gt 0
    $reposDecl  = $null -ne $data.source.repoInclude
    $authoredN  = @($data.authored.members).Count + @($data.authored.teamAdmins).Count
    $unresolved = @(if ($byCheck.ContainsKey('member.unresolvable')) { $byCheck['member.unresolvable'] }) + @(if ($byCheck.ContainsKey('teamAdmin.unresolvable')) { $byCheck['teamAdmin.unresolvable'] })
    $unauth     = @(if ($byCheck.ContainsKey('member.unauthored')) { $byCheck['member.unauthored'] })
    $repoOrph   = @(if ($byCheck.ContainsKey('repo.orphan')) { $byCheck['repo.orphan'] })
    $errors     = @(if ($byCheck.ContainsKey('preflight.error')) { $byCheck['preflight.error'] })
    $infoLines  = @($fin.info)
    $sanctionedUnused = @($infoLines | ForEach-Object { if ($_ -match '^sanctioned tag not in use at the source \(apply seeds it in the target\): (.+)$') { $Matches[1] } })

    # ── document ──────────────────────────────────────────────────────────
    $L.Add("# $code — $title")
    $L.Add('')
    $head = [System.Collections.Generic.List[object]]::new()
    $head.Add(@('**Team**', "``$code``"))
    $head.Add(@('**Today**', "``$($data.source.org)`` / ``$($data.source.project)`` / ``$srcRoot`` — $(& $n $data.workItems.count) work items"))
    $head.Add(@('**Destination**', "``$($data.target.org)`` / ``$($data.target.project)`` / ``$($data.target.root)``"))
    # ConvertFrom-Json turns the ISO string back into a DateTime; render it
    # invariantly so the document is the same bytes on every machine.
    $gathered = if ($data.gathered -is [datetime]) { ([datetime]$data.gathered).ToUniversalTime().ToString("yyyy-MM-dd HH:mm 'UTC'", $inv) } else { [string]$data.gathered }
    $head.Add(@('**Data gathered**', $gathered))
    # Never let a reader guess which work items these counts cover. An
    # unscoped run includes archive nobody will migrate, and saying so is the
    # prompt that gets the migration query written.
    $scopeCell = if ($data.scope -and $data.scope.query) {
        $lbl = [string]$data.scope.label
        "$(if ($lbl) { "$lbl — " })``$([string]$data.scope.query)``"
    } else {
        '**every work item under the area, archive included** — no migration query declared'
    }
    $head.Add(@('**Work items in scope**', $scopeCell))
    if ($Reporting -and $Reporting.standard) { $head.Add(@('**Standard**', [string]$Reporting.standard)) }
    if ($Reporting -and $Reporting.audience) { $head.Add(@('**Audience**', [string]$Reporting.audience)) }
    $result = if ($errors.Count -gt 0) { "**ERROR** — the gather did not complete; see Errors" }
              elseif ($findings.Count -eq 0) { '**COMPLIANT** — zero findings' }
              else { "**NOT YET READY** — $(& $n $findings.Count) finding(s) across $($byCheck.Keys.Count) check(s)" }
    $head.Add(@('**Result**', $result))
    & $table @('', '') $head

    if ($errors.Count -gt 0) {
        $L.Add('## Errors'); $L.Add('')
        foreach ($e in $errors) { $L.Add("- $($e.message)"); if ($e.why) { $L.Add(" - **Why:** $($e.why)") } }
        $L.Add('')
    }

    # Summary
    $L.Add('## Summary'); $L.Add('')
    $catalogue = @(
        @{ check = 'area.orphan';            label = 'Area paths not authored in the target';      result = { "$(& $n (& $count 'area.orphan')) of $(& $n $subAreas.Count) sub-areas" } },
        @{ check = 'area.unmapped';          label = 'Selected source areas without target placement'; result = { "$(& $n $unmappedAreas.Count) area(s)" } },
        @{ check = 'tag.disallowed';         label = 'Tags: machine-generated families';           result = { if ($families.Count) { "$($families.Count) families, $(& $n $famTags) tags on $(& $n $famItems) work items" } else { 'none' } } },
        @{ check = 'tag.boardColumn';        label = 'Tags that become board columns';             result = { if ($asColumn.Count) { "$(& $n $asColumn.Count) tags" } else { 'none declared' } } },
        @{ check = 'tag.retire';             label = 'Tags to retire outright';                    result = { if ($toRetire.Count) { "$(& $n $toRetire.Count) tags" } else { 'none declared' } } },
        @{ check = 'tag.unsanctioned';       label = 'Tags with no destination decided';           result = { "$(& $n $unsanct.Count) tags" } },
        @{ check = 'repo.orphan';            label = 'Repositories not authored';                  result = { if (-not $reposDecl) { 'not checked — no repository filter declared' } else { "$(& $n $repoOrph.Count) of $(& $n @($data.repos).Count) repos" } } },
        @{ check = 'member.unresolvable';    label = 'Authored people the target cannot resolve';  result = { if ($unresolved.Count) { "$(& $n $unresolved.Count) of $(& $n $authoredN)" } else { "pass — $(& $n $authoredN) authored, all resolve" } } },
        @{ check = 'member.unauthored';      label = 'People in the source team today, not authored'; result = { if (-not $teamsDecl) { 'not checked — no source teams declared' } elseif ($unauth.Count) { "$(& $n $unauth.Count) of $(& $n @($data.population.Keys).Count)" } else { "pass — $(& $n @($data.population.Keys).Count) people, all authored" } } }
    )
    $hdr = @('Check', 'Result') + @($usedLabelKeys | ForEach-Object { & $titleCase $_ })
    # Rows are emitted with a leading comma throughout this function: a bare
    # array coming out of a foreach statement unrolls into single cells.
    $rows = foreach ($c in $catalogue) {
        $lab = & $labelsFor $c.check
        if ($c.check -eq 'member.unresolvable' -and $lab.Count -eq 0) { $lab = & $labelsFor 'teamAdmin.unresolvable' }
        , (@($c.label, (& $c.result)) + @($usedLabelKeys | ForEach-Object { if ($lab.Contains($_)) { $lab[$_] } else { '' } }))
    }
    & $table $hdr @($rows)

    # 1. Area paths
    $L.Add("## 1. Area paths that fold to tags — $(& $n (& $count 'area.orphan')) of $(& $n $subAreas.Count) sub-areas"); $L.Add('')
    $L.Add('An area path in the target means one thing: who answers for the work. A sub-area that is authored has a node to land on and survives the move; one that is not folds — to a tag, or to nothing. Work items are those sitting directly on each path.'); $L.Add('')
    $areaRows = foreach ($a in ($areas | Sort-Object { -[long]$_.workItems }, { [string]$_.source })) {
        $src = [string]$a.source
        $st  = if ($src -eq $srcRoot)            { 'the team root' }
               elseif ($orphanSet.ContainsKey($src)) { 'folds — not authored' }
               else                              { "authored as ``$([string]$a.target)``" }
        , @((& $n $a.workItems), "``$src``", $st)
    }
    $areaRows += @(foreach ($a in ($unmappedAreas | Sort-Object { -[long]$_.workItems }, { [string]$_.source })) {
        , @((& $n $a.workItems), "``$($a.source)``", 'outside source root — target placement required')
    })
    & $table @('Work items', 'Area path today', 'In the target') @($areaRows)

    # 2. Tags
    $L.Add("## 2. Tags — $(& $n @($data.tags.Keys).Count) distinct tags in use"); $L.Add('')
    $L.Add('### 2a. Machine-generated families'); $L.Add('')
    if ($families.Count -eq 0) { $L.Add('None matched a disallowed pattern.'); $L.Add('') }
    else {
        $L.Add('Each row is one disallowed pattern. These are reported as families because no one applied them by hand, and they are removed as families.'); $L.Add('')
        $famRows = foreach ($f in ($families | Sort-Object { -[long]$_.tags })) { , @("``$($f.subject)``", (& $n $f.tags), (& $n $f.workItems), (@($f.examples | Select-Object -First 3) -join ', ')) }
        & $table @('Pattern', 'Distinct tags', 'Work items', 'Examples') @($famRows)
    }
    if ($asColumn.Count -gt 0) {
        $L.Add("### 2b. Tags that become board columns — $(& $n $asColumn.Count)"); $L.Add('')
        $L.Add('These name where work has got to, not what it is. A board column carries that, and the tag stops being applied once the column exists — so this is a board change, not a vocabulary decision.'); $L.Add('')
        & $table @('Uses', 'Tag') @(foreach ($t in ($asColumn | Sort-Object { -[long]$_.workItems }, { [string]$_.subject })) { , @((& $n $t.workItems), $t.subject) })
    }
    if ($toRetire.Count -gt 0) {
        $L.Add("### 2c. Tags to retire outright — $(& $n $toRetire.Count)"); $L.Add('')
        $L.Add('Declared for removal with no replacement. Nothing depends on them.'); $L.Add('')
        & $table @('Uses', 'Tag') @(foreach ($t in ($toRetire | Sort-Object { -[long]$_.workItems }, { [string]$_.subject })) { , @((& $n $t.workItems), $t.subject) })
    }
    $L.Add("### 2d. Tags with no destination decided — $(& $n $unsanct.Count)"); $L.Add('')
    $L.Add('Each of these needs one of three answers: **sanction** it into the vocabulary, make it a **board column**, or **retire** it. Anything still undecided at migration is an exception in the target from day one.'); $L.Add('')
    if ($unsanct.Count -gt 0) {
        $buckets = [ordered]@{ '1 work item' = 0; '2 to 5' = 0; '6 to 20' = 0; '21 to 50' = 0; 'more than 50' = 0 }
        foreach ($t in $unsanct) {
            $w = [long]$t.workItems
            $k = if ($w -le 1) { '1 work item' } elseif ($w -le 5) { '2 to 5' } elseif ($w -le 20) { '6 to 20' } elseif ($w -le 50) { '21 to 50' } else { 'more than 50' }
            $buckets[$k]++
        }
        & $table @('Used on', 'Distinct tags') @(foreach ($k in $buckets.Keys) { , @($k, (& $n $buckets[$k])) })
        $cands = @($unsanct | Where-Object { [long]$_.workItems -gt $threshold } | Sort-Object { -[long]$_.workItems }, { [string]$_.subject })
        $L.Add("Tags used on more than $(& $n $threshold) work items — decide these first ($(& $n $cands.Count)):"); $L.Add('')
        if ($cands.Count -gt 0) { & $table @('Uses', 'Tag') @(foreach ($t in $cands) { , @((& $n $t.workItems), $t.subject) }) }
        else { $L.Add('None.'); $L.Add('') }
    }
    if ($sanctionedUnused.Count -gt 0) {
        $L.Add("Sanctioned tags not yet in use here, created in the target automatically: $(($sanctionedUnused | Sort-Object) -join ', ').")
        $L.Add('')
    }

    # 3. People
    $L.Add('## 3. People'); $L.Add('')
    if ($unresolved.Count -eq 0) { $L.Add("All $(& $n $authoredN) authored people and team admins resolve in the target organisation.") }
    else {
        $L.Add("$(& $n $unresolved.Count) authored entries do not resolve in the target organisation:"); $L.Add('')
        & $table @('UPN', 'Where', 'Did you mean') @(foreach ($u in $unresolved) { , @($u.subject, $(if ($u.group) { $u.group } else { $u.team }), (@($u.suggestions) -join ', ')) })
    }
    $L.Add('')
    if (-not $teamsDecl) { $L.Add('**Not checked:** no source teams are declared, so whether anyone working here today is missing from the authored list is unknown. Declaring the source team(s) enables this check.') }
    elseif ($unauth.Count -eq 0) { $L.Add("Everyone in the declared source team(s) ($(& $n @($data.population.Keys).Count) people) is authored.") }
    else {
        $L.Add("$(& $n $unauth.Count) people work here today and are not authored — they lose access at migration unless added or deliberately left out:"); $L.Add('')
        & $table @('UPN', 'Source team') @(foreach ($u in $unauth) { , @($u.subject, $u.sourceTeam) })
    }
    $L.Add('')

    # 4. Repositories
    $L.Add('## 4. Repositories'); $L.Add('')
    if (-not $reposDecl) { $L.Add('**Not checked:** no repository filter is declared for this team. Declaring one enables the naming check.') }
    elseif ($repoOrph.Count -eq 0) { $L.Add("All $(& $n @($data.repos).Count) matched repositories have an authored name.") }
    else {
        $L.Add("$(& $n $repoOrph.Count) of $(& $n @($data.repos).Count) matched repositories have no authored name:"); $L.Add('')
        & $table @('Repository') @(foreach ($r in $repoOrph) { , @($r.subject) })
    }
    $L.Add('')

    # 5. Iterations
    $iters = @($data.iterations.Keys | Sort-Object { -[long]$data.iterations[$_] }, { $_ } | Select-Object -First $iterTop)
    if ($iters.Count -gt 0) {
        $L.Add("## 5. Iteration paths in use — top $(& $n $iters.Count) of $(& $n @($data.iterations.Keys).Count)"); $L.Add('')
        $L.Add('Context for the migration mapping, not a finding.'); $L.Add('')
        & $table @('Work items', 'Iteration path') @(foreach ($i in $iters) { , @((& $n $data.iterations[$i]), $i) })
    }

    # 6. Observations (the only section anyone other than this function writes)
    $L.Add('## 6. Observations'); $L.Add('')
    $L.Add('<!-- observations:begin -->')
    # Observations comment on a specific analysis. If the findings have been
    # regenerated since the fragment was written, the commentary may contradict
    # the tables above it - which is exactly how a report ends up arguing with
    # itself - so it is WITHHELD rather than spliced. Modification time is the
    # test on purpose: a confused writer cannot forge it the way it could forge
    # a stamp inside the file.
    $obsStale = $false
    if ($ObservationsPath -and (Test-Path -LiteralPath $ObservationsPath)) {
        $obsStale = (Get-Item -LiteralPath $ObservationsPath).LastWriteTimeUtc -lt (Get-Item -LiteralPath $FindingsPath).LastWriteTimeUtc
    }
    if ($ObservationsPath -and (Test-Path -LiteralPath $ObservationsPath) -and -not $obsStale) {
        $frag = (Get-Content -LiteralPath $ObservationsPath -Raw) -replace "`r`n", "`n"
        foreach ($line in ($frag.Trim() -split "`n")) { $L.Add($line) }
    } elseif ($obsStale) {
        $L.Add("_Observations withheld: ``$(Split-Path -Leaf $ObservationsPath)`` was written before the current analysis, so it may contradict the tables above. Re-run the observation step, or delete the file._")
    } else {
        $L.Add('_No observations were generated for this report._')
    }
    $L.Add('<!-- observations:end -->')
    $L.Add('')

    # 7. Returns
    $L.Add('## 7. What this team owes'); $L.Add('')
    $owed = [System.Collections.Generic.List[string]]::new()
    foreach ($c in $catalogue) {
        $hits = & $count $c.check
        if ($c.check -eq 'member.unresolvable') { $hits += & $count 'teamAdmin.unresolvable' }
        if ($hits -eq 0) { continue }
        $lab = & $labelsFor $c.check
        $tag = @(); if ($lab.Contains('task')) { $tag += "Task $($lab['task'])" }; if ($lab.Contains('rule')) { $tag += "rule $($lab['rule'])" }; if ($lab.Contains('lane')) { $tag += $lab['lane'] }
        $owed.Add("- **$($c.label)** — $(& $n $hits)$(if ($tag.Count) { ' · ' + ($tag -join ' · ') })")
    }
    if (-not $teamsDecl) { $owed.Add('- **Declare the source team(s)** so the missing-people check can run') }
    if (-not $reposDecl) { $owed.Add('- **Declare the repository filter** so the naming check can run') }
    if ($owed.Count -eq 0) { $L.Add('Nothing. This team is at the bar.') } else { foreach ($o in $owed) { $L.Add($o) } }
    $L.Add('')
    $L.Add('---')
    $L.Add("_Rendered by NKDAgility.AzureDevOps.Governance from ``$(Split-Path -Leaf $DataPath)`` and ``$(Split-Path -Leaf $FindingsPath)``. Every number above is copied from those files; the Observations section is the only prose written by anyone else._")
    $L.Add('')

    $outDir = [System.IO.Path]::GetDirectoryName($OutputPath)
    if ($outDir -and -not (Test-Path -LiteralPath $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null }
    [System.IO.File]::WriteAllText($OutputPath, ($L -join "`n"), [System.Text.UTF8Encoding]::new($false))
    return $OutputPath
}