Files
PowerToys/.github/skills/winmd-api-search/scripts/Invoke-WinMdQuery.ps1
Gordon Lam eeeb6c0c93 feat(winmd-api-search): add WinMD cache generator script and related files (#45606)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

Adds a new Copilot agent skill (`winmd-api-search`) that lets AI agents
discover and explore Windows desktop APIs by searching a local cache of
WinMD metadata. The skill covers Windows Platform SDK, WinAppSDK/WinUI,
NuGet package WinMDs, and project-output WinMDs — providing full API
surface details (types, members, enumeration values, namespaces) without
needing external documentation lookups.

**Key components:**

- `.github/skills/winmd-api-search/SKILL.md` — Skill definition with
usage instructions, search/detail workflows, and scoring guidance
- `scripts/Invoke-WinMdQuery.ps1` — PowerShell query engine supporting
actions: `search`, `type`, `members`, `enums`, `namespaces`, `stats`,
`projects`
- `scripts/Update-WinMdCache.ps1` — Orchestrator that builds the C#
cache generator, discovers project files, and runs the generator
- `scripts/cache-generator/CacheGenerator.csproj` + `Program.cs` — .NET
console app using `System.Reflection.Metadata` to parse WinMD files from
NuGet packages, project references, Windows SDK, and packages.config
into per-package JSON caches
- `scripts/cache-generator/Directory.Build.props`,
`Directory.Build.targets`, `Directory.Packages.props` — Empty isolation
files to prevent repo-level Central Package Management and build targets
from interfering with this standalone tool

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [ ] Closes: #xxx <!-- Replace with issue number if applicable -->
- [x] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass — N/A: This is an offline
agent skill (PowerShell + standalone .NET tool) with no integration into
the main product build or runtime. Validated manually by running the
cache generator across multiple project contexts (ColorPickerUI,
CmdPal.UI, runner, ImageResizer, etc.) and exercising all query actions.
- [ ] **Localization:** All end-user-facing strings can be localized —
N/A: No end-user-facing strings; this is an internal developer/agent
tool
- [ ] **Dev docs:** Added/updated — The SKILL.md itself serves as the
documentation
- [ ] **New binaries:** Added on the required places — N/A: The cache
generator is a standalone dev-time tool, not shipped in the installer
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: N/A

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

### Cache Generator (`Program.cs`, ~1000 lines)

A self-contained .NET console app that:

1. **Discovers WinMD sources** from four channels:
   - `project.assets.json` (PackageReference — modern .csproj/.vcxproj)
   - `packages.config` (legacy NuGet format)
- `<ProjectReference>` bin/ output (class libraries producing `.winmd`)
   - Windows SDK `UnionMetadata/` (highest installed version)

2. **Parses WinMD files** using `System.Reflection.Metadata` /
`PEReader` to extract:
- Types (classes, structs, interfaces, enums, delegates) with full
namespace
- Members (methods with decoded signatures/parameters, properties with
accessors, events)
   - Enum values
   - Base types and type kinds

3. **Outputs per-package JSON** under `Generated Files/winmd-cache/`:
- `packages/<Id>/<Version>/meta.json` — package summary
(type/member/namespace counts)
   - `packages/<Id>/<Version>/namespaces.json` — ordered namespace list
- `packages/<Id>/<Version>/types/<Namespace>.json` — full type detail
per namespace
- `projects/<ProjectName>.json` — maps each project to its package set

4. **Deduplicates** at the package level — if a package+version is
already cached, it's skipped on subsequent runs.

### Build Isolation

Three empty MSBuild files (`Directory.Build.props`,
`Directory.Build.targets`, `Directory.Packages.props`) in the
cache-generator folder prevent the repo's Central Package Management and
shared build configuration from interfering with this standalone tool.

### Query Engine (`Invoke-WinMdQuery.ps1`)

Supports seven actions: `search` (fuzzy text search across
types/members), `type` (full detail for a specific type), `members`
(filtered members of a type), `enums` (enumeration values), `namespaces`
(list all namespaces), `stats` (cache statistics), and `projects` (list
cached projects with their packages).

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed

1. **Cache generation:** Ran `Update-WinMdCache.ps1` across 310+ project
files in the repo — 8 packages parsed, 316 reused from cache, all
completed without errors
2. **Query testing on multiple projects:**
   - `ColorPickerUI` — verified Windows SDK baseline (7,023 types)
- `Microsoft.CmdPal.UI.ViewModels` (after restore) — verified 13
packages, 49,799 types, 112,131 members including WinAppSDK,
AdaptiveCards, CsWinRT, Win32Metadata
   - `runner` (C++ vcxproj) — verified packages.config fallback path
   - `ImageResizerExt` — verified project reference WinMD discovery
3. **All seven query actions validated:** `stats`, `search`,
`namespaces`, `type`, `enums`, `members`, `projects` — all returned
correct results
4. **Spell-check compliance:** SKILL.md vocabulary reviewed against
repo's check-spelling dictionaries; replaced flagged words with standard
alternatives

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-11 10:15:32 +08:00

506 lines
19 KiB
PowerShell

<#
.SYNOPSIS
Query WinMD API metadata from cached JSON files.
.DESCRIPTION
Reads pre-built JSON cache of WinMD types, members, and namespaces.
The cache is organized per-package (deduplicated) with project manifests
that map each project to its referenced packages.
Supports listing namespaces, types, members, searching, enum value lookup,
and listing cached projects/packages.
.PARAMETER Action
The query action to perform:
- projects : List cached projects
- packages : List packages for a project
- stats : Show aggregate statistics for a project
- namespaces : List all namespaces (optional -Filter prefix)
- types : List types in a namespace (-Namespace required)
- members : List members of a type (-TypeName required)
- search : Search types and members by name (-Query required)
- enums : List enum values (-TypeName required)
.PARAMETER Project
Project name to query. Auto-selected if only one project is cached.
Use -Action projects to list available projects.
.PARAMETER Namespace
Namespace to query types from (used with -Action types).
.PARAMETER TypeName
Full type name e.g. "Microsoft.UI.Xaml.Controls.Button" (used with -Action members, enums).
.PARAMETER Query
Search query string (used with -Action search).
.PARAMETER Filter
Optional prefix filter for namespaces (used with -Action namespaces).
.PARAMETER CacheDir
Path to the winmd-cache directory. Defaults to "Generated Files\winmd-cache"
relative to the workspace root.
.PARAMETER MaxResults
Maximum number of results to return for search. Defaults to 30.
.EXAMPLE
.\Invoke-WinMdQuery.ps1 -Action projects
.\Invoke-WinMdQuery.ps1 -Action packages -Project BlankWinUI
.\Invoke-WinMdQuery.ps1 -Action stats -Project BlankWinUI
.\Invoke-WinMdQuery.ps1 -Action namespaces -Filter "Microsoft.UI"
.\Invoke-WinMdQuery.ps1 -Action types -Namespace "Microsoft.UI.Xaml.Controls"
.\Invoke-WinMdQuery.ps1 -Action members -TypeName "Microsoft.UI.Xaml.Controls.Button"
.\Invoke-WinMdQuery.ps1 -Action search -Query "NavigationView"
.\Invoke-WinMdQuery.ps1 -Action enums -TypeName "Microsoft.UI.Xaml.Visibility"
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateSet('projects', 'packages', 'stats', 'namespaces', 'types', 'members', 'search', 'enums')]
[string]$Action,
[string]$Project,
[string]$Namespace,
[string]$TypeName,
[string]$Query,
[string]$Filter,
[string]$CacheDir,
[int]$MaxResults = 30
)
# ─── Resolve cache directory ─────────────────────────────────────────────────
if (-not $CacheDir) {
# Convention: skill lives at .github/skills/winmd-api-search/scripts/
# so workspace root is 4 levels up from $PSScriptRoot.
$scriptDir = $PSScriptRoot
$root = (Resolve-Path (Join-Path $scriptDir '..\..\..\..')).Path
$CacheDir = Join-Path $root 'Generated Files\winmd-cache'
}
if (-not (Test-Path $CacheDir)) {
Write-Error "Cache not found at: $CacheDir`nRun: .\Update-WinMdCache.ps1 (from .github\skills\winmd-api-search\scripts\)"
exit 1
}
# ─── Project resolution helpers ──────────────────────────────────────────────
function Get-CachedProjects {
$projectsDir = Join-Path $CacheDir 'projects'
if (-not (Test-Path $projectsDir)) { return @() }
Get-ChildItem $projectsDir -Filter '*.json' | ForEach-Object { $_.BaseName }
}
function Resolve-ProjectManifest {
param([string]$Name)
$projectsDir = Join-Path $CacheDir 'projects'
if (-not (Test-Path $projectsDir)) {
Write-Error "No projects cached. Run Update-WinMdCache.ps1 first."
exit 1
}
if ($Name) {
$path = Join-Path $projectsDir "$Name.json"
if (-not (Test-Path $path)) {
# Scan mode appends a hash suffix -- try prefix match
$matching = @(Get-ChildItem $projectsDir -Filter "${Name}_*.json" -ErrorAction SilentlyContinue)
if ($matching.Count -eq 1) {
return Get-Content $matching[0].FullName -Raw | ConvertFrom-Json
}
if ($matching.Count -gt 1) {
$names = ($matching | ForEach-Object { $_.BaseName }) -join ', '
Write-Error "Multiple projects match '$Name'. Specify the full name: $names"
exit 1
}
$available = (Get-CachedProjects) -join ', '
Write-Error "Project '$Name' not found. Available: $available"
exit 1
}
return Get-Content $path -Raw | ConvertFrom-Json
}
# Auto-select if only one project
$manifests = Get-ChildItem $projectsDir -Filter '*.json' -ErrorAction SilentlyContinue
if ($manifests.Count -eq 0) {
Write-Error "No projects cached. Run Update-WinMdCache.ps1 first."
exit 1
}
if ($manifests.Count -eq 1) {
return Get-Content $manifests[0].FullName -Raw | ConvertFrom-Json
}
$available = ($manifests | ForEach-Object { $_.BaseName }) -join ', '
Write-Error "Multiple projects cached -- use -Project to specify. Available: $available"
exit 1
}
function Get-PackageCacheDirs {
param($Manifest)
$dirs = @()
foreach ($pkg in $Manifest.packages) {
$dir = Join-Path (Join-Path (Join-Path $CacheDir 'packages') $pkg.id) $pkg.version
if (Test-Path $dir) {
$dirs += $dir
}
}
return $dirs
}
# ─── Action: projects ────────────────────────────────────────────────────────
function Show-Projects {
$projects = Get-CachedProjects
if ($projects.Count -eq 0) {
Write-Output "No projects cached."
return
}
Write-Output "Cached projects ($($projects.Count)):"
foreach ($p in $projects) {
$manifest = Get-Content (Join-Path (Join-Path $CacheDir 'projects') "$p.json") -Raw | ConvertFrom-Json
$pkgCount = $manifest.packages.Count
Write-Output " $p ($pkgCount package(s))"
}
}
# ─── Action: packages ────────────────────────────────────────────────────────
function Show-Packages {
$manifest = Resolve-ProjectManifest -Name $Project
Write-Output "Packages for project '$($manifest.projectName)' ($($manifest.packages.Count)):"
foreach ($pkg in $manifest.packages) {
$metaPath = Join-Path (Join-Path (Join-Path (Join-Path $CacheDir 'packages') $pkg.id) $pkg.version) 'meta.json'
if (Test-Path $metaPath) {
$meta = Get-Content $metaPath -Raw | ConvertFrom-Json
Write-Output " $($pkg.id)@$($pkg.version) -- $($meta.totalTypes) types, $($meta.totalMembers) members"
} else {
Write-Output " $($pkg.id)@$($pkg.version) -- (cache missing)"
}
}
}
# ─── Action: stats ───────────────────────────────────────────────────────────
function Show-Stats {
$manifest = Resolve-ProjectManifest -Name $Project
$totalTypes = 0
$totalMembers = 0
$totalNamespaces = 0
$totalWinMd = 0
foreach ($pkg in $manifest.packages) {
$metaPath = Join-Path (Join-Path (Join-Path (Join-Path $CacheDir 'packages') $pkg.id) $pkg.version) 'meta.json'
if (Test-Path $metaPath) {
$meta = Get-Content $metaPath -Raw | ConvertFrom-Json
$totalTypes += $meta.totalTypes
$totalMembers += $meta.totalMembers
$totalNamespaces += $meta.totalNamespaces
$totalWinMd += $meta.winMdFiles.Count
}
}
Write-Output "WinMD Index Statistics -- $($manifest.projectName)"
Write-Output "======================================"
Write-Output " Packages: $($manifest.packages.Count)"
Write-Output " Namespaces: $totalNamespaces (may overlap across packages)"
Write-Output " Types: $totalTypes"
Write-Output " Members: $totalMembers"
Write-Output " WinMD files: $totalWinMd"
}
# ─── Action: namespaces ──────────────────────────────────────────────────────
function Get-Namespaces {
param([string]$Prefix)
$manifest = Resolve-ProjectManifest -Name $Project
$dirs = Get-PackageCacheDirs -Manifest $manifest
$allNs = @()
foreach ($dir in $dirs) {
$nsFile = Join-Path $dir 'namespaces.json'
if (Test-Path $nsFile) {
$allNs += (Get-Content $nsFile -Raw | ConvertFrom-Json)
}
}
$allNs = $allNs | Sort-Object -Unique
if ($Prefix) {
$allNs = $allNs | Where-Object { $_ -like "$Prefix*" }
}
$allNs | ForEach-Object { Write-Output $_ }
}
# ─── Action: types ───────────────────────────────────────────────────────────
function Get-TypesInNamespace {
param([string]$Ns)
if (-not $Ns) {
Write-Error "-Namespace is required for 'types' action."
exit 1
}
$manifest = Resolve-ProjectManifest -Name $Project
$dirs = Get-PackageCacheDirs -Manifest $manifest
$safeFile = $Ns.Replace('.', '_') + '.json'
$found = $false
$seen = @{}
foreach ($dir in $dirs) {
$filePath = Join-Path $dir "types\$safeFile"
if (-not (Test-Path $filePath)) { continue }
$found = $true
$types = Get-Content $filePath -Raw | ConvertFrom-Json
foreach ($t in $types) {
if ($seen.ContainsKey($t.fullName)) { continue }
$seen[$t.fullName] = $true
Write-Output "$($t.kind) $($t.fullName)$(if ($t.baseType) { " : $($t.baseType)" } else { '' })"
}
}
if (-not $found) {
Write-Error "Namespace not found: $Ns"
exit 1
}
}
# ─── Action: members ─────────────────────────────────────────────────────────
function Get-MembersOfType {
param([string]$FullName)
if (-not $FullName) {
Write-Error "-TypeName is required for 'members' action."
exit 1
}
$lastDot = $FullName.LastIndexOf('.')
if ($lastDot -lt 0) {
Write-Error "-TypeName must include a namespace (for example: 'MyNamespace.MyType'). Provided: $FullName"
exit 1
}
$ns = $FullName.Substring(0, $lastDot)
$safeFile = $ns.Replace('.', '_') + '.json'
$manifest = Resolve-ProjectManifest -Name $Project
$dirs = Get-PackageCacheDirs -Manifest $manifest
foreach ($dir in $dirs) {
$filePath = Join-Path $dir "types\$safeFile"
if (-not (Test-Path $filePath)) { continue }
$types = Get-Content $filePath -Raw | ConvertFrom-Json
$type = $types | Where-Object { $_.fullName -eq $FullName }
if (-not $type) { continue }
Write-Output "$($type.kind) $($type.fullName)"
if ($type.baseType) { Write-Output " Extends: $($type.baseType)" }
Write-Output ""
foreach ($m in $type.members) {
Write-Output " [$($m.kind)] $($m.signature)"
}
return
}
Write-Error "Type not found: $FullName"
exit 1
}
# ─── Action: search ──────────────────────────────────────────────────────────
# Ranks namespaces by best match score on type names and member names.
# Outputs: ranked namespaces with top matching types and the JSON file path.
# The agent can then read the JSON file to inspect all members intelligently.
function Search-WinMd {
param([string]$SearchQuery, [int]$Max)
if (-not $SearchQuery) {
Write-Error "-Query is required for 'search' action."
exit 1
}
$manifest = Resolve-ProjectManifest -Name $Project
$dirs = Get-PackageCacheDirs -Manifest $manifest
# Collect: namespace -> { bestScore, matchingTypes[], filePath }
$nsResults = @{}
foreach ($dir in $dirs) {
$nsFile = Join-Path $dir 'namespaces.json'
if (-not (Test-Path $nsFile)) { continue }
$nsList = Get-Content $nsFile -Raw | ConvertFrom-Json
foreach ($n in $nsList) {
$safeFile = $n.Replace('.', '_') + '.json'
$filePath = Join-Path $dir "types\$safeFile"
if (-not (Test-Path $filePath)) { continue }
$types = Get-Content $filePath -Raw | ConvertFrom-Json
foreach ($t in $types) {
$typeScore = Get-MatchScore -Name $t.name -FullName $t.fullName -Query $SearchQuery
# Also search member names for matches
$bestMemberScore = 0
$matchingMember = $null
if ($t.members) {
foreach ($m in $t.members) {
$memberName = $m.name
$mScore = Get-MatchScore -Name $memberName -FullName "$($t.fullName).$memberName" -Query $SearchQuery
if ($mScore -gt $bestMemberScore) {
$bestMemberScore = $mScore
$matchingMember = $m.signature
}
}
}
$score = [Math]::Max($typeScore, $bestMemberScore)
if ($score -le 0) { continue }
if (-not $nsResults.ContainsKey($n)) {
$nsResults[$n] = @{ BestScore = 0; Types = @(); FilePaths = @() }
}
$entry = $nsResults[$n]
if ($score -gt $entry.BestScore) { $entry.BestScore = $score }
if ($entry.FilePaths -notcontains $filePath) {
$entry.FilePaths += $filePath
}
if ($typeScore -ge $bestMemberScore) {
$entry.Types += @{ Text = "$($t.kind) $($t.fullName) [$typeScore]"; Score = $typeScore }
} else {
$entry.Types += @{ Text = "$($t.kind) $($t.fullName) -> $matchingMember [$bestMemberScore]"; Score = $bestMemberScore }
}
}
}
}
if ($nsResults.Count -eq 0) {
Write-Output "No results found for: $SearchQuery"
return
}
$ranked = $nsResults.GetEnumerator() |
Sort-Object { $_.Value.BestScore } -Descending |
Select-Object -First $Max
foreach ($r in $ranked) {
$ns = $r.Key
$info = $r.Value
Write-Output "[$($info.BestScore)] $ns"
foreach ($fp in $info.FilePaths) {
Write-Output " File: $fp"
}
# Show top 5 highest-scoring matching types in this namespace
$info.Types | Sort-Object { $_.Score } -Descending |
Select-Object -First 5 |
ForEach-Object { Write-Output " $($_.Text)" }
Write-Output ""
}
}
# ─── Search scoring ──────────────────────────────────────────────────────────
# Simple ranked scoring on type names. Higher = better.
# 100 = exact name 80 = starts-with 60 = substring
# 50 = PascalCase 40 = multi-keyword 20 = fuzzy subsequence
function Get-MatchScore {
param([string]$Name, [string]$FullName, [string]$Query)
$q = $Query.Trim()
if (-not $q) { return 0 }
if ($Name -eq $q) { return 100 }
if ($Name -like "$q*") { return 80 }
if ($Name -like "*$q*" -or $FullName -like "*$q*") { return 60 }
$initials = ($Name.ToCharArray() | Where-Object { [char]::IsUpper($_) }) -join ''
if ($initials.Length -ge 2 -and $initials -like "*$q*") { return 50 }
$words = $q -split '\s+' | Where-Object { $_.Length -gt 0 }
if ($words.Count -gt 1) {
$allFound = $true
foreach ($w in $words) {
if ($Name -notlike "*$w*" -and $FullName -notlike "*$w*") {
$allFound = $false
break
}
}
if ($allFound) { return 40 }
}
if (Test-FuzzySubsequence -Text $Name -Pattern $q) { return 20 }
return 0
}
function Test-FuzzySubsequence {
param([string]$Text, [string]$Pattern)
$ti = 0
$tLower = $Text.ToLowerInvariant()
$pLower = $Pattern.ToLowerInvariant()
foreach ($ch in $pLower.ToCharArray()) {
$idx = $tLower.IndexOf($ch, $ti)
if ($idx -lt 0) { return $false }
$ti = $idx + 1
}
return $true
}
# ─── Action: enums ───────────────────────────────────────────────────────────
function Get-EnumValues {
param([string]$FullName)
if (-not $FullName) {
Write-Error "-TypeName is required for 'enums' action."
exit 1
}
$lastDot = $FullName.LastIndexOf('.')
if ($lastDot -lt 1) {
Write-Error "-TypeName must be a fully-qualified type name including namespace, e.g. 'Namespace.TypeName'. Provided: $FullName"
exit 1
}
$ns = $FullName.Substring(0, $lastDot)
$safeFile = $ns.Replace('.', '_') + '.json'
$manifest = Resolve-ProjectManifest -Name $Project
$dirs = Get-PackageCacheDirs -Manifest $manifest
foreach ($dir in $dirs) {
$filePath = Join-Path $dir "types\$safeFile"
if (-not (Test-Path $filePath)) { continue }
$types = Get-Content $filePath -Raw | ConvertFrom-Json
$type = $types | Where-Object { $_.fullName -eq $FullName }
if (-not $type) { continue }
if ($type.kind -ne 'Enum') {
Write-Error "$FullName is not an Enum (kind: $($type.kind))"
exit 1
}
Write-Output "Enum $($type.fullName)"
if ($type.enumValues) {
$type.enumValues | ForEach-Object { Write-Output " $_" }
} else {
Write-Output " (no values)"
}
return
}
Write-Error "Type not found: $FullName"
exit 1
}
# ─── Dispatch ─────────────────────────────────────────────────────────────────
switch ($Action) {
'projects' { Show-Projects }
'packages' { Show-Packages }
'stats' { Show-Stats }
'namespaces' { Get-Namespaces -Prefix $Filter }
'types' { Get-TypesInNamespace -Ns $Namespace }
'members' { Get-MembersOfType -FullName $TypeName }
'search' { Search-WinMd -SearchQuery $Query -Max $MaxResults }
'enums' { Get-EnumValues -FullName $TypeName }
}