microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
docs/foundation-governance-overlay

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/collections/Validate-Collections.ps1

470lines · modecode

1#!/usr/bin/env pwsh
2# Copyright (c) Microsoft Corporation.
3# SPDX-License-Identifier: MIT
4#Requires -Version 7.0
5
6<#
7.SYNOPSIS
8 Validates collection manifests for Copilot CLI plugin generation.
9
10.DESCRIPTION
11 Reads all .collection.yml files from collections/ and validates structure,
12 required fields, artifact path existence, and kind-suffix consistency.
13
14.EXAMPLE
15 ./Validate-Collections.ps1
16#>
17
18[CmdletBinding()]
19param()
20
21$ErrorActionPreference = 'Stop'
22
23Import-Module (Join-Path $PSScriptRoot 'Modules/CollectionHelpers.psm1') -Force
24Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force
25
26#region Validation Helpers
27
28function Test-KindSuffix {
29 <#
30 .SYNOPSIS
31 Validates that an item path matches its declared kind suffix.
32
33 .DESCRIPTION
34 Checks kind-suffix consistency: agent files end with .agent.md,
35 prompt files with .prompt.md, instruction files with .instructions.md,
36 and skill items are directories containing a SKILL.md file.
37
38 .PARAMETER Kind
39 The declared artifact kind (agent, prompt, instruction, skill).
40
41 .PARAMETER ItemPath
42 The relative path from the collection manifest.
43
44 .PARAMETER RepoRoot
45 Absolute path to the repository root for skill directory checks.
46
47 .OUTPUTS
48 [string] Error message if validation fails, empty string if valid.
49 #>
50 [CmdletBinding()]
51 [OutputType([string])]
52 param(
53 [Parameter(Mandatory = $true)]
54 [string]$Kind,
55
56 [Parameter(Mandatory = $true)]
57 [string]$ItemPath,
58
59 [Parameter(Mandatory = $true)]
60 [string]$RepoRoot
61 )
62
63 switch ($Kind) {
64 'agent' {
65 if ($ItemPath -notmatch '\.agent\.md$') {
66 return "kind 'agent' expects *.agent.md but got '$ItemPath'"
67 }
68 }
69 'prompt' {
70 if ($ItemPath -notmatch '\.prompt\.md$') {
71 return "kind 'prompt' expects *.prompt.md but got '$ItemPath'"
72 }
73 }
74 'instruction' {
75 if ($ItemPath -notmatch '\.instructions\.md$') {
76 return "kind 'instruction' expects *.instructions.md but got '$ItemPath'"
77 }
78 }
79 'skill' {
80 $skillDir = Join-Path -Path $RepoRoot -ChildPath $ItemPath
81 $skillFile = Join-Path -Path $skillDir -ChildPath 'SKILL.md'
82 if (-not (Test-Path -Path $skillFile)) {
83 return "kind 'skill' expects SKILL.md inside '$ItemPath'"
84 }
85 }
86 }
87
88 return ''
89}
90
91function Get-CollectionItemKey {
92 <#
93 .SYNOPSIS
94 Builds a stable uniqueness key for collection items.
95
96 .DESCRIPTION
97 Uses kind and path to identify the same artifact across collections.
98
99 .PARAMETER Kind
100 Artifact kind.
101
102 .PARAMETER ItemPath
103 Artifact path.
104
105 .OUTPUTS
106 [string] Composite key.
107 #>
108 [CmdletBinding()]
109 [OutputType([string])]
110 param(
111 [Parameter(Mandatory = $true)]
112 [string]$Kind,
113
114 [Parameter(Mandatory = $true)]
115 [string]$ItemPath
116 )
117
118 return "$Kind|$ItemPath"
119}
120
121#endregion Validation Helpers
122
123#region Orchestration
124
125function Invoke-CollectionValidation {
126 <#
127 .SYNOPSIS
128 Validates all collection manifests for correctness.
129
130 .DESCRIPTION
131 Scans the collections/ directory for .collection.yml files and validates
132 each manifest for required fields (id, name, description, items), id
133 format, artifact path existence, kind-suffix consistency, and duplicate
134 ids across collections.
135
136 .PARAMETER RepoRoot
137 Absolute path to the repository root directory.
138
139 .OUTPUTS
140 Hashtable with Success bool, ErrorCount int, and CollectionCount int.
141 #>
142 [CmdletBinding()]
143 [OutputType([hashtable])]
144 param(
145 [Parameter(Mandatory = $true)]
146 [ValidateNotNullOrEmpty()]
147 [string]$RepoRoot
148 )
149
150 $collectionsDir = Join-Path -Path $RepoRoot -ChildPath 'collections'
151 $collectionFiles = Get-ChildItem -Path $collectionsDir -Filter '*.collection.yml' -File
152
153 if ($collectionFiles.Count -eq 0) {
154 Write-Host ' WARN No collection manifests found in collections/' -ForegroundColor Yellow
155 return @{ Success = $true; ErrorCount = 0; CollectionCount = 0 }
156 }
157
158 Write-Host 'Validating collections...'
159
160 $errorCount = 0
161 $seenIds = @{}
162 $validatedCount = 0
163 $allowedMaturities = @('stable', 'preview', 'experimental', 'deprecated', 'removed')
164 $canonicalCollectionId = 'hve-core-all'
165 $itemOccurrences = @{}
166
167 $knownCollectionIds = @{}
168 foreach ($cf in $collectionFiles) {
169 $cfId = $cf.Name -replace '\.collection\.yml$', ''
170 $knownCollectionIds[$cfId] = $true
171 }
172
173 # Sub-domain folders that group artifacts shared across multiple themed collections
174 # but are intentionally not collections themselves.
175 $sharedSubdomainFolders = @{
176 'shared' = $true
177 'rai-planning' = $true
178 }
179
180 foreach ($file in $collectionFiles) {
181 $baseName = $file.Name -replace '\.collection\.yml$', ''
182 $companionPath = Join-Path -Path $collectionsDir -ChildPath "$baseName.collection.md"
183 if (-not (Test-Path -Path $companionPath)) {
184 Write-Host " WARN $($file.Name): missing companion '$baseName.collection.md'" -ForegroundColor Yellow
185 }
186
187 if (Test-Path -Path $companionPath) {
188 $mdContent = Get-Content -Path $companionPath -Raw
189 $hasBegin = $mdContent.Contains($CollectionMdBeginMarker)
190 $hasEnd = $mdContent.Contains($CollectionMdEndMarker)
191
192 if ($hasBegin -xor $hasEnd) {
193 Write-Host " WARN $($file.Name): $baseName.collection.md has mismatched auto-generation markers" -ForegroundColor Yellow
194 }
195
196 if ($hasBegin -and $hasEnd) {
197 $beginIdx = $mdContent.IndexOf($CollectionMdBeginMarker)
198 $endIdx = $mdContent.IndexOf($CollectionMdEndMarker)
199 if ($endIdx -le $beginIdx) {
200 Write-Host " WARN $($file.Name): $baseName.collection.md has markers in wrong order" -ForegroundColor Yellow
201 }
202 }
203 }
204
205 $manifest = Get-CollectionManifest -CollectionPath $file.FullName
206 $fileErrors = @()
207 $seenItemKeys = @{}
208
209 # Required fields
210 $requiredFields = @('id', 'name', 'description', 'items')
211 foreach ($field in $requiredFields) {
212 if (-not $manifest.ContainsKey($field) -or $null -eq $manifest[$field]) {
213 $fileErrors += "missing required field '$field'"
214 }
215 }
216
217 # Skip further checks if required fields are absent
218 if ($fileErrors.Count -gt 0) {
219 foreach ($err in $fileErrors) {
220 Write-Host " x $($file.Name): $err" -ForegroundColor Red
221 }
222 $errorCount += $fileErrors.Count
223 continue
224 }
225
226 $id = $manifest.id
227
228 # Id format
229 if ($id -notmatch '^[a-z0-9-]+$') {
230 $fileErrors += "id '$id' must match ^[a-z0-9-]+$"
231 }
232
233 # Duplicate id check
234 if ($seenIds.ContainsKey($id)) {
235 $fileErrors += "duplicate id '$id' (also in $($seenIds[$id]))"
236 }
237 else {
238 $seenIds[$id] = $file.Name
239 }
240
241 # Validate collection-level maturity if present
242 if ($manifest.ContainsKey('maturity') -and -not [string]::IsNullOrWhiteSpace([string]$manifest.maturity)) {
243 $collMaturity = [string]$manifest.maturity
244 if ($allowedMaturities -notcontains $collMaturity) {
245 $fileErrors += "invalid collection maturity '$collMaturity' (allowed: $($allowedMaturities -join ', '))"
246 }
247 }
248
249 # Validate each item
250 $itemCount = $manifest.items.Count
251 foreach ($item in $manifest.items) {
252 $itemPath = $item.path
253 $kind = $item.kind
254 $absolutePath = Join-Path -Path $RepoRoot -ChildPath $itemPath
255 $itemMaturity = $null
256 if ($item.ContainsKey('maturity')) {
257 $itemMaturity = [string]$item.maturity
258 }
259 $effectiveMaturity = Resolve-CollectionItemMaturity -Maturity $itemMaturity
260
261 # Repo-specific path exclusion
262 if (Test-HveCoreRepoRelativePath -Path $itemPath) {
263 $fileErrors += "repo-specific path not allowed in collections: $itemPath (root-level artifacts under .github/{type}/ are excluded from distribution)"
264 }
265
266 # Path existence
267 if (-not (Test-Path -Path $absolutePath)) {
268 $fileErrors += "path not found: $itemPath"
269 }
270
271 # Kind-suffix consistency
272 if ($kind) {
273 $suffixError = Test-KindSuffix -Kind $kind -ItemPath $itemPath -RepoRoot $RepoRoot
274 if ($suffixError) {
275 $fileErrors += $suffixError
276 }
277 }
278 else {
279 $fileErrors += "item missing 'kind': $itemPath"
280 }
281
282 if (-not [string]::IsNullOrWhiteSpace($itemMaturity) -and ($allowedMaturities -notcontains $itemMaturity)) {
283 $fileErrors += "invalid maturity '$itemMaturity' for item '$itemPath' (allowed: $($allowedMaturities -join ', '))"
284 }
285
286 # Check 2: intra-collection duplicate detection
287 if (-not [string]::IsNullOrWhiteSpace($itemPath) -and -not [string]::IsNullOrWhiteSpace($kind)) {
288 $dupKey = Get-CollectionItemKey -Kind $kind -ItemPath $itemPath
289 if ($seenItemKeys.ContainsKey($dupKey)) {
290 $fileErrors += "duplicate item '$dupKey' appears more than once in collection '$id'"
291 } else {
292 $seenItemKeys[$dupKey] = $true
293 }
294 }
295
296 # Check 3: collection-id to folder name consistency
297 if ($id -ne 'hve-core-all') {
298 $pathSegments = $itemPath -split '[/\\]'
299 # Expected pattern: .github/{type}/{collection-id}/{file-or-deeper}
300 if ($pathSegments.Count -ge 4 -and $pathSegments[0] -eq '.github') {
301 $folderName = $pathSegments[2]
302 if (-not $sharedSubdomainFolders.ContainsKey($folderName) -and -not $knownCollectionIds.ContainsKey($folderName)) {
303 Write-Host " WARN collection '$id': item folder '$folderName' does not match any known collection ID: $itemPath" -ForegroundColor Yellow
304 }
305 }
306 }
307
308 if (-not [string]::IsNullOrWhiteSpace($itemPath) -and -not [string]::IsNullOrWhiteSpace($kind)) {
309 $itemKey = Get-CollectionItemKey -Kind $kind -ItemPath $itemPath
310 if (-not $itemOccurrences.ContainsKey($itemKey)) {
311 $itemOccurrences[$itemKey] = @()
312 }
313
314 $itemOccurrences[$itemKey] += @{
315 CollectionId = $id
316 CollectionFile = $file.Name
317 Kind = $kind
318 Path = $itemPath
319 Maturity = $effectiveMaturity
320 }
321 }
322
323 # Informational log for instruction items
324 if ($kind -eq 'instruction') {
325 Write-Verbose " instruction: $itemPath"
326 }
327 }
328
329 if ($fileErrors.Count -gt 0) {
330 Write-Host " FAIL $id ($itemCount items) - $($fileErrors.Count) error(s)" -ForegroundColor Red
331 foreach ($err in $fileErrors) {
332 Write-Host " $err" -ForegroundColor Red
333 }
334 $errorCount += $fileErrors.Count
335 }
336 else {
337 Write-Host " OK $id ($itemCount items)"
338 }
339
340 $validatedCount++
341 }
342
343 $canonicalManifestFound = ($collectionFiles | Where-Object {
344 ($_.Name -replace '\.collection\.yml$', '') -eq $canonicalCollectionId
345 }).Count -gt 0
346 if (-not $canonicalManifestFound) {
347 Write-Host " WARN '$canonicalCollectionId.collection.yml' not found; skipping orphan and cross-collection coverage checks" -ForegroundColor Yellow
348 }
349
350 # Duplicate artifact key detection across all collections
351 $artifactKeyMap = @{}
352 foreach ($itemKey in $itemOccurrences.Keys) {
353 $occurrences = $itemOccurrences[$itemKey]
354 $first = $occurrences[0]
355 $artifactKey = Get-CollectionArtifactKey -Kind $first.Kind -Path $first.Path
356 $compositeKey = "$($first.Kind)|$artifactKey"
357
358 if (-not $artifactKeyMap.ContainsKey($compositeKey)) {
359 $artifactKeyMap[$compositeKey] = @()
360 }
361 if ($artifactKeyMap[$compositeKey] -notcontains $first.Path) {
362 $artifactKeyMap[$compositeKey] += $first.Path
363 }
364 }
365
366 foreach ($compositeKey in $artifactKeyMap.Keys) {
367 $paths = $artifactKeyMap[$compositeKey]
368 if ($paths.Count -gt 1) {
369 $kindLabel = ($compositeKey -split '\|')[0]
370 $nameLabel = ($compositeKey -split '\|')[1]
371 $pathList = ($paths | Sort-Object) -join ', '
372 Write-Host " FAIL duplicate $kindLabel artifact key '$nameLabel' found at distinct paths: $pathList" -ForegroundColor Red
373 $errorCount++
374 }
375 }
376
377 foreach ($itemKey in $itemOccurrences.Keys) {
378 $occurrences = $itemOccurrences[$itemKey]
379 $canonicalMatches = @($occurrences | Where-Object { $_.CollectionId -eq $canonicalCollectionId })
380 $themedMatches = @($occurrences | Where-Object { $_.CollectionId -ne $canonicalCollectionId })
381
382 # Check 4: item in one or more themed collections but absent from hve-core-all
383 # Skip when all themed occurrences are marked maturity:'removed' (intentional tombstone
384 # excluded from hve-core-all by Update-HveCoreAllCollection).
385 $activeThemedMatches = @($themedMatches | Where-Object { $_.Maturity -ne 'removed' })
386 if ($canonicalManifestFound -and $activeThemedMatches.Count -gt 0 -and $canonicalMatches.Count -eq 0) {
387 $themedCollections = ($activeThemedMatches | ForEach-Object { $_.CollectionId } | Sort-Object -Unique) -join ', '
388 Write-Host " FAIL item '$itemKey' exists in themed collection(s) [$themedCollections] but is absent from '$canonicalCollectionId'" -ForegroundColor Red
389 $errorCount++
390 continue
391 }
392
393 # Maturity conflict: only when item appears in canonical AND at least one themed
394 if ($canonicalMatches.Count -gt 0 -and $themedMatches.Count -gt 0) {
395 $canonical = $canonicalMatches[0]
396 foreach ($occurrence in $themedMatches) {
397 if ($occurrence.Maturity -ne $canonical.Maturity) {
398 Write-Host " FAIL maturity conflict for '$itemKey': canonical '$canonicalCollectionId'='$($canonical.Maturity)', '$($occurrence.CollectionId)'='$($occurrence.Maturity)'" -ForegroundColor Red
399 $errorCount++
400 }
401 }
402 }
403 }
404
405 if ($canonicalManifestFound) {
406 # Check 1: Orphan artifact detection
407 $onDiskArtifacts = Get-ArtifactFiles -RepoRoot $RepoRoot
408 foreach ($artifact in $onDiskArtifacts) {
409 $diskKey = Get-CollectionItemKey -Kind $artifact.kind -ItemPath $artifact.path
410 $occurrences = if ($itemOccurrences.ContainsKey($diskKey)) { $itemOccurrences[$diskKey] } else { @() }
411
412 $inCanonical = @($occurrences | Where-Object { $_.CollectionId -eq $canonicalCollectionId }).Count -gt 0
413 $inThemed = @($occurrences | Where-Object { $_.CollectionId -ne $canonicalCollectionId }).Count -gt 0
414
415 if (-not $inCanonical) {
416 # Skip orphan failure when all themed occurrences are tombstoned (maturity:'removed').
417 $themedActive = @($occurrences | Where-Object { $_.CollectionId -ne $canonicalCollectionId -and $_.Maturity -ne 'removed' }).Count -gt 0
418 $themedRemoved = @($occurrences | Where-Object { $_.CollectionId -ne $canonicalCollectionId -and $_.Maturity -eq 'removed' }).Count -gt 0
419 if ($themedRemoved -and -not $themedActive) {
420 Write-Verbose "Skipping orphan check for tombstoned item '$diskKey'"
421 } else {
422 Write-Host " FAIL orphan: '$diskKey' is on disk but absent from '$canonicalCollectionId'" -ForegroundColor Red
423 $errorCount++
424 }
425 } elseif (-not $inThemed) {
426 Write-Host " WARN '$diskKey' exists in '$canonicalCollectionId' but is not in any themed collection" -ForegroundColor Yellow
427 }
428 }
429 }
430
431 Write-Host ''
432 Write-Host "$validatedCount collections validated, $errorCount errors"
433
434 return @{
435 Success = ($errorCount -eq 0)
436 ErrorCount = $errorCount
437 CollectionCount = $validatedCount
438 }
439}
440
441#endregion Orchestration
442
443#region Main Execution
444if ($MyInvocation.InvocationName -ne '.') {
445 try {
446 # Verify PowerShell-Yaml module
447 if (-not (Get-Module -ListAvailable -Name PowerShell-Yaml)) {
448 throw "Required module 'PowerShell-Yaml' is not installed."
449 }
450 Import-Module PowerShell-Yaml -ErrorAction Stop
451
452 # Resolve paths
453 $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
454 $RepoRoot = (Get-Item "$ScriptDir/../..").FullName
455
456 $result = Invoke-CollectionValidation -RepoRoot $RepoRoot
457
458 if (-not $result.Success) {
459 throw "Validation failed with $($result.ErrorCount) error(s)."
460 }
461
462 exit 0
463 }
464 catch {
465 Write-Error "Collection validation failed: $($_.Exception.Message)"
466 Write-CIAnnotation -Message $_.Exception.Message -Level Error
467 exit 1
468 }
469}
470#endregion
471