microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
3cade0d7dd510bcfa350a1dc9512159a1f60ae4f

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/collections/Validate-Collections.ps1

417lines · 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-Warning 'No collection manifests found in collections/'
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')
164 $canonicalCollectionId = 'hve-core-all'
165 $itemOccurrences = @{}
166
167 foreach ($file in $collectionFiles) {
168 $baseName = $file.Name -replace '\.collection\.yml$', ''
169 $companionPath = Join-Path -Path $collectionsDir -ChildPath "$baseName.collection.md"
170 if (-not (Test-Path -Path $companionPath)) {
171 Write-Host " WARN $($file.Name): missing companion '$baseName.collection.md'" -ForegroundColor Yellow
172 }
173
174 $manifest = Get-CollectionManifest -CollectionPath $file.FullName
175 $fileErrors = @()
176 $seenItemKeys = @{}
177
178 # Required fields
179 $requiredFields = @('id', 'name', 'description', 'items')
180 foreach ($field in $requiredFields) {
181 if (-not $manifest.ContainsKey($field) -or $null -eq $manifest[$field]) {
182 $fileErrors += "missing required field '$field'"
183 }
184 }
185
186 # Skip further checks if required fields are absent
187 if ($fileErrors.Count -gt 0) {
188 foreach ($err in $fileErrors) {
189 Write-Host " x $($file.Name): $err" -ForegroundColor Red
190 }
191 $errorCount += $fileErrors.Count
192 continue
193 }
194
195 $id = $manifest.id
196
197 # Id format
198 if ($id -notmatch '^[a-z0-9-]+$') {
199 $fileErrors += "id '$id' must match ^[a-z0-9-]+$"
200 }
201
202 # Duplicate id check
203 if ($seenIds.ContainsKey($id)) {
204 $fileErrors += "duplicate id '$id' (also in $($seenIds[$id]))"
205 }
206 else {
207 $seenIds[$id] = $file.Name
208 }
209
210 # Validate collection-level maturity if present
211 if ($manifest.ContainsKey('maturity') -and -not [string]::IsNullOrWhiteSpace([string]$manifest.maturity)) {
212 $collMaturity = [string]$manifest.maturity
213 if ($allowedMaturities -notcontains $collMaturity) {
214 $fileErrors += "invalid collection maturity '$collMaturity' (allowed: $($allowedMaturities -join ', '))"
215 }
216 }
217
218 # Validate each item
219 $itemCount = $manifest.items.Count
220 foreach ($item in $manifest.items) {
221 $itemPath = $item.path
222 $kind = $item.kind
223 $absolutePath = Join-Path -Path $RepoRoot -ChildPath $itemPath
224 $itemMaturity = $null
225 if ($item.ContainsKey('maturity')) {
226 $itemMaturity = [string]$item.maturity
227 }
228 $effectiveMaturity = Resolve-CollectionItemMaturity -Maturity $itemMaturity
229
230 # Repo-specific path exclusion
231 if (Test-HveCoreRepoRelativePath -Path $itemPath) {
232 $fileErrors += "repo-specific path not allowed in collections: $itemPath (root-level artifacts under .github/{type}/ are excluded from distribution)"
233 }
234
235 # Path existence
236 if (-not (Test-Path -Path $absolutePath)) {
237 $fileErrors += "path not found: $itemPath"
238 }
239
240 # Kind-suffix consistency
241 if ($kind) {
242 $suffixError = Test-KindSuffix -Kind $kind -ItemPath $itemPath -RepoRoot $RepoRoot
243 if ($suffixError) {
244 $fileErrors += $suffixError
245 }
246 }
247 else {
248 $fileErrors += "item missing 'kind': $itemPath"
249 }
250
251 if (-not [string]::IsNullOrWhiteSpace($itemMaturity) -and ($allowedMaturities -notcontains $itemMaturity)) {
252 $fileErrors += "invalid maturity '$itemMaturity' for item '$itemPath' (allowed: $($allowedMaturities -join ', '))"
253 }
254
255 # Check 2: intra-collection duplicate detection
256 if (-not [string]::IsNullOrWhiteSpace($itemPath) -and -not [string]::IsNullOrWhiteSpace($kind)) {
257 $dupKey = Get-CollectionItemKey -Kind $kind -ItemPath $itemPath
258 if ($seenItemKeys.ContainsKey($dupKey)) {
259 $fileErrors += "duplicate item '$dupKey' appears more than once in collection '$id'"
260 } else {
261 $seenItemKeys[$dupKey] = $true
262 }
263 }
264
265 if (-not [string]::IsNullOrWhiteSpace($itemPath) -and -not [string]::IsNullOrWhiteSpace($kind)) {
266 $itemKey = Get-CollectionItemKey -Kind $kind -ItemPath $itemPath
267 if (-not $itemOccurrences.ContainsKey($itemKey)) {
268 $itemOccurrences[$itemKey] = @()
269 }
270
271 $itemOccurrences[$itemKey] += @{
272 CollectionId = $id
273 CollectionFile = $file.Name
274 Kind = $kind
275 Path = $itemPath
276 Maturity = $effectiveMaturity
277 }
278 }
279
280 # Informational log for instruction items
281 if ($kind -eq 'instruction') {
282 Write-Verbose " instruction: $itemPath"
283 }
284 }
285
286 if ($fileErrors.Count -gt 0) {
287 Write-Host " FAIL $id ($itemCount items) - $($fileErrors.Count) error(s)" -ForegroundColor Red
288 foreach ($err in $fileErrors) {
289 Write-Host " $err" -ForegroundColor Red
290 }
291 $errorCount += $fileErrors.Count
292 }
293 else {
294 Write-Host " OK $id ($itemCount items)"
295 }
296
297 $validatedCount++
298 }
299
300 $canonicalManifestFound = ($collectionFiles | Where-Object {
301 ($_.Name -replace '\.collection\.yml$', '') -eq $canonicalCollectionId
302 }).Count -gt 0
303 if (-not $canonicalManifestFound) {
304 Write-Host " WARN '$canonicalCollectionId.collection.yml' not found; skipping orphan and cross-collection coverage checks" -ForegroundColor Yellow
305 }
306
307 # Duplicate artifact key detection across all collections
308 $artifactKeyMap = @{}
309 foreach ($itemKey in $itemOccurrences.Keys) {
310 $occurrences = $itemOccurrences[$itemKey]
311 $first = $occurrences[0]
312 $artifactKey = Get-CollectionArtifactKey -Kind $first.Kind -Path $first.Path
313 $compositeKey = "$($first.Kind)|$artifactKey"
314
315 if (-not $artifactKeyMap.ContainsKey($compositeKey)) {
316 $artifactKeyMap[$compositeKey] = @()
317 }
318 if ($artifactKeyMap[$compositeKey] -notcontains $first.Path) {
319 $artifactKeyMap[$compositeKey] += $first.Path
320 }
321 }
322
323 foreach ($compositeKey in $artifactKeyMap.Keys) {
324 $paths = $artifactKeyMap[$compositeKey]
325 if ($paths.Count -gt 1) {
326 $kindLabel = ($compositeKey -split '\|')[0]
327 $nameLabel = ($compositeKey -split '\|')[1]
328 $pathList = ($paths | Sort-Object) -join ', '
329 Write-Host " FAIL duplicate $kindLabel artifact key '$nameLabel' found at distinct paths: $pathList" -ForegroundColor Red
330 $errorCount++
331 }
332 }
333
334 foreach ($itemKey in $itemOccurrences.Keys) {
335 $occurrences = $itemOccurrences[$itemKey]
336 $canonicalMatches = @($occurrences | Where-Object { $_.CollectionId -eq $canonicalCollectionId })
337 $themedMatches = @($occurrences | Where-Object { $_.CollectionId -ne $canonicalCollectionId })
338
339 # Check 4: item in one or more themed collections but absent from hve-core-all
340 if ($canonicalManifestFound -and $themedMatches.Count -gt 0 -and $canonicalMatches.Count -eq 0) {
341 $themedCollections = ($themedMatches | ForEach-Object { $_.CollectionId } | Sort-Object -Unique) -join ', '
342 Write-Host " FAIL item '$itemKey' exists in themed collection(s) [$themedCollections] but is absent from '$canonicalCollectionId'" -ForegroundColor Red
343 $errorCount++
344 continue
345 }
346
347 # Maturity conflict: only when item appears in canonical AND at least one themed
348 if ($canonicalMatches.Count -gt 0 -and $themedMatches.Count -gt 0) {
349 $canonical = $canonicalMatches[0]
350 foreach ($occurrence in $themedMatches) {
351 if ($occurrence.Maturity -ne $canonical.Maturity) {
352 Write-Host " FAIL maturity conflict for '$itemKey': canonical '$canonicalCollectionId'='$($canonical.Maturity)', '$($occurrence.CollectionId)'='$($occurrence.Maturity)'" -ForegroundColor Red
353 $errorCount++
354 }
355 }
356 }
357 }
358
359 if ($canonicalManifestFound) {
360 # Check 1: Orphan artifact detection
361 $onDiskArtifacts = Get-ArtifactFiles -RepoRoot $RepoRoot
362 foreach ($artifact in $onDiskArtifacts) {
363 $diskKey = Get-CollectionItemKey -Kind $artifact.kind -ItemPath $artifact.path
364 $occurrences = if ($itemOccurrences.ContainsKey($diskKey)) { $itemOccurrences[$diskKey] } else { @() }
365
366 $inCanonical = @($occurrences | Where-Object { $_.CollectionId -eq $canonicalCollectionId }).Count -gt 0
367 $inThemed = @($occurrences | Where-Object { $_.CollectionId -ne $canonicalCollectionId }).Count -gt 0
368
369 if (-not $inCanonical) {
370 Write-Host " FAIL orphan: '$diskKey' is on disk but absent from '$canonicalCollectionId'" -ForegroundColor Red
371 $errorCount++
372 } elseif (-not $inThemed) {
373 Write-Host " WARN '$diskKey' exists in '$canonicalCollectionId' but is not in any themed collection" -ForegroundColor Yellow
374 }
375 }
376 }
377
378 Write-Host ''
379 Write-Host "$validatedCount collections validated, $errorCount errors"
380
381 return @{
382 Success = ($errorCount -eq 0)
383 ErrorCount = $errorCount
384 CollectionCount = $validatedCount
385 }
386}
387
388#endregion Orchestration
389
390#region Main Execution
391if ($MyInvocation.InvocationName -ne '.') {
392 try {
393 # Verify PowerShell-Yaml module
394 if (-not (Get-Module -ListAvailable -Name PowerShell-Yaml)) {
395 throw "Required module 'PowerShell-Yaml' is not installed."
396 }
397 Import-Module PowerShell-Yaml -ErrorAction Stop
398
399 # Resolve paths
400 $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
401 $RepoRoot = (Get-Item "$ScriptDir/../..").FullName
402
403 $result = Invoke-CollectionValidation -RepoRoot $RepoRoot
404
405 if (-not $result.Success) {
406 throw "Validation failed with $($result.ErrorCount) error(s)."
407 }
408
409 exit 0
410 }
411 catch {
412 Write-Error "Collection validation failed: $($_.Exception.Message)"
413 Write-CIAnnotation -Message $_.Exception.Message -Level Error
414 exit 1
415 }
416}
417#endregion
418