mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-03 09:46:54 +02:00
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>
This commit is contained in:
505
.github/skills/winmd-api-search/scripts/Invoke-WinMdQuery.ps1
vendored
Normal file
505
.github/skills/winmd-api-search/scripts/Invoke-WinMdQuery.ps1
vendored
Normal file
@@ -0,0 +1,505 @@
|
||||
<#
|
||||
.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 }
|
||||
}
|
||||
208
.github/skills/winmd-api-search/scripts/Update-WinMdCache.ps1
vendored
Normal file
208
.github/skills/winmd-api-search/scripts/Update-WinMdCache.ps1
vendored
Normal file
@@ -0,0 +1,208 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Generate or refresh the WinMD cache for the Agent Skill.
|
||||
|
||||
.DESCRIPTION
|
||||
Builds and runs the standalone cache generator to export cached JSON files
|
||||
from all WinMD metadata found in project NuGet packages and Windows SDK.
|
||||
|
||||
The cache is per-package+version: if two projects reference the same
|
||||
package at the same version, the WinMD data is parsed once and shared.
|
||||
|
||||
Supports single project or recursive scan of an entire repo.
|
||||
|
||||
.PARAMETER ProjectDir
|
||||
Path to a project directory (contains .csproj/.vcxproj), or a project file itself.
|
||||
Defaults to scanning the workspace root.
|
||||
|
||||
.PARAMETER Scan
|
||||
Recursively discover all .csproj/.vcxproj files under ProjectDir.
|
||||
|
||||
.PARAMETER OutputDir
|
||||
Path to the cache output directory. Defaults to "Generated Files\winmd-cache".
|
||||
|
||||
.EXAMPLE
|
||||
.\Update-WinMdCache.ps1
|
||||
.\Update-WinMdCache.ps1 -ProjectDir BlankWinUI
|
||||
.\Update-WinMdCache.ps1 -Scan -ProjectDir .
|
||||
.\Update-WinMdCache.ps1 -ProjectDir "src\MyApp\MyApp.csproj"
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ProjectDir,
|
||||
[switch]$Scan,
|
||||
[string]$OutputDir = 'Generated Files\winmd-cache'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Convention: skill lives at .github/skills/winmd-api-search/scripts/
|
||||
# so workspace root is 4 levels up from $PSScriptRoot.
|
||||
$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..\..')).Path
|
||||
$generatorProj = Join-Path (Join-Path $PSScriptRoot 'cache-generator') 'CacheGenerator.csproj'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WinAppSDK version detection -- look only at the repo root folder (no recursion)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Get-WinAppSdkVersionFromDirectoryPackagesProps {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Extract Microsoft.WindowsAppSDK version from a Directory.Packages.props
|
||||
(Central Package Management) at the repo root.
|
||||
#>
|
||||
param([string]$RepoRoot)
|
||||
$propsFile = Join-Path $RepoRoot 'Directory.Packages.props'
|
||||
if (-not (Test-Path $propsFile)) { return $null }
|
||||
try {
|
||||
[xml]$xml = Get-Content $propsFile -Raw
|
||||
$node = $xml.SelectNodes('//PackageVersion') |
|
||||
Where-Object { $_.Include -eq 'Microsoft.WindowsAppSDK' } |
|
||||
Select-Object -First 1
|
||||
if ($node) { return $node.Version }
|
||||
} catch {
|
||||
Write-Verbose "Could not parse $propsFile : $_"
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-WinAppSdkVersionFromPackagesConfig {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Extract Microsoft.WindowsAppSDK version from a packages.config at the repo root.
|
||||
#>
|
||||
param([string]$RepoRoot)
|
||||
$configFile = Join-Path $RepoRoot 'packages.config'
|
||||
if (-not (Test-Path $configFile)) { return $null }
|
||||
try {
|
||||
[xml]$xml = Get-Content $configFile -Raw
|
||||
$node = $xml.SelectNodes('//package') |
|
||||
Where-Object { $_.id -eq 'Microsoft.WindowsAppSDK' } |
|
||||
Select-Object -First 1
|
||||
if ($node) { return $node.version }
|
||||
} catch {
|
||||
Write-Verbose "Could not parse $configFile : $_"
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# Try Directory.Packages.props first (CPM), then packages.config
|
||||
$winAppSdkVersion = Get-WinAppSdkVersionFromDirectoryPackagesProps -RepoRoot $root
|
||||
if (-not $winAppSdkVersion) {
|
||||
$winAppSdkVersion = Get-WinAppSdkVersionFromPackagesConfig -RepoRoot $root
|
||||
}
|
||||
if ($winAppSdkVersion) {
|
||||
Write-Host "Detected WinAppSDK version from repo: $winAppSdkVersion" -ForegroundColor Cyan
|
||||
} else {
|
||||
Write-Host "No WinAppSDK version found at repo root; will use latest (Version=*)" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Default: if no ProjectDir, scan the workspace root
|
||||
if (-not $ProjectDir) {
|
||||
$ProjectDir = $root
|
||||
$Scan = $true
|
||||
}
|
||||
|
||||
Push-Location $root
|
||||
|
||||
try {
|
||||
# Detect installed .NET SDK -- require >= 8.0, prefer stable over preview
|
||||
$dotnetSdks = dotnet --list-sdks 2>$null
|
||||
$bestMajor = $dotnetSdks |
|
||||
Where-Object { $_ -notmatch 'preview|rc|alpha|beta' } |
|
||||
ForEach-Object { if ($_ -match '^(\d+)\.') { [int]$Matches[1] } } |
|
||||
Where-Object { $_ -ge 8 } |
|
||||
Sort-Object -Descending |
|
||||
Select-Object -First 1
|
||||
|
||||
# Fall back to preview SDKs if no stable SDK found
|
||||
if (-not $bestMajor) {
|
||||
$bestMajor = $dotnetSdks |
|
||||
ForEach-Object { if ($_ -match '^(\d+)\.') { [int]$Matches[1] } } |
|
||||
Where-Object { $_ -ge 8 } |
|
||||
Sort-Object -Descending |
|
||||
Select-Object -First 1
|
||||
}
|
||||
|
||||
if (-not $bestMajor) {
|
||||
Write-Error "No .NET SDK >= 8.0 found. Install from https://dotnet.microsoft.com/download"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$targetFramework = "net$bestMajor.0"
|
||||
Write-Host "Using .NET SDK: $targetFramework" -ForegroundColor Cyan
|
||||
|
||||
# Build MSBuild properties -- pass detected WinAppSDK version when available
|
||||
$sdkVersionProp = ''
|
||||
if ($winAppSdkVersion) {
|
||||
$sdkVersionProp = "-p:WinAppSdkVersion=$winAppSdkVersion"
|
||||
}
|
||||
|
||||
Write-Host "Building cache generator..." -ForegroundColor Cyan
|
||||
$restoreArgs = @($generatorProj, "-p:TargetFramework=$targetFramework", '--nologo', '-v', 'q')
|
||||
if ($sdkVersionProp) { $restoreArgs += $sdkVersionProp }
|
||||
dotnet restore @restoreArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Restore failed"
|
||||
exit 1
|
||||
}
|
||||
$buildArgs = @($generatorProj, '-c', 'Release', '--nologo', '-v', 'q', "-p:TargetFramework=$targetFramework", '--no-restore')
|
||||
if ($sdkVersionProp) { $buildArgs += $sdkVersionProp }
|
||||
dotnet build @buildArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Build failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Run the built executable directly (avoids dotnet run target framework mismatch issues)
|
||||
$generatorDir = Join-Path $PSScriptRoot 'cache-generator'
|
||||
$exePath = Join-Path $generatorDir "bin\Release\$targetFramework\CacheGenerator.exe"
|
||||
if (-not (Test-Path $exePath)) {
|
||||
# Fallback: try dll with dotnet
|
||||
$dllPath = Join-Path $generatorDir "bin\Release\$targetFramework\CacheGenerator.dll"
|
||||
if (Test-Path $dllPath) {
|
||||
$exePath = $null
|
||||
} else {
|
||||
Write-Error "Built executable not found at: $exePath"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
$runArgs = @()
|
||||
if ($Scan) {
|
||||
$runArgs += '--scan'
|
||||
}
|
||||
|
||||
# Detect installed WinAppSDK runtime via Get-AppxPackage (the WindowsApps
|
||||
# folder is ACL-restricted so C# cannot enumerate it directly).
|
||||
# WinMD files are architecture-independent metadata, so pick whichever arch
|
||||
# matches the current OS to ensure the package is present.
|
||||
$osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()
|
||||
$runtimePkg = Get-AppxPackage -Name 'Microsoft.WindowsAppRuntime.*' -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -notmatch 'CBS' -and $_.Architecture -eq $osArch } |
|
||||
Sort-Object -Property Version -Descending |
|
||||
Select-Object -First 1
|
||||
if ($runtimePkg -and $runtimePkg.InstallLocation -and (Test-Path $runtimePkg.InstallLocation)) {
|
||||
Write-Host "Detected WinAppSDK runtime: $($runtimePkg.Name) v$($runtimePkg.Version)" -ForegroundColor Cyan
|
||||
$runArgs += '--winappsdk-runtime'
|
||||
$runArgs += $runtimePkg.InstallLocation
|
||||
}
|
||||
|
||||
$runArgs += $ProjectDir
|
||||
$runArgs += $OutputDir
|
||||
|
||||
Write-Host "Exporting WinMD cache..." -ForegroundColor Cyan
|
||||
if ($exePath) {
|
||||
& $exePath @runArgs
|
||||
} else {
|
||||
dotnet $dllPath @runArgs
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Cache export failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Cache updated at: $OutputDir" -ForegroundColor Green
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
29
.github/skills/winmd-api-search/scripts/cache-generator/CacheGenerator.csproj
vendored
Normal file
29
.github/skills/winmd-api-search/scripts/cache-generator/CacheGenerator.csproj
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<!-- Default fallback; Update-WinMdCache.ps1 overrides via -p:TargetFramework=net{X}.0 -->
|
||||
<TargetFramework Condition="'$(TargetFramework)' == ''">net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<!-- System.Reflection.Metadata is inbox in net9.0+, only needed for net8.0 -->
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
|
||||
<PackageReference Include="System.Reflection.Metadata" Version="8.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Baseline WinAppSDK packages: downloaded during restore so the cache generator
|
||||
can always index WinAppSDK APIs, even if the target project hasn't been restored.
|
||||
ExcludeAssets="all" means they're downloaded but don't affect this tool's build.
|
||||
|
||||
When the repo has a known version (passed via -p:WinAppSdkVersion=X.Y.Z from
|
||||
Update-WinMdCache.ps1), prefer that version to avoid unnecessary NuGet downloads.
|
||||
Falls back to Version="*" (latest) on fresh clones with no restore.
|
||||
-->
|
||||
<ItemGroup Condition="'$(WinAppSdkVersion)' != ''">
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="$(WinAppSdkVersion)" ExcludeAssets="all" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(WinAppSdkVersion)' == ''">
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="*" ExcludeAssets="all" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
3
.github/skills/winmd-api-search/scripts/cache-generator/Directory.Build.props
vendored
Normal file
3
.github/skills/winmd-api-search/scripts/cache-generator/Directory.Build.props
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
<Project>
|
||||
<!-- Isolate this standalone tool from the repo-level build configuration -->
|
||||
</Project>
|
||||
3
.github/skills/winmd-api-search/scripts/cache-generator/Directory.Build.targets
vendored
Normal file
3
.github/skills/winmd-api-search/scripts/cache-generator/Directory.Build.targets
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
<Project>
|
||||
<!-- Isolate this standalone tool from the repo-level build targets -->
|
||||
</Project>
|
||||
3
.github/skills/winmd-api-search/scripts/cache-generator/Directory.Packages.props
vendored
Normal file
3
.github/skills/winmd-api-search/scripts/cache-generator/Directory.Packages.props
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
<Project>
|
||||
<!-- Isolate this standalone tool from the repo-level Central Package Management -->
|
||||
</Project>
|
||||
1222
.github/skills/winmd-api-search/scripts/cache-generator/Program.cs
vendored
Normal file
1222
.github/skills/winmd-api-search/scripts/cache-generator/Program.cs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user