microsoft/hve-core
Publicmirrored fromhttps://github.com/microsoft/hve-coreAvailable
scripts/linting/Modules/FrontmatterValidation.psm1
1094lines ยท modecode
| 1 | # Copyright (c) Microsoft Corporation. |
| 2 | # SPDX-License-Identifier: MIT |
| 3 | |
| 4 | <# |
| 5 | .SYNOPSIS |
| 6 | Frontmatter validation module with validation functions and I/O helpers. |
| 7 | .DESCRIPTION |
| 8 | Contains content-type validators, shared helpers, and output functions |
| 9 | for frontmatter validation. Returns ValidationIssue arrays for testability. |
| 10 | .NOTES |
| 11 | Author: HVE Core Team |
| 12 | #> |
| 13 | |
| 14 | #region Classes |
| 15 | |
| 16 | class ValidationIssue { |
| 17 | [ValidateSet('Error', 'Warning', 'Notice')] |
| 18 | [string]$Type = 'Warning' |
| 19 | [string]$Field |
| 20 | [string]$Message |
| 21 | [string]$FilePath |
| 22 | [int]$Line |
| 23 | |
| 24 | ValidationIssue() { |
| 25 | $this.Type = 'Warning' |
| 26 | $this.Line = 0 |
| 27 | } |
| 28 | |
| 29 | ValidationIssue([string]$type, [string]$field, [string]$message, [string]$filePath) { |
| 30 | $this.Type = $type |
| 31 | $this.Field = $field |
| 32 | $this.Message = $message |
| 33 | $this.FilePath = $filePath |
| 34 | $this.Line = 0 |
| 35 | } |
| 36 | |
| 37 | ValidationIssue([string]$type, [string]$field, [string]$message, [string]$filePath, [int]$line) { |
| 38 | $this.Type = $type |
| 39 | $this.Field = $field |
| 40 | $this.Message = $message |
| 41 | $this.FilePath = $filePath |
| 42 | $this.Line = $line |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | class FileTypeInfo { |
| 47 | [bool]$IsGitHub |
| 48 | [bool]$IsChatMode |
| 49 | [bool]$IsPrompt |
| 50 | [bool]$IsInstruction |
| 51 | [bool]$IsAgent |
| 52 | [bool]$IsRootCommunityFile |
| 53 | [bool]$IsDevContainer |
| 54 | [bool]$IsVSCodeReadme |
| 55 | [bool]$IsDocsFile |
| 56 | |
| 57 | FileTypeInfo() { |
| 58 | $this.IsGitHub = $false |
| 59 | $this.IsChatMode = $false |
| 60 | $this.IsPrompt = $false |
| 61 | $this.IsInstruction = $false |
| 62 | $this.IsAgent = $false |
| 63 | $this.IsRootCommunityFile = $false |
| 64 | $this.IsDevContainer = $false |
| 65 | $this.IsVSCodeReadme = $false |
| 66 | $this.IsDocsFile = $false |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | class FileValidationResult { |
| 71 | [ValidateNotNullOrEmpty()] |
| 72 | [string]$FilePath |
| 73 | |
| 74 | [string]$RelativePath |
| 75 | [bool]$HasFrontmatter |
| 76 | [hashtable]$Frontmatter |
| 77 | [FileTypeInfo]$FileType |
| 78 | [System.Collections.Generic.List[ValidationIssue]]$Issues |
| 79 | [datetime]$ValidatedAt |
| 80 | |
| 81 | FileValidationResult([string]$filePath) { |
| 82 | $this.FilePath = $filePath |
| 83 | $this.RelativePath = $filePath |
| 84 | $this.Issues = [System.Collections.Generic.List[ValidationIssue]]::new() |
| 85 | $this.ValidatedAt = [datetime]::UtcNow |
| 86 | } |
| 87 | |
| 88 | [bool] HasErrors() { |
| 89 | return ($this.Issues | Where-Object Type -eq 'Error').Count -gt 0 |
| 90 | } |
| 91 | |
| 92 | [bool] HasWarnings() { |
| 93 | return ($this.Issues | Where-Object Type -eq 'Warning').Count -gt 0 |
| 94 | } |
| 95 | |
| 96 | [bool] IsValid() { |
| 97 | return -not $this.HasErrors() |
| 98 | } |
| 99 | |
| 100 | [int] ErrorCount() { |
| 101 | return ($this.Issues | Where-Object Type -eq 'Error').Count |
| 102 | } |
| 103 | |
| 104 | [int] WarningCount() { |
| 105 | return ($this.Issues | Where-Object Type -eq 'Warning').Count |
| 106 | } |
| 107 | |
| 108 | [void] AddIssue([ValidationIssue]$issue) { |
| 109 | $this.Issues.Add($issue) |
| 110 | } |
| 111 | |
| 112 | [void] AddError([string]$message, [string]$field) { |
| 113 | $this.AddError($message, $field, 0) |
| 114 | } |
| 115 | |
| 116 | [void] AddError([string]$message, [string]$field, [int]$line) { |
| 117 | $issue = [ValidationIssue]::new() |
| 118 | $issue.Type = 'Error' |
| 119 | $issue.Message = $message |
| 120 | $issue.Field = $field |
| 121 | $issue.FilePath = $this.FilePath |
| 122 | $issue.Line = $line |
| 123 | $this.Issues.Add($issue) |
| 124 | } |
| 125 | |
| 126 | [void] AddWarning([string]$message, [string]$field) { |
| 127 | $this.AddWarning($message, $field, 0) |
| 128 | } |
| 129 | |
| 130 | [void] AddWarning([string]$message, [string]$field, [int]$line) { |
| 131 | $issue = [ValidationIssue]::new() |
| 132 | $issue.Type = 'Warning' |
| 133 | $issue.Message = $message |
| 134 | $issue.Field = $field |
| 135 | $issue.FilePath = $this.FilePath |
| 136 | $issue.Line = $line |
| 137 | $this.Issues.Add($issue) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | class ValidationSummary { |
| 142 | [int]$TotalFiles |
| 143 | [int]$FilesWithErrors |
| 144 | [int]$FilesWithWarnings |
| 145 | [int]$FilesValid |
| 146 | [int]$TotalErrors |
| 147 | [int]$TotalWarnings |
| 148 | [System.Collections.ArrayList]$Results |
| 149 | [datetime]$StartedAt |
| 150 | [datetime]$CompletedAt |
| 151 | [timespan]$Duration |
| 152 | |
| 153 | ValidationSummary() { |
| 154 | $this.Results = [System.Collections.ArrayList]::new() |
| 155 | $this.StartedAt = [datetime]::UtcNow |
| 156 | } |
| 157 | |
| 158 | # Type constraint removed for testability (PowerShell class identity conflicts) |
| 159 | [void] AddResult($result) { |
| 160 | $this.Results.Add($result) |
| 161 | $this.TotalFiles++ |
| 162 | |
| 163 | if ($result.HasErrors()) { |
| 164 | $this.FilesWithErrors++ |
| 165 | $this.TotalErrors += $result.ErrorCount() |
| 166 | } |
| 167 | if ($result.HasWarnings()) { |
| 168 | $this.FilesWithWarnings++ |
| 169 | $this.TotalWarnings += $result.WarningCount() |
| 170 | } |
| 171 | if ($result.IsValid() -and -not $result.HasWarnings()) { |
| 172 | $this.FilesValid++ |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | [void] Complete() { |
| 177 | $this.CompletedAt = [datetime]::UtcNow |
| 178 | $this.Duration = $this.CompletedAt - $this.StartedAt |
| 179 | } |
| 180 | |
| 181 | [bool] Passed([bool]$warningsAsErrors) { |
| 182 | if ($this.TotalErrors -gt 0) { return $false } |
| 183 | if ($warningsAsErrors -and $this.TotalWarnings -gt 0) { return $false } |
| 184 | return $true |
| 185 | } |
| 186 | |
| 187 | [int] GetExitCode([bool]$warningsAsErrors) { |
| 188 | # Exit code 2 indicates no files were validated (distinct from validation errors) |
| 189 | if ($this.TotalFiles -eq 0) { return 2 } |
| 190 | if ($this.Passed($warningsAsErrors)) { return 0 } else { return 1 } |
| 191 | } |
| 192 | |
| 193 | [hashtable] ToHashtable() { |
| 194 | return @{ |
| 195 | totalFiles = $this.TotalFiles |
| 196 | filesWithErrors = $this.FilesWithErrors |
| 197 | filesWithWarnings = $this.FilesWithWarnings |
| 198 | filesValid = $this.FilesValid |
| 199 | totalErrors = $this.TotalErrors |
| 200 | totalWarnings = $this.TotalWarnings |
| 201 | duration = $this.Duration.TotalSeconds |
| 202 | results = [object[]]($this.Results | ForEach-Object { |
| 203 | @{ |
| 204 | filePath = $_.RelativePath |
| 205 | isValid = $_.IsValid() |
| 206 | errorCount = $_.ErrorCount() |
| 207 | warningCount = $_.WarningCount() |
| 208 | issues = [object[]]($_.Issues | ForEach-Object { |
| 209 | @{ |
| 210 | type = $_.Type |
| 211 | message = $_.Message |
| 212 | field = $_.Field |
| 213 | line = $_.Line |
| 214 | } |
| 215 | }) |
| 216 | } |
| 217 | }) |
| 218 | } |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | #endregion Classes |
| 223 | |
| 224 | #region Shared Helpers |
| 225 | |
| 226 | function Test-RequiredField { |
| 227 | <# |
| 228 | .SYNOPSIS |
| 229 | Validates that a required field exists and is not empty. |
| 230 | .DESCRIPTION |
| 231 | Pure validation helper that checks for field presence and non-empty value. |
| 232 | Returns a ValidationIssue if the field is missing or empty. |
| 233 | .PARAMETER Frontmatter |
| 234 | Hashtable containing parsed frontmatter fields. |
| 235 | .PARAMETER FieldName |
| 236 | Name of the required field to check. |
| 237 | .PARAMETER RelativePath |
| 238 | Relative path to the file being validated. |
| 239 | .PARAMETER Severity |
| 240 | Issue severity: 'Error' or 'Warning'. Default: 'Error'. |
| 241 | .OUTPUTS |
| 242 | ValidationIssue or $null if field is valid. |
| 243 | #> |
| 244 | [CmdletBinding()] |
| 245 | [OutputType([ValidationIssue])] |
| 246 | param( |
| 247 | [Parameter(Mandatory)] |
| 248 | [hashtable]$Frontmatter, |
| 249 | |
| 250 | [Parameter(Mandatory)] |
| 251 | [string]$FieldName, |
| 252 | |
| 253 | [Parameter(Mandatory)] |
| 254 | [string]$RelativePath, |
| 255 | |
| 256 | [Parameter()] |
| 257 | [ValidateSet('Error', 'Warning')] |
| 258 | [string]$Severity = 'Error' |
| 259 | ) |
| 260 | |
| 261 | if (-not $Frontmatter.ContainsKey($FieldName) -or [string]::IsNullOrWhiteSpace($Frontmatter[$FieldName])) { |
| 262 | return [ValidationIssue]::new($Severity, $FieldName, "Missing required field: $FieldName", $RelativePath) |
| 263 | } |
| 264 | |
| 265 | return $null |
| 266 | } |
| 267 | |
| 268 | function Test-DateFormat { |
| 269 | <# |
| 270 | .SYNOPSIS |
| 271 | Validates date format is ISO 8601 (YYYY-MM-DD) or placeholder. |
| 272 | .DESCRIPTION |
| 273 | Pure validation helper that checks date format compliance. |
| 274 | Accepts ISO 8601 format or placeholder syntax (YYYY-MM-dd). |
| 275 | .PARAMETER Frontmatter |
| 276 | Hashtable containing parsed frontmatter fields. |
| 277 | .PARAMETER FieldName |
| 278 | Name of the date field to check. Default: 'ms.date'. |
| 279 | .PARAMETER RelativePath |
| 280 | Relative path to the file being validated. |
| 281 | .OUTPUTS |
| 282 | ValidationIssue or $null if format is valid or field not present. |
| 283 | #> |
| 284 | [CmdletBinding()] |
| 285 | [OutputType([ValidationIssue])] |
| 286 | param( |
| 287 | [Parameter(Mandatory)] |
| 288 | [hashtable]$Frontmatter, |
| 289 | |
| 290 | [Parameter()] |
| 291 | [string]$FieldName = 'ms.date', |
| 292 | |
| 293 | [Parameter(Mandatory)] |
| 294 | [string]$RelativePath |
| 295 | ) |
| 296 | |
| 297 | if (-not $Frontmatter.ContainsKey($FieldName)) { |
| 298 | return $null |
| 299 | } |
| 300 | |
| 301 | $date = $Frontmatter[$FieldName] |
| 302 | if ($date -notmatch '^(\d{4}-\d{2}-\d{2}|\(YYYY-MM-dd\))$') { |
| 303 | return [ValidationIssue]::new('Warning', $FieldName, "Invalid date format: Expected YYYY-MM-DD, got: $date", $RelativePath) |
| 304 | } |
| 305 | |
| 306 | return $null |
| 307 | } |
| 308 | |
| 309 | function Test-SuggestedFields { |
| 310 | <# |
| 311 | .SYNOPSIS |
| 312 | Validates presence of suggested (optional but recommended) fields. |
| 313 | .DESCRIPTION |
| 314 | Pure validation helper that checks for suggested field presence. |
| 315 | Returns warnings for missing suggested fields. |
| 316 | .PARAMETER Frontmatter |
| 317 | Hashtable containing parsed frontmatter fields. |
| 318 | .PARAMETER FieldNames |
| 319 | Array of suggested field names to check. |
| 320 | .PARAMETER RelativePath |
| 321 | Relative path to the file being validated. |
| 322 | .OUTPUTS |
| 323 | ValidationIssue[] Array of warnings for missing fields. |
| 324 | #> |
| 325 | [CmdletBinding()] |
| 326 | [OutputType([ValidationIssue[]])] |
| 327 | param( |
| 328 | [Parameter(Mandatory)] |
| 329 | [hashtable]$Frontmatter, |
| 330 | |
| 331 | [Parameter(Mandatory)] |
| 332 | [string[]]$FieldNames, |
| 333 | |
| 334 | [Parameter(Mandatory)] |
| 335 | [string]$RelativePath |
| 336 | ) |
| 337 | |
| 338 | $issues = [System.Collections.Generic.List[ValidationIssue]]::new() |
| 339 | |
| 340 | foreach ($field in $FieldNames) { |
| 341 | if (-not $Frontmatter.ContainsKey($field)) { |
| 342 | $issues.Add([ValidationIssue]::new('Warning', $field, "Suggested field '$field' missing", $RelativePath)) |
| 343 | } |
| 344 | } |
| 345 | |
| 346 | return , $issues.ToArray() |
| 347 | } |
| 348 | |
| 349 | function Test-TopicValue { |
| 350 | <# |
| 351 | .SYNOPSIS |
| 352 | Validates ms.topic field value against allowed values. |
| 353 | .DESCRIPTION |
| 354 | Pure validation helper that checks topic value is one of the allowed types. |
| 355 | .PARAMETER Frontmatter |
| 356 | Hashtable containing parsed frontmatter fields. |
| 357 | .PARAMETER RelativePath |
| 358 | Relative path to the file being validated. |
| 359 | .OUTPUTS |
| 360 | ValidationIssue or $null if valid or not present. |
| 361 | #> |
| 362 | [CmdletBinding()] |
| 363 | [OutputType([ValidationIssue])] |
| 364 | param( |
| 365 | [Parameter(Mandatory)] |
| 366 | [hashtable]$Frontmatter, |
| 367 | |
| 368 | [Parameter(Mandatory)] |
| 369 | [string]$RelativePath |
| 370 | ) |
| 371 | |
| 372 | if (-not $Frontmatter.ContainsKey('ms.topic')) { |
| 373 | return $null |
| 374 | } |
| 375 | |
| 376 | $validTopics = @('overview', 'concept', 'tutorial', 'reference', 'how-to', 'troubleshooting') |
| 377 | $topicValue = $Frontmatter['ms.topic'] |
| 378 | |
| 379 | if ($topicValue -notin $validTopics) { |
| 380 | return [ValidationIssue]::new('Warning', 'ms.topic', "Unknown topic type: '$topicValue'. Expected one of: $($validTopics -join ', ')", $RelativePath) |
| 381 | } |
| 382 | |
| 383 | return $null |
| 384 | } |
| 385 | |
| 386 | #endregion Shared Helpers |
| 387 | |
| 388 | #region Content-Type Validators |
| 389 | |
| 390 | function Test-RootCommunityFileFields { |
| 391 | <# |
| 392 | .SYNOPSIS |
| 393 | Validates frontmatter fields for root community files. |
| 394 | .DESCRIPTION |
| 395 | Pure validation for README.md, CONTRIBUTING.md, CODE_OF_CONDUCT.md, |
| 396 | SECURITY.md, SUPPORT.md in repository root. |
| 397 | .PARAMETER Frontmatter |
| 398 | Hashtable containing parsed frontmatter fields. |
| 399 | .PARAMETER RelativePath |
| 400 | Relative path to the file being validated. |
| 401 | .OUTPUTS |
| 402 | ValidationIssue[] Array of validation issues found. |
| 403 | #> |
| 404 | [CmdletBinding()] |
| 405 | [OutputType([ValidationIssue[]])] |
| 406 | param( |
| 407 | [Parameter(Mandatory)] |
| 408 | [hashtable]$Frontmatter, |
| 409 | |
| 410 | [Parameter(Mandatory)] |
| 411 | [string]$RelativePath |
| 412 | ) |
| 413 | |
| 414 | $issues = [System.Collections.Generic.List[ValidationIssue]]::new() |
| 415 | |
| 416 | # Required fields |
| 417 | $titleIssue = Test-RequiredField -Frontmatter $Frontmatter -FieldName 'title' -RelativePath $RelativePath |
| 418 | if ($titleIssue) { $issues.Add($titleIssue) } |
| 419 | |
| 420 | $descIssue = Test-RequiredField -Frontmatter $Frontmatter -FieldName 'description' -RelativePath $RelativePath |
| 421 | if ($descIssue) { $issues.Add($descIssue) } |
| 422 | |
| 423 | # Suggested fields |
| 424 | $suggestedIssues = Test-SuggestedFields -Frontmatter $Frontmatter -FieldNames @('author', 'ms.date') -RelativePath $RelativePath |
| 425 | $issues.AddRange($suggestedIssues) |
| 426 | |
| 427 | # Date format |
| 428 | $dateIssue = Test-DateFormat -Frontmatter $Frontmatter -RelativePath $RelativePath |
| 429 | if ($dateIssue) { $issues.Add($dateIssue) } |
| 430 | |
| 431 | return , $issues.ToArray() |
| 432 | } |
| 433 | |
| 434 | function Test-DevContainerFileFields { |
| 435 | <# |
| 436 | .SYNOPSIS |
| 437 | Validates frontmatter fields for devcontainer documentation. |
| 438 | .DESCRIPTION |
| 439 | Pure validation for .devcontainer/ markdown files. |
| 440 | .PARAMETER Frontmatter |
| 441 | Hashtable containing parsed frontmatter fields. |
| 442 | .PARAMETER RelativePath |
| 443 | Relative path to the file being validated. |
| 444 | .OUTPUTS |
| 445 | ValidationIssue[] Array of validation issues found. |
| 446 | #> |
| 447 | [CmdletBinding()] |
| 448 | [OutputType([ValidationIssue[]])] |
| 449 | param( |
| 450 | [Parameter(Mandatory)] |
| 451 | [hashtable]$Frontmatter, |
| 452 | |
| 453 | [Parameter(Mandatory)] |
| 454 | [string]$RelativePath |
| 455 | ) |
| 456 | |
| 457 | $issues = [System.Collections.Generic.List[ValidationIssue]]::new() |
| 458 | |
| 459 | $titleIssue = Test-RequiredField -Frontmatter $Frontmatter -FieldName 'title' -RelativePath $RelativePath |
| 460 | if ($titleIssue) { $issues.Add($titleIssue) } |
| 461 | |
| 462 | $descIssue = Test-RequiredField -Frontmatter $Frontmatter -FieldName 'description' -RelativePath $RelativePath |
| 463 | if ($descIssue) { $issues.Add($descIssue) } |
| 464 | |
| 465 | return , $issues.ToArray() |
| 466 | } |
| 467 | |
| 468 | function Test-VSCodeReadmeFileFields { |
| 469 | <# |
| 470 | .SYNOPSIS |
| 471 | Validates frontmatter fields for VS Code extension README files. |
| 472 | .DESCRIPTION |
| 473 | Pure validation for extension/ README.md files. |
| 474 | .PARAMETER Frontmatter |
| 475 | Hashtable containing parsed frontmatter fields. |
| 476 | .PARAMETER RelativePath |
| 477 | Relative path to the file being validated. |
| 478 | .OUTPUTS |
| 479 | ValidationIssue[] Array of validation issues found. |
| 480 | #> |
| 481 | [CmdletBinding()] |
| 482 | [OutputType([ValidationIssue[]])] |
| 483 | param( |
| 484 | [Parameter(Mandatory)] |
| 485 | [hashtable]$Frontmatter, |
| 486 | |
| 487 | [Parameter(Mandatory)] |
| 488 | [string]$RelativePath |
| 489 | ) |
| 490 | |
| 491 | $issues = [System.Collections.Generic.List[ValidationIssue]]::new() |
| 492 | |
| 493 | $titleIssue = Test-RequiredField -Frontmatter $Frontmatter -FieldName 'title' -RelativePath $RelativePath |
| 494 | if ($titleIssue) { $issues.Add($titleIssue) } |
| 495 | |
| 496 | $descIssue = Test-RequiredField -Frontmatter $Frontmatter -FieldName 'description' -RelativePath $RelativePath |
| 497 | if ($descIssue) { $issues.Add($descIssue) } |
| 498 | |
| 499 | return , $issues.ToArray() |
| 500 | } |
| 501 | |
| 502 | function Test-GitHubResourceFileFields { |
| 503 | <# |
| 504 | .SYNOPSIS |
| 505 | Validates frontmatter fields for .github/ resource files. |
| 506 | .DESCRIPTION |
| 507 | Pure validation for instructions, prompts, agents, and skills. |
| 508 | .PARAMETER Frontmatter |
| 509 | Hashtable containing parsed frontmatter fields. |
| 510 | .PARAMETER RelativePath |
| 511 | Relative path to the file being validated. |
| 512 | .PARAMETER FileTypeInfo |
| 513 | FileTypeInfo object with classification details. Type constraint removed |
| 514 | to avoid PowerShell class identity conflicts in tests. |
| 515 | .OUTPUTS |
| 516 | ValidationIssue[] Array of validation issues found. |
| 517 | #> |
| 518 | [CmdletBinding()] |
| 519 | [OutputType([ValidationIssue[]])] |
| 520 | param( |
| 521 | [Parameter(Mandatory)] |
| 522 | [hashtable]$Frontmatter, |
| 523 | |
| 524 | [Parameter(Mandatory)] |
| 525 | [string]$RelativePath, |
| 526 | |
| 527 | [Parameter(Mandatory)] |
| 528 | $FileTypeInfo |
| 529 | ) |
| 530 | |
| 531 | $issues = [System.Collections.Generic.List[ValidationIssue]]::new() |
| 532 | |
| 533 | if ($FileTypeInfo.IsAgent -or $FileTypeInfo.IsChatMode) { |
| 534 | if (-not $Frontmatter.ContainsKey('description')) { |
| 535 | $issues.Add([ValidationIssue]::new('Warning', 'description', "Chat or agent file missing 'description' field", $RelativePath)) |
| 536 | } |
| 537 | } |
| 538 | elseif ($FileTypeInfo.IsInstruction) { |
| 539 | $descIssue = Test-RequiredField -Frontmatter $Frontmatter -FieldName 'description' -RelativePath $RelativePath |
| 540 | if ($descIssue) { |
| 541 | $descIssue.Message = "Instruction file missing required 'description' field" |
| 542 | $issues.Add($descIssue) |
| 543 | } |
| 544 | } |
| 545 | # Prompt files have no specific requirements |
| 546 | |
| 547 | return , $issues.ToArray() |
| 548 | } |
| 549 | |
| 550 | function Test-DocsFileFields { |
| 551 | <# |
| 552 | .SYNOPSIS |
| 553 | Validates frontmatter fields for docs/ directory files. |
| 554 | .DESCRIPTION |
| 555 | Pure validation for documentation files with comprehensive requirements. |
| 556 | .PARAMETER Frontmatter |
| 557 | Hashtable containing parsed frontmatter fields. |
| 558 | .PARAMETER RelativePath |
| 559 | Relative path to the file being validated. |
| 560 | .OUTPUTS |
| 561 | ValidationIssue[] Array of validation issues found. |
| 562 | #> |
| 563 | [CmdletBinding()] |
| 564 | [OutputType([ValidationIssue[]])] |
| 565 | param( |
| 566 | [Parameter(Mandatory)] |
| 567 | [hashtable]$Frontmatter, |
| 568 | |
| 569 | [Parameter(Mandatory)] |
| 570 | [string]$RelativePath |
| 571 | ) |
| 572 | |
| 573 | $issues = [System.Collections.Generic.List[ValidationIssue]]::new() |
| 574 | |
| 575 | # Required fields |
| 576 | $titleIssue = Test-RequiredField -Frontmatter $Frontmatter -FieldName 'title' -RelativePath $RelativePath |
| 577 | if ($titleIssue) { $issues.Add($titleIssue) } |
| 578 | |
| 579 | $descIssue = Test-RequiredField -Frontmatter $Frontmatter -FieldName 'description' -RelativePath $RelativePath |
| 580 | if ($descIssue) { $issues.Add($descIssue) } |
| 581 | |
| 582 | # Suggested fields |
| 583 | $suggestedIssues = Test-SuggestedFields -Frontmatter $Frontmatter -FieldNames @('author', 'ms.date', 'ms.topic') -RelativePath $RelativePath |
| 584 | $issues.AddRange($suggestedIssues) |
| 585 | |
| 586 | # Date format |
| 587 | $dateIssue = Test-DateFormat -Frontmatter $Frontmatter -RelativePath $RelativePath |
| 588 | if ($dateIssue) { $issues.Add($dateIssue) } |
| 589 | |
| 590 | # Topic value |
| 591 | $topicIssue = Test-TopicValue -Frontmatter $Frontmatter -RelativePath $RelativePath |
| 592 | if ($topicIssue) { $issues.Add($topicIssue) } |
| 593 | |
| 594 | return , $issues.ToArray() |
| 595 | } |
| 596 | |
| 597 | function Test-CommonFields { |
| 598 | <# |
| 599 | .SYNOPSIS |
| 600 | Validates common frontmatter fields for all content types. |
| 601 | .DESCRIPTION |
| 602 | Pure validation for fields like keywords and estimated_reading_time. |
| 603 | .PARAMETER Frontmatter |
| 604 | Hashtable containing parsed frontmatter fields. |
| 605 | .PARAMETER RelativePath |
| 606 | Relative path to the file being validated. |
| 607 | .OUTPUTS |
| 608 | ValidationIssue[] Array of validation issues found. |
| 609 | #> |
| 610 | [CmdletBinding()] |
| 611 | [OutputType([ValidationIssue[]])] |
| 612 | param( |
| 613 | [Parameter(Mandatory)] |
| 614 | [hashtable]$Frontmatter, |
| 615 | |
| 616 | [Parameter(Mandatory)] |
| 617 | [string]$RelativePath |
| 618 | ) |
| 619 | |
| 620 | $issues = [System.Collections.Generic.List[ValidationIssue]]::new() |
| 621 | |
| 622 | # Validate keywords array |
| 623 | # ConvertFrom-Yaml returns sequences as List[object], not native PowerShell arrays |
| 624 | if ($Frontmatter.ContainsKey('keywords')) { |
| 625 | $keywords = $Frontmatter['keywords'] |
| 626 | $isCollection = $keywords -is [array] -or |
| 627 | $keywords -is [System.Collections.IList] -or |
| 628 | ($keywords -is [System.Collections.IEnumerable] -and |
| 629 | $keywords -isnot [string] -and |
| 630 | $keywords -isnot [hashtable]) |
| 631 | if (-not $isCollection -and $keywords -notmatch ',') { |
| 632 | $issues.Add([ValidationIssue]::new('Warning', 'keywords', 'Keywords should be an array', $RelativePath)) |
| 633 | } |
| 634 | } |
| 635 | |
| 636 | # Validate estimated_reading_time |
| 637 | if ($Frontmatter.ContainsKey('estimated_reading_time')) { |
| 638 | $readingTime = $Frontmatter['estimated_reading_time'] |
| 639 | if ($readingTime -notmatch '^\d+$') { |
| 640 | $issues.Add([ValidationIssue]::new('Warning', 'estimated_reading_time', 'Should be a positive integer', $RelativePath)) |
| 641 | } |
| 642 | } |
| 643 | |
| 644 | return , $issues.ToArray() |
| 645 | } |
| 646 | |
| 647 | function Test-FooterPresence { |
| 648 | <# |
| 649 | .SYNOPSIS |
| 650 | Validates Copilot attribution footer presence. |
| 651 | .DESCRIPTION |
| 652 | Pure validation wrapper for footer check. |
| 653 | .PARAMETER HasFooter |
| 654 | Boolean result from Test-MarkdownFooter. |
| 655 | .PARAMETER RelativePath |
| 656 | Relative path to the file being validated. |
| 657 | .PARAMETER Severity |
| 658 | Issue severity: 'Error' or 'Warning'. Default: 'Error'. |
| 659 | .OUTPUTS |
| 660 | ValidationIssue or $null if footer is present. |
| 661 | #> |
| 662 | [CmdletBinding()] |
| 663 | [OutputType([ValidationIssue])] |
| 664 | param( |
| 665 | [Parameter(Mandatory)] |
| 666 | [bool]$HasFooter, |
| 667 | |
| 668 | [Parameter(Mandatory)] |
| 669 | [string]$RelativePath, |
| 670 | |
| 671 | [Parameter()] |
| 672 | [ValidateSet('Error', 'Warning')] |
| 673 | [string]$Severity = 'Error' |
| 674 | ) |
| 675 | |
| 676 | if (-not $HasFooter) { |
| 677 | return [ValidationIssue]::new($Severity, 'footer', 'Missing standard Copilot footer', $RelativePath) |
| 678 | } |
| 679 | |
| 680 | return $null |
| 681 | } |
| 682 | |
| 683 | function Test-MarkdownFooter { |
| 684 | <# |
| 685 | .SYNOPSIS |
| 686 | Checks if markdown content contains the standard Copilot attribution footer. |
| 687 | .DESCRIPTION |
| 688 | Pure function that validates markdown content ends with the standard Copilot |
| 689 | attribution footer. Normalizes content by removing HTML comments and markdown |
| 690 | formatting before pattern matching. |
| 691 | .PARAMETER Content |
| 692 | The markdown content string to validate. |
| 693 | .OUTPUTS |
| 694 | [bool] $true if valid footer present; $false otherwise. |
| 695 | #> |
| 696 | [CmdletBinding()] |
| 697 | [OutputType([bool])] |
| 698 | param( |
| 699 | [Parameter(Mandatory, ValueFromPipeline)] |
| 700 | [AllowEmptyString()] |
| 701 | [string]$Content |
| 702 | ) |
| 703 | |
| 704 | process { |
| 705 | if ([string]::IsNullOrEmpty($Content)) { |
| 706 | return $false |
| 707 | } |
| 708 | |
| 709 | $normalized = $Content -replace '(?s)<!--.*?-->', '' |
| 710 | $normalized = $normalized -replace '\*\*([^*]+)\*\*', '$1' |
| 711 | $normalized = $normalized -replace '__([^_]+)__', '$1' |
| 712 | $normalized = $normalized -replace '\*([^*]+)\*', '$1' |
| 713 | $normalized = $normalized -replace '_([^_]+)_', '$1' |
| 714 | $normalized = $normalized -replace '~~([^~]+)~~', '$1' |
| 715 | $normalized = $normalized -replace '`([^`]+)`', '$1' |
| 716 | $normalized = $normalized.TrimEnd() |
| 717 | |
| 718 | $pattern = '๐ค\s*Crafted\s+with\s+precision\s+by\s+โจCopilot\s+following\s+brilliant\s+human\s+instruction[,\s]+(then\s+)?carefully\s+refined\s+by\s+our\s+team\s+of\s+discerning\s+human\s+reviewers\.?' |
| 719 | |
| 720 | return $normalized -match $pattern |
| 721 | } |
| 722 | } |
| 723 | |
| 724 | #endregion Content-Type Validators |
| 725 | |
| 726 | #region File Classification |
| 727 | |
| 728 | function Get-FileTypeInfo { |
| 729 | <# |
| 730 | .SYNOPSIS |
| 731 | Classifies a file based on its path and name. |
| 732 | .DESCRIPTION |
| 733 | Pure function that determines file type for validation routing. |
| 734 | .PARAMETER File |
| 735 | FileInfo object to classify. |
| 736 | .PARAMETER RepoRoot |
| 737 | Repository root path for relative path computation. |
| 738 | .OUTPUTS |
| 739 | FileTypeInfo object with classification flags. |
| 740 | #> |
| 741 | [CmdletBinding()] |
| 742 | [OutputType([FileTypeInfo])] |
| 743 | param( |
| 744 | [Parameter(Mandatory)] |
| 745 | [System.IO.FileInfo]$File, |
| 746 | |
| 747 | [Parameter(Mandatory)] |
| 748 | [string]$RepoRoot |
| 749 | ) |
| 750 | |
| 751 | $info = [FileTypeInfo]::new() |
| 752 | $info.IsGitHub = $File.DirectoryName -like "*.github*" |
| 753 | $info.IsChatMode = $File.Name -like "*.chatmode.md" |
| 754 | $info.IsPrompt = $File.Name -like "*.prompt.md" |
| 755 | $info.IsInstruction = $File.Name -like "*.instructions.md" |
| 756 | $info.IsAgent = $File.Name -like "*.agent.md" |
| 757 | $info.IsRootCommunityFile = ($File.DirectoryName -eq $RepoRoot) -and |
| 758 | ($File.Name -in @('CODE_OF_CONDUCT.md', 'CONTRIBUTING.md', 'SECURITY.md', 'SUPPORT.md', 'README.md')) |
| 759 | $info.IsDevContainer = $File.DirectoryName -like "*.devcontainer*" -and $File.Name -eq 'README.md' |
| 760 | $info.IsVSCodeReadme = $File.DirectoryName -like "*.vscode*" -and $File.Name -eq 'README.md' |
| 761 | # Exclude .copilot-tracking (gitignored workflow artifacts) and markdown templates from docs validation |
| 762 | $isCopilotTracking = $File.DirectoryName -like "*.copilot-tracking*" |
| 763 | $isTemplate = $File.Name -like "*TEMPLATE*" |
| 764 | # Use repo-relative path to avoid misclassifying files when repo is under a parent containing "docs" |
| 765 | $relativePath = [System.IO.Path]::GetRelativePath($RepoRoot, $File.FullName) |
| 766 | $relativePathNormalized = $relativePath -replace '\\', '/' |
| 767 | $info.IsDocsFile = ($relativePathNormalized -match '(^|/)docs(/|$)') -and -not $info.IsGitHub -and -not $isCopilotTracking -and -not $isTemplate |
| 768 | |
| 769 | return $info |
| 770 | } |
| 771 | |
| 772 | #endregion File Classification |
| 773 | |
| 774 | #region Orchestration |
| 775 | |
| 776 | function Test-SingleFileFrontmatter { |
| 777 | <# |
| 778 | .SYNOPSIS |
| 779 | Validates frontmatter for a single markdown file. |
| 780 | .DESCRIPTION |
| 781 | Performs complete frontmatter validation including presence check, |
| 782 | YAML parsing, file type detection, field validation, and footer check. |
| 783 | .PARAMETER FilePath |
| 784 | Absolute path to the markdown file. |
| 785 | .PARAMETER RepoRoot |
| 786 | Repository root path for relative path computation and file classification. |
| 787 | .PARAMETER FileReader |
| 788 | Optional scriptblock for reading file content. Enables testing. |
| 789 | .PARAMETER FooterExcludePaths |
| 790 | Array of wildcard patterns for files to exclude from footer validation. |
| 791 | Uses PowerShell -like operator for matching against relative paths. |
| 792 | Path separators are normalized to forward slashes for cross-platform support. |
| 793 | .OUTPUTS |
| 794 | FileValidationResult |
| 795 | #> |
| 796 | [CmdletBinding()] |
| 797 | [OutputType([FileValidationResult])] |
| 798 | param( |
| 799 | [Parameter(Mandatory)] |
| 800 | [ValidateNotNullOrEmpty()] |
| 801 | [string]$FilePath, |
| 802 | |
| 803 | [Parameter(Mandatory)] |
| 804 | [ValidateNotNullOrEmpty()] |
| 805 | [string]$RepoRoot, |
| 806 | |
| 807 | [scriptblock]$FileReader = { param($p) Get-Content -Path $p -Raw -ErrorAction Stop }, |
| 808 | |
| 809 | [string[]]$FooterExcludePaths = @(), |
| 810 | |
| 811 | [switch]$SkipFooterValidation |
| 812 | ) |
| 813 | |
| 814 | $relativePath = $FilePath |
| 815 | if ($FilePath.StartsWith($RepoRoot)) { |
| 816 | $relativePath = $FilePath.Substring($RepoRoot.Length).TrimStart('\', '/') |
| 817 | } |
| 818 | |
| 819 | $result = [FileValidationResult]::new($FilePath) |
| 820 | $result.RelativePath = $relativePath |
| 821 | |
| 822 | # Detect file type early - needed for frontmatter requirement decisions |
| 823 | $fileInfo = [System.IO.FileInfo]::new($FilePath) |
| 824 | $result.FileType = Get-FileTypeInfo -File $fileInfo -RepoRoot $RepoRoot |
| 825 | $fileTypeInfo = $result.FileType |
| 826 | |
| 827 | # AI artifacts (prompts, instructions, agents, chatmodes) don't require frontmatter |
| 828 | $isAiArtifact = $fileTypeInfo.IsPrompt -or $fileTypeInfo.IsInstruction -or $fileTypeInfo.IsAgent -or $fileTypeInfo.IsChatMode |
| 829 | |
| 830 | # Read file content |
| 831 | try { |
| 832 | $content = & $FileReader $FilePath |
| 833 | } |
| 834 | catch { |
| 835 | $result.AddError("Failed to read file: $($_.Exception.Message)", 'file') |
| 836 | return $result |
| 837 | } |
| 838 | |
| 839 | # Parse frontmatter |
| 840 | $frontmatter = $null |
| 841 | $hasFrontmatterBlock = $content -match '(?s)^---\r?\n(.*?)\r?\n---' |
| 842 | if ($hasFrontmatterBlock) { |
| 843 | $yamlBlock = $Matches[1] |
| 844 | |
| 845 | # Verify ConvertFrom-Yaml is available (requires powershell-yaml module) |
| 846 | if (-not (Get-Command -Name 'ConvertFrom-Yaml' -ErrorAction SilentlyContinue)) { |
| 847 | $result.AddError("ConvertFrom-Yaml cmdlet not found. Install powershell-yaml module: Install-Module -Name PowerShell-Yaml -Force -Scope CurrentUser", 'dependency') |
| 848 | return $result |
| 849 | } |
| 850 | |
| 851 | try { |
| 852 | $frontmatter = $yamlBlock | ConvertFrom-Yaml -ErrorAction Stop |
| 853 | } |
| 854 | catch { |
| 855 | $result.AddError("Invalid YAML syntax: $($_.Exception.Message)", 'yaml') |
| 856 | return $result |
| 857 | } |
| 858 | } |
| 859 | |
| 860 | $result.HasFrontmatter = $null -ne $frontmatter |
| 861 | $result.Frontmatter = $frontmatter |
| 862 | |
| 863 | # Only warn about missing frontmatter for content types that require it |
| 864 | # AI artifacts (.github prompts, instructions, agents, chatmodes) are exempt |
| 865 | if (-not $result.HasFrontmatter -and -not $isAiArtifact) { |
| 866 | $result.AddWarning('No frontmatter found', 'frontmatter') |
| 867 | # Continue to footer validation even without frontmatter |
| 868 | } |
| 869 | |
| 870 | # Validate fields based on file type (only if frontmatter was successfully parsed) |
| 871 | $issues = @() |
| 872 | |
| 873 | if ($null -ne $frontmatter) { |
| 874 | if ($fileTypeInfo.IsDocsFile) { |
| 875 | $issues = Test-DocsFileFields -Frontmatter $frontmatter -RelativePath $relativePath |
| 876 | } |
| 877 | elseif ($fileTypeInfo.IsInstruction -or $fileTypeInfo.IsPrompt -or $fileTypeInfo.IsChatMode -or $fileTypeInfo.IsAgent) { |
| 878 | $issues = Test-GitHubResourceFileFields -Frontmatter $frontmatter -FileTypeInfo $fileTypeInfo -RelativePath $relativePath |
| 879 | } |
| 880 | elseif ($fileTypeInfo.IsDevContainer) { |
| 881 | $issues = Test-DevContainerFileFields -Frontmatter $frontmatter -RelativePath $relativePath |
| 882 | } |
| 883 | elseif ($fileTypeInfo.IsVSCodeReadme) { |
| 884 | $issues = Test-VSCodeReadmeFileFields -Frontmatter $frontmatter -RelativePath $relativePath |
| 885 | } |
| 886 | elseif ($fileTypeInfo.IsRootCommunityFile) { |
| 887 | $issues = Test-RootCommunityFileFields -Frontmatter $frontmatter -RelativePath $relativePath |
| 888 | } |
| 889 | |
| 890 | foreach ($issue in $issues) { |
| 891 | $result.AddIssue($issue) |
| 892 | } |
| 893 | } |
| 894 | |
| 895 | # Common field validation for all content types with frontmatter |
| 896 | if ($null -ne $frontmatter) { |
| 897 | $commonIssues = Test-CommonFields -Frontmatter $frontmatter -RelativePath $relativePath |
| 898 | foreach ($commonIssue in $commonIssues) { |
| 899 | $result.AddIssue($commonIssue) |
| 900 | } |
| 901 | } |
| 902 | |
| 903 | # Check if file matches footer exclusion pattern |
| 904 | # Normalize path separators for cross-platform pattern matching |
| 905 | $skipFooterForFile = $false |
| 906 | $normalizedRelativePath = $relativePath -replace '\\', '/' |
| 907 | foreach ($pattern in $FooterExcludePaths) { |
| 908 | $normalizedPattern = $pattern -replace '\\', '/' |
| 909 | if ($normalizedRelativePath -like $normalizedPattern) { |
| 910 | $skipFooterForFile = $true |
| 911 | break |
| 912 | } |
| 913 | } |
| 914 | |
| 915 | # Footer validation for all markdown EXCEPT AI artifacts (prompts, instructions, agents, chatmodes) |
| 916 | if (-not $isAiArtifact -and -not $SkipFooterValidation -and -not $skipFooterForFile) { |
| 917 | # Determine severity based on file type |
| 918 | $footerSeverity = 'Warning' |
| 919 | if ($fileTypeInfo.IsRootCommunityFile -or $fileTypeInfo.IsDevContainer -or $fileTypeInfo.IsVSCodeReadme) { |
| 920 | $footerSeverity = 'Error' |
| 921 | } |
| 922 | |
| 923 | $hasFooter = Test-MarkdownFooter -Content $content |
| 924 | $footerIssue = Test-FooterPresence -HasFooter $hasFooter -RelativePath $relativePath -Severity $footerSeverity |
| 925 | if ($footerIssue) { |
| 926 | $result.AddIssue($footerIssue) |
| 927 | } |
| 928 | } |
| 929 | |
| 930 | return $result |
| 931 | } |
| 932 | |
| 933 | function Invoke-FrontmatterValidation { |
| 934 | <# |
| 935 | .SYNOPSIS |
| 936 | Validates frontmatter across multiple markdown files. |
| 937 | |
| 938 | .DESCRIPTION |
| 939 | Orchestrates validation of multiple files and aggregates results |
| 940 | into a ValidationSummary object. |
| 941 | |
| 942 | .PARAMETER Files |
| 943 | Array of file paths to validate. |
| 944 | |
| 945 | .PARAMETER RepoRoot |
| 946 | Repository root path for relative path computation and file classification. |
| 947 | |
| 948 | .PARAMETER FooterExcludePaths |
| 949 | Array of wildcard patterns for files to exclude from footer validation. |
| 950 | Uses PowerShell -like operator for matching against relative paths. |
| 951 | Path separators are normalized to forward slashes for cross-platform support. |
| 952 | |
| 953 | .OUTPUTS |
| 954 | ValidationSummary |
| 955 | #> |
| 956 | [CmdletBinding()] |
| 957 | [OutputType([ValidationSummary])] |
| 958 | param( |
| 959 | [Parameter(Mandatory)] |
| 960 | [string[]]$Files, |
| 961 | |
| 962 | [Parameter(Mandatory)] |
| 963 | [ValidateNotNullOrEmpty()] |
| 964 | [string]$RepoRoot, |
| 965 | |
| 966 | [string[]]$FooterExcludePaths = @(), |
| 967 | |
| 968 | [switch]$SkipFooterValidation |
| 969 | ) |
| 970 | |
| 971 | $summary = [ValidationSummary]::new() |
| 972 | |
| 973 | foreach ($file in $Files) { |
| 974 | $result = Test-SingleFileFrontmatter -FilePath $file -RepoRoot $RepoRoot -FooterExcludePaths $FooterExcludePaths -SkipFooterValidation:$SkipFooterValidation |
| 975 | $summary.AddResult($result) |
| 976 | } |
| 977 | |
| 978 | $summary.Complete() |
| 979 | return $summary |
| 980 | } |
| 981 | |
| 982 | #endregion Orchestration |
| 983 | |
| 984 | #region Output |
| 985 | |
| 986 | function Write-ValidationConsoleOutput { |
| 987 | <# |
| 988 | .SYNOPSIS |
| 989 | Writes validation results to console. |
| 990 | |
| 991 | .PARAMETER Summary |
| 992 | ValidationSummary object to display. |
| 993 | |
| 994 | .PARAMETER ShowDetails |
| 995 | When true, shows per-file details. |
| 996 | #> |
| 997 | [CmdletBinding()] |
| 998 | param( |
| 999 | # Type constraint removed for testability (PowerShell class identity conflicts) |
| 1000 | [Parameter(Mandatory)] |
| 1001 | $Summary, |
| 1002 | |
| 1003 | [switch]$ShowDetails |
| 1004 | ) |
| 1005 | |
| 1006 | Write-Host "`n๐ Frontmatter Validation Results" -ForegroundColor Cyan |
| 1007 | Write-Host "โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ" -ForegroundColor DarkGray |
| 1008 | |
| 1009 | if ($ShowDetails) { |
| 1010 | foreach ($result in $Summary.Results) { |
| 1011 | $hasError = $result.Issues | Where-Object { $_.Type -eq 'Error' } | Select-Object -First 1 |
| 1012 | $hasWarning = $result.Issues | Where-Object { $_.Type -eq 'Warning' } | Select-Object -First 1 |
| 1013 | $icon = if ($hasError) { 'โ' } elseif ($hasWarning) { 'โ ๏ธ' } else { 'โ
' } |
| 1014 | Write-Host "$icon $($result.RelativePath)" |
| 1015 | |
| 1016 | foreach ($issue in $result.Issues) { |
| 1017 | $color = if ($issue.Type -eq 'Error') { 'Red' } else { 'Yellow' } |
| 1018 | $prefix = if ($issue.Type -eq 'Error') { ' โ' } else { ' โ ๏ธ' } |
| 1019 | Write-Host "$prefix $($issue.Message)" -ForegroundColor $color |
| 1020 | } |
| 1021 | } |
| 1022 | Write-Host "" |
| 1023 | } |
| 1024 | |
| 1025 | # Summary |
| 1026 | Write-Host "๐ Summary:" -ForegroundColor Cyan |
| 1027 | $errorColor = if ($Summary.FilesWithErrors -gt 0) { 'Red' } else { 'Green' } |
| 1028 | $warnColor = if ($Summary.FilesWithWarnings -gt 0) { 'Yellow' } else { 'Green' } |
| 1029 | |
| 1030 | Write-Host " Files validated: $($Summary.TotalFiles)" |
| 1031 | Write-Host " Files with errors: $($Summary.FilesWithErrors)" -ForegroundColor $errorColor |
| 1032 | Write-Host " Files with warnings: $($Summary.FilesWithWarnings)" -ForegroundColor $warnColor |
| 1033 | Write-Host " Duration: $($Summary.Duration.TotalSeconds.ToString('F2'))s" |
| 1034 | } |
| 1035 | |
| 1036 | function Export-ValidationResults { |
| 1037 | <# |
| 1038 | .SYNOPSIS |
| 1039 | Exports validation results to JSON file. |
| 1040 | |
| 1041 | .PARAMETER Summary |
| 1042 | ValidationSummary object to export. |
| 1043 | |
| 1044 | .PARAMETER OutputPath |
| 1045 | Path to output JSON file. |
| 1046 | #> |
| 1047 | [CmdletBinding()] |
| 1048 | param( |
| 1049 | # Type constraint removed for testability (PowerShell class identity conflicts) |
| 1050 | [Parameter(Mandatory)] |
| 1051 | $Summary, |
| 1052 | |
| 1053 | [Parameter(Mandatory)] |
| 1054 | [string]$OutputPath |
| 1055 | ) |
| 1056 | |
| 1057 | $outputDir = Split-Path -Path $OutputPath -Parent |
| 1058 | if ($outputDir -and -not (Test-Path $outputDir)) { |
| 1059 | New-Item -ItemType Directory -Path $outputDir -Force | Out-Null |
| 1060 | } |
| 1061 | |
| 1062 | $Summary.ToHashtable() | ConvertTo-Json -Depth 10 | Set-Content -Path $OutputPath -Encoding utf8 |
| 1063 | } |
| 1064 | |
| 1065 | #endregion Output |
| 1066 | |
| 1067 | #region Exports |
| 1068 | |
| 1069 | Export-ModuleMember -Function @( |
| 1070 | # Shared helpers |
| 1071 | 'Test-RequiredField' |
| 1072 | 'Test-DateFormat' |
| 1073 | 'Test-SuggestedFields' |
| 1074 | 'Test-TopicValue' |
| 1075 | # Content-type validators |
| 1076 | 'Test-RootCommunityFileFields' |
| 1077 | 'Test-DevContainerFileFields' |
| 1078 | 'Test-VSCodeReadmeFileFields' |
| 1079 | 'Test-GitHubResourceFileFields' |
| 1080 | 'Test-DocsFileFields' |
| 1081 | 'Test-CommonFields' |
| 1082 | 'Test-FooterPresence' |
| 1083 | 'Test-MarkdownFooter' |
| 1084 | # Classification |
| 1085 | 'Get-FileTypeInfo' |
| 1086 | # Orchestration |
| 1087 | 'Test-SingleFileFrontmatter' |
| 1088 | 'Invoke-FrontmatterValidation' |
| 1089 | # Output |
| 1090 | 'Write-ValidationConsoleOutput' |
| 1091 | 'Export-ValidationResults' |
| 1092 | ) |
| 1093 | |
| 1094 | #endregion Exports |
| 1095 | |