microsoft/hve-core
Publicmirrored fromhttps://github.com/microsoft/hve-coreAvailable
scripts/linting/Invoke-MsDateFreshnessCheck.ps1
308lines · modecode
| 1 | #!/usr/bin/env pwsh |
| 2 | # Copyright (c) Microsoft Corporation. |
| 3 | # SPDX-License-Identifier: MIT |
| 4 | <# |
| 5 | .SYNOPSIS |
| 6 | Checks ms.date frontmatter freshness in markdown files. |
| 7 | |
| 8 | .DESCRIPTION |
| 9 | Scans markdown files for ms.date frontmatter and flags files where the date |
| 10 | exceeds a configurable staleness threshold. Generates JSON report and markdown |
| 11 | summary for GitHub Actions job summaries. |
| 12 | |
| 13 | .PARAMETER ThresholdDays |
| 14 | Number of days before ms.date is considered stale. Defaults to 90. |
| 15 | |
| 16 | .PARAMETER Paths |
| 17 | Directories to scan for markdown files. Defaults to repository root. |
| 18 | |
| 19 | .PARAMETER ChangedFilesOnly |
| 20 | Only check files changed relative to BaseBranch. |
| 21 | |
| 22 | .PARAMETER BaseBranch |
| 23 | Base branch for changed-file detection. Defaults to 'origin/main'. |
| 24 | #> |
| 25 | |
| 26 | #Requires -Version 7.0 |
| 27 | |
| 28 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Parameters consumed via script scope')] |
| 29 | [CmdletBinding()] |
| 30 | param( |
| 31 | [Parameter()] |
| 32 | [int]$ThresholdDays = 90, |
| 33 | |
| 34 | [Parameter()] |
| 35 | [string[]]$Paths = @('.'), |
| 36 | |
| 37 | [Parameter()] |
| 38 | [switch]$ChangedFilesOnly, |
| 39 | |
| 40 | [Parameter()] |
| 41 | [string]$BaseBranch = 'origin/main' |
| 42 | ) |
| 43 | |
| 44 | $ErrorActionPreference = 'Stop' |
| 45 | Set-StrictMode -Version Latest |
| 46 | |
| 47 | $scriptRoot = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Definition } |
| 48 | Import-Module (Join-Path $scriptRoot 'Modules' 'LintingHelpers.psm1') -Force |
| 49 | Import-Module (Join-Path $scriptRoot '..' 'lib' 'Modules' 'CIHelpers.psm1') -Force |
| 50 | |
| 51 | #region Helper Functions |
| 52 | |
| 53 | function Get-MarkdownFiles { |
| 54 | [CmdletBinding()] |
| 55 | param( |
| 56 | [Parameter(Mandatory = $true)] |
| 57 | [string[]]$SearchPaths, |
| 58 | |
| 59 | [Parameter(Mandatory = $false)] |
| 60 | [switch]$ChangedOnly, |
| 61 | |
| 62 | [Parameter(Mandatory = $false)] |
| 63 | [string]$Base = 'origin/main' |
| 64 | ) |
| 65 | |
| 66 | if ($ChangedOnly) { |
| 67 | Write-Verbose "Getting changed markdown files relative to $Base" |
| 68 | $files = @(Get-ChangedFilesFromGit -BaseBranch $Base -FileExtensions @('*.md')) |
| 69 | return @($files | Where-Object { Test-Path $_ -PathType Leaf }) |
| 70 | } |
| 71 | |
| 72 | $excludePatterns = @('node_modules', '.git', 'logs', '.copilot-tracking', 'CHANGELOG.md') |
| 73 | $allFiles = @() |
| 74 | |
| 75 | # Bypass exclusions only when the caller passes a single explicit file path. |
| 76 | # Directory paths (including '.' or absolute paths) always receive standard exclusions. |
| 77 | $isExplicitFilePath = @($SearchPaths).Count -eq 1 -and (Test-Path $SearchPaths[0] -PathType Leaf) |
| 78 | |
| 79 | foreach ($path in $SearchPaths) { |
| 80 | if (-not (Test-Path $path)) { |
| 81 | Write-Warning "Path not found: $path" |
| 82 | continue |
| 83 | } |
| 84 | |
| 85 | $files = @(Get-ChildItem -Path $path -Recurse -Include '*.md' -File -ErrorAction SilentlyContinue) |
| 86 | |
| 87 | $allFiles += @($files | Where-Object { |
| 88 | $file = $_ |
| 89 | |
| 90 | if ($isExplicitFilePath) { |
| 91 | return $true |
| 92 | } |
| 93 | |
| 94 | $excluded = $false |
| 95 | foreach ($pattern in $excludePatterns) { |
| 96 | if ($file.FullName -like "*$([System.IO.Path]::DirectorySeparatorChar)$pattern$([System.IO.Path]::DirectorySeparatorChar)*" -or |
| 97 | $file.FullName -like "*$([System.IO.Path]::DirectorySeparatorChar)$pattern" -or |
| 98 | $file.Name -eq $pattern) { |
| 99 | $excluded = $true |
| 100 | break |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | -not $excluded |
| 105 | }) |
| 106 | } |
| 107 | |
| 108 | Write-Verbose "Found $(@($allFiles).Count) markdown files" |
| 109 | return $allFiles |
| 110 | } |
| 111 | |
| 112 | function Get-MsDateFromFrontmatter { |
| 113 | [CmdletBinding()] |
| 114 | param( |
| 115 | [Parameter(Mandatory = $true)] |
| 116 | [string]$FilePath |
| 117 | ) |
| 118 | |
| 119 | try { |
| 120 | $content = Get-Content -Path $FilePath -Raw -ErrorAction Stop |
| 121 | |
| 122 | if ($content -match '(?s)^---\r?\n(.*?)\r?\n---') { |
| 123 | $yamlContent = $matches[1] |
| 124 | |
| 125 | if (-not (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue)) { |
| 126 | Write-Warning "PowerShell-Yaml module not found. Install with: Install-Module PowerShell-Yaml" |
| 127 | return $null |
| 128 | } |
| 129 | |
| 130 | try { |
| 131 | $frontmatter = $yamlContent | ConvertFrom-Yaml |
| 132 | |
| 133 | if ($frontmatter -and $frontmatter.'ms.date') { |
| 134 | $msDateString = $frontmatter.'ms.date' |
| 135 | |
| 136 | try { |
| 137 | $msDate = [DateTime]::ParseExact( |
| 138 | $msDateString, |
| 139 | 'yyyy-MM-dd', |
| 140 | [Globalization.CultureInfo]::InvariantCulture |
| 141 | ) |
| 142 | return $msDate |
| 143 | } |
| 144 | catch { |
| 145 | Write-Verbose "Invalid ms.date format in ${FilePath}: $msDateString" |
| 146 | return $null |
| 147 | } |
| 148 | } |
| 149 | } |
| 150 | catch { |
| 151 | Write-Verbose "Failed to parse YAML frontmatter in ${FilePath}: $($_.Exception.Message)" |
| 152 | return $null |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | return $null |
| 157 | } |
| 158 | catch { |
| 159 | Write-Warning "Error reading file ${FilePath}: $($_.Exception.Message)" |
| 160 | return $null |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | function New-MsDateReport { |
| 165 | [CmdletBinding()] |
| 166 | param( |
| 167 | [Parameter(Mandatory = $true)] |
| 168 | [array]$Results, |
| 169 | |
| 170 | [Parameter(Mandatory = $true)] |
| 171 | [int]$Threshold, |
| 172 | |
| 173 | [Parameter()] |
| 174 | [string]$OutputDirectory = '' |
| 175 | ) |
| 176 | |
| 177 | $logsDir = if ($OutputDirectory) { $OutputDirectory } else { Join-Path $PSScriptRoot '..' '..' 'logs' } |
| 178 | if (-not (Test-Path $logsDir)) { |
| 179 | New-Item -ItemType Directory -Path $logsDir -Force | Out-Null |
| 180 | } |
| 181 | |
| 182 | $jsonPath = Join-Path $logsDir 'msdate-freshness-results.json' |
| 183 | $mdPath = Join-Path $logsDir 'msdate-summary.md' |
| 184 | |
| 185 | $Results | ConvertTo-Json -Depth 10 | Out-File -FilePath $jsonPath -Encoding utf8 |
| 186 | Write-Verbose "JSON report written to $jsonPath" |
| 187 | |
| 188 | $staleFiles = @($Results | Where-Object { $_.IsStale }) |
| 189 | $totalFiles = @($Results).Count |
| 190 | |
| 191 | $markdown = @" |
| 192 | # ms.date Freshness Check Results |
| 193 | |
| 194 | **Threshold**: $Threshold days |
| 195 | **Files Checked**: $totalFiles |
| 196 | **Stale Files**: $(@($staleFiles).Count) |
| 197 | "@ |
| 198 | |
| 199 | if (@($staleFiles).Count -gt 0) { |
| 200 | $markdown += @" |
| 201 | |
| 202 | ## 🚨 Stale Documentation Files |
| 203 | |
| 204 | | File | ms.date | Age (days) | |
| 205 | |------|---------|------------| |
| 206 | "@ |
| 207 | |
| 208 | $sortedStaleFiles = $staleFiles | Sort-Object -Property AgeDays -Descending |
| 209 | |
| 210 | foreach ($file in $sortedStaleFiles) { |
| 211 | $markdown += "| $($file.File) | $($file.MsDate) | $($file.AgeDays) |`n" |
| 212 | } |
| 213 | } |
| 214 | else { |
| 215 | $markdown += @" |
| 216 | |
| 217 | ### ✅ All Files Fresh |
| 218 | |
| 219 | All documentation files with ms.date frontmatter are within the $Threshold-day freshness threshold. |
| 220 | "@ |
| 221 | } |
| 222 | |
| 223 | $markdown | Out-File -FilePath $mdPath -Encoding utf8 -NoNewline |
| 224 | |
| 225 | return @{ |
| 226 | JsonPath = $jsonPath |
| 227 | MarkdownPath = $mdPath |
| 228 | StaleCount = @($staleFiles).Count |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | #endregion |
| 233 | |
| 234 | #region Main Logic |
| 235 | |
| 236 | Write-Verbose "Starting ms.date freshness check with $ThresholdDays-day threshold" |
| 237 | |
| 238 | $markdownFiles = @(Get-MarkdownFiles -SearchPaths $Paths -ChangedOnly:$ChangedFilesOnly -Base $BaseBranch) |
| 239 | |
| 240 | if (@($markdownFiles).Count -eq 0) { |
| 241 | Write-Warning "No markdown files found to check" |
| 242 | exit 0 |
| 243 | } |
| 244 | |
| 245 | Write-Verbose "Checking $(@($markdownFiles).Count) markdown files" |
| 246 | |
| 247 | $results = [System.Collections.Generic.List[PSCustomObject]]::new() |
| 248 | $currentDate = Get-Date |
| 249 | |
| 250 | foreach ($file in $markdownFiles) { |
| 251 | $relativePath = if ($file -is [System.IO.FileInfo]) { |
| 252 | $file.FullName.Replace("$PWD$([System.IO.Path]::DirectorySeparatorChar)", '') |
| 253 | } |
| 254 | else { |
| 255 | $file.Replace("$PWD$([System.IO.Path]::DirectorySeparatorChar)", '') |
| 256 | } |
| 257 | |
| 258 | $msDate = Get-MsDateFromFrontmatter -FilePath $file |
| 259 | |
| 260 | if ($null -eq $msDate) { |
| 261 | Write-Verbose "Skipping $relativePath (no ms.date)" |
| 262 | continue |
| 263 | } |
| 264 | |
| 265 | $age = $currentDate - $msDate |
| 266 | $ageDays = [int]$age.TotalDays |
| 267 | $isStale = $ageDays -gt $ThresholdDays |
| 268 | |
| 269 | $result = [PSCustomObject]@{ |
| 270 | File = $relativePath |
| 271 | MsDate = $msDate.ToString('yyyy-MM-dd') |
| 272 | AgeDays = $ageDays |
| 273 | IsStale = $isStale |
| 274 | Threshold = $ThresholdDays |
| 275 | } |
| 276 | |
| 277 | $results.Add($result) |
| 278 | |
| 279 | if ($isStale) { |
| 280 | Write-Verbose "Stale file detected: $relativePath ($ageDays days old)" |
| 281 | Write-CIAnnotation -Message "${relativePath}: ms.date is $ageDays days old (threshold: $ThresholdDays days)" -Level 'Warning' -File $relativePath |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | if (@($results).Count -eq 0) { |
| 286 | Write-Warning "No files with ms.date frontmatter found" |
| 287 | exit 0 |
| 288 | } |
| 289 | |
| 290 | $report = New-MsDateReport -Results $results -Threshold $ThresholdDays |
| 291 | |
| 292 | Write-Host "`nms.date Freshness Check Summary:" |
| 293 | Write-Host " Files Checked: $(@($results).Count)" |
| 294 | Write-Host " Stale Files: $($report.StaleCount)" |
| 295 | Write-Host " Threshold: $ThresholdDays days" |
| 296 | |
| 297 | Write-CIStepSummary -Path $report.MarkdownPath |
| 298 | |
| 299 | if ($report.StaleCount -gt 0) { |
| 300 | Write-Host "`n❌ Found $($report.StaleCount) stale documentation file(s)" |
| 301 | exit 1 |
| 302 | } |
| 303 | else { |
| 304 | Write-Host "`n✅ All files are fresh" |
| 305 | exit 0 |
| 306 | } |
| 307 | |
| 308 | #endregion |
| 309 | |