microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/networking-agent

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/collections/Modules/CollectionHelpers.psm1

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