microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/1873-devcontainer

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/linting/Modules/FrontmatterValidation.psm1

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