src/functions/discovery-cache.ps1
|
#requires -version 7.0 $script:discoveryCacheMemory = @{} $script:discoveryCacheVersion = 1 function Get-ConfigMapUserHome { if ($env:HOME) { return [System.IO.Path]::GetFullPath($env:HOME) } return [System.IO.Path]::GetFullPath([Environment]::GetFolderPath('UserProfile')) } function ConvertTo-ConfigMapDiscoveryCacheProjectName { param( [Parameter(Mandatory = $true)] [string]$SourceFile ) $dir = [System.IO.Path]::GetFullPath((Split-Path -Parent $SourceFile)).TrimEnd('\', '/') $dir = $dir -replace ':', '' return ($dir -replace '[\\/]', '-') } function Get-ConfigMapDiscoveryCacheFileName { param( [Parameter(Mandatory = $true)] [string]$SourceFile, [ValidateSet('build', 'conf')] [string]$Language ) $leaf = Split-Path -Leaf $SourceFile $name = if ($leaf -match '^\.(.+)\.map\.ps1$') { $Matches[1] } else { [System.IO.Path]::GetFileNameWithoutExtension($leaf) } $isCanonical = ($Language -eq 'build' -and $name -eq 'build') -or ($Language -eq 'conf' -and $name -eq 'configuration') if ($isCanonical) { return "discovery.$name.cache.json" } return "discovery.$name.$Language.cache.json" } function Get-ConfigMapDiscoveryCacheSettingOverrides { param([System.Collections.IDictionary]$Map) if (!$Map -or $Map._settings -isnot [System.Collections.IDictionary]) { return $null } $overrides = @{} foreach ($name in @('DiscoveryCache', 'DiscoveryCacheLocation')) { if ($Map._settings.Contains($name)) { $overrides[$name] = $Map._settings[$name] } } if ($overrides.Count -eq 0) { return $null } return $overrides } function Resolve-ConfigMapDiscoveryCacheLocation { param($Value) if ($null -eq $Value -or "$Value" -eq '') { $Value = Get-ConfigMapSetting -Name DiscoveryCacheLocation } $normalized = "$Value".Trim().ToLowerInvariant() if ($normalized -notin @('local', 'user')) { throw "DiscoveryCacheLocation must be 'local' or 'user'." } return $normalized } function Get-ConfigMapDiscoveryCachePath { param( [Parameter(Mandatory = $true)] [string]$SourceFile, [ValidateSet('build', 'conf')] [string]$Language, [System.Collections.IDictionary]$Map, [string]$Location ) $fullPath = [System.IO.Path]::GetFullPath($SourceFile) if (-not $Location) { $previous = Enter-ConfigMapSettingsScope -Settings (Get-ConfigMapDiscoveryCacheSettingOverrides -Map $Map) try { $Location = Resolve-ConfigMapDiscoveryCacheLocation } finally { Exit-ConfigMapSettingsScope -PreviousSettings $previous } } else { $Location = Resolve-ConfigMapDiscoveryCacheLocation -Value $Location } $fileName = Get-ConfigMapDiscoveryCacheFileName -SourceFile $fullPath -Language $Language $cacheDir = if ($Location -eq 'user') { Join-Path (Join-Path (Get-ConfigMapUserHome) '.configmap') (ConvertTo-ConfigMapDiscoveryCacheProjectName -SourceFile $fullPath) } else { Join-Path (Split-Path -Parent $fullPath) '.configmap' } return Join-Path $cacheDir $fileName } function Get-ConfigMapDiscoveryCacheCandidate { param( [Parameter(Mandatory = $true)] [string]$SourceFile, [ValidateSet('build', 'conf')] [string]$Language, [ValidateSet('local', 'user')] [string]$Location ) $fullPath = [System.IO.Path]::GetFullPath($SourceFile) return [pscustomobject]@{ Location = $Location Path = Get-ConfigMapDiscoveryCachePath -SourceFile $fullPath -Language $Language -Location $Location MemoryKey = Get-ConfigMapDiscoveryMemoryKey -SourceFile $fullPath -Language $Language -Location $Location } } function Get-ConfigMapDiscoveryCacheCandidates { param( [Parameter(Mandatory = $true)] [string]$SourceFile, [ValidateSet('build', 'conf')] [string]$Language ) return @( Get-ConfigMapDiscoveryCacheCandidate -SourceFile $SourceFile -Language $Language -Location local Get-ConfigMapDiscoveryCacheCandidate -SourceFile $SourceFile -Language $Language -Location user ) } function Get-ConfigMapDiscoveryCacheTarget { param( [Parameter(Mandatory = $true)] [string]$SourceFile, [ValidateSet('build', 'conf')] [string]$Language, [System.Collections.IDictionary]$Map ) $previous = Enter-ConfigMapSettingsScope -Settings (Get-ConfigMapDiscoveryCacheSettingOverrides -Map $Map) try { $location = Resolve-ConfigMapDiscoveryCacheLocation return Get-ConfigMapDiscoveryCacheCandidate -SourceFile $SourceFile -Language $Language -Location $location } finally { Exit-ConfigMapSettingsScope -PreviousSettings $previous } } function Find-ConfigMapDiscoveryCache { <# .SYNOPSIS Finds a valid discovery cache by probing local and user files. .DESCRIPTION Does not read map settings. Validity uses only the cache payload and dependency file mtimes stored in that payload. #> param( [Parameter(Mandatory = $true)] [string]$SourceFile, [ValidateSet('build', 'conf')] [string]$Language, [switch]$WriteMemory ) $sourceFile = [System.IO.Path]::GetFullPath($SourceFile) $candidates = Get-ConfigMapDiscoveryCacheCandidates -SourceFile $sourceFile -Language $Language foreach ($candidate in $candidates) { $memoryCache = $script:discoveryCacheMemory[$candidate.MemoryKey] if (Test-ConfigMapDiscoveryCacheValid -Cache $memoryCache -SourceFile $sourceFile -Language $Language) { return [pscustomobject]@{ Cache = $memoryCache Candidate = $candidate Source = 'memory' } } } foreach ($candidate in $candidates) { $diskCache = Read-ConfigMapDiscoveryCache -CachePath $candidate.Path if (Test-ConfigMapDiscoveryCacheValid -Cache $diskCache -SourceFile $sourceFile -Language $Language) { if ($WriteMemory) { $script:discoveryCacheMemory[$candidate.MemoryKey] = $diskCache } return [pscustomobject]@{ Cache = $diskCache Candidate = $candidate Source = 'disk' } } } return $null } function Get-ConfigMapDiscoveryMemoryKey { param( [string]$SourceFile, [string]$Language, [string]$Location = 'local' ) return "$([System.IO.Path]::GetFullPath($SourceFile))|$Language|$Location" } function Add-ConfigMapDependsOnDependencies { param( [System.Collections.IDictionary]$Node, [hashtable]$OperationContext, [System.Collections.IDictionary]$Map = $null ) if (!$Node -or !$OperationContext) { return } if (-not $Node.Contains('_dependsOn')) { return } $raw = $Node['_dependsOn'] if ($null -eq $raw) { return } $paths = if ($raw -is [System.Collections.IEnumerable] -and $raw -isnot [string]) { @($raw) } else { @($raw) } $baseDir = $null if ($Node._baseDir) { $baseDir = [string]$Node._baseDir } elseif ($Map -and $Map._baseDir) { $baseDir = [string]$Map._baseDir } elseif ($Map -and $Map._sourceFile) { $baseDir = Split-Path -Parent $Map._sourceFile } elseif ($Node._sourceFile) { $baseDir = Split-Path -Parent $Node._sourceFile } foreach ($item in $paths) { if ($item -isnot [string] -or [string]::IsNullOrWhiteSpace($item)) { continue } $path = $item if ([System.IO.Path]::IsPathRooted($path)) { $fullPath = [System.IO.Path]::GetFullPath($path) } else { $resolved = if ($baseDir) { Join-Path $baseDir $path } else { $path } $fullPath = [System.IO.Path]::GetFullPath($resolved) } if (Test-Path -LiteralPath $fullPath -PathType Leaf) { $OperationContext.Dependencies[$fullPath] = (Get-Item -LiteralPath $fullPath).LastWriteTimeUtc.Ticks } else { $OperationContext.Dependencies[$fullPath] = $null } } } function ConvertTo-ConfigMapDiscoveryDependencyList { param([hashtable]$Dependencies) $list = @() foreach ($path in ($Dependencies.Keys | Sort-Object)) { $list += @{ path = $path ticks = $Dependencies[$path] } } return $list } function Get-EntryParameterMeta { <# .SYNOPSIS Extracts static parameter metadata from a scriptblock AST without resolving @sibling ValidateSet. #> param([scriptblock]$Func) $result = @() if (!$Func -or !$Func.Ast.ParamBlock -or !$Func.Ast.ParamBlock.Parameters) { return $result } $exclude = @('$_context', '$_self') foreach ($ast in $Func.Ast.ParamBlock.Parameters) { $name = $ast.Name.ToString().Trim('`$') if ("`$$name" -in $exclude -or $name -in @('_context', '_self')) { continue } $paramType = $ast.StaticType $isSwitch = $false $validateSet = $null $hasValidateSet = $false $staticValues = @() foreach ($attr in $ast.Attributes) { if ($attr -is [System.Management.Automation.Language.TypeConstraintAst]) { if ($attr.TypeName.ToString() -eq 'switch') { $paramType = [switch] $isSwitch = $true } } elseif ($attr -is [System.Management.Automation.Language.AttributeAst]) { $typeName = $attr.TypeName.ToString() -replace '^.*\.', '' if ($typeName -eq 'ValidateSet') { $hasValidateSet = $true foreach ($arg in $attr.PositionalArguments) { if ($arg -is [System.Management.Automation.Language.StringConstantExpressionAst]) { $staticValues += $arg.Value } } } } } if ($paramType -eq [switch]) { $isSwitch = $true } if ($hasValidateSet) { if ($staticValues.Count -eq 1 -and $staticValues[0] -match '^@') { $validateSet = $null } elseif ($staticValues.Count -gt 0) { $validateSet = @($staticValues) } } $typeName = if ($isSwitch) { 'switch' } elseif ($paramType -eq [string]) { 'string' } else { $paramType.Name } $result += @{ name = $name type = $typeName isSwitch = [bool]$isSwitch validateSet = $validateSet } } return $result } function New-ConfigMapDiscoveryEntryDescriptor { param( [string]$Key, $Entry, [ValidateSet('build', 'conf')] [string]$Language ) $reservedKeys = (Get-MapLanguage $Language).reservedKeys $isParent = Test-IsParentEntry $Entry -ReservedKeys $reservedKeys $description = '' if ($Entry -is [System.Collections.IDictionary] -and $Entry.description) { $description = [string]$Entry.description } $parameters = @() if (-not $isParent -and -not (Test-BuildAllEntry $Entry)) { try { $command = Get-EntryCommand $Entry 'exec' if ($command -is [scriptblock]) { $parameters = @(Get-EntryParameterMeta $command) } } catch { $parameters = @() } } return @{ key = $Key isParent = [bool]$isParent description = $description parameters = $parameters } } function ConvertTo-ConfigMapDiscoveryEntryDescriptors { param( [System.Collections.IDictionary]$EntryList, [ValidateSet('build', 'conf')] [string]$Language ) $descriptors = @() foreach ($kvp in $EntryList.GetEnumerator()) { $descriptors += New-ConfigMapDiscoveryEntryDescriptor -Key $kvp.Key -Entry $kvp.Value -Language $Language } return $descriptors } function Test-ConfigMapDiscoveryCacheValid { param( $Cache, [string]$SourceFile, [ValidateSet('build', 'conf')] [string]$Language ) if (!$Cache) { return $false } if ($Cache.version -ne $script:discoveryCacheVersion) { return $false } $rootMap = [System.IO.Path]::GetFullPath($SourceFile) if ("$($Cache.rootMap)" -ne $rootMap) { return $false } if ("$($Cache.language)" -ne $Language) { return $false } if (!$Cache.dependencies) { return $false } foreach ($dep in @($Cache.dependencies)) { $path = [string]$dep.path if ([string]::IsNullOrEmpty($path)) { return $false } $exists = Test-Path -LiteralPath $path -PathType Leaf $storedTicks = $dep.ticks if ($null -eq $storedTicks) { if ($exists) { return $false } continue } if (-not $exists) { return $false } $currentTicks = (Get-Item -LiteralPath $path).LastWriteTimeUtc.Ticks if ([int64]$currentTicks -ne [int64]$storedTicks) { return $false } } return $true } function Read-ConfigMapDiscoveryCache { param([string]$CachePath) if (!(Test-Path -LiteralPath $CachePath -PathType Leaf)) { return $null } try { return Get-Content -LiteralPath $CachePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop } catch { return $null } } function Write-ConfigMapDiscoveryCache { param( [string]$CachePath, $Cache ) try { $dir = Split-Path -Parent $CachePath if (!(Test-Path -LiteralPath $dir -PathType Container)) { New-Item -ItemType Directory -Path $dir -Force -ErrorAction Stop | Out-Null } $json = $Cache | ConvertTo-Json -Depth 8 Set-Content -LiteralPath $CachePath -Value $json -Encoding utf8 -ErrorAction Stop } catch { } } function Build-ConfigMapDiscoveryCache { param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Map, [ValidateSet('build', 'conf')] [string]$Language ) $sourceFile = [System.IO.Path]::GetFullPath($Map._sourceFile) $operationContext = New-ConfigMapOperationContext $operationContext.Dependencies[$sourceFile] = (Get-Item -LiteralPath $sourceFile).LastWriteTimeUtc.Ticks $pair = Get-MapEntryListPair -map $Map -language $Language -OperationContext $operationContext return @{ version = $script:discoveryCacheVersion rootMap = $sourceFile language = $Language dependencies = @(ConvertTo-ConfigMapDiscoveryDependencyList $operationContext.Dependencies) entries = @{ hierarchical = @(ConvertTo-ConfigMapDiscoveryEntryDescriptors $pair.hierarchical $Language) flatten = @(ConvertTo-ConfigMapDiscoveryEntryDescriptors $pair.flatten $Language) } } } function Test-ConfigMapDiscoveryCacheEnabled { param([System.Collections.IDictionary]$Map) $previous = Enter-ConfigMapSettingsScope -Settings (Get-ConfigMapDiscoveryCacheSettingOverrides -Map $Map) try { return Test-ConfigMapFeatureEnabled -Name DiscoveryCache } finally { Exit-ConfigMapSettingsScope -PreviousSettings $previous } } function Get-ConfigMapDiscoveryCache { <# .SYNOPSIS Returns a fresh or rebuilt discovery cache for a file-backed map. .DESCRIPTION Skips disk cache when the map has no _sourceFile (in-memory maps). Probes local and user cache files without reading map settings. Disabled when DiscoveryCache is opted out via _settings or QCONF_DiscoveryCache. #> param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Map, [ValidateSet('build', 'conf')] [string]$Language ) if (!$Map._sourceFile) { return $null } if (-not (Test-ConfigMapDiscoveryCacheEnabled -Map $Map)) { return $null } $sourceFile = [System.IO.Path]::GetFullPath($Map._sourceFile) $found = Find-ConfigMapDiscoveryCache -SourceFile $sourceFile -Language $Language -WriteMemory if ($found) { return $found.Cache } $target = Get-ConfigMapDiscoveryCacheTarget -SourceFile $sourceFile -Language $Language -Map $Map $built = Build-ConfigMapDiscoveryCache -Map $Map -Language $Language Write-ConfigMapDiscoveryCache -CachePath $target.Path -Cache $built $script:discoveryCacheMemory[$target.MemoryKey] = $built return $built } function Clear-ConfigMapDiscoveryCacheMemory { param([string]$MemoryKey) if ($MemoryKey) { $script:discoveryCacheMemory.Remove($MemoryKey) | Out-Null return } $script:discoveryCacheMemory = @{} } function New-ConfigMapDiscoveryCacheInfo { param( [string]$Action, [bool]$Enabled, [string]$Path, [bool]$Exists, [bool]$Valid, [string]$Source, [string]$RootMap, [string]$Language, [string]$Location, $Cache ) $entryCount = 0 $version = $null $dependencies = @() if ($Cache) { $version = $Cache.version if ($Cache.entries -and $null -ne $Cache.entries.hierarchical) { $entryCount = @($Cache.entries.hierarchical).Count } if ($Cache.dependencies) { $dependencies = @($Cache.dependencies) } if (-not $RootMap -and $Cache.rootMap) { $RootMap = [string]$Cache.rootMap } } return [pscustomobject]@{ Action = $Action Enabled = [bool]$Enabled Location = $Location Path = $Path Exists = [bool]$Exists Valid = [bool]$Valid Source = $Source Version = $version RootMap = $RootMap Language = $Language EntryCount = $entryCount Dependencies = $dependencies } } function Get-ConfigMapDiscoveryCacheInfo { <# .SYNOPSIS Inspects discovery cache state for a map without rebuilding it. #> param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Map, [ValidateSet('build', 'conf')] [string]$Language, [string]$Action = 'status' ) $enabled = Test-ConfigMapDiscoveryCacheEnabled -Map $Map if (!$Map._sourceFile) { $previous = Enter-ConfigMapSettingsScope -Settings (Get-ConfigMapDiscoveryCacheSettingOverrides -Map $Map) try { $location = Resolve-ConfigMapDiscoveryCacheLocation } finally { Exit-ConfigMapSettingsScope -PreviousSettings $previous } return New-ConfigMapDiscoveryCacheInfo -Action $Action -Enabled $enabled -Location $location -Path $null -Exists $false -Valid $false -Source 'none' -RootMap $null -Language $Language } $sourceFile = [System.IO.Path]::GetFullPath($Map._sourceFile) $found = Find-ConfigMapDiscoveryCache -SourceFile $sourceFile -Language $Language if ($found) { $exists = Test-Path -LiteralPath $found.Candidate.Path -PathType Leaf return New-ConfigMapDiscoveryCacheInfo -Action $Action -Enabled $enabled -Location $found.Candidate.Location -Path $found.Candidate.Path -Exists $exists -Valid $true -Source $found.Source -RootMap $sourceFile -Language $Language -Cache $found.Cache } foreach ($candidate in (Get-ConfigMapDiscoveryCacheCandidates -SourceFile $sourceFile -Language $Language)) { $stale = Read-ConfigMapDiscoveryCache -CachePath $candidate.Path if ($stale) { $exists = Test-Path -LiteralPath $candidate.Path -PathType Leaf return New-ConfigMapDiscoveryCacheInfo -Action $Action -Enabled $enabled -Location $candidate.Location -Path $candidate.Path -Exists $exists -Valid $false -Source 'none' -RootMap $sourceFile -Language $Language -Cache $stale } } $target = Get-ConfigMapDiscoveryCacheTarget -SourceFile $sourceFile -Language $Language -Map $Map return New-ConfigMapDiscoveryCacheInfo -Action $Action -Enabled $enabled -Location $target.Location -Path $target.Path -Exists $false -Valid $false -Source 'none' -RootMap $sourceFile -Language $Language } function Clear-ConfigMapDiscoveryCache { <# .SYNOPSIS Deletes the discovery cache file and memory entry for a map. #> param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Map, [ValidateSet('build', 'conf')] [string]$Language ) if ($Map._sourceFile) { $sourceFile = [System.IO.Path]::GetFullPath($Map._sourceFile) foreach ($candidate in (Get-ConfigMapDiscoveryCacheCandidates -SourceFile $sourceFile -Language $Language)) { Clear-ConfigMapDiscoveryCacheMemory -MemoryKey $candidate.MemoryKey if (Test-Path -LiteralPath $candidate.Path -PathType Leaf) { Remove-Item -LiteralPath $candidate.Path -Force } } } return Get-ConfigMapDiscoveryCacheInfo -Map $Map -Language $Language -Action 'clear' } function Rebuild-ConfigMapDiscoveryCache { <# .SYNOPSIS Clears and rebuilds the discovery cache for a file-backed map. #> param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Map, [ValidateSet('build', 'conf')] [string]$Language ) if (-not (Test-ConfigMapDiscoveryCacheEnabled -Map $Map)) { throw "Discovery cache is disabled. Enable it with _settings.DiscoveryCache or unset QCONF_DiscoveryCache." } if (!$Map._sourceFile) { throw "Cannot rebuild discovery cache for an in-memory map." } $null = Clear-ConfigMapDiscoveryCache -Map $Map -Language $Language $sourceFile = [System.IO.Path]::GetFullPath($Map._sourceFile) $target = Get-ConfigMapDiscoveryCacheTarget -SourceFile $sourceFile -Language $Language -Map $Map $built = Build-ConfigMapDiscoveryCache -Map $Map -Language $Language Write-ConfigMapDiscoveryCache -CachePath $target.Path -Cache $built $script:discoveryCacheMemory[$target.MemoryKey] = $built return Get-ConfigMapDiscoveryCacheInfo -Map $Map -Language $Language -Action 'rebuild' } function Invoke-QBuildCacheCommand { <# .SYNOPSIS Runs a qbuild !cache action against a map. #> param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Map, [ValidateSet('build', 'conf')] [string]$Language, [string]$Action = 'status' ) switch ($Action) { 'status' { return Get-ConfigMapDiscoveryCacheInfo -Map $Map -Language $Language -Action 'status' } 'clear' { return Clear-ConfigMapDiscoveryCache -Map $Map -Language $Language } 'rebuild' { return Rebuild-ConfigMapDiscoveryCache -Map $Map -Language $Language } default { throw "!cache accepts status, clear, or rebuild." } } } |