microsoft/hve-core

Public

mirrored from https://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
c3af708c39a4db1cd35d2ffd0d15db2bbe6dd0da

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/linting/Validate-MarkdownFrontmatter.ps1

1121lines · 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
505 $gitignorePatterns = @()
506 $gitignorePath = Join-Path $repoRoot ".gitignore"
507 if (Test-Path $gitignorePath) {
508 $gitignorePatterns = Get-Content $gitignorePath | Where-Object {
509 $_ -and
510 -not $_.StartsWith('#') -and
511 $_.Trim() -ne ''
512 } | ForEach-Object {
513 $pattern = $_.Trim()
514 # Convert gitignore patterns to PowerShell wildcard patterns
515 if ($pattern.EndsWith('/')) {
516 # Directory pattern
517 "*\$($pattern.TrimEnd('/'))\*"
518 }
519 elseif ($pattern.Contains('/')) {
520 # Path pattern
521 "*\$($pattern.Replace('/', '\'))*"
522 }
523 else {
524 # Simple pattern
525 "*\$pattern\*"
526 }
527 }
528 }
529
530 Write-Host "🔍 Validating frontmatter across markdown files..." -ForegroundColor Cyan
531
532 # Input validation and sanitization
533 $errors = @()
534 $warnings = @()
535 $filesWithErrors = [System.Collections.Generic.HashSet[string]]::new()
536 $filesWithWarnings = [System.Collections.Generic.HashSet[string]]::new()
537
538 # If ChangedFilesOnly is specified, get changed files from git
539 if ($ChangedFilesOnly) {
540 Write-Host "🔍 Detecting changed markdown files from git diff..." -ForegroundColor Cyan
541 $gitChangedFiles = Get-ChangedMarkdownFileGroup -BaseBranch $BaseBranch
542 if ($gitChangedFiles.Count -gt 0) {
543 $Files = $gitChangedFiles
544 Write-Host "Found $($Files.Count) changed markdown files to validate" -ForegroundColor Cyan
545 }
546 else {
547 Write-Host "No changed markdown files found - validation complete" -ForegroundColor Green
548 return @{
549 Errors = @()
550 Warnings = @()
551 HasIssues = $false
552 TotalFilesChecked = 0
553 }
554 }
555 }
556
557 # Sanitize Files array - remove empty or null entries
558 if ($Files.Count -gt 0) {
559 $sanitizedFiles = @()
560 foreach ($file in $Files) {
561 if (-not [string]::IsNullOrEmpty($file)) {
562 $sanitizedFiles += $file.Trim()
563 }
564 else {
565 Write-Verbose "Filtering out empty file path from Files array"
566 }
567 }
568 $Files = $sanitizedFiles
569 }
570
571 # Sanitize Paths array - remove empty or null entries
572 if ($Paths.Count -gt 0) {
573 $sanitizedPaths = @()
574 foreach ($path in $Paths) {
575 if (-not [string]::IsNullOrEmpty($path)) {
576 $sanitizedPaths += $path.Trim()
577 }
578 else {
579 Write-Verbose "Filtering out empty path from Paths array"
580 }
581 }
582 $Paths = $sanitizedPaths
583 }
584
585 # Ensure we have at least one valid input source
586 if ($Files.Count -eq 0 -and $Paths.Count -eq 0) {
587 $warnings += "No valid files or paths provided for validation"
588 return @{
589 Errors = @()
590 Warnings = $warnings
591 HasIssues = $true
592 TotalFilesChecked = 0
593 }
594 }
595
596 # Get markdown files either from specific files or from paths
597 [System.Collections.ArrayList]$markdownFiles = @()
598
599 if ($Files.Count -gt 0) {
600 Write-Host "Validating specific files..." -ForegroundColor Cyan
601 foreach ($file in $Files) {
602 if (-not [string]::IsNullOrEmpty($file) -and (Test-Path $file -PathType Leaf)) {
603 if ($file -like "*.md") {
604 $fileItem = Get-Item $file
605 if ($null -ne $fileItem -and -not [string]::IsNullOrEmpty($fileItem.FullName)) {
606 $markdownFiles += $fileItem
607 Write-Verbose "Added specific file: $file"
608 }
609 }
610 else {
611 Write-Verbose "Skipping non-markdown file: $file"
612 }
613 }
614 else {
615 Write-Warning "File not found or invalid: $file"
616 }
617 }
618 }
619 else {
620 Write-Host "Searching for markdown files in specified paths..." -ForegroundColor Cyan
621 foreach ($path in $Paths) {
622 if (Test-Path $path) {
623 # Get files and filter manually with strongly typed array
624 $rawFiles = Get-ChildItem -Path $path -Filter '*.md' -Recurse -File -ErrorAction SilentlyContinue
625
626 # Manual filtering with strongly typed array to prevent implicit string conversion
627 [System.IO.FileInfo[]]$files = @()
628 foreach ($f in $rawFiles) {
629 if ($null -eq $f -or
630 [string]::IsNullOrEmpty($f.FullName) -or
631 $f.PSIsContainer -eq $true) {
632 continue
633 }
634
635 # Check against gitignore patterns
636 $excluded = $false
637 foreach ($pattern in $gitignorePatterns) {
638 if ($f.FullName -like $pattern) {
639 $excluded = $true
640 break
641 }
642 }
643
644 if (-not $excluded) {
645 $files += $f
646 }
647 }
648
649 if ($files.Count -gt 0) {
650 [void]$markdownFiles.AddRange($files)
651 Write-Verbose "Found $($files.Count) markdown files in $path"
652 }
653 else {
654 Write-Verbose "No markdown files found in $path"
655 }
656 }
657 else {
658 Write-Warning "Path not found: $path"
659 }
660 }
661 }
662
663 Write-Host "Found $($markdownFiles.Count) total markdown files to validate" -ForegroundColor Cyan
664
665 # Initialize schema validation once before processing files
666 $schemaValidationEnabled = $false
667 if ($EnableSchemaValidation) {
668 $schemaValidationEnabled = Initialize-JsonSchemaValidation
669 if (-not $schemaValidationEnabled) {
670 Write-Warning "Schema validation requested but not available - continuing without schema validation"
671 }
672 }
673
674 foreach ($file in $markdownFiles) {
675 # Skip null file objects or files with empty/null paths
676 if ($null -eq $file) {
677 Write-Verbose "Skipping null file object"
678 continue
679 }
680
681 if ([string]::IsNullOrEmpty($file.FullName)) {
682 Write-Verbose "Skipping file with empty path"
683 continue
684 }
685
686 Write-Verbose "Validating: $($file.FullName)"
687
688 try {
689 $frontmatter = Get-MarkdownFrontmatter -FilePath $file.FullName
690
691 if ($frontmatter) {
692 # Soft validation mode: Schema validation reports issues via Write-Warning without failing builds.
693 # This provides comprehensive advisory feedback while manual validation below enforces critical rules.
694 if ($schemaValidationEnabled) {
695 $schemaPath = Get-SchemaForFile -FilePath $file.FullName
696 if ($schemaPath) {
697 $schemaResult = Test-JsonSchemaValidation -Frontmatter $frontmatter.Frontmatter -SchemaPath $schemaPath
698 if ($schemaResult.Errors.Count -gt 0) {
699 Write-Warning "JSON Schema validation errors in $($file.FullName):"
700 $schemaResult.Errors | ForEach-Object { Write-Warning " - $_" }
701 }
702 if ($schemaResult.Warnings.Count -gt 0) {
703 Write-Verbose "JSON Schema validation warnings in $($file.FullName):"
704 $schemaResult.Warnings | ForEach-Object { Write-Verbose " - $_" }
705 }
706 }
707 }
708
709 # Determine content type and required fields
710 $isGitHub = $file.DirectoryName -like "*.github*"
711 $isChatMode = $file.Name -like "*.chatmode.md"
712 $isPrompt = $file.Name -like "*.prompt.md"
713 $isInstruction = $file.Name -like "*.instructions.md"
714 $isRootCommunityFile = ($file.DirectoryName -eq $repoRoot) -and
715 ($file.Name -in @('CODE_OF_CONDUCT.md', 'CONTRIBUTING.md',
716 'SECURITY.md', 'SUPPORT.md', 'README.md'))
717 $isDevContainer = $file.DirectoryName -like "*.devcontainer*" -and $file.Name -eq 'README.md'
718
719 # Determine if file should have footer
720 $shouldHaveFooter = $false
721 $footerSeverity = 'Error' # Default to error if footer is required
722
723 # Set footer requirements for root community files
724 if ($isRootCommunityFile) {
725 # All root community files require footers in hve-core
726 $shouldHaveFooter = $true
727 $footerSeverity = 'Error'
728 }
729 elseif ($isDevContainer) {
730 # DevContainer docs are custom
731 $shouldHaveFooter = $true
732 $footerSeverity = 'Error'
733 }
734 elseif ($isGitHub) {
735 if ($file.Name -eq 'README.md') {
736 # GitHub subdirectory READMEs should have footers
737 $shouldHaveFooter = $true
738 $footerSeverity = 'Error'
739 }
740 # Chatmodes, instructions, and prompts are excluded from footer validation
741 # (they are internal configuration files, not public documentation)
742 }
743
744 # Validate required fields for root community files
745 if ($isRootCommunityFile) {
746 $requiredFields = @('title', 'description')
747 $suggestedFields = @('author', 'ms.date')
748
749 foreach ($field in $requiredFields) {
750 if (-not $frontmatter.Frontmatter.ContainsKey($field)) {
751 $errorMsg = "Missing required field '$field' in: $($file.FullName)"
752 $errors += $errorMsg
753 Write-GitHubAnnotation -Type 'error' -Message "Missing required field '$field'" -File $file.FullName
754 }
755 }
756
757 foreach ($field in $suggestedFields) {
758 if (-not $frontmatter.Frontmatter.ContainsKey($field)) {
759 $warningMsg = "Suggested field '$field' missing in: $($file.FullName)"
760 $warnings += $warningMsg
761 [void]$filesWithWarnings.Add($file.FullName)
762 Write-GitHubAnnotation -Type 'warning' -Message "Suggested field '$field' missing" -File $file.FullName
763 }
764 }
765
766 # Validate date format (ISO 8601: YYYY-MM-DD)
767 if ($frontmatter.Frontmatter.ContainsKey('ms.date')) {
768 $date = $frontmatter.Frontmatter['ms.date']
769 if ($date -notmatch '^\d{4}-\d{2}-\d{2}$') {
770 $warningMsg = "Invalid date format in: $($file.FullName). Expected YYYY-MM-DD (ISO 8601), got: $date"
771 $warnings += $warningMsg
772 [void]$filesWithWarnings.Add($file.FullName)
773 Write-GitHubAnnotation -Type 'warning' -Message "Invalid date format: Expected YYYY-MM-DD (ISO 8601), got: $date" -File $file.FullName
774 }
775 }
776 }
777 # Validate .devcontainer documentation
778 elseif ($isDevContainer) {
779 $requiredFields = @('title', 'description')
780
781 foreach ($field in $requiredFields) {
782 if (-not $frontmatter.Frontmatter.ContainsKey($field)) {
783 $errorMsg = "Missing required field '$field' in: $($file.FullName)"
784 $errors += $errorMsg
785 [void]$filesWithErrors.Add($file.FullName)
786 Write-GitHubAnnotation -Type 'error' -Message "Missing required field '$field'" -File $file.FullName
787 }
788 }
789 }
790
791 # GitHub resources have different requirements
792 elseif ($isGitHub) {
793 # ChatMode files (.chatmode.md) have specific frontmatter structure
794 if ($isChatMode) {
795 # ChatMode files typically have description, tools, etc. but not standard doc fields
796 # Only warn if missing description as it's commonly used
797 if (-not $frontmatter.Frontmatter.ContainsKey('description')) {
798 $warnings += "ChatMode file missing 'description' field: $($file.FullName)"
799 [void]$filesWithWarnings.Add($file.FullName)
800 }
801 }
802 # Instruction files (.instructions.md) have specific patterns
803 elseif ($isInstruction) {
804 # Instruction files should have 'applyTo' field for context-specific instructions
805 # This is informational only - does not fail validation
806 if (-not $frontmatter.Frontmatter.ContainsKey('applyTo')) {
807 Write-Verbose "Instruction file missing optional 'applyTo' field: $($file.FullName)"
808 }
809
810 # Validate required description field for instruction files
811 if (-not $frontmatter.Frontmatter.ContainsKey('description')) {
812 $errors += "Instruction file missing required 'description' field: $($file.FullName)"
813 [void]$filesWithErrors.Add($file.FullName)
814 Write-GitHubAnnotation -Type 'error' -Message "Missing required field 'description'" -File $file.FullName
815 }
816 }
817 # Prompt files (.prompt.md) are instructions/templates
818 elseif ($isPrompt) {
819 # Prompt files are typically instruction content, no specific frontmatter required
820 # These are generally freeform content
821 }
822 # Other GitHub files (exclude standard GitHub templates)
823 elseif ($file.Name -like "*template*" -and
824 -not ($file.Name -in @('PULL_REQUEST_TEMPLATE.md', 'ISSUE_TEMPLATE.md')) -and
825 -not $frontmatter.Frontmatter.ContainsKey('name')) {
826 $warnings += "GitHub template missing 'name' field: $($file.FullName)"
827 [void]$filesWithWarnings.Add($file.FullName)
828 }
829 }
830
831 # Validate keywords array (applies to all content types)
832 if ($frontmatter.Frontmatter.ContainsKey('keywords')) {
833 $keywords = $frontmatter.Frontmatter['keywords']
834 if ($keywords -isnot [array] -and $keywords -notmatch ',') {
835 $warnings += "Keywords should be an array in: $($file.FullName)"
836 [void]$filesWithWarnings.Add($file.FullName)
837 }
838 }
839 # Validate estimated_reading_time if present
840 if ($frontmatter.Frontmatter.ContainsKey('estimated_reading_time')) {
841 $readingTime = $frontmatter.Frontmatter['estimated_reading_time']
842 if ($readingTime -notmatch '^\d+$') {
843 $warnings += "Invalid estimated_reading_time format in: $($file.FullName). Should be a number."
844 [void]$filesWithWarnings.Add($file.FullName)
845 }
846 }
847
848 # Manual validation enforces critical rules (fails builds); schema validation above provides comprehensive advisory feedback (soft mode).
849 $isDocsFile = $file.DirectoryName -like "*docs*" -and -not $isGitHubLocal
850 if ($isDocsFile) {
851 # Documentation files should have comprehensive frontmatter
852 $requiredDocsFields = @('title', 'description')
853 $suggestedDocsFields = @('author', 'ms.date', 'ms.topic')
854
855 foreach ($field in $requiredDocsFields) {
856 if (-not $frontmatter.Frontmatter.ContainsKey($field)) {
857 $errors += "Documentation file missing required field '$field' in: $($file.FullName)"
858 [void]$filesWithErrors.Add($file.FullName)
859 Write-GitHubAnnotation -Type 'error' -Message "Missing required field '$field'" -File $file.FullName
860 }
861 }
862
863 foreach ($field in $suggestedDocsFields) {
864 if (-not $frontmatter.Frontmatter.ContainsKey($field)) {
865 $warnings += "Documentation file missing suggested field '$field' in: $($file.FullName)"
866 [void]$filesWithWarnings.Add($file.FullName)
867 Write-GitHubAnnotation -Type 'warning' -Message "Suggested field '$field' missing" -File $file.FullName
868 }
869 }
870
871 # Validate date format for docs
872 if ($frontmatter.Frontmatter.ContainsKey('ms.date')) {
873 $date = $frontmatter.Frontmatter['ms.date']
874 if ($date -notmatch '^\d{4}-\d{2}-\d{2}$') {
875 $warnings += "Invalid date format in: $($file.FullName). Expected YYYY-MM-DD (ISO 8601), got: $date"
876 [void]$filesWithWarnings.Add($file.FullName)
877 Write-GitHubAnnotation -Type 'warning' -Message "Invalid date format: Expected YYYY-MM-DD (ISO 8601), got: $date" -File $file.FullName
878 }
879 }
880 }
881
882 # Validate footer presence
883 if (-not $SkipFooterValidation -and $shouldHaveFooter -and $frontmatter.Content) {
884 $hasFooter = Test-MarkdownFooter -Content $frontmatter.Content
885
886 if (-not $hasFooter) {
887 $footerMessage = "Missing standard Copilot footer in: $($file.FullName)"
888
889 if ($footerSeverity -eq 'Error') {
890 $errors += $footerMessage
891 [void]$filesWithErrors.Add($file.FullName)
892 Write-GitHubAnnotation -Type 'error' -Message "Missing standard Copilot footer" -File $file.FullName
893 }
894 else {
895 $warnings += $footerMessage
896 [void]$filesWithWarnings.Add($file.FullName)
897 Write-GitHubAnnotation -Type 'warning' -Message "Missing standard Copilot footer" -File $file.FullName
898 }
899 }
900 }
901 }
902 else {
903 # Only warn for main docs, not for GitHub files, prompts, or chatmodes
904 $isGitHubLocal = $file.DirectoryName -like "*.github*"
905 $isMainDocLocal = ($file.DirectoryName -like "*docs*" -or
906 $file.DirectoryName -like "*scripts*") -and
907 -not $isGitHubLocal
908
909 if ($isMainDocLocal) {
910 $warnings += "No frontmatter found in: $($file.FullName)"
911 [void]$filesWithWarnings.Add($file.FullName)
912 }
913 }
914 }
915 catch {
916 $errors += "Error processing file '$($file.FullName)': $($_.Exception.Message)"
917 Write-Verbose "Error processing file '$($file.FullName)': $($_.Exception.Message)"
918 }
919 }
920
921 # Get repository root for logs directory
922 $repoRoot = (Get-Location).Path
923 if (-not (Test-Path ".git")) {
924 $gitRoot = git rev-parse --show-toplevel 2>$null
925 if ($gitRoot) {
926 $repoRoot = $gitRoot
927 }
928 }
929
930 # Create logs directory and export results
931 $logsDir = Join-Path -Path $repoRoot -ChildPath 'logs'
932 if (-not (Test-Path $logsDir)) {
933 New-Item -ItemType Directory -Path $logsDir -Force | Out-Null
934 }
935
936 $resultsJson = @{
937 timestamp = (Get-Date).ToUniversalTime().ToString('o')
938 script = 'frontmatter-validation'
939 summary = @{
940 total_files = $markdownFiles.Count
941 files_with_errors = $filesWithErrors.Count
942 files_with_warnings = $filesWithWarnings.Count
943 total_errors = $errors.Count
944 total_warnings = $warnings.Count
945 }
946 errors = $errors
947 warnings = $warnings
948 }
949
950 $resultsPath = Join-Path -Path $logsDir -ChildPath 'frontmatter-validation-results.json'
951 $resultsJson | ConvertTo-Json -Depth 10 | Set-Content -Path $resultsPath -Encoding UTF8
952
953 # Output results
954 $hasIssues = $false
955
956 if ($warnings.Count -gt 0) {
957 Write-Host "⚠️ Warnings found:" -ForegroundColor Yellow
958 $warnings | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow }
959 if ($WarningsAsErrors) {
960 $hasIssues = $true
961 }
962 }
963
964 if ($errors.Count -gt 0) {
965 Write-Host "❌ Errors found:" -ForegroundColor Red
966 $errors | ForEach-Object { Write-Host " $_" -ForegroundColor Red }
967 $hasIssues = $true
968 }
969
970 # Generate GitHub step summary
971 if ($hasIssues) {
972 $summaryContent = @"
973## ❌ Frontmatter Validation Failed
974
975**Files checked:** $($markdownFiles.Count)
976**Files with errors:** $($resultsJson.summary.files_with_errors)
977**Files with warnings:** $($resultsJson.summary.files_with_warnings)
978**Total errors:** $($errors.Count)
979**Total warnings:** $($warnings.Count)
980
981### Issues Found
982
983"@
984
985 if ($errors.Count -gt 0) {
986 $summaryContent += "`n#### Errors`n`n"
987 foreach ($errorItem in $errors | Select-Object -First 10) {
988 $summaryContent += "- ❌ $errorItem`n"
989 }
990 if ($errors.Count -gt 10) {
991 $summaryContent += "`n*... and $($errors.Count - 10) more errors*`n"
992 }
993 }
994
995 if ($warnings.Count -gt 0) {
996 $summaryContent += "`n#### Warnings`n`n"
997 foreach ($warning in $warnings | Select-Object -First 10) {
998 $summaryContent += "- ⚠️ $warning`n"
999 }
1000 if ($warnings.Count -gt 10) {
1001 $summaryContent += "`n*... and $($warnings.Count - 10) more warnings*`n"
1002 }
1003 }
1004
1005 $summaryContent += @"
1006
1007
1008### How to Fix
1009
10101. Review the errors and warnings listed above
10112. Update frontmatter fields as required
10123. Ensure date formats follow ISO 8601 (YYYY-MM-DD)
10134. Add missing Copilot attribution footer where required
10145. Re-run validation to verify fixes
1015
1016See the uploaded artifact for complete details.
1017"@
1018
1019 Write-GitHubStepSummary -Content $summaryContent
1020 Set-GitHubEnv -Name "FRONTMATTER_VALIDATION_FAILED" -Value "true"
1021 }
1022 else {
1023 $summaryContent = @"
1024## ✅ Frontmatter Validation Passed
1025
1026**Files checked:** $($markdownFiles.Count)
1027**Errors:** 0
1028**Warnings:** 0
1029
1030All frontmatter fields are valid and properly formatted. Great job! 🎉
1031"@
1032
1033 Write-GitHubStepSummary -Content $summaryContent
1034 Write-Host "✅ Frontmatter validation completed successfully" -ForegroundColor Green
1035 }
1036
1037 return @{
1038 Errors = $errors
1039 Warnings = $warnings
1040 HasIssues = $hasIssues
1041 TotalFilesChecked = $markdownFiles.Count
1042 }
1043}
1044
1045function Get-ChangedMarkdownFileGroup {
1046 <#
1047 .SYNOPSIS
1048 Gets list of changed markdown files from git diff.
1049
1050 .DESCRIPTION
1051 Uses git diff to identify changed markdown files, with fallback strategies for different scenarios.
1052
1053 .PARAMETER BaseBranch
1054 The base branch to compare against (default: origin/main).
1055
1056 .OUTPUTS
1057 Returns array of file paths for changed markdown files.
1058 #>
1059 param(
1060 [Parameter(Mandatory = $false)]
1061 [string]$BaseBranch = "origin/main"
1062 )
1063
1064 $changedMarkdownFiles = @()
1065
1066 try {
1067 # Try to get changed files from the merge base
1068 $changedFiles = git diff --name-only $(git merge-base HEAD $BaseBranch) HEAD 2>$null
1069 if ($LASTEXITCODE -ne 0) {
1070 Write-Verbose "Merge base failed, trying HEAD~1"
1071 # Fallback to comparing with HEAD~1 if merge-base fails
1072 $changedFiles = git diff --name-only HEAD~1 HEAD 2>$null
1073 if ($LASTEXITCODE -ne 0) {
1074 Write-Verbose "HEAD~1 failed, trying staged/unstaged files"
1075 # Last fallback - get staged and unstaged files
1076 $changedFiles = git diff --name-only HEAD 2>$null
1077 if ($LASTEXITCODE -ne 0) {
1078 Write-Warning "Unable to determine changed files from git"
1079 return @()
1080 }
1081 }
1082 }
1083
1084 # Filter for markdown files that exist and are not empty
1085 $changedMarkdownFiles = $changedFiles | Where-Object {
1086 -not [string]::IsNullOrEmpty($_) -and
1087 $_ -match '\.md$' -and
1088 (Test-Path $_ -PathType Leaf)
1089 }
1090
1091 Write-Verbose "Found $($changedMarkdownFiles.Count) changed markdown files from git diff"
1092 $changedMarkdownFiles | ForEach-Object { Write-Verbose " Changed: $_" }
1093
1094 return $changedMarkdownFiles
1095 }
1096 catch {
1097 Write-Warning "Error getting changed files from git: $($_.Exception.Message)"
1098 return @()
1099 }
1100}
1101
1102# Main execution
1103if ($MyInvocation.InvocationName -ne '.') {
1104 if ($ChangedFilesOnly) {
1105 $result = Test-FrontmatterValidation -ChangedFilesOnly -BaseBranch $BaseBranch -WarningsAsErrors:$WarningsAsErrors -SkipFooterValidation:$SkipFooterValidation -EnableSchemaValidation:$EnableSchemaValidation
1106 }
1107 elseif ($Files.Count -gt 0) {
1108 $result = Test-FrontmatterValidation -Files $Files -WarningsAsErrors:$WarningsAsErrors -SkipFooterValidation:$SkipFooterValidation -EnableSchemaValidation:$EnableSchemaValidation
1109 }
1110 else {
1111 $result = Test-FrontmatterValidation -Paths $Paths -WarningsAsErrors:$WarningsAsErrors -SkipFooterValidation:$SkipFooterValidation -EnableSchemaValidation:$EnableSchemaValidation
1112 }
1113
1114 if ($result.HasIssues) {
1115 exit 1
1116 }
1117 else {
1118 Write-Host "✅ All frontmatter validation checks passed!" -ForegroundColor Green
1119 exit 0
1120 }
1121}
1122