microsoft/hve-core

Public

mirrored from https://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
chore/sync-action-version-comments

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/collections/Validate-Collections.ps1

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