microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/sub-pr-185

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/linting/Validate-MarkdownFrontmatter.ps1

1118lines · modecode

1# Validate-MarkdownFrontmatter.ps1
2#
3# Purpose: Validates frontmatter consistency and footer presence across markdown files
4# Author: HVE Core Team
5# Created: 2025-11-05
6#
7# This script validates:
8# - Required frontmatter fields (title, description, author, ms.date)
9# - Date format (ISO 8601: YYYY-MM-DD)
10# - Standard Copilot attribution footer (excludes Microsoft template files)
11# - Content structure by file type (GitHub configs, DevContainer docs, etc.)
12
13param(
14 [Parameter(Mandatory = $false)]
15 [string[]]$Paths = @('.'),
16
17 [Parameter(Mandatory = $false)]
18 [string[]]$Files = @(),
19
20 [Parameter(Mandatory = $false)]
21 [switch]$WarningsAsErrors,
22
23 [Parameter(Mandatory = $false)]
24 [switch]$ChangedFilesOnly,
25
26 [Parameter(Mandatory = $false)]
27 [string]$BaseBranch = "origin/main",
28
29 [Parameter(Mandatory = $false)]
30 [switch]$SkipFooterValidation,
31
32 [Parameter(Mandatory = $false)]
33 [switch]$EnableSchemaValidation
34)
35
36# Import LintingHelpers module
37Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'Modules/LintingHelpers.psm1') -Force
38
39function Get-MarkdownFrontmatter {
40 <#
41 .SYNOPSIS
42 Extracts YAML frontmatter from a markdown file.
43
44 .DESCRIPTION
45 Parses YAML frontmatter from the beginning of a markdown file and returns
46 a structured object containing the frontmatter data and content.
47
48 .PARAMETER FilePath
49 Path to the markdown file to parse.
50
51 .OUTPUTS
52 Returns a hashtable with Frontmatter, FrontmatterEndIndex, and Content properties.
53 #>
54
55 param(
56 [Parameter(Mandatory = $true)]
57 [ValidateNotNullOrEmpty()]
58 [string]$FilePath
59 )
60
61 if (-not (Test-Path $FilePath)) {
62 Write-Warning "File not found: $FilePath"
63 return $null
64 }
65
66 try {
67 $content = Get-Content -Path $FilePath -Raw -Encoding UTF8
68
69 # Check if file starts with YAML frontmatter
70 if (-not $content.StartsWith("---")) {
71 return $null
72 }
73
74 # Find the end of frontmatter
75 $lines = $content -split "`n"
76 $endIndex = -1
77
78 for ($i = 1; $i -lt $lines.Count; $i++) {
79 if ($lines[$i].Trim() -eq "---") {
80 $endIndex = $i
81 break
82 }
83 }
84
85 if ($endIndex -eq -1) {
86 Write-Warning "Malformed YAML frontmatter in: $FilePath"
87 return $null
88 }
89
90 # Extract frontmatter lines
91 $frontmatterLines = $lines[1..($endIndex - 1)]
92 $frontmatter = @{}
93
94 foreach ($line in $frontmatterLines) {
95 $trimmedLine = $line.Trim()
96 if ($trimmedLine -eq "" -or $trimmedLine.StartsWith("#")) {
97 continue
98 }
99
100 if ($line -match "^([^:]+):\s*(.*)$") {
101 $key = $matches[1].Trim()
102 $value = $matches[2].Trim()
103
104 # Handle array values (YAML arrays starting with -)
105 if ($value.StartsWith("[") -and $value.EndsWith("]")) {
106 # Parse JSON-style array
107 try {
108 $frontmatter[$key] = $value | ConvertFrom-Json
109 }
110 catch {
111 $frontmatter[$key] = $value
112 }
113 }
114 else {
115 # Check if this is the start of a YAML array
116 if ($value.StartsWith("-") -or $value.Trim() -eq "") {
117 $arrayValues = @()
118 if ($value.StartsWith("-")) {
119 $arrayValues += $value.Substring(1).Trim()
120 }
121
122 # Look for additional array items
123 $j = $frontmatterLines.IndexOf($line) + 1
124 while ($j -lt $frontmatterLines.Count -and $frontmatterLines[$j].StartsWith(" -")) {
125 $arrayValues += $frontmatterLines[$j].Substring(3).Trim()
126 $j++
127 }
128
129 if ($arrayValues.Count -gt 0) {
130 $frontmatter[$key] = $arrayValues
131 }
132 else {
133 $frontmatter[$key] = $value
134 }
135 }
136 else {
137 # Remove quotes if present
138 if (($value.StartsWith('"') -and $value.EndsWith('"')) -or
139 ($value.StartsWith("'") -and $value.EndsWith("'"))) {
140 $value = $value.Substring(1, $value.Length - 2)
141 }
142 $frontmatter[$key] = $value
143 }
144 }
145 }
146 }
147
148 return @{
149 Frontmatter = $frontmatter
150 FrontmatterEndIndex = $endIndex + 1
151 Content = ($lines[($endIndex + 1)..($lines.Count - 1)] -join "`n")
152 }
153 }
154 catch {
155 Write-Warning "Error parsing frontmatter in ${FilePath}: [$($_.Exception.GetType().Name)] $($_.Exception.Message)"
156 return $null
157 }
158}
159
160function Test-MarkdownFooter {
161 <#
162 .SYNOPSIS
163 Checks if a markdown file has the standard Copilot footer.
164
165 .DESCRIPTION
166 Validates that markdown files end with the standard Copilot attribution footer.
167 Supports both plain text and markdownlint-wrapped variants.
168
169 .PARAMETER Content
170 The full content of the markdown file (from Get-MarkdownFrontmatter result).
171
172 .OUTPUTS
173 Returns $true if footer is present and valid, $false otherwise.
174 #>
175 param(
176 [Parameter(Mandatory = $true)]
177 [string]$Content
178 )
179
180 # Normalize content (remove HTML comments and markdown formatting)
181 # Use (?s) for multiline HTML comments and comprehensive markdown format removal
182 $normalized = $Content -replace '(?s)<!--.*?-->', '' # Remove HTML comments (multiline)
183 $normalized = $normalized -replace '\*\*([^*]+)\*\*', '$1' # Remove bold (**text**)
184 $normalized = $normalized -replace '__([^_]+)__', '$1' # Remove bold (__text__)
185 $normalized = $normalized -replace '\*([^*]+)\*', '$1' # Remove italic (*text*)
186 $normalized = $normalized -replace '_([^_]+)_', '$1' # Remove italic (_text_)
187 $normalized = $normalized -replace '~~([^~]+)~~', '$1' # Remove strikethrough
188 $normalized = $normalized -replace '`([^`]+)`', '$1' # Remove inline code
189 $normalized = $normalized.TrimEnd()
190
191 # Core footer pattern (flexible for line breaks and formatting variations)
192 $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\.?'
193
194 return $normalized -match $pattern
195}
196
197function Initialize-JsonSchemaValidation {
198 <#
199 .SYNOPSIS
200 Initializes JSON Schema validation using PowerShell native capabilities.
201
202 .DESCRIPTION
203 Validates that schema files exist and PowerShell can process JSON.
204 Uses PowerShell's built-in JSON and YAML processing capabilities.
205 #>
206 try {
207 # Check if we can work with JSON (built into PowerShell)
208 $testJson = '{"test": "value"}' | ConvertFrom-Json
209 if ($null -eq $testJson) {
210 Write-Warning "PowerShell JSON processing not available."
211 return $false
212 }
213
214 # Schema validation is ready using PowerShell native capabilities
215 return $true
216 }
217 catch {
218 Write-Warning "Error initializing schema validation: $_"
219 return $false
220 }
221}
222
223function Get-SchemaForFile {
224 <#
225 .SYNOPSIS
226 Determines the appropriate JSON Schema for a given file.
227
228 .PARAMETER FilePath
229 The path of the file to get schema for.
230
231 .OUTPUTS
232 Returns the schema file path or null if no specific schema applies.
233 #>
234 param(
235 [Parameter(Mandatory = $true)]
236 [string]$FilePath
237 )
238
239 $schemaDir = Join-Path -Path $PSScriptRoot -ChildPath 'schemas'
240 $mappingPath = Join-Path -Path $schemaDir -ChildPath 'schema-mapping.json'
241
242 if (-not (Test-Path $mappingPath)) {
243 return $null
244 }
245
246 try {
247 $mapping = Get-Content $mappingPath | ConvertFrom-Json
248
249 # Find repository root by searching for .git directory
250 $repoRoot = $PSScriptRoot
251 while ($repoRoot -and -not (Test-Path (Join-Path $repoRoot '.git'))) {
252 $repoRoot = Split-Path -Parent $repoRoot
253 }
254 if (-not $repoRoot) {
255 Write-Warning "Could not find repository root"
256 return $null
257 }
258
259 $relativePath = [System.IO.Path]::GetRelativePath($repoRoot, $FilePath) -replace '\\', '/'
260 $fileName = [System.IO.Path]::GetFileName($FilePath)
261
262 foreach ($rule in $mapping.mappings) {
263 # Directory-based patterns (check these FIRST for proper specificity)
264 if ($rule.pattern -like "*/**/*") {
265 # Convert glob to regex: '**/' => '(.*/)?', '*' => '[^/]*', '.' => '\.'
266 $regexPattern = $rule.pattern
267 $regexPattern = $regexPattern -replace '\*\*/', '(.*/)?'
268 $regexPattern = $regexPattern -replace '\*', '[^/]*'
269 $regexPattern = $regexPattern -replace '\.', '\.'
270 $regexPattern = '^' + $regexPattern + '$'
271 if ($relativePath -match $regexPattern) {
272 return Join-Path -Path $schemaDir -ChildPath $rule.schema
273 }
274 }
275 # Simple pattern matching for root file names only
276 elseif ($rule.pattern -match '\|') {
277 $patterns = $rule.pattern -split '\|'
278 # Only match if file is in root (relativePath equals fileName)
279 if ($relativePath -eq $fileName -and $fileName -in $patterns) {
280 return Join-Path -Path $schemaDir -ChildPath $rule.schema
281 }
282 }
283 # Simple file patterns
284 elseif ($relativePath -like $rule.pattern -or $fileName -like $rule.pattern) {
285 # Convert glob to regex: '**/' => '(.*/)?', '*' => '[^/]*', '.' => '\.'
286 $regexPattern = $rule.pattern
287 $regexPattern = $regexPattern -replace '\*\*/', '(.*/)?'
288 $regexPattern = $regexPattern -replace '\*', '[^/]*'
289 $regexPattern = $regexPattern -replace '\.', '\.'
290 $regexPattern = '^' + $regexPattern + '$'
291 if ($relativePath -match $regexPattern) {
292 return Join-Path -Path $schemaDir -ChildPath $rule.schema
293 }
294 }
295 # Simple file patterns
296 elseif ($relativePath -like $rule.pattern -or $fileName -like $rule.pattern) {
297 return Join-Path -Path $schemaDir -ChildPath $rule.schema
298 }
299 }
300
301 # Return default schema if available
302 if ($mapping.defaultSchema) {
303 return Join-Path -Path $schemaDir -ChildPath $mapping.defaultSchema
304 }
305 }
306 catch {
307 Write-Warning "Error reading schema mapping: $_"
308 }
309
310 return $null
311}
312
313function Test-JsonSchemaValidation {
314 <#
315 .SYNOPSIS
316 Validates frontmatter against JSON Schema using PowerShell native capabilities.
317
318 .PARAMETER Frontmatter
319 The frontmatter hashtable to validate.
320
321 .PARAMETER SchemaPath
322 Path to the JSON Schema file.
323
324 .OUTPUTS
325 Returns validation result with errors and warnings.
326
327 .NOTES
328 Validation limitations (intentional for soft validation):
329 - $ref references are not resolved (workaround: inline base schema properties)
330 - allOf/anyOf/oneOf schema composition is not supported
331 - object type validation is not implemented
332 - enum and minLength validations are supported
333 #>
334 param(
335 [Parameter(Mandatory = $true)]
336 [hashtable]$Frontmatter,
337
338 [Parameter(Mandatory = $true)]
339 [string]$SchemaPath
340 )
341
342 if (-not (Test-Path $SchemaPath)) {
343 return @{
344 IsValid = $false
345 Errors = @("Schema file not found: $SchemaPath")
346 Warnings = @()
347 }
348 }
349
350 try {
351 # Load the schema file
352 $schemaContent = Get-Content $SchemaPath -Raw | ConvertFrom-Json
353 $errors = @()
354 $warnings = @()
355
356 # Basic validation using PowerShell native capabilities
357 # Check required fields
358 if ($schemaContent.required) {
359 foreach ($requiredField in $schemaContent.required) {
360 if (-not $Frontmatter.ContainsKey($requiredField)) {
361 $errors += "Missing required field: $requiredField"
362 }
363 }
364 }
365
366 # Check field types if properties are defined
367 if ($schemaContent.properties) {
368 foreach ($prop in $schemaContent.properties.PSObject.Properties) {
369 $fieldName = $prop.Name
370 $fieldSchema = $prop.Value
371
372 if ($Frontmatter.ContainsKey($fieldName)) {
373 $value = $Frontmatter[$fieldName]
374
375 # Type validation
376 if ($fieldSchema.type) {
377 switch ($fieldSchema.type) {
378 'string' {
379 if ($value -isnot [string]) {
380 $errors += "Field '$fieldName' must be a string"
381 }
382 }
383 'array' {
384 if ($value -isnot [array] -and $value -isnot [System.Collections.IEnumerable]) {
385 $errors += "Field '$fieldName' must be an array"
386 }
387 }
388 'boolean' {
389 if ($value -isnot [bool] -and $value -notin @('true', 'false', 'True', 'False')) {
390 $errors += "Field '$fieldName' must be a boolean"
391 }
392 }
393 }
394 }
395
396 # Pattern validation for strings
397 if ($fieldSchema.pattern -and $value -is [string]) {
398 if ($value -notmatch $fieldSchema.pattern) {
399 $errors += "Field '$fieldName' does not match required pattern: $($fieldSchema.pattern)"
400 }
401 }
402
403 # Enum validation
404 if ($fieldSchema.enum) {
405 if ($value -is [array]) {
406 foreach ($item in $value) {
407 if ($item -notin $fieldSchema.enum) {
408 $errors += "Field '$fieldName' contains invalid value: $item. Allowed: $($fieldSchema.enum -join ', ')"
409 }
410 }
411 } else {
412 if ($value -notin $fieldSchema.enum) {
413 $errors += "Field '$fieldName' must be one of: $($fieldSchema.enum -join ', '). Got: $value"
414 }
415 }
416 }
417
418 # MinLength validation for strings
419 if ($fieldSchema.minLength -and $value -is [string]) {
420 if ($value.Length -lt $fieldSchema.minLength) {
421 $errors += "Field '$fieldName' must have minimum length of $($fieldSchema.minLength)"
422 }
423 }
424 }
425 }
426 }
427
428 return @{
429 IsValid = ($errors.Count -eq 0)
430 Errors = $errors
431 Warnings = $warnings
432 SchemaUsed = $SchemaPath
433 Note = "Schema validation using PowerShell native capabilities"
434 }
435 }
436 catch {
437 return @{
438 IsValid = $false
439 Errors = @("Schema validation error: $_")
440 Warnings = @()
441 }
442 }
443}
444
445function Test-FrontmatterValidation {
446 <#
447 .SYNOPSIS
448 Validates frontmatter across all markdown files in specified paths.
449
450 .DESCRIPTION
451 Performs comprehensive frontmatter validation including required fields,
452 date format validation, and content type-specific requirements.
453
454 .PARAMETER Paths
455 Array of paths to search for markdown files.
456
457 .PARAMETER Files
458 Array of specific file paths to validate (takes precedence over Paths).
459
460 .PARAMETER WarningsAsErrors
461 Treat warnings as errors (fail validation on warnings).
462
463 .PARAMETER EnableSchemaValidation
464 Enable JSON Schema validation against defined schemas.
465
466 .OUTPUTS
467 Returns validation results with errors and warnings.
468 #>
469
470 param(
471 [Parameter(Mandatory = $false)]
472 [AllowEmptyCollection()]
473 [string[]]$Paths = @(),
474
475 [Parameter(Mandatory = $false)]
476 [switch]$SkipFooterValidation,
477
478 [Parameter(Mandatory = $false)]
479 [AllowEmptyCollection()]
480 [string[]]$Files = @(),
481
482 [Parameter(Mandatory = $false)]
483 [switch]$WarningsAsErrors,
484
485 [Parameter(Mandatory = $false)]
486 [switch]$ChangedFilesOnly,
487
488 [Parameter(Mandatory = $false)]
489 [string]$BaseBranch = "origin/main",
490
491 [Parameter(Mandatory = $false)]
492 [switch]$EnableSchemaValidation
493 )
494
495 # Get repository root
496 $repoRoot = (Get-Location).Path
497 if (-not (Test-Path ".git")) {
498 $gitRoot = git rev-parse --show-toplevel 2>$null
499 if ($gitRoot) {
500 $repoRoot = $gitRoot
501 }
502 }
503
504 # Parse .gitignore patterns using shared helper function
505 $gitignorePath = Join-Path $repoRoot ".gitignore"
506 $gitignorePatterns = Get-GitIgnorePatterns -GitIgnorePath $gitignorePath
507
508 Write-Host "🔍 Validating frontmatter across markdown files..." -ForegroundColor Cyan
509
510 # Input validation and sanitization
511 $errors = @()
512 $warnings = @()
513 $filesWithErrors = [System.Collections.Generic.HashSet[string]]::new()
514 $filesWithWarnings = [System.Collections.Generic.HashSet[string]]::new()
515
516 # If ChangedFilesOnly is specified, get changed files from git
517 if ($ChangedFilesOnly) {
518 Write-Host "🔍 Detecting changed markdown files from git diff..." -ForegroundColor Cyan
519 $gitChangedFiles = Get-ChangedMarkdownFileGroup -BaseBranch $BaseBranch
520 if ($gitChangedFiles.Count -gt 0) {
521 $Files = $gitChangedFiles
522 Write-Host "Found $($Files.Count) changed markdown files to validate" -ForegroundColor Cyan
523 }
524 else {
525 Write-Host "No changed markdown files found - validation complete" -ForegroundColor Green
526 return @{
527 Errors = @()
528 Warnings = @()
529 HasIssues = $false
530 TotalFilesChecked = 0
531 }
532 }
533 }
534
535 # Sanitize Files array - remove empty or null entries
536 if ($Files.Count -gt 0) {
537 $sanitizedFiles = @()
538 foreach ($file in $Files) {
539 if (-not [string]::IsNullOrEmpty($file)) {
540 $sanitizedFiles += $file.Trim()
541 }
542 else {
543 Write-Verbose "Filtering out empty file path from Files array"
544 }
545 }
546 $Files = $sanitizedFiles
547 }
548
549 # Sanitize Paths array - remove empty or null entries
550 if ($Paths.Count -gt 0) {
551 $sanitizedPaths = @()
552 foreach ($path in $Paths) {
553 if (-not [string]::IsNullOrEmpty($path)) {
554 $sanitizedPaths += $path.Trim()
555 }
556 else {
557 Write-Verbose "Filtering out empty path from Paths array"
558 }
559 }
560 $Paths = $sanitizedPaths
561 }
562
563 # Ensure we have at least one valid input source
564 if ($Files.Count -eq 0 -and $Paths.Count -eq 0) {
565 $warnings += "No valid files or paths provided for validation"
566 return @{
567 Errors = @()
568 Warnings = $warnings
569 HasIssues = $true
570 TotalFilesChecked = 0
571 }
572 }
573
574 # Get markdown files either from specific files or from paths
575 [System.Collections.ArrayList]$markdownFiles = @()
576
577 if ($Files.Count -gt 0) {
578 Write-Host "Validating specific files..." -ForegroundColor Cyan
579 foreach ($file in $Files) {
580 if (-not [string]::IsNullOrEmpty($file) -and (Test-Path $file -PathType Leaf)) {
581 if ($file -like "*.md") {
582 $fileItem = Get-Item $file
583 if ($null -ne $fileItem -and -not [string]::IsNullOrEmpty($fileItem.FullName)) {
584 $markdownFiles += $fileItem
585 Write-Verbose "Added specific file: $file"
586 }
587 }
588 else {
589 Write-Verbose "Skipping non-markdown file: $file"
590 }
591 }
592 else {
593 Write-Warning "File not found or invalid: $file"
594 }
595 }
596 }
597 else {
598 Write-Host "Searching for markdown files in specified paths..." -ForegroundColor Cyan
599 foreach ($path in $Paths) {
600 if (Test-Path $path) {
601 # Get files and filter manually with strongly typed array
602 $rawFiles = Get-ChildItem -Path $path -Filter '*.md' -Recurse -File -ErrorAction SilentlyContinue
603
604 # Manual filtering with strongly typed array to prevent implicit string conversion
605 [System.IO.FileInfo[]]$files = @()
606 foreach ($f in $rawFiles) {
607 if ($null -eq $f -or
608 [string]::IsNullOrEmpty($f.FullName) -or
609 $f.PSIsContainer -eq $true) {
610 continue
611 }
612
613 # Check against gitignore patterns
614 $excluded = $false
615 foreach ($pattern in $gitignorePatterns) {
616 if ($f.FullName -like $pattern) {
617 $excluded = $true
618 break
619 }
620 }
621
622 if (-not $excluded) {
623 $files += $f
624 }
625 }
626
627 if ($files.Count -gt 0) {
628 [void]$markdownFiles.AddRange($files)
629 Write-Verbose "Found $($files.Count) markdown files in $path"
630 }
631 else {
632 Write-Verbose "No markdown files found in $path"
633 }
634 }
635 else {
636 Write-Warning "Path not found: $path"
637 }
638 }
639 }
640
641 Write-Host "Found $($markdownFiles.Count) total markdown files to validate" -ForegroundColor Cyan
642
643 # Initialize schema validation once before processing files
644 $schemaValidationEnabled = $false
645 if ($EnableSchemaValidation) {
646 $schemaValidationEnabled = Initialize-JsonSchemaValidation
647 if (-not $schemaValidationEnabled) {
648 Write-Warning "Schema validation requested but not available - continuing without schema validation"
649 }
650 }
651
652 foreach ($file in $markdownFiles) {
653 # Skip null file objects or files with empty/null paths
654 if ($null -eq $file) {
655 Write-Verbose "Skipping null file object"
656 continue
657 }
658
659 if ([string]::IsNullOrEmpty($file.FullName)) {
660 Write-Verbose "Skipping file with empty path"
661 continue
662 }
663
664 Write-Verbose "Validating: $($file.FullName)"
665
666 try {
667 $frontmatter = Get-MarkdownFrontmatter -FilePath $file.FullName
668
669 if ($frontmatter) {
670 # Soft validation mode: Schema validation reports issues via Write-Warning without failing builds.
671 # This provides comprehensive advisory feedback while manual validation below enforces critical rules.
672 if ($schemaValidationEnabled) {
673 $schemaPath = Get-SchemaForFile -FilePath $file.FullName
674 if ($schemaPath) {
675 $schemaResult = Test-JsonSchemaValidation -Frontmatter $frontmatter.Frontmatter -SchemaPath $schemaPath
676 if ($schemaResult.Errors.Count -gt 0) {
677 Write-Warning "JSON Schema validation errors in $($file.FullName):"
678 $schemaResult.Errors | ForEach-Object { Write-Warning " - $_" }
679 }
680 if ($schemaResult.Warnings.Count -gt 0) {
681 Write-Verbose "JSON Schema validation warnings in $($file.FullName):"
682 $schemaResult.Warnings | ForEach-Object { Write-Verbose " - $_" }
683 }
684 }
685 }
686
687 # Determine content type and required fields
688 $isGitHub = $file.DirectoryName -like "*.github*"
689 $isChatMode = $file.Name -like "*.chatmode.md"
690 $isPrompt = $file.Name -like "*.prompt.md"
691 $isInstruction = $file.Name -like "*.instructions.md"
692 $isRootCommunityFile = ($file.DirectoryName -eq $repoRoot) -and
693 ($file.Name -in @('CODE_OF_CONDUCT.md', 'CONTRIBUTING.md',
694 'SECURITY.md', 'SUPPORT.md', 'README.md'))
695 $isDevContainer = $file.DirectoryName -like "*.devcontainer*" -and $file.Name -eq 'README.md'
696 $isVSCodeReadme = $file.DirectoryName -like "*.vscode*" -and $file.Name -eq 'README.md'
697
698 # Determine if file should have footer
699 $shouldHaveFooter = $false
700 $footerSeverity = 'Error' # Default to error if footer is required
701
702 # Set footer requirements for root community files
703 if ($isRootCommunityFile) {
704 # All root community files require footers in hve-core
705 $shouldHaveFooter = $true
706 $footerSeverity = 'Error'
707 }
708 elseif ($isDevContainer) {
709 # DevContainer docs are custom
710 $shouldHaveFooter = $true
711 $footerSeverity = 'Error'
712 }
713 elseif ($isVSCodeReadme) {
714 # VS Code configuration docs require footers
715 $shouldHaveFooter = $true
716 $footerSeverity = 'Error'
717 }
718 elseif ($isGitHub) {
719 if ($file.Name -eq 'README.md') {
720 # GitHub subdirectory READMEs should have footers
721 $shouldHaveFooter = $true
722 $footerSeverity = 'Error'
723 }
724 # Chatmodes, instructions, and prompts are excluded from footer validation
725 # (they are internal configuration files, not public documentation)
726 }
727
728 # Validate required fields for root community files
729 if ($isRootCommunityFile) {
730 $requiredFields = @('title', 'description')
731 $suggestedFields = @('author', 'ms.date')
732
733 foreach ($field in $requiredFields) {
734 if (-not $frontmatter.Frontmatter.ContainsKey($field)) {
735 $errorMsg = "Missing required field '$field' in: $($file.FullName)"
736 $errors += $errorMsg
737 Write-GitHubAnnotation -Type 'error' -Message "Missing required field '$field'" -File $file.FullName
738 }
739 }
740
741 foreach ($field in $suggestedFields) {
742 if (-not $frontmatter.Frontmatter.ContainsKey($field)) {
743 $warningMsg = "Suggested field '$field' missing in: $($file.FullName)"
744 $warnings += $warningMsg
745 [void]$filesWithWarnings.Add($file.FullName)
746 Write-GitHubAnnotation -Type 'warning' -Message "Suggested field '$field' missing" -File $file.FullName
747 }
748 }
749
750 # Validate date format (ISO 8601: YYYY-MM-DD) or placeholder (YYYY-MM-dd)
751 if ($frontmatter.Frontmatter.ContainsKey('ms.date')) {
752 $date = $frontmatter.Frontmatter['ms.date']
753 if ($date -notmatch '^(\d{4}-\d{2}-\d{2}|\(YYYY-MM-dd\))$') {
754 $warningMsg = "Invalid date format in: $($file.FullName). Expected YYYY-MM-DD (ISO 8601), got: $date"
755 $warnings += $warningMsg
756 [void]$filesWithWarnings.Add($file.FullName)
757 Write-GitHubAnnotation -Type 'warning' -Message "Invalid date format: Expected YYYY-MM-DD (ISO 8601), got: $date" -File $file.FullName
758 }
759 }
760 }
761 # Validate .devcontainer documentation
762 elseif ($isDevContainer) {
763 $requiredFields = @('title', 'description')
764
765 foreach ($field in $requiredFields) {
766 if (-not $frontmatter.Frontmatter.ContainsKey($field)) {
767 $errorMsg = "Missing required field '$field' in: $($file.FullName)"
768 $errors += $errorMsg
769 [void]$filesWithErrors.Add($file.FullName)
770 Write-GitHubAnnotation -Type 'error' -Message "Missing required field '$field'" -File $file.FullName
771 }
772 }
773 }
774 # Validate .vscode documentation
775 elseif ($isVSCodeReadme) {
776 $requiredFields = @('title', 'description')
777
778 foreach ($field in $requiredFields) {
779 if (-not $frontmatter.Frontmatter.ContainsKey($field)) {
780 $errorMsg = "Missing required field '$field' in: $($file.FullName)"
781 $errors += $errorMsg
782 [void]$filesWithErrors.Add($file.FullName)
783 Write-GitHubAnnotation -Type 'error' -Message "Missing required field '$field'" -File $file.FullName
784 }
785 }
786 }
787
788 # GitHub resources have different requirements
789 elseif ($isGitHub) {
790 # ChatMode files (.chatmode.md) have specific frontmatter structure
791 if ($isChatMode) {
792 # ChatMode files typically have description, tools, etc. but not standard doc fields
793 # Only warn if missing description as it's commonly used
794 if (-not $frontmatter.Frontmatter.ContainsKey('description')) {
795 $warnings += "ChatMode file missing 'description' field: $($file.FullName)"
796 [void]$filesWithWarnings.Add($file.FullName)
797 }
798 }
799 # Instruction files (.instructions.md) have specific patterns
800 elseif ($isInstruction) {
801 # Instruction files should have 'applyTo' field for context-specific instructions
802 # This is informational only - does not fail validation
803 if (-not $frontmatter.Frontmatter.ContainsKey('applyTo')) {
804 Write-Verbose "Instruction file missing optional 'applyTo' field: $($file.FullName)"
805 }
806
807 # Validate required description field for instruction files
808 if (-not $frontmatter.Frontmatter.ContainsKey('description')) {
809 $errors += "Instruction file missing required 'description' field: $($file.FullName)"
810 [void]$filesWithErrors.Add($file.FullName)
811 Write-GitHubAnnotation -Type 'error' -Message "Missing required field 'description'" -File $file.FullName
812 }
813 }
814 # Prompt files (.prompt.md) are instructions/templates
815 elseif ($isPrompt) {
816 # Prompt files are typically instruction content, no specific frontmatter required
817 # These are generally freeform content
818 }
819 # Other GitHub files (exclude standard GitHub templates)
820 elseif ($file.Name -like "*template*" -and
821 -not ($file.Name -in @('PULL_REQUEST_TEMPLATE.md', 'ISSUE_TEMPLATE.md')) -and
822 -not $frontmatter.Frontmatter.ContainsKey('name')) {
823 $warnings += "GitHub template missing 'name' field: $($file.FullName)"
824 [void]$filesWithWarnings.Add($file.FullName)
825 }
826 }
827
828 # Validate keywords array (applies to all content types)
829 if ($frontmatter.Frontmatter.ContainsKey('keywords')) {
830 $keywords = $frontmatter.Frontmatter['keywords']
831 if ($keywords -isnot [array] -and $keywords -notmatch ',') {
832 $warnings += "Keywords should be an array in: $($file.FullName)"
833 [void]$filesWithWarnings.Add($file.FullName)
834 }
835 }
836 # Validate estimated_reading_time if present
837 if ($frontmatter.Frontmatter.ContainsKey('estimated_reading_time')) {
838 $readingTime = $frontmatter.Frontmatter['estimated_reading_time']
839 if ($readingTime -notmatch '^\d+$') {
840 $warnings += "Invalid estimated_reading_time format in: $($file.FullName). Should be a number."
841 [void]$filesWithWarnings.Add($file.FullName)
842 }
843 }
844
845 # Manual validation enforces critical rules (fails builds); schema validation above provides comprehensive advisory feedback (soft mode).
846 $isDocsFile = $file.DirectoryName -like "*docs*" -and -not $isGitHubLocal
847 if ($isDocsFile) {
848 # Documentation files should have comprehensive frontmatter
849 $requiredDocsFields = @('title', 'description')
850 $suggestedDocsFields = @('author', 'ms.date', 'ms.topic')
851
852 foreach ($field in $requiredDocsFields) {
853 if (-not $frontmatter.Frontmatter.ContainsKey($field)) {
854 $errors += "Documentation file missing required field '$field' in: $($file.FullName)"
855 [void]$filesWithErrors.Add($file.FullName)
856 Write-GitHubAnnotation -Type 'error' -Message "Missing required field '$field'" -File $file.FullName
857 }
858 }
859
860 foreach ($field in $suggestedDocsFields) {
861 if (-not $frontmatter.Frontmatter.ContainsKey($field)) {
862 $warnings += "Documentation file missing suggested field '$field' in: $($file.FullName)"
863 [void]$filesWithWarnings.Add($file.FullName)
864 Write-GitHubAnnotation -Type 'warning' -Message "Suggested field '$field' missing" -File $file.FullName
865 }
866 }
867
868 # Validate date format (ISO 8601: YYYY-MM-DD) or placeholder (YYYY-MM-dd) for docs
869 if ($frontmatter.Frontmatter.ContainsKey('ms.date')) {
870 $date = $frontmatter.Frontmatter['ms.date']
871 if ($date -notmatch '^(\d{4}-\d{2}-\d{2}|\(YYYY-MM-dd\))$') {
872 $warnings += "Invalid date format in: $($file.FullName). Expected YYYY-MM-DD (ISO 8601), got: $date"
873 [void]$filesWithWarnings.Add($file.FullName)
874 Write-GitHubAnnotation -Type 'warning' -Message "Invalid date format: Expected YYYY-MM-DD (ISO 8601), got: $date" -File $file.FullName
875 }
876 }
877 }
878
879 # Validate footer presence
880 if (-not $SkipFooterValidation -and $shouldHaveFooter -and $frontmatter.Content) {
881 $hasFooter = Test-MarkdownFooter -Content $frontmatter.Content
882
883 if (-not $hasFooter) {
884 $footerMessage = "Missing standard Copilot footer in: $($file.FullName)"
885
886 if ($footerSeverity -eq 'Error') {
887 $errors += $footerMessage
888 [void]$filesWithErrors.Add($file.FullName)
889 Write-GitHubAnnotation -Type 'error' -Message "Missing standard Copilot footer" -File $file.FullName
890 }
891 else {
892 $warnings += $footerMessage
893 [void]$filesWithWarnings.Add($file.FullName)
894 Write-GitHubAnnotation -Type 'warning' -Message "Missing standard Copilot footer" -File $file.FullName
895 }
896 }
897 }
898 }
899 else {
900 # Only warn for main docs, not for GitHub files, prompts, or chatmodes
901 $isGitHubLocal = $file.DirectoryName -like "*.github*"
902 $isMainDocLocal = ($file.DirectoryName -like "*docs*" -or
903 $file.DirectoryName -like "*scripts*") -and
904 -not $isGitHubLocal
905
906 if ($isMainDocLocal) {
907 $warnings += "No frontmatter found in: $($file.FullName)"
908 [void]$filesWithWarnings.Add($file.FullName)
909 }
910 }
911 }
912 catch {
913 $errors += "Error processing file '$($file.FullName)': $($_.Exception.Message)"
914 Write-Verbose "Error processing file '$($file.FullName)': $($_.Exception.Message)"
915 }
916 }
917
918 # Get repository root for logs directory
919 $repoRoot = (Get-Location).Path
920 if (-not (Test-Path ".git")) {
921 $gitRoot = git rev-parse --show-toplevel 2>$null
922 if ($gitRoot) {
923 $repoRoot = $gitRoot
924 }
925 }
926
927 # Create logs directory and export results
928 $logsDir = Join-Path -Path $repoRoot -ChildPath 'logs'
929 if (-not (Test-Path $logsDir)) {
930 New-Item -ItemType Directory -Path $logsDir -Force | Out-Null
931 }
932
933 $resultsJson = @{
934 timestamp = (Get-Date).ToUniversalTime().ToString('o')
935 script = 'frontmatter-validation'
936 summary = @{
937 total_files = $markdownFiles.Count
938 files_with_errors = $filesWithErrors.Count
939 files_with_warnings = $filesWithWarnings.Count
940 total_errors = $errors.Count
941 total_warnings = $warnings.Count
942 }
943 errors = $errors
944 warnings = $warnings
945 }
946
947 $resultsPath = Join-Path -Path $logsDir -ChildPath 'frontmatter-validation-results.json'
948 $resultsJson | ConvertTo-Json -Depth 10 | Set-Content -Path $resultsPath -Encoding UTF8
949
950 # Output results
951 $hasIssues = $false
952
953 if ($warnings.Count -gt 0) {
954 Write-Host "⚠️ Warnings found:" -ForegroundColor Yellow
955 $warnings | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow }
956 if ($WarningsAsErrors) {
957 $hasIssues = $true
958 }
959 }
960
961 if ($errors.Count -gt 0) {
962 Write-Host "❌ Errors found:" -ForegroundColor Red
963 $errors | ForEach-Object { Write-Host " $_" -ForegroundColor Red }
964 $hasIssues = $true
965 }
966
967 # Generate GitHub step summary
968 if ($hasIssues) {
969 $summaryContent = @"
970## ❌ Frontmatter Validation Failed
971
972**Files checked:** $($markdownFiles.Count)
973**Files with errors:** $($resultsJson.summary.files_with_errors)
974**Files with warnings:** $($resultsJson.summary.files_with_warnings)
975**Total errors:** $($errors.Count)
976**Total warnings:** $($warnings.Count)
977
978### Issues Found
979
980"@
981
982 if ($errors.Count -gt 0) {
983 $summaryContent += "`n#### Errors`n`n"
984 foreach ($errorItem in $errors | Select-Object -First 10) {
985 $summaryContent += "- ❌ $errorItem`n"
986 }
987 if ($errors.Count -gt 10) {
988 $summaryContent += "`n*... and $($errors.Count - 10) more errors*`n"
989 }
990 }
991
992 if ($warnings.Count -gt 0) {
993 $summaryContent += "`n#### Warnings`n`n"
994 foreach ($warning in $warnings | Select-Object -First 10) {
995 $summaryContent += "- ⚠️ $warning`n"
996 }
997 if ($warnings.Count -gt 10) {
998 $summaryContent += "`n*... and $($warnings.Count - 10) more warnings*`n"
999 }
1000 }
1001
1002 $summaryContent += @"
1003
1004
1005### How to Fix
1006
10071. Review the errors and warnings listed above
10082. Update frontmatter fields as required
10093. Ensure date formats follow ISO 8601 (YYYY-MM-DD)
10104. Add missing Copilot attribution footer where required
10115. Re-run validation to verify fixes
1012
1013See the uploaded artifact for complete details.
1014"@
1015
1016 Write-GitHubStepSummary -Content $summaryContent
1017 Set-GitHubEnv -Name "FRONTMATTER_VALIDATION_FAILED" -Value "true"
1018 }
1019 else {
1020 $summaryContent = @"
1021## ✅ Frontmatter Validation Passed
1022
1023**Files checked:** $($markdownFiles.Count)
1024**Errors:** 0
1025**Warnings:** 0
1026
1027All frontmatter fields are valid and properly formatted. Great job! 🎉
1028"@
1029
1030 Write-GitHubStepSummary -Content $summaryContent
1031 Write-Host "✅ Frontmatter validation completed successfully" -ForegroundColor Green
1032 }
1033
1034 return @{
1035 Errors = $errors
1036 Warnings = $warnings
1037 HasIssues = $hasIssues
1038 TotalFilesChecked = $markdownFiles.Count
1039 }
1040}
1041
1042function Get-ChangedMarkdownFileGroup {
1043 <#
1044 .SYNOPSIS
1045 Gets list of changed markdown files from git diff.
1046
1047 .DESCRIPTION
1048 Uses git diff to identify changed markdown files, with fallback strategies for different scenarios.
1049
1050 .PARAMETER BaseBranch
1051 The base branch to compare against (default: origin/main).
1052
1053 .OUTPUTS
1054 Returns array of file paths for changed markdown files.
1055 #>
1056 param(
1057 [Parameter(Mandatory = $false)]
1058 [string]$BaseBranch = "origin/main"
1059 )
1060
1061 $changedMarkdownFiles = @()
1062
1063 try {
1064 # Try to get changed files from the merge base
1065 $changedFiles = git diff --name-only $(git merge-base HEAD $BaseBranch) HEAD 2>$null
1066 if ($LASTEXITCODE -ne 0) {
1067 Write-Verbose "Merge base failed, trying HEAD~1"
1068 # Fallback to comparing with HEAD~1 if merge-base fails
1069 $changedFiles = git diff --name-only HEAD~1 HEAD 2>$null
1070 if ($LASTEXITCODE -ne 0) {
1071 Write-Verbose "HEAD~1 failed, trying staged/unstaged files"
1072 # Last fallback - get staged and unstaged files
1073 $changedFiles = git diff --name-only HEAD 2>$null
1074 if ($LASTEXITCODE -ne 0) {
1075 Write-Warning "Unable to determine changed files from git"
1076 return @()
1077 }
1078 }
1079 }
1080
1081 # Filter for markdown files that exist and are not empty
1082 $changedMarkdownFiles = $changedFiles | Where-Object {
1083 -not [string]::IsNullOrEmpty($_) -and
1084 $_ -match '\.md$' -and
1085 (Test-Path $_ -PathType Leaf)
1086 }
1087
1088 Write-Verbose "Found $($changedMarkdownFiles.Count) changed markdown files from git diff"
1089 $changedMarkdownFiles | ForEach-Object { Write-Verbose " Changed: $_" }
1090
1091 return $changedMarkdownFiles
1092 }
1093 catch {
1094 Write-Warning "Error getting changed files from git: $($_.Exception.Message)"
1095 return @()
1096 }
1097}
1098
1099# Main execution
1100if ($MyInvocation.InvocationName -ne '.') {
1101 if ($ChangedFilesOnly) {
1102 $result = Test-FrontmatterValidation -ChangedFilesOnly -BaseBranch $BaseBranch -WarningsAsErrors:$WarningsAsErrors -SkipFooterValidation:$SkipFooterValidation -EnableSchemaValidation:$EnableSchemaValidation
1103 }
1104 elseif ($Files.Count -gt 0) {
1105 $result = Test-FrontmatterValidation -Files $Files -WarningsAsErrors:$WarningsAsErrors -SkipFooterValidation:$SkipFooterValidation -EnableSchemaValidation:$EnableSchemaValidation
1106 }
1107 else {
1108 $result = Test-FrontmatterValidation -Paths $Paths -WarningsAsErrors:$WarningsAsErrors -SkipFooterValidation:$SkipFooterValidation -EnableSchemaValidation:$EnableSchemaValidation
1109 }
1110
1111 if ($result.HasIssues) {
1112 exit 1
1113 }
1114 else {
1115 Write-Host "✅ All frontmatter validation checks passed!" -ForegroundColor Green
1116 exit 0
1117 }
1118}
1119