Private/Test-OrganizationIntegrity.ps1
|
# Copyright (c) 2026 Jeffrey Snover. All rights reserved. # Licensed under the MIT License. See LICENSE file in the project root. # Organization integrity validator (t/1224). # Dot-sourced by AITriad.psm1 — do NOT export (called from Import-Organization # and can be run manually via InModuleScope). function Test-OrganizationIntegrity { <# .SYNOPSIS Validates organizations.json content against schema rules. .DESCRIPTION Returns an OrganizationIntegrityReport with per-issue detail. Checks: - Required fields (id, name, type) - Id format: matches ^org-\d{3}$ - Id uniqueness across records - POV alignment score range: [-1.0, 1.0] - Type enum membership - Stance enum membership (topic_engagement + policy_engagement) - Ref format for topic_ref (sit-*), policy_ref (pol-*) - With -ResolveRefs, cross-checks refs against situations.json and policy_actions.json (falls back to warning if those files aren't loaded) Pass = true iff there are zero Errors (warnings are non-blocking). .PARAMETER Path Optional explicit path to organizations.json. Defaults to Get-OrganizationsFilePath. .PARAMETER ResolveRefs Also validate that every topic_ref/policy_ref resolves to an existing sit-*/pol-* record in the taxonomy data. .OUTPUTS [OrganizationIntegrityReport] .EXAMPLE Test-OrganizationIntegrity .EXAMPLE Test-OrganizationIntegrity -ResolveRefs #> [CmdletBinding()] [OutputType([OrganizationIntegrityReport])] param( [string]$Path, [switch]$ResolveRefs ) Set-StrictMode -Version Latest if (-not $Path) { $Path = Get-OrganizationsFilePath } $store = Get-OrganizationsStore -Force -Path $Path $validTypes = @('think_tank','advocacy','regulatory','academic','corporate','intergovernmental','civil_society','standards_body','research_lab') $validPovs = @('accelerationist','safetyist','skeptic') $validTopicStances = @('advocate','opponent','researcher','neutral') $validPolicyStances = @('supports','opposes') $validStatuses = @('active','dissolved','merged') $idRegex = '^org-\d{3,}$' $issues = [System.Collections.Generic.List[OrganizationIntegrityIssue]]::new() $seenIds = [System.Collections.Generic.HashSet[string]]::new() $orgs = @() if ($store.PSObject.Properties['organizations']) { $orgs = @($store.organizations) } # Reference resolution setup $validSitIds = $null $validPolIds = $null if ($ResolveRefs) { try { $tax = Get-TaxonomyDir $sitPath = Join-Path $tax 'situations.json' $polPath = Join-Path $tax 'policy_actions.json' if (Test-Path $sitPath) { $sitData = Get-Content -Raw -Path $sitPath -Encoding utf8 | ConvertFrom-Json $validSitIds = [System.Collections.Generic.HashSet[string]]::new() foreach ($n in @($sitData.nodes)) { if ($n.PSObject.Properties['id']) { $validSitIds.Add([string]$n.id) | Out-Null } } } if (Test-Path $polPath) { $polData = Get-Content -Raw -Path $polPath -Encoding utf8 | ConvertFrom-Json $validPolIds = [System.Collections.Generic.HashSet[string]]::new() foreach ($p in @($polData.policies)) { if ($p.PSObject.Properties['id']) { $validPolIds.Add([string]$p.id) | Out-Null } } } } catch { $iss = [OrganizationIntegrityIssue]::new() $iss.OrgId = '' $iss.Severity = 'warning' $iss.Field = 'ResolveRefs' $iss.Message = "Failed to load reference registries: $($_.Exception.Message)" $issues.Add($iss) } } foreach ($rawOrg in $orgs) { $id = if ($rawOrg.PSObject.Properties['id']) { [string]$rawOrg.id } else { '' } # Required: id if ([string]::IsNullOrWhiteSpace($id)) { $iss = [OrganizationIntegrityIssue]::new() $iss.OrgId = '<missing>' $iss.Severity = 'error' $iss.Field = 'id' $iss.Message = 'Organization record is missing required field: id' $issues.Add($iss) continue } if ($id -notmatch $idRegex) { $iss = [OrganizationIntegrityIssue]::new() $iss.OrgId = $id; $iss.Severity = 'error'; $iss.Field = 'id' $iss.Message = "Id '$id' does not match format org-NNN (regex: $idRegex)" $issues.Add($iss) } if (-not $seenIds.Add($id)) { $iss = [OrganizationIntegrityIssue]::new() $iss.OrgId = $id; $iss.Severity = 'error'; $iss.Field = 'id' $iss.Message = "Duplicate organization id: '$id'" $issues.Add($iss) } # Required: name, type if (-not $rawOrg.PSObject.Properties['name'] -or [string]::IsNullOrWhiteSpace([string]$rawOrg.name)) { $iss = [OrganizationIntegrityIssue]::new() $iss.OrgId = $id; $iss.Severity = 'error'; $iss.Field = 'name' $iss.Message = 'Missing required field: name' $issues.Add($iss) } if (-not $rawOrg.PSObject.Properties['type']) { $iss = [OrganizationIntegrityIssue]::new() $iss.OrgId = $id; $iss.Severity = 'error'; $iss.Field = 'type' $iss.Message = 'Missing required field: type' $issues.Add($iss) } elseif ($validTypes -notcontains [string]$rawOrg.type) { $iss = [OrganizationIntegrityIssue]::new() $iss.OrgId = $id; $iss.Severity = 'error'; $iss.Field = 'type' $iss.Message = "Type '$($rawOrg.type)' is not in enum: $($validTypes -join ', ')" $issues.Add($iss) } # Status enum (optional field) if ($rawOrg.PSObject.Properties['status'] -and $validStatuses -notcontains [string]$rawOrg.status) { $iss = [OrganizationIntegrityIssue]::new() $iss.OrgId = $id; $iss.Severity = 'warning'; $iss.Field = 'status' $iss.Message = "Status '$($rawOrg.status)' is not in enum: $($validStatuses -join ', ')" $issues.Add($iss) } # POV alignment: score range + rationale presence if ($rawOrg.PSObject.Properties['pov_alignment']) { foreach ($povProp in $rawOrg.pov_alignment.PSObject.Properties) { if ($validPovs -notcontains $povProp.Name) { $iss = [OrganizationIntegrityIssue]::new() $iss.OrgId = $id; $iss.Severity = 'warning'; $iss.Field = "pov_alignment.$($povProp.Name)" $iss.Message = "Unknown POV '$($povProp.Name)' (expected: $($validPovs -join ', '))" $issues.Add($iss) } $val = $povProp.Value # Skip non-camp keys (e.g. legacy pov_alignment-root assessed_at). # The unknown-POV warning above already flags them; don't cascade # a bogus "no score / no rationale" complaint on top. if ($validPovs -notcontains $povProp.Name) { continue } # t/1583: legacy .score numeric bounds check retained for any # pre-migration records; post-migration entries carry .tier # (enum) — validate against the tier vocabulary instead. if ($val.PSObject.Properties['score']) { $s = [double]$val.score if ($s -lt -1.0 -or $s -gt 1.0) { $iss = [OrganizationIntegrityIssue]::new() $iss.OrgId = $id; $iss.Severity = 'error'; $iss.Field = "pov_alignment.$($povProp.Name).score" $iss.Message = "Score $s is outside allowed range [-1.0, 1.0]" $issues.Add($iss) } } if ($val.PSObject.Properties['tier']) { $t = [string]$val.tier $validTiers = @('opposes','leans_against','mixed_or_silent','leans_toward','champions') if ($validTiers -notcontains $t) { $iss = [OrganizationIntegrityIssue]::new() $iss.OrgId = $id; $iss.Severity = 'error'; $iss.Field = "pov_alignment.$($povProp.Name).tier" $iss.Message = "Tier '$t' is not in enum: $($validTiers -join ', ')" $issues.Add($iss) } } if (-not $val.PSObject.Properties['rationale'] -or [string]::IsNullOrWhiteSpace([string]$val.rationale)) { $iss = [OrganizationIntegrityIssue]::new() $iss.OrgId = $id; $iss.Severity = 'warning'; $iss.Field = "pov_alignment.$($povProp.Name).rationale" $iss.Message = "Tier/score present without rationale — makes the assignment opaque" $issues.Add($iss) } } } # Topic engagement if ($rawOrg.PSObject.Properties['topic_engagement']) { foreach ($t in @($rawOrg.topic_engagement)) { $tref = if ($t.PSObject.Properties['topic_ref']) { [string]$t.topic_ref } else { '' } if ($tref -notmatch '^sit-\d+$') { $iss = [OrganizationIntegrityIssue]::new() $iss.OrgId = $id; $iss.Severity = 'error'; $iss.Field = 'topic_engagement.topic_ref' $iss.Message = "topic_ref '$tref' does not match sit-NNN" $issues.Add($iss) } elseif ($validSitIds -and -not $validSitIds.Contains($tref)) { $iss = [OrganizationIntegrityIssue]::new() $iss.OrgId = $id; $iss.Severity = 'error'; $iss.Field = 'topic_engagement.topic_ref' $iss.Message = "topic_ref '$tref' does not resolve to any record in situations.json" $issues.Add($iss) } if ($t.PSObject.Properties['stance'] -and $validTopicStances -notcontains [string]$t.stance) { $iss = [OrganizationIntegrityIssue]::new() $iss.OrgId = $id; $iss.Severity = 'warning'; $iss.Field = 'topic_engagement.stance' $iss.Message = "Stance '$($t.stance)' not in enum: $($validTopicStances -join ', ')" $issues.Add($iss) } } } # Policy engagement (t/1224 addition) if ($rawOrg.PSObject.Properties['policy_engagement']) { foreach ($p in @($rawOrg.policy_engagement)) { $pref = if ($p.PSObject.Properties['policy_ref']) { [string]$p.policy_ref } else { '' } if ($pref -notmatch '^pol-\d+$') { $iss = [OrganizationIntegrityIssue]::new() $iss.OrgId = $id; $iss.Severity = 'error'; $iss.Field = 'policy_engagement.policy_ref' $iss.Message = "policy_ref '$pref' does not match pol-NNN" $issues.Add($iss) } elseif ($validPolIds -and -not $validPolIds.Contains($pref)) { $iss = [OrganizationIntegrityIssue]::new() $iss.OrgId = $id; $iss.Severity = 'error'; $iss.Field = 'policy_engagement.policy_ref' $iss.Message = "policy_ref '$pref' does not resolve to any record in policy_actions.json" $issues.Add($iss) } if ($p.PSObject.Properties['stance'] -and $validPolicyStances -notcontains [string]$p.stance) { $iss = [OrganizationIntegrityIssue]::new() $iss.OrgId = $id; $iss.Severity = 'error'; $iss.Field = 'policy_engagement.stance' $iss.Message = "Policy stance '$($p.stance)' not in enum: $($validPolicyStances -join ', ')" $issues.Add($iss) } } } } $errCount = @($issues | Where-Object { $_.Severity -eq 'error' }).Count $warnCount = @($issues | Where-Object { $_.Severity -eq 'warning' }).Count $report = [OrganizationIntegrityReport]::new() $report.Total = $orgs.Count $report.Errors = $errCount $report.Warnings = $warnCount $report.Pass = ($errCount -eq 0) $report.Issues = @($issues) return $report } |