param( [string]$Root = (Get-Location).Path, [string]$IndexPath, [Parameter(Mandatory = $true)] [string]$Query, [int]$Top = 10, [switch]$Json, [switch]$Rebuild ) $ErrorActionPreference = "Stop" if ([string]::IsNullOrWhiteSpace($Query)) { throw "Query must not be empty." } if (-not (Test-Path -LiteralPath $Root)) { throw "Root path does not exist: $Root" } $rootPath = (Resolve-Path -LiteralPath $Root).Path.TrimEnd([char[]]"\/") if ([string]::IsNullOrWhiteSpace($IndexPath)) { $IndexPath = Join-Path (Join-Path $rootPath "_indexes") "requirements-index.json" } if ($Rebuild -or -not (Test-Path -LiteralPath $IndexPath)) { $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $buildScript = Join-Path $scriptDir "build-requirement-index.ps1" & $buildScript -Root $rootPath -OutputPath $IndexPath -Quiet } if (-not (Test-Path -LiteralPath $IndexPath)) { throw "Index file does not exist: $IndexPath" } function Get-QueryTerms { param([string]$Value) $normalized = $Value.ToLowerInvariant() $terms = New-Object System.Collections.Generic.List[string] foreach ($part in [regex]::Split($normalized, "[\s,;,;、]+")) { $trimmed = $part.Trim() if (-not [string]::IsNullOrWhiteSpace($trimmed)) { [void]$terms.Add($trimmed) } } return @($terms) } function Test-ContainsText { param( [string]$Haystack, [string]$Needle ) if ([string]::IsNullOrWhiteSpace($Haystack) -or [string]::IsNullOrWhiteSpace($Needle)) { return $false } return $Haystack.ToLowerInvariant().Contains($Needle) } function Get-TextValue { param($Value) if ($null -eq $Value) { return "" } if ($Value -is [array]) { return (@($Value) -join " ") } return [string]$Value } function Add-Score { param( [int]$Current, [string]$FieldValue, [string]$Term, [int]$Weight, [string]$FieldName, [System.Collections.Generic.List[string]]$MatchedFields ) if (Test-ContainsText -Haystack $FieldValue -Needle $Term) { [void]$MatchedFields.Add($FieldName) return ($Current + $Weight) } return $Current } $terms = @(Get-QueryTerms -Value $Query) if ($terms.Count -eq 0) { throw "Query must contain at least one searchable term." } $rawItems = Get-Content -Raw -Encoding UTF8 -LiteralPath $IndexPath | ConvertFrom-Json $items = @() if ($null -ne $rawItems) { if ($rawItems -is [array]) { $items = @($rawItems) } else { $items = @($rawItems) } } $results = New-Object System.Collections.Generic.List[object] foreach ($item in $items) { $score = 0 $matchedTerms = New-Object System.Collections.Generic.HashSet[string] $matchedFields = New-Object System.Collections.Generic.List[string] foreach ($term in $terms) { $termScore = 0 $termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.title) -Term $term -Weight 80 -FieldName "title" -MatchedFields $matchedFields $termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.slug) -Term $term -Weight 60 -FieldName "slug" -MatchedFields $matchedFields $termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.tags) -Term $term -Weight 55 -FieldName "tags" -MatchedFields $matchedFields $termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.domain) -Term $term -Weight 35 -FieldName "domain" -MatchedFields $matchedFields $termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.category) -Term $term -Weight 25 -FieldName "category" -MatchedFields $matchedFields $termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.feature) -Term $term -Weight 45 -FieldName "feature" -MatchedFields $matchedFields $termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.businessRules) -Term $term -Weight 40 -FieldName "businessRules" -MatchedFields $matchedFields $termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.acceptance) -Term $term -Weight 25 -FieldName "acceptance" -MatchedFields $matchedFields $termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.flow) -Term $term -Weight 18 -FieldName "flow" -MatchedFields $matchedFields $termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.dataTables) -Term $term -Weight 12 -FieldName "dataTables" -MatchedFields $matchedFields $termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.dictionaries) -Term $term -Weight 12 -FieldName "dictionaries" -MatchedFields $matchedFields $termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.sourceEvidence) -Term $term -Weight 8 -FieldName "sourceEvidence" -MatchedFields $matchedFields if ($termScore -gt 0) { [void]$matchedTerms.Add($term) $score += $termScore } } if ($matchedTerms.Count -eq $terms.Count) { $score += 30 } if ($score -gt 0) { [void]$results.Add([pscustomobject][ordered]@{ score = $score title = $item.title domain = $item.domain packageType = $item.packageType slug = $item.slug relativePath = $item.relativePath fullPath = $item.fullPath tags = @($item.tags) updated = $item.updated matchedTerms = @($matchedTerms) matchedFields = @($matchedFields | Select-Object -Unique) feature = $item.feature businessRules = $item.businessRules }) } } $resultArray = @($results.ToArray()) $ranked = @($resultArray | Sort-Object ` @{ Expression = "score"; Descending = $true }, @{ Expression = "updated"; Descending = $true }, @{ Expression = "relativePath"; Descending = $false } | Select-Object -First $Top) if ($Json) { if ($ranked.Count -eq 0) { Write-Output "[]" } elseif ($ranked.Count -eq 1) { Write-Output "[$($ranked[0] | ConvertTo-Json -Depth 8)]" } else { ConvertTo-Json -InputObject $ranked -Depth 8 } return } if ($ranked.Count -eq 0) { Write-Host "No matching requirement packages found." return } foreach ($result in $ranked) { Write-Host "[$($result.score)] $($result.title) ($($result.domain)/$($result.slug))" Write-Host " path: $($result.relativePath)" if ($result.tags.Count -gt 0) { Write-Host " tags: $(@($result.tags) -join ', ')" } Write-Host " matched: $(@($result.matchedTerms) -join ', ') in $(@($result.matchedFields) -join ', ')" if (-not [string]::IsNullOrWhiteSpace($result.feature)) { Write-Host " feature: $($result.feature)" } Write-Host "" }