Private/Resolve-GraphMapping.ps1
|
function Resolve-GraphMapping { <# .SYNOPSIS Core lookup engine behind Get-GraphMapping. .DESCRIPTION Resolves one or more GraphShell catalog entry points (Cmdlet, Endpoint, Permission, Module, Query) into enriched GraphShell.Mapping objects. -Cmdlet, -Endpoint, -Permission and -Module only load the small, sharded lite indexes generated by scripts/build-graphshell-lite-indexes.ps1 (a few hundred KB to ~2 MB, instead of the full ~11 MB / ~220 MB in-memory catalog). Only -Query, and the rare -Endpoint partial-match fallback, genuinely need a broad scan and load the full index (Get-GraphCatalogIndex). #> [CmdletBinding()] [OutputType([pscustomobject])] param( [string]$Cmdlet, [string]$Endpoint, [string]$Permission, [string]$Module, [string]$Query, [switch]$IncludeDetails, [int]$MaxQueryResults = 50 ) $renameIndex = Get-GraphRenameIndex $results = [System.Collections.Generic.List[object]]::new() if ($Cmdlet) { $results.AddRange([object[]](Resolve-GraphCmdletMapping -Cmdlet $Cmdlet -RenameIndex $renameIndex -IncludeDetails:$IncludeDetails)) } if ($Endpoint) { $results.AddRange([object[]](Resolve-GraphEndpointMapping -Endpoint $Endpoint -RenameIndex $renameIndex -IncludeDetails:$IncludeDetails)) } if ($Permission) { $results.AddRange([object[]](Resolve-GraphPermissionMapping -Permission $Permission -RenameIndex $renameIndex -IncludeDetails:$IncludeDetails)) } if ($Module) { $results.AddRange([object[]](Resolve-GraphModuleMapping -Module $Module -RenameIndex $renameIndex -IncludeDetails:$IncludeDetails)) } if ($Query) { $results.AddRange([object[]](Resolve-GraphQueryMapping -Query $Query -RenameIndex $renameIndex -IncludeDetails:$IncludeDetails -MaxQueryResults $MaxQueryResults)) } return $results } function Resolve-GraphCmdletMapping { [CmdletBinding()] [OutputType([object[]])] param( [Parameter(Mandatory)][string]$Cmdlet, [object]$RenameIndex, [switch]$IncludeDetails ) $results = [System.Collections.Generic.List[object]]::new() $canonicalNames = [System.Collections.Generic.List[string]]::new() $canonicalNames.Add($Cmdlet) $aliasIndex = Get-GraphAliasIndex $aliasTargets = $null if ($aliasIndex.ByAlias.TryGetValue($Cmdlet, [ref]$aliasTargets)) { foreach ($target in $aliasTargets) { if (-not $canonicalNames.Contains($target)) { $canonicalNames.Add($target) } } } foreach ($name in $canonicalNames) { $shardKey = Get-GraphResourceShardKey -Command $name $shard = Get-GraphCmdletShard -Shard $shardKey if (-not $shard) { continue } $records = $null if ($shard.ByCommand.TryGetValue($name, [ref]$records)) { foreach ($record in $records) { $results.Add((New-GraphMappingObject -Record $record -MatchedBy 'Cmdlet' -RenameIndex $RenameIndex -IncludeDetails:$IncludeDetails)) } } } $renameRecord = $null if ($RenameIndex.ByOldCmdlet.TryGetValue($Cmdlet, [ref]$renameRecord)) { $newShardKey = Get-GraphResourceShardKey -Command $renameRecord.newCmdlet $newShard = Get-GraphCmdletShard -Shard $newShardKey $renamedRecords = $null if ($newShard -and $newShard.ByCommand.TryGetValue($renameRecord.newCmdlet, [ref]$renamedRecords)) { foreach ($record in $renamedRecords) { $obj = New-GraphMappingObject -Record $record -MatchedBy 'Cmdlet (renamed)' -RenameIndex $RenameIndex -IncludeDetails:$IncludeDetails $obj.RenamedFrom = $Cmdlet $results.Add($obj) } } } return , $results.ToArray() } function Resolve-GraphEndpointMapping { [CmdletBinding()] [OutputType([object[]])] param( [Parameter(Mandatory)][string]$Endpoint, [object]$RenameIndex, [switch]$IncludeDetails ) $results = [System.Collections.Generic.List[object]]::new() $key = $Endpoint.Trim('/').ToLowerInvariant() $segment = ($key -split '/')[0] if (-not $segment) { $segment = '_root' } $segment = $segment -replace '[\\/:*?"<>|]', '_' $shard = Get-GraphEndpointShard -Segment $segment $direct = $null if ($shard -and $shard.ByUri.TryGetValue($key, [ref]$direct)) { foreach ($record in $direct) { $results.Add((New-GraphMappingObject -Record $record -MatchedBy 'Endpoint' -RenameIndex $RenameIndex -IncludeDetails:$IncludeDetails)) } return , $results.ToArray() } # Partial match fallback: scan shard-by-shard (loading and discarding one small file at a # time) instead of loading the full catalog, since this broader search is the exception, # not the common case, for -Endpoint. foreach ($otherSegment in (Get-GraphAllEndpointSegments)) { $otherShard = Get-GraphEndpointShard -Segment $otherSegment if (-not $otherShard) { continue } foreach ($entry in $otherShard.ByUri.GetEnumerator()) { if ($entry.Key.Contains($key)) { foreach ($record in $entry.Value) { $results.Add((New-GraphMappingObject -Record $record -MatchedBy 'Endpoint (partial)' -RenameIndex $RenameIndex -IncludeDetails:$IncludeDetails)) } } } } return , $results.ToArray() } function Resolve-GraphPermissionMapping { [CmdletBinding()] [OutputType([object[]])] param( [Parameter(Mandatory)][string]$Permission, [object]$RenameIndex, [switch]$IncludeDetails ) $results = [System.Collections.Generic.List[object]]::new() $permissionIndex = Get-GraphPermissionLiteIndex $records = $null if ($permissionIndex.ByName.TryGetValue($Permission, [ref]$records)) { foreach ($record in $records) { $obj = New-GraphMappingObject -Record $record -MatchedBy 'Permission' -RenameIndex $RenameIndex -IncludeDetails:$IncludeDetails $obj.MatchedPermission = $Permission $results.Add($obj) } } return , $results.ToArray() } function Resolve-GraphModuleMapping { [CmdletBinding()] [OutputType([object[]])] param( [Parameter(Mandatory)][string]$Module, [object]$RenameIndex, [switch]$IncludeDetails ) $results = [System.Collections.Generic.List[object]]::new() $moduleNameIndex = Get-GraphModuleNameIndex $moduleMatches = $moduleNameIndex.Modules | Where-Object { $_.module -like "*$Module*" -or $_.moduleName -like "*$Module*" } foreach ($match in $moduleMatches) { $shard = Get-GraphModuleShard -FileName $match.fileName if (-not $shard) { continue } foreach ($record in $shard.records) { $results.Add((New-GraphMappingObject -Record $record -MatchedBy 'Module' -RenameIndex $RenameIndex -IncludeDetails:$IncludeDetails)) } } return , $results.ToArray() } function Resolve-GraphQueryMapping { [CmdletBinding()] [OutputType([object[]])] param( [Parameter(Mandatory)][string]$Query, [object]$RenameIndex, [switch]$IncludeDetails, [int]$MaxQueryResults = 50 ) $results = [System.Collections.Generic.List[object]]::new() # Field weights: an exact command-name hit is worth much more than a module-name hit, so a # handful of generic module matches cannot outrank a precise command/endpoint match. $weightCommand = 12.0 $weightEndpoint = 8.0 $weightAliasVariant = 6.0 $weightPermission = 7.0 $weightModule = 3.0 $synonymMultiplier = 0.6 $exactPhraseBonus = 20.0 $catalog = Get-GraphCatalogIndex $synonymIndex = Get-GraphSynonymIndex $queryWords = ConvertTo-GraphSearchWords -Text $Query if ($queryWords.Count -eq 0) { return , $results.ToArray() } $normalizedQuery = ($queryWords -join ' ') # Group tokens into "concepts": one per distinct word the user actually typed, each carrying # its own weight-1.0 primary word plus weight-0.6 synonym-expanded variants. Scoring later # takes the *best* match within a single concept (so a word and its own synonyms never stack) # but *sums* across different concepts (so a two-word query like "group membership" correctly # scores higher when a record matches both distinct ideas, not just whichever one is best). # A synonym variant that happens to equal another word the user already typed is dropped from # this concept: that word already has its own dedicated concept, so keeping it here would let # the same field match count twice under two different concepts. $queryWordSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($word in $queryWords) { if ($word.Length -ge 3) { [void]$queryWordSet.Add($word) } } $concepts = [System.Collections.Generic.List[object]]::new() foreach ($word in $queryWords) { if ($word.Length -lt 3) { continue } $conceptTokens = [System.Collections.Generic.Dictionary[string, double]]::new([System.StringComparer]::OrdinalIgnoreCase) $conceptTokens[$word] = 1.0 $synonyms = $null if ($synonymIndex.ByTerm.TryGetValue($word, [ref]$synonyms)) { foreach ($synonym in $synonyms) { foreach ($synonymWord in (ConvertTo-GraphSearchWords -Text $synonym)) { if ($conceptTokens.ContainsKey($synonymWord)) { continue } if ($queryWordSet.Contains($synonymWord) -and $synonymWord -ne $word) { continue } $conceptTokens[$synonymWord] = $synonymMultiplier } } } $concepts.Add($conceptTokens) } if ($concepts.Count -eq 0) { return , $results.ToArray() } # Build (once per session) an inverted word -> record-ids index per field, since only -Query # needs it. Scoring a query then only touches records that actually contain a matching word # instead of re-scanning all ~31k catalog records for every call, which is what made the # previous per-record/per-token/per-word triple loop scale so poorly. if (-not $script:GraphQueryInvertedIndex) { $comparer = [System.StringComparer]::OrdinalIgnoreCase $invCommand = [System.Collections.Generic.Dictionary[string, System.Collections.Generic.List[string]]]::new($comparer) $invEndpoint = [System.Collections.Generic.Dictionary[string, System.Collections.Generic.List[string]]]::new($comparer) $invAliasVariant = [System.Collections.Generic.Dictionary[string, System.Collections.Generic.List[string]]]::new($comparer) $invModule = [System.Collections.Generic.Dictionary[string, System.Collections.Generic.List[string]]]::new($comparer) # Inlined rather than routed through a scriptblock helper: invoking a scriptblock (`&`) # once per record/field (~125k times across the whole catalog) has enough per-call # overhead on its own to noticeably slow down this one-time index build. foreach ($record in $catalog.Records) { $id = $record.id foreach ($word in (ConvertTo-GraphSearchWords -Text $record.command)) { $list = $null if (-not $invCommand.TryGetValue($word, [ref]$list)) { $list = [System.Collections.Generic.List[string]]::new(); $invCommand[$word] = $list } $list.Add($id) } foreach ($word in (ConvertTo-GraphSearchWords -Text $record.uri)) { $list = $null if (-not $invEndpoint.TryGetValue($word, [ref]$list)) { $list = [System.Collections.Generic.List[string]]::new(); $invEndpoint[$word] = $list } $list.Add($id) } foreach ($word in (ConvertTo-GraphSearchWords -Text (($record.aliases + $record.variants) -join ' '))) { $list = $null if (-not $invAliasVariant.TryGetValue($word, [ref]$list)) { $list = [System.Collections.Generic.List[string]]::new(); $invAliasVariant[$word] = $list } $list.Add($id) } foreach ($word in (ConvertTo-GraphSearchWords -Text "$($record.module) $($record.moduleName)")) { $list = $null if (-not $invModule.TryGetValue($word, [ref]$list)) { $list = [System.Collections.Generic.List[string]]::new(); $invModule[$word] = $list } $list.Add($id) } } $script:GraphQueryInvertedIndex = [pscustomobject]@{ Command = $invCommand Endpoint = $invEndpoint AliasVariant = $invAliasVariant Module = $invModule } } $inverted = $script:GraphQueryInvertedIndex # best[id] = double[4] holding, per field (Command, Endpoint, AliasVariant, Module), the SUM # across concepts of each concept's own best token contribution for that field. Summing # across distinct concepts is what makes multi-word queries like "group membership" score a # record that matches both ideas higher than one that only matches a single idea; keeping # only the best *within* a concept is what stops a word and its own synonyms from stacking. $fieldNames = @('Command', 'Endpoint', 'AliasVariant', 'Module') $fieldWeights = @($weightCommand, $weightEndpoint, $weightAliasVariant, $weightModule) $best = [System.Collections.Generic.Dictionary[string, double[]]]::new([System.StringComparer]::OrdinalIgnoreCase) # IDF-style dampening: a word that appears in a large fraction of the catalog's records for a # given field (e.g. "identity", "management", "role") carries almost no discriminative value # and, left unweighted, would both dominate ranking (drowning out precise command/endpoint # matches under a flood of low-relevance module hits) and cost a lot of time updating tens of # thousands of candidate scores for a near-zero contribution. Standard log-scaled IDF # (normalized to the catalog size) fixes both, without hardcoding any specific word. $catalogSize = [double]$catalog.Records.Count $logCatalogSize = [Math]::Log10($catalogSize) $idfNegligibleThreshold = 0.3 foreach ($concept in $concepts) { $conceptBest = [System.Collections.Generic.Dictionary[string, double[]]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($tokenEntry in $concept.GetEnumerator()) { $token = $tokenEntry.Key $tokenWeight = $tokenEntry.Value # (candidate word, match-weight) pairs: exact match, plus simple plural/singular variants. $candidates = [System.Collections.Generic.List[object]]::new() $candidates.Add(@{ Word = $token; MatchWeight = 1.0 }) if ($token.Length -ge 3) { $candidates.Add(@{ Word = ($token + 's'); MatchWeight = 0.7 }) if ($token.EndsWith('s') -and $token.Length -gt 3) { $candidates.Add(@{ Word = $token.Substring(0, $token.Length - 1); MatchWeight = 0.7 }) } } for ($fieldIndex = 0; $fieldIndex -lt $fieldNames.Count; $fieldIndex++) { $fieldIndex_index = $fieldIndex $fieldIndexDict = $inverted.($fieldNames[$fieldIndex_index]) $fieldWeight = $fieldWeights[$fieldIndex_index] foreach ($candidate in $candidates) { $ids = $null if (-not $fieldIndexDict.TryGetValue($candidate.Word, [ref]$ids)) { continue } $documentFrequency = [double]$ids.Count if ($documentFrequency -le 0) { continue } $idfNorm = [Math]::Log10($catalogSize / $documentFrequency) / $logCatalogSize if ($idfNorm -lt $idfNegligibleThreshold) { continue } $contribution = $fieldWeight * $tokenWeight * $candidate.MatchWeight * $idfNorm foreach ($id in $ids) { $scoreArray = $null if (-not $conceptBest.TryGetValue($id, [ref]$scoreArray)) { $scoreArray = [double[]]::new(4) $conceptBest[$id] = $scoreArray } if ($contribution -gt $scoreArray[$fieldIndex_index]) { $scoreArray[$fieldIndex_index] = $contribution } } } } } foreach ($entry in $conceptBest.GetEnumerator()) { $scoreArray = $null if (-not $best.TryGetValue($entry.Key, [ref]$scoreArray)) { $scoreArray = [double[]]::new(4) $best[$entry.Key] = $scoreArray } for ($i = 0; $i -lt 4; $i++) { $scoreArray[$i] += $entry.Value[$i] } } } $scores = [System.Collections.Generic.Dictionary[string, double]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($entry in $best.GetEnumerator()) { $total = 0.0 foreach ($value in $entry.Value) { $total += $value } if ($total -gt 0) { $scores[$entry.Key] = $total } } # Exact-phrase bonus only needs to check records that already matched at least one word, not # the entire catalog. if ($normalizedQuery.Length -ge 5) { foreach ($id in @($scores.Keys)) { $record = $catalog.ById[$id] if ($record.command.ToLowerInvariant().Contains($normalizedQuery) -or $record.uri.ToLowerInvariant().Contains($normalizedQuery)) { $scores[$id] += $exactPhraseBonus } } } $permissionIndex = Get-GraphPermissionLiteIndex # Build (once per session) a word -> permission-name inverted index, mirroring the field # index above, so scoring only touches permissions that actually contain a matching word # instead of re-tokenizing and re-scanning all permission names on every query. if (-not $script:GraphPermissionWordIndex) { $permWordIndex = [System.Collections.Generic.Dictionary[string, System.Collections.Generic.List[string]]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($permissionName in $permissionIndex.Names) { foreach ($word in (ConvertTo-GraphSearchWords -Text $permissionName)) { $list = $null if (-not $permWordIndex.TryGetValue($word, [ref]$list)) { $list = [System.Collections.Generic.List[string]]::new() $permWordIndex[$word] = $list } $list.Add($permissionName) } } $script:GraphPermissionWordIndex = $permWordIndex } $permissionWordIndex = $script:GraphPermissionWordIndex # Same concept-scoped best-then-sum approach as the field scan: within one concept, keep only # the single best permission-based contribution per record (so a resource area with many # near-duplicate permission scopes -- e.g. 10+ Teams app permission variants such as # TeamsAppInstallation.ReadForChat/.ReadForTeam/.ReadForUser/... -- cannot stack); sum across # distinct concepts so a record matching two different query ideas via permissions still # scores higher than one matching only one. $permissionBest = [System.Collections.Generic.Dictionary[string, double]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($concept in $concepts) { $conceptPermissionBest = [System.Collections.Generic.Dictionary[string, double]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($tokenEntry in $concept.GetEnumerator()) { $token = $tokenEntry.Key $tokenWeight = $tokenEntry.Value $candidates = [System.Collections.Generic.List[object]]::new() $candidates.Add(@{ Word = $token; MatchWeight = 1.0 }) if ($token.Length -ge 3) { $candidates.Add(@{ Word = ($token + 's'); MatchWeight = 0.7 }) if ($token.EndsWith('s') -and $token.Length -gt 3) { $candidates.Add(@{ Word = $token.Substring(0, $token.Length - 1); MatchWeight = 0.7 }) } } # Avoid bonusing the same permission twice for the same token via multiple candidate # variants (e.g. both the exact word and its plural form matching). $permissionsBonusedThisToken = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($candidate in $candidates) { $permissionNames = $null if (-not $permissionWordIndex.TryGetValue($candidate.Word, [ref]$permissionNames)) { continue } foreach ($permissionName in $permissionNames) { if (-not $permissionsBonusedThisToken.Add($permissionName)) { continue } # Same IDF-style dampening as the field-word scan above: a permission that # underlies a very large slice of the catalog (e.g. Directory.Read.All) is a # weak, low-value signal and should not flood the candidate set. $recordsForPermission = $permissionIndex.ByName[$permissionName] $documentFrequency = [double]$recordsForPermission.Count if ($documentFrequency -le 0) { continue } $idfNorm = [Math]::Log10($catalogSize / $documentFrequency) / $logCatalogSize if ($idfNorm -lt $idfNegligibleThreshold) { continue } $bonus = $weightPermission * $tokenWeight * $candidate.MatchWeight * $idfNorm foreach ($record in $recordsForPermission) { $current = 0.0 if ($conceptPermissionBest.TryGetValue($record.id, [ref]$current)) { if ($bonus -gt $current) { $conceptPermissionBest[$record.id] = $bonus } } else { $conceptPermissionBest[$record.id] = $bonus } } } } } foreach ($entry in $conceptPermissionBest.GetEnumerator()) { if ($permissionBest.ContainsKey($entry.Key)) { $permissionBest[$entry.Key] += $entry.Value } else { $permissionBest[$entry.Key] = $entry.Value } } } foreach ($entry in $permissionBest.GetEnumerator()) { if ($scores.ContainsKey($entry.Key)) { $scores[$entry.Key] += $entry.Value } else { $scores[$entry.Key] = $entry.Value } } if ($scores.Count -eq 0) { return , $results.ToArray() } # Deterministic ordering: score desc, then v1.0 before beta (so a beta duplicate never # outranks its v1.0 counterpart on a tie), then command name, then id -- never relies on # dictionary enumeration order. Built with a plain loop (not a `| ForEach-Object` pipeline, # which has significant per-item overhead at this scale) before a single Sort-Object call. $rankedList = [System.Collections.Generic.List[object]]::new($scores.Count) foreach ($entry in $scores.GetEnumerator()) { $record = $catalog.ById[$entry.Key] $rankedList.Add([pscustomobject]@{ Id = $entry.Key Score = $entry.Value Record = $record IsBeta = ($record.apiVersion -eq 'beta') DedupKey = (($record.command -replace 'Beta', '') + '|' + $record.uri) }) } $ranked = $rankedList | Sort-Object -Property @{Expression = 'Score'; Descending = $true }, @{Expression = 'IsBeta'; Descending = $false }, @{Expression = { $_.Record.command }; Descending = $false }, 'Id' # Collapse near-duplicate v1.0/beta pairs of the same operation so they do not both occupy # top-N slots; keep only the highest-scored representative of each (command-without-Beta, # endpoint) pair. $seenDedupKeys = [System.Collections.Generic.HashSet[string]]::new() $emitted = 0 foreach ($entry in $ranked) { if ($emitted -ge $MaxQueryResults) { break } if (-not $seenDedupKeys.Add($entry.DedupKey)) { continue } $obj = New-GraphMappingObject -Record $entry.Record -MatchedBy 'Query' -RenameIndex $RenameIndex -IncludeDetails:$IncludeDetails $results.Add($obj) $emitted++ } return , $results.ToArray() } |