Private/OrganizationsStore.ps1
|
# Copyright (c) 2026 Jeffrey Snover. All rights reserved. # Licensed under the MIT License. See LICENSE file in the project root. # Organizations loader + module-scope cache (t/1224). # Dot-sourced by AITriad.psm1 — do NOT export. $script:OrganizationsCache = $null $script:OrganizationsCacheTimestamp = $null $script:OrganizationsCachePath = $null function Get-OrganizationsFilePath { [CmdletBinding()] [OutputType([string])] param() $tax = Get-TaxonomyDir return Join-Path $tax 'organizations.json' } function Get-OrganizationsStore { <# .SYNOPSIS Loads (or returns cached) organizations.json content. .DESCRIPTION Reads ../ai-triad-data/taxonomy/Origin/organizations.json, caches the parsed structure in $script:OrganizationsCache, and invalidates on file mtime change. Returns the raw parsed object with fields: _schema_version, _doc, org_count, last_modified, organizations[]. .PARAMETER Force Bypass the cache and re-read from disk. .PARAMETER Path Override the source file path. Defaults to Get-OrganizationsFilePath. Used by Test-OrganizationIntegrity so callers can validate a snapshot or fixture file without touching the live registry. #> [CmdletBinding()] param( [switch]$Force, [string]$Path ) Set-StrictMode -Version Latest if ($Path) { $path = $Path } else { $path = Get-OrganizationsFilePath } if (-not (Test-Path $path)) { throw (New-ActionableError ` -Goal 'Load organizations registry' ` -Problem "organizations.json not found at $path" ` -Location 'Get-OrganizationsStore' ` -NextSteps @( 'Verify the data repo is present at ../ai-triad-data/', 'Check .aitriad.json or $env:AI_TRIAD_DATA_ROOT for override', 'Confirm taxonomy/Origin/organizations.json exists in the data repo' )) } $mtime = (Get-Item $path).LastWriteTimeUtc if (-not $Force -and $script:OrganizationsCache -and $script:OrganizationsCacheTimestamp -eq $mtime -and $script:OrganizationsCachePath -eq $path) { return $script:OrganizationsCache } try { $raw = Get-Content -Raw -Path $path -Encoding utf8 $parsed = $raw | ConvertFrom-Json } catch { throw (New-ActionableError ` -Goal 'Parse organizations registry' ` -Problem "Failed to parse organizations.json: $($_.Exception.Message)" ` -Location 'Get-OrganizationsStore' ` -NextSteps @( 'Validate JSON with: Get-Content organizations.json | ConvertFrom-Json', 'Check for trailing commas or unbalanced brackets', 'Restore from git history if the file was recently modified' )) } $script:OrganizationsCache = $parsed $script:OrganizationsCacheTimestamp = $mtime $script:OrganizationsCachePath = $path return $parsed } function ConvertTo-OrganizationObject { <# .SYNOPSIS Converts a raw PSObject from organizations.json into a typed [Organization]. .DESCRIPTION Guards all optional-field access with PSObject.Properties['x'] per the project's strict-mode + ConvertFrom-Json convention. Handles missing pov_alignment sub-fields gracefully. #> [CmdletBinding()] [OutputType([Organization])] param( [Parameter(Mandatory)] [object]$Raw ) Set-StrictMode -Version Latest $o = [Organization]::new() $o.Id = if ($Raw.PSObject.Properties['id']) { [string]$Raw.id } else { '' } $o.Name = if ($Raw.PSObject.Properties['name']) { [string]$Raw.name } else { '' } $o.ShortName = if ($Raw.PSObject.Properties['short_name']) { [string]$Raw.short_name } else { '' } $o.Type = if ($Raw.PSObject.Properties['type']) { [string]$Raw.type } else { '' } $o.Description = if ($Raw.PSObject.Properties['description']) { [string]$Raw.description } else { '' } $o.Url = if ($Raw.PSObject.Properties['url']) { [string]$Raw.url } else { '' } $o.Headquarters = if ($Raw.PSObject.Properties['headquarters']) { [string]$Raw.headquarters } else { '' } $o.Founded = if ($Raw.PSObject.Properties['founded']) { [int]$Raw.founded } else { 0 } $o.Status = if ($Raw.PSObject.Properties['status']) { [string]$Raw.status } else { 'active' } $o.CreatedAt = if ($Raw.PSObject.Properties['created_at']) { [string]$Raw.created_at } else { '' } $o.LastModified = if ($Raw.PSObject.Properties['last_modified']) { [string]$Raw.last_modified } else { '' } $o.Tags = if ($Raw.PSObject.Properties['tags']) { @($Raw.tags | ForEach-Object { [string]$_ }) } else { @() } $o.SourceRefs = if ($Raw.PSObject.Properties['source_refs']) { @($Raw.source_refs | ForEach-Object { [string]$_ }) } else { @() } # POV alignment: hashtable of pov → OrganizationPovAlignment $povAlign = @{} if ($Raw.PSObject.Properties['pov_alignment']) { foreach ($povProp in $Raw.pov_alignment.PSObject.Properties) { $entry = [OrganizationPovAlignment]::new() $val = $povProp.Value # t/1583: post-migration entries carry .tier (enum) not .score # (numeric). Map through ConvertFrom-PovTier to a representative # midpoint so the typed [OrganizationPovAlignment].Score contract # (numeric) stays populated for consumers like # Compare-OrganizationPositions and Find-OrganizationByPOV. $entry.Score = if ($val.PSObject.Properties['score']) { [double]$val.score } elseif ($val.PSObject.Properties['tier']) { ConvertFrom-PovTier -Tier ([string]$val.tier) } else { 0.0 } $entry.Rationale = if ($val.PSObject.Properties['rationale']) { [string]$val.rationale } else { '' } $povAlign[$povProp.Name] = $entry } } $o.PovAlignment = $povAlign # Topic engagement $topics = @() if ($Raw.PSObject.Properties['topic_engagement']) { foreach ($t in @($Raw.topic_engagement)) { $te = [OrganizationTopicEngagement]::new() $te.TopicRef = if ($t.PSObject.Properties['topic_ref']) { [string]$t.topic_ref } else { '' } $te.Stance = if ($t.PSObject.Properties['stance']) { [string]$t.stance } else { '' } $te.Description = if ($t.PSObject.Properties['description']) { [string]$t.description } else { '' } $topics += $te } } $o.TopicEngagement = $topics # Policy engagement (t/1224 TL addition) $policies = @() if ($Raw.PSObject.Properties['policy_engagement']) { foreach ($p in @($Raw.policy_engagement)) { $pe = [OrganizationPolicyEngagement]::new() $pe.PolicyRef = if ($p.PSObject.Properties['policy_ref']) { [string]$p.policy_ref } else { '' } $pe.Stance = if ($p.PSObject.Properties['stance']) { [string]$p.stance } else { '' } $policies += $pe } } $o.PolicyEngagement = $policies # Key figures $figures = @() if ($Raw.PSObject.Properties['key_figures']) { foreach ($f in @($Raw.key_figures)) { $kf = [OrganizationKeyFigure]::new() $kf.Name = if ($f.PSObject.Properties['name']) { [string]$f.name } else { '' } $kf.Role = if ($f.PSObject.Properties['role']) { [string]$f.role } else { '' } $kf.Relevance = if ($f.PSObject.Properties['relevance']) { [string]$f.relevance } else { '' } $figures += $kf } } $o.KeyFigures = $figures # External links $links = @() if ($Raw.PSObject.Properties['external_links']) { foreach ($l in @($Raw.external_links)) { $el = [OrganizationExternalLink]::new() $el.Type = if ($l.PSObject.Properties['type']) { [string]$l.type } else { '' } $el.Url = if ($l.PSObject.Properties['url']) { [string]$l.url } else { '' } $el.Title = if ($l.PSObject.Properties['title']) { [string]$l.title } else { '' } $links += $el } } $o.ExternalLinks = $links # t/1560 — POV alignment derived (optional). Absent → stays $null on the # typed object (meaningful: "never computed"). Present → populated with # per-camp counts + provenance. if ($Raw.PSObject.Properties['pov_alignment_derived'] -and $null -ne $Raw.pov_alignment_derived) { $rawDer = $Raw.pov_alignment_derived $der = [OrganizationPovAlignmentDerived]::new() foreach ($camp in 'acc','saf','skp') { if ($rawDer.PSObject.Properties[$camp] -and $null -ne $rawDer.$camp) { $rc = $rawDer.$camp $perCamp = [OrganizationPovAlignmentDerivedPerCamp]::new() $perCamp.Advocates = if ($rc.PSObject.Properties['advocates']) { [int]$rc.advocates } else { 0 } $perCamp.Opposes = if ($rc.PSObject.Properties['opposes']) { [int]$rc.opposes } else { 0 } $perCamp.N = if ($rc.PSObject.Properties['n']) { [int]$rc.n } else { 0 } # NetRatio is [Nullable[double]] — preserve null as null, do # not collapse to 0.0 (three-state semantics, TL t/1560#4). if ($rc.PSObject.Properties['net_ratio'] -and $null -ne $rc.net_ratio) { $perCamp.NetRatio = [double]$rc.net_ratio } $der.$($camp.Substring(0,1).ToUpper() + $camp.Substring(1)) = $perCamp } } if ($rawDer.PSObject.Properties['provenance'] -and $null -ne $rawDer.provenance) { $rp = $rawDer.provenance $prov = [OrganizationPovAlignmentDerivedProvenance]::new() $prov.ComputedAt = if ($rp.PSObject.Properties['computed_at']) { [string]$rp.computed_at } else { '' } $prov.CmdletVersion = if ($rp.PSObject.Properties['cmdlet_version']) { [string]$rp.cmdlet_version } else { '' } $prov.InputEdgesSha = if ($rp.PSObject.Properties['input_edges_sha']) { [string]$rp.input_edges_sha } else { '' } $prov.IncludedStatusFilter = if ($rp.PSObject.Properties['included_status_filter']) { @($rp.included_status_filter | ForEach-Object { [string]$_ }) } else { @() } $prov.EdgeCount = if ($rp.PSObject.Properties['edge_count']) { [int]$rp.edge_count } else { 0 } $der.Provenance = $prov } $o.PovAlignmentDerived = $der } return $o } function Clear-OrganizationsCache { <# .SYNOPSIS Invalidates the organizations cache. Called by Import-Organization after writes. #> [CmdletBinding()] param() $script:OrganizationsCache = $null $script:OrganizationsCacheTimestamp = $null $script:OrganizationsCachePath = $null } |