# Copyright (c) Microsoft Corporation.
# SPDX-License-Identifier: MIT
<#
.SYNOPSIS
Frontmatter validation module with validation functions and I/O helpers.
.DESCRIPTION
Contains content-type validators, shared helpers, and output functions
for frontmatter validation. Returns ValidationIssue arrays for testability.
.NOTES
Author: HVE Core Team
#>
#region Classes
class ValidationIssue {
[ValidateSet('Error', 'Warning', 'Notice')]
[string]$Type = 'Warning'
[string]$Field
[string]$Message
[string]$FilePath
[int]$Line
ValidationIssue() {
$this.Type = 'Warning'
$this.Line = 0
}
ValidationIssue([string]$type, [string]$field, [string]$message, [string]$filePath) {
$this.Type = $type
$this.Field = $field
$this.Message = $message
$this.FilePath = $filePath
$this.Line = 0
}
ValidationIssue([string]$type, [string]$field, [string]$message, [string]$filePath, [int]$line) {
$this.Type = $type
$this.Field = $field
$this.Message = $message
$this.FilePath = $filePath
$this.Line = $line
}
}
class FileTypeInfo {
[bool]$IsGitHub
[bool]$IsChatMode
[bool]$IsPrompt
[bool]$IsInstruction
[bool]$IsAgent
[bool]$IsRootCommunityFile
[bool]$IsDevContainer
[bool]$IsVSCodeReadme
[bool]$IsDocsFile
FileTypeInfo() {
$this.IsGitHub = $false
$this.IsChatMode = $false
$this.IsPrompt = $false
$this.IsInstruction = $false
$this.IsAgent = $false
$this.IsRootCommunityFile = $false
$this.IsDevContainer = $false
$this.IsVSCodeReadme = $false
$this.IsDocsFile = $false
}
}
class FileValidationResult {
[ValidateNotNullOrEmpty()]
[string]$FilePath
[string]$RelativePath
[bool]$HasFrontmatter
[hashtable]$Frontmatter
[FileTypeInfo]$FileType
[System.Collections.Generic.List[ValidationIssue]]$Issues
[datetime]$ValidatedAt
FileValidationResult([string]$filePath) {
$this.FilePath = $filePath
$this.RelativePath = $filePath
$this.Issues = [System.Collections.Generic.List[ValidationIssue]]::new()
$this.ValidatedAt = [datetime]::UtcNow
}
[bool] HasErrors() {
return ($this.Issues | Where-Object Type -eq 'Error').Count -gt 0
}
[bool] HasWarnings() {
return ($this.Issues | Where-Object Type -eq 'Warning').Count -gt 0
}
[bool] IsValid() {
return -not $this.HasErrors()
}
[int] ErrorCount() {
return ($this.Issues | Where-Object Type -eq 'Error').Count
}
[int] WarningCount() {
return ($this.Issues | Where-Object Type -eq 'Warning').Count
}
[void] AddIssue([ValidationIssue]$issue) {
$this.Issues.Add($issue)
}
[void] AddError([string]$message, [string]$field) {
$this.AddError($message, $field, 0)
}
[void] AddError([string]$message, [string]$field, [int]$line) {
$issue = [ValidationIssue]::new()
$issue.Type = 'Error'
$issue.Message = $message
$issue.Field = $field
$issue.FilePath = $this.FilePath
$issue.Line = $line
$this.Issues.Add($issue)
}
[void] AddWarning([string]$message, [string]$field) {
$this.AddWarning($message, $field, 0)
}
[void] AddWarning([string]$message, [string]$field, [int]$line) {
$issue = [ValidationIssue]::new()
$issue.Type = 'Warning'
$issue.Message = $message
$issue.Field = $field
$issue.FilePath = $this.FilePath
$issue.Line = $line
$this.Issues.Add($issue)
}
}
class ValidationSummary {
[int]$TotalFiles
[int]$FilesWithErrors
[int]$FilesWithWarnings
[int]$FilesValid
[int]$TotalErrors
[int]$TotalWarnings
[System.Collections.ArrayList]$Results
[datetime]$StartedAt
[datetime]$CompletedAt
[timespan]$Duration
ValidationSummary() {
$this.Results = [System.Collections.ArrayList]::new()
$this.StartedAt = [datetime]::UtcNow
}
# Type constraint removed for testability (PowerShell class identity conflicts)
[void] AddResult($result) {
$this.Results.Add($result)
$this.TotalFiles++
if ($result.HasErrors()) {
$this.FilesWithErrors++
$this.TotalErrors += $result.ErrorCount()
}
if ($result.HasWarnings()) {
$this.FilesWithWarnings++
$this.TotalWarnings += $result.WarningCount()
}
if ($result.IsValid() -and -not $result.HasWarnings()) {
$this.FilesValid++
}
}
[void] Complete() {
$this.CompletedAt = [datetime]::UtcNow
$this.Duration = $this.CompletedAt - $this.StartedAt
}
[bool] Passed([bool]$warningsAsErrors) {
if ($this.TotalErrors -gt 0) { return $false }
if ($warningsAsErrors -and $this.TotalWarnings -gt 0) { return $false }
return $true
}
[int] GetExitCode([bool]$warningsAsErrors) {
# Exit code 2 indicates no files were validated (distinct from validation errors)
if ($this.TotalFiles -eq 0) { return 2 }
if ($this.Passed($warningsAsErrors)) { return 0 } else { return 1 }
}
[hashtable] ToHashtable() {
return @{
totalFiles = $this.TotalFiles
filesWithErrors = $this.FilesWithErrors
filesWithWarnings = $this.FilesWithWarnings
filesValid = $this.FilesValid
totalErrors = $this.TotalErrors
totalWarnings = $this.TotalWarnings
duration = $this.Duration.TotalSeconds
results = [object[]]($this.Results | ForEach-Object {
@{
filePath = $_.RelativePath
isValid = $_.IsValid()
errorCount = $_.ErrorCount()
warningCount = $_.WarningCount()
issues = [object[]]($_.Issues | ForEach-Object {
@{
type = $_.Type
message = $_.Message
field = $_.Field
line = $_.Line
}
})
}
})
}
}
}
#endregion Classes
#region Shared Helpers
function Test-RequiredField {
<#
.SYNOPSIS
Validates that a required field exists and is not empty.
.DESCRIPTION
Pure validation helper that checks for field presence and non-empty value.
Returns a ValidationIssue if the field is missing or empty.
.PARAMETER Frontmatter
Hashtable containing parsed frontmatter fields.
.PARAMETER FieldName
Name of the required field to check.
.PARAMETER RelativePath
Relative path to the file being validated.
.PARAMETER Severity
Issue severity: 'Error' or 'Warning'. Default: 'Error'.
.OUTPUTS
ValidationIssue or $null if field is valid.
#>
[CmdletBinding()]
[OutputType([ValidationIssue])]
param(
[Parameter(Mandatory)]
[hashtable]$Frontmatter,
[Parameter(Mandatory)]
[string]$FieldName,
[Parameter(Mandatory)]
[string]$RelativePath,
[Parameter()]
[ValidateSet('Error', 'Warning')]
[string]$Severity = 'Error'
)
if (-not $Frontmatter.ContainsKey($FieldName) -or [string]::IsNullOrWhiteSpace($Frontmatter[$FieldName])) {
return [ValidationIssue]::new($Severity, $FieldName, "Missing required field: $FieldName", $RelativePath)
}
return $null
}
function Test-DateFormat {
<#
.SYNOPSIS
Validates date format is ISO 8601 (YYYY-MM-DD) or placeholder.
.DESCRIPTION
Pure validation helper that checks date format compliance.
Accepts ISO 8601 format or placeholder syntax (YYYY-MM-dd).
.PARAMETER Frontmatter
Hashtable containing parsed frontmatter fields.
.PARAMETER FieldName
Name of the date field to check. Default: 'ms.date'.
.PARAMETER RelativePath
Relative path to the file being validated.
.OUTPUTS
ValidationIssue or $null if format is valid or field not present.
#>
[CmdletBinding()]
[OutputType([ValidationIssue])]
param(
[Parameter(Mandatory)]
[hashtable]$Frontmatter,
[Parameter()]
[string]$FieldName = 'ms.date',
[Parameter(Mandatory)]
[string]$RelativePath
)
if (-not $Frontmatter.ContainsKey($FieldName)) {
return $null
}
$date = $Frontmatter[$FieldName]
if ($date -notmatch '^(\d{4}-\d{2}-\d{2}|\(YYYY-MM-dd\))$') {
return [ValidationIssue]::new('Warning', $FieldName, "Invalid date format: Expected YYYY-MM-DD, got: $date", $RelativePath)
}
return $null
}
function Test-SuggestedFields {
<#
.SYNOPSIS
Validates presence of suggested (optional but recommended) fields.
.DESCRIPTION
Pure validation helper that checks for suggested field presence.
Returns warnings for missing suggested fields.
.PARAMETER Frontmatter
Hashtable containing parsed frontmatter fields.
.PARAMETER FieldNames
Array of suggested field names to check.
.PARAMETER RelativePath
Relative path to the file being validated.
.OUTPUTS
ValidationIssue[] Array of warnings for missing fields.
#>
[CmdletBinding()]
[OutputType([ValidationIssue[]])]
param(
[Parameter(Mandatory)]
[hashtable]$Frontmatter,
[Parameter(Mandatory)]
[string[]]$FieldNames,
[Parameter(Mandatory)]
[string]$RelativePath
)
$issues = [System.Collections.Generic.List[ValidationIssue]]::new()
foreach ($field in $FieldNames) {
if (-not $Frontmatter.ContainsKey($field)) {
$issues.Add([ValidationIssue]::new('Warning', $field, "Suggested field '$field' missing", $RelativePath))
}
}
return , $issues.ToArray()
}
function Test-TopicValue {
<#
.SYNOPSIS
Validates ms.topic field value against allowed values.
.DESCRIPTION
Pure validation helper that checks topic value is one of the allowed types.
.PARAMETER Frontmatter
Hashtable containing parsed frontmatter fields.
.PARAMETER RelativePath
Relative path to the file being validated.
.OUTPUTS
ValidationIssue or $null if valid or not present.
#>
[CmdletBinding()]
[OutputType([ValidationIssue])]
param(
[Parameter(Mandatory)]
[hashtable]$Frontmatter,
[Parameter(Mandatory)]
[string]$RelativePath
)
if (-not $Frontmatter.ContainsKey('ms.topic')) {
return $null
}
$validTopics = @('overview', 'concept', 'tutorial', 'reference', 'how-to', 'troubleshooting')
$topicValue = $Frontmatter['ms.topic']
if ($topicValue -notin $validTopics) {
return [ValidationIssue]::new('Warning', 'ms.topic', "Unknown topic type: '$topicValue'. Expected one of: $($validTopics -join ', ')", $RelativePath)
}
return $null
}
#endregion Shared Helpers
#region Content-Type Validators
function Test-RootCommunityFileFields {
<#
.SYNOPSIS
Validates frontmatter fields for root community files.
.DESCRIPTION
Pure validation for README.md, CONTRIBUTING.md, CODE_OF_CONDUCT.md,
SECURITY.md, SUPPORT.md in repository root.
.PARAMETER Frontmatter
Hashtable containing parsed frontmatter fields.
.PARAMETER RelativePath
Relative path to the file being validated.
.OUTPUTS
ValidationIssue[] Array of validation issues found.
#>
[CmdletBinding()]
[OutputType([ValidationIssue[]])]
param(
[Parameter(Mandatory)]
[hashtable]$Frontmatter,
[Parameter(Mandatory)]
[string]$RelativePath
)
$issues = [System.Collections.Generic.List[ValidationIssue]]::new()
# Required fields
$titleIssue = Test-RequiredField -Frontmatter $Frontmatter -FieldName 'title' -RelativePath $RelativePath
if ($titleIssue) { $issues.Add($titleIssue) }
$descIssue = Test-RequiredField -Frontmatter $Frontmatter -FieldName 'description' -RelativePath $RelativePath
if ($descIssue) { $issues.Add($descIssue) }
# Suggested fields
$suggestedIssues = Test-SuggestedFields -Frontmatter $Frontmatter -FieldNames @('author', 'ms.date') -RelativePath $RelativePath
$issues.AddRange($suggestedIssues)
# Date format
$dateIssue = Test-DateFormat -Frontmatter $Frontmatter -RelativePath $RelativePath
if ($dateIssue) { $issues.Add($dateIssue) }
return , $issues.ToArray()
}
function Test-DevContainerFileFields {
<#
.SYNOPSIS
Validates frontmatter fields for devcontainer documentation.
.DESCRIPTION
Pure validation for .devcontainer/ markdown files.
.PARAMETER Frontmatter
Hashtable containing parsed frontmatter fields.
.PARAMETER RelativePath
Relative path to the file being validated.
.OUTPUTS
ValidationIssue[] Array of validation issues found.
#>
[CmdletBinding()]
[OutputType([ValidationIssue[]])]
param(
[Parameter(Mandatory)]
[hashtable]$Frontmatter,
[Parameter(Mandatory)]
[string]$RelativePath
)
$issues = [System.Collections.Generic.List[ValidationIssue]]::new()
$titleIssue = Test-RequiredField -Frontmatter $Frontmatter -FieldName 'title' -RelativePath $RelativePath
if ($titleIssue) { $issues.Add($titleIssue) }
$descIssue = Test-RequiredField -Frontmatter $Frontmatter -FieldName 'description' -RelativePath $RelativePath
if ($descIssue) { $issues.Add($descIssue) }
return , $issues.ToArray()
}
function Test-VSCodeReadmeFileFields {
<#
.SYNOPSIS
Validates frontmatter fields for VS Code extension README files.
.DESCRIPTION
Pure validation for extension/ README.md files.
.PARAMETER Frontmatter
Hashtable containing parsed frontmatter fields.
.PARAMETER RelativePath
Relative path to the file being validated.
.OUTPUTS
ValidationIssue[] Array of validation issues found.
#>
[CmdletBinding()]
[OutputType([ValidationIssue[]])]
param(
[Parameter(Mandatory)]
[hashtable]$Frontmatter,
[Parameter(Mandatory)]
[string]$RelativePath
)
$issues = [System.Collections.Generic.List[ValidationIssue]]::new()
$titleIssue = Test-RequiredField -Frontmatter $Frontmatter -FieldName 'title' -RelativePath $RelativePath
if ($titleIssue) { $issues.Add($titleIssue) }
$descIssue = Test-RequiredField -Frontmatter $Frontmatter -FieldName 'description' -RelativePath $RelativePath
if ($descIssue) { $issues.Add($descIssue) }
return , $issues.ToArray()
}
function Test-GitHubResourceFileFields {
<#
.SYNOPSIS
Validates frontmatter fields for .github/ resource files.
.DESCRIPTION
Pure validation for instructions, prompts, agents, and skills.
.PARAMETER Frontmatter
Hashtable containing parsed frontmatter fields.
.PARAMETER RelativePath
Relative path to the file being validated.
.PARAMETER FileTypeInfo
FileTypeInfo object with classification details. Type constraint removed
to avoid PowerShell class identity conflicts in tests.
.OUTPUTS
ValidationIssue[] Array of validation issues found.
#>
[CmdletBinding()]
[OutputType([ValidationIssue[]])]
param(
[Parameter(Mandatory)]
[hashtable]$Frontmatter,
[Parameter(Mandatory)]
[string]$RelativePath,
[Parameter(Mandatory)]
$FileTypeInfo
)
$issues = [System.Collections.Generic.List[ValidationIssue]]::new()
if ($FileTypeInfo.IsAgent -or $FileTypeInfo.IsChatMode) {
if (-not $Frontmatter.ContainsKey('description')) {
$issues.Add([ValidationIssue]::new('Warning', 'description', "Chat or agent file missing 'description' field", $RelativePath))
}
}
elseif ($FileTypeInfo.IsInstruction) {
$descIssue = Test-RequiredField -Frontmatter $Frontmatter -FieldName 'description' -RelativePath $RelativePath
if ($descIssue) {
$descIssue.Message = "Instruction file missing required 'description' field"
$issues.Add($descIssue)
}
}
# Prompt files have no specific requirements
return , $issues.ToArray()
}
function Test-DocsFileFields {
<#
.SYNOPSIS
Validates frontmatter fields for docs/ directory files.
.DESCRIPTION
Pure validation for documentation files with comprehensive requirements.
.PARAMETER Frontmatter
Hashtable containing parsed frontmatter fields.
.PARAMETER RelativePath
Relative path to the file being validated.
.OUTPUTS
ValidationIssue[] Array of validation issues found.
#>
[CmdletBinding()]
[OutputType([ValidationIssue[]])]
param(
[Parameter(Mandatory)]
[hashtable]$Frontmatter,
[Parameter(Mandatory)]
[string]$RelativePath
)
$issues = [System.Collections.Generic.List[ValidationIssue]]::new()
# Required fields
$titleIssue = Test-RequiredField -Frontmatter $Frontmatter -FieldName 'title' -RelativePath $RelativePath
if ($titleIssue) { $issues.Add($titleIssue) }
$descIssue = Test-RequiredField -Frontmatter $Frontmatter -FieldName 'description' -RelativePath $RelativePath
if ($descIssue) { $issues.Add($descIssue) }
# Suggested fields
$suggestedIssues = Test-SuggestedFields -Frontmatter $Frontmatter -FieldNames @('author', 'ms.date', 'ms.topic') -RelativePath $RelativePath
$issues.AddRange($suggestedIssues)
# Date format
$dateIssue = Test-DateFormat -Frontmatter $Frontmatter -RelativePath $RelativePath
if ($dateIssue) { $issues.Add($dateIssue) }
# Topic value
$topicIssue = Test-TopicValue -Frontmatter $Frontmatter -RelativePath $RelativePath
if ($topicIssue) { $issues.Add($topicIssue) }
return , $issues.ToArray()
}
function Test-CommonFields {
<#
.SYNOPSIS
Validates common frontmatter fields for all content types.
.DESCRIPTION
Pure validation for fields like keywords and estimated_reading_time.
.PARAMETER Frontmatter
Hashtable containing parsed frontmatter fields.
.PARAMETER RelativePath
Relative path to the file being validated.
.OUTPUTS
ValidationIssue[] Array of validation issues found.
#>
[CmdletBinding()]
[OutputType([ValidationIssue[]])]
param(
[Parameter(Mandatory)]
[hashtable]$Frontmatter,
[Parameter(Mandatory)]
[string]$RelativePath
)
$issues = [System.Collections.Generic.List[ValidationIssue]]::new()
# Validate keywords array
# ConvertFrom-Yaml returns sequences as List[object], not native PowerShell arrays
if ($Frontmatter.ContainsKey('keywords')) {
$keywords = $Frontmatter['keywords']
$isCollection = $keywords -is [array] -or
$keywords -is [System.Collections.IList] -or
($keywords -is [System.Collections.IEnumerable] -and
$keywords -isnot [string] -and
$keywords -isnot [hashtable])
if (-not $isCollection -and $keywords -notmatch ',') {
$issues.Add([ValidationIssue]::new('Warning', 'keywords', 'Keywords should be an array', $RelativePath))
}
}
# Validate estimated_reading_time
if ($Frontmatter.ContainsKey('estimated_reading_time')) {
$readingTime = $Frontmatter['estimated_reading_time']
if ($readingTime -notmatch '^\d+$') {
$issues.Add([ValidationIssue]::new('Warning', 'estimated_reading_time', 'Should be a positive integer', $RelativePath))
}
}
return , $issues.ToArray()
}
function Test-FooterPresence {
<#
.SYNOPSIS
Validates Copilot attribution footer presence.
.DESCRIPTION
Pure validation wrapper for footer check.
.PARAMETER HasFooter
Boolean result from Test-MarkdownFooter.
.PARAMETER RelativePath
Relative path to the file being validated.
.PARAMETER Severity
Issue severity: 'Error' or 'Warning'. Default: 'Error'.
.OUTPUTS
ValidationIssue or $null if footer is present.
#>
[CmdletBinding()]
[OutputType([ValidationIssue])]
param(
[Parameter(Mandatory)]
[bool]$HasFooter,
[Parameter(Mandatory)]
[string]$RelativePath,
[Parameter()]
[ValidateSet('Error', 'Warning')]
[string]$Severity = 'Error'
)
if (-not $HasFooter) {
return [ValidationIssue]::new($Severity, 'footer', 'Missing standard Copilot footer', $RelativePath)
}
return $null
}
function Test-MarkdownFooter {
<#
.SYNOPSIS
Checks if markdown content contains the standard Copilot attribution footer.
.DESCRIPTION
Pure function that validates markdown content ends with the standard Copilot
attribution footer. Normalizes content by removing HTML comments and markdown
formatting before pattern matching.
.PARAMETER Content
The markdown content string to validate.
.OUTPUTS
[bool] $true if valid footer present; $false otherwise.
#>
[CmdletBinding()]
[OutputType([bool])]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[AllowEmptyString()]
[string]$Content
)
process {
if ([string]::IsNullOrEmpty($Content)) {
return $false
}
$normalized = $Content -replace '(?s)<!--.*?-->', ''
$normalized = $normalized -replace '\*\*([^*]+)\*\*', '$1'
$normalized = $normalized -replace '__([^_]+)__', '$1'
$normalized = $normalized -replace '\*([^*]+)\*', '$1'
$normalized = $normalized -replace '_([^_]+)_', '$1'
$normalized = $normalized -replace '~~([^~]+)~~', '$1'
$normalized = $normalized -replace '`([^`]+)`', '$1'
$normalized = $normalized.TrimEnd()
$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\.?'
return $normalized -match $pattern
}
}
#endregion Content-Type Validators
#region File Classification
function Get-FileTypeInfo {
<#
.SYNOPSIS
Classifies a file based on its path and name.
.DESCRIPTION
Pure function that determines file type for validation routing.
.PARAMETER File
FileInfo object to classify.
.PARAMETER RepoRoot
Repository root path for relative path computation.
.OUTPUTS
FileTypeInfo object with classification flags.
#>
[CmdletBinding()]
[OutputType([FileTypeInfo])]
param(
[Parameter(Mandatory)]
[System.IO.FileInfo]$File,
[Parameter(Mandatory)]
[string]$RepoRoot
)
$info = [FileTypeInfo]::new()
$info.IsGitHub = $File.DirectoryName -like "*.github*"
$info.IsChatMode = $File.Name -like "*.chatmode.md"
$info.IsPrompt = $File.Name -like "*.prompt.md"
$info.IsInstruction = $File.Name -like "*.instructions.md"
$info.IsAgent = $File.Name -like "*.agent.md"
$info.IsRootCommunityFile = ($File.DirectoryName -eq $RepoRoot) -and
($File.Name -in @('CODE_OF_CONDUCT.md', 'CONTRIBUTING.md', 'SECURITY.md', 'SUPPORT.md', 'README.md'))
$info.IsDevContainer = $File.DirectoryName -like "*.devcontainer*" -and $File.Name -eq 'README.md'
$info.IsVSCodeReadme = $File.DirectoryName -like "*.vscode*" -and $File.Name -eq 'README.md'
# Exclude .copilot-tracking (gitignored workflow artifacts) and markdown templates from docs validation
$isCopilotTracking = $File.DirectoryName -like "*.copilot-tracking*"
$isTemplate = $File.Name -like "*TEMPLATE*"
# Use repo-relative path to avoid misclassifying files when repo is under a parent containing "docs"
$relativePath = [System.IO.Path]::GetRelativePath($RepoRoot, $File.FullName)
$relativePathNormalized = $relativePath -replace '\\', '/'
$info.IsDocsFile = ($relativePathNormalized -match '(^|/)docs(/|$)') -and -not $info.IsGitHub -and -not $isCopilotTracking -and -not $isTemplate
return $info
}
#endregion File Classification
#region Orchestration
function Test-SingleFileFrontmatter {
<#
.SYNOPSIS
Validates frontmatter for a single markdown file.
.DESCRIPTION
Performs complete frontmatter validation including presence check,
YAML parsing, file type detection, field validation, and footer check.
.PARAMETER FilePath
Absolute path to the markdown file.
.PARAMETER RepoRoot
Repository root path for relative path computation and file classification.
.PARAMETER FileReader
Optional scriptblock for reading file content. Enables testing.
.PARAMETER FooterExcludePaths
Array of wildcard patterns for files to exclude from footer validation.
Uses PowerShell -like operator for matching against relative paths.
Path separators are normalized to forward slashes for cross-platform support.
.OUTPUTS
FileValidationResult
#>
[CmdletBinding()]
[OutputType([FileValidationResult])]
param(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$FilePath,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$RepoRoot,
[scriptblock]$FileReader = { param($p) Get-Content -Path $p -Raw -ErrorAction Stop },
[string[]]$FooterExcludePaths = @(),
[switch]$SkipFooterValidation
)
$relativePath = $FilePath
if ($FilePath.StartsWith($RepoRoot)) {
$relativePath = $FilePath.Substring($RepoRoot.Length).TrimStart('\', '/')
}
$result = [FileValidationResult]::new($FilePath)
$result.RelativePath = $relativePath
# Detect file type early - needed for frontmatter requirement decisions
$fileInfo = [System.IO.FileInfo]::new($FilePath)
$result.FileType = Get-FileTypeInfo -File $fileInfo -RepoRoot $RepoRoot
$fileTypeInfo = $result.FileType
# AI artifacts (prompts, instructions, agents, chatmodes) don't require frontmatter
$isAiArtifact = $fileTypeInfo.IsPrompt -or $fileTypeInfo.IsInstruction -or $fileTypeInfo.IsAgent -or $fileTypeInfo.IsChatMode
# Read file content
try {
$content = & $FileReader $FilePath
}
catch {
$result.AddError("Failed to read file: $($_.Exception.Message)", 'file')
return $result
}
# Parse frontmatter
$frontmatter = $null
$hasFrontmatterBlock = $content -match '(?s)^---\r?\n(.*?)\r?\n---'
if ($hasFrontmatterBlock) {
$yamlBlock = $Matches[1]
# Verify ConvertFrom-Yaml is available (requires powershell-yaml module)
if (-not (Get-Command -Name 'ConvertFrom-Yaml' -ErrorAction SilentlyContinue)) {
$result.AddError("ConvertFrom-Yaml cmdlet not found. Install powershell-yaml module: Install-Module -Name PowerShell-Yaml -Force -Scope CurrentUser", 'dependency')
return $result
}
try {
$frontmatter = $yamlBlock | ConvertFrom-Yaml -ErrorAction Stop
}
catch {
$result.AddError("Invalid YAML syntax: $($_.Exception.Message)", 'yaml')
return $result
}
}
$result.HasFrontmatter = $null -ne $frontmatter
$result.Frontmatter = $frontmatter
# Only warn about missing frontmatter for content types that require it
# AI artifacts (.github prompts, instructions, agents, chatmodes) are exempt
if (-not $result.HasFrontmatter -and -not $isAiArtifact) {
$result.AddWarning('No frontmatter found', 'frontmatter')
# Continue to footer validation even without frontmatter
}
# Validate fields based on file type (only if frontmatter was successfully parsed)
$issues = @()
if ($null -ne $frontmatter) {
if ($fileTypeInfo.IsDocsFile) {
$issues = Test-DocsFileFields -Frontmatter $frontmatter -RelativePath $relativePath
}
elseif ($fileTypeInfo.IsInstruction -or $fileTypeInfo.IsPrompt -or $fileTypeInfo.IsChatMode -or $fileTypeInfo.IsAgent) {
$issues = Test-GitHubResourceFileFields -Frontmatter $frontmatter -FileTypeInfo $fileTypeInfo -RelativePath $relativePath
}
elseif ($fileTypeInfo.IsDevContainer) {
$issues = Test-DevContainerFileFields -Frontmatter $frontmatter -RelativePath $relativePath
}
elseif ($fileTypeInfo.IsVSCodeReadme) {
$issues = Test-VSCodeReadmeFileFields -Frontmatter $frontmatter -RelativePath $relativePath
}
elseif ($fileTypeInfo.IsRootCommunityFile) {
$issues = Test-RootCommunityFileFields -Frontmatter $frontmatter -RelativePath $relativePath
}
foreach ($issue in $issues) {
$result.AddIssue($issue)
}
}
# Common field validation for all content types with frontmatter
if ($null -ne $frontmatter) {
$commonIssues = Test-CommonFields -Frontmatter $frontmatter -RelativePath $relativePath
foreach ($commonIssue in $commonIssues) {
$result.AddIssue($commonIssue)
}
}
# Check if file matches footer exclusion pattern
# Normalize path separators for cross-platform pattern matching
$skipFooterForFile = $false
$normalizedRelativePath = $relativePath -replace '\\', '/'
foreach ($pattern in $FooterExcludePaths) {
$normalizedPattern = $pattern -replace '\\', '/'
if ($normalizedRelativePath -like $normalizedPattern) {
$skipFooterForFile = $true
break
}
}
# Footer validation for all markdown EXCEPT AI artifacts (prompts, instructions, agents, chatmodes)
if (-not $isAiArtifact -and -not $SkipFooterValidation -and -not $skipFooterForFile) {
# Determine severity based on file type
$footerSeverity = 'Warning'
if ($fileTypeInfo.IsRootCommunityFile -or $fileTypeInfo.IsDevContainer -or $fileTypeInfo.IsVSCodeReadme) {
$footerSeverity = 'Error'
}
$hasFooter = Test-MarkdownFooter -Content $content
$footerIssue = Test-FooterPresence -HasFooter $hasFooter -RelativePath $relativePath -Severity $footerSeverity
if ($footerIssue) {
$result.AddIssue($footerIssue)
}
}
return $result
}
function Invoke-FrontmatterValidation {
<#
.SYNOPSIS
Validates frontmatter across multiple markdown files.
.DESCRIPTION
Orchestrates validation of multiple files and aggregates results
into a ValidationSummary object.
.PARAMETER Files
Array of file paths to validate.
.PARAMETER RepoRoot
Repository root path for relative path computation and file classification.
.PARAMETER FooterExcludePaths
Array of wildcard patterns for files to exclude from footer validation.
Uses PowerShell -like operator for matching against relative paths.
Path separators are normalized to forward slashes for cross-platform support.
.OUTPUTS
ValidationSummary
#>
[CmdletBinding()]
[OutputType([ValidationSummary])]
param(
[Parameter(Mandatory)]
[string[]]$Files,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$RepoRoot,
[string[]]$FooterExcludePaths = @(),
[switch]$SkipFooterValidation
)
$summary = [ValidationSummary]::new()
foreach ($file in $Files) {
$result = Test-SingleFileFrontmatter -FilePath $file -RepoRoot $RepoRoot -FooterExcludePaths $FooterExcludePaths -SkipFooterValidation:$SkipFooterValidation
$summary.AddResult($result)
}
$summary.Complete()
return $summary
}
#endregion Orchestration
#region Output
function Write-ValidationConsoleOutput {
<#
.SYNOPSIS
Writes validation results to console.
.PARAMETER Summary
ValidationSummary object to display.
.PARAMETER ShowDetails
When true, shows per-file details.
#>
[CmdletBinding()]
param(
# Type constraint removed for testability (PowerShell class identity conflicts)
[Parameter(Mandatory)]
$Summary,
[switch]$ShowDetails
)
Write-Host "`n🔍 Frontmatter Validation Results" -ForegroundColor Cyan
Write-Host "─────────────────────────────────" -ForegroundColor DarkGray
if ($ShowDetails) {
foreach ($result in $Summary.Results) {
$hasError = $result.Issues | Where-Object { $_.Type -eq 'Error' } | Select-Object -First 1
$hasWarning = $result.Issues | Where-Object { $_.Type -eq 'Warning' } | Select-Object -First 1
$icon = if ($hasError) { '❌' } elseif ($hasWarning) { '⚠️' } else { '✅' }
Write-Host "$icon $($result.RelativePath)"
foreach ($issue in $result.Issues) {
$color = if ($issue.Type -eq 'Error') { 'Red' } else { 'Yellow' }
$prefix = if ($issue.Type -eq 'Error') { ' ❌' } else { ' ⚠️' }
Write-Host "$prefix $($issue.Message)" -ForegroundColor $color
}
}
Write-Host ""
}
# Summary
Write-Host "📊 Summary:" -ForegroundColor Cyan
$errorColor = if ($Summary.FilesWithErrors -gt 0) { 'Red' } else { 'Green' }
$warnColor = if ($Summary.FilesWithWarnings -gt 0) { 'Yellow' } else { 'Green' }
Write-Host " Files validated: $($Summary.TotalFiles)"
Write-Host " Files with errors: $($Summary.FilesWithErrors)" -ForegroundColor $errorColor
Write-Host " Files with warnings: $($Summary.FilesWithWarnings)" -ForegroundColor $warnColor
Write-Host " Duration: $($Summary.Duration.TotalSeconds.ToString('F2'))s"
}
function Export-ValidationResults {
<#
.SYNOPSIS
Exports validation results to JSON file.
.PARAMETER Summary
ValidationSummary object to export.
.PARAMETER OutputPath
Path to output JSON file.
#>
[CmdletBinding()]
param(
# Type constraint removed for testability (PowerShell class identity conflicts)
[Parameter(Mandatory)]
$Summary,
[Parameter(Mandatory)]
[string]$OutputPath
)
$outputDir = Split-Path -Path $OutputPath -Parent
if ($outputDir -and -not (Test-Path $outputDir)) {
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
}
$Summary.ToHashtable() | ConvertTo-Json -Depth 10 | Set-Content -Path $OutputPath -Encoding utf8
}
#endregion Output
#region Exports
Export-ModuleMember -Function @(
# Shared helpers
'Test-RequiredField'
'Test-DateFormat'
'Test-SuggestedFields'
'Test-TopicValue'
# Content-type validators
'Test-RootCommunityFileFields'
'Test-DevContainerFileFields'
'Test-VSCodeReadmeFileFields'
'Test-GitHubResourceFileFields'
'Test-DocsFileFields'
'Test-CommonFields'
'Test-FooterPresence'
'Test-MarkdownFooter'
# Classification
'Get-FileTypeInfo'
# Orchestration
'Test-SingleFileFrontmatter'
'Invoke-FrontmatterValidation'
# Output
'Write-ValidationConsoleOutput'
'Export-ValidationResults'
)
#endregion Exportsmicrosoft/hve-core
Publicmirrored from https://github.com/microsoft/hve-coreAvailable
scripts/linting/Modules/FrontmatterValidation.psm1
1094lines · modepreview