microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/1637-l4-tests

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/linting/Invoke-MsDateFreshnessCheck.ps1

337lines · 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()]
30param(
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'
45Set-StrictMode -Version Latest
46
47$scriptRoot = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Definition }
48Import-Module (Join-Path $scriptRoot 'Modules' 'LintingHelpers.psm1') -Force
49Import-Module (Join-Path $scriptRoot '..' 'lib' 'Modules' 'CIHelpers.psm1') -Force
50
51#region Helper Functions
52
53function 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 $excludeDirNames = @('node_modules', '.git', 'logs', '.copilot-tracking', 'plugins')
73 $excludeFileNames = @('CHANGELOG.md')
74 $allFiles = [System.Collections.Generic.List[System.IO.FileInfo]]::new()
75
76 # Bypass exclusions only when the caller passes a single explicit file path.
77 # Directory paths (including '.' or absolute paths) always receive standard exclusions.
78 $isExplicitFilePath = @($SearchPaths).Count -eq 1 -and (Test-Path $SearchPaths[0] -PathType Leaf)
79
80 foreach ($path in $SearchPaths) {
81 if (-not (Test-Path $path)) {
82 Write-Warning "Path not found: $path"
83 continue
84 }
85
86 $resolvedRoot = (Resolve-Path -LiteralPath $path).Path
87
88 if ($isExplicitFilePath) {
89 if ([System.IO.Path]::GetExtension($resolvedRoot) -eq '.md') {
90 $allFiles.Add([System.IO.FileInfo]::new($resolvedRoot))
91 }
92 continue
93 }
94
95 # Stack-based walk that prunes excluded directories during traversal,
96 # avoiding descent into large excluded trees (node_modules, plugins, .git).
97 $pending = [System.Collections.Generic.Stack[string]]::new()
98 $pending.Push($resolvedRoot)
99
100 while ($pending.Count -gt 0) {
101 $currentDir = $pending.Pop()
102
103 try {
104 foreach ($file in [System.IO.Directory]::EnumerateFiles($currentDir, '*.md')) {
105 if ([System.IO.Path]::GetExtension($file) -ne '.md') {
106 continue
107 }
108
109 if ($excludeFileNames -notcontains [System.IO.Path]::GetFileName($file)) {
110 $allFiles.Add([System.IO.FileInfo]::new($file))
111 }
112 }
113
114 foreach ($subDir in [System.IO.Directory]::EnumerateDirectories($currentDir)) {
115 if ($excludeDirNames -contains [System.IO.Path]::GetFileName($subDir)) {
116 continue
117 }
118
119 # Do not follow reparse points (symlinks/junctions), matching
120 # the default non-following behavior of Get-ChildItem -Recurse.
121 if ([System.IO.File]::GetAttributes($subDir) -band [System.IO.FileAttributes]::ReparsePoint) {
122 continue
123 }
124
125 $pending.Push($subDir)
126 }
127 }
128 catch {
129 Write-Verbose "Skipping inaccessible directory '$currentDir': $($_.Exception.Message)"
130 }
131 }
132 }
133
134 Write-Verbose "Found $($allFiles.Count) markdown files"
135 return $allFiles.ToArray()
136}
137
138function Get-MsDateFromFrontmatter {
139 [CmdletBinding()]
140 param(
141 [Parameter(Mandatory = $true)]
142 [string]$FilePath
143 )
144
145 try {
146 $content = Get-Content -Path $FilePath -Raw -ErrorAction Stop
147
148 if ($content -match '(?s)^---\r?\n(.*?)\r?\n---') {
149 $yamlContent = $matches[1]
150
151 if (-not (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue)) {
152 Write-Warning "PowerShell-Yaml module not found. Install with: Install-Module -Name PowerShell-Yaml -RequiredVersion 0.4.7"
153 return $null
154 }
155
156 try {
157 $frontmatter = $yamlContent | ConvertFrom-Yaml
158
159 if ($frontmatter -and $frontmatter.'ms.date') {
160 $msDateString = $frontmatter.'ms.date'
161
162 try {
163 $msDate = [DateTime]::ParseExact(
164 $msDateString,
165 'yyyy-MM-dd',
166 [Globalization.CultureInfo]::InvariantCulture
167 )
168 return $msDate
169 }
170 catch {
171 Write-Verbose "Invalid ms.date format in ${FilePath}: $msDateString"
172 return $null
173 }
174 }
175 }
176 catch {
177 Write-Verbose "Failed to parse YAML frontmatter in ${FilePath}: $($_.Exception.Message)"
178 return $null
179 }
180 }
181
182 return $null
183 }
184 catch {
185 Write-Warning "Error reading file ${FilePath}: $($_.Exception.Message)"
186 return $null
187 }
188}
189
190function New-MsDateReport {
191 [CmdletBinding()]
192 param(
193 [Parameter(Mandatory = $true)]
194 [array]$Results,
195
196 [Parameter(Mandatory = $true)]
197 [int]$Threshold,
198
199 [Parameter()]
200 [string]$OutputDirectory = ''
201 )
202
203 $logsDir = if ($OutputDirectory) { $OutputDirectory } else { Join-Path $PSScriptRoot '..' '..' 'logs' }
204 if (-not (Test-Path $logsDir)) {
205 New-Item -ItemType Directory -Path $logsDir -Force | Out-Null
206 }
207
208 $jsonPath = Join-Path $logsDir 'msdate-freshness-results.json'
209 $mdPath = Join-Path $logsDir 'msdate-summary.md'
210
211 $Results | ConvertTo-Json -Depth 10 | Out-File -FilePath $jsonPath -Encoding utf8
212 Write-Verbose "JSON report written to $jsonPath"
213
214 $staleFiles = @($Results | Where-Object { $_.IsStale })
215 $totalFiles = @($Results).Count
216
217 $markdown = @"
218# ms.date Freshness Check Results
219
220**Threshold**: $Threshold days
221**Files Checked**: $totalFiles
222**Stale Files**: $(@($staleFiles).Count)
223"@
224
225 if (@($staleFiles).Count -gt 0) {
226 $markdown += @"
227
228## 🚨 Stale Documentation Files
229
230| File | ms.date | Age (days) |
231|------|---------|------------|
232"@
233 $markdown += "`n"
234
235 $sortedStaleFiles = $staleFiles | Sort-Object -Property AgeDays -Descending
236
237 foreach ($file in $sortedStaleFiles) {
238 $markdown += "| $($file.File) | $($file.MsDate) | $($file.AgeDays) |`n"
239 }
240 }
241 else {
242 $markdown += @"
243
244### ✅ All Files Fresh
245
246All documentation files with ms.date frontmatter are within the $Threshold-day freshness threshold.
247"@
248 }
249
250 $markdown | Out-File -FilePath $mdPath -Encoding utf8 -NoNewline
251
252 return @{
253 JsonPath = $jsonPath
254 MarkdownPath = $mdPath
255 StaleCount = @($staleFiles).Count
256 }
257}
258
259#endregion
260
261#region Main Logic
262
263if ($MyInvocation.InvocationName -ne '.') {
264 Write-Verbose "Starting ms.date freshness check with $ThresholdDays-day threshold"
265
266 $markdownFiles = @(Get-MarkdownFiles -SearchPaths $Paths -ChangedOnly:$ChangedFilesOnly -Base $BaseBranch)
267
268 if (@($markdownFiles).Count -eq 0) {
269 Write-Warning "No markdown files found to check"
270 exit 0
271 }
272
273 Write-Verbose "Checking $(@($markdownFiles).Count) markdown files"
274
275 $results = [System.Collections.Generic.List[PSCustomObject]]::new()
276 $currentDate = Get-Date
277
278 foreach ($file in $markdownFiles) {
279 $relativePath = if ($file -is [System.IO.FileInfo]) {
280 $file.FullName.Replace("$PWD$([System.IO.Path]::DirectorySeparatorChar)", '')
281 }
282 else {
283 $file.Replace("$PWD$([System.IO.Path]::DirectorySeparatorChar)", '')
284 }
285
286 $msDate = Get-MsDateFromFrontmatter -FilePath $file
287
288 if ($null -eq $msDate) {
289 Write-Verbose "Skipping $relativePath (no ms.date)"
290 continue
291 }
292
293 $age = $currentDate - $msDate
294 $ageDays = [int]$age.TotalDays
295 $isStale = $ageDays -gt $ThresholdDays
296
297 $result = [PSCustomObject]@{
298 File = $relativePath
299 MsDate = $msDate.ToString('yyyy-MM-dd')
300 AgeDays = $ageDays
301 IsStale = $isStale
302 Threshold = $ThresholdDays
303 }
304
305 $results.Add($result)
306
307 if ($isStale) {
308 Write-Verbose "Stale file detected: $relativePath ($ageDays days old)"
309 Write-CIAnnotation -Message "${relativePath}: ms.date is $ageDays days old (threshold: $ThresholdDays days)" -Level 'Warning' -File $relativePath
310 }
311 }
312
313 if (@($results).Count -eq 0) {
314 Write-Warning "No files with ms.date frontmatter found"
315 exit 0
316 }
317
318 $report = New-MsDateReport -Results $results -Threshold $ThresholdDays
319
320 Write-Host "`nms.date Freshness Check Summary:"
321 Write-Host " Files Checked: $(@($results).Count)"
322 Write-Host " Stale Files: $($report.StaleCount)"
323 Write-Host " Threshold: $ThresholdDays days"
324
325 Write-CIStepSummary -Path $report.MarkdownPath
326
327 if ($report.StaleCount -gt 0) {
328 Write-Host "`n❌ Found $($report.StaleCount) stale documentation file(s)"
329 exit 1
330 }
331 else {
332 Write-Host "`n✅ All files are fresh"
333 exit 0
334 }
335}
336
337#endregion
338