microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat-ds-agent

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/plugins/Validate-Collections.ps1

371lines · 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 each item
234 $itemCount = $manifest.items.Count
235 foreach ($item in $manifest.items) {
236 $itemPath = $item.path
237 $kind = $item.kind
238 $absolutePath = Join-Path -Path $RepoRoot -ChildPath $itemPath
239 $itemMaturity = $null
240 if ($item.ContainsKey('maturity')) {
241 $itemMaturity = [string]$item.maturity
242 }
243 $effectiveMaturity = Resolve-ItemMaturity -Maturity $itemMaturity
244
245 # Repo-specific path exclusion
246 if ($itemPath -match '^\.github/.*/hve-core/') {
247 $fileErrors += "repo-specific path not allowed in collections: $itemPath (artifacts under .github/**/hve-core/ are excluded from distribution)"
248 }
249
250 # Path existence
251 if (-not (Test-Path -Path $absolutePath)) {
252 $fileErrors += "path not found: $itemPath"
253 }
254
255 # Kind-suffix consistency
256 if ($kind) {
257 $suffixError = Test-KindSuffix -Kind $kind -ItemPath $itemPath -RepoRoot $RepoRoot
258 if ($suffixError) {
259 $fileErrors += $suffixError
260 }
261 }
262 else {
263 $fileErrors += "item missing 'kind': $itemPath"
264 }
265
266 if (-not [string]::IsNullOrWhiteSpace($itemMaturity) -and ($allowedMaturities -notcontains $itemMaturity)) {
267 $fileErrors += "invalid maturity '$itemMaturity' for item '$itemPath' (allowed: $($allowedMaturities -join ', '))"
268 }
269
270 if (-not [string]::IsNullOrWhiteSpace($itemPath) -and -not [string]::IsNullOrWhiteSpace($kind)) {
271 $itemKey = Get-CollectionItemKey -Kind $kind -ItemPath $itemPath
272 if (-not $itemOccurrences.ContainsKey($itemKey)) {
273 $itemOccurrences[$itemKey] = @()
274 }
275
276 $itemOccurrences[$itemKey] += @{
277 CollectionId = $id
278 CollectionFile = $file.Name
279 Kind = $kind
280 Path = $itemPath
281 Maturity = $effectiveMaturity
282 }
283 }
284
285 # Informational log for instruction items
286 if ($kind -eq 'instruction') {
287 Write-Verbose " instruction: $itemPath"
288 }
289 }
290
291 if ($fileErrors.Count -gt 0) {
292 Write-Host " FAIL $id ($itemCount items) - $($fileErrors.Count) error(s)" -ForegroundColor Red
293 foreach ($err in $fileErrors) {
294 Write-Host " $err" -ForegroundColor Red
295 }
296 $errorCount += $fileErrors.Count
297 }
298 else {
299 Write-Host " OK $id ($itemCount items)"
300 }
301
302 $validatedCount++
303 }
304
305 foreach ($itemKey in $itemOccurrences.Keys) {
306 $occurrences = $itemOccurrences[$itemKey]
307 if ($occurrences.Count -le 1) {
308 continue
309 }
310
311 $canonicalMatches = @($occurrences | Where-Object { $_.CollectionId -eq $canonicalCollectionId })
312 if ($canonicalMatches.Count -eq 0) {
313 $sharedCollections = ($occurrences | ForEach-Object { $_.CollectionId } | Sort-Object -Unique) -join ', '
314 Write-Host " FAIL shared item '$itemKey' exists in collections [$sharedCollections] but has no canonical entry in '$canonicalCollectionId'" -ForegroundColor Red
315 $errorCount++
316 continue
317 }
318
319 $canonical = $canonicalMatches[0]
320 foreach ($occurrence in $occurrences) {
321 if ($occurrence.CollectionId -eq $canonicalCollectionId) {
322 continue
323 }
324
325 if ($occurrence.Maturity -ne $canonical.Maturity) {
326 Write-Host " FAIL maturity conflict for '$itemKey': canonical '$canonicalCollectionId'='$($canonical.Maturity)', '$($occurrence.CollectionId)'='$($occurrence.Maturity)'" -ForegroundColor Red
327 $errorCount++
328 }
329 }
330 }
331
332 Write-Host ''
333 Write-Host "$validatedCount collections validated, $errorCount errors"
334
335 return @{
336 Success = ($errorCount -eq 0)
337 ErrorCount = $errorCount
338 CollectionCount = $validatedCount
339 }
340}
341
342#endregion Orchestration
343
344#region Main Execution
345if ($MyInvocation.InvocationName -ne '.') {
346 try {
347 # Verify PowerShell-Yaml module
348 if (-not (Get-Module -ListAvailable -Name PowerShell-Yaml)) {
349 throw "Required module 'PowerShell-Yaml' is not installed."
350 }
351 Import-Module PowerShell-Yaml -ErrorAction Stop
352
353 # Resolve paths
354 $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
355 $RepoRoot = (Get-Item "$ScriptDir/../..").FullName
356
357 $result = Invoke-CollectionValidation -RepoRoot $RepoRoot
358
359 if (-not $result.Success) {
360 throw "Validation failed with $($result.ErrorCount) error(s)."
361 }
362
363 exit 0
364 }
365 catch {
366 Write-Error "Collection validation failed: $($_.Exception.Message)"
367 Write-CIAnnotation -Message $_.Exception.Message -Level Error
368 exit 1
369 }
370}
371#endregion
372