microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
hve-core-v2.0.0

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/linting/Modules/FrontmatterValidation.psm1

1144lines ยท modecode

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