microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/update-workflow-file-and-script

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/plugins/Validate-Collections.ps1

406lines · 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/PluginHelpers.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 Resolve-ItemMaturity {
92 <#
93 .SYNOPSIS
94 Resolves an item's effective maturity value.
95
96 .DESCRIPTION
97 Returns 'stable' when maturity is omitted; otherwise returns the
98 provided maturity string.
99
100 .PARAMETER Maturity
101 Optional maturity string from collection item metadata.
102
103 .OUTPUTS
104 [string] Effective maturity value.
105 #>
106 [CmdletBinding()]
107 [OutputType([string])]
108 param(
109 [Parameter()]
110 [AllowNull()]
111 [string]$Maturity
112 )
113
114 if ([string]::IsNullOrWhiteSpace($Maturity)) {
115 return 'stable'
116 }
117
118 return $Maturity
119}
120
121function Get-CollectionItemKey {
122 <#
123 .SYNOPSIS
124 Builds a stable uniqueness key for collection items.
125
126 .DESCRIPTION
127 Uses kind and path to identify the same artifact across collections.
128
129 .PARAMETER Kind
130 Artifact kind.
131
132 .PARAMETER ItemPath
133 Artifact path.
134
135 .OUTPUTS
136 [string] Composite key.
137 #>
138 [CmdletBinding()]
139 [OutputType([string])]
140 param(
141 [Parameter(Mandatory = $true)]
142 [string]$Kind,
143
144 [Parameter(Mandatory = $true)]
145 [string]$ItemPath
146 )
147
148 return "$Kind|$ItemPath"
149}
150
151#endregion Validation Helpers
152
153#region Orchestration
154
155function Invoke-CollectionValidation {
156 <#
157 .SYNOPSIS
158 Validates all collection manifests for correctness.
159
160 .DESCRIPTION
161 Scans the collections/ directory for .collection.yml files and validates
162 each manifest for required fields (id, name, description, items), id
163 format, artifact path existence, kind-suffix consistency, and duplicate
164 ids across collections.
165
166 .PARAMETER RepoRoot
167 Absolute path to the repository root directory.
168
169 .OUTPUTS
170 Hashtable with Success bool, ErrorCount int, and CollectionCount int.
171 #>
172 [CmdletBinding()]
173 [OutputType([hashtable])]
174 param(
175 [Parameter(Mandatory = $true)]
176 [ValidateNotNullOrEmpty()]
177 [string]$RepoRoot
178 )
179
180 $collectionsDir = Join-Path -Path $RepoRoot -ChildPath 'collections'
181 $collectionFiles = Get-ChildItem -Path $collectionsDir -Filter '*.collection.yml' -File
182
183 if ($collectionFiles.Count -eq 0) {
184 Write-Warning 'No collection manifests found in collections/'
185 return @{ Success = $true; ErrorCount = 0; CollectionCount = 0 }
186 }
187
188 Write-Host 'Validating collections...'
189
190 $errorCount = 0
191 $seenIds = @{}
192 $validatedCount = 0
193 $allowedMaturities = @('stable', 'preview', 'experimental', 'deprecated')
194 $canonicalCollectionId = 'hve-core-all'
195 $itemOccurrences = @{}
196
197 foreach ($file in $collectionFiles) {
198 $manifest = Get-CollectionManifest -CollectionPath $file.FullName
199 $fileErrors = @()
200
201 # Required fields
202 $requiredFields = @('id', 'name', 'description', 'items')
203 foreach ($field in $requiredFields) {
204 if (-not $manifest.ContainsKey($field) -or $null -eq $manifest[$field]) {
205 $fileErrors += "missing required field '$field'"
206 }
207 }
208
209 # Skip further checks if required fields are absent
210 if ($fileErrors.Count -gt 0) {
211 foreach ($err in $fileErrors) {
212 Write-Host " x $($file.Name): $err" -ForegroundColor Red
213 }
214 $errorCount += $fileErrors.Count
215 continue
216 }
217
218 $id = $manifest.id
219
220 # Id format
221 if ($id -notmatch '^[a-z0-9-]+$') {
222 $fileErrors += "id '$id' must match ^[a-z0-9-]+$"
223 }
224
225 # Duplicate id check
226 if ($seenIds.ContainsKey($id)) {
227 $fileErrors += "duplicate id '$id' (also in $($seenIds[$id]))"
228 }
229 else {
230 $seenIds[$id] = $file.Name
231 }
232
233 # Validate collection-level maturity if present
234 if ($manifest.ContainsKey('maturity') -and -not [string]::IsNullOrWhiteSpace([string]$manifest.maturity)) {
235 $collMaturity = [string]$manifest.maturity
236 if ($allowedMaturities -notcontains $collMaturity) {
237 $fileErrors += "invalid collection maturity '$collMaturity' (allowed: $($allowedMaturities -join ', '))"
238 }
239 }
240
241 # Validate each item
242 $itemCount = $manifest.items.Count
243 foreach ($item in $manifest.items) {
244 $itemPath = $item.path
245 $kind = $item.kind
246 $absolutePath = Join-Path -Path $RepoRoot -ChildPath $itemPath
247 $itemMaturity = $null
248 if ($item.ContainsKey('maturity')) {
249 $itemMaturity = [string]$item.maturity
250 }
251 $effectiveMaturity = Resolve-ItemMaturity -Maturity $itemMaturity
252
253 # Repo-specific path exclusion
254 if (Test-HveCoreRepoRelativePath -Path $itemPath) {
255 $fileErrors += "repo-specific path not allowed in collections: $itemPath (root-level artifacts under .github/{type}/ are excluded from distribution)"
256 }
257
258 # Path existence
259 if (-not (Test-Path -Path $absolutePath)) {
260 $fileErrors += "path not found: $itemPath"
261 }
262
263 # Kind-suffix consistency
264 if ($kind) {
265 $suffixError = Test-KindSuffix -Kind $kind -ItemPath $itemPath -RepoRoot $RepoRoot
266 if ($suffixError) {
267 $fileErrors += $suffixError
268 }
269 }
270 else {
271 $fileErrors += "item missing 'kind': $itemPath"
272 }
273
274 if (-not [string]::IsNullOrWhiteSpace($itemMaturity) -and ($allowedMaturities -notcontains $itemMaturity)) {
275 $fileErrors += "invalid maturity '$itemMaturity' for item '$itemPath' (allowed: $($allowedMaturities -join ', '))"
276 }
277
278 if (-not [string]::IsNullOrWhiteSpace($itemPath) -and -not [string]::IsNullOrWhiteSpace($kind)) {
279 $itemKey = Get-CollectionItemKey -Kind $kind -ItemPath $itemPath
280 if (-not $itemOccurrences.ContainsKey($itemKey)) {
281 $itemOccurrences[$itemKey] = @()
282 }
283
284 $itemOccurrences[$itemKey] += @{
285 CollectionId = $id
286 CollectionFile = $file.Name
287 Kind = $kind
288 Path = $itemPath
289 Maturity = $effectiveMaturity
290 }
291 }
292
293 # Informational log for instruction items
294 if ($kind -eq 'instruction') {
295 Write-Verbose " instruction: $itemPath"
296 }
297 }
298
299 if ($fileErrors.Count -gt 0) {
300 Write-Host " FAIL $id ($itemCount items) - $($fileErrors.Count) error(s)" -ForegroundColor Red
301 foreach ($err in $fileErrors) {
302 Write-Host " $err" -ForegroundColor Red
303 }
304 $errorCount += $fileErrors.Count
305 }
306 else {
307 Write-Host " OK $id ($itemCount items)"
308 }
309
310 $validatedCount++
311 }
312
313 # Duplicate artifact key detection across all collections
314 $artifactKeyMap = @{}
315 foreach ($itemKey in $itemOccurrences.Keys) {
316 $occurrences = $itemOccurrences[$itemKey]
317 $first = $occurrences[0]
318 $artifactKey = Get-CollectionArtifactKey -Kind $first.Kind -Path $first.Path
319 $compositeKey = "$($first.Kind)|$artifactKey"
320
321 if (-not $artifactKeyMap.ContainsKey($compositeKey)) {
322 $artifactKeyMap[$compositeKey] = @()
323 }
324 if ($artifactKeyMap[$compositeKey] -notcontains $first.Path) {
325 $artifactKeyMap[$compositeKey] += $first.Path
326 }
327 }
328
329 foreach ($compositeKey in $artifactKeyMap.Keys) {
330 $paths = $artifactKeyMap[$compositeKey]
331 if ($paths.Count -gt 1) {
332 $kindLabel = ($compositeKey -split '\|')[0]
333 $nameLabel = ($compositeKey -split '\|')[1]
334 $pathList = ($paths | Sort-Object) -join ', '
335 Write-Host " FAIL duplicate $kindLabel artifact key '$nameLabel' found at distinct paths: $pathList" -ForegroundColor Red
336 $errorCount++
337 }
338 }
339
340 foreach ($itemKey in $itemOccurrences.Keys) {
341 $occurrences = $itemOccurrences[$itemKey]
342 if ($occurrences.Count -le 1) {
343 continue
344 }
345
346 $canonicalMatches = @($occurrences | Where-Object { $_.CollectionId -eq $canonicalCollectionId })
347 if ($canonicalMatches.Count -eq 0) {
348 $sharedCollections = ($occurrences | ForEach-Object { $_.CollectionId } | Sort-Object -Unique) -join ', '
349 Write-Host " FAIL shared item '$itemKey' exists in collections [$sharedCollections] but has no canonical entry in '$canonicalCollectionId'" -ForegroundColor Red
350 $errorCount++
351 continue
352 }
353
354 $canonical = $canonicalMatches[0]
355 foreach ($occurrence in $occurrences) {
356 if ($occurrence.CollectionId -eq $canonicalCollectionId) {
357 continue
358 }
359
360 if ($occurrence.Maturity -ne $canonical.Maturity) {
361 Write-Host " FAIL maturity conflict for '$itemKey': canonical '$canonicalCollectionId'='$($canonical.Maturity)', '$($occurrence.CollectionId)'='$($occurrence.Maturity)'" -ForegroundColor Red
362 $errorCount++
363 }
364 }
365 }
366
367 Write-Host ''
368 Write-Host "$validatedCount collections validated, $errorCount errors"
369
370 return @{
371 Success = ($errorCount -eq 0)
372 ErrorCount = $errorCount
373 CollectionCount = $validatedCount
374 }
375}
376
377#endregion Orchestration
378
379#region Main Execution
380if ($MyInvocation.InvocationName -ne '.') {
381 try {
382 # Verify PowerShell-Yaml module
383 if (-not (Get-Module -ListAvailable -Name PowerShell-Yaml)) {
384 throw "Required module 'PowerShell-Yaml' is not installed."
385 }
386 Import-Module PowerShell-Yaml -ErrorAction Stop
387
388 # Resolve paths
389 $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
390 $RepoRoot = (Get-Item "$ScriptDir/../..").FullName
391
392 $result = Invoke-CollectionValidation -RepoRoot $RepoRoot
393
394 if (-not $result.Success) {
395 throw "Validation failed with $($result.ErrorCount) error(s)."
396 }
397
398 exit 0
399 }
400 catch {
401 Write-Error "Collection validation failed: $($_.Exception.Message)"
402 Write-CIAnnotation -Message $_.Exception.Message -Level Error
403 exit 1
404 }
405}
406#endregion
407