microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/fix-broken-file-references

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/collections/Validate-Collections.ps1

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