Modules/DotForge.Catalog/Private/Resolve-DFCatalogQueryMerge.ps1
|
#Requires -Version 7.0 function Resolve-DFCatalogQueryMerge { <# .SYNOPSIS Core catalog search-and-merge: fans a query out across every provider, overlays installed state, groups hits into one row per tool (via the Tools/*.json identity map), sorts by match quality, and applies the PATH fallback. .DESCRIPTION Shared by Find-DFPackage's ordinary query path and its -Category/-WorksWith facet search — both need "resolve this text/name to accurate, live ToolInfo results," and this is the one place that logic lives. .PARAMETER QueryText The query text (already qualified-id-stripped, if applicable). .PARAMETER Source Restrict to these catalogs. .PARAMETER Fresh Block on live catalog fetches instead of serving cached data. .PARAMETER RecordSeenQuery Whether to record QueryText in the seen-queries LRU (the Update-DFPackageCache re-warm list). Default $true, matching Find-DFPackage's existing single-query behavior. Facet-search callers pass $false to avoid polluting the LRU with N synthetic per-tool lookups for one facet search. .PARAMETER InstalledInfo A Get-DFCatalogInstalled result to annotate hits with. Default: take a fresh snapshot. A caller merging several queries (facet search) takes one snapshot and passes it to each, since a snapshot enumerates every catalog's installed packages in parallel runspaces. .PARAMETER IdentityGuide A Get-DFToolIdentityGuide result. Default: load it (cached per session). .OUTPUTS [object[]] — sorted DotForge.ToolInfo array, PATH fallback applied. #> [CmdletBinding()] [OutputType([object[]])] param( [Parameter(Mandatory)] [string]$QueryText, [string[]]$Source, [switch]$Fresh, [bool]$RecordSeenQuery = $true, [hashtable]$InstalledInfo, [object]$IdentityGuide ) $normalized = (ConvertTo-DFCatalogQueryKey -Query $QueryText).Normalized $providers = @(Get-DFCatalogProvider -Source $Source) # Fan out across catalogs (canonical order), then overlay installed state # from a live installed snapshot (taken here unless the caller passed one). # First runs can spend a while on index builds and live fetches, so keep the # user informed via the progress stream (renders as a status line; never # pollutes stdout). $progressId = 47 try { $hits = [System.Collections.Generic.List[object]]::new() for ($i = 0; $i -lt $providers.Count; $i++) { $provider = $providers[$i] Write-Progress -Id $progressId -Activity 'trifle' ` -Status "Searching $($provider.Name) catalog… (first run may build local indexes)" ` -PercentComplete ([int](100 * $i / ($providers.Count + 1))) foreach ($hit in @(& $provider.Search $QueryText $Fresh.IsPresent)) { if ($hit) { $hits.Add($hit) } } } Write-Progress -Id $progressId -Activity 'trifle' ` -Status 'Reading installed packages…' ` -PercentComplete ([int](100 * $providers.Count / ($providers.Count + 1))) if (-not $InstalledInfo) { $InstalledInfo = Get-DFCatalogInstalled } $installedInfo = $InstalledInfo } finally { Write-Progress -Id $progressId -Activity 'trifle' -Completed } $installedBySource = @{} foreach ($item in $installedInfo.Items) { if (-not $installedBySource.ContainsKey($item.Source)) { $installedBySource[$item.Source] = @{} } foreach ($key in @($item.Name, $item.PackageId)) { if ($key) { $installedBySource[$item.Source][([string]$key).ToLowerInvariant()] = $item.InstalledVersion } } } foreach ($hit in $hits) { $map = $installedBySource[$hit.Source] if (-not $map) { continue } foreach ($key in @($hit.Name, $hit.PackageId)) { $lookup = ([string]$key).ToLowerInvariant() if ($map.ContainsKey($lookup)) { $hit.Installed = $true $hit.InstalledVersion = $map[$lookup] break } } } if ($RecordSeenQuery) { Add-DFCatalogSeenQuery -Query $QueryText } # Merge per-catalog hits into one row per tool: an identity link — either # the live Tools/*.json packages map, or the precomputed tool-identity # guide (data/tool-identities.json) — collapses hits under the DotForge # tool name even when catalogs name the package differently. On a # (source, packageId) conflict between the two, Tools/*.json wins (it's # checked first and short-circuits). Bare name-string matching across # DIFFERENT catalog sources is never used as a grouping key — two # unlinked same-named hits from different sources render as separate # rows, on purpose: a shared display name is not proof of shared # identity (see docs/superpowers/specs/2026-07-06-trifle-tool-identity-guide-design.md). $identityGuide = $IdentityGuide ?? (Get-DFToolIdentityGuide) $resolveDFTool = { param($Hit) $keys = @(Get-DFIdentityKeys -Source $Hit.Source -PackageId $Hit.PackageId) foreach ($k in $keys) { if ($installedInfo.IdentityMap[$k]) { return $installedInfo.IdentityMap[$k] } } foreach ($k in $keys) { if ($identityGuide.IdIndex[$k]) { return $identityGuide.IdIndex[$k] } } } $groups = [ordered]@{} $dfNames = @{} foreach ($hit in $hits) { $dfName = & $resolveDFTool $hit # No identity link: group by source-qualified name, never bare name # alone — two different catalogs' same-named packages must never # merge without a genuine identity link backing them. $key = $dfName ? "df:$dfName" : "$($hit.Source):$($hit.Name.ToLowerInvariant())" if (-not $groups.Contains($key)) { $groups[$key] = [System.Collections.Generic.List[object]]::new() if ($dfName) { $dfNames[$key] = $dfName } } $groups[$key].Add($hit) } $merged = @(foreach ($entry in $groups.GetEnumerator()) { $sources = @($entry.Value) $dfTool = $dfNames[$entry.Key] $latest = [ordered]@{} foreach ($s in $sources) { if ($s.LatestVersion) { $latest[$s.Source] = $s.LatestVersion } } $installedSources = @($sources | Where-Object Installed) $matchKind = if ($sources.MatchKind -contains 'exact-id') { 'exact-id' } elseif ($sources.MatchKind -contains 'exact-name') { 'exact-name' } else { 'keyword' } New-DFToolInfo -Name ($dfTool ? $dfTool : $sources[0].Name) ` -Description (@($sources.Description) -ne '' -ne $null | Select-Object -First 1) ` -Installed:($installedSources.Count -gt 0) ` -InstalledVia @($installedSources.Source) ` -InstalledVersion (@($installedSources.InstalledVersion) | Select-Object -First 1) ` -Sources $sources ` -Latest $latest ` -Homepage (@($sources.Homepage) -ne '' -ne $null | Select-Object -First 1) ` -License (@($sources.License) -ne '' -ne $null | Select-Object -First 1) ` -DFTool $dfTool ` -MatchKind $matchKind ` -CacheAge (@($sources.CacheAgeMinutes | Measure-Object -Maximum).Maximum) }) # Best matches first: exact package-id, then exact name/moniker, then # keyword hits; installed tools win ties, then alphabetical. $matchRank = @{ 'exact-id' = 0; 'exact-name' = 1; 'keyword' = 2 } $merged = @($merged | Sort-Object ` { $matchRank[$_.MatchKind] }, { -not $_.Installed }, Name) # PATH fallback: the command exists locally but no catalog claims it. if ($normalized -notmatch ' ') { $pathCommand = Get-Command -Name $normalized -ErrorAction Ignore | Select-Object -First 1 if ($pathCommand) { foreach ($info in $merged) { if (-not $info.Installed -and $info.MatchKind -in 'exact-id', 'exact-name') { $info.Installed = $true $info.InstalledVia = @('PATH') if ($pathCommand.Version) { $info.InstalledVersion = [string]$pathCommand.Version } } } } } $merged } function Find-DFCatalogFacet { <# .SYNOPSIS Resolves a category search (Find-DFPackage -Category/-WorksWith) to merged, live catalog rows. .DESCRIPTION Validates the terms against the category database's taxonomy (an unknown term is a terminating error), ORs values within a facet, ANDs -Category with -WorksWith, then resolves each matching tool through the normal search-and-merge path, so installed state and versions are live. A tool whose database ids no longer resolve in any catalog is skipped. One installed snapshot and identity guide serve the whole search. .PARAMETER Database Get-DFCategoryDb result (must have Raw). .PARAMETER Category Function-facet terms. .PARAMETER WorksWith Works-with-facet terms. .PARAMETER Source Restrict to these catalogs. .PARAMETER Fresh Block on live catalog fetches. .OUTPUTS DotForge.ToolInfo[], sorted by name. #> [CmdletBinding()] param( [Parameter(Mandatory)]$Database, [string[]]$Category, [string[]]$WorksWith, [string[]]$Source, [switch]$Fresh ) $db = $Database $validateFacet = { param($Values, $FacetName, $Vocab) $bad = @($Values | Where-Object { $_ -notin $Vocab }) if ($bad) { Write-Error "DotForge: unknown $FacetName value(s): $($bad -join ', '). Run Get-DFCategoryList -Facet $FacetName to see valid terms." -ErrorAction Stop } } if ($Category) { & $validateFacet $Category 'function' @($db.Raw.taxonomy.function) } if ($WorksWith) { & $validateFacet $WorksWith 'worksWith' @($db.Raw.taxonomy.worksWith) } $unionFacet = { param($Values, $Prefix) if (-not $Values) { return $null } $union = [System.Collections.Generic.HashSet[string]]::new() foreach ($v in $Values) { foreach ($k in @($db.FacetIndex["${Prefix}:$v"])) { $null = $union.Add($k) } } $union } $catMatch = & $unionFacet $Category 'function' $wwMatch = & $unionFacet $WorksWith 'worksWith' $matchedKeys = if ($catMatch -and $wwMatch) { @($catMatch | Where-Object { $wwMatch.Contains($_) }) } elseif ($catMatch) { @($catMatch) } else { @($wwMatch) } $canonicalOrder = Get-DFCatalogName $allowedSources = $Source ? $Source : $canonicalOrder # One installed snapshot and identity guide for the whole facet search, # not one per matched tool. $installedInfo = Get-DFCatalogInstalled $identityGuide = Get-DFToolIdentityGuide $merged = [System.Collections.Generic.List[object]]::new() foreach ($key in ($matchedKeys | Sort-Object -Unique)) { $entry = $db.Raw.tools.$key $probeQueryText = $key $probeSource = $Source if ($entry.ids) { foreach ($src in $canonicalOrder) { if ($src -notin $allowedSources) { continue } $idProp = $entry.ids.PSObject.Properties[$src] if ($idProp) { $probeQueryText = $idProp.Value; $probeSource = @($src); break } } } # A db entry whose ids no longer resolve against any live catalog # (renamed/removed upstream) is silently skipped — stale seed # data, not a search failure. $hits = @(Resolve-DFCatalogQueryMerge -QueryText $probeQueryText -Source $probeSource -Fresh:$Fresh -RecordSeenQuery $false ` -InstalledInfo $installedInfo -IdentityGuide $identityGuide) if ($hits) { $merged.Add($hits[0]) } } @($merged | Sort-Object Name) } |