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. .OUTPUTS [object[]] — sorted DotForge.ToolInfo array, PATH fallback applied. #> [CmdletBinding()] [OutputType([object[]])] param( [Parameter(Mandatory)] [string]$QueryText, [string[]]$Source, [switch]$Fresh, [bool]$RecordSeenQuery = $true ) $normalized = (ConvertTo-DFCatalogQueryKey -Query $QueryText).Normalized $providers = @(Get-DFCatalogProvider -Source $Source) # Fan out across catalogs (canonical order), then overlay installed state # from the cached unified snapshot (15-min TTL — avoids re-enumerating slow # sources like Get-Module -ListAvailable on every query). 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))) $installedInfo = Get-DFCatalogInstalled } 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 = Get-DFToolIdentityGuide $resolveDFTool = { param($Hit) $id = $Hit.PackageId.ToLowerInvariant() $lookup = "$($Hit.Source):$id" $name = $installedInfo.IdentityMap[$lookup] if (-not $name -and $id.Contains('/')) { # scoop ids are bucket-qualified; the packages map holds bare names $name = $installedInfo.IdentityMap["$($Hit.Source):$(($id -split '/')[-1])"] } if (-not $name) { $name = $identityGuide.IdIndex[$lookup] if (-not $name -and $id.Contains('/')) { $name = $identityGuide.IdIndex["$($Hit.Source):$(($id -split '/')[-1])"] } } $name } $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 } |