Private/Compliance/Resolve-GovernancePreflightFindings.ps1
|
# The check ids a preflight finding can carry. sources.yaml `labels:` may attach # extra fields (an engagement's rule number, owner lane, task id) per check id; # Test-GovernanceSources validates against this list. $script:GovernancePreflightChecks = @( 'area.orphan', # source sub-area with no authored counterpart 'area.unmapped', # selected work items outside the source projection root 'tag.disallowed', # a disallowed pattern family in use on source work items 'tag.boardColumn', # a tag that names a process stage: becomes a board column 'tag.retire', # a tag declared for removal, no replacement 'tag.unsanctioned', # a tag outside the vocabulary with no destination decided yet 'repo.orphan', # a source repo with no authored name under the node 'member.unresolvable', # an authored role-list UPN the target org cannot resolve 'teamAdmin.unresolvable', # an authored team admin UPN the target org cannot resolve 'member.unauthored', # someone in a source team today who is not authored 'preflight.error' # the gather itself failed ) function Resolve-GovernancePreflightFindings { <# .SYNOPSIS The ANALYSIS half of preflight (ADR-008): pure. Takes the gathered data document (fresh from Get-GovernancePreflightData, or read back from preflight-<code>.data.json) plus the node's slice of the resolved model, runs the SAME evaluators audit uses, and returns structured findings — never touching either organisation. Each finding is an ordered hashtable with at least: class missing | drift | error | unresolvable | exception (the report writer's JSON classes) check one of $script:GovernancePreflightChecks subject the thing the finding is about (path, tag, pattern, UPN, repo) message the human line the text report prints — keeps the legacy prefix (DRIFT / AUDIT EXCEPTION / UNRESOLVABLE / ERROR) plus check-specific fields (source, workItems, tags, examples, group, team, sourceTeam, suggestions) and any fields the caller's Labels map attaches for that check. Also returns Info (context lines that are never findings) and Lines (the console narrative, tagged by level, for the caller to print). #> [CmdletBinding()] param( [Parameter(Mandatory)][object]$Data, [Parameter(Mandatory)][object]$Slice, [object]$Labels = $null ) $findings = [System.Collections.Generic.List[object]]::new() $info = [System.Collections.Generic.List[string]]::new() $lines = [System.Collections.Generic.List[object]]::new() $say = { param($level, $text) $lines.Add(@{ level = $level; text = $text }) } $add = { param($finding) # an [ordered] dictionary — never cast to [hashtable], that drops the order $check = [string]$finding.check if ($Labels -and $Labels.Contains($check)) { foreach ($k in @($Labels[$check].Keys)) { if (-not $finding.Contains($k)) { $finding[$k] = $Labels[$check][$k] } } } $findings.Add($finding) } $nodeCode = [string]$Data.node $srcArea = ([string]$Data.source.areaPath).TrimStart('\') $populationDescription = if ($Data.scope -and $Data.scope.query) { 'in the migration query' } else { "under $srcArea" } $tagUsage = $Data.tags $tagCount = { param($t) [int]$tagUsage[$t] } # ── 1. Area subtree, already projected into target coordinates ──────────── & $say 'section' 'Area paths' $projectedSet = @{} $sourceOf = @{} $usageOf = @{} foreach ($a in @($Data.areas)) { $projectedSet[[string]$a.target] = $true $sourceOf[[string]$a.target] = [string]$a.source $usageOf[[string]$a.target] = [int]$a.workItems } $desiredPaths = @($Slice.AreaPaths | Where-Object { $_.scope -ne 'future' } | ForEach-Object { $_.path }) $areaVerdict = Test-GovernanceAreaCompliance -DesiredPaths $desiredPaths ` -LiveDesiredSet $projectedSet -ModelPaths @($Slice.AreaPaths | ForEach-Object { $_.path }) ` -LiveSubtree $projectedSet foreach ($p in $desiredPaths) { if ($projectedSet[$p]) { & $say 'ok' $p } } foreach ($orphan in $areaVerdict.Orphans) { & $add ([ordered]@{ class = 'exception' check = 'area.orphan' subject = $orphan source = $sourceOf[$orphan] workItems = $usageOf[$orphan] message = "AUDIT EXCEPTION area path: $orphan (today: $($sourceOf[$orphan]), $($usageOf[$orphan]) work item(s) directly on it — author it or fold it to a tag before migration)" }) & $say 'orphan' "area path: $orphan (today: $($sourceOf[$orphan]), $($usageOf[$orphan]) work item(s))" } foreach ($outside in @($Data.unmappedAreas | Where-Object { $_ })) { & $add ([ordered]@{ class = 'exception' check = 'area.unmapped' subject = [string]$outside.source source = [string]$outside.source workItems = [int]$outside.workItems message = "AUDIT EXCEPTION source area: $($outside.source) has $($outside.workItems) selected work item(s) outside the configured source root; define their target placement before migration" }) & $say 'orphan' "selected work items outside source root: $($outside.source) ($($outside.workItems))" } foreach ($m in $areaVerdict.Missing) { $info.Add("authored area path with no source counterpart (apply creates it; decide where its work items come from): $m") & $say 'info' "no source counterpart: $m" } # ── 2. Tags in use on selected work items ──────────────────────────────── if ($Slice.Tags) { & $say 'section' 'Tags' $tagVerdict = Test-GovernanceTagCompliance -Sanctioned @($Slice.Tags.sanctioned) ` -DisallowedPatterns @($Slice.Tags.disallowedPatterns) -LiveTagNames @($tagUsage.Keys) ` -BoardColumns @($Slice.Tags.boardColumns) -Retire @($Slice.Tags.retire) ` -SanctionedPatterns @($Slice.Tags.sanctionedPatterns) # Disallowed patterns exist to catch machine-generated families (build # ids, session ids) that run to thousands of distinct tags — one # finding per PATTERN, with usage and examples, is what a team can act # on; one per tag is a wall nobody reads. foreach ($entry in $tagVerdict.DisallowedByPattern.GetEnumerator()) { $names = @($entry.Value) $onItems = ($names | ForEach-Object { & $tagCount $_ } | Measure-Object -Sum).Sum $examples = @($names | Sort-Object { & $tagCount $_ } -Descending | Select-Object -First 5) & $add ([ordered]@{ class = 'drift' check = 'tag.disallowed' subject = [string]$entry.Key tags = $names.Count workItems = [int]$onItems examples = $examples message = "DRIFT tag pattern '$($entry.Key)': $($names.Count) tag(s) on $onItems work item(s) $populationDescription match a disallowed pattern — e.g. $($examples -join ', ')" }) & $say 'drift' "tag pattern '$($entry.Key)': $($names.Count) tag(s), $onItems work item(s) — e.g. $($examples -join ', ')" } # A tag naming a process stage is a board column wearing a tag's # clothes: the fix is a column, and the tag then stops being applied. foreach ($t in $tagVerdict.BoardColumns) { $n = & $tagCount $t & $add ([ordered]@{ class = 'drift' check = 'tag.boardColumn' subject = $t workItems = $n message = "DRIFT tag '$t': names a process stage, so it belongs as a board column rather than a tag (in use on $n work item(s) $populationDescription)" }) & $say 'drift' "tag -> board column: $t ($n work item(s))" } foreach ($t in $tagVerdict.Retire) { $n = & $tagCount $t & $add ([ordered]@{ class = 'drift' check = 'tag.retire' subject = $t workItems = $n message = "DRIFT tag '$t': declared for retirement, remove it with no replacement (in use on $n work item(s) $populationDescription)" }) & $say 'drift' "tag -> retire: $t ($n work item(s))" } foreach ($t in $tagVerdict.Unsanctioned) { $n = & $tagCount $t & $add ([ordered]@{ class = 'exception' check = 'tag.unsanctioned' subject = $t workItems = $n message = "AUDIT EXCEPTION tag: $t (in use on $n work item(s) $populationDescription — no destination decided: sanction it, make it a board column, or retire it)" }) & $say 'orphan' "tag: $t ($n work item(s))" } foreach ($t in ($tagVerdict.Missing | Sort-Object)) { $info.Add("sanctioned tag not in use at the source (apply seeds it in the target): $t") } # Sanctioned families are accepted, so they are context, never findings. foreach ($entry in $tagVerdict.SanctionedByPattern.GetEnumerator()) { $names = @($entry.Value.tags) $onItems = ($names | ForEach-Object { & $tagCount $_ } | Measure-Object -Sum).Sum $info.Add("sanctioned tag family '$($entry.Key)': $($names.Count) tag(s) on $onItems work item(s), accepted as-is$(if ($entry.Value.note) { " — $($entry.Value.note)" })") } & $say 'ok' "$($tagVerdict.OkCount) sanctioned tag(s) in use across $($Data.workItems.count) work item(s)" } # ── 3. Repo naming (only when sources.yaml names the team's repos) ───────── if ($null -ne $Data.source.repoInclude) { & $say 'section' 'Repos' $globs = @($Data.source.repoInclude) $matched = @($Data.repos) if ($matched.Count -eq 0) { $info.Add("no source repos matched the include patterns ($($globs -join ', ')) — repo naming not checked") & $say 'info' "no source repos matched: $($globs -join ', ')" } else { $repoVerdict = Test-GovernanceRepoCompliance -DesiredNames @($Slice.Repos | ForEach-Object { $_.name }) ` -LiveNames $matched -Project ([string]$Data.target.project) foreach ($name in $matched) { if ($name -notin $repoVerdict.Orphans) { & $say 'ok' "repo: $name" } } foreach ($name in $repoVerdict.Orphans) { & $add ([ordered]@{ class = 'exception' check = 'repo.orphan' subject = $name message = "AUDIT EXCEPTION repo: $name (no authored repo with this name under '$nodeCode' — author it in hierarchy.yaml repos: or plan the rename; authored names are prefixed '$nodeCode-')" }) & $say 'orphan' "repo: $name" } foreach ($name in $repoVerdict.Missing) { $info.Add("authored repo with no source counterpart: $name") & $say 'info' "no source counterpart: repo $name" } } } # ── 4. Members — resolution in the TARGET org + source population ───────── & $say 'section' 'Members' $authoredUpns = @{} foreach ($m in @($Data.authored.members)) { $upn = [string]$m.upn $authoredUpns[$upn] = $true if ($m.resolved) { & $say 'ok' "member resolves: $upn ($($m.group))"; continue } $near = @($m.suggestions) $why = if ($near.Count -gt 0) { "no org member has this exact UPN - did you mean: $($near -join ', ')?" } else { "no org member matches this UPN - the user must be added to the org (Entra membership alone is not enough) before governance can grant them access" } & $add ([ordered]@{ class = 'unresolvable' check = 'member.unresolvable' subject = $upn group = [string]$m.group suggestions = $near message = "UNRESOLVABLE member '$upn' in '$($m.group)': $why" }) & $say 'error' "unresolvable user '$upn' in '$($m.group)': $why" } foreach ($m in @($Data.authored.teamAdmins)) { $upn = [string]$m.upn $authoredUpns[$upn] = $true if ($m.resolved) { continue } $near = @($m.suggestions) $why = if ($near.Count -gt 0) { "no org member has this exact UPN - did you mean: $($near -join ', ')?" } else { 'no org member matches this UPN' } & $add ([ordered]@{ class = 'unresolvable' check = 'teamAdmin.unresolvable' subject = $upn team = [string]$m.team suggestions = $near message = "UNRESOLVABLE team admin '$upn' for '$($m.team)': $why" }) & $say 'error' "unresolvable team admin '$upn' for '$($m.team)': $why" } $population = $Data.population if (-not $population -or @($population.Keys).Count -eq 0) { $info.Add('no source teams declared in sources.yaml — the works-there-today membership check was skipped') & $say 'info' 'no source teams declared — population check skipped' } else { foreach ($upn in @($population.Keys | Sort-Object)) { if ($authoredUpns.ContainsKey($upn)) { & $say 'ok' "authored: $upn"; continue } & $add ([ordered]@{ class = 'drift' check = 'member.unauthored' subject = $upn sourceTeam = [string]$population[$upn] message = "DRIFT members '$nodeCode': '$upn' is in source team '$($population[$upn])' today but is not authored in members/$nodeCode.yaml — they lose access at migration unless added (with a reason) or deliberately left out" }) & $say 'drift' "unauthored: $upn (source team '$($population[$upn])')" } } # ── 5. Migration-mapping context (never findings) ───────────────────────── if ($Data.scope -and $Data.scope.query) { $info.Add("counts cover the work items the migration query selects$(if ($Data.scope.label) { " ($($Data.scope.label))" }): $($Data.scope.query)") } else { $info.Add('no migration query declared, so every work item under the area was counted, archive included — declare sources.yaml scope: to validate only what is actually moving') } $info.Add("$($Data.workItems.count) work item(s) $populationDescription at the source") $iterations = $Data.iterations if ($iterations) { foreach ($k in @($iterations.Keys | Sort-Object { [int]$iterations[$_] } -Descending | Select-Object -First 5)) { $info.Add("iteration path in use at the source ($($iterations[$k]) work item(s)): $k") } } $info.Add('work item type/state/field compatibility is validated by the migration toolchain, not governance') return @{ Findings = @($findings) Info = @($info) Lines = @($lines) } } |