microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
fix/dependabot-uuid-postcss-overrides

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/linting/Modules/FrontmatterValidation.psm1

1097lines ยท 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 # Read file content
834 try {
835 $content = & $FileReader $FilePath
836 }
837 catch {
838 $result.AddError("Failed to read file: $($_.Exception.Message)", 'file')
839 return $result
840 }
841
842 # Parse frontmatter
843 $frontmatter = $null
844 $hasFrontmatterBlock = $content -match '(?s)^---\r?\n(.*?)\r?\n---'
845 if ($hasFrontmatterBlock) {
846 $yamlBlock = $Matches[1]
847
848 # Verify ConvertFrom-Yaml is available (requires powershell-yaml module)
849 if (-not (Get-Command -Name 'ConvertFrom-Yaml' -ErrorAction SilentlyContinue)) {
850 $result.AddError("ConvertFrom-Yaml cmdlet not found. Install powershell-yaml module: Install-Module -Name PowerShell-Yaml -RequiredVersion 0.4.7 -Force -Scope CurrentUser", 'dependency')
851 return $result
852 }
853
854 try {
855 $frontmatter = $yamlBlock | ConvertFrom-Yaml -ErrorAction Stop
856 }
857 catch {
858 $result.AddError("Invalid YAML syntax: $($_.Exception.Message)", 'yaml')
859 return $result
860 }
861 }
862
863 $result.HasFrontmatter = $null -ne $frontmatter
864 $result.Frontmatter = $frontmatter
865
866 # Only warn about missing frontmatter for content types that require it
867 # AI artifacts (.github prompts, instructions, agents, chatmodes) are exempt
868 if (-not $result.HasFrontmatter -and -not $isAiArtifact) {
869 $result.AddWarning('No frontmatter found', 'frontmatter')
870 # Continue to footer validation even without frontmatter
871 }
872
873 # Validate fields based on file type (only if frontmatter was successfully parsed)
874 $issues = @()
875
876 if ($null -ne $frontmatter) {
877 if ($fileTypeInfo.IsDocsFile) {
878 $issues = Test-DocsFileFields -Frontmatter $frontmatter -RelativePath $relativePath
879 }
880 elseif ($fileTypeInfo.IsInstruction -or $fileTypeInfo.IsPrompt -or $fileTypeInfo.IsChatMode -or $fileTypeInfo.IsAgent) {
881 $issues = Test-GitHubResourceFileFields -Frontmatter $frontmatter -FileTypeInfo $fileTypeInfo -RelativePath $relativePath
882 }
883 elseif ($fileTypeInfo.IsDevContainer) {
884 $issues = Test-DevContainerFileFields -Frontmatter $frontmatter -RelativePath $relativePath
885 }
886 elseif ($fileTypeInfo.IsVSCodeReadme) {
887 $issues = Test-VSCodeReadmeFileFields -Frontmatter $frontmatter -RelativePath $relativePath
888 }
889 elseif ($fileTypeInfo.IsRootCommunityFile) {
890 $issues = Test-RootCommunityFileFields -Frontmatter $frontmatter -RelativePath $relativePath
891 }
892
893 foreach ($issue in $issues) {
894 $result.AddIssue($issue)
895 }
896 }
897
898 # Common field validation for all content types with frontmatter
899 if ($null -ne $frontmatter) {
900 $commonIssues = Test-CommonFields -Frontmatter $frontmatter -RelativePath $relativePath
901 foreach ($commonIssue in $commonIssues) {
902 $result.AddIssue($commonIssue)
903 }
904 }
905
906 # Check if file matches footer exclusion pattern
907 # Normalize path separators for cross-platform pattern matching
908 $skipFooterForFile = $false
909 $normalizedRelativePath = $relativePath -replace '\\', '/'
910 foreach ($pattern in $FooterExcludePaths) {
911 $normalizedPattern = $pattern -replace '\\', '/'
912 if ($normalizedRelativePath -like $normalizedPattern) {
913 $skipFooterForFile = $true
914 break
915 }
916 }
917
918 # Footer validation for all markdown EXCEPT AI artifacts (prompts, instructions, agents, chatmodes)
919 if (-not $isAiArtifact -and -not $SkipFooterValidation -and -not $skipFooterForFile) {
920 # Determine severity based on file type
921 $footerSeverity = 'Warning'
922 if ($fileTypeInfo.IsRootCommunityFile -or $fileTypeInfo.IsDevContainer -or $fileTypeInfo.IsVSCodeReadme) {
923 $footerSeverity = 'Error'
924 }
925
926 $hasFooter = Test-MarkdownFooter -Content $content
927 $footerIssue = Test-FooterPresence -HasFooter $hasFooter -RelativePath $relativePath -Severity $footerSeverity
928 if ($footerIssue) {
929 $result.AddIssue($footerIssue)
930 }
931 }
932
933 return $result
934}
935
936function Invoke-FrontmatterValidation {
937 <#
938 .SYNOPSIS
939 Validates frontmatter across multiple markdown files.
940
941 .DESCRIPTION
942 Orchestrates validation of multiple files and aggregates results
943 into a ValidationSummary object.
944
945 .PARAMETER Files
946 Array of file paths to validate.
947
948 .PARAMETER RepoRoot
949 Repository root path for relative path computation and file classification.
950
951 .PARAMETER FooterExcludePaths
952 Array of wildcard patterns for files to exclude from footer validation.
953 Uses PowerShell -like operator for matching against relative paths.
954 Path separators are normalized to forward slashes for cross-platform support.
955
956 .OUTPUTS
957 ValidationSummary
958 #>
959 [CmdletBinding()]
960 [OutputType([ValidationSummary])]
961 param(
962 [Parameter(Mandatory)]
963 [string[]]$Files,
964
965 [Parameter(Mandatory)]
966 [ValidateNotNullOrEmpty()]
967 [string]$RepoRoot,
968
969 [string[]]$FooterExcludePaths = @(),
970
971 [switch]$SkipFooterValidation
972 )
973
974 $summary = [ValidationSummary]::new()
975
976 foreach ($file in $Files) {
977 $result = Test-SingleFileFrontmatter -FilePath $file -RepoRoot $RepoRoot -FooterExcludePaths $FooterExcludePaths -SkipFooterValidation:$SkipFooterValidation
978 $summary.AddResult($result)
979 }
980
981 $summary.Complete()
982 return $summary
983}
984
985#endregion Orchestration
986
987#region Output
988
989function Write-ValidationConsoleOutput {
990 <#
991 .SYNOPSIS
992 Writes validation results to console.
993
994 .PARAMETER Summary
995 ValidationSummary object to display.
996
997 .PARAMETER ShowDetails
998 When true, shows per-file details.
999 #>
1000 [CmdletBinding()]
1001 param(
1002 # Type constraint removed for testability (PowerShell class identity conflicts)
1003 [Parameter(Mandatory)]
1004 $Summary,
1005
1006 [switch]$ShowDetails
1007 )
1008
1009 Write-Host "`n๐Ÿ” Frontmatter Validation Results" -ForegroundColor Cyan
1010 Write-Host "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" -ForegroundColor DarkGray
1011
1012 if ($ShowDetails) {
1013 foreach ($result in $Summary.Results) {
1014 $hasError = $result.Issues | Where-Object { $_.Type -eq 'Error' } | Select-Object -First 1
1015 $hasWarning = $result.Issues | Where-Object { $_.Type -eq 'Warning' } | Select-Object -First 1
1016 $icon = if ($hasError) { 'โŒ' } elseif ($hasWarning) { 'โš ๏ธ' } else { 'โœ…' }
1017 Write-Host "$icon $($result.RelativePath)"
1018
1019 foreach ($issue in $result.Issues) {
1020 $color = if ($issue.Type -eq 'Error') { 'Red' } else { 'Yellow' }
1021 $prefix = if ($issue.Type -eq 'Error') { ' โŒ' } else { ' โš ๏ธ' }
1022 Write-Host "$prefix $($issue.Message)" -ForegroundColor $color
1023 }
1024 }
1025 Write-Host ""
1026 }
1027
1028 # Summary
1029 Write-Host "๐Ÿ“Š Summary:" -ForegroundColor Cyan
1030 $errorColor = if ($Summary.FilesWithErrors -gt 0) { 'Red' } else { 'Green' }
1031 $warnColor = if ($Summary.FilesWithWarnings -gt 0) { 'Yellow' } else { 'Green' }
1032
1033 Write-Host " Files validated: $($Summary.TotalFiles)"
1034 Write-Host " Files with errors: $($Summary.FilesWithErrors)" -ForegroundColor $errorColor
1035 Write-Host " Files with warnings: $($Summary.FilesWithWarnings)" -ForegroundColor $warnColor
1036 Write-Host " Duration: $($Summary.Duration.TotalSeconds.ToString('F2'))s"
1037}
1038
1039function Export-ValidationResults {
1040 <#
1041 .SYNOPSIS
1042 Exports validation results to JSON file.
1043
1044 .PARAMETER Summary
1045 ValidationSummary object to export.
1046
1047 .PARAMETER OutputPath
1048 Path to output JSON file.
1049 #>
1050 [CmdletBinding()]
1051 param(
1052 # Type constraint removed for testability (PowerShell class identity conflicts)
1053 [Parameter(Mandatory)]
1054 $Summary,
1055
1056 [Parameter(Mandatory)]
1057 [string]$OutputPath
1058 )
1059
1060 $outputDir = Split-Path -Path $OutputPath -Parent
1061 if ($outputDir -and -not (Test-Path $outputDir)) {
1062 New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
1063 }
1064
1065 $Summary.ToHashtable() | ConvertTo-Json -Depth 10 | Set-Content -Path $OutputPath -Encoding utf8
1066}
1067
1068#endregion Output
1069
1070#region Exports
1071
1072Export-ModuleMember -Function @(
1073 # Shared helpers
1074 'Test-RequiredField'
1075 'Test-DateFormat'
1076 'Test-SuggestedFields'
1077 'Test-TopicValue'
1078 # Content-type validators
1079 'Test-RootCommunityFileFields'
1080 'Test-DevContainerFileFields'
1081 'Test-VSCodeReadmeFileFields'
1082 'Test-GitHubResourceFileFields'
1083 'Test-DocsFileFields'
1084 'Test-CommonFields'
1085 'Test-FooterPresence'
1086 'Test-MarkdownFooter'
1087 # Classification
1088 'Get-FileTypeInfo'
1089 # Orchestration
1090 'Test-SingleFileFrontmatter'
1091 'Invoke-FrontmatterValidation'
1092 # Output
1093 'Write-ValidationConsoleOutput'
1094 'Export-ValidationResults'
1095)
1096
1097#endregion Exports
1098