Private/Compliance/Evaluators.ps1
|
# Pure compliance evaluators. # # Each function compares a slice of the resolved (desired) model against an # observed state snapshot and returns the classification — no live calls, no # console output, no fixes. Invoke-GovernanceReconcile gathers live state and # acts on the results; Invoke-GovernancePreflight gathers from a team's # PRE-MIGRATION source location, projects it into target coordinates, and runs # the very same evaluators. Keeping the rules here, and only here, is what # guarantees audit and preflight can never disagree about what compliant means. function Test-GovernanceAreaCompliance { <# .SYNOPSIS Area-path rule: every desired (non-future) path exists; observed paths matched against the FULL model (including scope:future placeholders, ADR-004) are orphans when unmatched. Orphans come back deepest-first so pruning a subtree removes children before parents. Observed input: LiveDesiredSet — path -> $true for each desired path that exists. LiveSubtree — every observed path (hashtable or string[]); $null or a bare root (<=1 entries) skips orphan detection, matching the reconcile's best-effort bulk fetch. #> [CmdletBinding()] param( [AllowEmptyCollection()][string[]]$DesiredPaths = @(), [hashtable]$LiveDesiredSet = @{}, [AllowEmptyCollection()][string[]]$ModelPaths = @(), [object]$LiveSubtree = $null ) $missing = @($DesiredPaths | Where-Object { -not $LiveDesiredSet[$_] }) $orphans = @() $livePaths = if ($LiveSubtree -is [hashtable]) { @($LiveSubtree.Keys) } else { @($LiveSubtree) } if ($livePaths.Count -gt 1) { $modelSet = [System.Collections.Generic.HashSet[string]]::new( [string[]]$ModelPaths, [System.StringComparer]::OrdinalIgnoreCase) $orphans = @($livePaths | Where-Object { -not $modelSet.Contains($_) } | Sort-Object { ($_ -split '\\').Count } -Descending) } return @{ Missing = $missing; Orphans = $orphans } } function Test-GovernanceRepoCompliance { <# .SYNOPSIS Repo rule: every desired repo exists by exact name; observed repos not in the desired set are orphans, except the project default repo (named after the project). Case-insensitive on both sides. Orphans sorted. #> [CmdletBinding()] param( [AllowEmptyCollection()][string[]]$DesiredNames = @(), [AllowEmptyCollection()][string[]]$LiveNames = @(), [Parameter(Mandatory)][string]$Project ) $liveSet = [System.Collections.Generic.HashSet[string]]::new( [string[]]$LiveNames, [System.StringComparer]::OrdinalIgnoreCase) $missing = @($DesiredNames | Where-Object { -not $liveSet.Contains($_) }) $desiredSet = [System.Collections.Generic.HashSet[string]]::new( [string[]](@($DesiredNames) + @($Project)), [System.StringComparer]::OrdinalIgnoreCase) $orphans = @($LiveNames | Sort-Object | Where-Object { -not $desiredSet.Contains($_) }) return @{ Missing = $missing; Orphans = $orphans } } function Test-GovernanceTagCompliance { <# .SYNOPSIS Tag rule (Decision-0041, ADR-006): disallowed patterns are checked FIRST and always win — a sanctioned name matching a disallowed pattern is still drift. Observed tags outside the sanctioned vocabulary are exceptions; sanctioned tags not observed are missing. Disallowed and Unsanctioned come back in sorted observed-tag order, Missing unsorted (callers sort for display, as the reconcile always has). DisallowedByPattern is the same Disallowed set keyed by the pattern that caught each tag (first matching pattern wins, authored order), so a caller can report a machine-generated family — build ids, session ids — as ONE finding instead of one per tag. Patterns that caught nothing are absent. BoardColumns and Retire are DISPOSITIONS for tags that are not in the vocabulary (ADR-011). A tag naming where work has got to — "test passed", "kicked off" — is a board column wearing a tag's clothes, and the fix is a column, not a vocabulary entry. A tag that is simply noise is retired. Whatever is left is Unsanctioned: in use, not sanctioned, and with no destination decided yet — the actual worklist. SanctionedPatterns covers families that are legitimately in use but cannot be listed by name — a per-season marker mints a new one every season. Matches count as OK, never as missing (apply cannot seed a pattern) and never as undecided. Each entry is a regex string or @{ pattern; note }; SanctionedByPattern groups the matches so a report can show the family and say why it is tolerated. Precedence: disallowed pattern, then sanctioned name, then sanctioned pattern, then board column, then retire, then undecided. #> [CmdletBinding()] param( [AllowEmptyCollection()][string[]]$Sanctioned = @(), [AllowEmptyCollection()][string[]]$DisallowedPatterns = @(), [AllowEmptyCollection()][string[]]$LiveTagNames = @(), [AllowEmptyCollection()][string[]]$BoardColumns = @(), [AllowEmptyCollection()][string[]]$Retire = @(), [AllowEmptyCollection()][object[]]$SanctionedPatterns = @() ) $sanctionedPatternList = @(foreach ($p in $SanctionedPatterns) { if ($p -is [System.Collections.IDictionary]) { [pscustomobject]@{ pattern = [string]$p.pattern; note = [string]$p.note } } else { [pscustomobject]@{ pattern = [string]$p; note = '' } } }) | Where-Object { $_.pattern } $liveSet = [System.Collections.Generic.HashSet[string]]::new( [string[]]$LiveTagNames, [System.StringComparer]::OrdinalIgnoreCase) $sanctionedSet = [System.Collections.Generic.HashSet[string]]::new( [string[]]$Sanctioned, [System.StringComparer]::OrdinalIgnoreCase) $columnSet = [System.Collections.Generic.HashSet[string]]::new( [string[]]$BoardColumns, [System.StringComparer]::OrdinalIgnoreCase) $retireSet = [System.Collections.Generic.HashSet[string]]::new( [string[]]$Retire, [System.StringComparer]::OrdinalIgnoreCase) $missing = @($Sanctioned | Where-Object { -not $liveSet.Contains($_) }) $disallowed = [System.Collections.Generic.List[string]]::new() $byPattern = [ordered]@{} $unsanctioned = [System.Collections.Generic.List[string]]::new() $asColumn = [System.Collections.Generic.List[string]]::new() $toRetire = [System.Collections.Generic.List[string]]::new() $bySanctionedPattern = [ordered]@{} $okCount = 0 foreach ($tagName in ($LiveTagNames | Sort-Object)) { $caughtBy = $null foreach ($p in $DisallowedPatterns) { if ($tagName -match $p) { $caughtBy = $p; break } } if ($caughtBy) { $disallowed.Add($tagName) if (-not $byPattern.Contains($caughtBy)) { $byPattern[$caughtBy] = [System.Collections.Generic.List[string]]::new() } $byPattern[$caughtBy].Add($tagName) } elseif ($sanctionedSet.Contains($tagName)) { $okCount++ } else { $okPattern = $null foreach ($sp in $sanctionedPatternList) { if ($tagName -match $sp.pattern) { $okPattern = $sp; break } } if ($okPattern) { $okCount++ if (-not $bySanctionedPattern.Contains($okPattern.pattern)) { $bySanctionedPattern[$okPattern.pattern] = [pscustomobject]@{ note = $okPattern.note; tags = [System.Collections.Generic.List[string]]::new() } } $bySanctionedPattern[$okPattern.pattern].tags.Add($tagName) } elseif ($columnSet.Contains($tagName)) { $asColumn.Add($tagName) } elseif ($retireSet.Contains($tagName)) { $toRetire.Add($tagName) } else { $unsanctioned.Add($tagName) } } } $grouped = [ordered]@{} foreach ($p in $byPattern.Keys) { $grouped[$p] = @($byPattern[$p]) } $groupedOk = [ordered]@{} foreach ($p in $bySanctionedPattern.Keys) { $groupedOk[$p] = [ordered]@{ note = $bySanctionedPattern[$p].note; tags = @($bySanctionedPattern[$p].tags) } } return @{ Missing = $missing Disallowed = @($disallowed) DisallowedByPattern = $grouped SanctionedByPattern = $groupedOk BoardColumns = @($asColumn) Retire = @($toRetire) Unsanctioned = @($unsanctioned) OkCount = $okCount } } function Test-GovernanceMemberSetCompliance { <# .SYNOPSIS Exact-match membership rule shared by security groups and team administrators: every resolved desired entry must be present, and every observed entry must be desired. Two deliberate protections: - Extras are NEVER reported while any desired entry failed to resolve (-AnyUnresolved): an unresolved desired member is indistinguishable from an extra, and acting on that guess could strip a group (or a team's admins) to nothing over a typo. - ExtraFilterPattern limits which observed descriptors are candidates for "extra". Security groups pass '^(aad|msa)\.' so nested groups (which cannot be matched to a UPN list) are never touched; team admins pass '' (everything is a candidate). Desired entries are ordered objects with at least a `descriptor` key; Missing preserves their order so findings read in authored order. #> [CmdletBinding()] param( [AllowEmptyCollection()][object[]]$Desired = @(), [hashtable]$Live = @{}, [bool]$AnyUnresolved = $false, [string]$ExtraFilterPattern = '' ) $desiredDescs = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($d in $Desired) { if ($d.descriptor) { $desiredDescs.Add([string]$d.descriptor) | Out-Null } } $missing = @($Desired | Where-Object { $_.descriptor -and -not $Live.ContainsKey([string]$_.descriptor) }) $extras = @() if (-not $AnyUnresolved) { $extras = @($Live.Keys | Where-Object { (-not $ExtraFilterPattern -or $_ -match $ExtraFilterPattern) -and -not $desiredDescs.Contains($_) }) } return @{ Missing = $missing; Extras = $extras } } |