microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
hve-core-v1.1.0

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/linting/Validate-MarkdownFrontmatter.ps1

1119lines · 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 $isAgent = $file.Name -like "*.agent.md"
690 $isChatMode = $file.Name -like "*.chatmode.md" # Legacy pattern
691 $isPrompt = $file.Name -like "*.prompt.md"
692 $isInstruction = $file.Name -like "*.instructions.md"
693 $isRootCommunityFile = ($file.DirectoryName -eq $repoRoot) -and
694 ($file.Name -in @('CODE_OF_CONDUCT.md', 'CONTRIBUTING.md',
695 'SECURITY.md', 'SUPPORT.md', 'README.md'))
696 $isDevContainer = $file.DirectoryName -like "*.devcontainer*" -and $file.Name -eq 'README.md'
697 $isVSCodeReadme = $file.DirectoryName -like "*.vscode*" -and $file.Name -eq 'README.md'
698
699 # Determine if file should have footer
700 $shouldHaveFooter = $false
701 $footerSeverity = 'Error' # Default to error if footer is required
702
703 # Set footer requirements for root community files
704 if ($isRootCommunityFile) {
705 # All root community files require footers in hve-core
706 $shouldHaveFooter = $true
707 $footerSeverity = 'Error'
708 }
709 elseif ($isDevContainer) {
710 # DevContainer docs are custom
711 $shouldHaveFooter = $true
712 $footerSeverity = 'Error'
713 }
714 elseif ($isVSCodeReadme) {
715 # VS Code configuration docs require footers
716 $shouldHaveFooter = $true
717 $footerSeverity = 'Error'
718 }
719 elseif ($isGitHub) {
720 if ($file.Name -eq 'README.md') {
721 # GitHub subdirectory READMEs should have footers
722 $shouldHaveFooter = $true
723 $footerSeverity = 'Error'
724 }
725 # Agents, chatmodes, instructions, and prompts are excluded from footer validation
726 # (they are internal configuration files, not public documentation)
727 }
728
729 # Validate required fields for root community files
730 if ($isRootCommunityFile) {
731 $requiredFields = @('title', 'description')
732 $suggestedFields = @('author', 'ms.date')
733
734 foreach ($field in $requiredFields) {
735 if (-not $frontmatter.Frontmatter.ContainsKey($field)) {
736 $errorMsg = "Missing required field '$field' in: $($file.FullName)"
737 $errors += $errorMsg
738 Write-GitHubAnnotation -Type 'error' -Message "Missing required field '$field'" -File $file.FullName
739 }
740 }
741
742 foreach ($field in $suggestedFields) {
743 if (-not $frontmatter.Frontmatter.ContainsKey($field)) {
744 $warningMsg = "Suggested field '$field' missing in: $($file.FullName)"
745 $warnings += $warningMsg
746 [void]$filesWithWarnings.Add($file.FullName)
747 Write-GitHubAnnotation -Type 'warning' -Message "Suggested field '$field' missing" -File $file.FullName
748 }
749 }
750
751 # Validate date format (ISO 8601: YYYY-MM-DD) or placeholder (YYYY-MM-dd)
752 if ($frontmatter.Frontmatter.ContainsKey('ms.date')) {
753 $date = $frontmatter.Frontmatter['ms.date']
754 if ($date -notmatch '^(\d{4}-\d{2}-\d{2}|\(YYYY-MM-dd\))$') {
755 $warningMsg = "Invalid date format in: $($file.FullName). Expected YYYY-MM-DD (ISO 8601), got: $date"
756 $warnings += $warningMsg
757 [void]$filesWithWarnings.Add($file.FullName)
758 Write-GitHubAnnotation -Type 'warning' -Message "Invalid date format: Expected YYYY-MM-DD (ISO 8601), got: $date" -File $file.FullName
759 }
760 }
761 }
762 # Validate .devcontainer documentation
763 elseif ($isDevContainer) {
764 $requiredFields = @('title', 'description')
765
766 foreach ($field in $requiredFields) {
767 if (-not $frontmatter.Frontmatter.ContainsKey($field)) {
768 $errorMsg = "Missing required field '$field' in: $($file.FullName)"
769 $errors += $errorMsg
770 [void]$filesWithErrors.Add($file.FullName)
771 Write-GitHubAnnotation -Type 'error' -Message "Missing required field '$field'" -File $file.FullName
772 }
773 }
774 }
775 # Validate .vscode documentation
776 elseif ($isVSCodeReadme) {
777 $requiredFields = @('title', 'description')
778
779 foreach ($field in $requiredFields) {
780 if (-not $frontmatter.Frontmatter.ContainsKey($field)) {
781 $errorMsg = "Missing required field '$field' in: $($file.FullName)"
782 $errors += $errorMsg
783 [void]$filesWithErrors.Add($file.FullName)
784 Write-GitHubAnnotation -Type 'error' -Message "Missing required field '$field'" -File $file.FullName
785 }
786 }
787 }
788
789 # GitHub resources have different requirements
790 elseif ($isGitHub) {
791 # Agent files (.agent.md) and legacy ChatMode files (.chatmode.md) have specific frontmatter structure
792 if ($isAgent -or $isChatMode) {
793 # Agent/ChatMode files typically have description, tools, etc. but not standard doc fields
794 # Only warn if missing description as it's commonly used
795 if (-not $frontmatter.Frontmatter.ContainsKey('description')) {
796 $warnings += "Agent file missing 'description' field: $($file.FullName)"
797 [void]$filesWithWarnings.Add($file.FullName)
798 }
799 }
800 # Instruction files (.instructions.md) have specific patterns
801 elseif ($isInstruction) {
802 # Instruction files should have 'applyTo' field for context-specific instructions
803 # This is informational only - does not fail validation
804 if (-not $frontmatter.Frontmatter.ContainsKey('applyTo')) {
805 Write-Verbose "Instruction file missing optional 'applyTo' field: $($file.FullName)"
806 }
807
808 # Validate required description field for instruction files
809 if (-not $frontmatter.Frontmatter.ContainsKey('description')) {
810 $errors += "Instruction file missing required 'description' field: $($file.FullName)"
811 [void]$filesWithErrors.Add($file.FullName)
812 Write-GitHubAnnotation -Type 'error' -Message "Missing required field 'description'" -File $file.FullName
813 }
814 }
815 # Prompt files (.prompt.md) are instructions/templates
816 elseif ($isPrompt) {
817 # Prompt files are typically instruction content, no specific frontmatter required
818 # These are generally freeform content
819 }
820 # Other GitHub files (exclude standard GitHub templates)
821 elseif ($file.Name -like "*template*" -and
822 -not ($file.Name -in @('PULL_REQUEST_TEMPLATE.md', 'ISSUE_TEMPLATE.md')) -and
823 -not $frontmatter.Frontmatter.ContainsKey('name')) {
824 $warnings += "GitHub template missing 'name' field: $($file.FullName)"
825 [void]$filesWithWarnings.Add($file.FullName)
826 }
827 }
828
829 # Validate keywords array (applies to all content types)
830 if ($frontmatter.Frontmatter.ContainsKey('keywords')) {
831 $keywords = $frontmatter.Frontmatter['keywords']
832 if ($keywords -isnot [array] -and $keywords -notmatch ',') {
833 $warnings += "Keywords should be an array in: $($file.FullName)"
834 [void]$filesWithWarnings.Add($file.FullName)
835 }
836 }
837 # Validate estimated_reading_time if present
838 if ($frontmatter.Frontmatter.ContainsKey('estimated_reading_time')) {
839 $readingTime = $frontmatter.Frontmatter['estimated_reading_time']
840 if ($readingTime -notmatch '^\d+$') {
841 $warnings += "Invalid estimated_reading_time format in: $($file.FullName). Should be a number."
842 [void]$filesWithWarnings.Add($file.FullName)
843 }
844 }
845
846 # Manual validation enforces critical rules (fails builds); schema validation above provides comprehensive advisory feedback (soft mode).
847 $isDocsFile = $file.DirectoryName -like "*docs*" -and -not $isGitHubLocal
848 if ($isDocsFile) {
849 # Documentation files should have comprehensive frontmatter
850 $requiredDocsFields = @('title', 'description')
851 $suggestedDocsFields = @('author', 'ms.date', 'ms.topic')
852
853 foreach ($field in $requiredDocsFields) {
854 if (-not $frontmatter.Frontmatter.ContainsKey($field)) {
855 $errors += "Documentation file missing required field '$field' in: $($file.FullName)"
856 [void]$filesWithErrors.Add($file.FullName)
857 Write-GitHubAnnotation -Type 'error' -Message "Missing required field '$field'" -File $file.FullName
858 }
859 }
860
861 foreach ($field in $suggestedDocsFields) {
862 if (-not $frontmatter.Frontmatter.ContainsKey($field)) {
863 $warnings += "Documentation file missing suggested field '$field' in: $($file.FullName)"
864 [void]$filesWithWarnings.Add($file.FullName)
865 Write-GitHubAnnotation -Type 'warning' -Message "Suggested field '$field' missing" -File $file.FullName
866 }
867 }
868
869 # Validate date format (ISO 8601: YYYY-MM-DD) or placeholder (YYYY-MM-dd) for docs
870 if ($frontmatter.Frontmatter.ContainsKey('ms.date')) {
871 $date = $frontmatter.Frontmatter['ms.date']
872 if ($date -notmatch '^(\d{4}-\d{2}-\d{2}|\(YYYY-MM-dd\))$') {
873 $warnings += "Invalid date format in: $($file.FullName). Expected YYYY-MM-DD (ISO 8601), got: $date"
874 [void]$filesWithWarnings.Add($file.FullName)
875 Write-GitHubAnnotation -Type 'warning' -Message "Invalid date format: Expected YYYY-MM-DD (ISO 8601), got: $date" -File $file.FullName
876 }
877 }
878 }
879
880 # Validate footer presence
881 if (-not $SkipFooterValidation -and $shouldHaveFooter -and $frontmatter.Content) {
882 $hasFooter = Test-MarkdownFooter -Content $frontmatter.Content
883
884 if (-not $hasFooter) {
885 $footerMessage = "Missing standard Copilot footer in: $($file.FullName)"
886
887 if ($footerSeverity -eq 'Error') {
888 $errors += $footerMessage
889 [void]$filesWithErrors.Add($file.FullName)
890 Write-GitHubAnnotation -Type 'error' -Message "Missing standard Copilot footer" -File $file.FullName
891 }
892 else {
893 $warnings += $footerMessage
894 [void]$filesWithWarnings.Add($file.FullName)
895 Write-GitHubAnnotation -Type 'warning' -Message "Missing standard Copilot footer" -File $file.FullName
896 }
897 }
898 }
899 }
900 else {
901 # Only warn for main docs, not for GitHub files, prompts, or agents
902 $isGitHubLocal = $file.DirectoryName -like "*.github*"
903 $isMainDocLocal = ($file.DirectoryName -like "*docs*" -or
904 $file.DirectoryName -like "*scripts*") -and
905 -not $isGitHubLocal
906
907 if ($isMainDocLocal) {
908 $warnings += "No frontmatter found in: $($file.FullName)"
909 [void]$filesWithWarnings.Add($file.FullName)
910 }
911 }
912 }
913 catch {
914 $errors += "Error processing file '$($file.FullName)': $($_.Exception.Message)"
915 Write-Verbose "Error processing file '$($file.FullName)': $($_.Exception.Message)"
916 }
917 }
918
919 # Get repository root for logs directory
920 $repoRoot = (Get-Location).Path
921 if (-not (Test-Path ".git")) {
922 $gitRoot = git rev-parse --show-toplevel 2>$null
923 if ($gitRoot) {
924 $repoRoot = $gitRoot
925 }
926 }
927
928 # Create logs directory and export results
929 $logsDir = Join-Path -Path $repoRoot -ChildPath 'logs'
930 if (-not (Test-Path $logsDir)) {
931 New-Item -ItemType Directory -Path $logsDir -Force | Out-Null
932 }
933
934 $resultsJson = @{
935 timestamp = (Get-Date).ToUniversalTime().ToString('o')
936 script = 'frontmatter-validation'
937 summary = @{
938 total_files = $markdownFiles.Count
939 files_with_errors = $filesWithErrors.Count
940 files_with_warnings = $filesWithWarnings.Count
941 total_errors = $errors.Count
942 total_warnings = $warnings.Count
943 }
944 errors = $errors
945 warnings = $warnings
946 }
947
948 $resultsPath = Join-Path -Path $logsDir -ChildPath 'frontmatter-validation-results.json'
949 $resultsJson | ConvertTo-Json -Depth 10 | Set-Content -Path $resultsPath -Encoding UTF8
950
951 # Output results
952 $hasIssues = $false
953
954 if ($warnings.Count -gt 0) {
955 Write-Host "⚠️ Warnings found:" -ForegroundColor Yellow
956 $warnings | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow }
957 if ($WarningsAsErrors) {
958 $hasIssues = $true
959 }
960 }
961
962 if ($errors.Count -gt 0) {
963 Write-Host "❌ Errors found:" -ForegroundColor Red
964 $errors | ForEach-Object { Write-Host " $_" -ForegroundColor Red }
965 $hasIssues = $true
966 }
967
968 # Generate GitHub step summary
969 if ($hasIssues) {
970 $summaryContent = @"
971## ❌ Frontmatter Validation Failed
972
973**Files checked:** $($markdownFiles.Count)
974**Files with errors:** $($resultsJson.summary.files_with_errors)
975**Files with warnings:** $($resultsJson.summary.files_with_warnings)
976**Total errors:** $($errors.Count)
977**Total warnings:** $($warnings.Count)
978
979### Issues Found
980
981"@
982
983 if ($errors.Count -gt 0) {
984 $summaryContent += "`n#### Errors`n`n"
985 foreach ($errorItem in $errors | Select-Object -First 10) {
986 $summaryContent += "- ❌ $errorItem`n"
987 }
988 if ($errors.Count -gt 10) {
989 $summaryContent += "`n*... and $($errors.Count - 10) more errors*`n"
990 }
991 }
992
993 if ($warnings.Count -gt 0) {
994 $summaryContent += "`n#### Warnings`n`n"
995 foreach ($warning in $warnings | Select-Object -First 10) {
996 $summaryContent += "- ⚠️ $warning`n"
997 }
998 if ($warnings.Count -gt 10) {
999 $summaryContent += "`n*... and $($warnings.Count - 10) more warnings*`n"
1000 }
1001 }
1002
1003 $summaryContent += @"
1004
1005
1006### How to Fix
1007
10081. Review the errors and warnings listed above
10092. Update frontmatter fields as required
10103. Ensure date formats follow ISO 8601 (YYYY-MM-DD)
10114. Add missing Copilot attribution footer where required
10125. Re-run validation to verify fixes
1013
1014See the uploaded artifact for complete details.
1015"@
1016
1017 Write-GitHubStepSummary -Content $summaryContent
1018 Set-GitHubEnv -Name "FRONTMATTER_VALIDATION_FAILED" -Value "true"
1019 }
1020 else {
1021 $summaryContent = @"
1022## ✅ Frontmatter Validation Passed
1023
1024**Files checked:** $($markdownFiles.Count)
1025**Errors:** 0
1026**Warnings:** 0
1027
1028All frontmatter fields are valid and properly formatted. Great job! 🎉
1029"@
1030
1031 Write-GitHubStepSummary -Content $summaryContent
1032 Write-Host "✅ Frontmatter validation completed successfully" -ForegroundColor Green
1033 }
1034
1035 return @{
1036 Errors = $errors
1037 Warnings = $warnings
1038 HasIssues = $hasIssues
1039 TotalFilesChecked = $markdownFiles.Count
1040 }
1041}
1042
1043function Get-ChangedMarkdownFileGroup {
1044 <#
1045 .SYNOPSIS
1046 Gets list of changed markdown files from git diff.
1047
1048 .DESCRIPTION
1049 Uses git diff to identify changed markdown files, with fallback strategies for different scenarios.
1050
1051 .PARAMETER BaseBranch
1052 The base branch to compare against (default: origin/main).
1053
1054 .OUTPUTS
1055 Returns array of file paths for changed markdown files.
1056 #>
1057 param(
1058 [Parameter(Mandatory = $false)]
1059 [string]$BaseBranch = "origin/main"
1060 )
1061
1062 $changedMarkdownFiles = @()
1063
1064 try {
1065 # Try to get changed files from the merge base
1066 $changedFiles = git diff --name-only $(git merge-base HEAD $BaseBranch) HEAD 2>$null
1067 if ($LASTEXITCODE -ne 0) {
1068 Write-Verbose "Merge base failed, trying HEAD~1"
1069 # Fallback to comparing with HEAD~1 if merge-base fails
1070 $changedFiles = git diff --name-only HEAD~1 HEAD 2>$null
1071 if ($LASTEXITCODE -ne 0) {
1072 Write-Verbose "HEAD~1 failed, trying staged/unstaged files"
1073 # Last fallback - get staged and unstaged files
1074 $changedFiles = git diff --name-only HEAD 2>$null
1075 if ($LASTEXITCODE -ne 0) {
1076 Write-Warning "Unable to determine changed files from git"
1077 return @()
1078 }
1079 }
1080 }
1081
1082 # Filter for markdown files that exist and are not empty
1083 $changedMarkdownFiles = $changedFiles | Where-Object {
1084 -not [string]::IsNullOrEmpty($_) -and
1085 $_ -match '\.md$' -and
1086 (Test-Path $_ -PathType Leaf)
1087 }
1088
1089 Write-Verbose "Found $($changedMarkdownFiles.Count) changed markdown files from git diff"
1090 $changedMarkdownFiles | ForEach-Object { Write-Verbose " Changed: $_" }
1091
1092 return $changedMarkdownFiles
1093 }
1094 catch {
1095 Write-Warning "Error getting changed files from git: $($_.Exception.Message)"
1096 return @()
1097 }
1098}
1099
1100# Main execution
1101if ($MyInvocation.InvocationName -ne '.') {
1102 if ($ChangedFilesOnly) {
1103 $result = Test-FrontmatterValidation -ChangedFilesOnly -BaseBranch $BaseBranch -WarningsAsErrors:$WarningsAsErrors -SkipFooterValidation:$SkipFooterValidation -EnableSchemaValidation:$EnableSchemaValidation
1104 }
1105 elseif ($Files.Count -gt 0) {
1106 $result = Test-FrontmatterValidation -Files $Files -WarningsAsErrors:$WarningsAsErrors -SkipFooterValidation:$SkipFooterValidation -EnableSchemaValidation:$EnableSchemaValidation
1107 }
1108 else {
1109 $result = Test-FrontmatterValidation -Paths $Paths -WarningsAsErrors:$WarningsAsErrors -SkipFooterValidation:$SkipFooterValidation -EnableSchemaValidation:$EnableSchemaValidation
1110 }
1111
1112 if ($result.HasIssues) {
1113 exit 1
1114 }
1115 else {
1116 Write-Host "✅ All frontmatter validation checks passed!" -ForegroundColor Green
1117 exit 0
1118 }
1119}