tools/Test-UmlVersionDrift.ps1
|
<# .SYNOPSIS Detects drift between PlantUML diagram version pins and a module's manifest ModuleVersion. The mechanical anchor that converts a UML corpus from honor-system to verifiable. .DESCRIPTION Reverse of theater: a .puml always renders, so a rendered diagram is not evidence it is current. This check compares the version token in each diagram's title (convention: a "(v1.2.3)" pin) against the repository's module manifest ModuleVersion and reports every diagram that is stale, unpinned, or untitled. Read-only. Never throws. Returns a structured result on every path. Fails OPEN: a repo with no UML directory (or an empty one) is reported as Not-Applicable (exit 0), not as a failure — the check must never block unrelated work. A repo with a UML corpus but NO manifest (a static site, a docs corpus) is NOT skipped: it runs in "presence mode" — every diagram must still carry SOME release identifier (a "(v1.2.3)" version, a "(Stage 0)" / "(Phase 2.1)" token, or a "(@d090b30)" short git SHA), even though there is no ModuleVersion to compare against. This is the smallest change that brings no-manifest projects inside the one enforced rule. Presence mode still exits 0 in advisory mode; only -FailOnDrift + an unpinned diagram exits 1. Per-repo by design: copy this one file into a repo (e.g. its Tools/ directory) so the check travels with the repo if it is ever made public or handed off. One canonical template; many repo copies. .PARAMETER RepoPath Repository root to scan. Required. No reliance on script location. .PARAMETER FailOnDrift If set, exit code is 1 when any diagram is Drift or NoPin. Default behaviour still reports findings but exits 0 (advisory mode), so a project can adopt it before wiring it into a gate. .EXAMPLE .\Test-UmlVersionDrift.ps1 -RepoPath C:\Sysadmin\Scripts\ClusterValidator Reports each diagram's pin vs the manifest version. A fully reconciled corpus prints all InSync and exits 0. .EXAMPLE .\Test-UmlVersionDrift.ps1 -RepoPath C:\Repo -FailOnDrift Same scan, but exits 1 if any diagram is stale or unpinned — the form a CI step or pre-commit check uses to actually block drift. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string] $RepoPath, [switch] $FailOnDrift ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function New-DriftResult { <# .DESCRIPTION Internal. Builds a structured drift-check result object. Steps: 1. Pack the status, repo path, manifest path, manifest version, diagram findings, and message into a pscustomobject. #> param($Status, $Manifest, $ManifestVersion, $Diagrams, $Message) [pscustomobject]@{ Status = $Status # OK | Drift | NotApplicable | Error RepoPath = $RepoPath Manifest = $Manifest ManifestVersion = $ManifestVersion Diagrams = $Diagrams # array of per-file findings Message = $Message } } try { if (-not (Test-Path -LiteralPath $RepoPath)) { $r = New-DriftResult 'NotApplicable' $null $null @() ('Repo path not found: {0}' -f $RepoPath) $r; exit 0 } $repo = (Resolve-Path -LiteralPath $RepoPath).Path # --- Locate the module manifest (the project-root marker). --- # Candidate locations, no deep recursion (vendored manifests under # sub-trees are not the project's manifest): root-level *.psd1, then # the suite's standard nested layout repo\<leaf>\<leaf>.psd1. The # chosen manifest is the first candidate that PARSES and actually # carries a non-empty ModuleVersion — a config .psd1 # (PSScriptAnalyzerSettings, build config) is correctly skipped. $repoLeaf = Split-Path -Leaf $repo $cands = New-Object System.Collections.Generic.List[string] foreach ($g in @( (Get-ChildItem -LiteralPath $repo -Filter *.psd1 -File -ErrorAction SilentlyContinue), (Get-ChildItem -LiteralPath (Join-Path $repo $repoLeaf) -Filter *.psd1 -File -ErrorAction SilentlyContinue) )) { foreach ($fi in @($g)) { if ($fi) { $cands.Add($fi.FullName) } } } # Prefer a manifest whose base name matches the repo folder. $ordered = $cands | Sort-Object @{ Expression = { [System.IO.Path]::GetFileNameWithoutExtension($_) -ne $repoLeaf } } $manifestPath = $null $manifestVersion = $null foreach ($cp in $ordered) { # Import-PowerShellDataFile parses data only — it does NOT execute # the manifest as code (no Invoke-Expression / dot-source). A # manifest is untrusted-shaped input from this check's view. try { $data = Import-PowerShellDataFile -LiteralPath $cp -ErrorAction Stop } catch { continue } if ($data -is [hashtable] -and $data.ContainsKey('ModuleVersion')) { $v = [string]$data['ModuleVersion'] if (-not [string]::IsNullOrWhiteSpace($v)) { $manifestPath = $cp; $manifestVersion = $v; break } } } # No manifest → do NOT bail. Fall back to "pinned at all" presence # mode: a no-manifest project (a static site, a docs corpus) is still # inside the one enforced rule — every diagram must carry SOME release # identifier, even though there is no ModuleVersion to compare it to. $presenceMode = -not $manifestPath # --- Locate the UML directory (convention + known aliases). --- $umlDir = $null foreach ($cand in @('Docs\UML', 'docs\UML', 'architecture')) { $p = Join-Path $repo $cand if (Test-Path -LiteralPath $p) { $umlDir = (Resolve-Path -LiteralPath $p).Path; break } } if (-not $umlDir) { $r = New-DriftResult 'NotApplicable' $manifestPath $manifestVersion @() 'No UML directory (Docs/UML, docs/UML, architecture) — N/A.' $r; exit 0 } $puml = @(Get-ChildItem -LiteralPath $umlDir -Filter *.puml -File -Recurse -ErrorAction SilentlyContinue) if ($puml.Count -eq 0) { $r = New-DriftResult 'NotApplicable' $manifestPath $manifestVersion @() 'UML directory contains no .puml files — N/A.' $r; exit 0 } # Manifest mode: a "(v1.2.3)" / "(v1.2.3.4)" pin compared against the # manifest ModuleVersion. Presence mode (no manifest): accept any # release identifier — a version, a "(Stage 0)" / "(Phase 2.1)" # token, or a "(@d090b30)" short git SHA — and only check it is # present (there is no manifest version to compare against). $pinRegex = '\(v(\d+\.\d+\.\d+(?:\.\d+)?)\)' $releaseRegex = '\((v\d+\.\d+\.\d+(?:\.\d+)?|(?:Stage|Phase)\s+\S+|@?[0-9a-f]{7,40})\)' $rx = if ($presenceMode) { $releaseRegex } else { $pinRegex } $findings = foreach ($f in $puml) { $text = Get-Content -LiteralPath $f.FullName -Raw -ErrorAction SilentlyContinue $rel = $f.FullName.Substring($repo.Length).TrimStart('\','/') $m = if ($text) { [regex]::Match($text, $rx) } else { $null } if (-not $m -or -not $m.Success) { $status = 'NoPin'; $pinned = $null } elseif ($presenceMode) { # No manifest to compare against — presence is the whole test. $pinned = $m.Groups[1].Value $status = 'Pinned' } else { $pinned = $m.Groups[1].Value $status = if ($pinned -eq $manifestVersion) { 'InSync' } else { 'Drift' } } [pscustomobject]@{ Diagram = $rel PinnedVersion = $pinned ManifestVersion = $manifestVersion Status = $status } } $bad = @($findings | Where-Object { $_.Status -in @('Drift','NoPin') }) if ($bad.Count -eq 0) { $okMsg = if ($presenceMode) { 'No manifest — version comparison N/A; all {0} diagram(s) carry a release-identifier pin (presence mode).' -f $findings.Count } else { 'All {0} diagram(s) pinned to {1}.' -f $findings.Count, $manifestVersion } $r = New-DriftResult 'OK' $manifestPath $manifestVersion $findings $okMsg $r; exit 0 } $msg = if ($presenceMode) { '{0} of {1} diagram(s) carry no release-identifier pin (presence mode; no manifest to compare against).' -f ` $bad.Count, $findings.Count } else { '{0} of {1} diagram(s) out of sync with manifest {2}: {3} drift, {4} unpinned.' -f ` $bad.Count, $findings.Count, $manifestVersion, @($bad | Where-Object Status -eq 'Drift').Count, @($bad | Where-Object Status -eq 'NoPin').Count } $r = New-DriftResult 'Drift' $manifestPath $manifestVersion $findings $msg $r if ($FailOnDrift) { exit 1 } else { exit 0 } } catch { # Fail open on unexpected error: report, do not block. $r = New-DriftResult 'Error' $null $null @() ('Unexpected error (failing open): {0}' -f $_.Exception.Message) $r; exit 0 } |