microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
docs/621-ai-artifacts

Branches

Tags

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

Clone

HTTPS

Download ZIP

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
16class 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
46class 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
70class 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
141class 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
226function 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
268function 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
309function 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
349function 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
390function 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
434function 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
468function 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
502function 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
550function 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
597function 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
647function 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
683function 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
728function 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
776function 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
933function 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
986function 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
1036function 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
1069Export-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