Private/Test-OrganizationEdgeIntegrity.ps1
|
# Copyright (c) 2026 Jeffrey Snover. All rights reserved. # Licensed under the MIT License. See LICENSE file in the project root. # Organization actor-edge integrity validator (t/1526). # Dot-sourced by AITriad.psm1 — do NOT export (called from Import-OrganizationEdge # and can be run manually via InModuleScope). function Test-OrganizationEdgeIntegrity { <# .SYNOPSIS Validates organization_edges.json content against schema rules. .DESCRIPTION Returns an OrganizationEdgeIntegrityReport. Checks: - Required fields (source, target, type) - source matches ^org-\d{3,}$ - type validates via Resolve-OrganizationEdgeType (drop-on-unknown → error) - target format matches the type's expected family: * ALLIED_WITH / COMPETES_WITH / FUNDS → org-* * SUPPORTS_POLICY / OPPOSES_POLICY → pol-* * ADVOCATES_FOR / OPPOSES → sit-* or POV BDI node (acc-/saf-/skp-*) * ENGAGED_WITH → sit-* * PUBLISHED → src-* - status enum (approved | proposed | disputed | rejected) if present - discovered_at ISO-8601 YYYY-MM-DD if present - Composite dedup on (source, target, type) - No self-loops (source == target) - With -ResolveRefs, cross-checks org-* / sit-* / pol-* targets against the corresponding registries (missing registry → warning, not error) Pass = true iff zero Errors (warnings are non-blocking). .PARAMETER Path Optional explicit path. Defaults to Get-OrganizationEdgesFilePath. .PARAMETER ResolveRefs Also validate that every org-/sit-/pol- target resolves to an existing record. .OUTPUTS [OrganizationEdgeIntegrityReport] .EXAMPLE Test-OrganizationEdgeIntegrity .EXAMPLE Test-OrganizationEdgeIntegrity -ResolveRefs #> [CmdletBinding()] [OutputType([OrganizationEdgeIntegrityReport])] param( [string]$Path, [switch]$ResolveRefs ) Set-StrictMode -Version Latest if (-not $Path) { $Path = Get-OrganizationEdgesFilePath } $store = Get-OrganizationEdgesStore -Force -Path $Path $validStatuses = @('approved','proposed','disputed','rejected') $orgIdRegex = '^org-\d{3,}$' $sitIdRegex = '^sit-\d+$' $polIdRegex = '^pol-\d+$' # Source/publication targets are slug-style doc_ids in this dataset (e.g., 'anthropic-rsp-2023'), # not src-* prefixed — see taxonomy/Origin/source_evidence_index.json values. Accept any # lowercase slug that is NOT already claimed by another id family (org-/sit-/pol-/BDI shortcut # via the family switch below), and forbid the reserved prefixes to prevent misrouting. $srcIdRegex = '^(?!org-|sit-|pol-)[a-z0-9][a-z0-9._-]{2,}$' $bdiIdRegex = '^(acc|saf|skp)-(beliefs|desires|intentions)-\d+$' $dateRegex = '^\d{4}-\d{2}-\d{2}$' $orgTypes = @('ALLIED_WITH','COMPETES_WITH','FUNDS') $policyTypes = @('SUPPORTS_POLICY','OPPOSES_POLICY') $sitTypes = @('ENGAGED_WITH') $stanceTypes = @('ADVOCATES_FOR','OPPOSES') # sit-* OR BDI node $srcTypes = @('PUBLISHED') $issues = [System.Collections.Generic.List[OrganizationEdgeIntegrityIssue]]::new() $seenKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $edges = @() if ($store.PSObject.Properties['edges']) { $edges = @($store.edges) } # Reference resolution setup — best-effort; missing registries downgrade to warning. $validOrgIds = $null $validSitIds = $null $validPolIds = $null if ($ResolveRefs) { try { $tax = Get-TaxonomyDir $orgPath = Join-Path $tax 'organizations.json' $sitPath = Join-Path $tax 'situations.json' $polPath = Join-Path $tax 'policy_actions.json' if (Test-Path $orgPath) { $orgData = Get-Content -Raw -Path $orgPath -Encoding utf8 | ConvertFrom-Json $validOrgIds = [System.Collections.Generic.HashSet[string]]::new() foreach ($o in @($orgData.organizations)) { if ($o.PSObject.Properties['id']) { [void]$validOrgIds.Add([string]$o.id) } } } 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']) { [void]$validSitIds.Add([string]$n.id) } } } 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']) { [void]$validPolIds.Add([string]$p.id) } } } } catch { $iss = [OrganizationEdgeIntegrityIssue]::new() $iss.EdgeIndex = -1; $iss.Severity = 'warning'; $iss.Field = 'ResolveRefs' $iss.Message = "Failed to load reference registries: $($_.Exception.Message)" $issues.Add($iss) } } for ($idx = 0; $idx -lt @($edges).Count; $idx++) { $edge = $edges[$idx] $source = if ($edge.PSObject.Properties['source']) { [string]$edge.source } else { '' } $target = if ($edge.PSObject.Properties['target']) { [string]$edge.target } else { '' } $type = if ($edge.PSObject.Properties['type']) { [string]$edge.type } else { '' } # source shape if ([string]::IsNullOrWhiteSpace($source)) { $iss = [OrganizationEdgeIntegrityIssue]::new() $iss.EdgeIndex = $idx; $iss.Source = $source; $iss.Target = $target; $iss.Type = $type $iss.Severity = 'error'; $iss.Field = 'source' $iss.Message = 'Missing required field: source' $issues.Add($iss) } elseif ($source -notmatch $orgIdRegex) { $iss = [OrganizationEdgeIntegrityIssue]::new() $iss.EdgeIndex = $idx; $iss.Source = $source; $iss.Target = $target; $iss.Type = $type $iss.Severity = 'error'; $iss.Field = 'source' $iss.Message = "source '$source' does not match $orgIdRegex" $issues.Add($iss) } elseif ($validOrgIds -and -not $validOrgIds.Contains($source)) { $iss = [OrganizationEdgeIntegrityIssue]::new() $iss.EdgeIndex = $idx; $iss.Source = $source; $iss.Target = $target; $iss.Type = $type $iss.Severity = 'error'; $iss.Field = 'source' $iss.Message = "source '$source' does not resolve to any record in organizations.json" $issues.Add($iss) } # target shape if ([string]::IsNullOrWhiteSpace($target)) { $iss = [OrganizationEdgeIntegrityIssue]::new() $iss.EdgeIndex = $idx; $iss.Source = $source; $iss.Target = $target; $iss.Type = $type $iss.Severity = 'error'; $iss.Field = 'target' $iss.Message = 'Missing required field: target' $issues.Add($iss) } # type validation via canonical registry $typeOk = $false if ([string]::IsNullOrWhiteSpace($type)) { $iss = [OrganizationEdgeIntegrityIssue]::new() $iss.EdgeIndex = $idx; $iss.Source = $source; $iss.Target = $target; $iss.Type = $type $iss.Severity = 'error'; $iss.Field = 'type' $iss.Message = 'Missing required field: type' $issues.Add($iss) } else { $res = Resolve-OrganizationEdgeType -Type $type if ($res.Action -ne 'accept') { $iss = [OrganizationEdgeIntegrityIssue]::new() $iss.EdgeIndex = $idx; $iss.Source = $source; $iss.Target = $target; $iss.Type = $type $iss.Severity = 'error'; $iss.Field = 'type' $iss.Message = "type '$type' rejected by Resolve-OrganizationEdgeType: $($res.Reason)" $issues.Add($iss) } else { $type = $res.Type # normalize to upper-case for downstream family check $typeOk = $true } } # target family check (only if type resolved and target present) if ($typeOk -and -not [string]::IsNullOrWhiteSpace($target)) { $familyOk = $false; $expectedFamily = ''; $registryToCheck = $null if ($orgTypes -contains $type) { $expectedFamily = "org-* ($($orgTypes -join '/'))" $familyOk = $target -match $orgIdRegex $registryToCheck = 'org' } elseif ($policyTypes -contains $type) { $expectedFamily = "pol-* ($($policyTypes -join '/'))" $familyOk = $target -match $polIdRegex $registryToCheck = 'pol' } elseif ($sitTypes -contains $type) { $expectedFamily = "sit-* ($($sitTypes -join '/'))" $familyOk = $target -match $sitIdRegex $registryToCheck = 'sit' } elseif ($stanceTypes -contains $type) { $expectedFamily = "sit-* or BDI node ($($stanceTypes -join '/'))" $familyOk = ($target -match $sitIdRegex) -or ($target -match $bdiIdRegex) $registryToCheck = if ($target -match $sitIdRegex) { 'sit' } else { $null } # no BDI-node registry check } elseif ($srcTypes -contains $type) { $expectedFamily = "src-* ($($srcTypes -join '/'))" $familyOk = $target -match $srcIdRegex $registryToCheck = $null # source records aren't cheap to preload; skip } if (-not $familyOk) { $iss = [OrganizationEdgeIntegrityIssue]::new() $iss.EdgeIndex = $idx; $iss.Source = $source; $iss.Target = $target; $iss.Type = $type $iss.Severity = 'error'; $iss.Field = 'target' $iss.Message = "target '$target' does not match expected family for type $type — expected $expectedFamily" $issues.Add($iss) } elseif ($registryToCheck) { switch ($registryToCheck) { 'org' { if ($validOrgIds -and -not $validOrgIds.Contains($target)) { $iss = [OrganizationEdgeIntegrityIssue]::new() $iss.EdgeIndex = $idx; $iss.Source = $source; $iss.Target = $target; $iss.Type = $type $iss.Severity = 'error'; $iss.Field = 'target' $iss.Message = "target '$target' does not resolve to any record in organizations.json" $issues.Add($iss) } } 'sit' { if ($validSitIds -and -not $validSitIds.Contains($target)) { $iss = [OrganizationEdgeIntegrityIssue]::new() $iss.EdgeIndex = $idx; $iss.Source = $source; $iss.Target = $target; $iss.Type = $type $iss.Severity = 'error'; $iss.Field = 'target' $iss.Message = "target '$target' does not resolve to any record in situations.json" $issues.Add($iss) } } 'pol' { if ($validPolIds -and -not $validPolIds.Contains($target)) { $iss = [OrganizationEdgeIntegrityIssue]::new() $iss.EdgeIndex = $idx; $iss.Source = $source; $iss.Target = $target; $iss.Type = $type $iss.Severity = 'error'; $iss.Field = 'target' $iss.Message = "target '$target' does not resolve to any record in policy_actions.json" $issues.Add($iss) } } } } } # No self-loops if (-not [string]::IsNullOrWhiteSpace($source) -and $source -eq $target) { $iss = [OrganizationEdgeIntegrityIssue]::new() $iss.EdgeIndex = $idx; $iss.Source = $source; $iss.Target = $target; $iss.Type = $type $iss.Severity = 'error'; $iss.Field = 'source/target' $iss.Message = "self-loop: source and target are both '$source'" $issues.Add($iss) } # Composite dedup — (source, target, type) must be unique. if (-not [string]::IsNullOrWhiteSpace($source) -and -not [string]::IsNullOrWhiteSpace($target) -and -not [string]::IsNullOrWhiteSpace($type)) { $key = "$source|$target|$type" if (-not $seenKeys.Add($key)) { $iss = [OrganizationEdgeIntegrityIssue]::new() $iss.EdgeIndex = $idx; $iss.Source = $source; $iss.Target = $target; $iss.Type = $type $iss.Severity = 'error'; $iss.Field = 'source/target/type' $iss.Message = "duplicate edge for key ($key)" $issues.Add($iss) } } # Optional-field checks if ($edge.PSObject.Properties['status'] -and $validStatuses -notcontains [string]$edge.status) { $iss = [OrganizationEdgeIntegrityIssue]::new() $iss.EdgeIndex = $idx; $iss.Source = $source; $iss.Target = $target; $iss.Type = $type $iss.Severity = 'warning'; $iss.Field = 'status' $iss.Message = "status '$($edge.status)' not in enum: $($validStatuses -join ', ')" $issues.Add($iss) } if ($edge.PSObject.Properties['discovered_at']) { $da = [string]$edge.discovered_at if (-not [string]::IsNullOrWhiteSpace($da) -and $da -notmatch $dateRegex) { $iss = [OrganizationEdgeIntegrityIssue]::new() $iss.EdgeIndex = $idx; $iss.Source = $source; $iss.Target = $target; $iss.Type = $type $iss.Severity = 'warning'; $iss.Field = 'discovered_at' $iss.Message = "discovered_at '$da' does not match YYYY-MM-DD" $issues.Add($iss) } } if (-not $edge.PSObject.Properties['rationale'] -or [string]::IsNullOrWhiteSpace([string]$edge.rationale)) { $iss = [OrganizationEdgeIntegrityIssue]::new() $iss.EdgeIndex = $idx; $iss.Source = $source; $iss.Target = $target; $iss.Type = $type $iss.Severity = 'warning'; $iss.Field = 'rationale' $iss.Message = 'edge has no rationale — makes the relationship opaque' $issues.Add($iss) } } $errCount = @($issues | Where-Object { $_.Severity -eq 'error' }).Count $warnCount = @($issues | Where-Object { $_.Severity -eq 'warning' }).Count $report = [OrganizationEdgeIntegrityReport]::new() $report.Total = @($edges).Count $report.Errors = $errCount $report.Warnings = $warnCount $report.Pass = ($errCount -eq 0) $report.Issues = @($issues) return $report } |