microsoft/hve-core

Public

mirrored fromhttps://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
1d56d25494d03b3ff5b9bf68c8ec3e7e38d351d5

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

scripts/collections/Modules/CollectionHelpers.psm1

565lines · modecode

1# Copyright (c) Microsoft Corporation.
2# SPDX-License-Identifier: MIT
3
4# CollectionHelpers.psm1
5#
6# Purpose: Collection helpers - YAML parsing, validation, and shared collection utilities.
7# Author: HVE Core Team
8
9#Requires -Version 7.0
10#Requires -Modules PowerShell-Yaml
11
12# ---------------------------------------------------------------------------
13# Pure Functions (no file system side effects)
14# ---------------------------------------------------------------------------
15
16function Test-DeprecatedPath {
17 <#
18 .SYNOPSIS
19 Checks whether a file path contains a deprecated directory segment.
20
21 .DESCRIPTION
22 Returns true when the path contains a /deprecated/ or \deprecated\ segment,
23 indicating the artifact resides in a deprecated directory tree.
24
25 .PARAMETER Path
26 File path to check (absolute or relative, any slash style).
27
28 .OUTPUTS
29 [bool] True when the path contains a deprecated segment.
30 #>
31 [CmdletBinding()]
32 [OutputType([bool])]
33 param(
34 [Parameter(Mandatory = $true)]
35 [ValidateNotNullOrEmpty()]
36 [string]$Path
37 )
38
39 return ($Path -match '[/\\]deprecated[/\\]')
40}
41
42function Test-HveCoreRepoSpecificPath {
43 <#
44 .SYNOPSIS
45 Checks whether a type-relative path is a root-level repo-specific artifact.
46
47 .DESCRIPTION
48 Returns true when the type-relative path has no subdirectory component,
49 indicating it is a root-level repo-specific artifact not intended for
50 distribution. Collection-scoped artifacts reside in subdirectories.
51
52 .PARAMETER RelativePath
53 Type-relative path (relative to the agents/, prompts/, instructions/, or skills/ directory).
54
55 .OUTPUTS
56 [bool] True when the path is repo-specific.
57 #>
58 [CmdletBinding()]
59 [OutputType([bool])]
60 param(
61 [Parameter(Mandatory = $true)]
62 [ValidateNotNullOrEmpty()]
63 [string]$RelativePath
64 )
65
66 return ($RelativePath -notlike '*/*')
67}
68
69function Test-HveCoreRepoRelativePath {
70 <#
71 .SYNOPSIS
72 Checks whether a repo-relative path is a root-level repo-specific artifact.
73
74 .DESCRIPTION
75 Returns true when the repo-relative path is directly under a .github type
76 directory (agents, instructions, prompts, skills) with no subdirectory,
77 indicating it is a root-level repo-specific artifact not intended for distribution.
78
79 .PARAMETER Path
80 Repo-relative path (e.g., .github/instructions/workflows.instructions.md).
81
82 .OUTPUTS
83 [bool] True when the path is a root-level repo-specific artifact.
84 #>
85 [CmdletBinding()]
86 [OutputType([bool])]
87 param(
88 [Parameter(Mandatory = $true)]
89 [ValidateNotNullOrEmpty()]
90 [string]$Path
91 )
92
93 return ($Path -match '^\.github/(agents|instructions|prompts|skills)/[^/]+$')
94}
95
96function Get-CollectionManifest {
97 <#
98 .SYNOPSIS
99 Loads a collection manifest from a YAML or JSON file.
100
101 .DESCRIPTION
102 Reads and parses a collection manifest file that defines collection-based
103 artifact filtering rules. Supports both YAML (.yml/.yaml) and JSON (.json)
104 formats.
105
106 .PARAMETER CollectionPath
107 Path to the collection manifest file (YAML or JSON).
108
109 .OUTPUTS
110 [hashtable] Parsed collection manifest with id, name, displayName, description, items, and optional include/exclude.
111 #>
112 [CmdletBinding()]
113 [OutputType([hashtable])]
114 param(
115 [Parameter(Mandatory = $true)]
116 [ValidateNotNullOrEmpty()]
117 [string]$CollectionPath
118 )
119
120 if (-not (Test-Path $CollectionPath)) {
121 throw "Collection manifest not found: $CollectionPath"
122 }
123
124 $extension = [System.IO.Path]::GetExtension($CollectionPath).ToLowerInvariant()
125 if ($extension -in @('.yml', '.yaml')) {
126 $content = Get-Content -Path $CollectionPath -Raw
127 return ConvertFrom-Yaml -Yaml $content
128 }
129
130 $content = Get-Content -Path $CollectionPath -Raw
131 return $content | ConvertFrom-Json -AsHashtable
132}
133
134function Get-CollectionArtifactKey {
135 <#
136 .SYNOPSIS
137 Extracts a unique key from an artifact path based on its kind.
138
139 .DESCRIPTION
140 Produces the same key that extension packaging uses for deduplication.
141 Agents and prompts use the filename only; instructions use the
142 type-relative path; skills use the directory name.
143
144 .PARAMETER Kind
145 The artifact kind (agent, prompt, instruction, skill).
146
147 .PARAMETER Path
148 The repo-relative artifact path.
149
150 .OUTPUTS
151 [string] The artifact key.
152 #>
153 [CmdletBinding()]
154 [OutputType([string])]
155 param(
156 [Parameter(Mandatory = $true)]
157 [string]$Kind,
158
159 [Parameter(Mandatory = $true)]
160 [string]$Path
161 )
162
163 switch ($Kind) {
164 'agent' {
165 return ([System.IO.Path]::GetFileName($Path) -replace '\.agent\.md$', '')
166 }
167 'prompt' {
168 return ([System.IO.Path]::GetFileName($Path) -replace '\.prompt\.md$', '')
169 }
170 'instruction' {
171 return ($Path -replace '^\.github/instructions/', '' -replace '\.instructions\.md$', '')
172 }
173 'skill' {
174 return [System.IO.Path]::GetFileName($Path.TrimEnd('/'))
175 }
176 default {
177 if ($Path -match "\.$([regex]::Escape($Kind))\.md$") {
178 return ([System.IO.Path]::GetFileName($Path) -replace "\.$([regex]::Escape($Kind))\.md$", '')
179 }
180
181 if ($Path -like '*.md') {
182 return [System.IO.Path]::GetFileNameWithoutExtension($Path)
183 }
184
185 return [System.IO.Path]::GetFileName($Path)
186 }
187 }
188}
189
190function Get-ArtifactFrontmatter {
191 <#
192 .SYNOPSIS
193 Extracts YAML frontmatter from a markdown file.
194
195 .DESCRIPTION
196 Parses the YAML frontmatter block delimited by --- markers at the start
197 of a markdown file. Returns a hashtable with description.
198
199 .PARAMETER FilePath
200 Path to the markdown file to parse.
201
202 .PARAMETER FallbackDescription
203 Default description if none found in frontmatter.
204
205 .OUTPUTS
206 [hashtable] With description key.
207 #>
208 [CmdletBinding()]
209 [OutputType([hashtable])]
210 param(
211 [Parameter(Mandatory = $true)]
212 [string]$FilePath,
213
214 [Parameter(Mandatory = $false)]
215 [string]$FallbackDescription = ''
216 )
217
218 $content = Get-Content -Path $FilePath -Raw
219 $description = ''
220
221 if ($content -match '(?s)^---\s*\r?\n(.*?)\r?\n---') {
222 $yamlContent = $Matches[1] -replace '\r\n', "`n" -replace '\r', "`n"
223 try {
224 $data = ConvertFrom-Yaml -Yaml $yamlContent
225 if ($data.ContainsKey('description')) {
226 $description = $data.description
227 }
228 }
229 catch {
230 Write-Warning "Failed to parse YAML frontmatter in $(Split-Path -Leaf $FilePath): $_"
231 }
232 }
233
234 return @{
235 description = if ($description) { $description } else { $FallbackDescription }
236 }
237}
238
239function Resolve-CollectionItemMaturity {
240 <#
241 .SYNOPSIS
242 Resolves effective maturity from collection item metadata.
243
244 .DESCRIPTION
245 Returns stable when maturity is omitted; otherwise returns the provided
246 maturity string.
247
248 .PARAMETER Maturity
249 Optional maturity value from a collection item.
250
251 .OUTPUTS
252 [string] Effective maturity value.
253 #>
254 [CmdletBinding()]
255 [OutputType([string])]
256 param(
257 [Parameter()]
258 [AllowNull()]
259 [AllowEmptyString()]
260 [string]$Maturity
261 )
262
263 if ([string]::IsNullOrWhiteSpace($Maturity)) {
264 return 'stable'
265 }
266
267 return $Maturity
268}
269
270function Get-AllCollections {
271 <#
272 .SYNOPSIS
273 Discovers and parses all .collection.yml files in a directory.
274
275 .DESCRIPTION
276 Scans the specified directory for files matching *.collection.yml and
277 parses each one into a hashtable via Get-CollectionManifest.
278
279 .PARAMETER CollectionsDir
280 Path to the directory containing .collection.yml files.
281
282 .OUTPUTS
283 [hashtable[]] Array of parsed collection manifests.
284 #>
285 [CmdletBinding()]
286 [OutputType([hashtable[]])]
287 param(
288 [Parameter(Mandatory = $true)]
289 [string]$CollectionsDir
290 )
291
292 $files = Get-ChildItem -Path $CollectionsDir -Filter '*.collection.yml' -File
293 $collections = @()
294
295 foreach ($file in $files) {
296 $manifest = Get-CollectionManifest -CollectionPath $file.FullName
297 $collections += $manifest
298 }
299
300 return $collections
301}
302
303# ---------------------------------------------------------------------------
304# I/O Functions (file system operations)
305# ---------------------------------------------------------------------------
306
307function Get-ArtifactFiles {
308 <#
309 .SYNOPSIS
310 Discovers all artifact files from .github/ directories.
311
312 .DESCRIPTION
313 Scans .github/agents/, .github/prompts/, .github/instructions/ (recursively),
314 and .github/skills/ to build a complete list of collection items. Returns
315 repo-relative paths with forward slashes.
316
317 .PARAMETER RepoRoot
318 Absolute path to the repository root directory.
319
320 .OUTPUTS
321 [hashtable[]] Array of hashtables with path and kind keys.
322 #>
323 [CmdletBinding()]
324 [OutputType([hashtable[]])]
325 param(
326 [Parameter(Mandatory = $true)]
327 [ValidateNotNullOrEmpty()]
328 [string]$RepoRoot
329 )
330
331 $items = @()
332
333 # AI artifacts discovered by .<kind>.md suffix under .github/
334 # Keep explicit suffix mapping only where naming differs from manifest kind values.
335 $gitHubDir = Join-Path -Path $RepoRoot -ChildPath '.github'
336 if (Test-Path -Path $gitHubDir) {
337 $suffixToKind = @{
338 instructions = 'instruction'
339 }
340
341 $artifactFiles = Get-ChildItem -Path $gitHubDir -Filter '*.*.md' -File -Recurse
342 foreach ($file in $artifactFiles) {
343 if ($file.Name -notmatch '\.(?<suffix>[^.]+)\.md$') {
344 continue
345 }
346
347 $suffix = $Matches['suffix'].ToLowerInvariant()
348 $kind = if ($suffixToKind.ContainsKey($suffix)) { $suffixToKind[$suffix] } else { $suffix }
349 $relativePath = [System.IO.Path]::GetRelativePath($RepoRoot, $file.FullName) -replace '\\', '/'
350
351 if (Test-HveCoreRepoRelativePath -Path $relativePath) {
352 continue
353 }
354 if (Test-DeprecatedPath -Path $relativePath) {
355 continue
356 }
357 $items += @{ path = $relativePath; kind = $kind }
358 }
359 }
360
361 # Skills (directories containing SKILL.md)
362 $skillsDir = Join-Path -Path $RepoRoot -ChildPath '.github/skills'
363 if (Test-Path -Path $skillsDir) {
364 $skillMdFiles = Get-ChildItem -Path $skillsDir -Filter 'SKILL.md' -File -Recurse
365 foreach ($skillFile in $skillMdFiles) {
366 $dir = $skillFile.Directory
367 $relativePath = [System.IO.Path]::GetRelativePath($RepoRoot, $dir.FullName) -replace '\\', '/'
368
369 if (Test-DeprecatedPath -Path $relativePath) {
370 continue
371 }
372 if (Test-HveCoreRepoRelativePath -Path $relativePath) {
373 continue
374 }
375
376 $items += @{ path = $relativePath; kind = 'skill' }
377 }
378 }
379
380 return $items
381}
382
383function Test-ArtifactDeprecated {
384 <#
385 .SYNOPSIS
386 Checks whether an artifact has maturity deprecated in collection metadata.
387
388 .DESCRIPTION
389 Reads maturity from the provided collection item metadata value and
390 returns $true when the effective value equals deprecated.
391
392 .PARAMETER Maturity
393 Optional maturity value from collection item metadata.
394
395 .OUTPUTS
396 [bool] True when the artifact is deprecated.
397 #>
398 [CmdletBinding()]
399 [OutputType([bool])]
400 param(
401 [Parameter()]
402 [AllowNull()]
403 [AllowEmptyString()]
404 [string]$Maturity
405 )
406
407 return ((Resolve-CollectionItemMaturity -Maturity $Maturity) -eq 'deprecated')
408}
409
410function Update-HveCoreAllCollection {
411 <#
412 .SYNOPSIS
413 Auto-updates hve-core-all.collection.yml with all non-deprecated artifacts.
414
415 .DESCRIPTION
416 Discovers all artifacts from .github/ directories, excludes deprecated items,
417 and rewrites the hve-core-all collection manifest. Preserves existing
418 metadata fields (id, name, description, tags, display).
419
420 .PARAMETER RepoRoot
421 Absolute path to the repository root directory.
422
423 .PARAMETER DryRun
424 When specified, logs changes without writing to disk.
425
426 .OUTPUTS
427 [hashtable] With ItemCount, AddedCount, RemovedCount, and DeprecatedCount keys.
428 #>
429 [CmdletBinding()]
430 [OutputType([hashtable])]
431 param(
432 [Parameter(Mandatory = $true)]
433 [ValidateNotNullOrEmpty()]
434 [string]$RepoRoot,
435
436 [Parameter(Mandatory = $false)]
437 [switch]$DryRun
438 )
439
440 $collectionPath = Join-Path -Path $RepoRoot -ChildPath 'collections/hve-core-all.collection.yml'
441
442 # Read existing manifest to preserve metadata
443 $existing = Get-CollectionManifest -CollectionPath $collectionPath
444 $existingPaths = @($existing.items | ForEach-Object { $_.path })
445
446 # Discover all artifacts
447 $allItems = Get-ArtifactFiles -RepoRoot $RepoRoot
448
449 # Exclude deprecated items by path (independent of maturity metadata)
450 $allItems = @($allItems | Where-Object { -not (Test-DeprecatedPath -Path $_.path) })
451
452 # Filter deprecated based on existing collection item maturity metadata
453 $existingItemMaturities = @{}
454 foreach ($existingItem in $existing.items) {
455 $existingKey = "$($existingItem.kind)|$($existingItem.path)"
456 $existingItemMaturities[$existingKey] = Resolve-CollectionItemMaturity -Maturity $existingItem.maturity
457 }
458
459 $deprecatedCount = 0
460 $filteredItems = @()
461 foreach ($item in $allItems) {
462 $itemKey = "$($item.kind)|$($item.path)"
463 $itemMaturity = 'stable'
464 if ($existingItemMaturities.ContainsKey($itemKey)) {
465 $itemMaturity = $existingItemMaturities[$itemKey]
466 }
467
468 if (Test-ArtifactDeprecated -Maturity $itemMaturity) {
469 $deprecatedCount++
470 Write-Verbose "Excluding deprecated: $($item.path)"
471 continue
472 }
473
474 $filteredItems += @{
475 path = $item.path
476 kind = $item.kind
477 maturity = $itemMaturity
478 }
479 }
480
481 # Sort: known kinds first, then any additional kinds, then by path
482 $kindOrder = @{ 'agent' = 0; 'prompt' = 1; 'instruction' = 2; 'skill' = 3 }
483 $sortedItems = $filteredItems | Sort-Object `
484 { if ($kindOrder.ContainsKey($_.kind)) { $kindOrder[$_.kind] } else { 100 } }, `
485 { $_.kind }, `
486 { $_.path }
487
488 # Build new items array as ordered hashtables for clean YAML output
489 $newItems = @()
490 foreach ($item in $sortedItems) {
491 $newItem = [ordered]@{
492 path = $item.path
493 kind = $item.kind
494 }
495
496 if ((Resolve-CollectionItemMaturity -Maturity $item.maturity) -ne 'stable') {
497 $newItem['maturity'] = $item.maturity
498 }
499
500 $newItems += $newItem
501 }
502
503 # Compute diff
504 $newPaths = @($sortedItems | ForEach-Object { $_.path })
505 $added = @($newPaths | Where-Object { $_ -notin $existingPaths })
506 $removed = @($existingPaths | Where-Object { $_ -notin $newPaths })
507
508 Write-Host "`n--- hve-core-all Auto-Update ---" -ForegroundColor Cyan
509 Write-Host " Discovered: $($allItems.Count) artifacts"
510 Write-Host " Deprecated: $deprecatedCount (excluded)"
511 Write-Host " Final: $($newItems.Count) items"
512 if ($added.Count -gt 0) {
513 Write-Host " Added: $($added -join ', ')" -ForegroundColor Green
514 }
515 if ($removed.Count -gt 0) {
516 Write-Host " Removed: $($removed -join ', ')" -ForegroundColor Yellow
517 }
518
519 if ($DryRun) {
520 Write-Host ' [DRY RUN] No changes written' -ForegroundColor Yellow
521 }
522 else {
523 # Rebuild manifest preserving metadata
524 $displayOrdered = [ordered]@{}
525 if ($existing.display.Contains('featured')) {
526 $displayOrdered['featured'] = $existing.display['featured']
527 }
528 if ($existing.display.Contains('ordering')) {
529 $displayOrdered['ordering'] = $existing.display['ordering']
530 }
531 $manifest = [ordered]@{
532 id = $existing.id
533 name = $existing.name
534 description = $existing.description
535 tags = $existing.tags
536 items = $newItems
537 display = $displayOrdered
538 }
539
540 $yaml = ConvertTo-Yaml -Data $manifest
541 Set-Content -Path $collectionPath -Value $yaml -Encoding utf8 -NoNewline
542 Write-Verbose "Updated $collectionPath"
543 }
544
545 return @{
546 ItemCount = $newItems.Count
547 AddedCount = $added.Count
548 RemovedCount = $removed.Count
549 DeprecatedCount = $deprecatedCount
550 }
551}
552
553Export-ModuleMember -Function @(
554 'Get-AllCollections',
555 'Get-ArtifactFiles',
556 'Get-ArtifactFrontmatter',
557 'Get-CollectionArtifactKey',
558 'Get-CollectionManifest',
559 'Resolve-CollectionItemMaturity',
560 'Test-ArtifactDeprecated',
561 'Test-DeprecatedPath',
562 'Test-HveCoreRepoRelativePath',
563 'Test-HveCoreRepoSpecificPath',
564 'Update-HveCoreAllCollection'
565)
566