microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
fix/1359-pip-audit

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/collections/Modules/CollectionHelpers.psm1

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